4

I have essentially the memory leak described here as detected by LeakCanary.

LC stacktrace

I am trying to solve this by using the "Plumber's Fix" in the above post which flushes the message queue with an empty message. The provided code sample flush message queue every time it becomes idle. I only need to flush the queue once, after my dialog gets dismissed:

public class ExampleDialogFragment extends AppCompatDialogFragment {

    public static ExampleDialogFragment newInstance() {
        return new ExampleDialogFragment();
    }

    @NonNull
    @Override
    public Dialog onCreateDialog(final Bundle savedInstanceState) {
        return new AlertDialog.Builder(getContext())
                .setPositiveButton(android.R.string.ok, (dialog, which) -> onClicked())
                .create();
    }

    private void onClicked() {
        if (getTargetFragment() instanceof Callbacks) {
            ((Callbacks) getTargetFragment()).onButtonClicked();
            flushStackLocalLeaks();
        }
    }

    private static void flushStackLocalLeaks() {
        final Handler handler = new Handler(Looper.myLooper());
        handler.post(() -> Looper.myQueue().addIdleHandler(() -> {
            Timber.d("Flushing on thread %s", Thread.currentThread().getName());
            handler.sendMessageDelayed(handler.obtainMessage(), 1000);
            return false; // we only want to flush once, not *every* 1000 mSec
        }));
    }

    public interface Callbacks {
        void onButtonClicked();
    }
}

The problem I'm facing is that in the LeakCanary report, the HandlerThread at the root of the leak is never the thread I expect. Sometimes it's a ConnectivityThread, sometimes it's a HandlerThread, where mName = "queued-work-looper", other it's a HandlerThread used by my analytics library. In none of these cases is it the main thread. I would expect the message to be leaking from the main thread. So

  1. why is this leak happening on a HandlerThread/ConnectivityThread other than the main thread?
  2. how do I know which HandlerThread needs flushing?
tir38
  • 9,810
  • 10
  • 64
  • 107
  • Were you able to find a fix? The closest thing I found was a workaround by material-dialog framework, but they use reflection and I'm not sure if those APIs will get greylisted so prefer not to do that fix. https://github.com/afollestad/material-dialogs/issues/513 – mliu Sep 29 '19 at 06:52
  • Have asked [@piwai](https://twitter.com/_azizbekian/status/1229807384224292865) to have a look. – azizbekian Feb 18 '20 at 16:39

1 Answers1

2

I filed the issue here in December: https://issuetracker.google.com/issues/146144484 .

The core problem is that every idle HandlerThread retains their last recycled Message.

Our current fix is to find new handler threads over time and set up a recurring flush:

import android.os.Handler
import android.os.HandlerThread
import android.os.Looper
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit.SECONDS

object AndroidLeaks {

  /**
   * HandlerThread instances keep local reference to their last handled message after recycling it.
   * That message is obtained by a dialog which sets on an OnClickListener on it and then never
   * recycles it, expecting it to be garbage collected but it ends up being held by the HandlerThread.
   */
  fun flushHandlerThreads() {
    val executor = Executors.newSingleThreadScheduledExecutor()

    val flushedThreadIds = mutableSetOf<Int>()
    // Wait 2 seconds then look for handler threads every 3 seconds.
    executor.scheduleWithFixedDelay({
      val newHandlerThreadsById = findAllHandlerThreads()
          .mapNotNull { thread ->
            val threadId = thread.threadId
            if (threadId == -1 || threadId in flushedThreadIds) {
              null
            } else {
              threadId to thread
            }
          }
      flushedThreadIds += newHandlerThreadsById.map { it.first }
      newHandlerThreadsById
          .map { it.second }
          .forEach { handlerThread ->
            var scheduleFlush = true
            val flushHandler = Handler(handlerThread.looper)
            flushHandler.onEachIdle {
              if (scheduleFlush) {
                scheduleFlush = false
                // When the Handler thread becomes idle, we post a message to force it to move.
                // Source: https://developer.squareup.com/blog/a-small-leak-will-sink-a-great-ship/
                try {
                  flushHandler.postDelayed({
                    // Right after this postDelayed executes, the idle handler will likely be called
                    // again (if the queue is otherwise empty), so we'll need to schedule a flush
                    // again.
                    scheduleFlush = true
                  }, 1000)
                } catch (ignored: RuntimeException) {
                  // If the thread is quitting, posting to it will throw. There is no safe and atomic way
                  // to check if a thread is quitting first then post it it.
                }
              }
            }
          }
    }, 2, 3, SECONDS)
  }

  private fun Handler.onEachIdle(onIdle: () -> Unit) {
    try {
      // Unfortunately Looper.getQueue() is API 23. Looper.myQueue() is API 1.
      // So we have to post to the handler thread to be able to obtain the queue for that
      // thread from within that thread.
      post {
        Looper
            .myQueue()
            .addIdleHandler {
              onIdle()
              true
            }
      }
    } catch (ignored: RuntimeException) {
      // If the thread is quitting, posting to it will throw. There is no safe and atomic way
      // to check if a thread is quitting first then post it it.
    }
  }

  private fun findAllHandlerThreads(): List<HandlerThread> {
    // Based on https://stackoverflow.com/a/1323480
    var rootGroup = Thread.currentThread().threadGroup!!
    while (rootGroup.parent != null) rootGroup = rootGroup.parent
    var threads = arrayOfNulls<Thread>(rootGroup.activeCount())
    while (rootGroup.enumerate(threads, true) == threads.size) {
      threads = arrayOfNulls(threads.size * 2)
    }
    return threads.mapNotNull { if (it is HandlerThread) it else null }
  }
}
Pierre-Yves Ricau
  • 8,209
  • 2
  • 27
  • 43