androidengineers.Book a session

Android Architecture Fundamentals

Memory Management & GC on Android

article25 minHard

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 ThreadLocal storage

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:

  1. Initial mark — brief pause to mark roots.
  2. Concurrent mark — traces the heap while app runs.
  3. Remark / final mark — brief pause to handle objects mutated during concurrent mark.
  4. 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.heapgrowthlimit per 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:

  1. Run app in debug mode.
  2. Open Profiler → Memory.
  3. Click Dump Java Heap to capture a snapshot.
  4. In the heap dump, sort classes by Retained Size descending.
  5. Look for unexpected instances of Activity, Fragment, Context, or large Bitmap arrays.

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 causes Canvas: trying to use a recycled bitmap crash.
  • Static references to Context — the classic leak. A static Context field 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 freed in logcat. Frequent explicit GCs (triggered by System.gc() calls from libraries) are a red flag.

Summary

TopicKey Takeaway
Heap regionsYoung gen for short-lived; Large object space for Bitmaps
GC pauses1–15 ms STW pauses can drop frames on a 16 ms budget
OOMGrow heap limit per app; check with Runtime.maxMemory()
onTrimMemoryClear caches in response; TRIM_MEMORY_UI_HIDDEN is the first background signal
Allocation reductionAvoid allocation in onDraw, use primitives, pools, SparseArray
ProfilingAndroid Studio Memory Profiler for heap dumps; LeakCanary for leak detection

YOUR LEARNING JOURNEY

0 of 177 available lessons completed

Progress saved in this browser. No account needed.
Memory Management & GC on Android | Android System Design | Android Engineers