Overview
Android apps run in a managed runtime (ART) where memory is garbage-collected. But GC is not free. A pause of even 5–10 ms in the middle of a frame render causes a jank spike. On devices targeting 60 fps, you have a 16.67 ms budget per frame. Understanding how ART allocates and collects memory lets you write code that produces less GC pressure.
ART Heap Regions
ART uses a generational, concurrent, compacting garbage collector. The heap is divided into regions:
┌─────────────────────────────────────────────────────────────┐
│ ART Heap │
├──────────────────────┬──────────────────────────────────────┤
│ Young Generation │ Old Generation │
│ (Allocation space) │ (Tenured / Large object space) │
│ Most new objects │ Long-lived objects; large arrays │
│ allocated here │ (> ~12 KB) go directly here │
└──────────────────────┴──────────────────────────────────────┘
- Young generation — small bump-pointer allocator. Allocation is cheap (just bump a pointer). Collected frequently with a "minor GC".
- Old generation — objects promoted from young when they survive enough collections. Collected less often with a "major GC".
- Large object space — large objects (typically Bitmaps > 12 KB) allocated directly here to avoid copying cost.
- Non-moving space — used by the runtime itself for ART internal structures.
On Android 8+ ART uses the Concurrent Copying (CC) collector for the young generation and Concurrent Mark-Sweep (CMS) or Concurrent Scavenge for old generation.
GC Roots
The GC starts from roots — objects it knows are alive — and traces references:
- Local variables in running methods (stack frames)
- Static fields of loaded classes
- Objects held by JNI global references
- Objects held in
ThreadLocalstorage
Anything not reachable from a root is collected. Holding a reference (even transitively) prevents collection.
Concurrent Mark-Sweep and GC Pauses
ART's CMS GC runs most of its work concurrently with the app's mutator threads. But it still has stop-the-world (STW) pauses:
- Initial mark — brief pause to mark roots.
- Concurrent mark — traces the heap while app runs.
- Remark / final mark — brief pause to handle objects mutated during concurrent mark.
- Concurrent sweep — reclaims dead objects while app runs.
Typical STW pause durations:
- Minor GC: 1–3 ms
- Major GC: 5–15 ms (worst case on old devices: 50+ ms)
A 10 ms GC pause leaves only 6.67 ms for rendering, almost certainly causing a dropped frame.
Frame timeline with GC pause:
│───── 16 ms budget ─────│
│ measure │ layout │ draw │← normal
│── GC pause (10ms) ──│ d │← jank: frame dropped
OutOfMemoryError
OutOfMemoryError is thrown when:
- The heap cannot grow further (limited by
dalvik.vm.heapgrowthlimitper app, typically 128–512 MB on phones) - A large allocation (like a huge Bitmap) fails even after a GC
// Typical OOM stack trace:
// java.lang.OutOfMemoryError: Failed to allocate a 8294400 byte allocation
// with 6291456 free bytes and 6MB until OOM, target footprint 201326592,
// growth limit 201326592
// Check available heap at runtime:
val runtime = Runtime.getRuntime()
val maxMemory = runtime.maxMemory() // hard limit
val totalMemory = runtime.totalMemory() // current heap size
val freeMemory = runtime.freeMemory() // free in current heap
val usedMemory = totalMemory - freeMemory
// Safe image loading check:
fun canLoadBitmap(width: Int, height: Int): Boolean {
val needed = width.toLong() * height * 4 // ARGB_8888
val available = runtime.maxMemory() - (totalMemory - freeMemory)
return needed < available * 0.8 // keep 20% headroom
}
onTrimMemory Levels
The OS calls onTrimMemory() to give your app a chance to reduce its footprint before LMKD kills it:
override fun onTrimMemory(level: Int) {
when (level) {
// Still running (foreground)
ComponentCallbacks2.TRIM_MEMORY_RUNNING_MODERATE -> trimThumbnailCache()
ComponentCallbacks2.TRIM_MEMORY_RUNNING_LOW -> trimAllCaches()
ComponentCallbacks2.TRIM_MEMORY_RUNNING_CRITICAL -> dropEverything()
// Backgrounded
ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN -> releaseBitmapCache()
ComponentCallbacks2.TRIM_MEMORY_BACKGROUND -> releaseSmallCaches()
ComponentCallbacks2.TRIM_MEMORY_MODERATE -> releaseMostCaches()
ComponentCallbacks2.TRIM_MEMORY_COMPLETE -> releaseAllCaches()
}
}
Best Practices to Reduce GC Pressure
1. Avoid object allocation in hot paths
// BAD: allocates a new Paint and RectF on every draw call (60x/sec!)
override fun onDraw(canvas: Canvas) {
val paint = Paint().apply { color = Color.RED }
canvas.drawRect(RectF(0f, 0f, width.toFloat(), height.toFloat()), paint)
}
// GOOD: allocate once in init
private val paint = Paint().apply { color = Color.RED }
private val rect = RectF()
override fun onDraw(canvas: Canvas) {
rect.set(0f, 0f, width.toFloat(), height.toFloat())
canvas.drawRect(rect, paint)
}
2. Use object pools for frequently created/discarded objects
// Kotlin stdlib Pools (from AndroidX):
import androidx.core.util.Pools
class MotionEventPool {
private val pool = Pools.SynchronizedPool<MotionEvent>(10)
fun obtain(): MotionEvent = pool.acquire() ?: MotionEvent.obtain(...)
fun recycle(event: MotionEvent) {
pool.release(event)
}
}
3. Use primitive arrays instead of boxed collections for large data
// BAD for large data sets: each Int is boxed to Integer object (16 bytes each)
val scores: List<Int> = List(100_000) { it }
// GOOD: primitive array, 4 bytes each, no GC overhead
val scores: IntArray = IntArray(100_000) { it }
4. Prefer StringBuilder over string concatenation in loops
// BAD: creates N intermediate String objects
var result = ""
for (item in items) {
result += item.name + ", "
}
// GOOD: single StringBuilder, one final String allocation
val result = buildString {
for (item in items) {
append(item.name)
append(", ")
}
}
5. Use SparseArray instead of HashMap<Int, V>
// BAD: HashMap boxes Int keys to Integer
val map = HashMap<Int, String>()
// GOOD: SparseArray uses primitive int keys
val map = SparseArray<String>()
map.put(42, "hello")
val value = map.get(42)
6. WeakReference for observer/listener caches
class ImageLoader {
private val listeners = mutableListOf<WeakReference<LoadListener>>()
fun addListener(listener: LoadListener) {
listeners.add(WeakReference(listener))
}
private fun notifyListeners(bitmap: Bitmap) {
val iter = listeners.iterator()
while (iter.hasNext()) {
val ref = iter.next().get()
if (ref == null) {
iter.remove() // Clean up dead references
} else {
ref.onLoaded(bitmap)
}
}
}
}
Android Profiler: Heap Dumps
Use Android Studio's Memory Profiler to find memory leaks and excessive allocations:
- Run app in debug mode.
- Open Profiler → Memory.
- Click Dump Java Heap to capture a snapshot.
- In the heap dump, sort classes by Retained Size descending.
- Look for unexpected instances of
Activity,Fragment,Context, or largeBitmaparrays.
For automated leak detection in CI:
// LeakCanary (Square) — add to debugImplementation only:
// debugImplementation 'com.squareup.leakcanary:leakcanary-android:2.x'
// No code changes needed — it hooks into Activity/Fragment lifecycle automatically.
For allocation tracking in a specific code path:
# Record allocations over 5 seconds with Perfetto:
adb shell perfetto \
-c - --txt \
-o /data/misc/perfetto-traces/trace \
<<EOF
buffers: { size_kb: 63488 fill_policy: DISCARD }
data_sources: { config { name: "android.heapprofd"
heapprofd_config { pid: <YOUR_PID> sampling_interval_bytes: 4096 } } }
duration_ms: 5000
EOF
adb pull /data/misc/perfetto-traces/trace ~/Desktop/heap.perfetto
# Open in ui.perfetto.dev
Practical Gotchas
- Bitmap.recycle() is rarely needed on modern Android (API 11+). ART handles Bitmap pixel memory via native heap, but the GC can still collect it. However, calling
recycle()on a Bitmap still in use causesCanvas: trying to use a recycled bitmapcrash. - Static references to Context — the classic leak. A static
Contextfield keeps the Activity alive indefinitely. - Anonymous inner classes hold an implicit reference to the outer class. An anonymous Runnable posted to a Handler with a delay holds the Activity alive for that delay.
- LruCache size in bytes, not count — always specify cache size as a fraction of available memory, not a fixed count.
val cacheSize = (Runtime.getRuntime().maxMemory() / 1024 / 8).toInt() // 1/8 of max heap
val cache = object : LruCache<String, Bitmap>(cacheSize) {
override fun sizeOf(key: String, value: Bitmap): Int = value.byteCount / 1024
}
- GC logs — look for
Explicit concurrent mark sweep GC freedin logcat. Frequent explicit GCs (triggered bySystem.gc()calls from libraries) are a red flag.
Summary
| Topic | Key Takeaway |
|---|---|
| Heap regions | Young gen for short-lived; Large object space for Bitmaps |
| GC pauses | 1–15 ms STW pauses can drop frames on a 16 ms budget |
| OOM | Grow heap limit per app; check with Runtime.maxMemory() |
| onTrimMemory | Clear caches in response; TRIM_MEMORY_UI_HIDDEN is the first background signal |
| Allocation reduction | Avoid allocation in onDraw, use primitives, pools, SparseArray |
| Profiling | Android Studio Memory Profiler for heap dumps; LeakCanary for leak detection |