Memory is the constraint that arrives last and hurts most. A game that fits on a test device with 12 GB will be installed on phones with 4 GB, and the first report will be a crash with no stack trace because the process was killed by the system, not by your code.

What a phone actually gives you

The number on the box is not the number available to an app. The kernel, the vendor services and the launcher take a share, and Android sets a per-app ceiling that depends on the device tier. The ceiling is enforced by the low-memory killer, which does not ask permission.

Approximate per-app limits for a foreground game. Values differ by vendor; measure the device you care about.
Device RAMPer-app ceilingReasonable native budgetTexture budget
4 GB512–768 MB300 MB180 MB
6 GB1.0–1.2 GB600 MB360 MB
8 GB1.5–2.0 GB900 MB540 MB
12 GB and up2.5 GB or more1.4 GB840 MB

These numbers assume the game is in the foreground with one activity. Add a WebView, a video decoder or a second process and the effective budget shrinks, sometimes by 200 MB before your code allocates anything.

Where the memory goes

  • Java heap. Limited by dalvik.vm.heapgrowthlimit, usually 256 or 512 MB. Native games use little of it, but a crash there is a OutOfMemoryError with a Java stack, which is at least easy to find.
  • Native heap. Your allocator, your leaks. This is where the interesting failures live.
  • GPU allocations. Textures, buffers, render targets. On most mobile GPUs this is system memory, counted against the process.
  • Mapped assets. Files mapped from the APK or from expanded files. Cheap to map, expensive when touched.
  • Code and stacks. Small, fixed, and the first thing people blame when the real problem is a texture.

Measure the split, not the total. dumpsys meminfo gives a per-category view, and a native heap profile gives the rest.

adb shell dumpsys meminfo com.example.game | head -40
adb shell am dumpheap -n com.example.game /data/local/tmp/game.hprof

The first command is instant and good enough to see whether you are at 300 or 800 MB. The second captures a heap profile that you can open offline; use it when the total is right but a single category is not.

Allocators

Android's native allocator changed over the years: jemalloc, then scudo as the default for security-critical allocations. A game that allocates millions of small objects at load time will notice the difference in fragmentation, not in speed.

For engine-side allocations we use mimalloc, with a per-frame arena on top. The arena is one large block that is reset at the end of each frame: no free calls, no fragmentation, and a bug that writes past the end is a crash at a known address instead of a mystery six frames later.

class FrameArena {
 public:
  explicit FrameArena(size_t bytes) : base_(mi_malloc(bytes)), cap_(bytes) {}
  ~FrameArena() { mi_free(base_); }

  void* alloc(size_t n) {
    n = (n + 15) & ~size_t{15};
    if (used_ + n > cap_) return nullptr;   // caller decides what to do
    void* p = static_cast<char*>(base_) + used_;
    used_ += n;
    return p;
  }
  void reset() { used_ = 0; }

 private:
  void* base_; size_t cap_; size_t used_ = 0;
};

Two habits come with the arena. First, anything that outlives a frame cannot come from it; make that explicit in the type system so a level object cannot accidentally keep a pointer into a reset arena. Second, size the arena from the busiest frame you have measured, plus 30 percent, and log an overflow instead of falling back to the global allocator.

Textures are the budget

A 2048 by 2048 RGBA8 texture is 16 MB. With a full mip chain it is 21.3 MB. Ten of those is 213 MB, which is most of a 4 GB device's texture budget before the level is loaded.

Three levers, in order of effect:

  1. Format. ASTC 6x6 is 3.5 bits per pixel: the same texture becomes 3.5 MB instead of 16. Visual loss is small for albedo, noticeable for normal maps at grazing angles.
  2. Mips. A chain costs a third of the base level. Keep it for anything sampled at distance; skip it for UI and for textures that are always screen-sized.
  3. Streaming. Load the mip levels a level needs, not every level of every texture. A 512 MB texture set often has 200 MB that is never sampled at full resolution.

Compression is not free to decode, but on a tiled GPU the decode happens in the texture unit and does not cost shader time. The real cost is authoring: a normal map compressed at 6x6 needs its own validation pass, and some art does not survive it.

The killer is not your leak

The low-memory killer reclaims processes when the device is under pressure, and it does not distinguish a backgrounded game from one the player is about to return to. Your process can be killed while paused, with no callback you can rely on to save state.

What you can do:

  • Save progress on onStop, not only on onDestroy. Assume no further callback.
  • Handle onTrimMemory(TRIM_MEMORY_RUNNING_LOW) by dropping caches and unused textures, not by allocating a save buffer.
  • Keep the peak allocation below the ceiling in the table above, not at it. A device at 95 percent of its ceiling is one texture away from a kill.
  • Test with the device under pressure: start a video call, then return to the game. This finds budget assumptions that a clean device never will.

A budget you can enforce

A number in a document is not a budget. Put it in the build: a tracking layer that knows the category of each allocation, a check at level load, and a warning in the log when a category crosses its share. Ours runs in release builds with a cheap atomic counter and costs under 1 percent of frame time.

enum class Kind { Texture, Mesh, Audio, Other };

void* allocate(Kind kind, size_t bytes) {
  used_[static_cast<int>(kind)].fetch_add(bytes);
  if (over_budget(kind))
    LOGW("budget: %s at %zu MB", name(kind), used_mb(kind));
  return mi_malloc(bytes);
}

The log line is the point: a warning at load time names the category and the level, which turns a crash report into a fix. The packaging article covers the download side of the same assets, and the Android 17 preview note has the new ceiling numbers.