JVM Memory & Garbage Collection
Java frees memory for you. You create objects with new, and you never write a free or delete statement. A part of the JVM called the garbage collector (GC) finds objects your program can no longer use and reclaims their memory. That is a big reason Java programs rarely corrupt memory the way C programs can.
"Automatic" does not mean "you never need to think about it". Production systems still crash with OutOfMemoryError, slow down because of long GC pauses, or use far more memory than the server has. In this lesson you will learn where Java stores data, how the GC decides what is garbage, what a memory leak looks like in Java, and the handful of JVM options and tools that every backend developer should recognise.
Stack, heap and metaspace
The JVM divides memory into several areas. The three that matter most:
| Area | What lives there | Shared? |
|---|---|---|
| Stack (one per thread) | each method call's local variables, parameters and references | no, private to its thread |
| Heap | every object and array created with new, including strings | yes, all threads |
| Metaspace | class information: method code, field layouts, constants | yes |
When a method is called, Java pushes a frame with its local variables onto the thread's stack; when the method returns, the frame is thrown away instantly. A local variable of a primitive type holds the value itself; a local variable of a reference type holds only an arrow (a reference) to an object on the heap.
public class Reach {
record Booking(String passenger, int seat) {}
public static void main(String[] args) {
Booking a = new Booking("Meera", 21); // a is on main's stack, the object on the heap
Booking b = a; // second reference, same object
a = null; // object still reachable through b
System.out.println("Still reachable: " + b);
b = new Booking("Rohan", 22); // now nothing refers to Meera's booking
System.out.println("Now: " + b);
// Meera's booking is unreachable: the garbage collector may free it at any time.
}
}Still reachable: Booking[passenger=Meera, seat=21] Now: Booking[passenger=Rohan, seat=22]
An object is garbage when it is unreachable: no chain of references leads to it from a GC root. GC roots include local variables in active stack frames, static fields of loaded classes and running threads. Setting a = null did not free Meera's booking, because b still pointed to it. Only after b moved to a new object did it become unreachable.
Each thread's stack has a limited size. Endless recursion fills it and throws StackOverflowError:
public class Deep {
static int depth = 0;
static void countdown(int n) {
depth++;
countdown(n - 1); // no base case: never stops
}
public static void main(String[] args) {
try {
countdown(10);
} catch (StackOverflowError e) {
System.out.println("StackOverflowError after more than 1,000 calls? " + (depth > 1000));
}
}
}StackOverflowError after more than 1,000 calls? true
The exact depth depends on the JVM, the operating system and the frame size, so the program only checks that it was large. The fix is almost always a missing or wrong base case, not a bigger stack.
How garbage collection works
Most objects die young: the string you build for one log line, the temporary list inside one request, the receipt for one order. This observation is called the generational hypothesis, and modern collectors use it. The heap is split into a young generation, where new objects are created, and an old generation, for objects that survived several collections. Collecting the young generation is cheap, because only the few surviving objects are copied; the dead ones cost nothing.
During some phases the GC must pause your application threads briefly; this is called a stop-the-world pause. Different collectors make different trade-offs between pause time, throughput and memory:
| Collector | Flag | Best for |
|---|---|---|
| G1 (the default on most machines) | -XX:+UseG1GC | general server applications; balances pauses and throughput |
| ZGC | -XX:+UseZGC | very large heaps and very short pauses (usually under a millisecond) |
| Parallel | -XX:+UseParallelGC | batch jobs where total throughput matters more than pauses |
| Serial | -XX:+UseSerialGC | tiny heaps and single-CPU containers |
| Shenandoah | -XX:+UseShenandoahGC | low pauses, included in many OpenJDK builds |
ZGC became generational by default in Java 23 (JEP 474), and Java 24 removed its old non-generational mode (JEP 490). Java 25 made compact object headers a product feature (JEP 519): with -XX:+UseCompactObjectHeaders, every object's hidden header shrinks, which can noticeably reduce heap use for programs with many small objects.
You can watch the GC work with the unified logging option -Xlog:gc. This program creates 200,000 short-lived receipts:
public class Churn {
public static void main(String[] args) {
long total = 0;
for (int order = 1; order <= 200_000; order++) {
String receipt = "Order " + order + ": Rs " + (order % 900 + 99); // garbage soon after
byte[] pdf = new byte[1024]; // short-lived 1 KB
total += receipt.length() + pdf.length;
}
System.out.println("Bytes of receipts made: " + total);
}
}java -Xmx64m -Xlog:gc ChurnPart of one run on JDK 21 (timings and sizes differ on every machine and run):
[0.034s][info][gc] Using G1
[4.930s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 23M->0M(64M) 219.815ms
[5.182s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 35M->0M(64M) 3.668ms
[5.406s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 37M->0M(64M) 3.672ms
[5.448s][info][gc] GC(3) Pause Young (Normal) (G1 Evacuation Pause) 37M->0M(64M) 3.572ms
Bytes of receipts made: 208688673Read a line like 35M->0M(64M) 3.668ms as: heap used before, heap used after, total heap size, and pause time. The program created about 200 MB of objects inside a 64 MB heap without any trouble, because almost everything was dead by the next young collection.
OutOfMemoryError and memory leaks
In Java, a memory leak is not memory that nobody owns. It is memory that is still reachable but will never be used again, so the GC is not allowed to free it. The classic case is a static collection that only grows:
import java.util.ArrayList;
import java.util.List;
public class Leak {
// A "cache" that only ever grows: every report stays reachable forever.
private static final List<byte[]> reportCache = new ArrayList<>();
static void generateReport(int id) {
byte[] pdf = new byte[1024 * 1024]; // 1 MB pretend PDF
reportCache.add(pdf); // the bug: never removed
}
public static void main(String[] args) {
Runtime rt = Runtime.getRuntime();
System.out.println("Max heap: " + rt.maxMemory() / (1024 * 1024) + " MB");
int id = 0;
try {
while (true) {
generateReport(++id);
}
} catch (OutOfMemoryError e) {
int made = reportCache.size();
reportCache.clear(); // free the memory so we can print
System.out.println("Crashed after " + made + " reports: " + e);
}
}
}Run with java -Xmx64m Leak:
Max heap: 64 MB Crashed after 31 reports: java.lang.OutOfMemoryError: Java heap space
The exact count can vary slightly with the JVM version and collector. Run it with -Xlog:gc and you see the warning sign of a leak: collections that free almost nothing (62M->62M(64M)), followed by full GC pauses, followed by the crash. Catching OutOfMemoryError is done here only to print a message; in real code you let it crash and fix the cause.
Common leak sources in real projects: static maps used as caches, listeners that are registered and never removed, ThreadLocal values left on pooled threads, and sessions or connections that are never closed.
Heap options and tools
The options you will see in start scripts, Dockerfiles and Kubernetes files:
java -Xms512m -Xmx2g -jar orders.jar # start and maximum heap size
java -XX:MaxRAMPercentage=75 -jar orders.jar # heap as a share of container memory
java -XX:+UseZGC -jar orders.jar # choose a collector
java -Xlog:gc*:file=gc.log -jar orders.jar # detailed GC log to a file
java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=dumps -jar orders.jarWithout -Xmx, the JVM picks a maximum heap of one quarter of the machine's (or container's) memory; that is the meaning of the default MaxRAMPercentage of 25. In containers, teams usually set MaxRAMPercentage instead of a fixed -Xmx, leaving room for metaspace, thread stacks and other memory outside the heap.
The JDK ships tools to inspect a running JVM:
jcmdlists Java processes;jcmd <pid> GC.heap_infoshows heap use andjcmd <pid> GC.heap_dump file.hprofwrites a heap dump.jcmd <pid> Thread.printprints every thread's stack (useful for hangs and deadlocks).- JDK Flight Recorder (
jcmd <pid> JFR.start) records low-overhead profiling data; open the file in JDK Mission Control. - Heap dumps open in tools such as Eclipse Memory Analyzer, which shows which objects keep the most memory alive.
JVM tuning in practice means: measure first (GC logs, JFR), set a sensible heap size, pick the collector that matches your goal (G1 for most services, ZGC for strict latency), and change one thing at a time. Copying long lists of flags from old blog posts usually makes things worse, because many old flags were removed or changed meaning in newer JDKs.
Common mistakes
- Thinking
obj = nullfrees memory immediately. It only removes one reference; the GC decides when to collect. - Calling
System.gc(). It is only a hint and can cause long pauses; well-written code never needs it. - Static collections that only grow. Give caches a size limit or an expiry.
- Raising
-Xmxto hide a leak. Find the leak with a heap dump. - Setting a container's memory limit equal to
-Xmx. The JVM also needs memory outside the heap.
Key points
- Local variables live on each thread's stack; objects live on the shared heap; class data lives in metaspace.
- An object becomes garbage when it is unreachable from GC roots such as stack variables and static fields.
- Generational collectors make short-lived objects cheap; G1 is the usual default, ZGC targets very short pauses.
- A Java memory leak is reachable but useless data, often in a static collection.
-Xmx,MaxRAMPercentage,-Xlog:gcandHeapDumpOnOutOfMemoryErrorare the options to know.- Use
jcmd, JFR and heap dumps to investigate; measure before tuning.
Exercise
Fix the Leak program. Replace the ever-growing list with a cache that keeps at most 10 reports and drops the least recently used one. Use a LinkedHashMap created with access order (new LinkedHashMap<>(16, 0.75f, true)) and override removeEldestEntry. Generate reports 1 to 1000 with -Xmx64m, then request report 995 again and a new report 2000, and print the cache size and the cached ids.
Show solution
import java.util.LinkedHashMap;
import java.util.Map;
public class ReportCache {
private static final int MAX_REPORTS = 10;
// access-order LinkedHashMap that drops the least recently used entry
private static final Map<Integer, byte[]> cache =
new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<Integer, byte[]> eldest) {
return size() > MAX_REPORTS;
}
};
static byte[] report(int id) {
return cache.computeIfAbsent(id, k -> new byte[1024 * 1024]);
}
public static void main(String[] args) {
for (int id = 1; id <= 1000; id++) {
report(id);
}
report(995); // recently used again
report(2000); // pushes out the oldest
System.out.println("Reports made: 1001, kept in cache: " + cache.size());
System.out.println("Cached ids: " + cache.keySet());
System.out.println("Max heap: " + Runtime.getRuntime().maxMemory() / (1024 * 1024) + " MB");
}
}Reports made: 1001, kept in cache: 10 Cached ids: [992, 993, 994, 996, 997, 998, 999, 1000, 995, 2000] Max heap: 64 MB
Over 1 GB of reports were created in a 64 MB heap, because only the 10 newest stay reachable. Using report 995 moved it to the end, so 991 (the least recently used) was the one dropped when 2000 arrived. In production, libraries such as Caffeine provide caches with size limits and expiry.
Quick Quiz
1. Where is an object created with new stored?
Objects and arrays live on the shared heap; the stack holds only the reference.
2. When can the garbage collector free an object?
Reachability from roots such as stack variables and static fields keeps an object alive.
3. A service's heap use climbs for hours and GC logs show collections freeing almost nothing. What is the most likely cause?
Short-lived garbage is cheap; memory that stays reachable forever is a leak.
4. What does -XX:MaxRAMPercentage=75 do?
It sizes the heap relative to the machine or container memory instead of a fixed -Xmx.
Learn Java with a mentor
Free lessons get you started. Our Java, C & C++ Programming training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
