Diagnosing Android Memory Leaks: Activity Lifecycle and Reference Handling
A diagnostic workflow for Android memory leaks caused by Activity lifecycle mismanagement: recognize the symptoms, identify the reference type, apply the matching fix, and verify with LeakCanary and heap dumps.
04 Aug 2025, 12:24 UTC

Your app runs fine for a few minutes, then scrolling stutters, GC pauses get longer, and eventually it dies with an OutOfMemoryError after the user navigates back and forth between screens. That pattern almost always means one thing: something long-lived is holding a reference to a destroyed Activity, so the garbage collector can never reclaim it. The fix is rarely "use less memory" — it's finding the leaked reference and cutting it at the right lifecycle point.
This guide assumes a Kotlin/Java Android project on a recent Android Gradle Plugin with debug builds available. All profiling steps should be done on debug builds; heap analysis is expensive and misleading on optimized release builds.
Recognizing the condition
A leaked Activity rarely crashes immediately. Instead you see a recognizable progression:
- Heap usage climbs every time you open and close the same screen, and never returns to baseline.
- GC runs more frequently, visible as jank or dropped frames during navigation.
- After enough cycles, the app throws
OutOfMemoryErroreven though no single screen uses much memory.
The underlying mechanism: an Activity is short-lived, but if a static field, singleton, running thread, or registered callback holds a strong reference to it, the entire Activity — including its view hierarchy and bitmaps — stays in memory after onDestroy().
Common causes and how to tell them apart
| Cause | Typical signature | Quick check |
|---|---|---|
| Static field or singleton holding a View/Context | Leak persists across process configuration changes; heap dump shows Activity referenced from a static root | Search codebase for companion object / static fields typed as Context, View, or Activity |
| Unregistered broadcast receiver or observer | One leaked instance per registration; leak trace ends at the system service or event bus | Verify every registerX() has a matching unregisterX() in the symmetric lifecycle method |
| Non-static inner class (Thread, Handler, Runnable) | Leak trace shows an anonymous class holding an implicit reference to the outer Activity | Look for anonymous Thread, Handler, or callbacks declared inside the Activity |
| Callbacks not cleared in onDestroy() | Leak appears only when the async work outlives the screen (network calls, timers) | Check that long-running work is cancelled or its listener nulled in onDestroy() |
Ordered diagnostic checks
1. Confirm the leak with LeakCanary
LeakCanary is the standard tool for this: it watches objects that should be garbage collected after their lifecycle ends and dumps the heap when they aren't. Add it to your debug dependencies (in the app module's build.gradle.kts, requires no extra permissions):
dependencies {
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")
}Check the current version before pasting this into a real project; the 2.x line auto-initializes on debug builds. Then run the app, open and close the suspected Activity five to ten times, and watch for a leak notification. The notification's "leak trace" shows the reference chain from a GC root to the leaked Activity — the topmost app-owned reference in that chain is your culprit.
2. Verify with a heap dump in Android Studio
If you need independent confirmation: open Profiler > Memory in Android Studio, navigate through the screen several times, force GC with the trash-can icon, then capture a heap dump. Search for your Activity class name. If the destroyed Activity shows multiple instances (or any instance after several close cycles plus GC), it's leaked. Select an instance and inspect the "References" view to see what holds it.
3. Audit lifecycle symmetry manually
Tools find leaks; they don't find every latent bug. Grep the suspected screen's code for register, addObserver, addListener, and setCallback. Each call made in onCreate()/onStart() must have a matching removal in onDestroy()/onStop(). Mismatched pairs (register in onStart(), unregister in onPause()) are a common subtle variant.
Fixes tied to findings
Static or singleton references
Never store an Activity or View in a static field. If a singleton needs a Context, use the application context, which lives as long as the process:
object AnalyticsHolder {
private lateinit var appContext: Context
fun init(context: Context) {
// applicationContext, NOT the Activity
appContext = context.applicationContext
}
}Inner classes holding the Activity
Anonymous inner classes and non-static nested classes implicitly capture the outer Activity. In Java, make them static and pass in a WeakReference; in Kotlin, prefer a coroutine scoped to the lifecycle (lifecycleScope), which cancels automatically in onDestroy():
lifecycleScope.launch {
val result = repository.fetchData()
// Cancelled automatically if the Activity is destroyed
updateUi(result)
}Note the limitation: WeakReference only permits collection if nothing else holds a strong reference. It is a safety valve, not a fix for an architectural leak.
Unregistered observers and receivers
Pair every registration with an unregistration in the symmetric callback, or use lifecycle-aware components (LiveData, Flow collected with repeatOnLifecycle) that handle this for you:
override fun onStart() {
super.onStart()
registerReceiver(connectivityReceiver, filter)
}
override fun onStop() {
unregisterReceiver(connectivityReceiver)
super.onStop()
}Verifying the fix
Repeat the reproduction cycle — open and close the screen ten times — with LeakCanary active. No notification and a flat memory curve in the Profiler after forced GC means the leak is closed. A heap dump showing exactly zero (or one, the live) instance of the Activity is the definitive check.
When to escalate
Some leaks aren't in your code. Escalate to a deeper investigation or a bug report when:
- The leak trace's topmost reference is inside the Android framework or a third-party SDK — check the library's issue tracker; some known framework leaks are documented and may have workarounds.
- Memory grows but LeakCanary reports nothing — this may be native allocations (bitmaps, WebView, media codecs), which require the Profiler's native heap view or
adb shell dumpsys meminfoinstead of heap-dump analysis. - The leak only reproduces on specific OEM builds — capture the leak trace and device details; OEM customizations occasionally introduce their own retained references.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.