Android 一种简单的线程阻塞检测方法

前言

在之前的两篇文章分别列举了死锁的发生场景和线程优化的方法,本篇重点来对阻塞和死锁进行检测。在Android发展至今,ANR、OOM、Crash、Object Leak,Fd Leak 等已经具备了完善的方法,当然还有Bitmap Monitor、JunkStats等卡顿和内存检测方法。

阅读本文前建议先阅读以下文章 《Android 线程死锁场景与优化》 《Android 线程性能优化方法总结》 《Android HandlerThread FD 优化

常见的检测工具

在本篇开始之前,我们先来梳理一下Android 开发中常用的检测工具和检测方法

  • Crash 监控: 主要依赖Java的 java.lang.Thread.UncaughtExceptionHandler和native层信号监控,不过Native层的crash往往无法让主线程避免被杀,比如常见的SEGV_MAPERR,大多数情况下都是线程安全问题引发的问题,有时只需要让子线程kill就行,但Native目前没有较好的处理方式。
  • OOM 监控: OOM 属于JVM或者JNI抛出的Error,堆、本地方法栈、虚拟机栈、方法区都有可能OOM,另外就是DirectByteBuffer直接内存也会OOM,不过总体上,OOM是可以try---catch的 JVM Error,因此目前很多工具也是使用java.lang.Thread.UncaughtExceptionHandler 监控。当然,还有些开源的工具,通过FD和内存阈值进行定时检测的,不过目前仍然缺乏对内存碎片引发OOM的检测。
  • ANR 检测: ANR检测实际上存在一些难点,主要是在Java 中监听不到ANR的发生,其次缺少必要的内存信息和对战信息,常用的检测方式是在Native层监控 SIG_QUIT 信号,然后使用ActivityManager#getProcessesInErrorState 获取ANR信息,当然其他信息如stackTrace和backtrace需要自行收集,不过另一个问题是stackTrace中的锁在java层中无法获取,因此相当一部份工作需要在native中进行。
  • leak memory: 这类检测工具相当常见,常用的工具有leakcanary、strictMode、Profiler等,但是也有些缺陷就是无法对未关注的对象进行检测,这个时候需要dump 内存数据,利用mat或者shark进行分析,计算出可疑异常的增量。
  • 性能检测:systrace,profiler、perfetto 等工具,各种指标非常全面,但是上手难度也不低,需要深入了解各种指标和图表信息。
  • 电量检测:battery historian 、profiler等,这个相当简单,异常信息在分析报告中能够很明确的展示出来。

阻塞检测现状

造成阻塞的情况一般有两种,一种是耗时任务,另一种是死锁。

阻塞和死锁检测一般常用的工具有WatchDog、Aspectj等,这些工具各有各的特点,前者主要检测HandlerThread,后者一般是编译时修改字节码用来分析锁。本篇会提供另一种思路,在不借助native和asm的情况下,实现Thread、HandlerThread阻塞检测和锁分析。

HandlerThread 阻塞检测:

这种检测分为两种情况:

超时检测法

先让Handler发送消息,然后自己WatchDog发送定时消息(注意是发送定时消息而不是定时发送消息),在满足期望的情况下,消息在额定的时间内执行完成,WatchDog不会触发超时异常,否则会触发超时异常。

盲测法

Android System_server中有个官方实现的WatchDog,这个检测属于盲测,实现原理是向MessageQueue头部插入多条消息(队列以先进先出的方式执行),然后等待检测这些消息是不是能在指定的时间内都完成,如果没有则认为产生了卡顿或者阻塞。

Thread 阻塞检测

Thread的检测相比HandlerThread而言存在一些难点,主要是无法知道当前线程的角色,以及无法对任务隔离,因而这方面的检测主要还是打印堆栈进行分析。

Thread 锁资源分析

Java 官方并没有提供lock相关信息,只提供了Thread#holdLock方法,但是这个方法存在缺陷,只能对已知的锁资源进行检测,但是java并没有开放局部变量表和调用字节码探测的机制,因此这种检测需要借助native或者aspectj进行字节码记录,显然这个是尴尬的问题。不过如果你使用的AQS锁,就不需要这么复杂的处理方式。

以上检测还有个共同的问题,就是被检测的线程被直接引用才行。

阻塞检测改进方法

其实在前言和现状中我们知道,每种检测工具都有他的优特点点和适应场景,不可能面面俱到,因此必然有他的缺陷。我们如何改进这些缺陷呢?首先我们先列出缺陷。

  • 缺乏对无法引用的Thread或者HandlerThread的检测,部分工具使用起来有一些侵入性
  • 无法对普通Thread进行检测
  • 缺陷synchronized锁分析方法

消除侵入性问题

如何消除侵入性问题呢?实际上我们要借助ThreadGroup来实现线程的获取,随时随地任意模块,都能获取到即可。 我们知道默认情况下所有的ThreadGroup的groupName都是main,实际上ThreadGroup是可以自己定义的,不过正常情况下几乎没有这么做的,当然即便自己定义了也没关系,只需要将目标的ThreadGroup自己管理起来即可。

从下面我们可以看到 group="main"

java 复制代码
  suspend all histogram:  Sum: 2.834s 99% C.I. 5.738us-7145.919us Avg: 607.155us Max: 41543us
  DALVIK THREADS (248):
          "main" prio=5 tid=1 Native
| group="main" sCount=1 dsCount=0 flags=1 obj=0x74b17080 self=0x7bb7a14c00
          | sysTid=2080 nice=-2 cgrp=default sched=0/0 handle=0x7c3e82b548
          | state=S schedstat=( 757205342094 583547320723 2145008 ) utm=52002 stm=23718 core=5 HZ=100
          | stack=0x7fdc995000-0x7fdc997000 stackSize=8MB
| held mutexes=
          kernel: __switch_to+0xb0/0xbc
  kernel: SyS_epoll_wait+0x288/0x364
  kernel: SyS_epoll_pwait+0xb0/0x124
  kernel: cpu_switch_to+0x38c/0x2258

不过,为了更加全面的获取当前的线程,推荐从最顶级的ThreadGroup入手,这样做的目的是可以获取到最全面的当前正在激活的线程。

java 复制代码
public static ThreadGroup getThreadGroup() {
    ThreadGroup threadGroup = Thread.currentThread().getThreadGroup();
    while (threadGroup != null) {
        ThreadGroup parent = threadGroup.getParent();
        if (parent == null) {
            return threadGroup;
        }
        threadGroup = parent;
    }
    return threadGroup;
}

接下来当然是获取所有线程

java 复制代码
Thread[] threads = new Thread[threadGroup.activeCount()];
try {
    threadGroup.enumerate(threads);
}catch (Exception e){
    e.printStackTrace();
}

但是我们其实没必要检测所有的线程,只需要检测我们关注的线程即可。不过在检测之前,我们来一个粗略的划线。

这里我们不用太详细,因为毕竟是为了检测阻塞而不是卡顿

java 复制代码
public static final int THREAD_UNKNOWN = 0;  //未知状态
public static final int THREAD_HEALTH = 1;  // 线程处于健康状态
public static final int THREAD_BLOCK = 2;   // 发生了阻塞 
public static final int THREAD_DEATH = 4;   // 线程死亡

显然,我们只需要检测我们关注的线程,那怎么知道哪个线程是自己关注的呢?其实使用线程名称就行了(所以,在使用线程时对线程命名是非常重要的)。

java 复制代码
public static void checkThreadHealth(final HappenBlockWarning warning,final String... threads) {
 //省略核心代码
}
//使用方式
ThreadHealthUtil.checkThreadHealth(
         warningListener,  //监听器
        "dexloader", //线程名称
        "fileDescrypt", //线程名称
        "PlayerManager", //线程名称
        "MessageCenter", //线程名称
        "IO_Input", //线程名称
        "GlideExectutor" //线程名称
      );

监听器

java 复制代码
public interface HappenBlockWarning {
    void onReport(String name, Thread thread, int diagnosis);

    void onStart();

    void onFinish();
}

通过线程名称我们可以与拿到的线程进行匹配,这样就能筛选出目标线程

java 复制代码
Thread[] threads = new Thread[threadGroup.activeCount()];
try {
    threadGroup.enumerate(threads);
}catch (Exception e){
    e.printStackTrace();
}

阻塞检测方法

Thread我们分两种情况,一种是HandlerThread,另一种是普通的Thread

  • 检测HandlerThread时: 使用常用handler#postAtFrontQueue盲测方式,定时检测消息能否执行完,如果不能则发生了卡顿或者阻塞。
  • 检测普通Thread时,我们使用栈扫描方式盲测,定时检测堆栈变化,但是我们需要排除线程池和HandlerThread休眠的堆栈。
java 复制代码
private static String getStackTraceCrc32Sum(Thread thread) {
    StackTraceElement[] stackTraceElements = thread.getStackTrace();
    if (stackTraceElements == null) {
        return "";
    }
    CRC32 crc32 = new CRC32();
    StringBuilder sb = new StringBuilder();
    boolean hasAppPackage = false;
    for (StackTraceElement stackTrace : stackTraceElements) {
        String s = stackTrace.toString();
        if (s.startsWith("java.lang.")
                || s.startsWith("java.util.")
                || s.startsWith("sun.misc.")
                || s.startsWith("android.os.")
                || s.startsWith("android.app.")
                || s.startsWith("com.android.")
        ) {
            sb.append("*");
            continue;
        }
        hasAppPackage = true;
        crc32.update(s.getBytes());
        sb.append(crc32.getValue());
    }
    if (!hasAppPackage) {
        return "";
    }
    return sb.toString();
}

上面我们排除这些java和android sdk包名 ,因为这些包名是线程池中默认的片段,线程池休眠不应该作为卡顿或者阻塞处理。这样,就能实现对普通的Thread检测。

另外我们知道,线程中存在6种状态,但实际上真正运行过程中,满足期望的状态仅有NEW\BLOCKED\TERMINATED三种是绝对准确的,因此我们可以利用这三种状态实现跟快速的检测。

java 复制代码
NEW A thread that has not yet started is in this state.
RUNNABLE A thread executing in the Java virtual machine is in this state.
BLOCKED A thread that is blocked waiting for a monitor lock is in this state.
WAITING A thread that is waiting indefinitely for another thread to perform a particular action is in this state.
TIMED_WAITING A thread that is waiting for another thread to perform an action for up to a specified waiting time is in this state.
TERMINATED A thread that has exited is in this state.

下面是检测逻辑的核心方法

java 复制代码
/**
 * @param thread      target handlerThread
 * @param waitPeriod  每次等待时长
 * @param maxTryTimes 最长等待次数
 * @return
 * @throws IllegalAccessException
 */
public static int diagnosis(Thread thread, long waitPeriod, int maxTryTimes) throws IllegalAccessException {
    if (thread == null) {
        return THREAD_UNKNOWN;
    }

    Thread.State threadState = thread.getState();
    if (threadState == Thread.State.NEW
    ) {
        return THREAD_HEALTH;
    }
    if (threadState == Thread.State.TERMINATED) {
        return THREAD_DEATH;
    }

    if (Thread.currentThread() == thread) {
        throw new IllegalAccessException("#diagnosis should be called on the different thread ");
    }

    if (waitPeriod < 5) {
        waitPeriod = 5;
    }

    if (!(thread instanceof HandlerThread)) {
        //检测普通线程
        return diagnosisCommonThread(thread, waitPeriod, maxTryTimes);
    }

    HandlerThread handlerThread = (HandlerThread) thread;
    Looper looper = handlerThread.getLooper();
    if (looper == null) {
        if (!handlerThread.isAlive()) {
            return THREAD_DEATH;
        }
        return THREAD_UNKNOWN;
    }
    int countDown = maxTryTimes / 5;
    final Handler handler = new Handler(looper);
    String threadName = thread.getName();
    final HealthCheckTask task = new HealthCheckTask(countDown, threadName);

    while (countDown > 0) {
        try {
            handler.postAtFrontOfQueue(task);
        } catch (IllegalStateException e) {
            e.printStackTrace();
            if ((e.getLocalizedMessage() + "").contains("sending message to a Handler on a dead thread")) {
                return THREAD_DEATH;
            }
        }
        countDown--;
    }
    while (maxTryTimes > 0 && task.getCount() > 0) {
        Thread.State state = handlerThread.getState();
        if (state == Thread.State.TERMINATED) {
            return THREAD_DEATH;
        }
        try {
            task.await(waitPeriod, TimeUnit.MILLISECONDS);
        } catch (Exception e) {
            e.printStackTrace();
        } finally {
            maxTryTimes--;
        }
    }
    if (maxTryTimes <= 0) {
        System.err.println("WARNING: " + thread + " : Happen Block");
        ThreadStackDumper.printBlockStackTrace(handlerThread);
        return THREAD_BLOCK;
    }
    return THREAD_HEALTH;
}


private static int diagnosisCommonThread(Thread thread, long waitPeriod, int maxTryTimes) {

    String stackTraceCrc32Sum = getStackTraceCrc32Sum(thread);

    if (TextUtils.isEmpty(stackTraceCrc32Sum)) {
        return THREAD_DEATH;
    }
    int checkCrc32SameStackCount = 0;
    final Object o = new Object();
    final int MAX_TRY_TIMES = maxTryTimes;
    while (maxTryTimes > 0) {
        Thread.State state = thread.getState();
        if (state == Thread.State.TERMINATED) {
            return THREAD_DEATH;
        }
        try {
            synchronized (o) {
                try {
                    o.wait(waitPeriod);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
            if (state == Thread.State.BLOCKED) {
                checkCrc32SameStackCount++;
                continue;
            }
            String crc32Sum = getStackTraceCrc32Sum(thread);
            if (crc32Sum.equals(stackTraceCrc32Sum)) {
                checkCrc32SameStackCount++;
                continue;
            }
        } finally {
            maxTryTimes--;
        }
    }
    if (maxTryTimes <= 0) {
        if (checkCrc32SameStackCount >= MAX_TRY_TIMES - 1) {
            System.err.println("WARNING: " + thread + " : Happen Thread Block");
            ThreadStackDumper.printBlockStackTrace(thread);
            return THREAD_BLOCK;
        }
    }
    return THREAD_HEALTH;
}

private static String getStackTraceCrc32Sum(Thread thread) {
    StackTraceElement[] stackTraceElements = thread.getStackTrace();
    if (stackTraceElements == null) {
        return "";
    }
    CRC32 crc32 = new CRC32();
    StringBuilder sb = new StringBuilder();
    boolean hasAppPackage = false;
    for (StackTraceElement stackTrace : stackTraceElements) {
        String s = stackTrace.toString();
        if (s.startsWith("java.lang.")
                || s.startsWith("java.util.")
                || s.startsWith("sun.misc.")
                || s.startsWith("android.os.")
                || s.startsWith("android.app.")
                || s.startsWith("com.android.")
        ) {
            sb.append("*");
            continue;
        }
        hasAppPackage = true;
        crc32.update(s.getBytes());
        sb.append(crc32.getValue());
    }
    if (!hasAppPackage) {
        return "";
    }
    return sb.toString();
}

下面是对ExoPlayer# MediaCodec发生阻塞的检测,这种检测实际上并不需要定时检测,只要关闭特定页面的时候检测就行,因此相比其他工具,使用起来更加方便顺手,没有与业务耦合,属于轻量级检测。

java 复制代码
[2023-07-16 21:26:41.177](Tid:760:CheckThreadHealth) :[ThreadHealthUtil]Check MessageCenter Thread Health : 2   //(线程健康)
[2023-07-16 21:26:41.679](Tid:760:CheckThreadHealth) :[CatchError]WARNING: Thread[ExoPlayer:Playback,10,main] : Happen Block  //次线程发生了阻塞
[2023-07-16 21:26:41.680](Tid:760:CheckThreadHealth) :[CatchError]Call Stacktrace :Thread[ExoPlayer:Playback,10,main]
ThreadState: RUNNABLE, isAlive=true

   android.media.MediaCodec#native_flush (MediaCodec.java:-2)
   android.media.MediaCodec#flush (MediaCodec.java:2052)
   com.google.android.exoplayer2.mediacodec.SynchronousMediaCodecAdapter#flush (SynchronousMediaCodecAdapter.java:81)
   com.google.android.exoplayer2.mediacodec.MediaCodecRenderer#flushOrReleaseCodec (MediaCodecRenderer.java:914)
   com.google.android.exoplayer2.mediacodec.MediaCodecRenderer#onDisabled (MediaCodecRenderer.java:773)
   com.google.android.exoplayer2.video.MediaCodecVideoRenderer#onDisabled (MediaCodecVideoRenderer.java:454)
   com.test.example.exo.AKDefaultExoRenderersFactory#onDisabled (AKDefaultExoRenderersFactory.java:165)
   com.google.android.exoplayer2.BaseRenderer#disable (BaseRenderer.java:175)
   com.google.android.exoplayer2.ExoPlayerImplInternal#disableRenderer (ExoPlayerImplInternal.java:1507)
   com.google.android.exoplayer2.ExoPlayerImplInternal#resetInternal (ExoPlayerImplInternal.java:1254)
   com.google.android.exoplayer2.ExoPlayerImplInternal#stopInternal (ExoPlayerImplInternal.java:1218)
   com.google.android.exoplayer2.ExoPlayerImplInternal#handleMessage (ExoPlayerImplInternal.java:483)
   android.os.Handler#dispatchMessage (Handler.java:98)
   android.os.Looper#loop (Looper.java:154)
   android.os.HandlerThread#run (HandlerThread.java:61)

实现锁分析功能

上面知识打印出了阻塞问题,但是对于一些ANR或者阻塞,本身不仅仅是耗时方法或者native层阻塞,而是由于死锁造成的,我们希望能够打印出持有锁的信息,以及当前堆栈在等待那个锁。

实际上,在Java 历代版本中都没有获取锁信息的功能,目前常见的ANR dump工具都是借助native实现的,这本身对很多kotlin和java开发者不够友好。Android Framework层中存在一些只允许native层调用的方法,但却不允许在java层调用,总的来说很多人也是不理解。

不过,关于锁的分析限制在Android 9的版本中被打破了,Android 9版本中的WatchDog 中提供了WatchdogDiagnostics 类用来解决此问题,但核心实现是VMStack和AnnotatedStackTraceElement,不过受限于@hide标记,我们需要借助反隐藏 dalvik.system.VMRuntime#setHiddenApiExemptions 来反隐藏api调用,如果觉麻烦的话可以借助开源项目《freereflection 》来处理即可。

VMStack 获取带锁的堆栈

通过VMStack,我们可以获取到带锁的堆栈

java 复制代码
AnnotatedStackTraceElement stack[] = VMStack.getAnnotatedThreadStackTrace(thread);

AnnotatedStackTraceElement 和StackTraceElement的区别是,前者包含后者,同时也提供了Stack Trace中没有的锁信息

java 复制代码
public final class AnnotatedStackTraceElement {
    // Stack Trace
    private StackTraceElement stackTraceElement;
    // 当前StackTrace 所持有的锁 (被哪些锁包围)
    private Object[] heldLocks;
    //当前在等待的锁
    private Object blockedOn; 
    
    // 省略一坨代码
   
}

我们先来测试一下

java 复制代码
2024-01-13 11:30:26.288 23484-9897  HappenBlockWarning    E  WARNING: Thread[IO_Input,5,main] : Happen Block
2024-01-13 11:30:26.290 23484-9897  HappenBlockWarning    E  Call Stacktrace :Thread[IO_Input,5,main]
    ThreadState: TIMED_WAITING, isAlive=true
    at java.lang.Object.wait(Native Method)
    at java.lang.Object.wait(Object.java:442)
    at java.lang.Thread.join(Thread.java:1430)
            - locked <0x0dd134df> (a java.lang.Object)
    at com.test.example.dm.IOReader.close(IOReader.java:220)
    at com.test.example.dm.MediaIO.release(MediaIO.java:80)
    at com.test.example.dm.DataWriter.releaseWriter(DataWriter.java:588)
    at com.test.example.dm.DataWriter.stopWriter(DataWriter.java:852)
            - locked <0x049f51e0> (a com.test.example.dm.DataWriter)
    at com.test.example.dm.utils.SimpleExecutor.execute(SimpleExecutor.java:12)
    at com.test.example.dm.utils.SimpleExecutor.access$4100(SimpleExecutor.java:86)
    at android.os.Handler.handleCallback(Handler.java:883)
    at android.os.Handler.dispatchMessage(Handler.java:100)
    at android.os.Looper.loop(Looper.java:237)
    at android.os.HandlerThread.run(HandlerThread.java:67)

从上面的逻辑中,我们完全在不使用字节码拦截或者native code情况下就能实现 锁信息的获取。

锁分析工具实现

默认情况下,如果没有wait lock,那么BlockOn是空的,所以可以判断没有锁等待。

对于发生锁阻塞的线程,我们通过遍历AnnotatedStackTraceElement ,找到blockedOn 的对象即可,堆栈中能打印出来。

java 复制代码
    at com.test.example.dm.utils.DataHandler.postAction(DataHandler.java:95)
       - waiting to lock <0x049f51e0> (a com.test.example.dm.DataWriter)

当然我们要查找的是当前lock被哪个线程持有,这里我们分为以下几步:

  1. 保存BlockOn和当前Thread的AnnotatedStackTraceElement数组
  2. 查找其他可疑线程的AnnotatedStackTraceElement数组,从其他线程的AnnotatedStackTraceElement数组数组中获取heldLocks数组
  3. 从heldLocks 中查找BlockOn是否在heldLocks中,如果是,则表明当BlockOn被当前heldLocks所在的Stack Trace线程持有。

收益

该工具使用范围更广,使用起来也很简单,线上效果明显,已经检测出很多死锁和native阻塞问题,但是由于其具本身设计初衷是为了检测阻塞而不是卡顿,所以,使用时无需设置定时器,在合适的地方添加代码检测即可。

总结

检测阻塞和卡顿本身就是开发需要注意的事项,另外关于VMStack,从Android 9 到Android 14版本依然在使用。不过它本身也是@hide类和方法,后续如何走向我们仍然需要注意,不过就目前为止,显然我们不用再去纠结native获取锁信息或者synchronized字节码插庄,直接使用java api就能实现。

到这里本篇就结束了,希望对大家有所帮助。

相关推荐
逐·風3 小时前
unity关于自定义渲染、内存管理、性能调优、复杂物理模拟、并行计算以及插件开发
前端·unity·c#
Devil枫3 小时前
Vue 3 单元测试与E2E测试
前端·vue.js·单元测试
尚梦4 小时前
uni-app 封装刘海状态栏(适用小程序, h5, 头条小程序)
前端·小程序·uni-app
GIS程序媛—椰子4 小时前
【Vue 全家桶】6、vue-router 路由(更新中)
前端·vue.js
前端青山5 小时前
Node.js-增强 API 安全性和性能优化
开发语言·前端·javascript·性能优化·前端框架·node.js
毕业设计制作和分享5 小时前
ssm《数据库系统原理》课程平台的设计与实现+vue
前端·数据库·vue.js·oracle·mybatis
清灵xmf7 小时前
在 Vue 中实现与优化轮询技术
前端·javascript·vue·轮询
大佩梨7 小时前
VUE+Vite之环境文件配置及使用环境变量
前端
GDAL7 小时前
npm入门教程1:npm简介
前端·npm·node.js
帅得不敢出门8 小时前
安卓设备adb执行AT指令控制电话卡
android·adb·sim卡·at指令·电话卡