CRITICAL · OutOfMemoryError Risk

Android
Memory
Leaks

Objects that should be dead, kept alive by forgotten references. A complete guide to understanding, detecting, and eliminating every category of memory leak in Android — including how GC pressure leads to OOM and ANR.

~30%
of ANRs linked to OOM
12+
leak categories covered
0
acceptable leaks in prod
01 · Foundation

Android Memory Model

Android runs each app in a sandboxed ART process with a fixed heap limit — from 32 MB on older devices to 512 MB+ on modern flagships. Cross this limit and the OS throws OutOfMemoryError, crashing your app immediately.

The heap is divided into generations. The Young generation holds short-lived objects collected quickly and cheaply. The Old generation holds long-lived tenured objects — this is where leaked objects accumulate and where GC becomes expensive.

Young Generation — short-lived
Eden space
new objects born here
Survivor S0 / S1
survived 1+ GC cycles
Minor GC
fast, 1–5ms typical
Old Generation — leak zone
Tenured objects
survived many GC cycles
Leaked Activities
should be dead — still reachable
Major GC
slow, 10–100ms, stop-the-world pauses
Special reference types
WeakReference
collected on next GC if only ref
SoftReference
collected only on OOM pressure
PhantomReference
post-finalization cleanup
Heap regions (dumpsys meminfo)
Java Heap
20–80 MB — your objects live here
Native Heap
bitmaps (API 26+), JNI, C++ malloc
Graphics / Code / Stack
GPU textures, .dex, thread stacks

Bitmaps changed in Android 8 (Oreo): Before API 26, bitmap pixel data lived on the Java heap — a 16 MB image consumed 16 MB of your heap limit directly. From API 26+, pixel data moved to native memory. Bitmap leaks no longer cause Java OOM directly, but they still cause native OOM and increase device memory pressure. Always use an image loading library.

02 · Garbage Collection

GC, Roots & Reachability

The garbage collector works by tracing all object references starting from GC Roots. Any object reachable from a root is considered alive and will not be collected. Memory leaks occur when a logically dead object is still reachable from a root through a forgotten reference chain.

Definition: A memory leak is an object that is no longer needed by your application, but is still reachable from a GC root. The GC cannot collect it. It accumulates in the Old generation. Eventually your heap fills and the app crashes with OutOfMemoryError.

GC Roots in Android

Static fields of any class — live for the entire process lifetime. The most common leak root.
Local variables on the stack of any running thread — including Handler messages, posted Runnables.
Running threads themselves — and every object they capture in their closure.
JNI references held by native code — invisible to Java-layer analysis tools.
System class loader — keeps all loaded classes and their static fields alive forever.

The leak chain always has the same structure: GC Root → long-lived object → leaked object. Example: static fieldSingletonActivity. The Activity is done but the Singleton holds it. GC traces from the static field, marks the Activity alive. Its entire 50 MB view hierarchy stays in memory — per rotation.

03 · GC Internals

GC Deep Dive — OOM & ANR

The garbage collector is your ally — but memory leaks turn it into your enemy. Understanding the full cycle from allocation to collection to crash explains exactly why leaks produce the specific symptoms they do: sluggishness, jank, and ultimately a process kill.

The full allocation & collection cycle

ALLOC
1 · Allocation — Eden space

Every new Object() is born in Eden space. ART uses a bump-pointer allocator — just increment a pointer. Extremely fast. When Eden fills, a Minor GC fires automatically.

MINOR
2 · Minor GC — Young generation sweep

ART traces GC roots through the Young generation only. Live objects are copied to a Survivor space. Dead objects in Eden are discarded instantly — no deallocation cost. Objects surviving multiple cycles are promoted to Old generation. Typical pause: 1–5 ms. You never notice this.

MAJOR
3 · Major GC — Old generation compaction

When Old generation fills, ART triggers a Full GC — traces the entire heap. ART's concurrent GC (Android 5+) runs most phases alongside your app, but still has mandatory stop-the-world checkpoints. Typical pause: 10–100 ms. On a leaked heap full of dead-but-reachable objects, GC takes much longer because it must trace every leaked object before determining nothing can be freed.

THRASH
4 · GC thrashing — the pre-OOM death spiral

When the heap is near-full from leaked objects, the allocator runs GC before every new allocation. Each GC frees almost nothing — leaked objects survive every collection. The VM spends more time collecting than executing your code. CPU spikes to 100%. UI thread is blocked constantly. This is GC thrashing — the direct precursor to OOM and the cause of most leak-driven ANRs.

CRASH
5 · OutOfMemoryError — heap exhausted

After multiple failed GC attempts, the VM throws OutOfMemoryError. You cannot meaningfully catch and recover from this — the process is doomed. Triggered by any allocation that pushes usage past Runtime.maxMemory(): decoding a bitmap, inflating a layout, creating a large array.

Interactive — GC pause impact visualizer

Drag the slider to simulate leaked Activities accumulating in the Old generation. Watch how GC pause times grow, frame rendering degrades, and the path to ANR and OOM shortens.

GC PRESSURE SIMULATOR
drag → watch the cascade
Leaked Activities:
0
Heap Used
24 MB
GC Pause
2 ms
Frame Time
16 ms
Risk Level
None
Frame rendering — 16ms budget per frame (amber line)
✓ Healthy — Sticky CC collecting in under 5ms. All frames within 16ms budget.

ART GC algorithms — which one is running

Default — fast path
Sticky Concurrent Copying (CC)
Only collects recently allocated objects. Sub-millisecond pauses. ART uses this most of the time in healthy apps. Leaks push the app out of this fast path because the Old generation fills up and requires full collection.
Young gen — normal
Concurrent Copying (CC)
Copies live Young generation objects to a new space while your app runs concurrently. Very short stop-the-world checkpoints only. Eliminates fragmentation by compacting during the copy phase. Pause: 1–5 ms.
Old gen — expensive
Mark-Compact
Triggered when Old generation fills. Traces entire heap, marks all live objects, then compacts them together closing gaps. Fixes fragmentation but significantly slower. On a leaked heap this runs constantly — causing GC thrashing.
Never call this
Explicit GC — System.gc()
ART may ignore it. When it does run, it typically triggers the most expensive Mark-Compact. Calling it manually causes unnecessary pauses with no guaranteed benefit. The GC decides when to run — always. Never call System.gc() in production code.

OOM anatomy — the 5-phase timeline

Phase 1 — Normal operation

Minor GC collects in under 5 ms. Old generation has room. Heap: 20–40 MB. GC log shows Sticky CC. App fully responsive.

Phase 2 — Leak accumulation

User rotates device 5 times. 5 leaked Activities × ~22 MB = 110 MB in Old generation. Major GC fires more frequently. Pauses grow to 50–200 ms. Logcat shows repeated GC_FOR_ALLOC. App feels sluggish.

Phase 3 — GC thrashing

Old generation 80%+ full. GC runs after every allocation — 100–500 ms each, freeing almost nothing. App spends more time in GC than executing. Frames take 200 ms+. UI visibly frozen. This is where users leave one-star reviews.

Phase 4 — Allocation failure

App tries to decode a thumbnail or inflate a layout. Allocator needs 8 MB. GC runs, frees 2 MB — not enough. GC runs again. Still not enough. After 3–5 failed attempts, ART prepares to throw.

Phase 5 — OOM crash

java.lang.OutOfMemoryError: Failed to allocate a 8388608 byte allocation with 2097152 free bytes and 12MB until OOM — process killed. The leaked memory was never the immediate trigger. It was the slow invisible pressure that made the final allocation impossible.

Dissecting the OOM error message

java.lang.OutOfMemoryError:
Failed to allocate a 8,388,608 byte allocation with 2,097,152 free bytes and 12,582,912 total bytes until OOM, max allowed footprint 134,217,728
8,388,608 bytes = 8 MB
The failing allocation — a bitmap decode, layout inflate, or large array. This is the last straw, not the root cause.
2,097,152 bytes = 2 MB free
Free within current heap total. Pathetically small — leaked objects consumed nearly everything despite GC attempts.
12,582,912 bytes = 12 MB headroom
Available if the heap expands to max. Still not enough for the 8 MB allocation. Even theoretical max is exhausted.
134,217,728 bytes = 128 MB limit
The hard ceiling for this process, set by ActivityManager.getMemoryClass(). Entirely consumed by accumulated leaked objects.

From GC to ANR — how memory pressure freezes the UI

An Application Not Responding (ANR) fires when the main thread is blocked for more than 5 seconds. Memory leaks are a major hidden ANR cause. The connection is not obvious until you understand how GC interacts with the UI thread.

The GC-ANR connection: Even ART's concurrent GC has mandatory stop-the-world checkpoints. On a heap bloated with leaked objects, GC takes much longer to complete — and those checkpoints block the main thread proportionally. 10 leaked Activities can turn a 5 ms GC pause into a 200 ms freeze. Repeated every few seconds — that is an ANR.

ANR Cause 1
GC stop-the-world checkpoints
Concurrent GC has two stop-the-world phases: initial mark (pause all threads to find roots) and remark (pause to process changes made during concurrent marking). On a bloated heap, these pauses compound. Add a GC triggered during a user gesture — visible jank or ANR.
ANR Cause 2
GC_FOR_ALLOC on main thread
When the allocator cannot satisfy a request, it triggers GC_FOR_ALLOC — a blocking GC that runs synchronously on the requesting thread. If the main thread is decoding a bitmap or inflating a layout, it freezes until GC completes. All heavy allocation must happen on background threads.
ANR Cause 3
Finalizer queue backup
Objects with finalize() methods (Cursor, pre-API26 Bitmap) go through a FinalizerDaemon thread before being truly collected. If this daemon falls behind — common during GC thrashing — the finalizer queue grows and the heap appears full even though objects are technically unreachable. A hidden source of memory pressure.
ANR Cause 4
Low Memory Killer cascade
When system RAM is critically low, the kernel Low Memory Killer terminates cached processes. If your app caused other processes to be killed by consuming too much RAM, IPC calls to those killed processes — ContentProvider queries, service bindings — block indefinitely, causing ANR in your own foreground app.

Reading GC signals in Logcat

ART logs every GC event. Run adb logcat | grep -E "art|GC|heap" while exercising your app. Steadily growing heap size and increasing pause times are the first visible warning signs of a leak — before any crash occurs.

Logcat GC output — annotated
// Format: GC_REASON(GC_TYPE) freed K(Y%) paused Xms+Xms total Xms

I/art: GC_CONCURRENT(sticky) freed 4MB (31%), paused 1.5ms+0.5ms, total 18ms
// ✓ HEALTHY — Sticky CC fast path. Tiny pause. Lots freed.

I/art: GC_FOR_ALLOC(full) freed 512KB (2%), paused 120ms+45ms, total 165ms
// ⚠ WARNING — Allocator failed. Blocking GC ran synchronously on calling thread.
//             If that thread was Main — UI was frozen for 165ms.

I/art: Heap size=128MB, alloc=124MB, free=4MB (3%)
I/art: GC_BEFORE_OOM freed 256KB (0%), paused 348ms, total 350ms
// ✗ CRITICAL — Last-ditch GC. Freed almost nothing. OOM imminent.

E/art: Out of memory on a 8388616-byte allocation.
java.lang.OutOfMemoryError: Failed to allocate a 8388616 byte allocation
  with 1048576 free bytes and 4MB until OOM, max allowed footprint 134217728

// GC_REASON reference:
//   GC_CONCURRENT  — background GC, proactive, non-blocking
//   GC_FOR_ALLOC   — allocator failed, blocking on requesting thread ← worst
//   GC_EXPLICIT    — System.gc() was called — never do this in prod
//   GC_BEFORE_OOM  — last-ditch attempt, almost always followed by OOM
//   GC_HPROF_DUMP  — heap dump (LeakCanary triggers this)

Reading an ANR trace — main thread blocked by GC

ANR traces.txt — GC-caused blockage
ANR
// ANR in com.myapp — Input dispatching timed out

// ─── MAIN THREAD (tid=1) — your blocked UI thread ───
"main" prio=5 tid=1 WaitingForGcToComplete
  | state=S
  at java.lang.Object.wait!(Object.java:-2)
  at com.android.internal.os.BinderInternal.waitForGcToComplete  ← waiting for GC!
  at android.os.Binder.blockUntilThreadAvailable (Binder.java:...

// ─── GC THREAD — the cause ───
"HeapTaskDaemon" prio=5 tid=6 Runnable
  | state=R (running — running full Mark-Compact on 120MB bloated heap)
  at dalvik.system.VMRuntime.runFinalization

// What to look for in a GC-caused ANR:
//   "WaitingForGcToComplete" in main thread state
//   "HeapTaskDaemon" thread is Runnable (running GC)
//   Large heap size in the trace header
//   FinalizerDaemon backed up with many pending objects

Measuring heap pressure in code

Runtime memory queries
val rt = Runtime.getRuntime()
val maxHeap   = rt.maxMemory()           // process heap limit e.g. 128 MB
val totalHeap = rt.totalMemory()         // current total (grows up to max)
val freeHeap  = rt.freeMemory()          // free within current total
val available = freeHeap + (maxHeap - totalHeap)  // true headroom

val am = getSystemService(ActivityManager::class.java)!!
val heapMB = am.memoryClass              // soft per-app limit for device tier

// System RAM pressure
val info = ActivityManager.MemoryInfo()
am.getMemoryInfo(info)
val lowMemory = info.lowMemory           // true = LMK is actively killing processes

// The rotation test — run this before and after rotating 5 times
// $ adb shell dumpsys meminfo com.myapp | grep "Java Heap"
// Java Heap PSS growing 20-50MB per rotation = confirmed leak

if (BuildConfig.DEBUG) {
    val pct = ((maxHeap - available) * 100.0 / maxHeap).toInt()
    Log.d("Heap", "$pct% used — ${available/1024/1024}MB free of ${maxHeap/1024/1024}MB")
}
💡

android:largeHeap="true" requests a larger heap from the manifest. This delays OOM but does not fix leaks. It also signals to the OS that your app is memory-hungry — making your process a higher-priority target for the Low Memory Killer when system RAM is low. Fix the leaks instead of enlarging the bucket.

04 · Leak Type

Static Reference Leaks

Static fields live for the lifetime of the process. Anything stored in a static field — or in a singleton backed by a static field — must never hold a reference to an Activity, Fragment, View, or Context. These have lifecycle-bound lifetimes. Static fields do not.

Static reference leaks
LEAK
object AnalyticsManager {
    var currentActivity: Activity? = null  // rotates once = 1 leaked Activity + full hierarchy
}
companion object {
    private var cachedView: View? = null  // View holds Context (Activity) → leak chain
}
class ImageLoader(val context: Context) {
    companion object {
        fun init(ctx: Context) = ImageLoader(ctx)  // accidentally passing Activity context
    }
}
Fixes
FIX
object AnalyticsManager {
    private var ref: WeakReference<Activity>? = null
    fun attach(a: Activity) { ref = WeakReference(a) }
    fun detach() { ref = null }
}
fun init(ctx: Context) = ImageLoader(ctx.applicationContext)  // Application context, safe
05 · Leak Type

Inner Class Leaks

Non-static inner classes and anonymous classes hold an implicit reference to their enclosing outer class. If the inner class outlives the outer — via Handler, background thread, or stored callback — the outer class is leaked.

Inner class & Handler leaks
LEAK
class SplashActivity : AppCompatActivity() {
    inner class SplashHandler : Handler(Looper.getMainLooper()) {
        override fun handleMessage(msg: Message) { navigate() }  // holds SplashActivity
    }
    private val handler = SplashHandler()
}
Fix
FIX
// Modern: use lifecycleScope — automatically cancelled in onDestroy
private fun startTimer() {
    lifecycleScope.launch { delay(5000); navigate() }
}
💡

Modern advice: Replace all Handler.postDelayed in Activities and Fragments with lifecycleScope.launch { delay(ms); doWork() }. The coroutine is automatically cancelled when the lifecycle is destroyed — no manual cleanup needed.

06 · Leak Type

Context Leaks

Context is the single most leaked object type in Android. There are two fundamentally different kinds. Storing the wrong one in a long-lived object causes leaks every time.

Application Context — safe for long-lived
context.applicationContext
lives for process lifetime
Singletons / Repositories
safe to hold
ViewModels
safe to hold
Activity Context — never store long-term
Activity / Fragment
destroyed on rotation
Views
hold Activity reference
Dialogs (unclosed)
hold window token
Fix — Hilt Application context injection
FIX
@HiltViewModel
class GoodViewModel @Inject constructor(
    @ApplicationContext private val ctx: Context  // Application context, safe
) : ViewModel()

override fun onDestroy() {
    dialog?.dismiss(); dialog = null  // always dismiss dialogs
    super.onDestroy()
}
07 · Leak Type

Listener & Callback Leaks

Registering a listener creates a reference from the system to your Activity. Forgetting to unregister keeps that reference alive long after the Activity should be dead. Extremely common with BroadcastReceivers, LocationManager, SensorManager, and custom event buses.

Symmetric register / unregister
FIX
// ✓ Symmetric — onStart/onStop, onResume/onPause
override fun onStart() {
    super.onStart()
    registerReceiver(receiver, filter)
    locationManager.requestLocationUpdates(provider, 0, 0f, locationListener)
}
override fun onStop() {
    unregisterReceiver(receiver)
    locationManager.removeUpdates(locationListener)
    super.onStop()
}

// ✓ Better — Lifecycle-aware observer auto-unregisters
lifecycle.addObserver(LocationObserver(locationManager))
08 · Leak Type

Coroutine & Scope Leaks

Coroutines in the wrong scope — or flow collection without lifecycle awareness — are the most modern and most underestimated leak category. They can keep entire coroutine contexts, suspend functions, and all captured closures alive indefinitely.

Coroutine leak patterns → fixes
// ✗ GlobalScope — never cancelled, outlives Fragment
GlobalScope.launch { val data = repo.fetch(); binding.tv.text = data }

// ✗ collectAsState — keeps collecting even when UI is in background
val state = viewModel.flow.collectAsState()

// ✓ lifecycleScope — cancelled in onDestroy automatically
// ✓ viewLifecycleOwner.lifecycleScope — cancelled in onDestroyView
// ✓ viewModelScope — cancelled in onCleared

override fun onViewCreated(view: View, state: Bundle?) {
    viewLifecycleOwner.lifecycleScope.launch {
        repeatOnLifecycle(Lifecycle.State.STARTED) {
            viewModel.uiState.collect { render(it) }
        }
    }
}

// ✓ In Compose — lifecycle-aware collection
val state = viewModel.flow.collectAsStateWithLifecycle()
09 · Leak Type

Bitmap & Drawable Leaks

Bitmaps are the largest objects in most Android apps. A single 2048×2048 ARGB_8888 image consumes 16 MB. Multiply by cached thumbnails and background images and you have the #1 source of OOM crashes — especially on devices below API 26 where bitmap data still lives on the Java heap.

Bitmap best practices
FIX
// ✓ Always use an image loading library — Coil, Glide, or Picasso
// They handle: lifecycle-awareness, caching, down-sampling, and recycle
binding.imageView.load(url) {
    crossfade(true)
    size(ViewSizeResolver(binding.imageView))  // auto-samples to view size
}  // Coil: request cancelled when Fragment is destroyed

// ✓ If manual, always clean up in onDetachedFromWindow
override fun onDetachedFromWindow() {
    bitmap?.recycle(); bitmap = null
    super.onDetachedFromWindow()
}
10 · Leak Type

Compose-specific Leaks

Compose has a different memory model than Views but introduces its own patterns — particularly around effects, state, and the Compose/View interop boundary.

Compose leaks → fixes
// ✗ DisposableEffect with empty onDispose — listener never removed
DisposableEffect(vm) {
    vm.addListener(listener)
    onDispose { }  // EMPTY — listener lives forever
}

// ✓ Always pair setup with cleanup
DisposableEffect(vm) {
    vm.addListener(listener)
    onDispose { vm.removeListener(listener) }
}

// ✗ collectAsState — keeps collecting in background, wastes CPU
val state = vm.flow.collectAsState()
// ✓ collectAsStateWithLifecycle — stops when UI goes background
val state = vm.flow.collectAsStateWithLifecycle()
11 · Interactive

Heap Simulator

Watch your heap grow as you create leaks. Observe GC events that free almost nothing, and see the moment the heap exhausts.

HEAP MONITOR — LIVE
Heap Used
24 MB
Max Heap
128 MB
Leaked Objects
0
GC Events
0
Heap usage over time — MB
> Heap monitor started. Press leak buttons to simulate leaks.
12 · Diagnosis

Detection Tools

🐤
LeakCanary
Automated · Zero setup
Automatically detects Activity, Fragment, ViewModel, and View leaks. Shows the exact reference chain. Add one debug dependency — it works automatically.
📊
Android Studio Profiler
Manual · Heap dumps
Memory Profiler captures heap dumps, tracks allocations, lets you inspect every object. Force GC, capture before/after snapshots, filter by class.
🔬
Eclipse MAT
Deep analysis · HPROF
The most powerful heap analyzer. Open HPROF dumps from Android Studio. Find the dominator tree, shortest GC root path, and retained sizes for complex leaks.
⌨️
adb shell dumpsys meminfo
CLI · Production-safe
Quick snapshot of Java Heap, Native Heap, Graphics, Code. Run before and after rotating — if Java Heap PSS grows 20–50 MB per rotation, you have a confirmed leak.
📈
Firebase Performance
Production · Monitoring
Track memory metrics across your entire user base. Set alerts for sustained high memory. Correlate with ANR and crash rates to quantify real-world leak impact.
🔎
StrictMode
Dev only · Auto-detection
Enable detectLeakedClosableObjects() and detectLeakedSqlLiteObjects(). Crashes or logs when you forget to close streams, cursors, or SQLite connections.
13 · Tool Deep Dive

LeakCanary Internals

Understanding how LeakCanary works helps you interpret its output and configure it for advanced use cases.

LeakCanary setup & internals
// build.gradle.kts — debug only, zero app code needed
debugImplementation("com.squareup.leakcanary:leakcanary-android:2.14")

// What LeakCanary does automatically:
// 1. Hooks ActivityLifecycleCallbacks — watches every Activity
// 2. After onDestroy(), waits 5 seconds
// 3. Creates WeakReference to the Activity
// 4. Forces GC, checks if WeakReference was cleared
// 5. If not cleared → suspected leak → dumps HPROF
// 6. Analyzes heap → finds shortest path from GC root to leaked object
// 7. Shows notification with full reference chain

// Reading a LeakCanary trace:
// ┬──────────────────────────────────────────────────
// │ GC Root: static field AnalyticsManager.INSTANCE
// │                                ↓
// │ AnalyticsManager.context       ↓  ← LEAK SUSPECT
// │                                ↓
// ╰→ MainActivity (5 instances, 48 MB retained)
// ┴──────────────────────────────────────────────────

// Watch custom objects too
override fun onDestroy() {
    super.onDestroy()
    AppWatcher.objectWatcher.expectWeaklyReachable(this, "MyService destroyed")
}

Retained size vs shallow size: "48 MB retained" means the total memory freed if the leaked object were collected — including everything only reachable through it. The shallow size is just the object itself. Always look at retained size to understand the real impact of a leak.

14 · Prevention

Prevention Checklist

Context rules

Use Application context in singletons, repositories, and any long-lived object that needs a Context
Never store Activity/Fragment reference in a ViewModel, singleton, or static field
Dismiss dialogs in onDestroy() — they hold window tokens that outlive the Activity

Lifecycle rules

Null ViewBinding in onDestroyView() — the binding holds the entire view hierarchy
Use viewLifecycleOwner (not this) when observing LiveData in Fragments
Symmetric register/unregister — every listener registered in onStart() removed in onStop()

Coroutine rules

Never use GlobalScope — use viewModelScope, lifecycleScope, or a scoped CoroutineScope you cancel
Use repeatOnLifecycle(STARTED) when collecting flows in Fragments
Use collectAsStateWithLifecycle() in Compose — never plain collectAsState() for long-lived flows

All leak types — quick reference

Leak TypeSeverityRoot CauseFix
Static Activity refCRITICALStatic field / singleton holds ActivityApplicationContext or WeakReference
Non-static inner classCRITICALImplicit this captureMake static/top-level, WeakReference
Context in ViewModelCRITICALViewModel outlives Activity@ApplicationContext via Hilt
Unregistered listenersHIGHRegister without unregisterSymmetric lifecycle calls
GlobalScope coroutinesHIGHCoroutine never cancelledviewModelScope / lifecycleScope
ViewBinding in FragmentHIGHNot nulled in onDestroyView_binding = null in onDestroyView
Bitmap not recycledHIGHLarge objects held after useUse Coil/Glide, recycle manually
Dialog not dismissedHIGHWindow token helddismiss() in onDestroy
DisposableEffect emptyMEDIUMEmpty onDispose blockAlways pair setup with cleanup
collectAsState in ComposeMEDIUMCollects in backgroundcollectAsStateWithLifecycle()
Unclosed streams/cursorsMEDIUMNo close() calluse { } / try-finally
Memory leaks are silent killers.

They don't crash immediately. They degrade slowly — GC pauses grow, frames drop, the app gets sluggish, then ANR, then OOM. Add LeakCanary on day one. Fix leaks on day two.