How Garbage Collection Impacts Android Performance
A detailed look at ART's garbage collection mechanisms, how GC pauses affect frame rates, and practical strategies to minimize GC impact in production Android apps.
Context
Android uses the Android Runtime (ART) with a generational, concurrent garbage collector. While modern ART GC is significantly better than Dalvik's stop-the-world collector, GC activity still directly impacts frame timing, startup latency, and UI responsiveness. Understanding the GC's behavior is necessary for writing performance-critical Android code.
Problem
GC pauses, even short ones, can push frame rendering past the 16.67ms deadline. A 2ms GC pause is invisible in isolation but devastating when it occurs mid-frame alongside 14ms of composition and layout work. The problem compounds on low-end devices where GC runs slower, the heap is smaller, and collection happens more frequently.
Constraints
- ART uses a concurrent copying collector (CC) on Android 10 and above
- Young generation collections are fast (sub-millisecond) but frequent
- Full heap collections can pause the mutator for 1 to 5ms
- GC frequency increases with allocation rate, not heap size
- Finalizers run on a dedicated
FinalizerDaemonthread and can delay object reclamation - Native memory (bitmaps, buffers) adds GC pressure through reference tracking even though it is not on the managed heap
Related: Memory Allocation Patterns That Hurt Performance.
Design
ART's Garbage Collector Architecture
ART (Android 10+) uses a concurrent copying collector with generational support:
| Generation | What Lives Here | Collection Frequency | Pause Duration |
|---|---|---|---|
| Young (nursery) | Recently allocated objects | Very frequent (milliseconds) | Sub-millisecond |
| Old (tenured) | Objects that survived young-gen collections | Less frequent | 1 to 5ms |
| Large object space | Objects > 12KB (arrays, bitmaps) | Collected with old gen | Variable |
The collector works concurrently with application threads for most of its work, but requires short pauses for root scanning and reference updating.
How GC Interacts with Frame Rendering
The rendering pipeline on Android:
- Choreographer posts a frame callback
- Main thread runs composition/layout/draw (or measure/layout/draw for Views)
- RenderThread processes draw commands
- SurfaceFlinger composites and displays
A GC pause on the main thread during step 2 directly steals from the frame budget. A GC pause on the RenderThread during step 3 delays frame submission. Both cause jank.
Frame budget: |-------- 16.67ms --------|
Without GC: |--Compose--Layout--Draw--|
With GC: |--Compose--GC(2ms)--Layout--Draw--| <- exceeds deadline
Allocation Rate: The Key Metric
GC frequency is driven by allocation rate, not total heap usage. An app that allocates and discards 50MB/s of short-lived objects triggers far more GC than an app holding a stable 200MB heap.
Common high-allocation patterns:
| Pattern | Allocation Cost | Example |
|---|---|---|
| String concatenation in loops | New String per iteration | "Item: " + item.name + " (" + item.count + ")" |
| Autoboxing primitives | New Integer/Float/Boolean per box | Map<String, Int> triggers boxing |
| Lambda captures in tight loops | New closure object per iteration | list.map { it.transform() } in per-frame code |
| Iterator allocation | New Iterator per for loop on collections | for (item in hashMap) allocates iterator |
| Varargs | New Array per call | String.format(...), listOf(...) |
Measuring GC Impact
Logcat GC Messages
ART logs GC events with timing:
See also: Event Tracking System Design for Android Applications.
// Format: GC reason, collector, objects freed, bytes freed, pause time
I/art: Concurrent copying GC freed 12500(500KB) AllocSpace objects,
5(100KB) LOS objects, 25% free, 15MB/20MB, paused 1.2ms total 15ms
Key fields:
- Concurrent copying GC: the collector type
- paused 1.2ms: actual pause duration (your frame budget loss)
- total 15ms: total GC wall time including concurrent phases
Programmatic GC Monitoring
class GcMonitor {
private val gcBeans = ManagementFactory.getGarbageCollectorMXBeans()
fun snapshot(): GcSnapshot {
var totalCollections = 0L
var totalTimeMs = 0L
gcBeans.forEach { bean ->
totalCollections += bean.collectionCount
totalTimeMs += bean.collectionTime
}
return GcSnapshot(totalCollections, totalTimeMs)
}
data class GcSnapshot(val collections: Long, val timeMs: Long)
}
// Measure GC during a specific operation
val before = gcMonitor.snapshot()
performExpensiveOperation()
val after = gcMonitor.snapshot()
val gcsDuringOp = after.collections - before.collections
val gcTimeDuringOp = after.timeMs - before.timeMsPerfetto Traces
GC events appear as slices in Perfetto traces on the GC daemon threads. Filter for art::gc events to see pause durations and heap transitions.
Reducing GC Pressure
Strategy 1: Object Pooling for Hot Paths
For objects created and discarded every frame (e.g., Rect, Point, Matrix), use object pools.
class RectPool(private val maxSize: Int = 20) {
private val pool = ArrayDeque<Rect>(maxSize)
fun acquire(): Rect = pool.removeLastOrNull() ?: Rect()
fun release(rect: Rect) {
rect.setEmpty()
if (pool.size < maxSize) {
pool.addLast(rect)
}
}
inline fun <R> withRect(block: (Rect) -> R): R {
val rect = acquire()
try {
return block(rect)
} finally {
release(rect)
}
}
}Strategy 2: Avoid Allocations in Per-Frame Code
// Bad: allocates on every frame
@Composable
fun AnimatedProgress(progress: Float) {
Canvas(modifier = Modifier.fillMaxWidth().height(4.dp)) {
val rect = Rect(0f, 0f, size.width * progress, size.height) // New Rect each frame
drawRect(color = Color.Blue, topLeft = rect.topLeft, size = rect.size)
}
}
// Good: no allocation
@Composable
fun AnimatedProgress(progress: Float) {
Canvas(modifier = Modifier.fillMaxWidth().height(4.dp)) {
drawRect(
color = Color.Blue,
size = Size(size.width * progress, size.height)
)
}
}Strategy 3: Use Primitives Over Boxed Types
// Bad: autoboxing in collection
val scores: Map<String, Int> = mapOf("a" to 1, "b" to 2)
// Every Int is boxed to java.lang.Integer
// Better: use Android's SparseArray or primitive-specialized collections
val scores = ArrayMap<String, Int>() // Still boxes, but lower overhead
// Best for int keys: SparseIntArray (no boxing at all)
val indexedScores = SparseIntArray()
indexedScores.put(0, 100)
indexedScores.put(1, 200)Strategy 4: StringBuilder for String Construction
// Bad: N allocations for N concatenations
fun buildLabel(items: List<Item>): String {
var result = ""
items.forEach { result += "${it.name}: ${it.value}\n" } // New String each iteration
return result
}
// Good: single StringBuilder, one final String
fun buildLabel(items: List<Item>): String = buildString {
items.forEach { item ->
append(item.name).append(": ").append(item.value).appendLine()
}
}Strategy 5: Avoid Unnecessary Intermediate Collections
// Bad: creates 3 intermediate lists
val result = items
.filter { it.isActive } // New List
.map { it.toDisplayModel() } // New List
.sortedBy { it.name } // New List
// Better: use sequences for lazy evaluation (single pass, minimal allocation)
val result = items.asSequence()
.filter { it.isActive }
.map { it.toDisplayModel() }
.sortedBy { it.name }
.toList() // Single terminal allocationTrade-offs
| Strategy | Benefit | Cost |
|---|---|---|
| Object pooling | Eliminates per-frame allocations | Pool management complexity, potential memory retention |
| Primitive specialization | Removes autoboxing overhead | Less idiomatic Kotlin, limited collection API |
| Sequence-based pipelines | Reduces intermediate allocations | Overhead for small collections (< 10 items) |
| Pre-sized collections | Avoids array resizing and copying | Must estimate size accurately |
Failure Modes
| Failure | Symptom | Mitigation |
|---|---|---|
| GC pause during frame rendering | Periodic 1 to 3 frame drops, not correlated with UI complexity | Reduce allocation rate in per-frame code |
| Full GC during startup | Cold start delay of 50 to 200ms | Minimize allocations in Application.onCreate() |
| Finalizer queue backup | Objects with finalizers stay alive longer than expected | Avoid finalizers, use Cleaner API or explicit close() |
| Large object space thrashing | Frequent GC triggered by large array allocations | Reuse large buffers, pool byte arrays |
| GC on background thread blocking main thread | Concurrent GC phases still acquire brief locks | Cannot control directly, reduce overall allocation rate |
Scaling Considerations
- Device segmentation: GC impact varies dramatically by device tier. Budget devices have smaller heaps (128 to 256MB) and slower GC. Profile on representative low-end hardware
- Large heap flag:
android:largeHeap="true"increases the heap ceiling but does not reduce GC frequency. It delays OOM but increases full-GC duration - Native memory: bitmaps (since Android 8.0) are allocated on the native heap but tracked by the GC. Excessive bitmap allocations still trigger GC even though the managed heap looks small
- Allocation tracking in CI: use Android Studio allocation tracker or
Debug.startAllocCounting()in automated tests to catch allocation regressions
Observability
- ART GC logs: filter logcat for
arttag to monitor GC frequency and pause durations Debug.getRuntimeStat("art.gc.*"): programmatic access to GC statistics- Perfetto: GC slices on daemon threads, correlated with main thread frame timing
- Production metrics: track total GC time per session, GC count per minute, and correlate with jank rate
fun reportGcStats(analytics: Analytics) {
val stats = mapOf(
"gc.count" to Debug.getRuntimeStat("art.gc.gc-count"),
"gc.time_ms" to Debug.getRuntimeStat("art.gc.gc-time"),
"gc.bytes_freed" to Debug.getRuntimeStat("art.gc.bytes-freed"),
"gc.blocking_count" to Debug.getRuntimeStat("art.gc.blocking-gc-count"),
"gc.blocking_time_ms" to Debug.getRuntimeStat("art.gc.blocking-gc-time")
)
analytics.track("gc_stats", stats)
}Key Takeaways
- GC frequency is driven by allocation rate, not heap size. Reducing allocations is more effective than increasing the heap
- ART's concurrent collector is good but not invisible. A 2ms pause in a 16.67ms frame budget is a 12% tax
- Per-frame code (draw callbacks, animation tick handlers, scroll listeners) must be allocation-free or allocation-minimal
- Autoboxing, string concatenation, iterator allocation, and intermediate collection creation are the most common hidden allocation sources
- Profile GC impact on low-end devices. A Pixel flagship masks GC costs that budget hardware exposes
Further Reading
- Debugging Performance Issues in Large Android Apps: A systematic approach to identifying, isolating, and fixing performance bottlenecks in large Android codebases, covering profiling strate...
- How I Profile Android Apps in Production: Techniques for collecting meaningful performance data from production Android apps without degrading user experience, covering sampling s...
- Memory Leaks in Android: Patterns I've Seen in Production: Real-world memory leak patterns from production Android apps, covering lifecycle-bound leaks, static references, listener registration, a...
Final Thoughts
Garbage collection on Android is a managed cost, not a free lunch. Modern ART is remarkably efficient, but efficiency has limits when your code allocates aggressively. The discipline is simple: measure allocation rate in hot paths, eliminate unnecessary allocations, pool objects that must be allocated per-frame, and verify on low-end hardware. Treat GC pauses as a frame budget line item, not an unpredictable external event.
Recommended
Jetpack Compose Recomposition: A Deep Dive
A detailed look at how Compose recomposition works under the hood, what triggers it, how the slot table tracks state, and how to control it in production apps.
Event Tracking System Design for Android Applications
A systems-level breakdown of designing an event tracking system for Android, covering batching, schema enforcement, local persistence, and delivery guarantees.
Understanding ANRs: Detection, Root Causes, and Fixes
A systematic look at Application Not Responding errors on Android, covering the detection mechanism, common root causes in production, and concrete strategies to fix and prevent them.