##前言
Q:咋回事?正常使用 Dialog 和 DialogFragment 也有可能会导致内存泄漏?
A: ....是的,说来话长。
长话短说:
- 某一个 HandlerThread 的 Looper#loop 方法,一直等待 queue#next 方法返回,但是它的 msg 局部变量还引用着上一个循环中已经被放到 Message Pool 中 Message,我们称之为 MessageA。
- DialogFragment#onActivityCreated 方法中,会调用 Dialog#setOnCancelListener 方法,将自身的引用作为 listener 参数传递给该方法
- Dialog#setOnCancelListener 方法内部,会尝试从 Message Pool 中获取一个 Message,取出的 Message 刚好是 MessageA,然后将传入的 Listener 实例赋值给 MessageA#obj。
- 外部调用 cancel 的时候,Dialog 内部会将 MessageA 拷贝一份,我们称它为 MessageB,然后将 MessageB 发送到消息队列中。
- DialogFragment 收到 onDestory 回调之后,LeakCanary 开始监听这个 DialogFragment 是否正常被回收,发现这个实例一直存在,dump 内存,分析引用链,报告内存泄漏问题。 具体细节介绍见下文 1、问题
开发的时候, LeakCanary 报告了一个诡异的内存泄漏链。 操作路径:app 显示 DialogFragment 然后点击外部使其消失,之后 LeakCanary 就报了如下问题:
从上面的截图可以看出:GCRoot 是 HandlerThread 正在执行的方法中的一个局部变量。这个局部变量强引用了一个 Message 对象,message 的 obj 字段又强引用了 NormalDialogFragment ,导致其调用了 onDestory 方法之后,也无法被回收。
2、分析
注:本文中的「HandlerThread」泛指那些带有 Looper 并且开启了消息循环(调用了 Looper#loop)的线程
DialogFragment 为啥会被一个 Message 的 obj 字段强引用?而且那还是一个被 HandlerThread 引用着的 Message。
回顾一下我们正常显示 DialogFragment 的流程:1、实例化 DialogFragment,2、调用 DialogFragment#show 方法让其显示出来。这个流程中有可能导致 Fragment 被 Message 强引用吗?
- 首先看 DialogFragment 的构造方法是一个空实现。排除。
- 其次看 DialogFragment show 方法逻辑如 ????,也是正常的 Fragment 显示逻辑。排除。
public void show(@NonNull FragmentManager manager, @Nullable String tag) {
mDismissed = false;
mShownByMe = true;
FragmentTransaction ft = manager.beginTransaction();
ft.add(this, tag);
ft.commit();
}
难道是 show 过程的某个步骤中去获取了 Message? 在 DialogFragment#onActivityCreated 方法中,可以看到
@Override
public void onActivityCreated(@Nullable Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
if (!mShowsDialog) {
return;
}
//省略一些代码
mDialog.setCancelable(mCancelable);
mDialog.setOnCancelListener(this);//设置 cancel 监听器
mDialog.setOnDismissListener(this);//设置 dismiss 监听器
//省略一些代码
}
以 Dialog#setOnCancelListener 方法为例
public void setOnCancelListener(@Nullable OnCancelListener listener) {
if (mCancelAndDismissTaken != null) {
throw new IllegalStateException(
"OnCancelListener is already taken by "
+ mCancelAndDismissTaken + " and can not be replaced.");
}
if (listener != null) {
//Listener 不为 null,取出一条 message(会尝试先从 pool 中获取,如果没有消息才会 new 一个新的) 这是一个比较关键的点,后续会讲到
mCancelMessage = mListenersHandler.obtainMessage(CANCEL, listener);
} else {
mCancelMessage = null;
}
}
可以看到,Dialog#setOnCancelListener 方法会从消息池中获取一条 message,并赋值给 Dialog 的 mCancelMessage 成员变量。
这个 message 什么时候会用到?当 cancel 方法被调用的时候。下面看下 Dialog#cancel 方法
Dialog#cancel 方法
@Override
public void cancel() {
if (!mCanceled && mCancelMessage != null) {
mCanceled = true;
// Obtain a new message so this dialog can be re-used
//复制一份,然后发送。这里为啥需要复制而不是用原来的消息?看官方的注释说,是为了 Dialog 能够被复用。(所谓「复用」应该是指,Dialog cancel 之后,再调用 show 还可以显示出来, 并且之前设置的监听都还有效)
Message.obtain(mCancelMessage).sendToTarget();
}
dismiss();
}
重点
也就是说,我们调用 Dialog#setOnCancelListener 方法从消息池获取到的 Message 最终是不会被发送出去的。因此 Message#recycleUnchecked 方法不会被调用。
但是即使没有发送出去,也只是 Dialog 的一个成员变量呀,Dialog 销毁的时候,这个 message 应该也能被回收,不至于导致内存泄漏吧?
再看回前面 LeakCanary 报出来的引用链,GCRoot 是一个 HandlerThread 中的局部变量。
Q:回顾一下 Android 的消息机制中,Message 是如何被使用的?
A:我们通过 Handler#postDelayed() 或者是 Message#sendToTarget 方法发送的消息,最终都会进入到 当前线程的 MessageQueue 中,然后 Looper#loop 方法不断地从队列中取出 Message,派发执行。当消息队列为空的时候,就会休眠。等到有新的 message 可以取出的时候,重新唤醒。
Looper#loop 方法
public static void loop() {
final Looper me = myLooper();
final MessageQueue queue = me.mQueue;
//省略一些代码
for (;;) {
Message msg = queue.next(); // might block
//省略一些代码
msg.target.dispatchMessage(msg);
//省略一些代码
msg.recycleUnchecked();
}
}
正常情况下,msg 派发到目标对象之后,都会调用 msg.recycleUnchecked() 方法完成重置,放入消息池。
难道执行 for 循环体中的一次迭代之后,msg 局部变量还是持有上一个迭代中的 Message 的强引用?
如果这个假设成立,那么上面的泄漏就说得通了。
2.1、 验证
咱们可以写一段类似的代码,然后用 javap 命令查看字节码验证一下。
新建一个 Test.java 文件,添加如下代码:
import java.util.concurrent.BlockingQueue;
public class Test {
static void loop(BlockingQueue<String> blockingQueue) throws InterruptedException {
while (true) {
String msg = blockingQueue.take();
System.out.println(msg);
}
}
}
执行如下命令:
javac Test.java javap -v Test
loop 方法对应的字节码如下:
static void loop(java.util.concurrent.BlockingQueue<java.lang.String>) throws java.lang.InterruptedException;
descriptor: (Ljava/util/concurrent/BlockingQueue;)V
flags: ACC\_STATIC
Code:
stack=2, locals=2, args\_size=1
0: aload\_0 #加载 slot0 的参数 将第0个引用类型本地变量推送至栈顶,因为是静态方法,没有 this,因此,是方法参数列表中的第一个参数,也就是加载 BlockingQueue
1: invokeinterface #2, 1 // InterfaceMethod java/util/concurrent/BlockingQueue.take:()Ljava/lang/Object;
6: checkcast #3 // class java/lang/String
9: astore\_1 #将 blockingQueue.take(); 执行的结果(一个 String 类型的值)存到第一个 slot
10: getstatic #4 // Field java/lang/System.out:Ljava/io/PrintStream;
13: aload\_1 # 将第1个引用类型本地变量推送至栈顶
14: invokevirtual #5 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
17: goto 0 #无条件跳转到第 0 行
LineNumberTable:
line 6: 0
line 7: 10
line 8: 17
StackMapTable: number\_of\_entries = 1
frame\_type = 0 /\* same \*/
Exceptions:
throws java.lang.InterruptedException
Signature: #18 // (Ljava/util/concurrent/BlockingQueue<Ljava/lang/String;>;)
从上面的字节码可以看出,当一个迭代执行结束之后,首先会跳转会循环体的第一行,上面的例子中对应的就是 blockingQueue#take 这行代码。此时,局部变量中的 slot1,还是指向上一次迭代中的 String 变量。如果 blockingQueue 中已经没有元素了,这时就会一直等待下一个元素插入,而上一次迭代中的 String 变量虽然已经没有用了,但是因为被局部变量表引用着,无法被 GC。
重点
回到我们的主线, Looper#loop 方法中 for 循环体中的第一行,queue.next(); 方法,当消息队列中没有消息的时候,这个调用会一直阻塞在那里。此时 msg 没有被重新赋值。因此,loop 方法的局部变量表中还是持有对上一个迭代中 message 实例的引用。
虽然 loop 方法结尾执行了 msg.recycleUnchecked(); 方法,会将 message 中的字段都置为空值,但是,与此同时,它会将这个 message 放入到 pool 中。这个时候,message 已经开始「泄漏」了。
再回到前面,DialogFragment#onActivityCreated 方法中,会调用 Dialog#setOnCancelListener 方法,该方法内部又会尝试从消息池中取一个 message。如果刚好取到的 message 是被某个 MessageQueue 为空的 handlerThread 的 loop 方法 (对应的栈帧中的局部变量表)所引用着的,那么 DialogFragment 销毁的时候,LeakCanary 就会报告说内存泄漏产生了。
重点
如下图所示:
2.2、复现
Q:看上面的描述,这个内存泄漏要触发的条件还是比较严苛的,有什么复现路径吗?
A:因为这个泄漏跟 message 复用有很大关系。要复现这个问题,我们可以先看下消息池中的 message message#recycleUnchecked 方法以及 Message#obtain 方法
void recycleUnchecked() {
//省略一些代码
synchronized (sPoolSync) {
if (sPoolSize < MAX\_POOL\_SIZE) {
next = sPool;
//相当于插入队头
sPool = this;
sPoolSize++;
}
}
}
public static Message obtain() {
synchronized (sPoolSync) {
if (sPool != null) {
//取出队首的第一个元素
Message m = sPool;
sPool = m.next;
m.next = null;
m.flags = 0; // clear in-use flag
sPoolSize--;
return m;
}
}
return new Message();
}
从两个方法可以看出:
- Message 回收的时候,会插入回收池列表的第一个元素
- Message 重用的时候,会取出回收池链表的第一个元素
**也就是说,取出的 message 一般是最新插入的。**因此,可以尝试使用如下代码进行复现。
class MainActivity : AppCompatActivity() {
//新建一个名为 BackgroundThread 的HandlerThread
private val background = HandlerThread("BackgroundThread")
.apply {
start()
}
private val backgroundHandler = Handler(background.looper)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity\_main)
mBtnShowNormalDialogFragment.setOnClickListener {
//往 通过 backgroundHandler 往 background HandlerThread 中的 MessageQueue 插入一条 msg
backgroundHandler.post(Runnable {
//调用 runOnUiThread,往主线程的 MessageQueue 插入一条 msg。因为当前线程并非主线程,因此会往主线程队列中 post 一个 Message(这个 message 回先尝试从 pool 中取,大概率会取到 backgroundHandler 刚刚执行完被回收的 message )
runOnUiThread {
val fragment = NormalDialogFragment()
fragment.apply {
show(supportFragmentManager, "NormalFragment")
}
}
})
}
}
}
运行之后,点击使 DialogFragment 消失,等待 10s 左右,LeakCanary 可能就会报告内存泄漏问题了。
2.3、message 内存泄漏的影响
Q:泄漏的内存是否会不断增长?是短暂泄漏还是长时间的泄漏?
- 存在增长的可能性,但是是有上限的。 - 增长的上限主要看 应用中有多少个之前执行过 Message 但是目前队列为空的带有 Looper 的 Thread,这种类型的 Thread 数目越多,Message 泄漏的概率就越高。 - 忽略那些不是通过继承 HandlerThread 实现的 带 Looper 的 Thread。TODO app 常驻的
- 主要影响的是类似于 Dialog 这种从消息池中获取了 Message 但是一直没有调用 Message#recycle 方法的情况。这种情况下,需要等待相应的线程有新的 Message 入队列并且被取出之后,才会释放。 - 如果有调用 recycle,即使 message 一直被另一个线程的 Looper#loop 方法 局部引用着,真正用到这条 message 被执行完,也会调用 Message#recycleUnchecked 方法将 消息的内容清除掉。
3、解决方案
3.1、系统侧
-
Android 官方消息机制中, Java 层的代码中应该在 Looper#loop 方法的末尾,将 msg 变量置为 null。
-
ART、Dalvik 中当引用变量无效时,可以将对应的 slot 置为 null
3.2、App 侧
相对通用的解决方案
1. 如果是 library 的开发者,自己开发的 library 使用到了 HandlerThread,想防止自己的库中的 HandlerThread 引发类似的内存泄漏问题,可以将 handlerThread 的 looper 传递给下面的 flushStackLocalLeaks 方法。
/\*\*
\* 接收 handlerThread 的 looper
\* \*/
fun flushStackLocalLeaks(looper: Looper) {
val handler = Handler(looper)
handler.post {
//当队列闲置的时候,就给它发送空的 message,以确保不会发生 message 内存泄漏
Looper.myQueue().addIdleHandler {
handler.sendMessageDelayed(handler.obtainMessage(), 1000)
//返回 true,不会自动移除
return@addIdleHandler true
}
}
}
2. 针对 app,可以通过 Thread.getAllStackTraces().keys 方法获取所有的线程。迭代遍历,判断线程是否为 HandlerThread,如果是,则调用上面的 flushStackLocalLeaks 方法。采用这种方案要注意的点是,要注意调用时机,确保调用的时候所有的 HandlerThread 都已经启动了,不然会有遗漏的情况。
Thread.getAllStackTraces().keys.forEach { thread ->
if (thread is HandlerThread && thread.isAlive) {
//添加 IdleHandler
flushStackLocalLeaks(thread.looper)
}
}
但是这种方案也存在不足的地方:
-
App 中可能存在带有 Looper 和 MessageQueue 的 Thread 但又不是通过继承 HandlerThread 来实现的,需要用更通用的判断方式。 Looper 是存在 Thread 的 threadLocalMap 里面的,仅通过线程实例对象,并不是很好获取。
-
系统版本限制。 Looper#getQueue 方法是 API Level 23 才添加的 ,也就是说,直接用这种方式无法涵盖 <= Android 5.1 版本的系统。
-
Looper#myQueue 方法没有 API 限制,但是它只能拿到当前线程的 queue,没法通过线程实例去获取 queue
-
针对版本 < 6.0 的手机,可以考虑通过反射获取 Looper#mQueue 字段解决
只针对 Dialog/DialogFragment 泄漏的解决方案:
在保证 Dialog 原有的复用功能正常运行的前提下:有两个思路:
1.思路:从 pool 中取出的 message 有可能是被其他某个 HandlerThread 引用着的,那我们不要从 pool 中取消息,而是直接 new Message 不就没有这个问题了吗?
- 查看 Dialog 源码 mCancelMessage mDismissMessage mShowMessage 访问权限都是 private 的,虽然可以通过继承重写 setOnXxxListener 方法,但是不使用反射的话,无法为 mCancelMessage 赋值。
- 反射有点 hack,我们优先看看是否有别的方案。
- Dialog 中还有 Dialog#setCancelMessage、以及 setDismissMessage 方法,可以实现对 cancelMessage 和 dismissMessage 赋值,但是没有 setShowMessage 这样的方法。这种方式覆盖不全面。
2.另一种思路,切断引用链
- 定义一个继承自 Dialog 的 AvoidLeakDialog 重写 setOnDismissListener setOnShowListener setOnCancelListener 方法,将传入的 Listener 包装一层。同时为了避免 Listener 变量因为仅被弱引用者,导致在 GC 的时候被提前回收,还应该添加在 重写的 Dialog 中添加三个成员变量,存储对应 Listener 的值。然后定义一个 DialogFragment 的子类,AvoidfLeakDialogFragment,重写 onCreateDialog 方法,返回自定义的 Dialog。
以 setOnShowListener 方法为例,包装类如下:
class WrappedShowListener(delegate: DialogInterface.OnShowListener?) :
DialogInterface.OnShowListener {
private var weakRef = WeakReference(delegate)
override fun onShow(dialog: DialogInterface?) {
weakRef.get()?.onShow(dialog)
}
}
square 以及其他网上的文章中,有一种解决方案,是将设置给 Dialog 的 Listener 包装一层为 ClearOnDetachListener ,然后业务方调用 Dialog#show 方法之后,再去手动 clearOnDetach 方法。
这种方法确实可以解决内存泄漏问题。但是存在这样的问题:在 dialog 调用 dimiss 方法之后,再调用 show 方法的话,原来设置的 Listener 就失效了。
/\*\*
\* https://medium.com/square-corner-blog/a-small-leak-will-sink-a-great-ship-efbae00f9a0f
\* square 的解决方案。View detach 的时候就将引用置为 null 了,
\* 会导致 Dialog 重新显示的时候,原来设置的 Listener 收不到回调
\*
\* 在 show 之后,调用 clearOnDetach
\* \*/
class ClearOnDetachListener(private var delegate: DialogInterface.OnClickListener?) :
DialogInterface.OnClickListener {
override fun onClick(dialog: DialogInterface?, which: Int) {
delegate?.onClick(dialog, which)
}
fun clearOnDetach(dialog: Dialog) {
dialog.window?.decorView?.viewTreeObserver?.addOnWindowAttachListener(object :
ViewTreeObserver.OnWindowAttachListener {
override fun onWindowDetached() {
Log.d(TAG, "onWindowDetached: ")
delegate = null
}
override fun onWindowAttached() {
}
})
}
}
使用方式
val clearOnDetachListener =
ClearOnDetachListener(DialogInterface.OnClickListener { dialog, which -> {} })
val dialog = AlertDialog.Builder(this)
.setPositiveButton("sure", clearOnDetachListener)
.show()
clearOnDetachListener.clearOnDetach(dialog)
结尾
今天的文章就到这里,感谢您的阅读,有问题可以在评论区留言探讨,期待与大家共同进步。喜欢的话不要忘了三连。大家的支持和认可,是我分享的最大动力。