系列文章
上文解释了因为同步屏障泄露,导致的页面假死问题。那么有没有什么手段可以检测同步屏障泄露呢?
当然有的,我们可以参考APM中,检测页面卡顿、页面ANR的方法,使用轮询的方法,检测同步屏障的泄露。
效果如下:在02:34
模拟同步屏障泄露,时间卡在了02:34
。Gif后半段,检测到泄露后进行移除,计时器继续运行。

一、同步屏障
1.同步屏障消息的生成
同步屏障消息是一种特殊的同步消息,他的msg.target=null
。
先看正常的Handler
消息,调用sendMessage
后,最终会调用enqueueMessage
,这里会强制将Message
的target
指向当前handler
。最终再调用MessageQueue.enqueueMessage
,将消息插入到消息队列中。

当调用postSyncBarrier
,插入同步屏障时,这个方法来自MessageQueue
,没有设置target
相关逻辑。

这里可以关注一下,为了避免内存抖动,Handler
的消息使用了对象池,即享元模式,进行对象的复用,大大减少了创建对象的过程。
我们都知道,Handler
中,是依靠Looper.loop
的for
循环,让主线程永远运转。也是依靠这个for
循环,我们才能实现跨线程通信,子线程可以给主线程发消息。

在looperOnce
方法中,当messageA
执行完毕后,会调用messageA.recycleUnchecked


所以在postSyncBarrier
中,使用Message.obtain
获取到的是一个没有target
的特殊Message
,这也就是同步屏障。

系统没有开放插入同步屏障的方法。我们可以通过反射的方法,插入一个同步屏障,用来模拟同步屏障泄露。
kotlin
/**
* 通过反射插入同步屏障
*/
@SuppressLint("DiscouragedPrivateApi")
private fun insertSyncBarrier() {
try {
MainScope().launch {
Toast.makeText(this, "插入同步屏障", Toast.LENGTH_SHORT).show()
}
// 获取当前线程的 Looper 和 MessageQueue
val looper = Looper.getMainLooper()
val messageQueueField: Field = Looper::class.java.getDeclaredField("mQueue")
messageQueueField.isAccessible = true
val messageQueue: Any? = messageQueueField.get(looper)
// 通过反射调用 postSyncBarrier 方法
val messageQueueClass: Class<*> = messageQueue!!.javaClass
val postSyncBarrierMethod: Method = messageQueueClass.getDeclaredMethod("postSyncBarrier")
postSyncBarrierMethod.isAccessible = true
Log.d(TAG, "Barrier token: ${postSyncBarrierMethod.invoke(messageQueue)}")
} catch (e: Exception) {
e.printStackTrace()
}
}
2.同步屏障消息的取出
在MessageQueue
的next
方法中,首先进入的逻辑,就是判断当前消息是不是同步屏障,如果是同步屏障,则已知查找消息队列中的异步消息。

3.消息执行顺序

回顾一下上文内容,如果当前MessageQueue
中的消息如上图所示,那么执行顺序就是:
1->4->2->3->6
因为同步屏障没有target
,所以他本身没有要执行的任务,他只是一个标记,遇到他之后,就去查找队列中的异步消息。
二、同步屏障检测+移除
同步屏障消息的定义很明确,即msg.target
为空的消息。那么我们可以通过反射方式,拿到消息队列中,msg.target=null
的消息,并且判断msg.when
是否存在时间过长,如果存在超过3秒(or 5秒),就可以初步判断,这个同步屏障泄露了。
当然,为了避免误判,我们可以再发送一个同步消息,一个异步消息到主线程,进行竞速。如果1秒后,异步消息处理完成了,但是同步消息还未处理,就更加可以印证,该同步屏障泄露了。
我们可以通过发射获取到该同步屏障的token
,然后通过反射调用removeSyncBarrier
移除该同步屏障。
具体代码如下:
kotlin
class BarrierLeakChecker {
private val TAG = "BarrierLeakChecker"
// 使用协程进行重复检查的任务
// limitedParallelism(1)将协程并发限制在1个,因为我们不需要结构化并发,排队做任务即可
@OptIn(ExperimentalCoroutinesApi::class)
private val barrierLeakCheckScope = CoroutineScope(Dispatchers.IO.limitedParallelism(1) + SupervisorJob())
// 记录下正在执行的任务,方便取消
// CoroutineScope.cancel会导致协程作用域进入取消状态,无法启动新任务,所以还是用Job.cancel
private var job: Job? = null
/**
* 启动SyncBarrier泄漏检测
* 通过协程每秒检查一次消息队列,发现泄漏的同步屏障会自动移除
*/
fun startCheckBarrier() {
/// 避免重复启动任务
job?.cancel()
job = barrierLeakCheckScope.launch {
// 启动延迟20秒,避免应用启动期间误报
delay(20_000L)
Log.d(TAG, "barrier leak check init.")
// 持续监控,每1秒检查一次
while (true) {
try {
Log.i(TAG, "barrier leak detect start")
detectSyncBarrierMessage()
// 每1秒检查一次
delay(1000L)
} catch (e: Exception) {
Log.e(TAG, "barrier leak error: ${e.message}")
break // 出现异常时退出循环
}
}
}
}
/// 在业务需要时,取消任务
fun stopCheck() {
job?.cancel()
}
/**
* 检测消息队列中是否存在SyncBarrier泄漏
* 检测超过3秒且target为null的消息,判定为泄漏并自动清理
*/
@SuppressLint("DiscouragedPrivateApi")
private fun detectSyncBarrierMessage() {
try {
// 获取主线程消息队列
val mainQueue = Looper.getMainLooper().queue
// 反射获取Handler消息队列
val field = mainQueue.javaClass.getDeclaredField("mMessages")
field.isAccessible = true
val mMessage = field[mainQueue] as? Message
if (mMessage != null) {
// 计算消息存在时间
val existTime = SystemClock.uptimeMillis() - mMessage.`when`
// 如果存在超过3秒且target为null,判定为SyncBarrier泄漏
if (existTime > 3000L && mMessage.target == null) {
val token = mMessage.arg1
Log.w(TAG, "barrier leak detected, token=$token, existTime=${existTime}ms")
removeSyncBarrier(token)
}
}
} catch (e: Exception) {
Log.e(TAG, "barrier leak error: ${e.message}")
}
}
/**
* 移除指定token的SyncBarrier
* @param token SyncBarrier的标识token
*/
@SuppressLint("DiscouragedPrivateApi")
private fun removeSyncBarrier(token: Int) {
try {
val mainQueue = Looper.getMainLooper().queue
val method = mainQueue.javaClass.getDeclaredMethod("removeSyncBarrier", Integer.TYPE)
method.isAccessible = true
method.invoke(mainQueue, token)
Log.i(TAG, "removeSyncBarrier success,token=$token")
} catch (e: Exception) {
Log.e(TAG, "removeSyncBarrier failed: ${e.message}")
}
}
}
三、工具使用
-
因为该检测使用到了反射手段,会对性能有一定影响,需要评估轮询时间,建议轮询时间大于1秒。
-
在进程启动后,延迟启动,一是避免误判,而是避免对冷启动造成影响。
-
自动移除泄露的同步屏障,理论上是没有问题的。因为本身同步屏障只是一种UI优先级的调整手段,所以即使误判移除了同步屏障,也只是某小段时间内的UI状态可能会错乱,最终态是正确的。
-
可以辅助增加上报机制,将检测到泄露的执行栈上报到后台。真正的解决该问题,避免再次出现。