Mobile Computing · Mobile Platforms
Memory Pressure, Reclaim Order, and the Kill Decision
How a mobile system decides which process to kill under memory pressure, why the score is an out-of-memory adjustment rather than a priority, and how file-backed pages change the arithmetic.
A phone has no disk-backed swap in the ordinary sense, so anonymous memory cannot be evicted to storage the way it can on a server. Some devices do run a compressed swap area in RAM, which trades CPU time for a smaller footprint and defers the decision, but it does not remove the need for the decision: once the compressed store is full, the only way to free anonymous memory is to end the process that holds it. The policy that chooses which process is computed continuously, in advance, from information the system already has, because at the moment of failure there is no time to deliberate.
Two different problems
Reclaiming page cache and reclaiming anonymous memory are not the same operation, and conflating them produces bad policy.
File-backed pages can be dropped. The page is a cached copy of something on storage; if the process that mapped it still needs it, the next access takes a fault and reads it back. Reclaiming such a page costs the fault and the read, and frees the physical frame immediately. The kernel’s page reclaim path does this before considering anything more drastic, and it does it by scanning lists of pages ordered by recency.
Anonymous pages cannot be dropped. There is no backing file; the page is the only copy. Without swap, the only ways to free one are to have its owner voluntarily release it, or to end the owner. Every mobile platform chooses the second, which is why process termination is a normal memory-management operation rather than an exceptional one.
The consequence is that a device with plenty of cached file pages is not under pressure, even if its free-memory count is low. Free memory is not the metric; reclaimable memory is. A system that reports low free memory but high reclaimable cache is healthy, and one that reports the same free memory with nothing reclaimable is about to kill something.
The score is a bias, not a priority
The kernel’s out-of-memory killer selects a victim by computing a score for each process and killing the highest. The score is derived from the process’s resident set size, adjusted by a per-process value that the platform sets.
The adjustment is conventionally a signed integer where more negative means less likely to be killed. Its name and range are historical and confusing: the value is an adjustment to the badness score, not the score itself, and a process with a large resident set can still be killed before a smaller one if its adjustment is sufficiently unfavourable. The kernel normalises the adjustment into the same range as the memory-derived component, so the two contributions are comparable.
The platform sets the adjustment from the process importance ranking described in Lifecycle Callbacks and the Process Importance Ranking. A foreground process receives a strongly negative adjustment; a cached process receives a positive one. The values are chosen so that no amount of memory held by a foreground process can make it a better victim than a cached process, which is the property the ranking is supposed to provide.
There is a second, more direct mechanism: a per-cgroup memory limit. Rather than scoring candidates globally, the platform places each app in a control group with a memory ceiling, and the kernel reclaims within that group when the group exceeds its limit. The reclaim within a group can still end a process, but the choice is confined to the group, which means one app’s growth cannot cause another app to be killed. Grouping turns a global policy problem into a per-app one, at the cost of needing a limit per app and a policy for setting it.
What the score misses
The score is computed from resident memory and an adjustment. It knows nothing about what the process is doing, how long it has been idle, or whether it holds a lock that another process is waiting on. Three consequences follow.
/* The adjustment is exposed per process and is readable without privilege. */
FILE *f = fopen("/proc/self/oom_score_adj", "r");
int adj = 0;
if (f) { fscanf(f, "%d", &adj); fclose(f); }
/* Negative values make the process a worse victim; the range is bounded. */
The file exists for inspection and for privileged adjustment; an ordinary app cannot raise its own protection, because the value is clamped against the ceiling the platform set for its importance class.
A process that has just entered the background is scored as a cached process immediately, even though the user is likely to return to it within seconds. The platform compensates with a recency term: among processes at the same adjustment, the least recently used is killed first. Recency is tracked from the last time the process was in the foreground, not from any access to its memory, because the kernel cannot distinguish a page touched by the app from a page touched by a shared library on its behalf.
A process holding a large shared mapping is charged for it. Pages shared with other processes appear in each process’s resident set, so the score overstates what killing one process would free. The score is a heuristic, not an accounting of reclaimable bytes: it counts resident pages and page tables, and it does not subtract the pages that would remain mapped elsewhere.
A process that is unkillable by policy, such as a system service, the launcher, or the process hosting the input method, must be excluded. The exclusion is expressed as an adjustment low enough that no ordinary process can compete, plus, for critical services, a rule that the kernel will not kill them at all. If such a process is the only remaining candidate, the kernel kills something else and the allocation that triggered the out-of-memory condition fails, which surfaces as an allocation failure in the caller rather than as a kill.
The compressed-memory alternative
One alternative keeps the process alive instead of ending it. On Apple’s platforms the system compresses the pages of a process that is not currently running, freeing the physical frames while the process remains suspended and resumable. A compressed page occupies a fraction of its original size, so the system can hold several suspended apps in compressed form in the space one of them occupied uncompressed. Android’s equivalent is a compressed block device used as swap: pages are evicted from a process into a compressed pool, and the process faults them back when it next touches them.
The trade is explicit and different from the kill-based approach. Compression costs CPU time to compress and to decompress, and it costs latency on the first access after decompression. Killing costs the app’s state and the user’s time to rebuild it. Compression is preferable when the CPU has headroom and the app is likely to be resumed; killing is preferable when memory is so short that even compressed pages do not fit, or when the process has been idle long enough that resumption is unlikely.
The platform exposes pressure to apps through a notification that arrives before the kill decision. An app receiving it is expected to release caches it can rebuild, because those caches are the cheapest thing to free and the only thing the app can free without losing user-visible state. The notification is not a request to save state; saving is handled by the channel described in Saved State, Process Death, and the Bundle Size Limit, which runs earlier. Apps that ignore the notification are not penalised directly, but their resident set stays large and they become better candidates.
Thresholds and hysteresis
A naive policy that kills whenever free memory falls below a threshold produces oscillation: the kill frees memory, the freed memory is immediately consumed by the surviving processes, and the threshold is crossed again. The platform avoids this with hysteresis: a low watermark at which reclaim begins and a high watermark at which it stops, with the gap sized to absorb the allocation rate of the processes that remain.
The watermarks are also per-zone and per-cgroup. A device with several memory zones, some of which are constrained by hardware addressing, can be under pressure in one zone while another is comfortable. The allocator’s fallback path moves allocations between zones when it can, and the killer is invoked only when the constrained zone cannot be satisfied. This is why memory pressure on mobile devices is often reported as a failure to allocate a specific order of pages rather than as general exhaustion: the kernel’s fragmentation means a contiguous allocation can fail while plenty of free pages exist in total.
Compaction is the third response, between reclaim and kill. It migrates movable pages to create contiguous free blocks, at the cost of copying them and updating every mapping. A system that can compact successfully avoids a kill; one that cannot, because too many pages are unmovable, must kill to free a whole process’s worth of frames at once.
When the policy itself misfires
A process that allocates in a tight loop after being told to release memory accelerates its own death. The notification is delivered on the main thread, so a main thread that is blocked cannot respond, and the process is killed while its own handler is queued.
A process killed while holding a file lock or a binder transaction leaves the resource in a state the next process must recover from. The kernel releases the file lock when the process’s descriptors close, but a binder transaction in flight is not recoverable: the sender sees a failure and must treat it as a possible partial operation, which is the mirror image of the delivery guarantee discussed in Intent Resolution, Exported Components, and URI Permission Grants: there the question is whether a call arrives, and here it is whether a call that arrived had any effect.
Kill decisions are not visible to the app. There is no callback before termination and no record the app can read afterwards. The only observable is that the process starts again from scratch, with saved state if it was written and nothing otherwise.
And the policy is version-dependent. The specific adjustment values, the grouping structure, and the availability of compression all differ across platform versions and device configurations. Code that reasons about the exact numbers is fragile; code that responds to the pressure notification and keeps its resident set small is not.
References
- Linux kernel documentation — Memory Management Concepts — Linux kernel documentation
- proc(5) — Linux manual page (oom_score_adj) — Linux man-pages project
- Apple Developer Documentation — Responding to memory warnings in UIKit — Apple Inc.