EthicsComputerCourses
Join training +91 90532 10052

JVM Memory & Garbage Collection

Java · Lesson 30 of 44 Advanced 9 min read Updated

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:

AreaWhat lives thereShared?
Stack (one per thread)each method call's local variables, parameters and referencesno, private to its thread
Heapevery object and array created with new, including stringsyes, all threads
Metaspaceclass information: method code, field layouts, constantsyes

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.

Java
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.
    }
}
Output
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:

Java
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));
        }
    }
}
Output
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:

CollectorFlagBest for
G1 (the default on most machines)-XX:+UseG1GCgeneral server applications; balances pauses and throughput
ZGC-XX:+UseZGCvery large heaps and very short pauses (usually under a millisecond)
Parallel-XX:+UseParallelGCbatch jobs where total throughput matters more than pauses
Serial-XX:+UseSerialGCtiny heaps and single-CPU containers
Shenandoah-XX:+UseShenandoahGClow 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:

Java
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);
    }
}
Terminal
java -Xmx64m -Xlog:gc Churn

Part of one run on JDK 21 (timings and sizes differ on every machine and run):

Text
[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: 208688673

Read 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:

Java
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:

Output
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:

Terminal
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.jar

Without -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:

  • jcmd lists Java processes; jcmd <pid> GC.heap_info shows heap use and jcmd <pid> GC.heap_dump file.hprof writes a heap dump.
  • jcmd <pid> Thread.print prints 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 = null frees 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 -Xmx to 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:gc and HeapDumpOnOutOfMemoryError are 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
Java
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");
    }
}
Output
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?

2. When can the garbage collector free an object?

3. A service's heap use climbs for hours and GC logs show collections freeing almost nothing. What is the most likely cause?

4. What does -XX:MaxRAMPercentage=75 do?

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