Square:一个内存泄漏引发的血案

在开发 LeakCanary 时我发现一处奇怪的内存泄漏,为了搞清楚到底是什么原因导致这个问题我一边 Debug,一边在邮件中和小伙伴们沟通,最后形成了这篇博文。

嫌弃篇幅太长懒的看的,概述在此:在 Android Lollipop 之前使用 AlertDialog 可能会导致内存泄漏。

The Artist

LeakCanary 通知我存在内存泄漏:

`

  • GC ROOT thread com.squareup.picasso.Dispatcher.DispatcherThread.

  • references android.os.Message.obj

  • references com.example.MyActivity$MyDialogClickListener.this$0

  • leaks com.example.MyActivity.MainActivity instance `

简单来说就是:一个 Picasso 线程正在站内持有一个 Message 实例的本地变量,而 Message 持有 DialogInterface.OnClickListener 的引用,而 DialogInterface.OnClickListener 又持有一个被销毁 Activity 的引用。

本地变量通常由于他们仅存在于栈内存活时间较短,当线程调用某个方法,系统就会为其分配栈帧。当方法返回,栈帧也会随之被销毁,栈内所有本地变量都会被回收。如果本地变量导致了内存泄漏,一般意味着线程要么死循环,要么阻塞了,而且线程在这种状态时持有着 Message 实例的引用。

于是 Dimitris 和我都去 Picasso 源码中一探究竟:

Dispatcher.DispatcherThread 是一个简单的 HandlerThread:

static class DispatcherThread extends HandlerThread {
  DispatcherThread() {
    super(Utils.THREAD_PREFIX + DISPATCHER_THREAD_NAME, THREAD_PRIORITY_BACKGROUND);
  }
}

这个线程用标准的方式通过 Handler 接收 Message:

private static class DispatcherHandler extends Handler {
  private final Dispatcher dispatcher;
  public DispatcherHandler(Looper looper, Dispatcher dispatcher) {
    super(looper);
    this.dispatcher = dispatcher;
  }
  @Override public void handleMessage(final Message msg) {
    switch (msg.what) {
      case REQUEST_SUBMIT: {
        Action action = (Action) msg.obj;
        dispatcher.performSubmit(action);
        break;
      }
      // ... handles other types of messages
    }
  }
}

显然 Dispatcher.DispatcherHandler.handleMessage() 里面没有明显会让本地变量持有 Message 引用的 Bug。

Queue Tips

Let's look at how HandlerThread works:

后来越来越多内存泄漏的通知出现了,这些通知不仅仅来自 Picasso,各种各样线程中的本地变量都存在内存泄漏,而且这些内存泄漏往往和 Dialog 的 OnClickListener 有关。发生内存泄漏的线程有一个共同的特性:他们都是工作者线程,而且通过某种阻塞队列接收各自的工作。

for (;;) {
    Message msg = queue.next(); // might block
    if (msg == null) {
        return;
    }
    msg.target.dispatchMessage(msg);
    msg.recycleUnchecked();
}

通过源码可以发现,肯定存在本地变量持有 Message 的引用,然而它的生命周期本应很短,而且在循环结束时被清除。

我们尝试通过利用阻塞队列实现一个简单的工作者线程来重现这个 Bug,它只发送一个 Message:

static class MyMessage {
  final String message;
  MyMessage(String message) {
    this.message = message;
  }
}
static void startThread() {
  final BlockingQueue<MyMessage> queue = new LinkedBlockingQueue<>();
  MyMessage message = new MyMessage("Hello Leaking World");
  queue.offer(message);
  new Thread() {
    @Override public void run() {
      try {
        loop(queue);
      } catch (InterruptedException e) {
        throw new RuntimeException(e);
      }
    }
  }.start();
}
static void loop(BlockingQueue<MyMessage> queue) throws InterruptedException {
  while (true) {
    MyMessage message = queue.take();
    System.out.println("Received: " + message);
  }
}

一旦 Message 被打印到 Log 中,MyMessage 实例应该被回收,然而还是发生了内存泄漏:

`

  • GC ROOT thread com.example.MyActivity$2. (named 'Thread-110')

  • leaks com.example.MyActivity$MyMessage instance `

我们发送新的 Message 到阻塞队列的瞬间,前一个 Message 就被回收,而新的 Message 就泄漏了。

在 VM 中,每一个栈帧都是本地变量的集合,而垃圾回收器是保守的:只要存在一个存活的引用,就不会回收它。

在循环结束后,本地变量不再可访问,然而本地变量仍持有对 Message 的引用,interpreter/JIT 理论上应该在本地变量不可访问时将其引用置为 null,然而它们并没有这样做,引用仍然存活,而且不会被置为 null,使得它不会被回收。

为了验证我们的结论,我们手动将引用设为 null,并打印它,使得 null 不会是最优化办法:

static void loop(BlockingQueue<MyMessage> queue) throws InterruptedException {
  while (true) {
    MyMessage message = queue.take();
    System.out.println("Received: " + message);
    message = null;
    System.out.println("Now null: " + message);
  }
}

在测试上面的代码时,我们发现 MyMessage 实例在 Message 被设为 null 时立刻被回收。也就是说我们的结论似乎是正确的。

因为这样的内存泄漏会在各种各样的线程和阻塞队列的实现中发生,我们现在确定这是一个存在于 VM 中的 Bug。基于这个结论,我们只能在 Dalvik VM 中复现这个 Bug,在 ART VM 或 JVM 中则无法复现。

Message in a (recycled) bottle

我们发现了一个会导致内存泄漏的 Bug,但这会导致严重的内存泄漏吗?不妨看看我们最初的泄漏信息:

`

  • GC ROOT thread com.squareup.picasso.Dispatcher.DispatcherThread.

  • references android.os.Message.obj

  • references com.example.MyActivity$MyDialogClickListener.this$0

  • leaks com.example.MyActivity.MainActivity instance `

我们发送给 Picasso Dispatcher 线程的 Message,我们从未将 Message.obj 设为 DialogInterface.OnClickListener,那它是怎么结束的?

此外,当 Message 被处理,它应该立刻被回收,而且 Message.obj 应该被设为 null。只有那样 HandlerThread 才会等待下一个 Message,并暂时泄漏前一个 Message:

for (;;) {
    Message msg = queue.next(); // might block
    if (msg == null) {
        return;
    }
    msg.target.dispatchMessage(msg);
    msg.recycleUnchecked();
}

因而我们知道泄漏的 Message 会被回收,因此不会持有之前的内容。

一旦被回收,Message 就会回到常量池中:

void recycleUnchecked() {
    // Mark the message as in use while it remains in the recycled object pool.
    // Clear out all other details.
    flags = FLAG_IN_USE;
    what = 0;
    arg1 = 0;
    arg2 = 0;
    obj = null;
    replyTo = null;
    sendingUid = -1;
    when = 0;
    target = null;
    callback = null;
    data = null;
    synchronized (sPoolSync) {
        if (sPoolSize < MAX_POOL_SIZE) {
            next = sPool;
            sPool = this;
            sPoolSize++;
        }
    }
}

我们此时拥有一个泄漏的空 Message,它可能会被重用,并填充不同的内容。Message 常常以相同的方式被使用:在池中被调用,填充内容,放入 MessageQueue,然后被处理,最后被回收,并置回到池中。

它理应在很长一段时间内不会持有它的内容,那我们为什么总会在 DialogInterface.OnClickListener 实例中发生内存泄漏呢?

Alert Dialogs

我们先创建一个简单的 AlertDialog:

new AlertDialog.Builder(this)
    .setPositiveButton("Baguette", new DialogInterface.OnClickListener() {
      @Override public void onClick(DialogInterface dialog, int which) {
        MyActivity.this.makeBread();
      }
    })
    .show();

注意到 ClickListener 持有 Activity 的引用,该匿名内部类实际完成的工作与下面的代码是一样的:

// First anonymous class of MyActivity.
class MyActivity$0 implements DialogInterface.OnClickListener {
  final MyActivity this$0;
  MyActivity$0(MyActivity this$0) {
    this.this$0 = this$0;
  }
  @Override public void onClick(DialogInterface dialog, int which) {
    this$0.makeBread();
  }
}
new AlertDialog.Builder(this)
    .setPositiveButton("Baguette", new MyActivity$0(this));
    .show();
Internally, AlertDialog delegates the work to AlertController:
/**
 * Sets a click listener or a message to be sent when the button is clicked.
 * You only need to pass one of {@code listener} or {@code msg}.
 */
public void setButton(int whichButton, CharSequence text,
        DialogInterface.OnClickListener listener, Message msg) {
    if (msg == null && listener != null) {
        msg = mHandler.obtainMessage(whichButton, listener);
    }
    switch (whichButton) {
        case DialogInterface.BUTTON_POSITIVE:
            mButtonPositiveText = text;
            mButtonPositiveMessage = msg;
            break;
        case DialogInterface.BUTTON_NEGATIVE:
            mButtonNegativeText = text;
            mButtonNegativeMessage = msg;
            break;
        case DialogInterface.BUTTON_NEUTRAL:
            mButtonNeutralText = text;
            mButtonNeutralMessage = msg;
            break;
    }
}

所以 OnClickListener 被包裹到 Message 中,并被设置到 AlertController.mButtonPositiveMessage,现在我们看看该 Message 在什么时候被使用:

private final View.OnClickListener mButtonHandler = new View.OnClickListener() {
    @Override public void onClick(View v) {
        final Message m;
        if (v == mButtonPositive && mButtonPositiveMessage != null) {
            m = Message.obtain(mButtonPositiveMessage);
        } else if (v == mButtonNegative && mButtonNegativeMessage != null) {
            m = Message.obtain(mButtonNegativeMessage);
        } else if (v == mButtonNeutral && mButtonNeutralMessage != null) {
            m = Message.obtain(mButtonNeutralMessage);
        } else {
            m = null;
        }
        if (m != null) {
            m.sendToTarget();
        }
        // Post a message so we dismiss after the above handlers are executed.
        mHandler.obtainMessage(ButtonHandler.MSG_DISMISS_DIALOG, mDialogInterface)
                .sendToTarget();
    }
};

注意这:m = Message.obtain(mButtonPositiveMessage).

Message 被克隆,也就是说后面使用的是它的拷贝。这就以为着原始的 Message 从没有被发送,因此不会被回收,所以永久保存着它的内容,直到发生垃圾回收。

现在假设 Message 已经由于 HandlerThread 本地引用在被回收池调用之前发生内存泄漏,Dialog 最终会被垃圾回收,并释放由 mButtonPositiveMessage 持有的 Message 的引用。

然而,由于 Message 已经泄漏,它并不会被垃圾回收。同样的,它持有的内容也不会被回收,而 OnClickListener 持有对 Activity 的引用,导致 Activity 不会被回收。

Smoking Gun

我们能证明这个结论么?

我们需要发送一个 Message 到 HandlerThread 中,让他被处理和回收,并不要再发送任何 Message 到那个线程中,使最后一个 Message 发生内存泄漏。然后,我们需要显示一个带 Button 的 Dialog ,并希望它会从池中获取到相同的 Message。这确实很可能会发生,因为一旦被回收,Message 就会变成池内的第一个可调用的 Message。

HandlerThread background = new HandlerThread("BackgroundThread");
background.start();
Handler backgroundhandler = new Handler(background.getLooper());
final DialogInterface.OnClickListener clickListener = new DialogInterface.OnClickListener() {
  @Override public void onClick(DialogInterface dialog, int which) {
    MyActivity.this.makeCroissants();
  }
};
backgroundhandler.post(new Runnable() {
  @Override public void run() {
    runOnUiThread(new Runnable() {
      @Override public void run() {
        new AlertDialog.Builder(MyActivity.this) //
            .setPositiveButton("Baguette", clickListener) //
            .show();
      }
    });
  }
});

如果我们运行上面的代码,然后旋转屏幕销毁当前 Activity,就很有可能会使该 Activity 发生内存泄漏。

LeakCanary 准确地检测到了内存泄漏:

`

  • GC ROOT thread android.os.HandlerThread. (named 'BackgroundThread')

  • references android.os.Message.obj

  • references com.example.MyActivity$1.this$0 (anonymous class implements android.content.DialogInterface$OnClickListener)

  • leaks com.example.MyActivity instance `

现在我们成功地重现了这个 Bug,不妨看看该怎么修复它。

The Startup Fix

只支持 ART VM的设备当然不会有这个 Bug,当然,也没多少人用……

The Won't Fix

你可能会觉得这些内存泄漏没啥影响,而且你有其他更值得做的事情,或许更简单的内存泄漏需要修复。LeakCanary 默认无视了所有 Message 泄漏。但要注意的是,Activity 持有其整个 View 的资源,那可是有好几兆的。

The App fix

确保 DialogInterface.OnClickListener 不会持有对 Activity 实例的强引用,例如在 Dialog 退出后清除对 Listener 的引用,下面是一个简化它的包裹类:

public final class DetachableClickListener implements DialogInterface.OnClickListener {
  public static DetachableClickListener wrap(DialogInterface.OnClickListener delegate) {
    return new DetachableClickListener(delegate);
  }
  private DialogInterface.OnClickListener delegateOrNull;
  private DetachableClickListener(DialogInterface.OnClickListener delegate) {
    this.delegateOrNull = delegate;
  }
  @Override public void onClick(DialogInterface dialog, int which) {
    if (delegateOrNull != null) {
      delegateOrNull.onClick(dialog, which);
    }
  }
  public void clearOnDetach(Dialog dialog) {
    dialog.getWindow()
        .getDecorView()
        .getViewTreeObserver()
        .addOnWindowAttachListener(new OnWindowAttachListener() {
          @Override public void onWindowAttached() { }
          @Override public void onWindowDetached() {
            delegateOrNull = null;
          }
        });
  }
}

然后你可以包裹所有 OnClickListener 实例:

DetachableClickListener clickListener = wrap(new DialogInterface.OnClickListener() {
  @Override public void onClick(DialogInterface dialog, int which) {
    MyActivity.this.makeCroissants();
  }
});
AlertDialog dialog = new AlertDialog.Builder(this) //
    .setPositiveButton("Baguette", clickListener) //
    .create();
clickListener.clearOnDetach(dialog);
dialog.show();

The Plumber's Fix

在一个常用的基础清晰你的工作者线程:当 Handler 闲置就向它发送空 Message,以确保不会发生 Message 的内存泄漏。

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

在使用库时这个办法很好似用,因为你不能控制开发者在 Dialog 中写的代码。我们在 Picasso 中就用这个办法解决了这个 Bug。

Conclusion

正如我们所见,一个小小的,难以发现的 VM 行为会导致内存泄漏,而这又会导致大量内存被泄漏,最终使 App 崩溃。

感谢 Josh Humphries, Jesse Wilson, Manik Surtani, 和 Wouter Coekaerts 在邮件沟通中帮助我解决了这个 Bug。