ART memory — heap, garbage collection, and leaks
Garbage collection solves reclamation, not ownership
Managed objects are reclaimed when the runtime determines they are no longer reachable from live roots. You do not manually free() ordinary Kotlin objects.
GC root → Application singleton → listener → destroyed Activity
The Activity is still reachable, so GC correctly keeps it. That is a memory leak: the program unintentionally retains an object beyond its useful lifetime.
Reachable versus useful
object EventHub {
private val listeners = mutableListOf<(Event) -> Unit>()
}
class CheckoutActivity : AppCompatActivity() {
override fun onCreate(state: Bundle?) {
super.onCreate(state)
EventHub.register { event -> render(event) }
}
}
The lambda captures the Activity. If it is never removed, a process-lifetime singleton retains the destroyed screen.
✅ Lifecycle-owned registration
override fun onStart() {
super.onStart()
subscription = eventHub.subscribe(::render)
}
override fun onStop() {
subscription.dispose()
super.onStop()
}
Flow plus lifecycle-aware collection can encode this ownership more safely, but any upstream scope/reference can still leak if designed incorrectly.
Common Android leak paths
- singleton/static field → Activity/View/Fragment;
- long-running coroutine in an application scope captures a screen;
- callback registered but never unregistered;
- Fragment binding retained after
onDestroyView(); - Handler/Runnable queued longer than screen lifetime;
- WebView, animation, or drawable callback retains context/view;
- unbounded cache retains large values;
- native/JNI allocation is not released.
Application context is not a universal fix
Use application context for dependencies that genuinely need process lifetime and do not require an Activity theme/window.
class Analytics(context: Context) {
private val appContext = context.applicationContext
}
Do not replace Activity context with application context when showing dialogs, inflating themed UI, or accessing window-scoped behaviour. Fix ownership rather than blindly changing context.
Allocation pressure and jank
Even objects that are not leaked can hurt performance if a hot loop allocates heavily. More allocations create more GC work and can contribute to missed frames.
❌ Allocation during every draw/recomposition
fun render(items: List<Item>) {
val formatter = DecimalFormat("#,##0.00")
items.forEach { draw(formatter.format(it.price)) }
}
✅ Reuse appropriate immutable/configured helpers
class PriceRenderer(
private val formatter: NumberFormat
) {
fun render(items: List<Item>) { /* use formatter with thread ownership */ }
}
Be careful: some formatter implementations are mutable/not thread-safe. Reuse must respect threading.
Large bitmaps
A 4,000 × 3,000 image at 4 bytes per pixel is roughly 48 MB uncompressed, regardless of a much smaller compressed JPEG file size.
width × height × bytes-per-pixel
4000 × 3000 × 4 ≈ 48,000,000 bytes
Load images at the display size using a proven image library, bound caches, and release references with lifecycle-aware requests.
Memory cache design
A cache deliberately retains objects, so it must define:
- maximum size/cost;
- eviction policy;
- key identity and invalidation;
- response to memory pressure;
- process-death behaviour;
- threading.
An unbounded mutableMapOf is not a production cache.
Diagnose with evidence
- Reproduce navigation/allocation pattern.
- Observe memory trend and GC, but do not conclude from one graph.
- Use LeakCanary for retained-object paths during development.
- Capture heap dump/profile when needed.
- Find the GC-root reference chain.
- Fix ownership/lifetime.
- Repeat the scenario and verify release.
System.gc() is not a leak fix
❌ Avoid
System.gc()
Requesting collection cannot reclaim reachable leaked objects and can disrupt performance. Remove the retaining reference.
ART garbage collection reclaims unreachable managed objects. A leak remains reachable through an unintended reference chain, often from a longer-lived singleton, coroutine, callback, or Fragment to a shorter-lived Activity/View. I fix lifecycle ownership, bound caches and image sizes, avoid allocation-heavy hot paths, and use heap/LeakCanary evidence to identify the GC-root path rather than calling System.gc().
Exercises
- Calculate approximate memory for a 1440 × 3200 ARGB bitmap.
- Draw the reference chain for a Fragment binding leak.
- Explain why an application context can still participate in a leak.