HomeDocumentationAndroid Under The Surface
Android Under The Surface
15

Memory from the Kernel's Perspective

Android Memory Management and the Low Memory Killer for Flutter

March 28, 2026

You've read dumpsys meminfo output. You've seen numbers like "Java Heap: 24MB" and "Native Heap: 87MB" and "Total PSS: 142MB". These numbers are real, but they're the view from above — the framework's summary. The kernel's view is different, more fundamental, and understanding it explains things that the framework's numbers can't: why your app gets killed in the background, why two apps can report overlapping memory, and why "total memory used" across all apps adds up to more than the phone's physical RAM.

Post 1 - your app is a linux process mentioned that each process has its own virtual address space. Our post about kernel&syscalls showed that memory operations are syscalls. This post is about the kernel's side of that story — the page tables, the physical RAM, and the brutal mechanism that frees memory when nothing else works.

Virtual memory is an illusion

Every process on Android — your Flutter app, Chrome, the launcher, system_server — believes it has access to a vast, contiguous address space. On a 64-bit ARM device, that address space is 256TB (48 bits of usable address space). Your phone has 6 or 8 or 12GB of physical RAM. The maths doesn't work. It's not supposed to — the virtual address space is an illusion maintained by the CPU hardware and the kernel.

When your Dart code allocates an object on the heap, the Dart VM's allocator finds space in the VM's managed heap (which is a region of the process's virtual address space). The address it gets back — say, 0x7a8c100000 — is a virtual address. It doesn't correspond to a physical location in RAM. It corresponds to an entry in the process's page table, which the kernel maintains.

The page table is a multi-level data structure (on ARM64, four levels) that maps virtual addresses to physical addresses. When the CPU needs to access memory at 0x7a8c100000, the hardware walks the page table:

javascript
Virtual address: 0x7a8c100000
  → Level 4 index: points to Level 3 table
    → Level 3 index: points to Level 2 table
      → Level 2 index: points to Level 1 table
        → Level 1 index: physical page frame 0x1A3F00
          → Physical address: 0x1A3F00000

This happens for every memory access, millions of times per second. The CPU caches recent translations in the TLB (Translation Lookaside Buffer) so it doesn't walk the full table every time. A TLB miss — where the CPU has to walk all four levels — takes tens of nanoseconds instead of the sub-nanosecond TLB hit. This is why process context switches have a cost: switching to a different process means switching page tables, which invalidates TLB entries.

The key insight: the kernel controls the page tables. It decides which virtual addresses map to which physical pages, or whether they map to anything at all. This is the mechanism behind everything else in this post.

Pages: the unit of memory

The kernel doesn't manage memory byte by byte. It manages it in pages — fixed-size blocks, typically 4KB on ARM64. (Some devices use 16KB pages, and Android is moving toward this as a default. The principle is the same.)

Physical RAM is divided into page frames. The kernel maintains a data structure tracking the state of every physical page frame:

  1. Free. Available for allocation.
  2. In use by a process. Mapped into one or more page tables. Could be code, heap data, stack, or memory-mapped files.
  3. In use by the kernel. Page tables themselves, kernel data structures, slab caches, kernel stacks.
  4. Page cache. File data cached in RAM for performance. Can be reclaimed if needed.

When a process allocates memory (through mmap or brk syscalls, which back malloc and the Dart heap), the kernel doesn't necessarily allocate physical pages immediately. It updates the page table to record that a range of virtual addresses is valid, but marks the entries as "not present." Only when the process actually reads or writes those addresses does the CPU trigger a page fault, and the kernel allocates a physical page and fills in the page table entry.

This is demand paging, and it's why VmSize (virtual memory size) in /proc/<pid>/status is always much larger than VmRSS (resident set size). VmSize is how much virtual address space the process has mapped. VmRSS is how much physical RAM it's actually using right now. For a Flutter app, VmSize might be 2GB while VmRSS is 150MB — the process has mapped a lot of address space, but only touched a fraction of it.

Shared memory: why the numbers don't add up

If you sum the RSS of every process on the device, you get a number larger than physical RAM. This isn't an error. It's because RSS includes shared pages — physical pages mapped into multiple processes' page tables simultaneously.

The biggest source of shared pages on Android is Zygote's preloaded framework code. After fork(), the parent and child share all their pages via copy-on-write. The framework classes, the shared libraries, the preloaded resources — they're backed by the same physical pages across every app process. The page is counted in every process's RSS, but it exists once in physical RAM.

libflutter.so is another example. If you're running two Flutter apps simultaneously, the kernel maps the code section of libflutter.so from both APKs. If the files are identical (same Flutter engine version), the kernel may recognise this (via the inode) and share the physical pages. The code is loaded once; both processes' page tables point to the same frames.

This is why Android's memory reporting distinguishes between different types of memory usage:

  1. USS (Unique Set Size) — physical pages used exclusively by this process. If you killed this process, this is how much RAM you'd free.
  2. PSS (Proportional Set Size) — each shared page is divided proportionally among the processes sharing it. If a page is shared by 3 processes, each is charged 1/3 of the page. PSS is what dumpsys meminfo reports by default.
  3. RSS (Resident Set Size) — all physical pages mapped by this process, including shared ones, counted in full.

For a Flutter app:

bash
adb shell dumpsys meminfo your.package.name
javascript
App Summary
                       Pss(KB)   Rss(KB)
                       ------   ------
  Java Heap:           24,312    34,560
  Native Heap:         87,440    92,160
  Code:                18,204    62,720
  Stack:                  892     1,024
  Graphics:            12,800    12,800
  ...
  TOTAL PSS:          142,648
  TOTAL RSS:          203,264

The gap between PSS and RSS (60MB here) is shared memory that other processes are also using. Killing your app wouldn't free 142MB — it would free your USS (probably 80-100MB), because the shared pages are still needed by other processes.

The "Native Heap" line includes the Dart heap. Dart allocates its managed heap via mmap, which the OS classifies as native memory. When you see a large native heap in a Flutter app, it's mostly Dart objects — your widget trees, your state, your cached data, and the Dart GC's overhead.

The page cache: RAM as disk cache

The kernel uses all available free RAM as a file cache. When you read a file, the kernel doesn't just return the data — it keeps the data in physical pages (the page cache) so that subsequent reads of the same file are served from RAM instead of hitting the flash storage.

This is why a phone that's been running for hours shows "95% memory used" — the kernel has filled available RAM with cached file data. This isn't a problem. The page cache is the first thing reclaimed when memory is needed. If a process needs a physical page and there are no free frames, the kernel evicts a page cache entry, adds that frame to the free list, and assigns it to the process.

The page cache is particularly relevant for Flutter because the engine loads `libflutter.so` and `libapp.so` from disk. After the first load, these files live in the page cache. If the user switches away from your app and back, the second load is fast because the files are served from RAM, not from flash storage. If memory pressure evicts these pages from the cache, the second load is slow because it's a real disk read.

This explains a user-perceptible phenomenon: reopening a Flutter app that was recently backgrounded is fast (engine binary in page cache), but reopening it after extended background time is slower (cache was evicted under memory pressure, the binary needs to be read from flash again).

Memory pressure and reclaim

When free physical pages run low, the kernel enters memory reclaim. This is a multi-stage process:

Stage 1: Page cache eviction. Drop clean page cache entries (file data that hasn't been modified, which can be re-read from disk if needed). This is painless — the data isn't lost, just moved back to being "on disk only."

Stage 2: Dirty page writeback. Write modified page cache entries to disk, then evict them. This requires I/O and takes longer.

Stage 3: Anonymous page swap. On systems with swap, anonymous pages (heap, stack — pages not backed by a file) can be written to a swap partition or compressed in RAM (zram). Android uses zram — a compressed block device in RAM. Pages are compressed and stored in a smaller region, freeing the original page frames. The compression ratio depends on the data; text and data structures compress well, already-compressed data (images) doesn't.

Stage 4: Low Memory Killer. If reclaim can't free enough memory through the above mechanisms, something has to die.

The Low Memory Killer

This is the mechanism that kills your background Flutter app, and it's worth understanding in detail because its behaviour directly affects the user experience of your app.

Android's Low Memory Killer (LMK) is a kernel mechanism (historically a kernel module called lowmemorykiller, now replaced by lmkd — a userspace daemon that uses kernel notifications). It maintains thresholds: when free memory drops below certain levels, it kills processes, starting with the least important.

Process importance is determined by oom_adj scores, which ActivityManagerService sets based on the process's state:

| Process state | oom_adj | Description | |---|---|---| | Foreground Activity | 0 | Currently on screen, interacting with user | | Visible Activity | 100 | Visible but not in foreground (e.g., behind a dialog) | | Perceptible service | 200 | Running a foreground service (music, navigation) | | Previous app | 700 | The last app the user was in (kept for fast switching) | | Cached (recent) | 700-900 | Recently backgrounded, still in recents | | Cached (old) | 900-999 | Backgrounded a while ago |

Higher oom_adj = more likely to be killed. The LMK kills the process with the highest oom_adj first. If that frees enough memory, it stops. If not, it kills the next one, and the next, until the free memory threshold is satisfied.

You can see your app's current oom_adj:

bash
adb shell cat /proc/14823/oom_score_adj

When your app is in the foreground: 0. Background it, and watch the number climb. Open a few more apps, and it climbs further. When it gets high enough and memory gets tight enough, the kernel terminates your process. No callback. No warning. The process is sent SIGKILL, and it's gone.

This is why Post 1 emphasised that dispose() methods might not run. The LMK doesn't ask your process to shut down. It tells the kernel to kill it. SIGKILL cannot be caught or handled — the process is terminated immediately by the kernel. File descriptors are closed by the kernel. Memory is reclaimed by the kernel. Any state that wasn't persisted to disk is lost.

What this means for Flutter apps

The Dart heap is invisible to Android's "Java Heap" accounting. Android's memory monitoring tracks Java/Kotlin heap usage and applies per-app heap limits (typically 256-512MB depending on the device). Dart's heap is allocated via mmap and classified as native memory, which doesn't have a per-app limit — the limit is physical RAM. A Flutter app can use 500MB of Dart heap without hitting a Java heap limit. But that 500MB increases memory pressure on the whole system, making the LMK more aggressive about killing other apps, and eventually, your app.

Image memory is the biggest lever. Decoded images are among the largest memory consumers in a Flutter app. A 1080×1920 image at 4 bytes per pixel is ~8MB in RAM. Ten such images in a ListView is 80MB. The GPU texture copy (used by Impeller) may double this. The kernel sees these as anonymous pages in your process — high-value targets for reclaim, but un-reclaimable while the Dart objects referencing them are alive.

Using cacheWidth and cacheHeight on Image widgets, evicting images from the cache when they're off-screen, and using appropriate resolution images for the display size are the most effective memory optimisations in Flutter apps. Not because they reduce some abstract "memory usage" number — because they reduce the physical page count of your process, which directly affects how long you survive in the background.

Background processes get killed by page count, effectively. The LMK uses oom_adj for priority ordering, but the reason it kills processes is to free physical pages. A background process using 50MB of RSS survives longer than one using 200MB, because killing the 200MB process frees more pages. If your Flutter app is doing aggressive image caching or holding large data structures in memory, it's painting a bigger target on itself.

`onTrimMemory` is a warning. Android sends onTrimMemory callbacks (via Binder, from AMS) before the LMK starts killing. Flutter's engine handles some of these internally — it can release GPU resources, clear font caches, and reduce its internal caches. The SystemChannels.lifecycle messages that arrive on the Dart side are a higher-level view of the same signals. If you're holding large caches in Dart, listening for AppLifecycleState.paused and trimming them is a direct response to the kernel telling you that physical pages are scarce.

Dart GC timing matters for peak memory. The Dart garbage collector runs on its own schedule, based on allocation rates and heap occupancy. Between GC cycles, dead objects still occupy physical pages. A burst of allocations (decoding 20 images during a fast scroll) can cause a temporary peak in physical memory usage that triggers LMK action, even if most of those allocations would be collected at the next GC cycle. The kernel doesn't know about GC intentions — it sees physical page usage right now.

zram: compressed RAM

Android doesn't use traditional swap (writing pages to flash storage). Flash write cycles are limited and slow. Instead, Android uses zram — a kernel module that compresses pages in RAM.

When the kernel needs to reclaim anonymous pages (heap, stack), it compresses the page contents and stores them in a reserved region of RAM. A 4KB page might compress to 1KB, effectively freeing 3KB. When the process accesses the compressed page, the kernel decompresses it on the fly.

You can see zram status:

bash
adb shell cat /proc/meminfo | grep -i swap
javascript
SwapTotal:       2097148 kB
SwapFree:         892340 kB

That 2GB of "swap" isn't a disk partition — it's zram. The difference between total and free (about 1.2GB here) is compressed data. With a typical compression ratio of 2-3x, that 1.2GB of compressed data represents 2.4-3.6GB of original page data.

For Flutter apps, zram means that backgrounded Dart heap pages can be compressed without the app being killed. The Dart objects are still "alive" from the GC's perspective — the virtual addresses are still valid, the page table entries still exist — but the physical pages have been compressed. When the app returns to the foreground and accesses those pages, the kernel decompresses them, which adds latency to the first few memory accesses. This is one reason a backgrounded Flutter app can feel sluggish for the first second after returning to the foreground: the kernel is decompressing pages on demand as the Dart VM and Flutter engine access them.

Observing memory from the kernel side

Beyond dumpsys meminfo, there are kernel-level tools that show the physical memory picture:

bash
adb shell cat /proc/meminfo

This shows the system-wide view: total RAM, free RAM, page cache size, active/inactive pages, zram usage. This is the global picture that the LMK is responding to.

bash
adb shell cat /proc/14823/smaps_rollup

This shows per-process memory details aggregated: RSS, PSS, shared/private clean/dirty breakdowns. Private dirty pages are the ones that only your process uses and that have been modified — this is the truest measure of your app's unique memory footprint.

bash
adb shell cat /proc/14823/oom_score_adj

Your app's current LMK priority. Watch it change as you foreground and background the app.

bash
adb shell cat /proc/vmstat | grep -E "pgfault|pgmajfault"

Page fault counters. Minor faults (page allocated for the first time, backed by free memory) are cheap. Major faults (page must be read from disk or decompressed from zram) are expensive — milliseconds, not microseconds. During app startup, major faults dominate because code and data pages are being loaded from the APK for the first time.

Connecting the layers

The memory system is where the kernel, the Android framework, and the Dart VM all interact in ways that are invisible from any single layer.

From Dart: you allocate objects, the GC manages the heap, everything seems clean.

From the kernel: your process is a collection of page table entries, some backed by physical pages, some compressed in zram, some still demand-paged from the APK. The kernel doesn't know what a Dart object is — it knows page frames, access patterns, and free memory thresholds.

From the Android framework: your process has an oom_adj score, a memory classification, and a lifecycle state. The framework adjusts the score; the kernel enforces the killing.

The most productive memory debugging happens when you can translate between these layers. "My app uses 150MB" becomes: "My process has 150MB of resident pages, of which 90MB are private dirty (my unique heap data, including the Dart heap), 30MB are shared clean (framework code shared with other apps via Zygote), and 30MB are private clean (my app's code and assets, reclaimable by re-reading from APK). If the device is under memory pressure, the kernel will first reclaim my 30MB of private clean pages, then compress my idle heap pages via zram, then — if I'm backgrounded — kill me to reclaim the 90MB of private dirty pages."

That's the kernel's perspective. It doesn't have opinions about your architecture. It has page frames, and it needs enough free ones to keep the system running.

The next post looks at what lives inside your process alongside the Dart VM: ART, the Android Runtime, and how two full runtimes coexist in the same address space.

This is Post 5 of the Android Under the Surface series. Previous: Binder: How Your App Talks to the Rest of Android. Next: ART and the Flutter Engine: Two Runtimes, One Process.

Related Topics

android memory managementlow memory killer flutterandroid oom killerflutter background killedandroid virtual memorypage tables androidflutter memory optimizationandroid ram management

Ready to build your app?

Flutter apps built on Clean Architecture — documented, tested, and yours to own. See which plan fits your project.