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.
