记一次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<Province> 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<Field> allField = getAllField();

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

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

    private void clearField(List<Field> 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,使线程处于运行状态,所以这种方案不推荐。

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 214,922评论 6 497
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 91,591评论 3 389
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 160,546评论 0 350
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 57,467评论 1 288
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 66,553评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 50,580评论 1 293
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,588评论 3 414
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 38,334评论 0 270
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,780评论 1 307
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 37,092评论 2 330
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 39,270评论 1 344
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,925评论 5 338
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,573评论 3 322
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 31,194评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,437评论 1 268
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 47,154评论 2 366
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 44,127评论 2 352