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
OutOfMemoryErrorin 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
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
| Area | Holds | Shared? | Typical failure |
|---|---|---|---|
| Thread stack | One frame per method call: local variables, primitives and references | One per thread | StackOverflowError from runaway recursion |
| Heap | Every object and array created with new | All threads | OutOfMemoryError: Java heap space |
| Metaspace | Class definitions and method metadata, in native memory | All threads | OutOfMemoryError: Metaspace (often class-loader leaks) |
| Code cache | Machine code produced by the JIT compiler | All threads | JIT 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.
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:
javac GcChurn.java
java -Xlog:gc -Xmx64m GcChurnOutput
[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.
| Flag | Meaning | Note |
|---|---|---|
-Xmx512m | Maximum heap size | Set it explicitly in production; leave room for native memory |
-Xms512m | Initial heap size | Equal to -Xmx avoids resizing pauses |
-Xlog:gc | Log each collection | First tool for "is GC the problem?" |
-XX:+UseZGC | Choose the ZGC collector | Try only when pause times are a measured problem |
-XX:+HeapDumpOnOutOfMemoryError | Write a heap dump when the heap fills | Open 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.
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.
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.
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:
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 failedCommon 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 intoString. Convert to a loop or increase-Xssonly 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 17givespatterns in switch statements are not supported in -source 17(a plain JDK 17 describes it as a preview feature instead). Checkjava -versionand your build's--releasesetting.
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
-Xmxdeliberately, leave headroom for native memory, and use-Xlog:gcand a heap dump before touching collector options. - Records give you immutable data carriers with generated
equals,hashCodeandtoString; 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.
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.