上图是DialogFragment泄露的典型路径,引用链根部的HandlerThread可能是app中任何一个HandlerThread。DialogFragment内存泄漏问题覆盖Android全部版本(目前最高版本Q),其泄漏的根源与Dialog有关,也就是说,Dialog导致的内存泄漏同样覆盖了Android全部版本。
(源码参考AOSP android-9.0.0_r34分支)
为了本文引用源码的稳定性,这里引用的源码为android.app.DialogFragment
。对support包或者androidx包下的DialogFragment,泄露同样存在,原理是一样的。
Message如何引用DialogFragment
首先看是哪里的Message引用了DialogFragment。
DialogFragment
重写了onActivityCreated
,在其中调用了Dialog.takeCancelAndDismissListeners
:
@Override
public void onActivityCreated(Bundle savedInstanceState) {
super.onActivityCreated(savedInstanceState);
...
mDialog.setCancelable(mCancelable);
if (!mDialog.takeCancelAndDismissListeners("DialogFragment", this, this)) {
throw new IllegalStateException(
"You can not set Dialog's OnCancelListener or OnDismissListener");
}
...
}
DialogFragment
直接实现了DialogInterface.OnCancelListener
和DialogInterface.OnDismissListener
并在调用Dialog.takeCancelAndDismissListeners
时传入:
public boolean takeCancelAndDismissListeners(@Nullable String msg,
@Nullable OnCancelListener cancel, @Nullable OnDismissListener dismiss) {
...
setOnCancelListener(cancel);
setOnDismissListener(dismiss);
...
}
这里我们只查看setOnDismissListener
方法即可,setOnCancelListener
与其类似。
public void setOnDismissListener(@Nullable OnDismissListener listener) {
...
if (listener != null) {
mDismissMessage = mListenersHandler.obtainMessage(DISMISS, listener);
} else {
mDismissMessage = null;
}
}
可以看到Dialog
通过obtainMessage
获取了Message
存储为成员变量mDismissMessage
,将外部传入的OnDismissListener
(也就是DialogFragment实例)存储在mDismissMessage
的成员obj
中。
Dialog.mDismissMessage
和Dialog.mCancelMessage
均持有DialogFragment。
为了分析方便,我们假设是Dialog.mDismissMessage
泄露了DialogFragment。对于mCancelMessage
原理是一样的,就不再赘述。
至此我们找到了泄露DialogMessage的上一级引用mDismissMessage
Message为何没有recycle
我们知道一个Message
只要被Looper
处理了,就会被recycle,recycle的时候会将其各种字段重置,其中包括将Message.obj
字段置null,也就是说,一个被处理过的Message
是不会泄露引用的,为什么mDismissMessage.obj
能一直持有DialogFragment?
这就需要看Dialog被dismiss的时候,是怎么使用这个mDismissMessage
的:
void dismissDialog() {
...
try {
...
} finally {
...
sendDismissMessage();
}
}
private void sendDismissMessage() {
if (mDismissMessage != null) {
// Obtain a new message so this dialog can be re-used
Message.obtain(mDismissMessage).sendToTarget();
}
}
这里Dialog
为了重用mDismissMessage
,并不是直接使用它,而是通过Message.obtain
拷贝出了一个新的Message
发送出去。因此mDismissMessage
不会被Looper处理,自然也就不会调用recycle方法,所以它会一直引用着DialogFragment
。
也就是说,如果mDismissMessage
发生了泄露,则DialogFragment
泄露。
引用链显示,是一个HandlerThread泄露了Message引用,继而导致DialogFragment泄露,那么mDismissMessage
引用是如何被HandlerThread拿到的呢?
HandlerThread如何引用mDismissMessage
Dialog本身是不会把mDismissMessage
的引用传到外面去的,唯一的可能就是Dialog拿到mDismissMessage
的时候,HandlerThread就已经持有对mDismissMessage
的引用了。
回想一下Dialog是如何获取mDismissMessage的:
public void setOnDismissListener(@Nullable OnDismissListener listener) {
...
if (listener != null) {
mDismissMessage = mListenersHandler.obtainMessage(DISMISS, listener);
} else {
mDismissMessage = null;
}
}
mListenersHandler.obtainMessage
最终是通过Message.obtain
获取消息的:
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();
}
sPool
指向一个Message链表的表头,这个链表其实就是一个Message对象池。
Message在recycle的时候,就会被放入sPool
对象池中,假如HandlerThread在一个Message被recycle之后依然保持对该Message的引用,则当Dialog从对象池中获取一个Message时,极有可能获取到被HandlerThread持有的那个Message。
至此我们知道了HandlerThread是如何拿到Dialog.mDismissMessage
的引用的,其本质是HandlerThread插入到sPool的Message被Dialog拿去用了,同时HandlerThread维持对该Message的引用(泄露了Message)。
因此问题的关键就成了HandlerThread是如何泄露Message的。
HandlerThread如何泄露Message。
HandlerThread
本身不会直接持有Message
,但与HandlerThread
关联的Looper
会持有Message,代码如下:
public static void loop() {
...
for (;;) {
Message msg = queue.next(); // might block
...
msg.recycleUnchecked();
}
}
考虑以下场景:
Looper通过queue.next
获取了一个MessageA存储在局部变量msg
中,经过一系列消息处理代码之后顺利调用msg.recycleUnchecked
,将MessageA插入对象池中,然后进入下一次循环,此时消息队列已经没有其他消息,Looper阻塞在queue.next
上。
假如Looper此后一直没有收到新消息,一直阻塞在queue.next
上,请问Looper现在是否持有MessageA的引用。
遗憾的是,不论是Dalvik还是ART上,这个问题的答案都是:Looper依然持有MessageA的引用。即:
阻塞状态的HandlerThread会泄露它处理的最后一个Message。
一般人看到上面的代码,都会说,不存在内存泄露,理由是:
局部变量
msg
的作用域仅在单次循环内有效,单次循环结束时,局部变量msg
就已经消失,不再指向MessageA,且在新的循环中,msg都是全新的局部变量,在queue.next
返回之前,它都处于未赋值状态。
要理解为什么会存在内存泄露,就需要明确一个概念:所谓的作用域,局部变量,都是高级语言给我们提供的一种抽象,在实际的机器码/字节码执行过程中,并不存在所谓的局部变量,只有对寄存器的读写。
可以猜测,在Looper.loop
中的for循环对应的字节码中,一定没有对寄存器的值进行擦除,导致最后一次写入寄存器的置,在queue.next
阻塞的时候,一直指向前一个Message。
smali字节码验证猜想
我们可以仿照HandlerThread和Looper,写一段代码来验证我们的猜想
public class LeakThread extends Thread {
private final LinkedBlockingDeque<LeakObj> linkedBlockingDeque = new LinkedBlockingDeque<>();
@Override
public void run() {
for (;;) {
LeakObj obj = nextObj();
obj.emptyMethod();
}
}
private LeakObj nextObj() {
try {
return linkedBlockingDeque.takeFirst();
} catch (InterruptedException e) {
e.printStackTrace();
}
return null;
}
public void offer(LeakObj obj) {
linkedBlockingDeque.offer(obj);
}
}
上面这个LeakThread,可以视为一个简陋的精简版HandlerThread,线程启动后将通过nextObj不断获取对象并调用方法,如果没有对象则会阻塞,外部可以通过offer方法插入对象。
编译后在Android机器上运行,会发现最后一个offer进去的LeakObj,无法被垃圾回收。
我们反编译看看它的run方法对应的smali字节码:
.method public run()V
.locals 1
.line 11
:goto_0
invoke-direct {p0}, Lio/github/wonshaw/testleak/LeakThread;->nextObj()Lio/github/wonshaw/testleak/LeakObj;
move-result-object v0
.line 12
.local v0, "obj":Lio/github/wonshaw/testleak/LeakObj;
invoke-interface {v0}, Lio/github/wonshaw/testleak/LeakObj;->emptyMethod()V
.line 13
.end local v0 # "obj":Lio/github/wonshaw/testleak/LeakObj;
goto :goto_0
.end method
p0寄存器指向当前对象,相当于this
,首先调用this.nextObj
,将结果存储到v0寄存器,随后调用v0的emptyMethod
,最后goto指令跳转goto_0
,也就是进行下一轮循环。
可以明显的看出,v0寄存器在被赋值之后,就一直指向一个LeakObj,即便goto语句跳转goto_0
,也就是新一轮循环开始的时候,v0也依然指向前一次循环中获得的LeakObj,如果线程阻塞在nextObj
上,v0便一直指向前一次循环中的LeakObj。在字节码层面,单纯的循环内局部变量,在多次循环时,并没有所谓的局部引用超出范围这一概念,仅仅是寄存器的值被覆盖而已。
无论是Dalvik还是ART的垃圾回收器,对这种情况都很保守,不会尝试回收v0指向的对象。
需要注意的是,单看上面的字节码,我们可以明确的看到局部变量obj
的作用域,从.local v0, "obj":Lio/github/wonshaw/testleak/LeakObj;
开始,到.end local v0
结束,但这些都是调试用的信息,如果去掉调试信息,则run方法对应的smali字节码如下:
.method public run()V
.locals 1
.line 11
:goto_0
invoke-direct {p0}, Lio/github/wonshaw/testleak/LeakThread;->nextObj()Lio/github/wonshaw/testleak/LeakObj;
move-result-object v0
.line 12
invoke-interface {v0}, Lio/github/wonshaw/testleak/LeakObj;->emptyMethod()V
goto :goto_0
.end method
验证猜想的注意点
如果你看了我的文章,想自己验证一下,那么你需要注意demo的代码写法是有一定要求的:
比如以下两个run方法,第二个方法在无调试信息的状态下,是没有内存泄漏的:
@Override
public void run() {
for (;;) {
LeakObj obj = nextObj();
obj.emptyMethod();
}
}
@Override
public void run() {
for (;;) {
LeakObj obj = nextObj();
Log.e("shaw", obj.toString());
}
}
第二个run方法,在无调试信息的情况下,存储obj引用的寄存器,会被obj.toString
的结果覆盖,相当于清理了寄存器的引用,此时泄露的是obj.toString
的返回值,如果你监测obj的泄露,是检测不到的。
而Android系统的Looper.loop
方法的循环里面,最后一行恰好是调用msg.recycleUnchecked
,使得存储msg的寄存器在循环的结尾不会被其他值覆盖。
解决方案
DialogFragment的泄漏模式,满足以下两点:
- 阻塞状态的Looper(通常与HandlerThread配合)会泄漏最后一个处理过的Message
- 通过
Message.obtain
获取Message,让Message.obj
引用其他对象,且不及时切断引用(比如不手动置null,不手动调Message.recycle
,不让Looper处理该Message)
满足这两点,就大概率会泄漏Message.obj
引用的对象。由此可知不仅是DialogFragment
泄露,Dialog
,AlertDialog
均可能间接导致我们设进去的OnDismissListener,OnCancelListener,OnShowListener发生泄漏。
对于 1,我们没有什么好办法,也就是说,我们通过Message.obtain
拿到的任何Message,都有可能被一个不知名的HandlerThread引用着。
对于 2,我们可以干涉。
由于DialogFragment泄漏的本质是Dialog间接泄露了DialogFragment传入的OnDismissListener,OnCancelListener,因此,首要任务,就是让setXXListener操作,不泄露,或者最多只泄露一个对象,因此我们需要在方法调用上做手脚,创建一个自己的WeakDialog:
public class WeakDialog extends Dialog {
public WeakDialog(@NonNull Context context) {
super(context);
}
public WeakDialog(@NonNull Context context, int themeResId) {
super(context, themeResId);
}
protected WeakDialog(@NonNull Context context, boolean cancelable, @Nullable OnCancelListener cancelListener) {
super(context, cancelable, cancelListener);
}
@Override
public void setOnCancelListener(@Nullable OnCancelListener listener) {
super.setOnCancelListener(Weak.proxy(listener));
}
@Override
public void setOnDismissListener(@Nullable OnDismissListener listener) {
super.setOnDismissListener(Weak.proxy(listener));
}
@Override
public void setOnShowListener(@Nullable OnShowListener listener) {
super.setOnShowListener(Weak.proxy(listener));
}
}
注意我们在listener上做了手脚,Weak.proxy
会将listener包在我们自己的代理类里:
public static WeakOnCancelListener proxy(DialogInterface.OnCancelListener real) {
return new WeakOnCancelListener(real);
}
代理类很简单,就是用弱引用保存外部的listener:
public class WeakOnCancelListener implements DialogInterface.OnCancelListener {
private WeakReference<DialogInterface.OnCancelListener> mRef;
public WeakOnCancelListener(DialogInterface.OnCancelListener real) {
this.mRef = new WeakReference<>(real);
}
@Override
public void onCancel(DialogInterface dialog) {
DialogInterface.OnCancelListener real = mRef.get();
if (real != null) {
real.onCancel(dialog);
}
}
}
如果Dialog中拿到已经泄露的Message来保存listener,最多也就泄露一个空壳代理类,不会导致外部listener泄露。WeakDialog准备好了,接下来就是用WeakDialog替换DialogFragment中的Dialog:
以androidx包下的DialogFragment为例:
public class DialogFragment extends androidx.fragment.app.DialogFragment {
@NonNull
@Override
public Dialog onCreateDialog(@Nullable Bundle savedInstanceState) {
return new WeakDialog(requireContext(), getTheme());
}
}
对于AOSP里的DialogFragment也可以如法炮制。
使用弱引用需要注意的一个问题是,弱引用所引用的对象,会不会因为没有GC roots的引用链而被回收?万幸的是DialogFragment直接实现OnDismissListener,OnCancelListener,即便Dialog不强引用外部传入的listener,listener也不会被回收,而是和DialogFragment对象的生命周期一致,因此这个解决方案对于DialogFragment是安全的,既切断了泄露的引用链,也能保证listener不会因为Dialog不强引用它而被回收。
相关代码我没有自己真正运行验证过,仅供参考:https://github.com/WonShaw/noleak
错误的解决方案
网上有人说只要通过setOnDismissListener(null)
这种方式,把所有的listener全部置null就能解决内存泄漏,其实并不行。DialogFragment泄露的根源并不是Dialog泄露了,而是Dialog把listener的引用赋给了一个已经泄露的Message.obj
,所以光清空Dialog中对Message的引用,并不能切断已经泄露的Message本身对listener的引用,必须从一开始就完全杜绝一个强引用的listener被设入,事后设null没有任何意义。
无法修改的DialogFragment
如果是第三方库中存在的DialogFragment,且我们无法修改源码,建议通过一些编译期字节码变换的工具,将他们的DialogFragment改为继承我们安全版本的DialogFragment。
网友评论