Mobile Dev

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.

1 answer 2 views

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 during memset(), not at malloc().
  • 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

c
#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

  1. Why does Android’s native malloc() allow overcommitting memory beyond physical RAM when unaccessed?
  2. Why is the process killed on access (memset()) instead of malloc() 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

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:

  1. Memset faults pages → PSI spikes.
  2. lmkd wakes, scans cgroups by pressure level (partial/full/critical).
  3. Your app’s score > min_threshold → kill -9.
  4. 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.

  1. mallinfo(): Classic stats.
c
#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.

  1. heapprofd: Android 10+ perf tool.
adb shell /data/local/tmp/heapprofd --dump-heap /data/misc/perfetto-traces/heap.prof

Flame graphs for native leaks.

  1. /proc/PID/status: VmRSS (resident) jumps on memset.
  2. 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.
c
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

  1. Android Low Memory Killer (lmkd) — Details lmkd userspace killing and PSI monitoring: https://source.android.com/docs/core/perf/lmkd
  2. Android Memory Management — Explains kernel overcommit and lazy page allocation: https://developer.android.com/topic/performance/memory-management
  3. Bionic Native Allocator — Covers vm.overcommit_memory=2 and Scudo behavior: https://android.googlesource.com/platform/bionic/+/HEAD/docs/native_allocator.md
  4. Native Memory Debugging — mallinfo and malloc hooks for Android: https://source.android.com/docs/core/tests/debug/native-memory
  5. 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
  6. 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.

Authors
Verified by moderation
Moderation
Android Native: malloc Succeeds But Memset Kills Process