Fundamental concepts

To understand memory usage on Android, you must look at the system from several different perspectives, ranging from high-level Java objects down to low-level kernel pages.

The performance cliff

Memory use in and of itself is not a good or bad thing; what matters is what you are using the memory for. However, as you approach the limit of available memory on a device, you eventually "fall off a performance cliff."

A graph showing stable performance until a certain memory threshold, followed
by a steep decline as the system starts thrashing and killing processes

When you are far away from the cliff, adding a small amount of memory usage (e.g., 50MB) may have no perceptible impact on performance. However, a cliff is reached when the available RAM is exhausted. At this point, the operating system must start paging out memory and killing processes to free up space. Beyond this cliff, even a small increase in memory use can lead to significant "thrashing," where the device becomes unresponsive or appears to reboot because critical processes are killed.

The Android stack

Each layer of the Android stack has its own unique view of memory:

A block diagram of the Android memory stack, showing Java applications at the
top, ART below them, and the Linux kernel with physical pages at the bottom

  1. Applications (Java/Kotlin): Developers primarily see Java objects allocated on the Java heap.
  2. Android Runtime (ART): ART manages the Java/Kotlin heap by using virtual memory pages from the kernel. Formerly known as Dalvik (the terms are sometimes used interchangeably).
  3. Linux Kernel: The kernel sees memory in terms of physical and virtual pages. Traditionally, these are 4KB, but Android also supports 16KB page sizes. The kernel knows nothing about "Java objects."

If you want to optimize memory, you must either optimize within your own layer (e.g., loading fewer bitmaps) or understand the layers below you to see how your high-level allocations translate to physical page usage.

Memory types: anonymous vs. file-backed

Before diving into how the OS reclaims memory, you must understand the two fundamental categories of memory pages in Linux:

  1. Anonymous Memory (anon): Memory that is not backed by a file on storage. This includes memory allocated by C/C++ malloc (like the Scudo allocator) and memory allocated for Java objects on the Java/Kotlin heap. Because this memory has no source file to return to, the OS must either keep it in RAM or compress and swap it out to ZRAM when under pressure.
  2. File-Backed Memory (file): Memory that is mapped directly from a file on storage. This includes executable code (DEX files, .so native libraries) and fonts.

Memory mapped files (mmap)

Android uses mmap to map both file-backed and anonymous memory into a process's address space.

When dealing with file-backed memory, pages are further classified into:

  • Clean Memory: Pages mapped from a file that have not been modified. If the system needs more memory, the kernel can simply drop these pages, as they can be re-loaded directly from the file on storage later.
  • Dirty Memory: Pages that have been modified by the process. These cannot be dropped; they behave like anonymous memory and must be kept in RAM or moved to ZRAM.

Android prefers file formats (like DEX) that are amenable to memory mapping, allowing the system to easily reclaim clean memory under pressure. Because anonymous and dirty memory cannot be simply dropped, high private dirty/anon memory is the primary cause of system thrashing and Low Memory Killer (LMK) interventions. If your application uses a lot of private dirty memory, you are directly contributing to the performance cliff.

The Zygote process model

Android minimizes the cost of starting new applications by using a process called Zygote.

  1. Zygote starts up at boot and preloads common framework classes and resources into its memory. From the kernel's perspective, this becomes anonymous dirty memory, but it is unique to the Zygote process.
  2. When a new application starts, the system forks the Zygote process.
  3. The new child process inherits the memory of the Zygote using a shared mapping with copy-on-write (COW) semantics.

A diagram illustrating the Zygote process sharing memory pages with newly
forked child processes using Copy-on-Write semantics

As long as the child process only reads the memory inherited from Zygote, the physical memory pages remain shared between all processes. When a child process modifies a shared page, the kernel transparently creates a private copy of that page for the process. This model allows many processes to share a large portion of their memory—especially the framework code and resources—significantly reducing the overall system memory footprint.

RSS, PSS, USS, and memcg

Because memory is shared heavily between processes—primarily through the Zygote model—there are several ways to account for a process's memory usage. RSS, PSS, and USS count the pages that are mapped into the address space of a process:

A diagram visualizing the difference between Resident Set Size (RSS),
Proportional Set Size (PSS), and Unique Set Size (USS) showing how shared memory
pages are accounted for

  • RSS (Resident Set Size): The total number of pages the process has in RAM. This overestimates usage because it counts shared pages multiple times (once for every process sharing them).
  • PSS (Proportional Set Size): The total amount of memory unique to the process, plus its proportional share of shared memory. If a page is shared by 5 processes, each process is charged for 1/5th of that page.
  • USS (Unique Set Size): The amount of memory that is unique to the process. This is the memory that would be returned to the system if the process were killed.

memcg: the kernel's own accounting

The kernel also keeps its own running total of the memory that each process uses, whether or not that memory is mapped. On Android, every app process belongs to its own memory control group (memcg), nested inside a memcg for the whole app (see The Android memcg hierarchy). Whenever a page of memory is allocated, the kernel charges it to the memcg of the process that allocated it. The total charge is in the memory.current file of the memcg.

memcg accounting differs from RSS, PSS, and USS in a few important ways:

  • Each page is charged exactly once, to the memcg of the process that first brought it into memory. The charge stays there even if other processes map the same page later. Pages that Zygote preloads are charged to Zygote, not to your app. When your app writes to one of these pages, the private copy is charged to your app.
  • It includes memory that page-table metrics can't see. memory.current counts file pages that your app reads without mapping them (for example, database pages read with read or pread).
  • It tracks swap separately. Memory that's compressed into ZRAM moves out of memory.current and into memory.swap.current, so you can see both without guessing.
  • It's cheap to read. The counters are always up to date, and reading them doesn't lock or scan your app's address space.
  • It's what the system limits. The memory limits and memory budgets that Android applies to apps act on memcg charge.

Graphics memory, such as the buffers behind hardware bitmaps, generally isn't charged to your app's memcg.

Which metrics to use, when?

Choosing the right memory metric depends on what you are trying to measure and the environment in which you are measuring it. On devices running Android 17 QPR2 (API level 37.2) and higher, start with memcg when you track your own app.

Metric Best For Key Strength Main Drawback
memcg Tracking an individual app over time, comparing against budgets Counts each page once, includes unmapped page cache, tracks swap separately. Cheap to read. Not available on all devices before Android 17 QPR2. Shared pages are charged to the process that touched them first.
PSS System-wide memory snapshots Accurately proportions shared memory. Summing PSS equals total memory used. Fluctuates based on other running processes. Bad for comparing a specific app over time.
RSS Tracking an individual app over time, field telemetry Stable, independent of other processes. Fast and cheap to collect. Overestimates total usage because it counts shared memory multiple times.
USS Assessing the impact of killing a process Shows exactly how much memory would be freed if the app was terminated. Ignores shared memory entirely.

memcg (memory control group charge)

  • Best for: Tracking an individual app's memory usage over time, across Critical User Journeys (CUJs), or between app versions, on devices running Android 17 QPR2 or higher. It's also the metric to watch when you calibrate memory budgets, because it's the number that budgets limit.
  • Why: Like RSS, memcg charge doesn't change when other apps start or stop sharing pages with your app. Unlike RSS, it never counts a page twice, so you can add up the charge of all your app's running processes. The whole-app memcg can be higher than that sum, because it keeps cached file pages from exited processes until the kernel reclaims them. It also counts memory that RSS, PSS, and USS miss, such as unmapped file cache. Reading it is cheap, because the kernel updates the counters as it charges pages.
  • How to read it: Read memory.current, memory.swap.current, and memory.stat in the memcg directory of a process (see memcg statistics). In Android 18 and higher, dumpsys meminfo also prints them. From inside your app, use MemoryBudgetManager.
  • Field Measurement Best Practices: For the same reasons explained in RSS, track anonymous memory + swap: the anon field of memory.stat plus memory.swap.current. The file field of memory.stat includes file pages that your app read once and that the kernel hasn't reclaimed yet, so memory.current can stay high for a while after your app stops using that memory.
  • When to be careful: Because pages are charged to the process that touched them first, your app is charged for a shared file page (such as a font or a shared library) that it reads before any other process does. Conversely, another process might be charged for pages that your app also uses. Expect small differences between devices and runs for file-backed memory, and rely on anon and swap when you compare.

PSS (Proportional Set Size)

  • Best for: Taking a complete system memory snapshot of a certain device at a given time.
  • Why: PSS has the useful mathematical property that the sum of the PSS for all running processes equals the total memory used by processes in the system. It perfectly apportions shared memory without double-counting.
  • When to avoid: You must not use PSS to compare a specific process across two different snapshots, at different times, or across different devices. Because PSS depends on how many other processes are currently sharing pages with your process, your app's PSS can fluctuate even if your app's actual behavior remains completely unchanged.

RSS (Resident Set Size)

  • Best for: Tracking an individual app's memory usage over time, across different Critical User Journeys (CUJs), or comparing between app versions. It is also the most practical choice for field telemetry.
  • Why: RSS is more stable than PSS. It tells you about the memory mapped into your process regardless of what other apps are doing. Furthermore, RSS is cheap to collect. Measuring PSS or USS requires scanning complex kernel memory management structures and obtaining locks, which can cause system performance issues (like UI jank) if collected too frequently.
  • Field Measurement Best Practices: When collecting data from devices in the wild, it is usually best to measure anonymous RSS + swap (ZRAM).
    • Why anonymous only? File-backed pages (like code, app resources, fonts, or other memory-mapped files) can be evicted by the kernel at any time due to system-wide pagecache pressure. Including file pages in metrics to track a particular app introduces noise from the OS's memory management decisions that may be driven by memory pressure from other apps or the system. Conversely, anonymous memory (like the Java and native heaps) is directly controlled by your app.
    • Why add swap? Under memory pressure, the OS will compress anonymous pages and move them to ZRAM (swap). If you only measure resident anonymous memory, your metrics might falsely show a "decrease" in memory usage simply because the system was under pressure and swapped out your pages. Adding swapped memory ensures you account for all the anonymous memory your app has allocated, whether it currently resides in RAM or ZRAM.
    • Prefer memcg when you can. On devices running Android 17 QPR2 and higher, anon in memory.stat plus memory.swap.current gives you a similar measurement that leaves out anonymous pages shared with Zygote, and is cheaper to collect (see memcg).

USS (Unique Set Size)

  • Best for: Determining the immediate system impact of killing a process.
  • Why: USS accurately represents the exact amount of memory that would be immediately reclaimed and returned to the system if the process were terminated (for instance, by the Low Memory Killer daemon, lmkd).
  • How to use: This metric is highly valuable when evaluating persistent processes or background services. If you are assessing the system-wide cost of a background process, USS tells you exactly how much memory the system is sacrificing exclusively to keep that specific process alive.

↑ Up | Tools →