Garbage collection in Java

Garbage collection in java

If you come to Java from a language like C or C++, one of the first things you notice is that nobody is calling free(). You allocate objects with new, use them, and then... just walk away. Something in the background quietly cleans up after you. That something is the garbage collector (GC). For a long time I treated the GC as magic — it just worked, so why look closer? But once you start caring about performance and security, the GC stops being a black box and becomes something you actually need to understand. This post is my attempt to explain how it works, and then to dig into a part that gets talked about far less: the security implications of automatic memory management.

garbage collector

What the garbage collector actually does

Java objects live on the heap. When you write new User(), the JVM reserves space on the heap for that object. The problem is obvious: if objects only ever get created and never removed, the heap fills up and the program crashes. The garbage collector's job is to find objects that are no longer needed and reclaim their memory. The key question is: how does it know an object is no longer needed? The answer is reachability. The GC starts from a set of known-alive references called GC roots, things like local variables on the stack of running threads, static fields, and active JNI references. From these roots, it follows every reference, and every reference those objects hold, and so on. Any object it can reach is considered "live." Anything it can't reach is garbage, because your program has no way to access it anymore. This is an important mental model: an object isn't collected because you "deleted" it. It's collected because nothing points to it anymore.

garbage collector

The object isn't freed the instant you set u = null. It becomes eligible. The GC will reclaim it whenever it next decides to run, which could be milliseconds or minutes later. That non-determinism matters a lot, and we'll come back to it in the security section.

The generational heap

If the GC had to scan the entire heap every time it ran, it would be painfully slow. So modern collectors lean on a simple observation known as the weak generational hypothesis: most objects die young. A huge fraction of objects (think temporary strings, request DTOs, loop variables) become garbage almost immediately, while a small number live for the whole life of the application. To exploit this, the heap is split into generations: Young generation - where new objects are allocated. It's further divided into Eden (where allocation happens) and two Survivor spaces. Collecting the young generation is called a minor GC, and it's fast because most objects here are already dead. Old generation (tenured) - objects that survive several minor GCs get "promoted" here. Cleaning this space is a major GC (or part of a full GC), and it's more expensive. The practical effect: short-lived objects are cheap, and the GC spends most of its effort in the small, fast young generation rather than walking the whole heap constantly.

Stop the world pauses

Here's the part that keeps performance engineers up at night. To safely move and reclaim objects, the GC sometimes has to pause every application thread. These are called stop-the-world (STW) pauses. During an STW pause, your application does nothing — no requests served, no work done. For a batch job, a 200ms pause is irrelevant. For a low-latency trading system or a real-time API, it can be a disaster. Most of the evolution in Java collectors over the past decade has been about making these pauses shorter and more predictable.

The collectors, briefly

Java doesn't have one garbage collector — it has several, each with different trade-offs between throughput (total work done) and latency (pause length). Serial GC — single-threaded, simple. Fine for small heaps and single-core environments. Parallel GC — uses multiple threads to maximize throughput. Great when you care about total work and can tolerate occasional longer pauses. G1 (Garbage-First) — the default since Java 9. It divides the heap into regions and tries to meet a target pause time, collecting the regions with the most garbage first. A good general-purpose balance. ZGC and Shenandoah — low-latency collectors designed to keep pauses in the single-digit milliseconds (or below) even on large heaps, by doing most of their work concurrently with the application. The old CMS collector you'll still see referenced in older articles was deprecated in Java 9 and removed in Java 14, so you can safely ignore it for new work.

garbage collector

"But Java has a GC, so no memory leaks, right?"

This is a myth worth killing. Garbage collection prevents one specific class of bug — manually freeing memory incorrectly (dangling pointers, double frees). It does not prevent logical memory leaks. A leak in Java happens when you keep a reference to an object you no longer need, so the GC (correctly) decides it's still reachable and never collects it. Classic culprits: A static Map or List that you keep adding to but never clean up. Listeners or callbacks you register but never unregister. Caches without an eviction policy.

The security angle

This is the part I really wanted to write about, because it's underappreciated. Automatic memory management is convenient, but it changes the threat model in a few subtle ways. 1. Sensitive data lingers in memory Because GC timing is non-deterministic, you don't control when an object's memory is reclaimed — and even when it is, the GC does not zero out the bytes. It just marks the space as available for reuse. The old contents can sit in memory until they happen to be overwritten by something else. This is a real problem for secrets: passwords, encryption keys, tokens. If they're sitting in the heap, anyone who can produce a heap dump (a debugging tool, a crash core dump, or an attacker with the right access) can read them straight out of memory. It gets worse with String. Strings in Java are immutable and often interned, which means: You cannot overwrite a String's contents after you're done with it. It may stay alive far longer than you expect, sometimes for the life of the JVM.

2. Memory exhaustion as a denial-of-service vector

Because the GC works hardest when memory pressure is high, an attacker who can make your application allocate a lot of memory can hurt you in two ways: GC thrashing — the JVM spends an increasing share of CPU running the collector instead of doing useful work, until throughput collapses. OutOfMemoryError — if they can push you over the limit, the application can crash or enter a degraded state. A request handler that allocates based on user-controlled input is the danger zone. If a user can send a payload that says "give me 10,000,000 items" and you eagerly build a list of that size, you've handed them a DoS button. Defenses are ordinary good hygiene: validate and cap input sizes, stream large responses instead of materializing them, set sane heap limits, and put request/payload size limits in front of the application.

3. The finalizer attack

This one is a genuine Java-specific security classic, and it ties directly into the GC because finalize() is called by the garbage collector. The idea: when an object is about to be collected, the old finalize() mechanism gave it one last chance to run code. An attacker can abuse this to resurrect a partially constructed object. Suppose a constructor validates its arguments and throws an exception on bad input:

public class Account { public Account(int balance) { if (balance < 0) { throw new IllegalArgumentException("negative balance"); } this.balance = balance; } }

You'd assume an Account with a negative balance can never exist. But an attacker can subclass it, override finalize(), and grab a reference to the half-built object as the GC tries to clean it up after the constructor throws — bypassing your validation entirely. The defenses: Avoid finalizers altogether. They've been deprecated since Java 9 for exactly these reasons (plus unpredictability and performance cost). Use java.lang.ref.Cleaner or PhantomReference for resource cleanup instead. If you can't, make the class final so it can't be subclassed, or use a boolean "initialized" guard flag that every method checks. The broader lesson: finalize() runs at a time you don't control, on the GC's terms, which makes it a poor and risky place to put any logic — security-sensitive or not.

That's it, stay safe!

Luka Vukovic