Lesson 09 / 19
Memory Management
A screen that stays in memory after it closes is a memory leak. The lesson measures a listener that is never unregistered and holds one screen per cycle, in how many cycles the retained objects exceed the budget, and how the same leak changes category with the budget.
Published Updated
In this lesson
- Defines the memory leak, the retained object and the weak reference
- Measures from the model that a listener that is never unregistered holds one screen in every open-and-close cycle
- Shows that the same leak can come out within, minority, majority or all depending on the budget
- Measures that tying the listener to the lifecycle does not increase the retained objects of later cycles and does not erase the earlier leak
Contents
In the previous lesson scrolling fit the frame budget, but every frame leaves objects in memory, and some of these objects are not freed when their work is done. A memory problem looks different from a rendering problem. A stutter is noticed at the moment it happens; memory fills silently, and the app is noticed only when the platform terminates it or when memory runs out. That is why we tie the measurement to a cycle instead of a frame: opening and closing a screen is a cycle, and at the end of each cycle we look at how many objects remain in memory.
The name of the problem is the memory leak. Garbage collection frees an object that nobody can reach. If there is still a path that reaches an object, that is, if another object holds a reference to it, that object is not freed even when its work is done. We call an object that stays in memory this way a retained object. A leak is the situation in which retained objects accumulate cycle by cycle.
The measure is again the course’s measure: at the end of how many cycles the budget for the number of retained objects was exceeded.
// Overrun.kt — classifies in how many samples a measurement exceeds its budget
class Budget(val name: String, val limit: Int)
class Overrun(val label: String, val budget: Budget, samples: List<Int>) {
val total: Int = samples.size
val over: Int = samples.count { it > budget.limit }
val verdict: String = when {
over == 0 -> "within"
over == total -> "all"
over * 2 < total -> "minority"
else -> "majority"
}
fun line() {
println("$label: budget=${budget.name}<=${budget.limit} | over=$over/$total -> $verdict")
}
}
fun countOverruns(overruns: List<Overrun>) {
for (name in listOf("within", "minority", "majority", "all")) {
println("$name: ${overruns.count { it.verdict == name }}")
}
}
This file is the same in every lesson of the course. Budget carries the name and limit of a measurement, and Overrun counts the samples that exceed the limit and gives one of four overruns.
A Listener That Is Never Unregistered
The most common form of a leak is this: a long-lived object holds a reference to a short-lived object. Let there be a data source that lives for the whole app. When a screen opens, it registers itself with this source as a listener, because it wants to receive updates. If the screen does not unregister when it closes, the source keeps the screen in its listener list. The screen’s work is done, but the path to it continues; garbage collection cannot free it.
// Retention.kt — a listener that is never unregistered keeps the closed screen in memory
class Screen(val id: Int) {
var closed: Boolean = false
}
class Source {
private val strong = mutableListOf<Screen>()
private val weak = mutableListOf<Screen>()
fun register(screen: Screen) {
strong.add(screen)
}
fun registerWeak(screen: Screen) {
weak.add(screen)
}
fun unregister(screen: Screen) {
strong.remove(screen)
weak.remove(screen)
}
// a weak reference does not hold a closed screen; a strong reference does
fun retainedObjects(): Int = strong.size + weak.count { !it.closed }
}
fun cycle(source: Source, id: Int, how: String): Int {
val screen = Screen(id)
when (how) {
"strong" -> source.register(screen)
"weak" -> source.registerWeak(screen)
"lifecycle" -> {
source.register(screen)
source.unregister(screen)
}
}
screen.closed = true
return source.retainedObjects()
}
Source is the source that lives for the whole app. register keeps a record with a strong reference, and registerWeak with a weak reference. cycle is one cycle: a screen opens, registers with the source, closes, and the number of screens the source is holding at that moment is returned. how says how the registration was made. We first run, for eight cycles, the form that never removes the registration, that is, strong. Let the budget be three objects.
// Main.kt — a listener that is never unregistered accumulates screens over eight cycles
fun main() {
val source = Source()
val samples = (1..8).map { cycle(source, it, "strong") }
println("retained: $samples")
val overrun = Overrun("leak", Budget("retained", 3), samples)
overrun.line()
countOverruns(listOf(overrun))
}
retained: [1, 2, 3, 4, 5, 6, 7, 8] leak: budget=retained<=3 | over=5/8 -> majority within: 0 minority: 0 majority: 1 all: 0
Every cycle left a screen behind, and the number of retained objects grew by one each time. The first three cycles stayed within the budget, and five cycles, from the fourth to the eighth, exceeded it: 5/8, that is, majority. The nature of a leak shows here: it is not noticed in one cycle, but the accumulating number sooner or later passes every budget. That is why a leak is measured across cycles, not by looking at a single cycle.
PD1. In the model each screen is one object and each leaking cycle leaves exactly one object behind. A real screen also holds its views, images and data structures beside itself; the size of the retained memory varies a great deal from screen to screen. The model counts screens; it does not set up the size of the memory they hold.
The Budget Hides the Leak
We apply four different budgets to the same leak. The leak is measured once and the samples are the same; only the limit changes.
// Main.kt — the same leak is measured with four budgets
fun main() {
val source = Source()
val samples = (1..8).map { cycle(source, it, "strong") }
val overruns = listOf(0, 3, 6, 8).map { limit ->
Overrun("limit-$limit", Budget("retained", limit), samples)
}
overruns.forEach { it.line() }
countOverruns(overruns)
}
limit-0: budget=retained<=0 | over=8/8 -> all limit-3: budget=retained<=3 | over=5/8 -> majority limit-6: budget=retained<=6 | over=2/8 -> minority limit-8: budget=retained<=8 | over=0/8 -> within within: 1 minority: 1 majority: 1 all: 1
Four lines, four overruns and a single leak. Under the budget of zero objects every cycle exceeded it. Under the budget of eight objects no cycle exceeded it, because in the eighth cycle the retained number was eight and a sample equal to the limit is not an overrun. The leak was there at eight objects too; the budget made it invisible. This is the weak side of the rule: if the budget is chosen wider than the leak, the leak is reported as within. That is why a leak budget is set not by the number of cycles but by the number the app should never retain. The budget that catches a leak is often based on zero or on a number close to zero.
On the platform, a virtual machine check and a heap dump are used to catch retained screens. The piece below sets up a check that writes leaking screens to the log and shows a function that takes a heap dump.
// Platform code: leak detection and heap dump
import android.app.Application
import android.os.Debug
import android.os.StrictMode
class SampleApp : Application() {
override fun onCreate() {
super.onCreate()
StrictMode.setVmPolicy(
StrictMode.VmPolicy.Builder()
.detectActivityLeaks()
.detectLeakedRegistrationObjects()
.penaltyLog()
.build()
)
}
}
fun dumpHeap(path: String) {
Debug.dumpHprofData(path)
}
This piece is platform code, and here we claim no output, duration or file path. StrictMode writes leaking screens, and broadcast receivers and service connections whose registration was never removed, to the log; the app’s own listeners do not fall under this check; the heap dump shows, in the IDE’s profiler, which object is held by which path. The model sets up the rule of this measurement; the model is not the tool itself. What the dump will show, that is, which object the retaining path passes through, is information the model does not show.
The Weak Reference
One way to solve the leak is for the source to hold the screen with a weak reference instead of a strong one. A weak reference does not prevent garbage collection: if no other strong path to the object remains, the object is freed and the weak reference becomes empty. In the model we set this up as a closed screen no longer being counted.
// Main.kt — a screen registered with a weak reference is not retained once it closes
fun main() {
val source = Source()
val samples = (1..8).map { cycle(source, it, "weak") }
println("retained: $samples")
val overrun = Overrun("weak", Budget("retained", 3), samples)
overrun.line()
countOverruns(listOf(overrun))
}
retained: [0, 0, 0, 0, 0, 0, 0, 0] weak: budget=retained<=3 | over=0/8 -> within within: 1 minority: 0 majority: 0 all: 0
PD2. In the model a weak reference lets go of a closed screen instantly. In reality garbage collection runs when its time comes; after a screen closes it stays in memory for a while, until the collection takes place, and this period varies from app to app. The instantaneousness of the model is a simplification. Also, a weak reference does not replace removing the registration: the source can go on carrying emptied records in its list, and that is an accumulation, though a small one.
Why We Measure Across Cycles
Memory problems are not instantaneous but cumulative. Opening and closing a single screen raises the retained object count by one or not at all; this difference stays below the budget in a single observation. A leak becomes visible by repeating the same operation again and again. That is why the samples of the measurement are the retained object counts taken at the end of consecutive cycles, and each sample contains the one before it.
This structure also affects how the rule is read. In a cumulative sequence the overrun usually concentrates toward the end of the sequence: the first cycles stay within, the later ones exceed. In the lesson’s first measurement the exceeding cycles were the fourth to the eighth. That is why increasing the number of cycles makes a leak more visible, and decreasing it hides the leak.
One more detail matters in measurement design: every cycle must start under the same conditions. If the state left over from a previous cycle affects the next one, the overrun may come from the measurement setup and not from the leak. That is why in the model every cycle runs with the same cycle function and on the same source; the only thing that differs is how the registration is made.
What a retained object is should also be said openly. A screen is an object, but everything that holds it stays with it: the screen’s views, its data, the helpers it uses. Finding a leak therefore requires looking not at the retained object itself but at the path that holds it. A heap dump shows exactly this path.
Three questions are asked in order when looking for a leak. Which object is being retained, which path holds it, and when should this path have been cut. The counter answers the first question, the heap dump the second, and the app’s lifecycle the third. In the model the path is a single link: the source’s list. In reality the path passes through several objects and each link belongs to a different owner; choosing the right link to cut the leak takes more thought than removing a registration.
A memory budget is also not a search for a lower bound. Some retained objects while the app is open are legitimate: the open screen, cached data, the user session. A leak budget takes this legitimate share into account and limits only the excess left after the screen closes. That is why the measurement looks not at the moment the screen is open but at the moment it closes, and the model reads the counter at the end of each cycle.
Comparing the leak budget between versions is also useful. If a screen that retained zero objects in eight cycles in the previous version retains one object in eight cycles in the next version, that single object is the first sign of a new leak. When the budget is zero this sign shows up immediately as an overrun; when the budget is left wide, a new leak accumulates unnoticed for several versions.
Common Misconceptions
“Garbage collection cleans up the leak.” Garbage collection frees only objects that cannot be reached. If the source still holds a reference to the screen, the screen is reachable and is not collected. A leak is exactly the existence of a path that still reaches an object whose work is done.
“A weak reference solves every leak.” A weak reference does not prevent an object from being collected, but it does not replace removing the registration either. The source can go on carrying emptied records, and this accumulation, though not as large as the object, is real. The right solution is to tie the registration to the object’s lifecycle.
“If the screen has closed, it is gone from memory.” Closing means the screen is not visible to the user; it does not mean it was deleted from memory. In the model the screen whose registration was never removed stayed in the source’s list after closing, and the counter never fell again.
“The fix undoes the old leak.” The lesson’s bounding measurement showed this plainly: the four screens that leaked before the fix stayed in memory after the fix too. The fix only stops the new leak.
“The budget defines the leak.” The budget defines not the leak but an acceptance limit. The same leak gave four different overruns under four budgets. The budget that catches a leak is set to a value close to the number the app should never retain.
The Bounding Measurement
The arrangement that solves the leak at its root is to tie the listener to the screen’s lifecycle: the screen registers when it becomes visible and removes its registration when it stops being visible. Its form on the platform is as follows.
// Platform code: tying the listener to the lifecycle
import androidx.appcompat.app.AppCompatActivity
class OrdersActivity : AppCompatActivity() {
private val listener = OrderListener { orders -> render(orders) }
override fun onStart() {
super.onStart()
OrderSource.register(listener)
}
override fun onStop() {
OrderSource.unregister(listener)
super.onStop()
}
}
The registration is made in onStart and removed in onStop, so the source’s path to the screen is cut when the screen stops being visible. In the model this is the lifecycle form: the screen registers and removes its registration as it closes. We take two measurements. In the first, the arrangement is correct from the very beginning. In the second, the first four cycles run with the old, leaking arrangement and then the fix arrives.
// Main.kt — tying to the lifecycle does not increase the retained objects of later cycles
fun main() {
val fixed = Source()
val fromStart = (1..8).map { cycle(fixed, it, "lifecycle") }
val patched = Source()
val afterPatch = (1..8).map { cycle(patched, it, if (it <= 4) "strong" else "lifecycle") }
println("from-start: $fromStart")
println("patched: $afterPatch")
val overruns = listOf(
Overrun("from-start", Budget("retained", 3), fromStart),
Overrun("patched", Budget("retained", 3), afterPatch),
)
overruns.forEach { it.line() }
countOverruns(overruns)
}
from-start: [0, 0, 0, 0, 0, 0, 0, 0] patched: [1, 2, 3, 4, 4, 4, 4, 4] from-start: budget=retained<=3 | over=0/8 -> within patched: budget=retained<=3 | over=5/8 -> majority within: 1 minority: 0 majority: 1 all: 0
The arrangement set up correctly from the beginning is within: no retained objects in any cycle. The fix made afterward came out majority under the same budget. After the first four, the fix leaked no new screens and the number stopped at four; but all four stayed in memory. The retained object count did not increase in later cycles, and the earlier leak did not go away by itself. In the rule’s eyes the difference between the two lines is between staying under the budget and staying over it; what the rule does not see is that in the second line the growth stopped. To see that growth stopped, we look at the samples themselves, that is, the patched: line.
PD3. That the first four screens are not freed in the second measurement is a rule the model sets up. In a real app a leaked screen stays in memory until the source holding it disappears, often until the process terminates. For this reason, releasing the fix does not rescue the sessions that leaked before it. They are cleaned up only when the app is restarted.
Summary
- This lesson’s overrun: majority. The listener that was never unregistered held a screen in every cycle, and five of the eight cycles exceeded the budget of three objects.
- A memory leak is the accumulation of retained objects across cycles; it is not seen by looking at a single cycle.
- The same leak came out all under a budget of zero objects, majority under three, minority under six and within under eight. If the budget is chosen wider than the leak, the leak is not visible in the report.
- A screen registered with a weak reference was not retained once it closed in the model. In reality this depends on when garbage collection runs.
- Tying the listener to the lifecycle did not increase the number of retained objects in later cycles; but it did not free the four screens that had leaked earlier.
Next Step
Memory is what is held inside the app. The app also talks to the outside: it sends requests to the network, wakes the device, spends battery. The next lesson, Network and Battery Profiling, measures in how many cycles the number of requests sent in a synchronization cycle exceeds the budget, and how sending the requests in batches lowers this number.
To keep your progress and take notes, Log in
My notes
Log in to take notes.