安卓JVM线程监控
前言
七猫免费小说客户端团队对线上崩溃数据非常关注,线上崩溃率维持在万分之一是安卓客户端 2021 年度目标之一。所以我们相当重视线上崩溃率 Top 20 的问题,会花大量时间和精力去排查解决,而 OOM 一直占领着 Top 20 好几个位置。
OOM 问题造成的因素非常多,比如内存泄漏,静态对象内存占用大,缓存过多,大图内存过大,线程超限,FileDescriptor 未释放超过限定的 1024个等因素。本文就线程数量问题讲述了安卓客户端团队是怎么监控线程创建,分析线程创建来源的。
众所周知,第三方 sdk 线程、线程池的管理是不受接入者控制的,这意味着第三方 sdk 越多,线程池就越多,线程占用常驻内存就越大,应用发生 OOM 的几率越大。
应用内部的线程我们可以很快地完成优化,但 App 接入的第三方 sdk 很多,sdk 内部线程怎么创建、创建了多少,我们一无所知。所以说,如何监控线程创建,以及怎么控制线程数量就成了我们需要解决的问题。
线程创建流程
第三方 sdk 的代码我们没有办法改动,那么我们要监控线程创建,就需要分析源码找到切入点。
在我们平时的编码工作中,创建线程的方式多种多样。不过总的来说,不管怎么封装,无非也就三种情况,一是通过 new Thread,二是创建线程池(线程池创建线程其实也是通过 ThreadFactory 调用 new Thread 方式),三是反射实例化并调用 Thread 相关方法。所以说,java 层线程创建是绕不过 Thread.java 这个类的,那么我们从 Thread.java 开始,往底层深入分析源码。
Thread.java 的关键方法 start() 如下,执行了 start() 方法才会真正调用 native 代码创建一个新线程。
Thread.java
public synchronized void start() {
...
try {
// native 函数,创建线程
nativeCreate(this, stackSize, daemon);
started = true;
} finally {
...
}
}
nativeCreate 对应的 c++ 代码
java_lang_Thread.cc
static void Thread_nativeCreate(JNIEnv* env, jclass, jobject java_thread, jlong stack_size, jboolean daemon) {
Thread::CreateNativeThread(env, java_thread, stack_size, daemon == JNI_TRUE);
}
进入到 thread.cc 的 CreateNativeThread 方法
thread.cc
void Thread::CreateNativeThread(JNIEnv* env, jobject java_peer, size_t stack_size, bool is_daemon) {
// 获取当前 线程指针
Thread* self = static_cast<JNIEnvExt*>(env)->self;
// 获取当前 Runtime 指针
Runtime* runtime = Runtime::Current();
...
// 创建新线程对象
Thread* child_thread = new Thread(is_daemon);
child_thread->tlsPtr_.jpeer = env->NewGlobalRef(java_peer);
stack_size = FixStackSize(stack_size);
// 给 Thread.java 对象设置 nativePeer
env->SetLongField(java_peer, WellKnownClasses::java_lang_Thread_nativePeer,reinterpret_cast<jlong>(child_thread));
std::unique_ptr<JNIEnvExt> child_jni_env_ext(
JNIEnvExt::Create(child_thread, Runtime::Current()->GetJavaVM()));
int pthread_create_result = 0;
if (child_jni_env_ext.get() != nullptr) {
pthread_t new_pthread;
pthread_attr_t attr;
child_thread->tlsPtr_.tmp_jni_env = child_jni_env_ext.get();
// 创建 native 线程,并返回结果
pthread_create_result = pthread_create(&new_pthread,
&attr, Thread::CreateCallback, child_thread);
if (pthread_create_result == 0) {
child_jni_env_ext.release();
return;
}
}
...
}
可以看到,线程的创建是调用了 pthread_create 方法来完成的。
整个线程创建的函数调用栈如下图所示:
不管 java 层创建线程代码怎么写,所有的线程创建实际上都是调用 pthread_create 方法完成。
技术方案选型
分析完线程创建流程,并且知道了几个关键的函数,最终我们决定通过 Native Hook 技术将 pthread_create 方法替换为我们自己的代码来监控线程的创建。
在技术选型中,我们分别分析并否定 Java 动态代理、ASM 字节码插桩技术,读者可结合相关技术实现要点与线程创建流程具体分析原因,这里就不再展开讲述。
Native Hook 技术也是多样的,分为 GOT Hook,Inline Hook,Trap Hook 等。
GOT Hook :兼容性比较好,可以达到上线标准,但是只能 hook 基于 GOT 表的一些函数。
Inline Hook:能 hook 几乎所有函数,但是兼容性较差,不能达到上线标准。
Trap Hook:兼容性也能达到上线标准而且 hook 范围比 Inline Hook 还广,但是由于它是基于信号处理的,性能比较差。
兼容性 | Hook范围 | 性能 | |
---|---|---|---|
GOT Hook | 优 | 差 | 优 |
Inline Hook | 差 | 优 | 优 |
Trap Hook | 优 | 优 | 差 |
可以看出三种方案有各自的优缺点,我们根据自己的实际应用场景去选择合适的 hook 方案,发挥他们的长处。而这次我们的线程创建监控只需要在测试阶段自行测试即可,属于线下工具,不需要上线,所以兼容性没有要求,hook 函数的范围越广越好,性能也倾向于优秀的更好,所以最终 Inline Hook 最符合条件。
Hook 线程创建
查阅了 InlineHook 相关的开源项目,我们知道了怎么 hook pthread_create 函数,那么我们要在 hook 点做些什么,才能知道线程是谁创建的呢?
其实很简单,就像寻找崩溃点在哪一样,只需要知道线程创建堆栈,就能知道线程是谁创建的。所以我们 hook 了 pthread_create 方法后,只要打印此时的 java 层堆栈,并且定位到 java 层最新创建的线程,就映射了新线程与创建堆栈的关系。下面是代码实现步骤:
第一步:导入 Inline Hook 库,编写 jni 代码 hook pthread_create方法,并回调到java 层。
threadHook.cpp
#include <jni.h>
#include <string>
#include <atomic>
#include <dlfcn.h>
#include <sys/mman.h>
#include <unistd.h>
#include <sstream>
#include <android/log.h>
#include <unordered_set>
#include <fcntl.h>
#include <sys/fcntl.h>
#include <stdlib.h>
#include <libgen.h>
#include <syscall.h>
#include "linker.h"
#include "hooks.h"
#include <pthread.h>
#define LOG_TAG "HOOOOOOOOK"
#define ALOG(...) __android_log_print(ANDROID_LOG_ERROR,LOG_TAG,__VA_ARGS__)
std::atomic<bool> thread_hooked;
static jclass kJavaClass;
static jmethodID kMethodGetStack;
static JavaVM *kJvm;
/**字符串转 jni char*/
char *jstringToChars(JNIEnv *env, jstring jstr) {
if (jstr == nullptr) {
return nullptr;
}
jboolean isCopy = JNI_FALSE;
const char *str = env->GetStringUTFChars(jstr, &isCopy);
char *ret = strdup(str);
env->ReleaseStringUTFChars(jstr, str);
return ret;
}
/**打印 java 层堆栈日志*/
void printJavaStack() {
JNIEnv* jniEnv = NULL;
// JNIEnv 是绑定线程的,所以这里要重新取
kJvm->GetEnv((void**)&jniEnv, JNI_VERSION_1_6);
// 回调 java 层 getStack 方法
jstring java_stack = static_cast<jstring>(jniEnv->CallStaticObjectMethod(kJavaClass, kMethodGetStack));
if (NULL == java_stack) {
return;
}
char* stack = jstringToChars(jniEnv, java_stack);
// 在日志台打印 堆栈信息
ALOG("stack:%s", stack);
free(stack);
jniEnv->DeleteLocalRef(java_stack);
}
/**
* 使用 pthread_create_hook 替代 libart.so 的 pthread_create 方法
*/
int pthread_create_hook(pthread_t* thread, const pthread_attr_t* attr,
void* (*start_routine) (void *), void* arg) {
// 打印java 堆栈
printJavaStack();
// 继续调用 libc 的 pthread_create 方法,保证线程正常创建
return CALL_PREV(pthread_create_hook, thread, attr, *start_routine, arg);
}
/**
* hook libart.so 的 pthread_create 方法
*/
void hookLoadedLibs() {
ALOG("hook_plt_method");
hook_plt_method("libart.so", "pthread_create", (hook_func) &pthread_create_hook);
}
void enableThreadHook() {
if (thread_hooked) {
return;
}
ALOG("enableThreadHook");
thread_hooked = true;
if (linker_initialize()) {
throw std::runtime_error("Could not initialize linker library");
}
// 开始 hook
hookLoadedLibs();
}
/**
* 入口方法,java 层调用 com.dodola.thread.ThreadHook 的 enableThreadHookNative() 方法开始 hook pthread_create 函数
*/
extern "C"
JNIEXPORT void JNICALL
Java_com_dodola_thread_ThreadHook_enableThreadHookNative(JNIEnv *env, jclass type) {
enableThreadHook();
}
static bool InitJniEnv(JavaVM *vm) {
kJvm = vm;
JNIEnv* env = NULL;
if (kJvm->GetEnv((void**)&env, JNI_VERSION_1_6) != JNI_OK){
ALOG("InitJniEnv GetEnv !JNI_OK");
return false;
}
kJavaClass = reinterpret_cast<jclass>(env->NewGlobalRef(env->FindClass("com/dodola/thread/ThreadHook")));
if (kJavaClass == NULL) {
ALOG("InitJniEnv kJavaClass NULL");
return false;
}
kMethodGetStack = env->GetStaticMethodID(kJavaClass, "getStack", "()Ljava/lang/String;");
if (kMethodGetStack == NULL) {
ALOG("InitJniEnv kMethodGetStack NULL");
return false;
}
return true;
}
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved){
ALOG("JNI_OnLoad");
if (!InitJniEnv(vm)) {
return -1;
}
return JNI_VERSION_1_6;
}
第二步:创建 ThreadHook.java 类,用来调用 threadHook.cpp 的方法监控线程创建。
ThreadHook.java
package com.dodola.thread;
import java.util.ArrayList;
import java.util.ListIterator;
public final class ThreadHook {
private static boolean sHasHook;
private static boolean sHookFailed;
private static ThreadHook.OnThreadListener onThreadListener;
public ThreadHook() {
}
// native 层回调此方法,将堆栈回调给外部处理
public static String getStack() {
String stack = stackTraceToString((new Throwable()).getStackTrace());
if (onThreadListener != null) {
onThreadListener.createThread(stack);
}
return stack;
}
private static String stackTraceToString(StackTraceElement[] arr) {
if (arr == null) {
return "";
} else {
StringBuffer sb = new StringBuffer();
StackTraceElement[] var2 = arr;
int var3 = arr.length;
for(int var4 = 0; var4 < var3; ++var4) {
StackTraceElement stackTraceElement = var2[var4];
String className = stackTraceElement.getClassName();
if (!className.contains("java.lang.Thread")) {
sb.append(stackTraceElement).append('\n');
}
}
return sb.toString();
}
}
public static void enableThreadHook() {
if (!sHasHook) {
sHasHook = true;
enableThreadHookNative();
}
}
private static native void enableThreadHookNative();
public static void setOnThreadListener(ThreadHook.OnThreadListener threadListener) {
onThreadListener = threadListener;
}
static {
System.loadLibrary("threadhook");
sHasHook = false;
sHookFailed = false;
onThreadListener = null;
}
public interface OnThreadListener {
void createThread(String var1);
}
}
第三步:在 application 启动时,调用 ThreadHook.java 的方法,并设置回调监听。
UserApplication.java
public class UserApplication extends Application {
...
@Override
public void onCreate(){
super.onCreate();
ThreadHook.enableThreadHook();
ThreadHook.setOnThreadListener(new ThreadHook.OnThreadListener() {
@Override
public void createThread(String stack) {
// Linux 层线程创建完毕回调, stack 为 java 层堆栈
}
}
}
...
}
第四步,找到 java 层对应的线程。
如何找到 java 层对应的线程?
我们知道,java 层并不会使用 native 层线程 的 id,而是自行维护线程的 id,每创建一个线程,都会用上一个线程的 id 加 1,所以遍历虚拟机中所有的线程,并且找到 id 最大的那个线程,就可以确定最新创建的线程。
Thread.java
public class Thread implements Runnable {
...
/* For generating thread ID */
private static long threadSeqNumber;
private static synchronized long nextThreadID() {
return ++threadSeqNumber;
}
...
}
/**获取最新创建的线程*/
public static Thread getLatestThread() {
Map<Thread, StackTraceElement[]> map = Thread.getAllStackTraces();
Set<Thread> set = map.keySet();
Set<Thread> copySet = new HashSet<>(set);
Thread latestThread = null;
for (Thread thread : copySet) {
if (latestThread == null) {
latestThread = thread;
continue;
}
if (latestThread.getId() < thread.getId()) {
latestThread = thread;
}
}
return latestThread;
}
完成以上四步,我们就已经完成了线程创建监控,整个 hook 流程如下图:
找到线程与其创建堆栈还不够,我们还需要对每个线程的创建进行分析,分析线程是由哪家 sdk 创建,并且统计每家 sdk 创建线程的数量。
线程创建来源分析与数据统计
线程创建来源分析需要对业务堆栈以及项目内使用的 sdk 包名比较熟悉,但如果有看不懂的堆栈,可以请教下相关业务的同事是否了解。
以下面的堆栈日志举例分析,可以看到是 com.kmmartial 包名下的类,调用了 SharedPreferences 代码,而 SharedPreferences 执行 startLoadFromDisk 方法。
可以得到结论:此线程是 com.kmmartial 第一次获取 SharedPreferences 创建读取 xml 文件的 io 线程。
2021-12-27 17:39:46.214 19027-19327/com.kmxs.reader D/stack: com.km.threadhook.ThreadHook.onCreateThread(ThreadHook.java:25)
android.app.SharedPreferencesImpl.startLoadFromDisk(SharedPreferencesImpl.java:128)
android.app.SharedPreferencesImpl.<init>(SharedPreferencesImpl.java:116)
android.app.ContextImpl.getSharedPreferences(ContextImpl.java:475)
android.app.ContextImpl.getSharedPreferences(ContextImpl.java:456)
android.content.ContextWrapper.getSharedPreferences(ContextWrapper.java:189)
com.kmmartial.b.b.<init>(SourceFile:3)
com.kmmartial.b.a.a(SourceFile:3)
com.kmmartial.g.a.c(SourceFile:1)
com.kmmartial.g.a.a(SourceFile:10)
com.kmmartial.g.a.a(SourceFile:1)
com.kmmartial.f.d.a(SourceFile:25)
com.kmmartial.f.d.a(SourceFile:1)
com.kmmartial.f.d$b.run(SourceFile:1)
java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1167)
java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:641)
当然,我们不可能人为地看一条条日志去分析线程堆栈,所以可以通过硬编码来分析线程来源,并进行归纳统计。
比如我们七猫分析线程代码:
/**
* 根据线程名、堆栈 归纳出所属组件, 需要对业务有一定的熟悉程度
* 并且记录每个线程执行了多次个任务,以及每个任务的堆栈
*/
void recordThreadInfoByThreadName(Thread t, String stack) {
String name;
long id;
if (t == null) {
name = "nullThread";
id = 0;
} else {
name = t.getName();
id = t.getId();
}
if (!TextUtils.isEmpty(name)) {
String lowerCaseName = name.toLowerCase();
ThreadSourceBean threadBeans;
if (name.startsWith("OkHttp ")) {
threadBeans = getThreadBeans("OkHttp");
} else if (name.startsWith("Fresco")) {
threadBeans = getThreadBeans("Fresco");
} else if (name.startsWith("Rx")) {
threadBeans = getThreadBeans("Rxjava");
} else if (name.startsWith("pool-") && name.contains("-thread-")) {
threadBeans = getThreadBeans("ThreadPool", "未命名线程池");
} else if (name.startsWith("UMThreadPoolExecutor")
|| name.startsWith("safe_thread")
|| name.startsWith("AWCN Scheduler")
|| name.startsWith("AMDC")
|| name.startsWith("report_thread")) {
threadBeans = getThreadBeans("umpush");
} else if (name.startsWith("ZIDThreadPoolExecutor")) {
threadBeans = getThreadBeans("umsdk:asms");
} else if (lowerCaseName.startsWith("bugly")) {
threadBeans = getThreadBeans("Bugly");
} else if (name.startsWith("ACCS") || name.startsWith("spdy-")) {
threadBeans = getThreadBeans("umsdk:agoo_*", "友盟基础组件");
} else if (name.startsWith("work_thread") || name.contains("NetWorkSender") || name.startsWith("process reaper")) {
threadBeans = getThreadBeans("umsdk:common");
} else if (name.startsWith("FileObserver")) {
threadBeans = getThreadBeans("FileObserver", "被业务调用");
} else if (name.startsWith("arch_disk_io_")) {
threadBeans = getThreadBeans("androidx DefaultTaskExecutor", "被业务调用");
} else if (name.startsWith("KM_DATABASE_THREAD")) {
threadBeans = getThreadBeans("DatabaseThread", "数据库线程");
} else if (name.startsWith("FastCat")) {
threadBeans = getThreadBeans("WorkExecutor");
} else if (name.startsWith("SharedPreferencesImpl-load")) {
threadBeans = getThreadBeans("SP-load", "SP加载磁盘xml");
} else if (name.startsWith("queued-work-looper")) {
threadBeans = getThreadBeans("SP-write", "SP写磁盘xml");
} else if (name.startsWith("Thread-")) {
threadBeans = getThreadBeans("new Thread", "通过new Thread()");
} else if (name.equals("C:S:ActiveObject")) {
threadBeans = getThreadBeans("exception-monitor-library", "异常上报");
} else if (name.startsWith("nullThread")) {
// new Thread(), 具体归属看堆栈
threadBeans = getThreadBeans("nullThread", "???");
} else if (name.startsWith("activity_watch") || name.startsWith("Okio Watchdog")) {
threadBeans = getThreadBeans("performance", "性能检测");
} else {
threadBeans = getThreadBeans("other", "其它");
}
// 保存
recordThreadMsg(t, name, id, stack, threadBeans);
}
}
结合堆栈,线程名归纳出所属组件,最后把所有的线程统计数据显示在测试界面上,很方便得,每家 sdk 创建的线程数量一目了然。
推动 sdk 优化线程数量
只是监控线程创建过多问题还是不够的,还需要推动第三方 sdk 去优化解决。
比如在我们优化前,打开 app 到进入到主页, 某家 sdk 创建的线程高达 80 个,后来反馈之后,合作方更新了 sdk,将数量降到了30个左右,减轻了线上压力。再比如 new Thread(),直接 new 的方式并不符合资源复用原则,我们可以推动对应的 sdk 开发者去优化。
如何再去优化不是本文的主题,读者们自行探讨研究,这里不再赘述。
总结
安卓 JVM 线程监控写到这里就告一段落了,如果有什么问题可以私信笔者,或者有不同的方案也欢迎一起探讨。
最后让我们回顾一下整个流程:
- 分析线程创建源码,找到可行 hook 点
- 选择合适的 hook 技术
- 引入 hook 库,编写 native 层 hook 代码
- App 初始化执行 hook 代码,并找到对应线程与创建堆栈
- 根据线程堆栈分析创建来源并归纳统计
- 推动第三方 sdk 解决
参考文档:
https://zhuanlan.zhihu.com/p/269441842
https://www.jianshu.com/p/a26d11502ec8
https://github.com/AndroidAdvanceWithGeektime/Chapter06-plus