DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on Originally published at mvpfactory.io

Android Memory Profiler Deep Dive

---
title: "Android Memory Profiler Deep Dive: Native Heap Fragmentation, GC Pressure, and the RSS Budget That Prevents OOM"
published: true
description: "Profile all three memory layers on Android — native allocator fragmentation (jemalloc/scudo), ART GC pause attribution, and per-process RSS ceilings — with actionable thresholds for 3–4GB mid-range devices."
tags: android, kotlin, mobile, performance
canonical_url: https://blog.mvp-factory.dev/android-memory-profiler-native-heap-gc-pressure-oom-budgets
---
Enter fullscreen mode Exit fullscreen mode

What you will learn

Mid-range Android devices (3–4GB RAM) give your app a 200–300MB RSS ceiling. Cross it and the Low Memory Killer terminates your process — silently, with no crash report filed. By the end of this guide you will know how to profile all three memory layers simultaneously, read the numbers that actually matter in production, and set pre-release gates that catch regressions before your users do.


Prerequisites

  • Android Studio with Memory Profiler enabled
  • adb on your PATH
  • A Perfetto trace setup (perfetto.dev)
  • A 3GB reference device (not a flagship — this matters)

The memory stack most teams only half-profile

Here is the pattern I use in every project: profile all three layers, not just the one Android Studio shows you by default.

Opening the AS Memory Profiler, watching the Java heap, and calling it done covers maybe 40% of your actual RSS footprint. The full picture looks like this:

Layer 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 Android memory hard.


Step 1 — Capture native heap fragmentation with heapprofd

Android 10+ ships scudo as its hardened allocator (replacing jemalloc on most OEM builds). Scudo has a more granular chunk size class profile than jemalloc — mixed-lifetime allocations from image decoding or OpenGL buffer uploads create fragmented arenas that inflate RSS without inflating your reported native heap size.

Here is the minimal setup to get this working:

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
Enter fullscreen mode Exit fullscreen mode

Feed heap.pb into Perfetto UI. Look for RSS vs. native heap delta. 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.


Step 2 — Attribute GC pauses in Perfetto

A concurrent GC cycle on ART takes 2–5ms under normal load. One triggered by allocation pressure — where the mutator pauses to let collection catch up — can exceed 16ms. That is your entire frame budget at 60fps, gone.

Filter your Perfetto trace for HeapTaskDaemon and GCDaemon threads. Then audit your 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)
}
Enter fullscreen mode Exit fullscreen mode

The threshold I use in production: if GC-attributed pause time exceeds 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.


Step 3 — Set your RSS ceiling by device tier

Derive your budgets from /proc/<pid>/smaps via adb shell dumpsys meminfo, reading the Pss Total column at steady state after 10 minutes of typical use:

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 are the thresholds beyond which your OOM kill rate in production starts climbing.


Gotchas

The docs do not mention this, but scudo's fragmentation profile differs from jemalloc in ways that trip up teams migrating from Android 9. Do not assume a clean native heap report means clean RSS.

Running only on flagship hardware is a trap. 3GB devices are a meaningful share of most install bases. Profile explicitly on your 3GB reference device.

RSS ≠ heap size. Watching the heap graph in Android Studio while missing fragmentation overhead is the most common profiling mistake I see on production apps.


Conclusion

Profile RSS, not just heap size — add a dumpsys meminfo snapshot to your CI pipeline gated on Pss Total. Set a GC pause budget of 8ms/sec and enforce it with Perfetto. Segment your allocation budget by device tier and use the table above as pre-release gates.

The LMK does not file a crash report. You will only know it is killing your app if you go looking.

Top comments (0)