Your App Was Not Killed by an OutOfMemoryError: Inside the Android 17 Memory Limiter
Your App Was Not Killed by an OutOfMemoryError: Inside the Android 17 Memory Limiter
A user on a 4 GB phone opens your photo editor, applies a few filters, and the app disappears. There is no crash dialog, Crashlytics shows no OutOfMemoryError, and the Java heap in your last profiling session never came close to Runtime.maxMemory(). When you finally ask the system what happened, ApplicationExitInfo reports REASON_OTHER with a description that contains MemoryLimiter:AnonSwap. Your app was not killed by the Java heap limit or by the low memory killer. It was killed by a new per process ceiling that Android 17 introduced, starting with Pixel devices.
In this article, you'll start from the memory accounting a typical app never looks at and trace inward through the Memory Limiter in system_server: where its limits come from, how process state picks one, how cgroup v2 throttles your process before anything is killed, how the native poller decides you crossed the line, the exact path that ends in REASON_OTHER, and how Play's new memory vitals line up with the same numbers.
The fundamental problem: Four ceilings that measure four different numbers
Here is a cache that many image heavy apps start with. It keeps the most recent decoded frames so that undo and before/after comparison feel instant:
class FrameCache(private val maxFrames: Int = 64) {
private val frames = object : LinkedHashMap<String, Bitmap>(16, 0.75f, true) {
override fun removeEldestEntry(eldest: MutableMap.MutableEntry<String, Bitmap>): Boolean {
return size > maxFrames
}
}
fun put(key: String, frame: Bitmap) {
frames[key] = frame
}
fun get(key: String): Bitmap? = frames[key]
}
The cache is bounded by a count, not by bytes. Sixty four full resolution frames from a 12MP camera are about 3 GB of pixels at four bytes per pixel. Since Android 8.0, bitmap pixel data lives in the native heap, so the Java heap only holds the small Bitmap objects. Runtime.maxMemory() never sees the pixels, and no OutOfMemoryError is thrown while the process keeps growing.
This is the core confusion: an Android process now runs under several memory ceilings, and each one measures something different.
| Ceiling | What it measures | What happens |
|---|---|---|
| Java heap limit | Objects in the ART heap | OutOfMemoryError thrown inside your process |
| Low memory killer (lmkd) | System wide memory pressure | Processes killed in oom_adj order, REASON_LOW_MEMORY |
| Memory Limiter (Android 17) | Your process's cgroup | Throttled above memory.high, killed when anon + shmem + swap passes memory.high + memory.swap.max |
| Play memory vitals | P90 of anon RSS + swap across sampled devices | No kill, a quality threshold enforced from February 2027 |
The first two are old friends. The Memory Limiter is new, and unlike lmkd it does not wait for the whole device to run out of memory. It watches each app process individually. The rest of this article follows that mechanism from configuration to kill.
Where the limits come from: No config file, no limiter
The Memory Limiter is a Java class in system_server, MemoryLimiter, backed by a native layer that talks to the Linux cgroup v2 filesystem. Before it watches anything, it decides whether it should run at all. Looking at how the default controller is chosen:
private static Controller getDefaultController(Context context) {
if (!Flags.memoryLimiterEnable()) {
return new ControllerDisabled();
} else if (Process.myUid() != Process.SYSTEM_UID) {
return new ControllerDisabled();
} else if (!isMemoryLimiterSupported()) {
return new ControllerDisabled();
} else {
return new ControllerEnabled(new Injector(context));
}
}
Three conditions must hold: the feature flag is on, the code runs inside system_server, and isMemoryLimiterSupported() returns true. That last check first looks for a configuration file. In the Android 17 source, the path is /vendor/etc/memory-limiter-config.xml. If the file does not exist, the limiter logs no config file and every method of ControllerDisabled becomes a no op. Two more build flags, one for monitoring and one for swap limits, decide how much of the mechanism runs once it is enabled. Both are on in the Android 17 release configuration, and the numbers in this article assume they are.
This is why the same Android 17 build can behave differently on two devices. Device makers opt in by shipping the file. You can see this on the Android 17 emulator image. With 4 GB of RAM configured, the emulator has no config file, and the limiter reports itself as disabled:
$ adb shell am memory-limiter status
Memory limiter
disabled
$ adb logcat -d -s MemoryLimiter
11-08 17:43:41.317 637 637 I MemoryLimiter: no config file: /vendor/etc/memory-limiter-config.xml
Run the same status command on your own test devices before you assume anything about limits. On a device that enforces them, it prints the visible and not visible limits that apply to every app.
Choosing a tier by MemTotal
When the file exists, it holds a list of limit sets, one per RAM tier. The limiter picks one by comparing each tier's floor against MemTotal from /proc/meminfo:
private static Configuration getConfiguration(List<LimitSet> sets, long memTotal) {
long minRequiredMem = 0;
Configuration result = null;
for (int i = 0; i < sets.size(); i++) {
LimitSet cfg = sets.get(i);
long minMemTotal = cfg.getMinimumRequiredMemTotal().longValue() * MB;
if (minMemTotal > memTotal || minMemTotal < minRequiredMem) {
continue;
}
minRequiredMem = minMemTotal;
result = new Configuration(
cfg.getMemVisible().longValue() * MB,
cfg.getMemNotVisible().longValue() * MB,
cfg.getSwapVisible().longValue() * MB,
cfg.getSwapNotVisible().longValue() * MB);
}
return result;
}
The winning tier is the one with the largest floor that still fits under MemTotal. Notice that the comparison uses MemTotal, not the RAM printed on the box. MemTotal is what the kernel can use after hardware carve outs for the GPU, modem, camera ISP, and secure environments, which the platform documentation puts between 500 MiB and more than 2 GiB. That is why the tier floors are lower than the marketing numbers. If no tier matches, isMemoryLimiterSupported() returns false and the limiter stays off.
Here is the 4 GB tier from the standard configuration that the platform documentation publishes for device makers:
<limitSet>
<minimumRequiredMemTotal>3200</minimumRequiredMemTotal>
<maximumAllowedMemTotal>4800</maximumAllowedMemTotal>
<memVisible>2048</memVisible>
<memNotVisible>1024</memNotVisible>
<swapVisible>1024</swapVisible>
<swapNotVisible>1024</swapNotVisible>
</limitSet>
All values are in MiB. A visible process on a 4 GB device gets a 2 GiB memory.high and 1 GiB of swap. A not visible process gets 1 GiB and 1 GiB. The published tiers scale up from there: 4 GiB visible on 6 GB devices, 5 GiB on 8 GB, 8 GiB on 12 GB, and 10 GiB on 16 GB. In that published configuration, devices below 3,200 MiB or at 18,432 MiB and above get no limits at all.
The published configuration and the Android 17 source differ in two details. The documentation places the file at /system/etc and gives each tier a ceiling with maximumAllowedMemTotal. The Android 17 source reads /vendor/etc, and its schema has no ceiling element, so it matches on the floor alone. On that code, a device at or above 18,432 MiB would fall into the 16 GB tier if its file contains one.
Process state decides which limit applies
A tier gives the limiter two pairs of numbers, one for visible processes and one for not visible processes. Which pair your process gets depends on its process state, the same PROCESS_STATE_* value that the activity manager computes alongside the oom_adj score lmkd uses. The mapping is built once, in initializeMemoryLimits():
Limits ignored = new Limits(LIMIT_IS_IGNORED, LIMIT_IS_IGNORED);
Limits cached = new Limits(LIMIT_IS_IGNORED, LIMIT_IS_DISABLED);
Limits unlimited = new Limits(LIMIT_IS_DISABLED, LIMIT_IS_DISABLED);
Limits notVisible = new Limits(mConfiguration.memNotVisible,
mConfiguration.swapNotVisible);
Limits visible = new Limits(mConfiguration.memVisible,
mConfiguration.swapVisible);
Each Limits pairs a value for memory.high with a value for memory.swap.max. Two special values appear here: LIMIT_IS_DISABLED is written to the cgroup as max, and LIMIT_IS_IGNORED means "leave the current value alone". The switch that follows assigns one of these to every process state:
Limits limit = switch (state) {
case PROCESS_STATE_PERSISTENT, PROCESS_STATE_PERSISTENT_UI -> unlimited;
case PROCESS_STATE_TOP, PROCESS_STATE_BOUND_TOP,
PROCESS_STATE_IMPORTANT_FOREGROUND, PROCESS_STATE_TOP_SLEEPING -> visible;
case PROCESS_STATE_FOREGROUND_SERVICE, PROCESS_STATE_BOUND_FOREGROUND_SERVICE,
PROCESS_STATE_IMPORTANT_BACKGROUND, PROCESS_STATE_TRANSIENT_BACKGROUND,
PROCESS_STATE_BACKUP, PROCESS_STATE_SERVICE, PROCESS_STATE_RECEIVER,
PROCESS_STATE_HEAVY_WEIGHT, PROCESS_STATE_HOME,
PROCESS_STATE_LAST_ACTIVITY -> notVisible;
case PROCESS_STATE_CACHED_ACTIVITY, PROCESS_STATE_CACHED_ACTIVITY_CLIENT,
PROCESS_STATE_CACHED_RECENT, PROCESS_STATE_CACHED_EMPTY -> cached;
case PROCESS_STATE_UNKNOWN, PROCESS_STATE_NONEXISTENT -> ignored;
};
The real switch lists every state on its own line, but the grouping above is the same. A few observations matter in practice:
- A foreground service is not visible. A music player or a navigation app that keeps running through a foreground service after the user leaves gets the not visible limit, half of the visible one on a 4 GB device. If your foreground service holds large decoded buffers, it is measured against the smaller number.
- Cached processes keep their old
memory.high. Thecachedpair ignoresmemory.highand disables the swap limit. Cached processes are frozen and reclaimed anyway, so the limiter does not need to push them further. - Persistent processes are unlimited. They are system processes that the platform depends on.
Before any of this applies, a process has to qualify. The per process Limiter refuses UIDs below FIRST_APPLICATION_UID (10000), and it exempts the package of the default on device sandboxed inference service. Every other app process, including preinstalled apps, is in scope.
How state changes reach the limiter
Every app process has a ProcessRecord in the activity manager, and every ProcessRecord owns a MemoryLimiter.Limiter. When the activity manager changes a process state, ProcessRecord.onProcStateUpdated() forwards it to the process's Limiter, whose onProcStateUpdated() looks like this:
void onProcStateUpdated(@ProcessState int newState) {
if (!mController.isEnabled()) return;
if (!mIsReady) return;
final Limits newLimit = mController.getStateLimit(newState);
if (newLimit != null && !Objects.equals(mLimit, newLimit)) {
mLimit = newLimit;
mController.setLimit(mPid, mUid, mLimit);
}
}
The Objects.equals check is important for cost. Process states change constantly as activities start and services bind, but most transitions stay within the same group, for example from TOP to BOUND_TOP. Only a change of group, such as visible to not visible, produces a message. setLimit() does not touch the cgroup directly either. It posts a message to a handler on the system background thread, so the activity manager lock is never held while the native layer writes files.
Throttle before kill: memory.high and the first event
The native layer turns each Limits into writes to the process's cgroup. Each app process has its own cgroup, listed on the 0:: line of /proc/<pid>/cgroup. The layout can differ between devices, so read the path rather than building it yourself. On the Android 17 emulator, the launcher's cgroup looks like this when the limiter is disabled:
$ adb shell cat /proc/1023/cgroup
3:cpuset:/top-app
2:cpu:/top-app
1:blkio:/
0::/apps/uid_10195/pid_1023
$ adb shell cat /sys/fs/cgroup/apps/uid_10195/pid_1023/memory.high
max
$ adb shell cat /sys/fs/cgroup/apps/uid_10195/pid_1023/memory.events
low 0
high 0
max 0
oom 0
oom_kill 0
oom_group_kill 0
Two files carry the limits. memory.high is a soft ceiling on memory.current, which is everything charged to the cgroup, file backed pages included. When usage goes above it, the kernel does not kill anything, it reclaims memory from that cgroup specifically. Clean file backed pages, such as code and mapped assets, are dropped because they can be read from storage again. Anonymous pages, such as heap allocations, are compressed into zRAM swap. memory.swap.max caps how much of that swap the cgroup may use. A third file, memory.events, has a high counter that the kernel increments whenever the cgroup's processes are throttled for being over memory.high, and that counter is what the limiter watches.
When a configuration message arrives, the native Process sets the three limits it tracks:
void setLimits(int64_t memHigh, int64_t swapMax) {
setLimit(MonitoredLimit::kMemoryHigh, memHigh);
setLimit(MonitoredLimit::kSwapMax, swapMax);
setLimit(MonitoredLimit::kAnonSwap, memHigh + swapMax);
}
The first two go straight into memory.high and memory.swap.max. The third, kAnonSwap, is never written to a file. It is the limiter's own kill threshold: the sum of the two. On a 4 GB device, that is 3 GiB for a visible process and 2 GiB for a not visible one.
To notice the first crossing, the native layer registers an inotify watch on memory.events and records the current high count as a baseline. inotify is a Linux facility that wakes a thread when a file changes, so the limiter does not have to read the file in a loop. When the kernel bumps the counter, the watch fires:
type = p.getLimitType(wd);
if (p.getEventCount(type) != p.getEventBaseline(type)) {
found = p;
p.unwatch(mInotifyFd, mWdMap, type);
p.setExceeded(type, mLimitMode);
anyRed = p.isRed();
p.resetLimits();
}
Notice the order of operations:
- The watch is removed, so a process sitting above
memory.highdoes not flood the limiter with events. setExceeded()marks thememory.highwatcher as triggered.resetLimits()writes the limits again, and this timesetLimit()adds a margin tomemory.high.
The margin comes from a constant, mMemHighMargin, which is 100 MB. Looking at the branch in setLimit() that uses it:
case MonitoredLimit::kMemoryHigh:
mMemoryHighLimit = limit;
if (mMemWatcher.mTriggered) {
limit = incrLimit(limit, mMemWatcher.mMargin);
}
writeLimit(cgroupPath(CgroupFile::kMemoryHigh), limit);
break;
The source comments explain the intent: the margin lets a process keep running even when its anonymous memory sits at the memory.high limit, and gives the CPU a chance to shed some of it before the limit is hit again.
Back in Java, the callback for this first event only records it. onLimitExceeded() logs the event and writes a statsd atom. The atom goes through a single token bucket shared by every process: a burst of up to four, then one more per hour. Nothing is killed at this stage, and nothing notifies your app either. From your app's point of view, this is the phase where frames start to drop: reclaim and zRAM compression cost CPU time, and the process keeps running. Because memory.high is compared against memory.current, file pages alone can start this phase. The kill check that follows looks only at anonymous memory.
The red zone: Polling anon plus swap
Once the memory.high event has fired and the watch is gone, the process is in what the source calls the red zone:
bool isRed() const {
return mMemWatcher.mTriggered && !mAnonSwapTriggered;
}
cgroup events cannot tell the limiter when anon plus swap crosses the kill threshold, because no kernel file tracks that sum. So the limiter switches to polling. Its monitor thread sleeps in epoll_wait\(\), and the timeout depends on whether any process is red: 30 seconds (RED_POLL_PERIOD_MS) while at least one is, and 5 minutes otherwise, which is only used to forget processes that have exited. On each timeout, every red process is tested:
AnonSwapState testAnonSwap() const {
if (mAnonSwapTriggered) return AnonSwapState::kTriggered;
int64_t metric = getMetric(MonitoredLimit::kAnonSwap);
if (mAnonSwapLimit < 0) {
return AnonSwapState::kOkay;
} else if (metric > mAnonSwapLimit) {
return AnonSwapState::kHot;
} else if (metric < (mMemoryHighLimit - mMemHighHysteresis)) {
return AnonSwapState::kCold;
} else {
return AnonSwapState::kOkay;
}
}
There are three outcomes for a process that has not already been triggered:
- Hot: the metric is above
memHigh + swapMax. The process has breached its kill threshold. - Cold: the metric has dropped more than 10 MB (
mMemHighHysteresis) below the configuredmemory.high. The limiter registers the inotify watch again and stops polling this process. - Okay: anything in between. The process stays red and is checked again on the next poll.
Freeing enough anonymous memory, swap included, to get 10 MB below memory.high is the way out that your app controls. A process state change moves both thresholds as well, because every new Limits message rewrites them. Becoming visible raises them and can let the next poll return Cold without anything being freed. Dropping to not visible lowers them, and a process that was Okay in the foreground can turn Hot right after the user leaves it.
The metric itself is the key to understanding what counts against you. It reads two cgroup files:
int64_t anonMetric() const {
return anon + shmem;
}
case MonitoredLimit::kAnonSwap:
return readMemoryStat().anonMetric() + readMetric(CgroupFile::kSwapCurrent);
anon and shmem come from memory.stat, and swap comes from memory.swap.current. Anonymous memory is everything your process allocated that has no file behind it: the ART heap, native malloc allocations, and since Android 8.0 the pixels of software bitmaps. shmem covers shared memory such as memfd buffers. Swapped out pages still count, because memory.swap.current is added in. File backed pages, like your DEX code and memory mapped assets, are not part of the metric at all. They are the first thing the kernel drops under memory.high.
This is why the FrameCache from the beginning is dangerous on a 4 GB device. Its pixels are anonymous memory. Reclaim can move them to zRAM, but zRAM pages still count. Once frames plus everything else exceed 3 GiB in the foreground, the next poll reports Hot.
The kill path: Release, profile, wait 30 seconds
A Hot result marks the process as triggered and calls back into Java with LIMIT_TYPE_ANON_SWAP. After logging the event and the statsd atom, the Java callback does something surprising:
if (type == LIMIT_TYPE_ANON_SWAP) {
// Release all limits.
configureLimit(mNative.get(), pid, uid, LIMIT_IS_DISABLED, LIMIT_IS_DISABLED);
}
onLimitExceeded(pid, uid, type, memHigh, swapHigh, pkg);
It removes the limits. Both memory.high and memory.swap.max are set to max, so the process is no longer throttled until its next change of limit group. If it changes group during the wait that follows, for example when the user leaves the app, that group's limits are written again. The source comment only notes that the call is safe because the native layer holds no locks, but the effect suits the next step, which wants the process running normally:
if (type == LIMIT_TYPE_ANON_SWAP) {
ProfilingServiceHelper helper = ProfilingServiceHelper.getInstance();
helper.onProfilingTriggerOccurred(uid, pkg,
ProfilingTrigger.TRIGGER_TYPE_ANOMALY);
Message msg = mQueue.obtainMessage(MESSAGE_KILL, pid, uid,
"MemoryLimiter:AnonSwap");
mQueue.sendMessageDelayed(msg, KILL_DELAY_MS);
}
The real code wraps the profiling call in a check of two profiling feature flags, a non-null package name, and a try block, but the sequence is the same. First, the limiter fires TRIGGER_TYPE_ANOMALY. If your app registered for that trigger, the profiling service can capture a heap dump. Then the limiter schedules the kill with KILL_DELAY_MS, which is 30 seconds. The source comment states the reason directly: the delay allows the profiler, if configured, to complete.
When the delayed message runs, the limiter checks one more switch and then asks the activity manager to kill the PID:
case MESSAGE_KILL -> {
if (mDisableKill) {
Slog.i(TAG, "not killing process " + pid);
} else {
mInjector.killProcess(pid, uid, (String) msg.obj);
}
}
killProcess() calls ActivityManagerService.killPids() with the reason string, and killPids() ends in killLocked(reason, ApplicationExitInfo.REASON_OTHER, ApplicationExitInfo.SUBREASON_KILL_PID, true\). On Android 17 (API 37.0), that is the full origin of what you see in the field: the reason is REASON_OTHER, and the description carries MemoryLimiter:AnonSwap. API 37.2 adds a dedicated REASON_MEMORY_LIMITER exit reason. The source for later Android 17 releases is not public yet, so it cannot confirm when limiter kills start reporting that reason.
Two runtime switches sit on this path. mDisableKill and its sibling mDisableLimits are read from DeviceConfig keys in the activity_manager namespace, memory_limiter_disable_kill and memory_limiter_disable_limits. Both can be flipped remotely through DeviceConfig without an OTA update, but they do different things. memory_limiter_disable_limits writes max to both cgroup files at each process's next state change. It does not clear the native kill threshold, so a process already in the red zone can still be killed. Only memory_limiter_disable_kill stops the kill itself.
Putting the timeline together from the source: the first crossing of memory.high starts throttling and puts the process in the red zone. The red check runs only after 30 seconds pass without any other inotify event, and once it finds the process Hot, the kill is posted 30 seconds later. On a quiet device, that is roughly 30 to 60 seconds after anon plus swap crosses the threshold, and at least about 60 seconds after the first memory.high crossing. Activity from other watched processes can push it later. This timeline is derived from the code, not measured on a device.
Catching it in the field
Because the kill does not throw, your crash reporter sees nothing unless it reads exit reasons. You can read them yourself on the next launch:
fun findMemoryLimiterKills(context: Context): List<ApplicationExitInfo> {
val am = context.getSystemService(ActivityManager::class.java)
return am.getHistoricalProcessExitReasons(context.packageName, 0, 10)
.filter { info -> info.description?.contains("MemoryLimiter:AnonSwap") == true }
}
On Android 17, REASON_OTHER covers several unrelated kills, so the description is the reliable marker. On API 37.2 and later, also match REASON_MEMORY_LIMITER, since what those builds put in the description is not public yet. Each matching record carries a timestamp and the importance the process had when it died, which helps tell a foreground kill from one behind a foreground service. Keep in mind that the kill lands 30 seconds or more after detection, so that importance can differ from the state the process was in when it crossed the threshold. Its pss and rss come from the last sampling, so they can be stale or zero. Crashlytics 20.1.0 adds debug data for Memory Limiter kills as well.
To find out what was holding the memory, register for the anomaly trigger that the limiter fires before the kill. The pattern follows the same ProfilingManager API that the OOM trigger uses, available from API 37:
@RequiresApi(Build.VERSION_CODES.CINNAMON_BUN)
fun registerAnomalyTrigger(context: Context) {
val profilingManager = context.getSystemService(ProfilingManager::class.java) ?: return
profilingManager.registerForAllProfilingResults(Executors.newSingleThreadExecutor()) { result ->
if (result.errorCode == ProfilingResult.ERROR_NONE &&
result.triggerType == ProfilingTrigger.TRIGGER_TYPE_ANOMALY
) {
// Schedule an upload of result.resultFilePath.
}
}
val anomaly = ProfilingTrigger.Builder(ProfilingTrigger.TRIGGER_TYPE_ANOMALY).build()
profilingManager.addProfilingTriggers(listOf(anomaly))
}
Register the result callback on every app startup, as the OOM case study in the documentation recommends, so that results queued while your process was gone are delivered when it starts again. Keep the two triggers apart in your head: TRIGGER_TYPE_OOM fires when the Java heap throws OutOfMemoryError, while TRIGGER_TYPE_ANOMALY is what the Memory Limiter fires. A leak in native or bitmap memory will usually show up only through the second one. Anomaly results are not limited to memory, so check result.tag, which carries additional information about the anomaly, before treating a profile as a Memory Limiter capture.
To reproduce the behavior locally, the am memory-limiter shell command offers status, ignore, and manual subcommands. Treat manual with care, because its descriptions disagree. The Android 17 help text and the design doc in the source tree describe a percentage, the behavior changes page describes megabytes, and the current platform documentation describes bytes with MB and GB suffixes. The Android 17 code multiplies the value by one MiB and applies it to both memory.high and swap, so the kill threshold becomes twice the number you pass, and none does not clear the override on that code. These commands only have an effect on a device where the limiter is enabled.
Play memory vitals: The same tiers, a lower bar
Google Play now reports two memory metrics in Android vitals: memory usage, measured as anonymous RSS plus swap, and bitmap memory usage. Play evaluates the 90th percentile over the last 28 days, and from February 2027 apps must stay under bad behavior thresholds to remain compliant. If you compare the Play tiers with the limiter configuration, the boundaries use the same numbers: 3,200, 4,800, 6,800, 9,216, 14,336, and 18,432 (MiB in the platform docs, labeled MB by Play). The metric is a close relative of the sum the limiter polls rather than the identical number: Play measures anonymous RSS plus swap per process, while the limiter reads its cgroup's anon, shmem, and swap. The thresholds differ much more. Here is the Apps category:
| Device tier | Play foreground for apps (P90) | Limiter visible memory.high | Limiter visible kill threshold |
|---|---|---|---|
| 4 GB | 2 GB | 2 GiB | 3 GiB |
| 8 GB | 2.25 GB | 5 GiB | 8 GiB |
| 16 GB | 4.25 GB | 10 GiB | 15 GiB |
On a 4 GB device, Play's foreground threshold for apps is the same number as the limiter's visible memory.high, although the two are measured on different quantities. On larger devices, Play's bar is far below the limiter's ceilings. The background thresholds follow the same pattern: 1 GB for apps on 4 GB devices, 1.5 GB on 8 GB, and 2 GB on 16 GB. Play's thresholds only cover phones and tablets between 3,200 and 18,432 MB of total memory, and games have their own, higher table. Inside that band, Play is the stricter of the two for apps on every tier above 4 GB.
Declaring your own ceiling: Memory budgets
Android 17 QPR2 (API level 37.2) adds the opposite direction: an app can declare a budget lower than the platform limit. The declaration lives in the manifest:
<application android:label="@string/app_name">
<memory-budget android:maxMb="200" />
<memory-budget android:maxMb="120" android:state="perceptible" />
<memory-budget android:maxMb="48" android:state="background" />
</application>
A clause without android:state is the fallback for any state you do not declare, which in this example means the foreground. The perceptible and background clauses take over when the process moves into those states. According to the platform documentation, a budget sets memory.high for the process and can never exceed the platform limit for the same state. Exceeding your own budget does not kill the process. It triggers the same targeted reclaim you saw earlier: clean file pages are dropped and cold anonymous pages move to zRAM. A budget is a way to ask for that reclaim early, at a size you chose, instead of discovering the platform limit from ApplicationExitInfo. Older Android versions ignore the unknown manifest element, so declaring one does not affect backward compatibility.
Two accounting rules make budgets easier to size. Shared memory inherited from the Zygote is excluded, and hardware bitmaps and DMA-BUF allocations are accounted outside your heap and budget. Keep in mind what a budget measures, though: memory.current, the resident memory charged to your cgroup, which includes file pages such as DEX code and excludes swap. That is a different number from the anon plus swap sum the limiter's kill check uses.
Conclusion
In practice, the useful change is to stop treating Runtime.maxMemory() as your memory limit. Nothing tells your app that it is being throttled, and once the limiter schedules a kill, nothing your app does cancels it. Size caches by bytes rather than entry counts, count native and bitmap memory in that size, and keep anon plus swap well below memory.high + memory.swap.max for every state your process runs in, including a foreground service. In the field, look for MemoryLimiter:AnonSwap in ApplicationExitInfo, register for TRIGGER_TYPE_ANOMALY, and watch the anon plus swap metric in Play vitals.
The Memory Limiter does not introduce a new kind of memory. It draws a per process line through the anonymous pages your app already allocates, with a stretch of throttling before the line and a kill that cannot be taken back after it. Once you measure your app the way the limiter does, the disappearing process stops being a mystery and becomes a number on a dashboard.
As always, happy coding!
— Jaewoong (skydoves)

