Android Native: malloc Succeeds But Memset Kills Process
Discover why Android native malloc allows 1GB+ overcommit beyond RAM without access, but lmkd kills the process during memset due to page faults and PSI pressure. No low memory callbacks; debug with mallinfo and heapprofd.
In Android native C/C++ code on an 8 GB device (Android 14+), why does malloc() succeed for large 1 GB allocations exceeding physical RAM if memory is not accessed, but the process gets killed during memset() without onLowMemory() or onTrimMemory() callbacks?
Observed Behavior
malloc(1GB)returns a non-null pointer repeatedly, allowing total allocations beyond device RAM.- App runs fine without accessing/initializing the memory.
- On
memset(ptr, 0, 1GB), the process is abruptly killed duringmemset(), not atmalloc(). - No
malloc()failure or NULL return. - Logcat shows:
InputDispatcher: channel 'com.example.app/.MainActivity'
~ Channel is unrecoverably broken and will be disposed
- App subclasses
Application, but no low memory/trim callbacks before kill.
Code Example
#include <stdlib.h>
#include <string.h>
#include <android/log.h>
#define ONE_GB (1024ULL * 1024 * 1024)
void allocate_chunks(int iterations) {
for (int i = 0; i < iterations; i++) {
void* ptr = malloc(ONE_GB);
__android_log_print(ANDROID_LOG_INFO, "NATIVE",
"Iteration %d, ptr=%p", i, ptr);
if (!ptr) {
__android_log_print(ANDROID_LOG_ERROR, "NATIVE",
"malloc returned NULL");
return;
}
// Uncommenting causes app kill
memset(ptr, 0, ONE_GB);
}
}
Questions
- Why does Android’s native
malloc()allow overcommitting memory beyond physical RAM when unaccessed? - Why is the process killed on access (
memset()) instead ofmalloc()failing gracefully?
Android’s native malloc allows massive over-allocations like 1GB chunks because the Linux kernel employs overcommitment, handing out virtual memory addresses without checking physical RAM availability upfront. Everything’s fine until you touch that memory with memset—then page faults kick in, demanding real RAM and triggering the Low Memory Killer (lmkd) to abruptly axe your process under pressure. No graceful malloc failure or Java callbacks like onLowMemory happen because this is kernel-level trickery, not a polite framework nudge.
Contents
- Android Memory: Why malloc Succeeds Without Access
- Android RAM Overcommitment Mechanics
- Process Kill on Access: lmkd and OOM Killer
- No Low Memory Callbacks in Native Code
- Debugging Native Allocations
- From dlmalloc to Scudo on Android 14+
- Preventing Kills: Strategies and Best Practices
- Sources
- Conclusion
Android Memory: Why malloc Succeeds Without Access
Picture this: you’re on an 8GB Android 14 device, firing off malloc(1GB) in a loop. It returns valid pointers every time, even as your total virtual allocations dwarf physical RAM. The app chugs along happily—no crashes, no NULLs. But uncomment that memset, and boom, gone.
Why? Native memory management in Android leans on Linux kernel smarts. When you call malloc, the bionic allocator (Scudo on modern devices) requests virtual address space via mmap or brk. The kernel doesn’t carve out physical pages yet. It just reserves the address range in your process’s virtual memory map. No harm, no foul—until access.
This is overcommitment in action. Android sets vm.overcommit_memory=2 system-wide, meaning the kernel commits more virtual memory than physically exists. It’s optimistic: “Sure, you can have that GB. We’ll deal with reality later.” Your code gets a pointer instantly, and since you’re not dereferencing it, no page faults occur. The allocator might even mark chunks as MADV_FREE or use lazy metadata, keeping things light.
But here’s the catch—it’s not infinite. Virtual address space tops out around 128TB on arm64, but practical limits hit from fragmentation or address space layout randomization (ASLR). Still, on 8GB devices, you can stack 10+ GB allocations before trouble, as long as they stay untouched.
Real-world test: developers report looping 5-10GB without issues, matching your logcat-free runs. Touch it? That’s when physics bites back.
Android RAM Overcommitment Mechanics
Ever wonder why Linux (and Android) doesn’t just say “no” at malloc? Overcommitment boosts efficiency. Servers fork processes constantly; if every malloc checked physical RAM, startups would crawl.
On Android, the kernel’s memory management docs spell it out: pages allocate lazily on first write. Malloc reserves VA (virtual addresses). Memset triggers minor page faults—kernel allocates physical pages (or swaps to zRAM). Rapid 1GB touch? That’s thousands of faults per second, spiking memory pressure.
Key players:
- vm.overcommit_memory=2: Heuristic mode. Kernel guesses based on swap + RAM/2. Your 1GB? Approved, no sweat.
- /proc/sys/vm/overcommit_ratio: Often 50% on Android, but irrelevant for unlimited VA.
- zRAM: Compressed swap in RAM. Helps a bit, but 1GB memset blows through it fast.
No malloc failure because failure only hits on ENOMEM from mmap/brk exhaustion—rare without access. It’s by design: bionic allocator guide notes Scudo requests greedily but commits lazily.
On 8GB devices? Plenty of headroom for VA, but physical crunch comes on demand. Fork() analogy: processes share pages copy-on-write until write—same vibe here.
Process Kill on Access: lmkd and OOM Killer
Now the drama: memset(ptr, 0, 1GB). You’re faulting in pages frantically. Kernel PSI (Pressure Stall Information) detects stalls—threads waiting on memory. Pressure mounts, zRAM fills, direct reclaim fails.
Enter lmkd, Android’s userspace Low Memory Killer since Android 9. Unlike old kernel OOM killer, lmkd docs show it polls /dev/kmsg for pressure events, then kills by oom_adj_score.
Your process? High score if foreground (500-1000), worse if background. Logcat screams “InputDispatcher channel broken” because lmkd SIGKILLs mid-frame—UI thread dies, binder channel snaps.
Sequence:
- Memset faults pages → PSI spikes.
- lmkd wakes, scans cgroups by pressure level (partial/full/critical).
- Your app’s score > min_threshold → kill -9.
- No unwind; process vanishes.
Why during memset, not malloc? Malloc=VA only (cheap). Memset=physical demand (expensive). StackOverflow threads confirm: large native mallocs fatten LMK targets.
On Android 14+ (8GB), Scudo exacerbates: tighter guards, but same overcommit.
| Factor | Impact on Kill |
|---|---|
| oom_adj_score | Foreground safer (906), background toast (1000+) |
| PSI pressure | Memset-induced stalls trigger faster |
| zRAM | Delays but doesn’t save 1GB rush |
| Device RAM | 8GB buys time, but not immunity |
No Low Memory Callbacks in Native Code
Frustrating, right? You subclassed Application, overrode onLowMemory and onTrimMemory. Crickets.
Callbacks are Java/ActivityManager territory. onLowMemory broadcasts from system_server when LMK pressure rises—but lmkd kills before that for speed. Lmkd source confirms: direct userspace kills bypass framework.
Native? No equivalent. malloc hooks exist (Android 6+), but not pressure signals. Your C++ loop runs unchecked.
Logcat “channel broken”? Binder death mid-dispatch, post-kill.
Debugging Native Allocations
Catch this early. Skip blind loops—profile.
- mallinfo(): Classic stats.
#include <malloc.h>
struct mallinfo info = mallinfo();
__android_log_print(ANDROID_LOG_INFO, "MEM", "Heap: %d MB used", info.uordblks / (1024*1024));
Logs pre/post-malloc. Native memory debug.
- heapprofd: Android 10+ perf tool.
adb shell /data/local/tmp/heapprofd --dump-heap /data/misc/perfetto-traces/heap.prof
Flame graphs for native leaks.
- /proc/PID/status: VmRSS (resident) jumps on memset.
- lmkd logs:
adb shell logcat | grep lmkd—see kill reason.
Scudo extras: M_MXFAST=0 via mallopt() for large alloc decay.
| Tool | What It Shows | Command |
|---|---|---|
| mallinfo | Heap bytes | C call |
| heapprofd | Allocation stacks | adb shell |
| cat /proc/PID/maps | VA ranges | adb shell |
From dlmalloc to Scudo on Android 14+
Android evolved allocators for security/speed.
- Pre-11: dlmalloc/jemalloc (low-RAM).
- Android 11+ (8GB+): Scudo hardened allocator. Primary guards, decay on free. Still overcommits.
Can’t escape: per-process disable impossible. System vm.overcommit rules all.
Scudo shines for 1GB: metadata-light, but touch still faults.
Preventing Kills: Strategies and Best Practices
Don’t fight overcommit—work with it.
- Gradual access: memset in 1MB chunks, sleep(1) between. Lets LMK pressure breathe.
- madvise(MADV_DONTNEED): Hint lazy reclaim.
madvise(ptr, ONE_GB, MADV_DONTNEED);
- posix_memalign for aligned slabs; avoid 1GB monsters.
- Java Heap: NDK? Bridge to ByteBuffer.allocateDirect(1GB)—GC pressure, but callbacks fire.
- Monitor: Pre-check mallinfo.arena > RAM * 0.7? Bail.
- Cgroups: Set memory.high, but app sandboxed.
Test on emulator: qemu.hw.mainkeys=no + low RAM profile. Low oom_adj via setprop.
Production: Cap iterations at phys_pages() * page_size * 0.4.
Sources
- Android Low Memory Killer (lmkd) — Details lmkd userspace killing and PSI monitoring: https://source.android.com/docs/core/perf/lmkd
- Android Memory Management — Explains kernel overcommit and lazy page allocation: https://developer.android.com/topic/performance/memory-management
- Bionic Native Allocator — Covers vm.overcommit_memory=2 and Scudo behavior: https://android.googlesource.com/platform/bionic/+/HEAD/docs/native_allocator.md
- Native Memory Debugging — mallinfo and malloc hooks for Android: https://source.android.com/docs/core/tests/debug/native-memory
- Android NDK Malloc Effect — Real-world native over-allocation LMK risks: https://stackoverflow.com/questions/15956481/android-ndk-why-is-this-malloc-having-no-observable-effect
- Scudo Hardened Allocator — Security-focused allocator on Android 11+: https://source.android.com/docs/security/test/scudo
Conclusion
Android native memory thrives on kernel overcommitment—malloc wins cheap VA battles, but memset loses the physical war to lmkd. Tune with mallinfo checks, chunked access, and madvise to sidestep kills without ditching C++. Profile relentlessly; your 8GB device forgives more than low-end, but habits stick across hardware. Dive into those docs—next big alloc won’t blindside you.