记一次Dialog导致的内存泄露

发现Message泄露

我是在使用AddressPicker(cn.qqtheme.framework.picker.AddressPicker)时候发现的这个问题,在关闭打开过AddressPicker的页面后,会有概率出现内存泄露。

LeakCanary报出的错误如下:


leakCanary_message.jpg

(上面的LeakAddressPicker继承了AddressPicker,可以理解为就是一个AddressPicker)

大致就是说:有一个HandlerThread持有了一个Message,这个Message.obj持有了AddressPicker,而AddressPicker持有了Activity,最终导致了我们的Activity无法回收。于是我尝试在AddressPicker中找出一个持有自己的Message,然后再从这个Message着手去分析最终原因,但是最后还是没能找到这个Message。
之后仔细查看LeakCanary消息,在Message.obj详情中看到了message.target=Dialog.ListenersHandler


message_detail.jpg

同时发现AddressPicker的弹窗就是个Dialog,于是猜测是这个Dialog导致了这次的内存泄露(本质上泄露的是Message,后面会说到)。

Dialog中的Message

我们可以在Dialog中找到这样3个Message

public class Dialog implements DialogInterface, Window.Callback,
        KeyEvent.Callback, OnCreateContextMenuListener, Window.OnWindowDismissedCallback{
    private Message mCancelMessage;
    private Message mDismissMessage;
    private Message mShowMessage;
}

找到它们被赋值的地方:

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) {
            mCancelMessage = mListenersHandler.obtainMessage(CANCEL, listener);
        } else {
            mCancelMessage = null;
        }
    }

  public void setOnDismissListener(@Nullable OnDismissListener listener) {
        if (mCancelAndDismissTaken != null) {
            throw new IllegalStateException(
                    "OnDismissListener is already taken by "
                    + mCancelAndDismissTaken + " and can not be replaced.");
        }
        if (listener != null) {
            mDismissMessage = mListenersHandler.obtainMessage(DISMISS, listener);
        } else {
            mDismissMessage = null;
        }
    }

   public void setOnShowListener(@Nullable OnShowListener listener) {
        if (listener != null) {
            mShowMessage = mListenersHandler.obtainMessage(SHOW, listener);
        } else {
            mShowMessage = null;
        }
    }

可以看到它们都是在设置Dialog对应的事件时,从Message的缓冲池中获取到的一个Message。并将对应的message.obj指向了listener,而这个listener持有外部对象。我这里的OnDismissListener 持有了AddressPicker,所以导致mDismissMessage持有了AddressPicker。

接下来我们来看这些Message是在什么地方被发送的

    public void cancel() {
        if (!mCanceled && mCancelMessage != null) {
            mCanceled = true;
            // Obtain a new message so this dialog can be re-used
            Message.obtain(mCancelMessage).sendToTarget();
        }
        dismiss();
    }

    private void sendDismissMessage() {
        if (mDismissMessage != null) {
            // Obtain a new message so this dialog can be re-used
            Message.obtain(mDismissMessage).sendToTarget();
        }
    }

    private void sendShowMessage() {
        if (mShowMessage != null) {
            // Obtain a new message so this dialog can be re-used
            Message.obtain(mShowMessage).sendToTarget();
        }
    }

可以看到,在Dialog的对应的cancle、dissmiss、show方法中都是通过发送对应的Message到Handler(Dialog.mListenersHandler),再在Handler中接收到Message后根据对应的Message的obj做出对应回调。但是这里发出的Message并不是Dialog中的mCancelMessage、mDismissMessage、mShowMessage这三个Message,都是将它们复制出一个Message进行发送,而这三个Message一直都不会被发出。也就不会被销毁,直到垃圾回收器回收AddressPicker。(这里是这次泄露的一个关键点,如果对应的Message被发出,当Message被处理完后,会调用message.recycleUnchecked()来清空message的内容,也就会清空message.obj对AddressPicker的引用了。当然如果一直不发送该Message的话,它也就会一直持有AddressPicker)

到这里,我们找到了持有AddressPicker的Message了,但是该Message在这里只是作为AddressPicker的一个成员,本不会有泄露问题,除非它在被Dialog使用前就已经发生了泄露。

Looper.loop()--Message泄露的根源

另外我们可以从LeakCanary报出的信息看出这个Message是被一个HandlerThread一直持有的。

在HandlerThread对应的线程中会有一个Looper对象,在Looper.loop()中会有一个死循环一直从对应的MessageQueue中取出Message,并处理Message,在Message处理完后会清空他的内容(massage.recycleUnchecked()),并添加到Message的缓冲池中。

下面是Looper.loop()与Message.recycleUnchecked()方法

public static void loop() {
        final Looper me = myLooper();
        final MessageQueue queue = me.mQueue;
        for (;;) {
            Message msg = queue.next(); // might block
            if (msg == null) {
                // No message indicates that the message queue is quitting.
                return;
            }
           //处理message
           msg.target.dispatchMessage(msg);
            //清空message内容,并添加到缓冲池
           msg.recycleUnchecked();
        }
    }
 void recycleUnchecked() {
        flags = FLAG_IN_USE;
        what = 0;
        arg1 = 0;
        arg2 = 0;
        obj = null;
        replyTo = null;
        sendingUid = UID_NONE;
        workSourceUid = UID_NONE;
        when = 0;
        target = null;
        callback = null;
        data = null;

        synchronized (sPoolSync) {
            if (sPoolSize < MAX_POOL_SIZE) {
                next = sPool;
                sPool = this;
                sPoolSize++;
            }
        }
 }

Java的内存模型告诉我们线程开启时会创建自己独有的虚拟机栈空间,当消息循环发生阻塞时,方法中的局部变量不能被释放。
而Looper.loop()方法中就有这样一个死循环,当Looper对应的MessageQueue中不能取出Message时便发生了阻塞,所以这时循环中最后一条msg不能被正常释放,发生了泄露。这就是Looper泄露Message的根源。
可以这样理解,在我们的项目中如果创建了一个HandlerThread,处理了一些Message之后很长一段时间没有新的Message加到MessageQueue中,或者说再也没有Message加入,此时HandlerThread便一直持有着最后一条Message,由于持有的Message最后调用了msg.recycleUnchecked()方法,所以这时候持有的是一个没有内容的message。但是msg.recycleUnchecked()最后可能会将msg添加到Message的缓冲池中,从而这条Message可能会在其它地方被使用到。

到这里,应该就能理清整个泄露的原因了。有这样一种情况,上面HandlerThread持有的Message恰巧被Dialog给获取到了。所以最中导致了HandlerThread->Message->OnCancelListener->AddressPicker->Activity这样的引用链,导致activity不能正常回收。

解决方案

1、既然找到了对应的引用链,就可以通过具体的将对应对象置空释放的方式切断引用链。
在这次AddressPicker泄露中,用于Dialog在AddressPicker内部,且OnDissmissListener在初始化时就已经设置,不方便通过Dialog切断引用,所以这里选择将AddressPicker中所有对activity的引用(直接或间接)置空。

public class LeakAddressPicker extends AddressPicker {

    LeakAddressPicker(Activity activity, ArrayList provinces) {
        super(activity, provinces);
    }

    @Override
    public void onDismiss(DialogInterface dialog) {
        super.onDismiss(dialog);
        refreshLeak();
    }

    private void refreshLeak() {
        activity = null;
        cancelButton = null;
        submitButton = null;
        titleView = null;
        headerView = null;
        centerView = null;
        footerView = null;
        List allField = getAllField();

        clearField(allField, "contentLayout");
        clearField(allField, "dialog");
        clearField(allField, "onAddressPickListener");
    }

    private List getAllField() {
        Class clazz = getClass();
        List fieldList = new ArrayList<>();
        while (clazz != null) {
            fieldList.addAll(new ArrayList<>(Arrays.asList(clazz.getDeclaredFields())));
            clazz = clazz.getSuperclass();
        }
        return fieldList;
    }

    private void clearField(List allField, String fieldName) {
        Field contentLayoutField = null;
        for (Field targetField : allField) {
            if (targetField.getName().equals(fieldName)) {
                contentLayoutField = targetField;
                break;
            }
        }

        if (contentLayoutField != null) {
            contentLayoutField.setAccessible(true);
            try {
                contentLayoutField.set(this, null);
            } catch (IllegalAccessException e) {
                e.printStackTrace();
            }
            contentLayoutField.setAccessible(false);
        }
    }
}

2、如果是自己使用Dialog,则可以通过对对应的Listener进行包装,内部使用弱引用持有外部对象,使得外部对象能够正常回收。类似Handler内存泄露处理。

3、LeakCanary作者提供了如下的一种解决方案

static void flushStackLocalLeaks(Looper looper) {
  final Handler handler = new Handler(looper);
  handler.post(new Runnable() {
    @Override public void run() {
      Looper.myQueue().addIdleHandler(new MessageQueue.IdleHandler() {
        @Override public boolean queueIdle() {
          handler.sendMessageDelayed(handler.obtainMessage(), 1000);
          return true;
        }
      });
    }
  });
}

找到所有可能导致Message泄漏的HandlerThread对应的Looper,并在它空闲时往它里面添加空内容的Message,使Looper.loop()中不会出现阻塞,或者只是短暂阻塞,从而避免msg的泄露。但是这种方案前提是需要我们找到所有可能发生Message泄露的HandlerThread。同时不断往他们的MessageQueue中添加message,使线程处于运行状态,所以这种方案不推荐。

最后感谢一个内存泄漏引发的血案,从这篇文中理清了整个流程。

你可能感兴趣的:(记一次Dialog导致的内存泄露)