The Invisible Janitor

Have you ever wondered what happens to all the objects you create in Kotlin? You call constructors all day long, you create lists, data classes, lambdas, coroutine continuations, and you never once think about cleaning any of that up. In C you have to call free for every malloc. In C++ you manage destructors and smart pointers. But in Kotlin? You just... walk away from your objects and trust that somebody else handles it.

That somebody is the garbage collector. It is one of the most important pieces of software running on every Android device, and most developers have never looked at how it actually works. Today we are going to change that. We are going to build a mental model of garbage collection from the ground up, see how it applies to the JVM, see what Android's ART runtime does differently, and most importantly, understand why garbage collection is the number one reason your UI stutters. Let's go.

Reachability: The One Rule

So how does the garbage collector know which objects are still needed and which ones are trash? It uses one beautifully simple idea called reachability.

Think of it like this. Imagine your app's memory as a giant room full of boxes connected by strings. Some of those boxes are nailed to the floor. These nailed-down boxes are called roots, or the root set. A root is any reference the runtime knows about without having to search for it. The local variables on your thread's call stack are roots. Static fields in your classes are roots. JNI global references from native code are roots. Active thread objects themselves are roots.

Now here is the rule. If the garbage collector can follow strings from any nailed-down box and eventually reach your box, your object is alive. If there is no path of references from any root to your object, no matter how indirect, then your object is unreachable. And unreachable means garbage. It does not matter that the object still has data in it. It does not matter that other garbage objects point to it. If no living root can reach it, it is dead, and its memory will be reclaimed.

This is fundamentally different from reference counting, which is what Swift and Python use. Reference counting tracks how many things point to each object. Reachability tracing asks a deeper question: can anything that matters actually get to you?

The Root Set in Practice

Let's make the root set concrete with a Kotlin example. Imagine you have a function called processOrder. Inside that function you create a val called order, which is a new Order object. That local variable, order, sitting on the stack frame of processOrder, is a root. The Order object is reachable through it.

Now say that Order object has a field called customer, pointing to a Customer object, and that Customer has a field called address pointing to an Address object. None of those inner objects are roots themselves, but they are all reachable because you can trace a path from the root, through order, through customer, to address. They are all alive.

Now your function returns. The stack frame for processOrder gets popped. That local variable is gone. If nothing else in your app holds a reference to that Order, suddenly the entire chain, the Order, the Customer, the Address, they all become unreachable in one instant. They are all garbage now, even though they still reference each other. A cycle of garbage pointing to garbage is still garbage. This is one of the big advantages of tracing collection over reference counting, which famously struggles with cycles.

The Generational Hypothesis

Now, the garbage collector could just scan every single object in the heap every time it needs to reclaim memory. That would be correct, but it would be painfully slow. Modern garbage collectors are smarter because they exploit one of the most reliable observations in computer science: the generational hypothesis.

The generational hypothesis says that most objects die young. Think about it. You create a temporary StringBuilder to format a log message, it's garbage before the function returns. You create an iterator to walk through a list, it's dead the moment the loop ends. You create a Pair to return two values, the caller destructures it and the Pair itself is never used again. Studies across decades of languages and applications consistently show that somewhere between eighty and ninety-five percent of all objects become garbage within microseconds of being created.

So the garbage collector divides the heap into generations. Newly created objects go into the young generation. Objects that survive several collection cycles get promoted to the old generation. The collector focuses most of its effort on the young generation, because that is where the highest density of garbage is. Collecting the young generation is fast because most of what's in there is already dead. This is one of the cleverest optimizations in all of runtime engineering.

Young Generation: Eden and Survivors

Let's zoom into the young generation because the mechanics here are elegant. In a classic generational collector like HotSpot's, the young generation is split into three spaces: Eden and two Survivor spaces, usually called S0 and S1.

When you create a new object in Kotlin, it gets allocated in Eden. Eden is where everything starts. Allocation in Eden is incredibly fast, often just a pointer bump. The runtime maintains a pointer to the next free spot and just advances it forward. This is faster than malloc in C.

When Eden fills up, a minor garbage collection is triggered. The collector traces from the roots, finds all the live objects in the young generation, and copies them into one of the Survivor spaces. Everything left behind in Eden is dead and the entire space is wiped clean in one shot. You don't free individual objects. You just reset the pointer back to the beginning. That's what makes it so fast.

Objects that survive a minor GC get copied between the two Survivor spaces, back and forth, each time a minor GC runs. After an object survives a certain number of these cycles, typically around fifteen, the collector says okay, you are clearly not temporary, and it promotes the object to the old generation. This process is called tenuring.

Old Generation and Major GC

The old generation is where long-lived objects end up. Your Application object, your singleton repositories, your cached bitmaps, your retained fragments, they all eventually land here. The old generation is larger than the young generation and its objects tend to stick around.

When the old generation fills up, the runtime triggers a major garbage collection, sometimes called a full GC. This is a much bigger deal than a minor GC. The collector has to examine a much larger set of objects, and the ratio of garbage to live objects is much worse because these are the survivors, the objects that have already proven they tend to stick around.

Major GC is slower, it touches more memory, and historically it was the main source of those dreaded GC pauses that would freeze your entire app. In the old days of Android, a major GC could pause your app for fifty, a hundred, even two hundred milliseconds. At sixty frames per second, you need to produce a frame every sixteen milliseconds. A hundred-millisecond pause means you just dropped six frames. Your users see a visible stutter, a jank, and it feels terrible.

This is why understanding garbage collection is not academic for Android developers. It is directly connected to whether your app feels smooth or janky.

ART's Concurrent Copying Collector

Now here is where Android's story diverges from the standard JVM. Android does not use HotSpot. It uses ART, the Android Runtime, and starting with Android 8, ART introduced a concurrent copying collector that is genuinely impressive.

The key word is concurrent. Older collectors had to stop the world, meaning every thread in your app freezes while the GC does its work. ART's concurrent copying collector does most of its work while your app threads are still running. It copies live objects from one region to another concurrently, meaning your UI thread can keep drawing frames while the collector is relocating objects in the background.

How does it pull this off? It uses a read barrier. Every time your code reads a reference to an object, a tiny check happens to see if that object has been moved by the collector. If it has, the read barrier updates the reference on the fly to point to the new location. This adds a small overhead to every reference read, but the tradeoff is worth it because you almost never get a long pause.

ART also uses a region-based heap instead of the classic Eden and Survivor space layout. The heap is divided into equal-sized regions, and the collector can independently collect regions that have a lot of garbage, leaving mostly-live regions alone. This is similar to what the G1 collector does on the JVM, but ART's implementation is tuned specifically for mobile devices with constrained memory and battery.

GC Pauses: What Still Hurts

So if ART's collector is concurrent, are GC pauses a thing of the past? Not entirely. Even concurrent collectors need short stop-the-world pauses at certain points. For example, the collector needs a brief pause to scan the root set, because it needs a consistent snapshot of what the roots look like. On ART, these pauses are typically under one or two milliseconds, which is vastly better than the old days, but they are not zero.

The real problem in practice is not the pause duration, it is the allocation rate. If your code allocates objects faster than the collector can reclaim them, the runtime has no choice but to slow your threads down or block them entirely until the collector catches up. This is called allocation stalling, and it can happen even with a concurrent collector.

Picture a RecyclerView that is scrolling fast. Every frame, your onBindViewHolder creates new Strings by concatenating text, new SpannableStringBuilders, new temporary objects from data transformations. If you are producing garbage at a ferocious rate, the collector has to work harder and harder. Even if each individual pause is small, the cumulative effect on your frame budget can push you over that sixteen-millisecond threshold.

The takeaway is this: a concurrent collector gives you a much bigger margin of error, but it is not a license to allocate recklessly.

Finalizers: The Trap

Now let's talk about one of the most misunderstood features in the JVM and ART: finalizers. In Kotlin, you can override the finalize method on any class, and the runtime promises to call it before the object's memory is reclaimed. Sounds useful, right? You could close a file handle or release a native resource in your finalizer. In practice, finalizers are almost always a mistake.

Here is why. First, the garbage collector cannot reclaim a finalizable object immediately. When it discovers the object is unreachable, it has to put it on a special finalization queue. A separate finalizer thread picks it up and calls finalize. Only after that can the object be collected in a subsequent GC cycle. So finalizable objects survive at least one extra GC cycle, which means they get promoted to the old generation more often, which means more major GCs, which means more pauses.

Second, the finalizer thread runs at a low priority. There is no guarantee when it will get to your object. On a busy Android device, your finalizer might not run for seconds, or it might never run at all if the process is killed first.

Third, if your finalizer throws an exception, it is silently swallowed. Nobody will ever know.

The right approach for resource cleanup in Kotlin is the use or Closeable pattern. If you have something that needs explicit cleanup, implement Closeable and use the use extension function. It is deterministic, it is immediate, and it does not mess with the garbage collector.

What You Can Actually Control

So as an Android developer, you cannot choose your garbage collector or tune its parameters the way a server-side Java developer can. But there is plenty you can control that directly affects GC behavior.

First, reduce your allocation rate. This is the single most impactful thing. Avoid creating objects in hot loops. Be careful with string concatenation in repeated code paths. Watch out for autoboxing, where Kotlin wraps your Int into a java.lang.Integer behind the scenes. Use primitive arrays when you can. Avoid creating unnecessary intermediate collections. If you are calling map then filter then toList on a large collection, consider using sequences or asSequence to avoid creating intermediate lists.

Second, avoid memory leaks. A leaked object is not just one wasted object. It keeps alive the entire graph of objects reachable from it. A leaked Activity can hold on to its entire view hierarchy, all the bitmaps those views reference, and all the data those views are rendering. That is potentially megabytes of memory that the collector cannot reclaim, pushing more pressure onto the old generation and triggering more major GCs.

Third, pool expensive objects. If you have objects that are costly to create and you use them frequently, consider reusing them. Android's own Recycler in RecyclerView is exactly this pattern. The Message class in the Handler system uses an object pool. Just be careful, object pools add complexity and can cause their own bugs if you accidentally use a returned object.

Profiling GC in Practice

All of this theory is useful, but you need to be able to see what the garbage collector is doing in your actual app. Android Studio's Memory Profiler is your best friend here. You can watch the heap size in real time, see allocation counts, and even trigger garbage collection manually to see what gets reclaimed.

Look for a sawtooth pattern in the memory graph. A healthy app shows a gentle sawtooth: memory gradually rises as objects are allocated, then drops when a GC runs, then rises again. If you see the baseline of that sawtooth creeping upward over time, you have a memory leak. The GC is running but it cannot bring the memory back down to where it was.

You can also use adb shell to see GC events in logcat. ART logs every collection with timing information. Look for lines that say concurrent copying GC and check the pause times. If you see pauses over five milliseconds during interactive use, something is wrong.

For allocation-heavy scenarios, use the allocation tracker in the profiler to find which methods are creating the most objects. Often you will find a surprise: some innocent-looking utility function that gets called thousands of times per frame, each time creating a small temporary object that adds up to a flood of garbage.

Why This Matters

Let's bring it all together. Garbage collection is not some distant infrastructure concern. It is the system sitting between your Kotlin code and the raw hardware, and it runs on the same CPU cores your UI thread needs for rendering frames.

Every object you create is a promise that the garbage collector will eventually have to keep. Every reference you hold is a root that keeps an entire object graph alive. Every allocation in a hot path is pressure on the collector to work harder and faster.

The good news is that ART's concurrent copying collector is remarkably good. For most code, you do not need to think about GC at all. But when your app stutters, when your scrolling hitches, when your animations drop frames, the garbage collector is one of the first places to look. And now you have the mental model to understand what you are looking at.

You know about roots and reachability. You know why generations exist. You know what happens during a minor GC versus a major GC. You know how ART's concurrent collector works and why it still has limits. You know why finalizers are a trap and what to do instead. And you know the levers you can pull: allocation rate, leak prevention, and object pooling.

That mental model is worth its weight in gold the next time you are staring at a memory profiler wondering why your app feels slow. You will know exactly where to look and exactly what questions to ask. That is the power of understanding how software actually works.