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.
"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#
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#
| Category | What it is | Behaviour |
|---|---|---|
| Java heap | objects allocated by managed code | grows with allocation, shrinks on GC |
| Native heap | malloc allocations from C/C++ | invisible to GC; leaks persist |
| Code | executable mappings (oat, .so) | mostly clean, pageable |
| Stack | thread stacks | small per thread, many threads add up |
| Graphics | GPU buffers, surfaces | often large and mostly shared |
| .so mmap | shared libraries | clean and pageable |
| .dex mmap | DEX/VDEX data | shared 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 --unreachableor 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 | Version | What changed |
|---|---|---|
| 1 | 1.0 |
|
| 8 | 2.2 Froyo |
|
| 9 | 2.3 Gingerbread |
|
| 19 | 4.4 KitKat |
|
| 21 | 5.0 Lollipop |
|
| 23 | 6.0 Marshmallow |
|
| 24 | 7.0 Nougat |
|
| 33 | 13 Tiramisu |
|
| 37 | 17 Cinnamon Bun |
|
Under the hood#
- ActivityManager.javaMemoryInfo, getMemoryClass and the memory classes
- ComponentCallbacks2.javathe onTrimMemory levels
- lmkd.cppthe consumer of the pressure numbers meminfo reports
- heap.ccthe managed heap: growth, collection and the heap limit
Curiosity corner#
- PSS is expensive to compute. It requires walking every mapping, which is why
dumpsys meminfois 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#
adb shell dumpsys meminfo com.example.appExample 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 0adb shell dumpsys meminfoExample output
Total RAM: 7,654,321K
Free RAM: 1,234,567K
Used RAM: 5,678,901K
Lost RAM: 123,456Kadb shell dumpsys meminfo com.example.app | grep -i "memory class"Example output
Memory class: 256
Large memory class: 512Check yourself#
Check yourself
Why can clearing apps from the recents screen fail to save battery?
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.
- docsManage your app's memory overviewdeveloper.android.comaccessed 2026-10-27↩
- docsAndroid Runtime (ART) and Dalviksource.android.comaccessed 2026-10-27↩
- docsManage your app's memorydeveloper.android.comaccessed 2026-10-27↩
- docsdumpsysdeveloper.android.comaccessed 2026-10-27↩
- docsLow-memory killer daemon (lmkd)source.android.comaccessed 2026-10-27↩