Android memory profiler: native heap, GC pressure & OOM budgets
Meta description: Deep dive into Android memory profiling — native heap fragmentation with jemalloc/scudo, ART GC pause attribution, and RSS ceilings for 3–4GB mid-range devices with real thresholds.
Tags: android mobile architecture kotlin backend
TL;DR
Mid-range Android devices (3–4GB RAM) run your app inside a 200–300MB RSS ceiling. Blow past it and the LMK (Low Memory Killer) terminates your process — silently, no crash report filed. Fixing it means profiling three distinct layers at once: native allocator fragmentation, ART garbage collector pause attribution, and your per-feature allocation budget. Here’s the toolchain and the thresholds that actually matter in production.
The memory stack most teams only half-profile
Most teams open Android Studio’s Memory Profiler, watch the Java heap, and call it a day. That covers maybe 40% of your actual RSS footprint on a typical production app.
The full picture has three layers:
| Layer | Profiling tool | Common failure mode |
|---|---|---|
| Java/Kotlin heap | AS Memory Profiler, MAT | Retained object graphs, leaked contexts |
| ART GC pauses | Perfetto, systrace | Concurrent GC can’t keep up with allocation rate |
| Native heap (jemalloc/scudo) | heapprofd, malloc_debug | Fragmentation inflating RSS without visible heap growth |
Each layer has failure modes the others can mask. That’s what makes this hard.
Native heap fragmentation: the silent RSS killer
Android 10+ ships scudo as its hardened allocator (replacing jemalloc on most OEM builds). Scudo reduces security vulnerabilities but has a different fragmentation profile than jemalloc — chunk size classes are more granular, which means mixed-lifetime allocations from image decoding pipelines or OpenGL buffer uploads create Swiss-cheese-shaped arenas that inflate RSS without inflating your reported native heap size.
Use heapprofd to capture a native allocation trace:
adb shell setprop persist.heapprofd.enable 1
adb shell am profile start --sampling 4096 com.yourapp
# reproduce the scenario
adb shell am profile stop com.yourapp
adb pull /data/misc/profiles/ref/com.yourapp/heap.pb
Feed the result into Perfetto UI and look for RSS vs. native heap delta exceeding 20%. That gap is fragmentation overhead. Anything above 15% sustained fragmentation on a 3GB device is worth triaging — it directly compresses the space available to other processes and raises LMK pressure.
ART GC pause attribution: where your frame budget goes
A concurrent GC cycle on ART takes 2–5ms under normal load. A GC triggered by allocation pressure — where the mutator must pause to let collection catch up — can exceed 16ms. That’s your entire frame budget at 60fps, gone.
Profile GC attribution in Perfetto by filtering for HeapTaskDaemon and GCDaemon threads:
// Kotlin side: flag high-frequency allocation sites
class ImageThumbnailLoader {
// BAD: creates a new ByteBuffer per frame
fun decode(bytes: ByteArray): Bitmap =
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)
// BETTER: reuse a pooled buffer via BitmapFactory.Options
private val opts = BitmapFactory.Options().apply {
inMutable = true
inBitmap = Bitmap.createBitmap(256, 256, Bitmap.Config.ARGB_8888)
}
fun decodeCached(bytes: ByteArray): Bitmap =
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)
}
The threshold I use in production: if your Perfetto trace shows GC-attributed pause time exceeding 8ms per second of wall time, your allocation rate is outpacing the concurrent collector. That’s when you need object pooling, not heap size tuning.
The allocation budget for 3–4GB devices
On a device with 3GB physical RAM, available userspace is typically 2.4–2.6GB after kernel and vendor partitions. Android’s LMK starts evicting cached processes around 85% memory pressure. Your foreground app budget:
| Device RAM | Typical app RSS ceiling | Native heap budget | Java heap budget |
|---|---|---|---|
| 3 GB | ~220 MB | ~80 MB | ~100 MB |
| 4 GB | ~280 MB | ~110 MB | ~130 MB |
| 6 GB+ | ~380 MB | ~150 MB | ~180 MB |
These aren’t hard limits. They’re the thresholds beyond which your OOM kill rate in production starts climbing. I derive them from /proc/<pid>/smaps rolled up via adb shell dumpsys meminfo against the Pss Total column, sampled at steady state after 10 minutes of typical use.
Three things worth doing before you ship
-
Profile RSS, not just heap size. Add a
dumpsys meminfosnapshot to your CI pipeline gated on Pss Total. Catch native fragmentation regressions before they reach users. -
Set a GC pause budget of 8ms/sec. Instrument your Perfetto traces with this threshold. When you exceed it, audit high-frequency allocation sites and apply object pooling — especially in rendering and decode paths.
-
Segment your allocation budget by device tier. 3GB devices are still a meaningful share of your install base in most markets. Run your profiling scenarios explicitly on a 3GB reference device, not just flagship hardware, and enforce the RSS ceilings in the table above as pre-release gates.