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.”
Sir, the log says “Pause Young” hundreds of times. Is something broken?
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.
Then what about the weekly crash?
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.
What is garbage collection?
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.
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
GC facts at a glance
| Generational idea | Most objects die young, so collect the young area often and cheaply |
|---|---|
| Minor GC | Young 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 collector | G1 since Java 9 (Parallel before). On a machine with 1 CPU or under about 2 GB RAM, the JVM picks Serial instead |
| Low-pause options | ZGC (production since Java 15, generational with -XX:+ZGenerational in Java 21) and Shenandoah (Java 15, in many OpenJDK builds) |
| See it | java -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.
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:+UseG1GCParallel 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:+UseParallelGCZGC 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:+UseZGCA 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");
}
}Run it with GC logging and a 64 MB heap (your numbers will differ):
java -Xlog:gc -Xmx64m MainReading one log line
| Part | Example | Meaning |
|---|---|---|
| Uptime | [0.052s] | Seconds since the JVM started |
| Level and tag | [info][gc] | Unified logging (Java 9+): the gc tag at info level |
| GC number | GC(3) | The fourth collection of this run (counting from 0) |
| Type and cause | Pause 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 time | 0.790ms | How 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.
One minor GC in G1, step by step
Allocate in Eden
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.int[] temp = new int[25_000];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 isPause Young (Normal) (G1 Evacuation Pause). Pauses grow with the number of live objects, not with the amount of garbage.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.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.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.
Generations at work: both views
Left: what the program does and the real -Xlog:gc lines. Right: the heap's young and old generations.
- Allocate: the loop creates arrays. The log only says which collector is in use.
- Young pause: Eden filled up, so G1 paused the program for a young collection.
- Read the numbers: 7 MB in use before, 1 MB after, pause 1.235 ms. Most of Eden was garbage.
- Steady state: collection after collection, the “after” number stays tiny. This is a healthy log.
- Full GC:
System.gc()requested a collection of the whole heap. It took several times longer than a young pause. - Leak: a static list keeps every array. Full GCs free nothing (60M → 60M), and the JVM gives up with
OutOfMemoryError.
- Every
newtakes the next free space in Eden. Allocation is just moving a pointer forward. - Eden is full, so threads stop. The collector follows references from the roots: only 2 of the 8 objects are reached. The crossed ones are garbage.
- The 2 live objects are copied into survivor space S0 with age 1. Eden is now empty without visiting a single dead object.
- On later minor GCs, survivors are copied between S0 and S1 and their age grows. An object that reaches the tenuring threshold (15 by default at most) is promoted to the old generation.
- Old objects are cleaned rarely. A full GC marks live objects in the whole heap, sweeps the dead ones and compacts the survivors so free space is one block.
- With a leak, every object is reachable from a static collection. Collections free nothing, old fills up, and allocation fails with
OutOfMemoryError.
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.
- Rush starts: each order request creates temporary objects (the raw request, parsed JSON, a DTO) in Eden.
- One keeper: the customer's cart is stored in a sessions map, which is reachable from a root. Everything else is needed only until the response is sent.
- Eden full: G1 pauses the threads for a young collection. Marking from the roots reaches only the cart; the crossed objects are garbage.
- Copy and free: the cart is copied to a survivor space with age 1, and the whole of Eden is reused at once. No dead object was visited.
- Promotion: the cart keeps surviving because the customer is still shopping. After enough minor GCs (at most 15) it moves to the old generation.
- Why it scales: a young collection's cost depends on how much is live, so a burst of lakhs of short-lived request objects is cheap to clean.
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.
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.
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.
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.
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.
Examples are simplified. GC tuning should always start from measurements (logs and metrics), not from flags copied from the internet.
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");
}
}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 KBvs400 KBSame work, 100 times less memory kept alive. With 10 times more requests, only the leaky number grows.
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.
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.
log = {"GET /cart 120", "POST /pay 480", "GET /home 35"}Total time: 635 ms
Approach
- Loop over the lines;
split(" ")creates a new array of 3 Strings for each line. - Only the number is needed, so
parseInt(parts[2])turns it into an int added tototalMs. - The
partsarray 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.
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.
logins: "riya", "arjun", "kabir"
Sessions kept: 3
Approach
logincreates a Session and keeps a reference in the static list.- When
loginreturns, its localsis gone, but the list (reachable from a static field, a GC root) still holds the Session. - 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.
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.
login riya, login kabir, logout riya
Active: [kabir]
Approach
loginputs a new Session into the map under the user name.logoutcallsremove(user): the map drops its reference to that Session.- 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.
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.
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.
[0.056s][info][gc] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 38M->2M(64M) 0.572ms
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");
}
}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.
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.
prices = {520, 480, 505, 499, 530, 470, 515, 490, 476, 510}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));
}
}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.
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.
views: "P101", "P205", "P333", "P101-v2", "P419"
[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);
}
}
}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.
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.
screens: "cart", "wishlist"; "cart" closes
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());
}
}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.
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.
pauses = {0.74, 1.00, 1.05, 0.57, 62.4, 0.70, 4.62, 0.69}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);
}
}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.
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.
lastActive: riya=58, arjun=30, kabir=47, meera=44 now = 60, timeout = 15 minutes
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());
}
}How it works:
- Many users never press logout, so a session map without expiry is a slow leak.
removeIfwalks 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.- 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.
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.
5 lines from -Xlog:gc (see the code)
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");
}
}How it works:
- Each line ends with the pause time, so
lastIndexOf(' ')finds it. - 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:+DisableExplicitGCwould turn such calls into no-ops. - These lines are real output from JDK 21 with G1.
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.
400 requests/s, 250 KB per request, Eden = 600 MB
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);
}
}How it works:
- 400 × 250 KB ≈ 97.7 MB of new objects every second.
- 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.
- 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.
“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.
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.
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
- 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. - 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. - 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.
- Verify with load tests and compare p99 before and after, changing one thing at a time.
Avoid: tuning random flags without evidence from logs.
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.