面试问Handler内存泄露的场景,别就只知道静态内部类&弱引用!
Posted 涂程
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了面试问Handler内存泄露的场景,别就只知道静态内部类&弱引用!相关的知识,希望对你有一定的参考价值。
我们在编码的过程中,如果出现疏忽或错误,造成程序未能释放已经不再使用的内存,就会导致内存泄露,随着泄露内存的增长,最终一定会导致 OOM。
在 JVM 中,对对象的回收 GC 是基于可达性分析。简单来说,就是从 GC Root 出发,被引用的对象均被标记为存活,而没有被引用的对象,则被标记为垃圾,即可以被 GC 回收。
那么如果出现内存泄露,可以理解为就是一个长生命周期的对象,引用了短生命周期的对象,导致短生命周期的对象,在生命周期结束后,仍然得不到回收,最终导致内存泄露。
而 Handler 若是使用不当,就有内存泄露的可能。这种情况,通常发生在对应的 MessageQueue 中,持有了延迟 Message,而这个 Message 又间接持有了 Activity,导致 Activity 回收不即时出现内存泄露。针对这种情况,我们也有了成熟的解决方案,例如使用静态内部类 + WeakReference 解决。
今天给大家介绍另外一个 Handler 体系下,出现的内存泄露的场景。主要由于基于享元模式的 Message Pool 导致 Message 对象的重用,这个 Message 会被 Looper 在循环时,作为局部变量短暂的持有,那么如果这个 Message 又被其他短生命周期的对象持有了,就会导致它的内存泄露。
本文介绍的场景,比较特殊。就是子线程 Looper(HandlerThread)持有的 Message 对象,又被 DialogFragment 持有了,导致这个弹出的 Dialog 的 Activity,在 finish()
后仍得不到回收的情况。
文章内我会在适当的地方加一些补充,仅代表个人理解,文末也追加了一个总结。希望各位阅读愉快,接下来是原文。
某一个 HandlerThread 的 Looper#loop
方法,一直等待 queue#next
方法返回,但是它的 msg
局部变量还引用着上一个循环中已经被放到 Message Pool 中 Message,我们称之为 MessageA。
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 内存,分析引用链,报告内存泄漏问题;
墨影补充:DialogFragment 实现在构造时,会从 Message Pool 中获取一个 Message,但在结束时又不会消费这个 Message 对象,会持续持有这个 Message 对象。
具体细节介绍见下文。
一、发现问题
开发的时候, LeakCanary 报告了一个诡异的内存泄漏链。
操作路径:App 显示 DialogFragment 然后点击外部使其消失,之后 LeakCanary 就报了如下问题:
从上面的截图,可以看出:GCRoot 是 HandlerThread 正在执行的方法中的一个局部变量。这个局部变量强引用了一个 Message 对象,message
的 obj
字段又强引用了 NormalDialogFragment ,导致其调用了 onDestory()
方法之后,也无法被回收。
二、分析
注:本文中的「HandlerThread」,泛指那些带有 Looper 并且开启了消息循环(调用了 Looper#loop
)的线程。
DialogFragment 为啥会被一个 Message 的 obj
字段强引用?而且那还是一个被 HandlerThread 引用着的 Message。
回顾一下我们正常显示 DialogFragment 的流程。
- 实例化 DialogFragment;
- 调用
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
方法。
@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;>;)V
从上面的字节码可以看出,当一个迭代执行结束之后,首先会跳转会循环体的第一行,上面的例子中对应的就是 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#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。
主要影响的是类似于 Dialog 这种从消息池中获取了 Message 但是一直没有调用 Message#recycle 方法的情况。
这种情况下,需要等待相应的线程有新的 Message 入队列并且被取出之后,才会释放。
如果有调用 recycle
,即使 message
一直被另一个线程的 Looper#loop
方法 局部引用着,真正用到这条 message
被执行完,也会调用 Message#recycleUnchecked
方法将 消息的内容清除掉。
三、解决方案
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
}
}
}
墨影补充:这个方法利用 IdleHandler 且不会移除,在 Looper 空闲时会导致每间隔 1s 就会想 looper 指向的线程发送一个空消息,这肯定会带来一些额外的性能问题。
其他还有一些小问题:例如没有做防重入,既然已经方法已经从外部传入了 looper,没必要再构造一个 Handler 去 post 消息,直接使用即可。
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)
}
}
墨影补充:这个方法主要是切断了 Dialog 与 Activity 的关联,Dialog 该泄露还是会泄露,只是 Dialog 相对于 Activity 而言轻一些而已。
附:其他解决方案
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)
四、墨影小结
最后我们回顾一下这个内存泄露的场景。
背景知识:
- Handler 的 Message 内部基于享元模式,维护了消息池 Message Pool,可以通过
obtain()
方法从复用已经创建的 Message 对象; - Looper 在
loop()
的循环中,若 MessageQueue 为空时,会将最后一次处理的 Message 作为局部变量 msg 一直持有,直到从 Queue 中获取了新的 Message 对象; - DialogFragment 在构造时,会从 Message Pool 中获取一个 Message 对象,但是在最终使用时,却又没有直接消费这个 Message 对象,而是选择一直持有它;
内存泄露场景:
- 场景:如果 Looper 的局部变量 msg 与 DialogFragment 持有了同一个 Message 对象,就会导致 DialogFragment 无法回收,从而影响弹出对话框的 Activity 无法回收,造成内存泄露;
- 通常我们无需关心主线程 Looper,因为主线程的系统消息非常多,基本不会遇到这个场景,只有在子线程 Looper 场景下,所有消息都依赖我们的业务发送,才有可能出现这样的情况。
针对这种情况的解决方案:
- 不使用 Message Pool。子线程 Looper 的所有消息不要从 Message Pool 中获取;
- 适当的时机,顶掉子线程 Looper 循环内持有的局部 msg 对象;
- 切断引用链,让泄露范围缩小;本文最后的方法,本质上 Dialog 依然被 msg 泄露了,但是因为没有泄露更重的 Activity,内存占用就比较小,在可接受的范围内;
最后
我分享一份我在学习过程中,从网上收集整理的 Android 开发和音视频的相关学习文档、面试题、学习笔记等等文档,希望能帮助到大家学习提升,如有需要参考的可以直接去我 CodeChina地址:https://codechina.csdn.net/u012165769/Android-T3 访问查阅。
以上是关于面试问Handler内存泄露的场景,别就只知道静态内部类&弱引用!的主要内容,如果未能解决你的问题,请参考以下文章