A small leak can sink a great ship !!

Ayushi Gupta
10 min readOct 7, 2022

--

The creation of this blog post is a summary of my talk at Droidcon Italy 2022. When one is in Italy, no doubt it takes you back to the era of Shakespeare and his numerous plays like Romeo and Juliet, Othello and many more.

One thing was common in his plays where men of great character have fallen because of small vices. If I correlate it with the android world, it’s kinda similar too.

Users like an app, that is smooth, seamless and has a snappy experience. But what would happen when your app starts dropping frames or maybe crashing when an end user is trying to do something critical? Next time for the same use case, the user for sure will choose an alternative, if this sludgy experience continues.

And this pitfall in user experience is that small leak which can sink a great ship. So, let’s give our attempt to save this sinking ship ⛴

Android Memory Structure

To begin, let’s try to have some context around android memory structure. Most of the apps on Android execute on top of Android Runtime (ART), which replaced the deprecate Dalvik Virtual Machine (DVM). ART uses 2 separate Memory Spaces to Store Running Application and its data. These memory spaces are stack and heap memory, which collaboratively forms RAM.

Stack Memory is used for the execution of a thread. It contains method-specific values that are short-lived and reference to other objects in the heap. As soon as the method ends, the block becomes unused and becomes available for the next method.

So, in short, the stack is more about static memory allocation. It is always referenced as LIFO (Last in First Out), so it’s pretty fast.

The size of stack memory is relatively small, as compared to heap memory. For Dalvik, it’s 32KB for Java code and 1mB for native C++ of JNI code. Whereas ART introduced a unified stack for both Java and C++, i.e total of 1MB, and whenever stack memory limit is hit, you will get a StackOverflow error.

Heap Memory is used for dynamic memory allocation of the objects and is used by all the parts of the application, unlike stack memory which is used only by one thread. Whenever you create an object, it’s always created in the heap and these objects are accessible globally.

So, in order to provide a smooth user experience, Android sets a hard limit on the heap size for each running application and this limit depends on the RAM of the device.

Most devices running Android 2.3 or later will return this size as 24MB or higher but is limited to 36 MB (depending on the specific device configuration).

And if your app hits this heap limit and tries to allocate more memory, it will receive an OutOfMemoryError and will terminate.

Garbage Collector (GC), is the superhero taking care of the detection and reclamation of unused objects to get more space in the memory under the hood. If there is an object in the heap that doesn’t contain any reference to it, it will be released by GC.

Memory leak is just an object whose memory was not collected because there was a hard reference to it still in memory. This means the garbage collector is not able to take out the trash.

So, when the user keeps on using our app, the heap memory keeps on increasing, initially, a short GC will kick off and try to clear up immediate dead objects. Short GC executes concurrently, i.e on a separate thread and it will not down your app significantly (2ms to 5ms pause).

If your app has some serious memory leaks hidden under the hood, then a short GC cannot do the entire reclamation so the heap will keep on increasing, which will force a larger GC to kick off.

Larger GC will stop the app execution completely, as it executes on the main thread for around 50ms to 100ms. At this point, your app seriously lags and becomes almost unusable.

If this doesn’t fix the problem, then the heap memory of your app will constantly increase until it reaches a point of death where no more memory can be allocated to your app, leading to the dreaded OutOfMemoryError, which crashes your app.

Now let’s detect these memory leaks !!

As soon as detection of a memory leak comes into the picture, every android developer thinks of leak canary, memory profiler (a handy tool with Android Studio), Infer etc.

Today we will check about what extra we can do with Leak Canary !!

LeakCanary’s knowledge of the internals of the Android Framework gives it a unique ability to narrow down the cause of each leak, helping developers to reduce Application Not Responding freezes and OutOfMemoryError crashes dramatically.

Under the hood leak canary does tasks for you :

  1. Retained Object detection
  2. Heap Dumping
  3. Heap Analysis
  4. Leak Categorisation

Let’s check them all a bit in detail.

Retained Object detection

LeakCanary hooks into the Android lifecycle to automatically detect when activities and fragments are destroyed and should be garbage collected. These destroyed objects are passed to an ObjectWatcher, which holds weak references to them. LeakCanary automatically detects leaks for objects like

  1. destroyed activity instances
  2. destroyed fragment instances
  3. destroyed fragment view instances
  4. cleared view model instances

You can also watch any object that is no longer needed for ex:

AppWatcher.objectWatcher.watch(myDetachedView, "View was detached")

So, if the weak reference held by the object watcher isn’t cleared after waiting for 5 seconds and running garbage collection on it, the watched object is considered as retained and potentially leaking.

Leak canary keeps count on the number of retained objects, as soon as the threshold is crossed, a dumping heap is triggered.

This threshold value is 5, when the app is visible and it’s automatically set to 1, when app is in background.

Heap Dumping

LeakCanary dumps the java heap into a .hprof file (a heap dump) stored in the Android file system. The default behaviour is to store heap dumps in a leak canary folder under the app directory.

Dumping the heap freezes the app for a short amount of time, during which LeakCanary displays the toast.

Leak canary is dumping heap to investigate leaks !!

Heap Analysis

In this step, Leak Canary parses the .hprof file using Shark — Smart Heap Analysis Reports for Kotlin — which itself is a new standalone library.

It locates all retained objects in that heap dump and finds the path of references that prevents that retained object from being garbage collected, i.e. its leak trace.

LeakCanary creates a signature for each leak trace, and groups together leaks that have the same signature, i.e leaks that are caused by the same bug.

Sample leak trace

Hash of the concatenation of each reference suspected to cause the leak, i.e. each reference displayed with an underline.

Leak Categorisation

An android app depends on many external libraries for its functionality and it’s entirely possible that there is a leak in that library.

Leak canary categorises leaks into 2 types:

  1. Application leaks → Leak from your app.
  2. Library leaks → Leak from 3rd party codes.

This was all about leak canary, but have you upgraded to Leak Canary 2.

Why should you need to upgrade to leak canary 2 if you haven’t 🧐, here are some reasons .. 🤓

  1. LeakCanary2 is a major rewrite with internals rewritten to 100% Kotlin.
  2. Multiple leak detection in one analysis and grouping based on per leak type.
  3. Updated APIs to simplify configuration.
  4. Easily accessible new heap analyser, which in turn has been reimplemented from scratch to use 10 times less memory.

Simplified dependency addition, with leak canary dependencies, were added like this:

dependencies {
debugImplementation("com.squareup.leakcanary:leakcanary-android:1.6.3")
releaseImplementation("com.squareup.leakcanary:leakcanary-android-no-op:1.6.3")
debugImplementation("com.squareup.leakcanary:leakcanary-support-fragment:1.6.3")
}

But now it’s just a one-liner dependency, like this:

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

Quite a neat and clean approach. Now there is no need to add any code in your application class for the installation of the library, it auto installs itself.

Leak Canary 2 with UI testing

A development cycle is incomplete without testing coverage, with leak canary 2 you add memory leak detection to your UI tests too.

Leak canary 2 now has an artifact that can help us to detect leaks in UI Tests. It’s as simple as adding this dependency:

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

Once, you have the dependency, you just need to add this code after each UI test case :

LeakAssertions.assertNoLeak()

If retained instances are detected, LeakCanary will dump and analyse the heap. If application leaks are found, LeakAssertions.assertNoLeak() will throw a NoLeakAssertionFailedError.

Leak Canary 2 with Test Rules

What would happen if you have to add, the above piece of code in all the n number of your test cases, any developer would not love this redundancy, so why not use some test rules for optimisation?

class LeakingActivityTest {
@get:Rule
val rule = DetectLeaksAfterTestSuccess(tag = "EndOfTest")
}

And if you want to execute the test case, while you are fixing the leak in code, you just add this annotation @SkipLeakDetection.

Quest for cleanup !! 🗡

Let's just think from a developer perspective !!

A developer, usually development starts with a product requirement, proceeds with clarification and then develops toward logic. Post development, a developer commences the phase of testing on its own i.e developer testing.

During this testing cycle, a developer usually turns off the toggle for leak canary if any to avoid any main thread pause due to leak canary heap dump.

Once this is done, the journey is followed with PR submission, QA execution and if all green then merging into the main branch.

If you notice, during this phase a developer rarely checks the information stored in the leak canary app and thus no memory leak lookout.

Now let us just see what we can do things during our development cycle itself.

Leak Canary 2 + Development Cycle

@HiltAndroidApp
class MausamApplication : Application() {
override fun onCreate() {
super.onCreate()
val analysisUploadListener = EventListener { event ->
if (event is EventListener.Event.HeapAnalysisDone.HeapAnalysisSucceeded) {
val heapAnalysis = event.heapAnalysis
LeakCanaryUploadService().upload(heapAnalysis)
}
}
LeakCanary.config = LeakCanary.config.run {
copy(eventListeners = eventListeners + analysisUploadListener)
}
}
}

Add analysis upload listener to leak canary 2, which will give a callback when heap analysis is successful, and some simplified APIs from leak canary 2.

When heap analysis is a success, just trigger a call to an upload service, which we will see in this post a bit later.

To invoke the listener, you need to add this upload listener to the leak canary config like this:

LeakCanary.config = LeakCanary.config.run {
copy(eventListeners = eventListeners + analysisUploadListener)
}

Now we have all our configs set up, let us just see what does LeakCanaryUploadService holds.

LeakCanaryUploadService will do proper extraction of heapAnalysisSuccess data class. We will extract out application and library leak data. Once u have the data, you can transform it the way u like, making sure that the data format is preserved and readable. So here we will need to build our custom stack trace builder.

Where to upload this data ??

Now the question arises, what tool we should use for visualising our leak traces on a better scale, something real-time, where you can check how many developers faced it? And most importantly without much effort.

Let’s use firebase crashlytics as our visualising tool !!

Firebase crashlytics, is already part of the majority of android apps, you don’t need any new dependency. It’s a pretty lightweight, almost real-time tracker and it becomes easy to prioritise your tasks based on the number of affected users as well.

Let’s wire it up !!

private fun captureApplicationLeaks(heapAnalysis: HeapAnalysisSuccess) {
heapAnalysis
.applicationLeaks
.flatMap { leak ->
leak.leakTraces.map { leakTrace -> leak to leakTrace }
}
.map { (_, leakTrace) ->
val crashlytics = FirebaseCrashlytics.getInstance()
crashlytics.recordException(
ApplicationLeakException(leakTrace, getStackTraceList(leakTrace.leakingObject)),
)
}
}

Let’s start logging our leak data as non-fatal exceptions and most importantly it’s all happening on debug flavour of the app, so there is no effect on production numbers.

Leak trace on firebase

You can wire LeakCanaryUploadService to test as well !!

@Before
fun setUp() {
DetectLeaksAssert.update(AndroidDetectLeaksAssert(
heapAnalysisReporter = { heapAnalysis ->
// Upload the heap analysis result
if (heapAnalysis is HeapAnalysisSuccess) {
LeakCanaryUploadService().upload(heapAnalysis)
}
// Fail the test if there are application leaks
throwingReporter.reportHeapAnalysis(heapAnalysis)
}
))
}

Pretty easy, it will help you to detect vulnerabilities in the testing cycle before things get baked so much that they cannot be removed.

Let’s fix these leaks now !!

Memory leaks will cost you a lot in terms of your app performance and in turn user retention too.

Some don’ts !! 🙈

  • Avoid using static variables for views or context-related references.
  • Avoid passing a context-related reference to the singleton class.
  • Avoid passing activityContext or viewContext, use applicationContext for toast, snack bars
  • Avoid inner nested classes and anonymous classes

Some do’s !! 🤩

  • Use weak references for context-related functionalities
  • Delegate referencing to DI
  • Practice cautious programming
  • Regular check on ANRs

These small steps while building the app can help you to deliver high-performance android apps.

Detecting, visualising and fixing memory leaks will not only make your app’s user experience better but will slowly turn you into a better developer as well. Leak detection can tell when there’s a code smell or bad coding patterns before they get baked so much into the app, that they haunt you to fix.

Leak detection can teach developers to write more robust apps, where we are taking care of even low-end device users and giving them the same experience.

No doubt development is an ever-going on journey, but these small things can help you to save your ship from sinking and can set the course for sailing it high !!

Find slides for the presentation at https://www.slideshare.net/AyushiGupta136410/a-small-leak-can-sink-a-great-ship

Github code: https://github.com/droidyayu/Mausam

Happy Coding !!

--

--

Ayushi Gupta
Ayushi Gupta

Responses (1)