Android Memory Optimization Series:
- Android Code Memory Optimization Suggestions - Android (Official)
- Android Code Memory Optimization Suggestions - Java (Official)
- Android Code Memory Optimization Suggestions - Android Resources
- Android Code Memory Optimization Suggestions - OnTrimMemory
For GC to reclaim unused objects, longer-lived owners must stop retaining references that are no longer needed. Reachability from GC roots matters, not whether another object holds a reference: a group of mutually referencing objects can still be collected when the whole group is unreachable. Becoming unreachable does not mean GC runs immediately or that the memory is immediately returned to the OS.
Memory management also includes bounded caches, resource ownership, and component lifecycles. Close files, connections, and native resources through their own APIs rather than waiting only for their Java wrappers to be collected.
Technical review: October 13, 2026. This article originated in Android’s memory guidance from around 2015. Advice about IntentService, some trim callbacks, nano protobuf, dependency injection, and APK packaging has changed. The original 16 topics are retained with version boundaries. Fixed object sizes and blanket prohibitions are no longer presented as general Android rules.
Introduction
The historical source is Managing Your App’s Memory in AOSP android-5.1.1_r1. Start with the current Android memory documentation for newer guidance. Distinguish the Java heap, native/graphics allocations, code mappings, and whole-process memory instead of treating them as one heap number.
1. Use Services Sparingly
Keep a Service active only while work requires it, and end its started work or bindings when appropriate. A Service is a component, not an automatic worker thread or a guarantee that its process cannot be killed. Unnecessary resident work consumes resources, but system process reclamation and Java memory leaks are different problems. Stopping a Service does not fix every leak.
The old recommendation to use IntentService is outdated. It stops after processing its queue, but is subject to Android 8.0 (API 26) background execution limits and was deprecated in Android 11 (API 30). See the IntentService API.
For persistent tasks that need scheduling across app exits or device reboots, evaluate WorkManager. Short tasks tied to a screen should be cancelled with that screen. User-visible ongoing work such as playback must follow the relevant foreground-service rules; replacing every Service with WorkManager is not the answer.
2. Release Memory When UI is Hidden
When UI is no longer visible, reduce caches retained only for that UI and which can be rebuilt. Do not also discard data needed for playback, uploads, or state restoration, or call recycle() on Bitmaps still used elsewhere.
TRIM_MEMORY_UI_HIDDEN, available since API 14, indicates that the current process no longer displays UI. It is different from one Activity’s onStop() and is not a unified notification covering every process of an app. onStop() can end work tied to that Activity’s visible lifetime. Release resources according to their ownership and acquisition lifecycle rather than waiting for UI_HIDDEN in every case.
This is a callback-method fragment for an existing Application or Activity class. Connect the comments to the app’s own cache-cleanup operations, and handle thresholds rather than only one exact value:
1 |
|
This callback is an opportunity to adjust caches, not a guaranteed notification before process death. Keep cache capacities bounded during normal use and persist user state promptly. See the current ComponentCallbacks2 contract.
3. Release Memory During Pressure
Version change: from Android 14 (API 34), apps no longer receive five old pressure levels: RUNNING_MODERATE, RUNNING_LOW, RUNNING_CRITICAL, MODERATE, and COMPLETE. The constants were deprecated in API 35. UI_HIDDEN and BACKGROUND remain useful for memory management during process-state changes. BACKGROUND alone does not prove that the device is critically short of memory.
3.1 While the App is Running
These are historical meanings for interpreting old code and logs, not a current sequence of callbacks:
| Old level (all introduced in API 16) | Historical meaning |
|---|---|
TRIM_MEMORY_RUNNING_MODERATE |
Memory pressure is beginning; consider reducing unnecessary caches. |
TRIM_MEMORY_RUNNING_LOW |
Pressure is increasing; release unused resources. |
TRIM_MEMORY_RUNNING_CRITICAL |
Pressure is severe; reduce non-essential usage as far as possible. |
These hints never guaranteed that a running app could not be killed. A trim level also cannot establish whether an OOM, system process kill, or app crash has occurred.
3.2 While the App is Cached
TRIM_MEMORY_BACKGROUND (API 14) indicates entry into the background LRU list and offers a chance to release rebuildable caches. The old MODERATE and COMPLETE levels, introduced in API 14 and no longer delivered from API 34, indicated positions nearer the middle and end of the background list. Do not depend on them to trigger progressively stronger cleanup today.
onLowMemory() is not a current fallback: it is no longer called from API 34 and was deprecated in API 35. The original compatibility advice for devices below API 14 is historical. See ComponentCallbacks and the Android 16 ComponentCallbacks2 source annotations.
Reducing background usage can help the system retain more cached processes, but an app cannot guarantee a fixed order of process survival.
4. Check Your Memory Limits
getMemoryClass() reports an approximate normal application memory class in MB, useful for budgeting. It is neither remaining heap capacity nor a limit on total process RSS/PSS. Observe Java, native, and graphics allocations separately; subtracting current usage from this value cannot guarantee that the next allocation will succeed.
For a justified large-heap requirement, set android:largeHeap="true" on <application> and consult getLargeMemoryClass(). Available since API 11, it can equal the normal memory class on some devices. Runtime.getRuntime().maxMemory() reports the maximum Java heap memory the current runtime will attempt to use, in bytes, also not remaining capacity.
Do not use largeHeap as a general OOM or leak fix. Identify who allocates and retains memory, and why retention is necessary. A larger live set can increase GC and system pressure, but does not make every collection slower.
5. Avoid Bitmap Waste
Decode images at the size required for display, zooming, or editing. Loading a full-resolution image just for a small thumbnail usually adds no value, but zoom or crop requirements must be considered too.
Compressed file size is not decoded memory size. At four bytes per pixel, a 1500 × 1000 ARGB_8888 image requires an estimated 6,000,000 bytes, about 5.72 MiB, even if its file occupies only 100 KB. This does not include every object, reused allocation capacity, or additional graphics cost.
From API 19, getAllocationByteCount() reports allocated pixel capacity. After reuse or reconfiguration it can exceed getByteCount(). Account for where pixel storage lives rather than treating the Java wrapper’s size as the entire bitmap cost.
6. Use Optimized Data Collections
SparseArray<E> uses int keys, avoiding key boxing and a separate entry object for each mapping, but its values are still objects. Specialized containers such as SparseBooleanArray and SparseIntArray also use their corresponding primitive value types. LongSparseArray<E> supports long keys; the framework version is available from API 16. The old android.support.v4 link is no longer the current entry point.
These containers prioritize memory efficiency. Lookups generally use binary search, while insertion and removal involve array maintenance, so they are not guaranteed to outperform HashMap. Choose by key/value types, size, and access patterns.
7. Be Aware of Memory Overheads
The old figures—roughly 500 bytes per class, 12–16 bytes of instance overhead, and enums costing over twice as much—were rough descriptions of an older environment. They are not layout guarantees from Java or Android. The historical 500-byte figure described code size, not fixed RAM usage after loading a class. Object headers, field layout, alignment, runtime implementation, and optimization affect actual costs.
The historical 32-byte figure referred to a HashMap entry object, not a rule that boxing one int always allocates 32 bytes. A container’s footprint also includes its arrays, references, keys, values, and sharing relationships.
Enums offer type safety and clear semantics; an old ratio is not a reason to prohibit them throughout Android code. If many small objects form a bottleneck, inspect counts, retention paths, and actual usage on the current build and device before comparing representations.
8. Be Careful with Abstractions
Abstraction can introduce objects, indirection, and initialization. More source-level classes do not prove that all of them must occupy memory or impose a fixed runtime cost: optimization, class loading, and executed paths matter.
I would keep interfaces that support clear responsibilities, testing, and evolution. Once a hotspot is identified, compare implementations that preserve those contracts. Removing useful module boundaries usually does not fix a retention chain or an unbounded cache.
9. Use Nano ProtoBufs for Serialized Data
“Always use nano on Android” is outdated. Evaluate Protobuf Java Lite against the required features; its runtime artifact is com.google.protobuf:protobuf-javalite. It trades features for mobile binary-size and memory goals, does not offer the full Java runtime’s entire feature set, and is not always faster.
From Protobuf 3.8.0, lite generation is built into protoc’s Java output through the lite option. This is a migration boundary, not a recommendation to pin new projects to 3.8.0. See the official Gradle plugin documentation. Check the generator, generated code, runtime versions, data compatibility, and R8 rules together; changing one dependency name is not a complete migration.
The original DEX limitation concerns method and other symbol references, not string length. Generated code size is also different from the memory occupied by live objects at runtime.
10. Avoid Dependency Injection Frameworks
The blanket prohibition in this old heading is outdated. The original concern was runtime scanning in frameworks such as Guice/RoboGuice, not every form of dependency injection. Current Android guidance recommends Hilt, built on Dagger’s compile-time code generation. Avoiding findViewById() and injecting application dependencies are separate concerns.
Simple cases can construct dependencies manually. This complete Java class accepts its source of names from the caller without a framework or runtime annotation scan:
1 | public final class GreetingService { |
The caller supplies a Names implementation, which tests can replace. More complex object graphs can use Hilt/Dagger. For memory review, inspect creation timing, scope, and ownership: a long-lived container must not retain a short-lived Activity unintentionally. Compile-time generation does not mean zero initialization cost or freedom from leaks.
11. Be Cautious with External Libraries
Before adding a library, inspect its maintenance status, dependency graph, initialization, caches, and final output. R8 may remove unused code when safe, so source or download size alone does not establish the APK or RAM increase.
Watch for duplicate logging, image-loading, serialization, and cache implementations. If they are all used, a shrinker cannot decide which behavior to remove for you. Consider smaller modules while accounting for the correctness and maintenance benefits of established libraries; using only a few features is not automatically a reason to rewrite them yourself.
12. Optimize Overall Performance
Evaluate memory changes in user flows. Smaller caches may cause frequent reloads, and an allocation-saving change may add main-thread work.
Use Layout Inspector to inspect UI hierarchy and Android Lint for static findings. Confirm the resulting benefit during startup, scrolling, and screen transitions.
13. Use ProGuard
Modern Android builds primarily use R8, the default code shrinker since AGP 3.4.0. Check settings such as isMinifyEnabled for release code optimization and isShrinkResources for resource shrinking. Upgrading AGP does not enable optimization in every build.
R8 can remove unreachable code, optimize calls, and obfuscate names. Smaller code does not automatically reduce runtime caches, Bitmap allocations, or leaks. Maintain keep rules for the actual library, reflection, and JNI requirements rather than keeping entire packages to hide a problem. See app optimization setup.
14. Use zipalign
zipalign aligns uncompressed APK entries for direct memory mapping. Normal Android Studio/AGP builds perform alignment automatically, so manual modification of the final file is usually unnecessary.
With apksigner, align first and sign afterwards. Modifying the APK after signing invalidates its signature. The old jarsigner workflow has a different order; the original advice to realign after compilation cannot simply be applied to every modern workflow.
To inspect an existing APK without modifying it, use the tool from Android SDK Build-Tools 35.0.0 or later and replace the example filename with your artifact:
1 | zipalign -c -P 16 -v 4 app-release.apk |
-P 16 checks 16 KiB page alignment for uncompressed .so files; the final 4 specifies four-byte alignment for other uncompressed entries. For 16 KB page-size devices, the official guidance recommends AGP 8.5.1 or later and NDK r28 or later, with compatible prebuilt dependencies. ELF alignment, native page-size assumptions, and runtime behavior still require checking; passing zipalign alone does not establish all of these. See 16 KB page-size support.
15. Analyze RAM Usage
Inspect memory throughout development. Exercise startup, repeated screen entry/exit, scrolling, rotation, and foreground/background transitions. Look for whether usage settles, caches remain bounded, and instance counts keep growing.
Android Studio heap dumps expose Java/Kotlin objects and retention paths. Native and graphics memory also require the relevant profiler or system data. Distinguish shallow size, retained size, process RSS, and PSS’s proportional accounting of shared pages instead of judging everything by one Java heap peak.
Heap dumps and allocation tracking perturb the observed app. After a fix, recheck user flows on physical devices with a configuration close to release, rather than treating one debugging measurement as stable performance.
16. Use Multiple Processes
A separate process can sometimes allow a heavy UI process to be reclaimed independently in the background, but adds runtime, initialization, cache, and IPC costs. Unreachable UI objects can already be collected within a shared process. Continued music playback does not imply that every UI allocation must remain alive.
This snippet shows only process isolation, not a complete background-playback configuration:
1 | <service android:name=".PlaybackService" |
The colon prefix names an app-private process, not an independent permission sandbox. Processes have separate heaps, static state, and Application initialization; ordinary in-memory references do not share objects between them. Also inspect bindings and ContentProvider dependencies on the UI process, which may prevent the intended reclamation.
For background playback, start with Media3’s MediaSessionService and its notification, foreground-service declaration, and lifecycle requirements. A separate Service component does not require a separate process. Compare the total cost of all relevant processes and the restoration experience before splitting them.
About Me && Blog
Here are my introduction and related links. I welcome conversations with fellow developers and opportunities to learn from one another.
- About me: contact details and WeChat groups.
- Blog navigation: a guide to this blog’s articles.
- Android Performance Optimization: Essential Skills and Tools: recommendations and contributions are welcome through WeChat.
- Android Performance community: The paid community stopped operating in 2026 and no longer accepts new members; this link preserves its history.
If you want to go fast, go alone. If you want to go far, go together.
