How Garbage Collection Impacts Android Performance

Dhruval Dhameliya·December 20, 2025·9 min read

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 FinalizerDaemon thread 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:

GenerationWhat Lives HereCollection FrequencyPause Duration
Young (nursery)Recently allocated objectsVery frequent (milliseconds)Sub-millisecond
Old (tenured)Objects that survived young-gen collectionsLess frequent1 to 5ms
Large object spaceObjects > 12KB (arrays, bitmaps)Collected with old genVariable

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:

  1. Choreographer posts a frame callback
  2. Main thread runs composition/layout/draw (or measure/layout/draw for Views)
  3. RenderThread processes draw commands
  4. 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:

PatternAllocation CostExample
String concatenation in loopsNew String per iteration"Item: " + item.name + " (" + item.count + ")"
Autoboxing primitivesNew Integer/Float/Boolean per boxMap<String, Int> triggers boxing
Lambda captures in tight loopsNew closure object per iterationlist.map { it.transform() } in per-frame code
Iterator allocationNew Iterator per for loop on collectionsfor (item in hashMap) allocates iterator
VarargsNew Array per callString.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.timeMs

Perfetto 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 allocation

Trade-offs

StrategyBenefitCost
Object poolingEliminates per-frame allocationsPool management complexity, potential memory retention
Primitive specializationRemoves autoboxing overheadLess idiomatic Kotlin, limited collection API
Sequence-based pipelinesReduces intermediate allocationsOverhead for small collections (< 10 items)
Pre-sized collectionsAvoids array resizing and copyingMust estimate size accurately

Failure Modes

FailureSymptomMitigation
GC pause during frame renderingPeriodic 1 to 3 frame drops, not correlated with UI complexityReduce allocation rate in per-frame code
Full GC during startupCold start delay of 50 to 200msMinimize allocations in Application.onCreate()
Finalizer queue backupObjects with finalizers stay alive longer than expectedAvoid finalizers, use Cleaner API or explicit close()
Large object space thrashingFrequent GC triggered by large array allocationsReuse large buffers, pool byte arrays
GC on background thread blocking main threadConcurrent GC phases still acquire brief locksCannot 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 art tag 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

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