安卓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 线程监控写到这里就告一段落了,如果有什么问题可以私信笔者,或者有不同的方案也欢迎一起探讨。

最后让我们回顾一下整个流程:

  1. 分析线程创建源码,找到可行 hook 点
  2. 选择合适的 hook 技术
  3. 引入 hook 库,编写 native 层 hook 代码
  4. App 初始化执行 hook 代码,并找到对应线程与创建堆栈
  5. 根据线程堆栈分析创建来源并归纳统计
  6. 推动第三方 sdk 解决

参考文档:

https://zhuanlan.zhihu.com/p/269441842

https://www.jianshu.com/p/a26d11502ec8

https://github.com/AndroidAdvanceWithGeektime/Chapter06-plus