Garbage collection

The garbage collector is the part of the JVM that frees memory you stopped using. It works because most objects die young, so it splits the heap into generations and cleans the young one often and cheaply. You'll see minor and full collections step by step, read real G1 log lines, learn why System.gc() is only a polite request, find the three classic ways Java programs still leak memory, and then practise with 8 problems.

Module 8 · JVM Memory⏱ 26 minIntermediate8 practice problems
1 The story

The server that slows down every few seconds

Riya's internship team runs an order API for a grocery app. Most requests take 20 ms, but a dashboard shows small spikes every few seconds and, once a week, the service slows to a crawl and then dies with OutOfMemoryError. Her lead hands her a GC log and says, “Tell me what the collector is doing.”

Young generation: cleaned often, cheaply Eden Survivor survivedmany GCs Old generation: cleaned rarely cacheconfigpoolsessions room left most new objects die here, young long-lived data ends up here myphatshala.in
Riya

Sir, the log says “Pause Young” hundreds of times. Is something broken?

Arjun Sir

No, that's healthy. Most objects a request creates are garbage a few milliseconds later. The collector cleans the young area often, keeps the few survivors, and pauses your program for about a millisecond each time.

Riya

Then what about the weekly crash?

Arjun Sir

That's different: something keeps objects reachable that should have died, so they pile up in the old area until nothing can be freed. Let's learn to read both stories from the log.

2 Definition

What is garbage collection?

Formal definition (write this in exams)

Garbage collection is the automatic process by which the JVM finds objects on the heap that are no longer reachable from any GC root and reclaims their memory. HotSpot collectors are generational: new objects are allocated in the young generation (Eden plus two survivor spaces), which is collected frequently in a minor GC; objects that survive several collections are promoted to the old generation, which is collected less often. Collection is based on mark (find live objects from the roots), sweep (reclaim the rest) and compact or copy (move live objects together). Since Java 9 the default collector is G1.

In simple words

A cleaner who visits often. She doesn't ask what is rubbish; she asks what is still in use, by following every string tied to something important, and throws away everything else. Most new things are rubbish quickly, so she checks the “new arrivals” shelf most often.

Words you'll meet in this lesson

EdenWhere new objects are allocated. Emptied by every minor GC.
Survivor spacesTwo small areas that hold objects which survived a minor GC.
PromotionMoving a long-lived object from young to old generation.
Stop-the-world (STW)A pause in which all application threads wait for the GC.
Minor / Full GCCollects only the young generation / the whole heap.
Memory leakObjects you no longer need that are still reachable, so they can't be freed.

GC facts at a glance

Generational ideaMost objects die young, so collect the young area often and cheaply
Minor GCYoung generation only. Live objects are copied to a survivor space (age + 1) or promoted to old
Major / Full GC“Major” loosely means collecting the old generation; a Full GC collects the whole heap and is the slowest pause
Default collectorG1 since Java 9 (Parallel before). On a machine with 1 CPU or under about 2 GB RAM, the JVM picks Serial instead
Low-pause optionsZGC (production since Java 15, generational with -XX:+ZGenerational in Java 21) and Shenandoah (Java 15, in many OpenJDK builds)
See itjava -Xlog:gc Main (Java 9+ unified logging). Java 8 used -XX:+PrintGCDetails

Exam tip: System.gc() does not force a collection; it only suggests one, and the JVM may ignore it (for example with -XX:+DisableExplicitGC). Also, no collection can free an object that is still reachable, so System.gc() never fixes a memory leak.

3 Types and ways to use it

Collectors, and how to read what they did

G1 (default)

Splits the heap into equal regions, each acting as Eden, survivor or old. Short young pauses, concurrent marking of old regions, aims for 200 ms max pauses by default.

-XX:+UseG1GC

Parallel and Serial

Parallel uses all CPUs for maximum throughput, accepting longer pauses: good for batch jobs. Serial uses one thread: small heaps and tiny containers.

-XX:+UseParallelGC

ZGC and Shenandoah

Do almost all work while your threads keep running. Pauses usually stay well under a few milliseconds, even on very large heaps. For latency-sensitive services.

-XX:+UseZGC

A program that makes lots of garbage

import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<int[]> live = new ArrayList<>();
        for (int i = 0; i < 2_000; i++) {
            int[] temp = new int[25_000];        // about 100 KB, garbage after this iteration
            temp[0] = i;
            if (i % 100 == 0) {
                live.add(new int[25_000]);       // every 100th one is kept
            }
        }
        System.out.println("Live arrays kept: " + live.size());
        System.gc();
        System.out.println("Done");
    }
}
Live arrays kept: 20 Done

Run it with GC logging and a 64 MB heap (your numbers will differ):

java -Xlog:gc -Xmx64m Main
[0.004s][info][gc] Using G1 [0.032s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 7M->1M(64M) 1.235ms [0.037s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 16M->1M(64M) 0.518ms [0.048s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 38M->1M(64M) 1.024ms [0.052s][info][gc] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 38M->2M(64M) 0.790ms [0.054s][info][gc] GC(4) Pause Young (Normal) (G1 Evacuation Pause) 38M->2M(64M) 0.863ms [0.057s][info][gc] GC(5) Pause Young (Normal) (G1 Evacuation Pause) 38M->2M(64M) 1.113ms Live arrays kept: 20 [0.078s][info][gc] GC(6) Pause Full (System.gc()) 38M->3M(17M) 5.790ms Done

Reading one log line

PartExampleMeaning
Uptime[0.052s]Seconds since the JVM started
Level and tag[info][gc]Unified logging (Java 9+): the gc tag at info level
GC numberGC(3)The fourth collection of this run (counting from 0)
Type and causePause Young (Normal) (G1 Evacuation Pause)A stop-the-world young collection because Eden was full
Heap before → after (size)38M->2M(64M)Used heap went from 38 MB to 2 MB; the heap's current size is 64 MB
Pause time0.790msHow long application threads were stopped

Leaks still happen in Java. The GC frees only unreachable objects. The classic leaks are: objects added to a static collection and never removed; resources (connections, streams) never closed; listeners and callbacks never unregistered; and caches without a size limit or expiry. In the log, a leak looks like the “after” number creeping up collection after collection.

4 Step by step

One minor GC in G1, step by step

  1. Allocate in Eden

    int[] temp = new int[25_000];
    Each thread has a private chunk of Eden (a TLAB, thread-local allocation buffer), so most allocations are just “bump a pointer”, with no locking. Very large objects (half a region or more) are allocated directly as “humongous” objects in old regions.
  2. Eden is full: stop the world

    The next allocation can't be satisfied, so G1 starts a young collection.

    All application threads are paused at a safe point. In the log this is Pause Young (Normal) (G1 Evacuation Pause). Pauses grow with the number of live objects, not with the amount of garbage.
  3. Mark what is live

    Start from the roots: locals in every thread's frames, static fields, and references from old objects into young ones.

    G1 keeps a record (remembered sets, built from a card table) of which old regions point into young regions, so it doesn't have to scan the whole old generation to find those references.
  4. Copy survivors, promote the old-timers

    Live young objects are copied into a survivor region, and their age goes up by one.

    An object whose age reaches the tenuring threshold (at most 15, set with -XX:MaxTenuringThreshold) is promoted to an old region. Copying also compacts: survivors end up side by side, with no gaps.
  5. Free Eden in one go, resume

    Everything left in Eden is garbage, so those regions are simply marked empty.

    No object is visited to be deleted, which is why young collections are cheap. Old regions are cleaned differently: G1 marks them concurrently while your code runs, then includes the emptiest ones in later “mixed” collections. A Full GC (whole heap, mark-sweep-compact) is the fallback when that can't keep up.
5 See it happen

Generations at work: both views

Left: what the program does and the real -Xlog:gc lines. Right: the heap's young and old generations.

From Eden to old generation, and a leak 6 steps
What you see int[] temp = new int[25_000]; System.gc(); cache.add(new byte[100_000]); [0.004s][info][gc] Using G1 // new arrays land in Eden GC(0) Pause Young (Normal) (G1 Evacuation Pause) 7M->1M(64M) 1.235ms // 6 MB was garbage, freed // in about 1 ms GC(3) Pause Young (Normal) (G1 Evacuation Pause) 38M->2M(64M) 0.790ms // a few survivors keep aging GC(6) Pause Full (System.gc()) 38M->3M(17M) 5.790ms // whole heap, longest pause GC(16) Pause Full (G1 Compaction Pause) 60M->60M(64M) 3.235ms java.lang.OutOfMemoryError: Java heap space Allocating is fast: no GC yet. Eden full: a short young pause. Before 7 MB, after 1 MB: mostly garbage. Hundreds of these are normal. A full GC cleans everything, slowly. 60M->60M: nothing could be freed. myphatshala.in
Behind the screen Young generation Eden S0 S1 Old generation Eden full a1a1 empty again a2 a15 promote livelive compacted to the left all reachable from static cache New objects fill Eden, side by side. Pause. Mark from roots: only 2 objects are live. Live ones copied to S0 (age 1). Eden freed in bulk. Survivors bounce S0 ↔ S1; at age 15 they go old. Full GC: mark, sweep and compact the whole heap. Leak: everything is live, so nothing can be freed. myphatshala.in
6 Real-world use

Garbage collection in real systems

Developers rarely tune the GC on day one, but they read GC logs, pick collectors and chase leaks regularly.

Watch it with an example: lunch-rush orders in a food-delivery server

Every order request creates a few short-lived objects; only the customer's cart session lives on. Watch a young collection throw away the rush and keep the few objects that matter.

Most objects die young 6 steps
Animated example OrderRequest r = parse(json); // per request sessions.put(userId, cart); // kept // Eden full: Pause Young // survivors copied, Eden freed // many GCs later: cart is old // result: rush cleaned cheaply Eden Survivor Old req json dto req json dto req json dto req cart cart age 1 cart age 15 empty again 9 temporary objects, 1 cart STW pause ≈ 1 ms: mark from roots copied 1 object, freed 9 in one go promoted after surviving many minor GCs cost depends on live objects, not garbage sessions map (a root) → cart myphatshala.in
pause

1. Low-latency APIs

Payment and trading APIs care about the slowest requests. A 300 ms GC pause shows up as a timeout. Such teams measure pause times from GC logs and may switch to ZGC for very large heaps.

p99 latency spikes→-Xlog:gc, -XX:+UseZGC→Short pauses

2. Overnight batch jobs

A job that recalculates interest for every account cares about total time, not pauses. The Parallel collector can finish it faster by using all CPUs during longer stop-the-world collections.

Batch job→-XX:+UseParallelGC→Higher throughput
GC(812) Pause YoungGC(813) Pause YoungGC(814) Pause Full

3. GC logs in production

Services are started with a GC log written to a rotating file, for example -Xlog:gc*:file=gc.log. When an incident happens, the log shows whether pauses or a full heap were involved.

Incident→gc.log→Pauses? Leak? Neither?
heap after GC

4. Hunting a memory leak

If the heap left after each full GC keeps rising, something is holding objects. A heap dump opened in a tool like Eclipse MAT shows the biggest objects and the reference chain (often a static map) that keeps them alive.

Rising baseline→heap dump → path to root→Fix the reference
1 CPU512 MB

5. Small containers

In a container with 1 CPU or less than about 2 GB of memory, the JVM chooses the Serial collector, not G1. Teams that expect G1 behaviour set the collector explicitly so it doesn't silently change between environments.

Tiny container→-XX:+UseG1GC (explicit)→Same GC everywhere

Examples are simplified. GC tuning should always start from measurements (logs and metrics), not from flags copied from the internet.

7 Run the code

A leaky cache next to a bounded one

import java.util.ArrayList;
import java.util.List;

public class Main {
    static List<int[]> leaky = new ArrayList<>();     // never cleaned
    static List<int[]> bounded = new ArrayList<>();   // keeps the last 100

    static void handleLeaky() {
        leaky.add(new int[1000]);                     // about 4 KB per request
    }

    static void handleBounded() {
        bounded.add(new int[1000]);
        if (bounded.size() > 100) {
            bounded.remove(0);                        // oldest becomes unreachable
        }
    }

    public static void main(String[] args) {
        for (int request = 1; request <= 10_000; request++) {
            handleLeaky();
            handleBounded();
        }
        System.out.println("Leaky cache:   " + leaky.size() + " entries, about " + leaky.size() * 4 + " KB kept alive");
        System.out.println("Bounded cache: " + bounded.size() + " entries, about " + bounded.size() * 4 + " KB kept alive");
    }
}
Leaky cache: 10000 entries, about 40000 KB kept alive Bounded cache: 100 entries, about 400 KB kept alive
  • static List<int[]> leakyA static field is a GC root. Everything in this list stays reachable until the JVM exits.
  • leaky.add(new int[1000]);Each request adds about 4 KB that can never be freed: memory grows with traffic.
  • bounded.remove(0);Dropping the list's reference makes the oldest array garbage; the next minor GC can reclaim it.
  • 40000 KB vs 400 KBSame work, 100 times less memory kept alive. With 10 times more requests, only the leaky number grows.
8 Line by line

Three quotes in, one survivor out

Recorded from the real JVM. Watch quotes #3 and #5 die young while #4 survives, and notice the static counter in the static entry: static fields are GC roots.

Worked examples: real problems, solved and traced in memory

Three server-side programs seen through the eyes of the garbage collector. Watch which objects die young, which ones are kept alive by a static field (the static entries), and how removing a single map entry turns a whole session into garbage.

Example 1Analytics · access logs

Temporary objects die every iteration

Problem statement

An analytics job reads web-server log lines like GET /cart 120 and adds up the response times (the last number, in milliseconds). Each line is split into parts. Trace which objects survive the loop.

Input
log = {"GET /cart 120", "POST /pay 480", "GET /home 35"}
Expected output
Total time: 635 ms

Approach

  1. Loop over the lines; split(" ") creates a new array of 3 Strings for each line.
  2. Only the number is needed, so parseInt(parts[2]) turns it into an int added to totalMs.
  3. The parts array and its Strings are referenced only inside one iteration: they are garbage as soon as the body ends. This is the generational hypothesis in action: most objects die young.

Solution: run it live

What happens in memory, step by step

Recorded from the real JVM: press Next to execute one line at a time. Watch method calls push and pop frames on the stack, and objects (numbered #1, #2 …) appear on the heap.

Example 2SaaS · audit trail

A static list that never lets go

Problem statement

A SaaS app records every login in a static audit list "for auditing". Each login creates a Session object inside a helper method. Show that no Session ever becomes garbage, even though the helper's local variable disappears.

Input
logins: "riya", "arjun", "kabir"
Expected output
Sessions kept: 3

Approach

  1. login creates a Session and keeps a reference in the static list.
  2. When login returns, its local s is gone, but the list (reachable from a static field, a GC root) still holds the Session.
  3. Run this for a few lakh logins and the old generation fills with sessions nobody needs: a classic Java memory leak.

Solution: run it live

What happens in memory, step by step

Recorded from the real JVM: press Next to execute one line at a time. Watch method calls push and pop frames on the stack, and objects (numbered #1, #2 …) appear on the heap.

Example 3SaaS · active sessions

Fixing the leak: remove on logout

Problem statement

The fixed design keeps only active sessions in a static map and removes an entry when the user logs out. Trace a login, a second login and a logout, and see the removed session become garbage.

Input
login riya, login kabir, logout riya
Expected output
Active: [kabir]

Approach

  1. login puts a new Session into the map under the user name.
  2. logout calls remove(user): the map drops its reference to that Session.
  3. With no other reference, the Session is unreachable; the next collection reclaims it. The map's size now tracks real activity instead of growing forever.

Solution: run it live

What happens in memory, step by step

Recorded from the real JVM: press Next to execute one line at a time. Watch method calls push and pop frames on the stack, and objects (numbered #1, #2 …) appear on the heap.

9 Quiz and revision

Check yourself

Quick revision

Lesson 8.3 in one screen

  • GC frees unreachable objects automatically; it starts from the roots and keeps whatever it can reach.
  • Young generation = Eden + 2 survivor spaces; old generation for long-lived objects. Promotion after surviving enough minor GCs (max 15).
  • Minor GC: young only, frequent, short. Full GC: whole heap, rare, longest pause.
  • Concepts: mark (find live), sweep (free dead), compact or copy (remove gaps).
  • G1 is the default since Java 9; ZGC and Shenandoah target very short pauses; Parallel targets throughput.
  • Read -Xlog:gc: GC number, type and cause, before→after(heap size), pause time.
  • System.gc() is a request. Leaks: static collections, unclosed resources, listeners, unbounded caches.

Interview and viva questions

Explain how generational garbage collection works.

The heap is divided into a young generation (Eden and two survivor spaces) and an old generation. New objects go to Eden. When Eden fills, a minor GC copies live objects to a survivor space and increments their age; Eden is then freed in bulk. Objects that survive enough minor GCs are promoted to the old generation, which is collected less often by a major or full GC. It works well because most objects die young.

What is the difference between minor, major and full GC?

A minor GC collects only the young generation and is usually very short. “Major GC” is an informal term for collecting the old generation. A full GC collects the entire heap (young and old, often with compaction) and causes the longest stop-the-world pause. In G1 logs they appear as Pause Young, mixed collections, and Pause Full.

Can Java have memory leaks if it has a garbage collector?

Yes. The GC frees only unreachable objects. If code keeps references it no longer needs (static collections that only grow, caches without limits, listeners never unregistered, objects stored in long-lived sessions), those objects stay reachable and accumulate until OutOfMemoryError. Unclosed resources can also exhaust connections or file handles even when the heap is fine.

Why is G1 the default collector?

G1 balances throughput and pause times. It divides the heap into regions, collects the young regions in short pauses, marks the old generation concurrently, and then collects the old regions with the most garbage first (hence “Garbage First”), aiming at a pause goal (200 ms by default). It became the default in Java 9, replacing Parallel.

Should you call System.gc()?

Almost never. It is only a request, it often triggers a full, expensive collection, and it cannot free objects that are still referenced. If memory keeps growing, find what is holding the objects. Some teams start JVMs with -XX:+DisableExplicitGC to neutralise such calls from libraries.

Practice problems

Garbage collection is invisible in normal output, so these problems practise the skills around it: reading GC logs, spotting what keeps objects alive, and bounding memory use. The last three are business tasks.

P1Easy

Read one GC log line

Take a real -Xlog:gc line and print the GC number, the kind of pause, the memory freed and the pause time.

Input
[0.056s][info][gc] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 38M->2M(64M) 0.572ms
Expected output
GC(3): Young pause of 0.572ms
Heap 38 MB -> 2 MB, freed 36 MB
Show solution and explanation
public class Main {
    public static void main(String[] args) {
        String line = "[0.056s][info][gc] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 38M->2M(64M) 0.572ms";
        String[] words = line.split(" ");
        String gcId = words[1];
        String kind = words[3];                       // Young, Full, ...
        String heap = words[words.length - 2];        // 38M->2M(64M)
        String pause = words[words.length - 1];       // 0.572ms

        int before = Integer.parseInt(heap.substring(0, heap.indexOf('M')));
        int after = Integer.parseInt(heap.substring(heap.indexOf('>') + 1, heap.indexOf("M(")));
        System.out.println(gcId + ": " + kind + " pause of " + pause);
        System.out.println("Heap " + before + " MB -> " + after + " MB, freed " + (before - after) + " MB");
    }
}
GC(3): Young pause of 0.572ms Heap 38 MB -> 2 MB, freed 36 MB

How it works: a G1 log line has a fixed shape: uptime, level, tag, GC number, type, cause, before->after(committed) and the pause time. Here a young collection shrank the used heap from 38 MB to 2 MB: almost everything in Eden was garbage, which is the normal, healthy case.

P2Easy

Created vs still alive

A loop creates 10 price quotes but keeps only the cheapest. Count how many objects were created and how many are still reachable at the end.

Input
prices = {520, 480, 505, 499, 530, 470, 515, 490, 476, 510}
Expected output
Quotes created: 10
Still reachable: 1 (best = Rs 470)
Garbage: 9
Show solution and explanation
public class Main {
    static class Quote {
        final int price;
        Quote(int price) { this.price = price; }
    }

    public static void main(String[] args) {
        int[] prices = {520, 480, 505, 499, 530, 470, 515, 490, 476, 510};
        Quote best = null;
        int created = 0;
        for (int p : prices) {
            Quote q = new Quote(p);
            created++;
            if (best == null || q.price < best.price) best = q;
        }
        System.out.println("Quotes created: " + created);
        System.out.println("Still reachable: 1 (best = Rs " + best.price + ")");
        System.out.println("Garbage: " + (created - 1));
    }
}
Quotes created: 10 Still reachable: 1 (best = Rs 470) Garbage: 9

How it works: each iteration creates a Quote; only the one held by best survives the iteration. 9 of 10 objects die young, which is why collectors split the heap into a young area that is cleaned often and cheaply, and an old area for the few survivors.

P3Medium

Bound a growing cache

A product-view cache must never hold more than 3 entries. Add entries one by one; when it is full, remove the oldest before adding. Print the cache after each step.

Input
views: "P101", "P205", "P333", "P101-v2", "P419"
Expected output
[P101]
[P101, P205]
[P101, P205, P333]
  evicted P101
[P205, P333, P101-v2]
  evicted P205
[P333, P101-v2, P419]
Show solution and explanation
import java.util.ArrayList;
import java.util.List;

public class Main {
    static final int MAX = 3;
    static List<String> cache = new ArrayList<>();

    static void remember(String product) {
        if (cache.size() == MAX) {
            String evicted = cache.remove(0);   // oldest entry loses its reference
            System.out.println("  evicted " + evicted);
        }
        cache.add(product);
    }

    public static void main(String[] args) {
        String[] views = {"P101", "P205", "P333", "P101-v2", "P419"};
        for (String v : views) {
            remember(v);
            System.out.println(cache);
        }
    }
}
[P101] [P101, P205] [P101, P205, P333] evicted P101 [P205, P333, P101-v2] evicted P205 [P333, P101-v2, P419]

How it works: a static collection is a GC root, so anything in it lives forever unless you remove it. Capping the size turns an unbounded leak into a fixed memory cost. Real systems use ready-made caches with size limits and expiry times; the principle is the same.

P4Medium

Unregister your listeners

A price-alert service keeps a static list of listeners. Two screens register, then one screen closes. Show the difference between forgetting to unregister and doing it properly.

Input
screens: "cart", "wishlist"; "cart" closes
Expected output
After closing without unregistering:
  [cart] price drop: Headphones
  [wishlist] price drop: Headphones
After unregistering:
  [wishlist] price drop: Smartwatch
Listeners left: 1
Show solution and explanation
import java.util.ArrayList;
import java.util.List;

public class Main {
    static class Screen {
        final String name;
        Screen(String name) { this.name = name; }
        void onPriceDrop(String item) {
            System.out.println("  [" + name + "] price drop: " + item);
        }
    }

    static List<Screen> listeners = new ArrayList<>();

    static void publish(String item) {
        for (Screen s : listeners) s.onPriceDrop(item);
    }

    public static void main(String[] args) {
        Screen cart = new Screen("cart");
        Screen wishlist = new Screen("wishlist");
        listeners.add(cart);
        listeners.add(wishlist);

        cart = null;                                  // screen closed, but still registered!
        System.out.println("After closing without unregistering:");
        publish("Headphones");

        listeners.removeIf(s -> s.name.equals("cart"));   // proper unregister
        System.out.println("After unregistering:");
        publish("Smartwatch");
        System.out.println("Listeners left: " + listeners.size());
    }
}
After closing without unregistering: [cart] price drop: Headphones [wishlist] price drop: Headphones After unregistering: [wishlist] price drop: Smartwatch Listeners left: 1

How it works: setting cart = null doesn't make the screen garbage: the static listeners list still reaches it, and it even keeps receiving events. Only removing it from the list makes it unreachable. In real apps, unregister in the screen's close or destroy method.

P5Medium

Check GC pauses against an SLA

An API promises responses within 50 ms. Given the pause times (ms) from a GC log, print the average pause, the longest pause, and how many pauses alone would break the promise.

Input
pauses = {0.74, 1.00, 1.05, 0.57, 62.4, 0.70, 4.62, 0.69}
Expected output
Average pause: 8.97 ms
Longest pause: 62.40 ms
Pauses over 50 ms: 1
Show solution and explanation
public class Main {
    public static void main(String[] args) {
        double[] pauses = {0.74, 1.00, 1.05, 0.57, 62.4, 0.70, 4.62, 0.69};
        double sum = 0, max = 0;
        int breaches = 0;
        for (double p : pauses) {
            sum += p;
            if (p > max) max = p;
            if (p > 50) breaches++;
        }
        System.out.printf("Average pause: %.2f ms%n", sum / pauses.length);
        System.out.printf("Longest pause: %.2f ms%n", max);
        System.out.println("Pauses over 50 ms: " + breaches);
    }
}
Average pause: 8.97 ms Longest pause: 62.40 ms Pauses over 50 ms: 1

How it works: the average looks fine (under 10 ms), but one 62.4 ms pause breaks the 50 ms promise for every request that was running at that moment. That's why latency-sensitive teams look at the maximum and high percentiles of GC pauses, not the average, and may choose a low-pause collector like ZGC.

P6Business problem

Expire idle sessions

A banking portal logs users out after 15 minutes of inactivity. Given each user's last-activity minute and the current minute (60), remove expired sessions from the map so they can be garbage collected, and print who is still logged in.

Input
lastActive: riya=58, arjun=30, kabir=47, meera=44
now = 60, timeout = 15 minutes
Expected output
expired: arjun (idle 30 min)
expired: meera (idle 16 min)
Still logged in: [kabir, riya]
Show solution and explanation
import java.util.Map;
import java.util.TreeMap;

public class Main {
    public static void main(String[] args) {
        Map<String, Integer> lastActive = new TreeMap<>();
        lastActive.put("riya", 58);
        lastActive.put("arjun", 30);
        lastActive.put("kabir", 47);
        lastActive.put("meera", 44);

        int now = 60, timeout = 15;
        lastActive.entrySet().removeIf(e -> {
            boolean expired = now - e.getValue() > timeout;
            if (expired) System.out.println("expired: " + e.getKey() + " (idle " + (now - e.getValue()) + " min)");
            return expired;
        });
        System.out.println("Still logged in: " + lastActive.keySet());
    }
}
expired: arjun (idle 30 min) expired: meera (idle 16 min) Still logged in: [kabir, riya]

How it works:

  1. Many users never press logout, so a session map without expiry is a slow leak.
  2. removeIf walks the entries once and removes those idle for more than 15 minutes: arjun (30 min) and meera (16 min). kabir has been idle exactly 13 minutes and stays.
  3. A TreeMap keeps the names sorted so the output is predictable. Real systems use a cache library or the web framework's session timeout setting.
P7Business problem

Summarise a GC log

An SRE pastes five GC log lines from a payments server. Count young and full pauses and add up the total pause time.

Input
5 lines from -Xlog:gc (see the code)
Expected output
Young pauses: 4, full pauses: 1
Total pause time: 7.977 ms
Check the cause of the full GC: here it was an explicit System.gc() call
Show solution and explanation
public class Main {
    public static void main(String[] args) {
        String[] log = {
            "[0.032s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 7M->1M(64M) 0.738ms",
            "[0.038s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 16M->1M(64M) 1.000ms",
            "[0.051s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 38M->1M(64M) 1.048ms",
            "[0.056s][info][gc] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 38M->2M(64M) 0.572ms",
            "[0.081s][info][gc] GC(6) Pause Full (System.gc()) 38M->3M(17M) 4.619ms"
        };
        int young = 0, full = 0;
        double totalMs = 0;
        for (String line : log) {
            if (line.contains("Pause Young")) young++;
            else if (line.contains("Pause Full")) full++;
            String last = line.substring(line.lastIndexOf(' ') + 1);      // e.g. 0.738ms
            totalMs += Double.parseDouble(last.replace("ms", ""));
        }
        System.out.println("Young pauses: " + young + ", full pauses: " + full);
        System.out.printf("Total pause time: %.3f ms%n", totalMs);
        if (full > 0) System.out.println("Check the cause of the full GC: here it was an explicit System.gc() call");
    }
}
Young pauses: 4, full pauses: 1 Total pause time: 7.977 ms Check the cause of the full GC: here it was an explicit System.gc() call

How it works:

  1. Each line ends with the pause time, so lastIndexOf(' ') finds it.
  2. Young pauses are expected and short. A Full pause is the expensive one; its cause in brackets tells you why it happened. Here it was System.gc(), which some library may be calling. -XX:+DisableExplicitGC would turn such calls into no-ops.
  3. These lines are real output from JDK 21 with G1.
P8Business problem

How often will Eden fill up?

An order API handles 400 requests per second, and each request allocates about 250 KB of short-lived objects. With an Eden space of 600 MB, estimate the allocation rate and how often a young GC will run.

Input
400 requests/s, 250 KB per request, Eden = 600 MB
Expected output
Allocation rate: 97.7 MB/s
Eden fills every 6.1 seconds
About 10 young GCs per minute
Show solution and explanation
public class Main {
    public static void main(String[] args) {
        int requestsPerSecond = 400;
        int kbPerRequest = 250;
        int edenMb = 600;

        double mbPerSecond = requestsPerSecond * kbPerRequest / 1024.0;
        double secondsToFill = edenMb / mbPerSecond;
        System.out.printf("Allocation rate: %.1f MB/s%n", mbPerSecond);
        System.out.printf("Eden fills every %.1f seconds%n", secondsToFill);
        System.out.printf("About %.0f young GCs per minute%n", 60 / secondsToFill);
    }
}
Allocation rate: 97.7 MB/s Eden fills every 6.1 seconds About 10 young GCs per minute

How it works:

  1. 400 × 250 KB ≈ 97.7 MB of new objects every second.
  2. A 600 MB Eden fills in about 6 seconds, so expect roughly 10 young collections per minute. That is normal; each is short if most objects are already dead.
  3. Capacity planners use exactly this arithmetic, together with real GC logs, to size the heap. Halving the allocation per request halves the GC frequency.

Real-world scenario questions (asked in company interviews)

GC questions separate developers who have read about it from those who have debugged it. Decide your answer first, then open the model answer.

Banking · batch jobFresher

“Let's call System.gc() after every batch”

A statement-generation job processes accounts in batches of 10,000. A teammate notices memory going up and down and suggests adding System.gc() after each batch “to keep memory clean”. What do you say?

The interviewer is checking: do you know what System.gc() really does and what it costs?

Show a strong answer
  • Memory going up and down is normal: objects from a batch become garbage and young GCs clean them.
  • System.gc() is only a request, and with G1 it usually triggers a full collection, the slowest kind, after every batch. The job gets slower, not cleaner.
  • If memory doesn't come back down after GCs, that is a leak: something still references the old batches. Find it with a heap dump instead.

Avoid: believing System.gc() can free objects that are still referenced.

E-commerce · product service1–3 years

OutOfMemoryError every Sunday

A product service is restarted every Monday deploy. By Sunday it dies with OutOfMemoryError: Java heap space. GC logs show the heap left after each full GC rising slowly all week. Someone added a static Map<String, Product> cache of every product ever viewed. Explain and fix.

The interviewer is checking: can you connect the GC log pattern to a reachability leak?

Show a strong answer
  • The rising “after” value means live data is growing: the static map is a GC root, and it only ever gets new entries, so every product viewed all week stays reachable.
  • Confirm with a heap dump: the map will dominate the heap, with a path to a static field.
  • Fix: a cache with a maximum size and expiry (for example a caching library, or at least an LRU with a limit), or no cache if the database is fast enough.
  • Add a heap-after-GC alert so the next leak is caught on Tuesday, not Sunday.

Avoid: “double -Xmx” or “restart it daily”. Both just move the crash.

Fintech · trading API3+ years

Latency spikes every few minutes

A quote API on a 16 GB G1 heap has a p99 latency target of 50 ms. Every few minutes a burst of requests takes 300–400 ms. Average latency looks fine. How do you investigate and what options would you consider?

The interviewer is checking: can you correlate GC pauses with latency and choose sensible remedies?

Show a strong answer
  1. Enable or read the GC log and line up pause timestamps with the slow requests. Look for Pause Full, long young pauses or humongous allocations.
  2. If full GCs appear, find their cause: old generation filling (too small heap or a leak), humongous objects (very large arrays), or explicit System.gc() calls.
  3. Reduce allocation in hot paths (reuse buffers, avoid huge temporary arrays), size the heap from measured live data, and consider ZGC, which keeps pauses very short even on large heaps.
  4. Verify with load tests and compare p99 before and after, changing one thing at a time.

Avoid: tuning random flags without evidence from logs.

Travel · price alerts1–3 years

Closed screens that never go away

A travel app's backend creates a FareWatcher object for each user who opens a flight search and registers it with a global, static FareFeed to receive price updates. Months later the heap is full of FareWatcher objects for searches users abandoned long ago. Why, and how do you design it properly?

The interviewer is checking: do you recognise the listener leak pattern?

Show a strong answer
  • The static FareFeed keeps a list of registered watchers. A watcher is reachable through it for as long as it stays registered, no matter that the user left.
  • Unregister when the search ends or the session expires; tie the watcher's lifetime to the session (for example in a try-with-resources style or a session-expiry hook).
  • Add an expiry to subscriptions so forgotten ones are removed automatically, and monitor the number of registered watchers.

Avoid: assuming the GC will notice the user is gone. It only sees references.