Skip to main content
PProfileMatch

Java Heap, Garbage Collection, Records and Sealed Classes Explained

ProgrammingLesson 4 of 48 min read

What you'll learn

  • Say where a local variable, an object and a class definition live in JVM memory
  • Explain reachability and why a static collection can cause an OutOfMemoryError in a garbage-collected language
  • Read a basic GC log line and know which heap flags to reach for first
  • Replace boilerplate classes with records and model a closed set of cases with sealed types and pattern-matching switch

On this page

  1. Where things live: stack, heap and metaspace
  2. How the collector decides what is garbage
  3. A leak in a language with a garbage collector
  4. Records and sealed types: say what you mean
  5. Common mistakes
  6. How this shows up in real projects and interviews

A service runs happily for three days, then dies with java.lang.OutOfMemoryError: Java heap space. The team insists Java has garbage collection, so memory cannot leak. Both statements are true, and the reason lies in a precise definition of what the collector is allowed to free. This lesson covers that definition, then moves to the other half of modern Java: records and sealed types, which remove the boilerplate that made older code long.

Where things live: stack, heap and metaspace

AreaHoldsShared?Typical failure
Thread stackOne frame per method call: local variables, primitives and referencesOne per threadStackOverflowError from runaway recursion
HeapEvery object and array created with newAll threadsOutOfMemoryError: Java heap space
MetaspaceClass definitions and method metadata, in native memoryAll threadsOutOfMemoryError: Metaspace (often class-loader leaks)
Code cacheMachine code produced by the JIT compilerAll threadsJIT stops compiling; the app slows down

The line Ticket t = new Ticket(); puts a reference in the current stack frame and the Ticket object on the heap. When the method returns, the frame vanishes and the reference with it; the object is now a candidate for collection if nothing else points to it. Primitives such as int inside an object live inside it on the heap, and the same int as a local variable lives in the frame. Objects are never copied when passed to a method, only the reference is, which is why a method can mutate the caller's list.

DeepCall.java
public class DeepCall {
    static int depth = 0;

    static void descend() {
        depth++;
        descend();                 // no base case: each call adds a stack frame
    }

    public static void main(String[] args) {
        try {
            descend();
        } catch (StackOverflowError e) {
            System.out.println("StackOverflowError after many frames: " + (depth > 1000));
        }
    }
}

Output

StackOverflowError after many frames: true

How the collector decides what is garbage

The garbage collector does not count references. It starts from GC roots: local variables on every thread's stack, static fields, and a few JVM internals. Everything reachable by following references from a root is live, and everything else is garbage that can be reclaimed. So "reachable but never used again" is invisible to it. That is the definition of a Java memory leak.

Most collectors also exploit the generational hypothesis: the great majority of objects die young. New objects are allocated in a young region that is collected often and cheaply; objects that survive several collections are promoted to an old region that is collected less often. G1, the default collector since Java 9, divides the heap into regions and aims to keep pauses within a target. ZGC targets very short pauses on large heaps. You can watch the young collections happen with the unified logging flag:

Terminal
javac GcChurn.java
java -Xlog:gc -Xmx64m GcChurn

Output

[0.009s][info][gc] Using G1
[0.050s][info][gc] GC(0) Pause Young (Normal) (G1 Evacuation Pause) 23M->0M(64M) 1.128ms
[0.056s][info][gc] GC(1) Pause Young (Normal) (G1 Evacuation Pause) 35M->0M(64M) 0.883ms
[0.061s][info][gc] GC(2) Pause Young (Normal) (G1 Evacuation Pause) 37M->0M(64M) 0.890ms
allocated about 200 MB, checksum 200000

This output comes from one run on a Java 21 JVM using G1; your timings and sizes will differ, and the first line names whichever collector your JVM picked. Read 37M->0M(64M) as: before the pause, 37 MB of the heap was in use; after, almost nothing; the heap's current capacity is 64 MB. Every object in the loop was dead by the next iteration, so each young collection reclaimed nearly everything. The flags you will actually use are few.

FlagMeaningNote
-Xmx512mMaximum heap sizeSet it explicitly in production; leave room for native memory
-Xms512mInitial heap sizeEqual to -Xmx avoids resizing pauses
-Xlog:gcLog each collectionFirst tool for "is GC the problem?"
-XX:+UseZGCChoose the ZGC collectorTry only when pause times are a measured problem
-XX:+HeapDumpOnOutOfMemoryErrorWrite a heap dump when the heap fillsOpen the file in a profiler to see what retains memory

A leak in a language with a garbage collector

The cache below is the three-day bug in miniature. It lives in a static field, which is a GC root, so everything added to it is reachable forever. It is run with a deliberately small heap so the failure arrives in a second.

LeakyCache.java (run with java -Xmx32m LeakyCache.java)
import java.util.ArrayList;
import java.util.List;

public class LeakyCache {
    // A static field is a GC root: everything it points to stays reachable forever
    private static final List<byte[]> CACHE = new ArrayList<>();

    public static void main(String[] args) {
        try {
            while (true) {
                CACHE.add(new byte[1024 * 1024]);       // 1 MB per entry, never evicted
            }
        } catch (OutOfMemoryError e) {
            int held = CACHE.size();
            CACHE.clear();                              // entries become unreachable
            System.out.println("caught: " + e.getMessage());
            System.out.println("cached at least 10 MB before failing: " + (held >= 10));
            byte[] fresh = new byte[1024 * 1024];       // memory is reclaimable again
            System.out.println("allocation after clear works: " + (fresh.length == 1024 * 1024));
        }
    }
}

Output

caught: Java heap space
cached at least 10 MB before failing: true
allocation after clear works: true

After CACHE.clear() the byte arrays become unreachable and the next allocation succeeds, which proves the memory was never lost, only retained. Catching OutOfMemoryError here is purely for the demonstration; in real code you let the process die, restart it and fix the retention. Typical culprits are unbounded static maps used as caches, listeners that are registered and never removed, ThreadLocal values in long-lived pool threads, and queues that grow faster than consumers drain them. A bounded cache with an eviction policy is almost always the fix.

Records and sealed types: say what you mean

A record declares an immutable data carrier in one line. The compiler generates the private final fields, the constructor, accessors named like the components (id(), not getId()), and equals, hashCode and toString. A compact constructor runs your validation before the fields are assigned. Records are only shallowly immutable, so a List component needs a defensive copy, which the example does with List.copyOf. A sealed interface lists exactly which types may implement it with permits; each permitted type must be final (records are implicitly final), sealed or non-sealed. This code needs Java 17 and compiles with --release 17.

Records17.java
import java.util.List;

public class Records17 {
    sealed interface TicketEvent permits Opened, Assigned, Closed {}

    record Opened(String id, String subject, List<String> tags) implements TicketEvent {
        Opened {                                        // compact constructor: validate and normalise
            if (subject.isBlank()) {
                throw new IllegalArgumentException("subject required");
            }
            tags = List.copyOf(tags);                   // defensive copy keeps the record immutable
        }
    }

    record Assigned(String id, String agent) implements TicketEvent {}

    record Closed(String id, int minutesOpen) implements TicketEvent {}

    static String describe(TicketEvent event) {
        if (event instanceof Opened o) {                // pattern matching for instanceof
            return "opened " + o.id() + ": " + o.subject();
        } else if (event instanceof Assigned a) {
            return a.agent() + " took " + a.id();
        } else if (event instanceof Closed c) {
            return c.id() + " closed after " + c.minutesOpen() + " min";
        }
        throw new IllegalStateException("unreachable if the hierarchy is sealed");
    }

    public static void main(String[] args) {
        var tags = new java.util.ArrayList<>(List.of("billing"));
        var opened = new Opened("T-7", "Refund missing", tags);
        tags.add("urgent");                              // does not leak into the record

        System.out.println(opened);
        System.out.println(opened.equals(new Opened("T-7", "Refund missing", List.of("billing"))));
        System.out.println(describe(new Assigned("T-7", "Meera")));
        System.out.println(describe(new Closed("T-7", 95)));

        String summary = """
            Ticket: %s
              tags: %s
            """.formatted(opened.id(), opened.tags());
        System.out.print(summary);

        try {
            new Opened("T-8", "  ", List.of());
        } catch (IllegalArgumentException e) {
            System.out.println("rejected: " + e.getMessage());
        }
    }
}

Output

Opened[id=T-7, subject=Refund missing, tags=[billing]]
true
Meera took T-7
T-7 closed after 95 min
Ticket: T-7
  tags: [billing]
rejected: subject required

Because the hierarchy is closed, a reader and the compiler both know that Opened, Assigned and Closed are the only cases. With Java 21, switch can use that knowledge. Pattern matching for switch and record patterns are final in 21, so the next snippet needs a 21 or newer JDK. Each case tests the type, binds a variable, can take apart a record in place, and can add a when guard.

PatternSwitch.java
public class PatternSwitch {
    sealed interface TicketEvent permits Opened, Assigned, Closed {}
    record Opened(String id, String subject) implements TicketEvent {}
    record Assigned(String id, String agent) implements TicketEvent {}
    record Closed(String id, int minutesOpen) implements TicketEvent {}

    // Java 21 or newer: pattern matching for switch and record patterns
    static String describe(TicketEvent event) {
        return switch (event) {
            case Opened o -> "opened " + o.id() + ": " + o.subject();
            case Assigned(String id, String agent) -> agent + " took " + id;
            case Closed c when c.minutesOpen() > 60 -> c.id() + " closed late (" + c.minutesOpen() + " min)";
            case Closed c -> c.id() + " closed on time";
        };                                              // no default: the compiler checks coverage
    }

    public static void main(String[] args) {
        System.out.println(describe(new Opened("T-7", "Refund missing")));
        System.out.println(describe(new Assigned("T-7", "Meera")));
        System.out.println(describe(new Closed("T-7", 95)));
        System.out.println(describe(new Closed("T-9", 20)));
    }
}

Output

opened T-7: Refund missing
Meera took T-7
T-7 closed late (95 min)
T-9 closed on time

There is no default branch, and that is the point. Add a fourth permitted record later and every switch like this one stops compiling until it handles the new case. Here is what the compiler says when a case is missing:

PatternSwitchGap.java (does not compile)
public class PatternSwitchGap {
    sealed interface TicketEvent permits Opened, Assigned, Closed {}
    record Opened(String id) implements TicketEvent {}
    record Assigned(String id, String agent) implements TicketEvent {}
    record Closed(String id) implements TicketEvent {}

    static String describe(TicketEvent event) {
        return switch (event) {
            case Opened o -> "opened";
            case Assigned a -> "assigned";
        };
    }

    public static void main(String[] args) {
        System.out.println(describe(new Closed("T-1")));
    }
}

Output

PatternSwitchGap.java:8: error: the switch expression does not cover all possible input values
        return switch (event) {
               ^
1 error
error: compilation failed

Common mistakes

  • Setting `-Xmx` to the whole machine or container limit. The JVM also needs native memory for stacks, metaspace and buffers, so the process grows past the limit and the container is killed (often exit code 137) with no Java exception at all.
  • An unbounded static `Map` used as a cache. The heap climbs steadily and ends in OutOfMemoryError: Java heap space. Add a size limit and eviction.
  • Catching `OutOfMemoryError` and carrying on. State may be half-updated and other threads are starving. Log, exit and restart.
  • Infinite or very deep recursion. StackOverflowError, usually from a missing base case or a cyclic object graph in toString. Convert to a loop or increase -Xss only if the depth is legitimate.
  • Mutable component in a record. new Opened(id, subject, tags) keeps a live reference to the caller's list unless the constructor copies it, so the record's value changes behind your back.
  • A `default` branch on a sealed switch. It silences the compiler's coverage check, so a new case falls into the default silently.
  • Using a Java 21 feature on a Java 17 build. Compiling with --release 17 gives patterns in switch statements are not supported in -source 17 (a plain JDK 17 describes it as a preview feature instead). Check java -version and your build's --release setting.

How this shows up in real projects and interviews

Memory problems at work rarely look like a textbook. You get an alert that a pod was restarted, a heap dump from -XX:+HeapDumpOnOutOfMemoryError, and a profiler view showing what retains the most memory. The skill is reading that retention path and finding the static map, the unbounded queue or the forgotten listener. Tuning collectors comes much later, and usually after a measurement shows pauses matter. Records and sealed types, in contrast, you will use every week: DTOs, events, results such as Success versus Failure, and parsed commands.

In interviews, expect "what is a memory leak in Java", "stack versus heap", and "what makes an object eligible for GC", and answer with reachability from roots rather than with reference counting. For modern syntax, interviewers like to ask what a record generates and what sealed adds. To revisit how threads interact with this memory, see Java concurrency; to see sealed hierarchies as a design tool, see Design Patterns.

Try it yourself

Turn LeakyCache into a bounded cache: store entries in a LinkedHashMap subclass that overrides removeEldestEntry to cap the size at 8 entries of 1 MB each. Run it with -Xmx32m, insert 100 entries, and confirm that no OutOfMemoryError occurs and that only the 8 most recent keys remain.

Show a hint

Construct the map with new LinkedHashMap<>(16, 0.75f, true) { protected boolean removeEldestEntry(Map.Entry<Integer, byte[]> e) { return size() > 8; } }. The true argument switches it to access order, which turns it into a least-recently-used cache.

Key takeaways

  • Locals and references live in a thread's stack frame, objects and arrays on the shared heap, and class metadata in metaspace.
  • The garbage collector frees what is unreachable from GC roots; a leak is memory that is still reachable but no longer needed.
  • Set -Xmx deliberately, leave headroom for native memory, and use -Xlog:gc and a heap dump before touching collector options.
  • Records give you immutable data carriers with generated equals, hashCode and toString; copy mutable components in the compact constructor.
  • Sealed interfaces plus pattern-matching switch (Java 21) give compiler-checked, exhaustive handling of every case.

Test yourself

A trick from this lesson's ride: The stack holds the leash, the heap holds the dog. Four more tricks like it wait along the route, each followed by a question.

Ride this lesson

Frequently asked questions

What is the difference between stack and heap memory in Java?

The stack is per-thread memory holding method frames with local variables and references, and it is freed automatically when a method returns. The heap is shared memory where all objects and arrays live, and it is reclaimed by the garbage collector when objects become unreachable.

Can a Java application have a memory leak if it has garbage collection?

Yes. The collector only frees objects that are unreachable from GC roots such as static fields and thread stacks. If code keeps references to objects it never uses again, for example in an unbounded static map or a forgotten listener, those objects stay reachable and the heap eventually fills.

What is the difference between a record and a class in Java?

A record is a restricted class for immutable data: the compiler generates private final fields, a canonical constructor, accessors, equals, hashCode and toString from the component list. Records cannot extend another class or declare extra instance fields, but they can implement interfaces and add methods.