Mobile Computing · Mobile Performance
Memory Pressure, Kill Decisions, and What an App Can Observe Before It Dies
How the platform ranks processes for termination under memory pressure, why a backgrounded app is not safe, and which signals give an app warning before the kill.
A phone has to keep several apps resident enough to switch between them instantly, and it has less physical memory than the sum of what those apps would like to hold. The resolution is not to deny allocations but to terminate processes. Which process dies is decided by a ranking, and the ranking is expressed through a value the app cannot read but can influence.
The score that decides who dies
Linux exposes a per-process adjustment, oom_score_adj, ranging from -1000 to 1000. The kernel’s out-of-memory killer combines it with a heuristic based on resident memory to produce an oom_score, and kills the process with the highest score when memory cannot be reclaimed. A value of -1000 removes a process from consideration entirely; 1000 makes it the first candidate.
Mobile platforms use this knob as the primary policy channel. A process hosting a foreground activity is given a value at or near 0. A process with a visible but not focused window gets a slightly higher value. A process that is only hosting background services or cached state gets a value in the high hundreds, close to 1000. A system process gets -1000 and is never selected. The exact numbers are defined in the platform’s process list and have changed across releases; the ordering is what matters, and it is stable.
The practical consequence is that “backgrounded” is a range, not a state. An app that has just been swiped away from the foreground but is still drawing a picture-in-picture window holds a low adjustment and is safe. An app that has been in the background for a while, with no visible window and no running service, holds a high adjustment and is the first thing to go when memory tightens. Between those, an app with a foreground service or a bound connection sits in the middle, and whether it survives depends on how much memory the system needs to recover.
Two properties of this ranking are worth stating explicitly. It is global, so the app competes with every other app rather than with a fixed budget. And it is not a promise: the kernel may kill a low-scored process if reclaim fails badly enough, and the platform may kill processes for reasons unrelated to memory, such as a watchdog on a stuck service.
The kernel killer and the userspace killer
Historically, Android ran a dedicated low-memory killer in the kernel that watched free memory against per-adjustment thresholds and killed processes to keep each threshold satisfied. Modern kernels cannot host out-of-tree code, so the mechanism moved to a userspace daemon, lmkd, which reads pressure information from the kernel and issues kills through the same signal path. The kernel’s own OOM killer remains as the last resort when reclaim inside the kernel fails.
The difference matters for what an app observes. The userspace killer acts on pressure that is still short of a hard failure, so it kills earlier and more predictably, and it kills one process at a time as pressure crosses thresholds rather than in a batch at the point of exhaustion. The kernel killer fires when an allocation cannot be satisfied after reclaim, which means it fires while some thread is already blocked in the page fault path, and the victim selection is less precise because it is constrained to processes the kernel can actually reclaim from.
Both paths end in SIGKILL. There is no handler, no atexit, no flush. Anything not already durable at the moment of the kill is lost, and the app’s only recovery is the state it persisted earlier.
What the app can see before the end
There are three observable signals, and none of them is reliable on its own.
The first is a callback the platform delivers when memory is getting tight but before killing. On Android this is onTrimMemory, delivered with a level that describes how tight things are: a level for the app being in the background with the system under pressure, a level for the app being in the foreground with the system under pressure, and levels for the app’s own heap being close to its limit. The callback is advisory. It is delivered on the main thread, so handling it expensively is itself a jank source, and it may not be delivered at all if pressure arrives faster than the platform can schedule the callback.
The second is the kernel’s pressure notification through the file descriptor the platform watches. An app cannot read this directly on a stock device, but the platform uses it to decide when to run its own trimming, so the callback above is a downstream effect of it.
The third is the allocator’s own behaviour. When a process’s heap approaches the platform’s per-app limit, allocation can fail rather than succeed slowly. On Android the limit is enforced by the runtime rather than by the kernel’s overcommit, and exceeding it raises an out-of-memory error inside the process, which is a different failure from being killed: the process survives and can handle the exception, but only if the allocation site is prepared for it. Most allocation sites are not.
Per-process limits versus system-wide pressure
Two different mechanisms are often conflated. System-wide pressure is about total physical memory and is resolved by killing whole processes. A per-process limit is about one app’s heap and is resolved by failing its allocations. The first is invisible from inside the process until it dies; the second is visible immediately as an exception.
The per-process limit exists because a single app with a leak could otherwise consume the whole device. It is set by the platform based on the device’s memory class, which the app can query, and it applies to the managed heap rather than to native allocations, which are limited by the kernel instead. An app that keeps large images in native memory can therefore exceed the device’s comfort while staying far below its managed heap limit, and the resulting pressure is resolved by the system killer rather than by a clean exception.
The interaction is the reason bitmap handling is a memory discipline in its own right: a few large decodes can push total process memory past what the ranking tolerates, and the kill arrives with no exception and no stack trace. Bitmap Memory and the Cost of Decoding at Full Resolution works through the arithmetic, and Allocation Rate, Heap Growth, and Garbage Collection Pauses on a Frame Budget covers the managed side of the same accounting.
Designing for a kill that has no callback
Because the kill is unannounced, the design constraint is that state must be durable at a granularity the app chooses, not at the granularity the platform chooses. A form that saves on pause is safe, because pause runs before the app leaves the foreground and before its adjustment rises. A form that saves on a timer is safe only if the timer’s interval is shorter than the shortest interval between foreground and kill, which is not knowable.
The platform provides a state-restoration channel for exactly this. An app can hand a bundle of identifiers to the system when it is stopped, and the system will return that bundle if the app is relaunched after a kill. The bundle is written to disk by a system process, not held in the app’s memory, so it survives the kill. The design rule is that the bundle contains keys, not values: a selected item’s ID rather than the item, a scroll position as an index rather than the loaded list. Values can be re-fetched; the identifiers are what cannot be reconstructed.
Restoration is not free. Writing the bundle happens on the main thread during the stop transition, so a large bundle becomes a pause the user sees when switching away. A bundle measured in kilobytes is fine; one measured in megabytes is a visible stall, and on some platforms it is rejected outright.
Situations the ranking does not cover
The app is killed while a foreground service is running. The service’s adjustment is lower than a cached app’s, so this is less likely than a background kill, but it is not impossible under sustained pressure. A service that holds unsaved data should persist it continuously rather than on a shutdown path that will not run.
The app is killed during a transaction with another process. A binding to a service in another app raises both processes’ adjustments while the binding is live, but the binding does not survive the death of either. A client that assumes a bound service is alive across a background transition will find the binder dead and must handle the resulting remote exception.
Memory is reclaimed without a kill. The platform can drop pages from an app’s file mappings and from its caches without terminating it, which is cheaper than a kill and invisible except as a slowdown when the pages are touched again. An app that maps large files and touches them randomly will therefore see latency spikes after a pressure event with no callback and no kill, because the pages must be read back from storage.
The ranking is not the only input. A process that is consuming an unusual amount of CPU, or that holds a wake lock too long, can be terminated by platform policy with no memory pressure at all. The observable symptom is the same, which is why distinguishing a memory kill from a policy kill requires reading the platform’s own logs rather than inferring from app behaviour.
References
- Out Of Memory Management — The Linux Kernel documentation
- Overview of memory management — Android Developers
- proc(5): Linux manual page — Linux man-pages project