Zygote
Memory, lifecycle & powernerd

Memory accounting

RSS, PSS, USS, private dirty and the shared Zygote pages: why the number in dumpsys meminfo is smaller than the sum of what the process mapped, and which number actually matters.

12 minAPI 1–37 · 17interactivequizverified 2026-10-27

"Your app uses 300 MB" is a sentence that is almost always wrong. A process maps far more memory than it owns, and much of what it maps is shared with every other app on the phone. Accounting for memory means deciding which of several defensible numbers you actually mean.

TL;DR#

  • RSS counts every mapped page once per process, so it double-counts shared pages.
  • PSS divides each shared page by the number of processes mapping it; the totals of all processes are meaningful.
  • USS counts only pages private to the process — the memory that actually disappears if it is killed.
  • Private dirty is the part that must be written to swap or freed; private clean can be dropped and re-read.
  • The Zygote's preloaded classes and the framework's shared libraries appear in every process but cost memory once.

Mental model#

Think of a shared office with a photocopier.

The copier is a shared library: every department uses it, but the building owns one.

If you ask each department "how much equipment do you use?", and each counts the copier, the total is nonsense. That is RSS.

If you split the copier's cost across the departments that use it, the totals add up. That is PSS.

If you ask "what would be freed if this department left?", the copier is not in the answer unless they were the only user. That is USS.

Where the analogy breaks#

Pages move between shared and private over time. A copy-on-write page starts shared and becomes private the moment a process writes to it — which is exactly what happens to Zygote's preloaded classes and to any forked process that modifies inherited state. Accounting is a snapshot, and the snapshot changes under you.

The big picture#

Loading diagram…
Text description of this diagram

An annotated dumpsys meminfo block for one app. Each row shows PSS, private dirty and private clean: Java heap, native heap, code, stack, graphics, .so mmap and .dex mmap, ending in a total. Selecting a row explains what it counts. The total is much smaller than the apparent sum of the process's mappings because shared pages are divided among all processes that map them.

Read the last row against the rows above it. The total is not the sum of what the process has mapped; it is the sum after shared pages are divided.

Deep dive#

The five numbers#

For a single memory region:

  • RSS (Resident Set Size): pages currently in physical memory. Counts shared pages fully.
  • PSS (Proportional Set Size): shared pages divided by the number of sharers, plus private pages.
  • USS (Unique Set Size): pages private to this process only.
  • Private dirty: private pages that have been modified and must be written out or discarded.
  • Private clean: private pages that match their backing store and can be dropped for free.

The relationships:

USS ≤ PSS ≤ RSS
PSS ≈ USS + (shared pages / number of sharers)

Why PSS is the number for "who is using memory"#

If you sum PSS across all processes, you get approximately the total physical memory in use. That property is what makes PSS the right measure for a system-wide view, and it is why dumpsys meminfo reports it.1

If you sum RSS, you overcount by every shared library, every shared font, and every page of the Zygote's preloaded classes — which on a phone is a substantial fraction of the total.

Why USS is the number for "what happens if I kill it"#

Killing a process frees its private memory and decrements the share count on its shared pages. If it was the last mapper of a shared library, that library is freed too; if not, nothing happens to it.

So the honest answer to "how much memory will killing this app free?" is USS plus whatever shared pages become unshared. That is why the OOM killer's decisions are about marginal memory, not total.

The Zygote economy#

Every app process is forked from Zygote, which has already loaded the framework classes, the resources and the common native libraries. Because the fork is copy-on-write, those pages are shared until a process writes to them.

The effect on accounting is large: a freshly forked app process may show a PSS far smaller than the code it appears to be running, because most of that code is shared with every other app. It also means the marginal cost of an extra app process is much lower than the cost of the first one.2

The categories in meminfo#

CategoryWhat it isBehaviour
Java heapobjects allocated by managed codegrows with allocation, shrinks on GC
Native heapmalloc allocations from C/C++invisible to GC; leaks persist
Codeexecutable mappings (oat, .so)mostly clean, pageable
Stackthread stackssmall per thread, many threads add up
GraphicsGPU buffers, surfacesoften large and mostly shared
.so mmapshared librariesclean and pageable
.dex mmapDEX/VDEX datashared across processes

The rows people misread are Code and .dex mmap: these look large but are mostly clean and shared, so they are cheap. The rows that matter for a leak hunt are Java heap and Native heap — those are the ones that grow.

onTrimMemory and the memory callback#

The system tells an app when memory is getting tight, at several levels. ComponentCallbacks2.onTrimMemory receives a level such as TRIM_MEMORY_RUNNING_LOW, TRIM_MEMORY_UI_HIDDEN or TRIM_MEMORY_COMPLETE. The app is expected to drop caches and non-essential allocations at the appropriate level.3

This is cooperative, not enforced: an app that ignores it will simply be killed sooner. But an app that responds well — dropping image caches when the UI is hidden — reduces both its kill probability and its restart cost.

Leaks and the tools#

A leak in Android is usually a reference held longer than intended: a static field holding an Activity, a listener never unregistered, a coroutine scope that outlives its screen.

The tools:

  • LeakCanary in debug builds, which watches for destroyed Activities that are still reachable.
  • Heap dumps through dumpsys meminfo --unreachable or the profiler, then analysis in a heap viewer.
  • dumpsys meminfo <pkg> over time, watching the Java heap grow without shrinking after GC.

Android's memory classes#

ActivityManager.getMemoryClass() reports the heap limit for the app's process — the point at which an allocation fails with OutOfMemoryError. getLargeMemoryClass() reports the larger limit available when the app declares android:largeHeap="true".

These limits are per-process heap limits, not the device's RAM. A device with 8 GB still gives a normal app a heap limit measured in hundreds of megabytes.

Why the numbers do not add up#

Run dumpsys meminfo on a phone and sum the processes' PSS totals. The result is close to total RAM, which is the property that makes PSS useful. Now sum their RSS instead and the result will exceed the phone's memory — a demonstration that RSS counts shared pages repeatedly.

Neither number is "the truth". The right one depends on the question: PSS for "how is memory distributed", USS for "what would be freed", RSS for "what is resident".

Native memory and the Java heap are different worlds#

The managed heap is collected; native allocations are not. A ByteArray of 10 MB is a managed allocation that the GC can reclaim. A 10 MB malloc from JNI is invisible to the GC and will persist until free is called.

Bitmaps used to be native allocations on older releases and moved into the managed heap later, which is why advice about bitmap memory can be contradictory depending on the release. The invariant is that dumpsys meminfo's Native Heap row is the place to look for native leaks, and the Java Heap row is where the GC's behaviour is visible.

Reading a heap dump#

A heap dump shows reachability. The useful question is not "what is big" but "what is reachable from a GC root that should not be".

The classic Android leaks:

  • A static field holding an Activity, a Context or a View.
  • A listener registered on a long-lived object and never removed.
  • A thread or coroutine that outlives the screen and captures it.
  • A handler with a delayed message that holds the Activity.

Each is a graph edge from a long-lived root to a short-lived object, and each is visible in a heap dump as a retained-size attribution.

The memory class and why it exists#

ActivityManager.getMemoryClass() reports the per-process heap limit. It exists because the platform needs to bound the total managed heap across all apps: without a limit, one app could allocate until the device ran out.

The limit is per process and per device class. An app that assumes a large heap will work everywhere will crash with OutOfMemoryError on a low-memory device, which is why largeHeap is a request, not a guarantee.

16 KB pages and accounting#

With 16 KB pages, every allocated page is four times larger than before. Small allocations that used to fit in a 4 KB page now occupy 16 KB, so the same amount of live data can have a larger resident footprint.

That changes the shape of dumpsys meminfo numbers without changing the app's logic — another reason to compare a process against itself over time rather than against a fixed threshold.

How it evolved#

API 1–API 20Dalvik heap limitssmall per-app heap, frequent OOMAPI 1
API 21 →ARTbigger heaps and a different collectorAPI 21
API 35 →16 KB pagespage size changes accounting granularityAPI 35
Runtime changes9 releases
APIVersionWhat changed
11.0
  • Initial AOSP platform: Linux kernel, Dalvik VM, core libraries
82.2 Froyo
  • Trace-based JIT compiler added to Dalvik
92.3 Gingerbread
  • Concurrent garbage collector added to Dalvik (GC pause reduction)
194.4 KitKat
  • Android Runtime (ART) appears as an experimental runtime
215.0 Lollipop
  • ART becomes the only runtime: ahead-of-time compilation at install
236.0 Marshmallow
  • Runtime permission model changes how every app asks for access
247.0 Nougat
  • Hybrid JIT + AOT compilation in ART
3313 Tiramisu
  • The notification permission becomes a runtime permission
3717 Cinnamon Bun
  • Generational garbage collection is added to ART's Concurrent Mark-Compact collector

Under the hood#

Curiosity corner#

  • PSS is expensive to compute. It requires walking every mapping, which is why dumpsys meminfo is not free and why the system does not compute it continuously.
  • A process can have a negative private-dirty count in some accounting views, because pages can be shared after being counted as private. The numbers are estimates, not invariants.
  • 16 KB pages change the granularity of all of this. With larger pages, the smallest allocatable unit is larger, and the waste from partially used pages grows.

Try it#

One app's memory
adb shell dumpsys meminfo com.example.app
Example output
                   Pss  Private  Private  Swap
Total    Dirty    Clean  Used
Native Heap    19640    17400     2240     0
Dalvik Heap    42180    38200     3980     0
Code    12880      520    12360     0
TOTAL    187432    57656   128980     0
System-wide summary
adb shell dumpsys meminfo
Example output
Total RAM: 7,654,321K
Free RAM: 1,234,567K
Used RAM: 5,678,901K
Lost RAM:   123,456K
Heap limit
adb shell dumpsys meminfo com.example.app | grep -i "memory class"
Example output
Memory class: 256
Large memory class: 512

Check yourself#

Check yourself

1 / 12

Why can clearing apps from the recents screen fail to save battery?

0 answered · 0 correct

Sources#

Sources

Every version number, API level, class name and date on this page is traced to one of these. Pages are re-verified against them; last_verified above says when.

  1. docsManage your app's memory overviewdeveloper.android.comaccessed 2026-10-27↩
  2. docsAndroid Runtime (ART) and Dalviksource.android.comaccessed 2026-10-27↩
  3. docsManage your app's memorydeveloper.android.comaccessed 2026-10-27↩
  4. docsdumpsysdeveloper.android.comaccessed 2026-10-27↩
  5. docsLow-memory killer daemon (lmkd)source.android.comaccessed 2026-10-27↩