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就能实现。

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

相关推荐
万少2 小时前
HarmonyOS官方模板集成创新活动-流蓝卡片
前端·harmonyos
-To be number.wan4 小时前
C++ 赋值运算符重载:深拷贝 vs 浅拷贝的生死线!
前端·c++
噢,我明白了5 小时前
JavaScript 中处理时间格式的核心方式
前端·javascript
纸上的彩虹6 小时前
半年一百个页面,重构系统也重构了我对前端工作的理解
前端·程序员·架构
李艺为6 小时前
根据apk包名动态修改Android品牌与型号
android·开发语言
be or not to be6 小时前
深入理解 CSS 浮动布局(float)
前端·css
LYFlied6 小时前
【每日算法】LeetCode 1143. 最长公共子序列
前端·算法·leetcode·职场和发展·动态规划
老华带你飞7 小时前
农产品销售管理|基于java + vue农产品销售管理系统(源码+数据库+文档)
java·开发语言·前端·数据库·vue.js·spring boot·后端
小徐_23337 小时前
2025 前端开源三年,npm 发包卡我半天
前端·npm·github
Tom4i7 小时前
【网络优化】Android 如何监听系统网络连接成功
android·网络