前言
在之前的两篇文章分别列举了死锁的发生场景和线程优化的方法,本篇重点来对阻塞和死锁进行检测。在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被哪个线程持有,这里我们分为以下几步:
- 保存BlockOn和当前Thread的AnnotatedStackTraceElement数组
- 查找其他可疑线程的AnnotatedStackTraceElement数组,从其他线程的AnnotatedStackTraceElement数组数组中获取heldLocks数组
- 从heldLocks 中查找BlockOn是否在heldLocks中,如果是,则表明当BlockOn被当前heldLocks所在的Stack Trace线程持有。
收益
该工具使用范围更广,使用起来也很简单,线上效果明显,已经检测出很多死锁和native阻塞问题,但是由于其具本身设计初衷是为了检测阻塞而不是卡顿,所以,使用时无需设置定时器,在合适的地方添加代码检测即可。
总结
检测阻塞和卡顿本身就是开发需要注意的事项,另外关于VMStack,从Android 9 到Android 14版本依然在使用。不过它本身也是@hide类和方法,后续如何走向我们仍然需要注意,不过就目前为止,显然我们不用再去纠结native获取锁信息或者synchronized字节码插庄,直接使用java api就能实现。
到这里本篇就结束了,希望对大家有所帮助。