Memory Leaks in Android Development

What Are Memory Leaks and Why Do They Matter in Android?

Memory leaks occur when an app retains references to objects that are no longer needed, preventing the garbage collector (GC) from reclaiming that memory. In Java/Kotlin on the JVM (which Android uses via ART—Android Runtime), the GC automatically frees up memory for objects that are no longer reachable from any root reference (like static fields, threads, or the call stack). However, if an unintended strong reference keeps an object alive, it leaks.

In Android, this is particularly problematic because apps run in resource-constrained environments. Devices have limited heap sizes (e.g., 256MB on mid-range phones), and leaks can lead to:

  • OutOfMemoryError (OOM): The app crashes when it can’t allocate more memory.
  • Performance Degradation: Frequent GC pauses cause jank (UI stuttering).
  • Battery Drain: More memory usage means higher CPU activity for GC.
  • User Frustration: Apps feel slow or unresponsive, leading to poor reviews.

Android’s component-based architecture exacerbates leaks. Activities, Fragments, Services, and BroadcastReceivers have lifecycles that don’t always align with the objects they reference. For instance, an Activity might hold a reference to a large Bitmap, but if that reference lingers after the Activity is destroyed (e.g., due to configuration changes), you’ve got a leak.

To quantify: A single leaked Activity can retain its entire view hierarchy, contexts, and resources—potentially gigabytes over time in a long-running app. In multi-activity apps, this compounds quickly.

Let’s start with a basic example of how GC works and fails. Suppose we have a simple class:

Kotlin

class LeakyObject {
    private val largeArray = ByteArray(10_000_000) // 10MB array
}

If we create an instance and lose all references, GC reclaims it. But if we store it in a static field:

Kotlin

object GlobalHolder {
    var leakyRef: LeakyObject? = null
}

fun main() {
    GlobalHolder.leakyRef = LeakyObject()
    // Even after this function ends, the object lives forever due to static ref
}

This is a leak because GlobalHolder is a root GC object, keeping leakyRef alive indefinitely.

In Android, contexts are often the culprits. Activities extend Context, and holding an Activity reference beyond its lifecycle leaks the entire component.

Common Mistakes Leading to Memory Leaks

Senior devs know the basics, but leaks often hide in subtle interactions. Here are the most frequent offenders, with detailed explanations and full code examples.

Context Leaks from Activities and Fragments

Activities and Fragments are destroyed and recreated frequently (e.g., on rotation). If a long-lived object holds a strong reference to them, the old instance leaks.

Common Scenario: Passing an Activity context to a singleton or async task.

Example: A download manager holding an Activity reference for callbacks.

Bad Code:

Kotlin

object DownloadManager {
    private var activityRef: Activity? = null
    private val downloads = mutableMapOf<String, DownloadTask>()

    fun startDownload(activity: Activity, url: String) {
        activityRef = activity // Leak! Strong ref to Activity
        val task = DownloadTask(url) { result ->
            activityRef?.runOnUiThread { showResult(result) }
        }
        downloads[url] = task
        task.execute()
    }

    private fun showResult(result: String) {
        // UI update logic
    }
}

class DownloadTask(private val url: String, private val callback: (String) -> Unit) {
    fun execute() {
        // Simulate async download
        Thread {
            Thread.sleep(5000) // Long-running
            callback("Downloaded from $url")
        }.start()
    }
}

// Usage in Activity
class MainActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        DownloadManager.startDownload(this, "https://example.com/file")
    }
}

Here, DownloadManager is static (via object), so activityRef keeps the Activity alive even after it’s destroyed. If the user rotates the screen, the old Activity leaks, along with its views and resources.

This can leak multiple Activities over time, ballooning memory.

Static References and Singletons

Statics live for the app’s lifetime. Referencing non-static objects from them is a recipe for disaster.

Common Scenario: Caching views or handlers in static fields.

Bad Code:

Kotlin

object ViewCache {
    var currentView: View? = null // Leak if View holds Context
}

class MyFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        ViewCache.currentView = view.findViewById(R.id.my_view)
    }
}

View implicitly holds a reference to its Context (the Fragment/Activity). Static caching leaks the entire component.

Unregistered Listeners and Callbacks

Listeners (e.g., for sensors, location) must be unregistered on destruction.

Common Scenario: Forgetting to unregister in onDestroy().

Bad Code:

Kotlin

class LocationActivity : AppCompatActivity() {
    private val locationListener = object : LocationListener {
        override fun onLocationChanged(location: Location) {
            // Update UI
        }
        // Other overrides...
    }

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
        locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0L, 0f, locationListener)
    }

    // Forgot to unregister in onDestroy!
}

The anonymous object holds a reference to the outer Activity (implicit this). The LocationManager keeps it alive, leaking the Activity.

Inner Classes and Anonymous Objects

Non-static inner classes hold implicit references to the outer class.

Common Scenario: Handlers or Runnables in Activities.

Bad Code:

Kotlin

class LeakyActivity : AppCompatActivity() {
    private val handler = Handler(Looper.getMainLooper())

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        handler.postDelayed({
            // This anonymous Runnable holds ref to LeakyActivity
            Log.d("Leak", "Delayed action")
        }, 60_000) // 1 minute delay
    }
}

If the Activity is destroyed before the delay ends, it leaks because the Runnable references the Activity, and Handler holds the Runnable.

Bitmap and Resource Leaks

Bitmaps are memory hogs; not recycling them properly leaks.

Common Scenario: Loading images without proper management.

Bad Code:

Kotlin

class ImageActivity : AppCompatActivity() {
    private var bitmap: Bitmap? = null

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        bitmap = BitmapFactory.decodeResource(resources, R.drawable.large_image) // Huge bitmap
        findViewById<ImageView>(R.id.image_view).setImageBitmap(bitmap)
    }

    // No onDestroy to recycle
}

If the Activity leaks (e.g., via another reference), the Bitmap stays in memory.

Reactive Programming Leaks (RxJava/Coroutines)

In Kotlin, Coroutines and Flows can leak if not cancelled.

Common Scenario: Infinite Flows without lifecycle binding.

Bad Code (Coroutines):

Kotlin

class CoroutineFragment : Fragment() {
    private val job = Job()
    private val scope = CoroutineScope(Dispatchers.Main + job)

    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        scope.launch {
            while (true) {
                delay(1000)
                updateUI() // Holds ref to Fragment
            }
        }
    }

    private fun updateUI() {
        // UI logic
    }

    // Forgot to cancel job in onDestroyView
}

The coroutine keeps running, leaking the Fragment.

For RxJava:

Bad Code:

Kotlin

class RxActivity : AppCompatActivity() {
    private val disposable = CompositeDisposable()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        disposable.add(
            Observable.interval(1, TimeUnit.SECONDS)
                .subscribe { updateUI() } // Lambda holds ref to Activity
        )
    }

    private fun updateUI() {
        // UI
    }

    // Forgot disposable.clear() in onDestroy
}

Detecting Memory Leaks

Detection is key. As seniors, we use tools beyond logs.

Android Profiler

In Android Studio, the Memory Profiler shows heap usage.

Steps:

  • Run app in profiler mode.
  • Perform actions that might leak (e.g., rotate screen multiple times).
  • Force GC, then take heap dump.
  • Analyze for retained instances (e.g., multiple Activities).

Example Interpretation: If you see multiple MainActivity instances in the heap, that’s a leak.

LeakCanary

The gold standard library for automatic leak detection.

Integration (build.gradle.kts):

Kotlin

dependencies {
    debugImplementation("com.squareup.leakcanary:leakcanary-android:2.12")
}

In Application:

Kotlin

class MyApp : Application() {
    override fun onCreate() {
        super.onCreate()
        if (BuildConfig.DEBUG) {
            LeakCanary.install(this)
        }
    }
}

LeakCanary watches for destroyed Activities/Fragments and reports leaks with stack traces.

Example Output: It might say “Activity leaked due to reference in DownloadManager.activityRef”.

Heap Dumps and MAT

Take a heap dump via Profiler or adb shell am dumpheap.

Analyze with Eclipse Memory Analyzer Tool (MAT).

In MAT:

  • Open .hprof file.
  • Use “Leak Suspects” report.
  • Look for dominator trees to find retaining paths.

For example, you might see: Static Field -> Activity -> ViewHierarchy.

Custom Detection

Use ReferenceQueue and WeakReference for manual checks.

Example:

Kotlin

class LeakDetector {
    private val refQueue = ReferenceQueue<Any>()
    private val weakRefs = mutableMapOf<String, WeakReference<Any>>()

    fun watch(obj: Any, tag: String) {
        weakRefs[tag] = WeakReference(obj, refQueue)
    }

    fun checkLeaks() {
        while (true) {
            val ref = refQueue.remove(1000) // Timeout
            if (ref != null) {
                val tag = weakRefs.entries.find { it.value == ref }?.key
                Log.d("Leak", "Object $tag was GC'd")
            } else {
                Log.e("Leak", "Potential leak: Objects not GC'd")
            }
        }
    }
}

// Usage
leakDetector.watch(this@MainActivity, "MainActivity")

Run checkLeaks() after destruction to verify.

Measuring Memory Usage

Beyond detection, measure to baseline and monitor.

Runtime APIs

Use ActivityManager and Debug for metrics.

Example:

Kotlin

fun logMemoryInfo(context: Context) {
    val activityManager = context.getSystemService(Context.ACTIVITY_SERVICE) as ActivityManager
    val memoryInfo = ActivityManager.MemoryInfo()
    activityManager.getMemoryInfo(memoryInfo)
    Log.d("Memory", "Available: ${memoryInfo.availMem / 1024 / 1024} MB, Total: ${memoryInfo.totalMem / 1024 / 1024} MB")

    val runtime = Runtime.getRuntime()
    val used = (runtime.totalMemory() - runtime.freeMemory()) / 1024 / 1024
    val max = runtime.maxMemory() / 1024 / 1024
    Log.d("Memory", "Used: $used MB, Max: $max MB")
}

Call this periodically or on events.

Android Profiler Metrics

  • Allocations: Tracks object creations.
  • Native Memory: For C++ leaks (rare in Kotlin).
  • Network: Indirectly, as large responses can cause leaks.

Profile sessions show graphs of heap size over time. Spikes without drops indicate leaks.

Third-Party Tools

  • Firebase Performance Monitoring: Tracks memory automatically.
  • Bugsnag/New Relic: For production monitoring.

Set alerts for memory thresholds.

Solutions and Best Practices

Now, fix those leaks! For each common cause, here’s a solution with full code.

Fixing Context Leaks

Use Application Context or WeakReferences.

Good Code for DownloadManager:

Kotlin

object DownloadManager {
    private var activityWeakRef: WeakReference<Activity>? = null
    private val downloads = mutableMapOf<String, DownloadTask>()

    fun startDownload(activity: Activity, url: String) {
        activityWeakRef = WeakReference(activity)
        val task = DownloadTask(url) { result ->
            activityWeakRef?.get()?.runOnUiThread { showResult(result) }
        }
        downloads[url] = task
        task.execute()
    }

    private fun showResult(result: String) {
        // UI update
    }
}

class DownloadTask(private val url: String, private val callback: (String) -> Unit) {
    fun execute() {
        Thread {
            Thread.sleep(5000)
            callback("Downloaded from $url")
        }.start()
    }
}

WeakReference allows GC if no strong refs exist.

Prefer Application Context for non-UI ops:

Kotlin

val appContext = applicationContext // From Activity
// Use appContext for SharedPreferences, etc.

Avoiding Static Leaks

Use dependency injection (Dagger/Hilt) instead of singletons.

For caching, use LRUCache with weak keys.

Good Code:

Kotlin

object ViewCache {
    private val cache = LruCache<String, WeakReference<View>>(10)

    fun cacheView(tag: String, view: View) {
        cache.put(tag, WeakReference(view))
    }

    fun getView(tag: String): View? = cache.get(tag)?.get()
}

Proper Listener Management

Always unregister symmetrically.

Good Code:

Kotlin

class LocationActivity : AppCompatActivity() {
    private val locationListener = object : LocationListener {
        override fun onLocationChanged(location: Location) {}
        // ...
    }

    private lateinit var locationManager: LocationManager

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        locationManager = getSystemService(Context.LOCATION_SERVICE) as LocationManager
        locationManager.requestLocationUpdates(LocationManager.GPS_PROVIDER, 0L, 0f, locationListener)
    }

    override fun onDestroy() {
        super.onDestroy()
        locationManager.removeUpdates(locationListener)
    }
}

For BroadcastReceivers, use registerReceiver in onResume and unregister in onPause.

Handling Inner Classes

Make them static or use lambdas carefully.

Good Code (Static Inner):

Kotlin

class LeakyActivity : AppCompatActivity() {
    private val handler = Handler(Looper.getMainLooper())

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        val runnable = SafeRunnable(this)
        handler.postDelayed(runnable, 60_000)
    }

    override fun onDestroy() {
        super.onDestroy()
        handler.removeCallbacksAndMessages(null) // Crucial!
    }

    class SafeRunnable(activity: LeakyActivity) : Runnable {
        private val weakActivity = WeakReference(activity)

        override fun run() {
            weakActivity.get()?.let {
                Log.d("Leak", "Delayed action")
            }
        }
    }
}

Clear handler in onDestroy.

Bitmap Management

Use recycle() (pre-Android 11) or let GC handle, but avoid leaks.

Use libraries like Glide/Coil for automatic management.

Good Code with Glide:

Kotlin

class ImageActivity : AppCompatActivity() {
    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        Glide.with(this)
            .load(R.drawable.large_image)
            .into(findViewById(R.id.image_view))
    }
}

Glide handles caching and lifecycle.

Reactive Fixes

Bind to lifecycle.

For Coroutines (with Lifecycle):

Kotlin

class CoroutineFragment : Fragment() {
    override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
        super.onViewCreated(view, savedInstanceState)
        viewLifecycleOwner.lifecycleScope.launch {
            repeatOnLifecycle(Lifecycle.State.STARTED) {
                while (true) {
                    delay(1000)
                    updateUI()
                }
            }
        }
    }

    private fun updateUI() {}
}

Auto-cancels on lifecycle events.

For RxJava:

Kotlin

class RxActivity : AppCompatActivity() {
    private val disposable = CompositeDisposable()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        disposable.add(
            Observable.interval(1, TimeUnit.SECONDS)
                .observeOn(AndroidSchedulers.mainThread())
                .subscribe { updateUI() }
        )
    }

    override fun onDestroy() {
        super.onDestroy()
        disposable.clear()
    }

    private fun updateUI() {}
}

Advanced Fixes

  • Services: Bind with BIND_AUTO_CREATE and unbind properly.
  • ViewModels: Use for data survival across config changes, avoiding Activity refs.

Example ViewModel:

Kotlin

class MyViewModel : ViewModel() {
    private val _data = MutableLiveData<String>()
    val data: LiveData<String> get() = _data

    fun loadData() {
        // Async load, no context needed
    }

    override fun onCleared() {
        super.onCleared()
        // Cleanup
    }
}

// In Activity
class VmActivity : AppCompatActivity() {
    private val viewModel: MyViewModel by viewModels()

    override fun onCreate(savedInstanceState: Bundle?) {
        super.onCreate(savedInstanceState)
        viewModel.data.observe(this) { /* UI */ }
    }
}

ViewModel clears on Activity destruction.

  • Hilt for DI: Inject dependencies without manual refs.

Advanced Topics: Leaks in Services, Receivers, and More

Services can leak if not stopped. Use startForeground for ongoing, but stopSelf() when done.

BroadcastReceivers: Prefer local (via LocalBroadcastManager) over global to avoid app-wide leaks.

In Kotlin Multiplatform or Native: Watch for JNI refs leaking Java objects.

Leaks in WebViews: Clear cache and destroy properly.

Production Monitoring: Use LeakCanary Shark for heap analysis in CI/CD.

Conclusion

Memory leaks are a persistent challenge in Android, but with disciplined practices—using weak refs, lifecycle-aware code, and tools like LeakCanary—you can eradicate them. As seniors, our role is to architect apps that scale without these issues. Always profile early, test rotations/back navigation, and monitor production.

Leave a Reply

Your email address will not be published. Required fields are marked *