面试问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: … 是的,说来话长。

长话短说:

  1. 某一个 HandlerThread 的 Looper#loop 方法,一直等待 queue#next 方法返回,但是它的 msg 局部变量还引用着上一个循环中已经被放到 Message Pool 中 Message,我们称之为 MessageA;
  2. DialogFragment 的 onActivityCreated 方法中,会调用 Dialog#setOnCancelListener 方法,将自身的引用作为 listener 参数传递给该方法;
  3. Dialog 的 setOnCancelListener 方法内部,会尝试从 Message Pool 中获取一个 Message,取出的 Message 刚好是 MessageA,然后将传入的 Listener 实例赋值给 MessageA#obj
  4. 外部调用 cancel() 的时候,Dialog 内部会将 MessageA 拷贝一份,我们称它为 MessageB,然后将 MessageB 发送到消息队列中;
  5. DialogFragment 收到 onDestory 回调之后,LeakCanary 开始监听这个 DialogFragment 是否正常被回收,发现这个实例一直存在,dump 内存,分析引用链,报告内存泄漏问题;

墨影补充:DialogFragment 实现在构造时,会从 Message Pool 中获取一个 Message,但在结束时又不会消费这个 Message 对象,会持续持有这个 Message 对象。

具体细节介绍见下文。

一、发现问题

开发的时候, LeakCanary 报告了一个诡异的内存泄漏链。

操作路径:App 显示 DialogFragment 然后点击外部使其消失,之后 LeakCanary 就报了如下问题:

从上面的截图,可以看出:GCRoot 是 HandlerThread 正在执行的方法中的一个局部变量。这个局部变量强引用了一个 Message 对象,messageobj 字段又强引用了 NormalDialogFragment ,导致其调用了 onDestory() 方法之后,也无法被回收。

二、分析

注:本文中的「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 方法。

@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();
}

从上面两个方法可以看出:

  1. Message 回收的时候,会插入回收池列表的第一个元素;
  2. 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)
  }
}

但是这种方案也存在不足的地方:

  1. App 中可能存在带有 Looper 和 MessageQueue 的 Thread,但又不是通过继承 HandlerThread 来实现的,需要用更通用的判断方式。Looper 是存在 Thread 的 threadLocalMap 里面的,仅通过线程实例对象,并不是很好获取。
  2. 系统版本限制。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 方法,可以实现对 cancelMessagedismissMessage 赋值,但是没有 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)

四、墨影小结

最后我们回顾一下这个内存泄露的场景。

背景知识:

  1. Handler 的 Message 内部基于享元模式,维护了消息池 Message Pool,可以通过 obtain()方法从复用已经创建的 Message 对象;
  2. Looper 在 loop() 的循环中,若 MessageQueue 为空时,会将最后一次处理的 Message 作为局部变量 msg 一直持有,直到从 Queue 中获取了新的 Message 对象;
  3. DialogFragment 在构造时,会从 Message Pool 中获取一个 Message 对象,但是在最终使用时,却又没有直接消费这个 Message 对象,而是选择一直持有它;

内存泄露场景:

  1. 场景:如果 Looper 的局部变量 msg 与 DialogFragment 持有了同一个 Message 对象,就会导致 DialogFragment 无法回收,从而影响弹出对话框的 Activity 无法回收,造成内存泄露;
  2. 通常我们无需关心主线程 Looper,因为主线程的系统消息非常多,基本不会遇到这个场景,只有在子线程 Looper 场景下,所有消息都依赖我们的业务发送,才有可能出现这样的情况。

针对这种情况的解决方案:

  1. 不使用 Message Pool。子线程 Looper 的所有消息不要从 Message Pool 中获取;
  2. 适当的时机,顶掉子线程 Looper 循环内持有的局部 msg 对象;
  3. 切断引用链,让泄露范围缩小;本文最后的方法,本质上 Dialog 依然被 msg 泄露了,但是因为没有泄露更重的 Activity,内存占用就比较小,在可接受的范围内;

最后

我分享一份我在学习过程中,从网上收集整理的 Android 开发和音视频的相关学习文档、面试题、学习笔记等等文档,希望能帮助到大家学习提升,如有需要参考的可以直接去我 CodeChina地址:https://codechina.csdn.net/u012165769/Android-T3 访问查阅。


以上是关于面试问Handler内存泄露的场景,别就只知道静态内部类&弱引用!的主要内容,如果未能解决你的问题,请参考以下文章

Handler Thread 内部类引起内存泄露分析

LeakCanary检测内存泄漏

内存泄漏篇--1

Java开发面试问题,kafka查看topic配置

[转帖]面试问Kafka,这一篇全搞定

内存泄漏篇--1.基础问题