Memory and the JVM
Java memory management is governed by the garbage collector of the JVM. Most of the time the GC is invisible; programs allocate freely and the runtime reclaims unreachable memory without explicit programmer intervention. The cases where the model becomes visible — the try-with-resources pattern for deterministic cleanup of unmanaged resources, the generational structure of the GC, the trade-offs of allocation-heavy code, and the modern memory-mapped and off-heap APIs — are the substance of this page. Java’s memory story is closer to C#‘s than to C/C++‘s: a managed runtime, no delete, finalisers strongly discouraged, and a substantial set of GC implementations to choose from.
The discipline of writing memory-correct Java is therefore mostly the discipline of disposing properly (closing streams, releasing locks) and avoiding unnecessary allocation; the explicit-free model of C is replaced by reachability analysis at the JVM level.
The JVM memory model
The JVM maintains:
- The heap — where all reference-typed objects are allocated. Per-process; shared across threads.
- The stack — per-thread; holds value-type locals, parameters, and references to heap objects.
- The metaspace — per-process; holds class metadata (since Java 8; replaced the older PermGen).
- The native memory — outside the JVM heap; used by JNI, NIO direct buffers, and (since Java 14)
MemorySegment.
Allocation on the heap is fast: the GC maintains a bump pointer into the next-generation region, and allocation is typically a single increment. Deallocation is amortised across collection cycles; the program pays for it only when collection runs.
The garbage collector
The HotSpot JVM provides several GC implementations, each with different trade-offs:
| GC | Generational? | Pause times | Throughput | Notes |
|---|---|---|---|---|
| Serial | yes | low (small heap) | low | Single-threaded; the default for very small machines |
| Parallel | yes | high | high | Multiple threads; the throughput-oriented default |
| G1 (Garbage-First) | partial | moderate | moderate | The default in most modern JVMs |
| ZGC | no (region-based) | very low | moderate | Sub-millisecond pauses, large heaps |
| Shenandoah | no | very low | moderate | Concurrent compaction; similar to ZGC |
| Epsilon | n/a | n/a | n/a | A no-op GC; for benchmarking and short-lived programs |
The default since Java 9 is G1; ZGC has matured significantly in recent revisions and is the conventional choice for latency-sensitive workloads.
The GC may be selected at JVM startup:
java -XX:+UseG1GC -Xmx4g MyApp # G1, 4 GB heap
java -XX:+UseZGC -Xmx16g MyApp # ZGC, 16 GB heap
java -XX:+UseParallelGC MyApp # Parallel
For most applications the default suffices; tuning becomes meaningful for high-throughput servers or low-latency services.
The generational hypothesis
The traditional generational GCs (Parallel, G1) divide the heap into generations:
- Young generation — newly-allocated objects. Most allocations are short-lived; collection is frequent and cheap.
- Old generation — objects that have survived several young-gen collections. Collected less frequently.
The hypothesis: most objects die young. The structure exploits this by collecting the young gen frequently and the old gen rarely.
The non-generational GCs (ZGC, Shenandoah) work region-by-region without explicit generations; they trade some throughput for substantially better worst-case pause times.
The conventional advice:
- Most programs need not configure the GC explicitly.
- Server applications benefit from explicit
-Xmx(max heap) and the appropriate GC. - Allocation-heavy code benefits from object pools, primitive arrays, and
Span/MemorySegment-style alternatives.
try-with-resources and AutoCloseable
The GC reclaims managed memory automatically; it does not automatically release unmanaged resources — file handles, sockets, locks, native memory. Types that hold unmanaged resources implement AutoCloseable:
public class FileHolder implements AutoCloseable {
private final RandomAccessFile file;
public FileHolder(String path) throws IOException {
this.file = new RandomAccessFile(path, "r");
}
@Override
public void close() throws IOException {
file.close();
}
}
The try-with-resources statement (since Java 7) guarantees close() is called at the end of the try block:
try (FileHolder f = new FileHolder("data.txt")) {
// use f
} // f.close() called here, even on exception
Equivalent to the explicit:
FileHolder f = new FileHolder("data.txt");
try {
// use f
} finally {
if (f != null) f.close();
}
Multiple resources may be acquired together; they are closed in reverse order of acquisition:
try (InputStream in = new FileInputStream(in_path);
OutputStream out = new FileOutputStream(out_path)) {
in.transferTo(out);
}
Java 9 admitted the use of effectively-final variables in the resource-spec position:
final FileHolder f = new FileHolder("data.txt");
try (f) { // since Java 9
// use f
}
The pattern is occasionally cleaner when the resource is constructed elsewhere.
The standard library implements AutoCloseable on most resource-holding types: InputStream, OutputStream, Reader, Writer, Connection (JDBC), Channel (NIO), Lock (concurrent), Stream (streams API), and many others.
Finalizers and Cleaner
Java has historically supported finalizers — a method protected void finalize() throws Throwable that the GC calls before reclaiming an object. The mechanism is deeply problematic:
- Non-deterministic timing — finalizers may run any time after the object becomes unreachable, or never (for short-lived programs).
- Resource leaks — an object with a finalizer holds resources longer than necessary.
- Two collection cycles — an object with a finalizer survives the first collection (so the finalizer can run); only on the next cycle is the memory reclaimed.
- Single-threaded — all finalizers run on a dedicated thread; a slow finalizer blocks all others.
- Exception suppression — an exception in a finalizer is silently swallowed.
Object.finalize() was deprecated in Java 9 and removed in Java 23. Modern code uses java.lang.ref.Cleaner (Java 9) when finalisation-style behaviour is genuinely needed:
public class ResourceHolder implements AutoCloseable {
private static final Cleaner cleaner = Cleaner.create();
private final Cleaner.Cleanable cleanable;
private final State state;
private static class State implements Runnable {
private long handle;
State(long handle) { this.handle = handle; }
@Override
public void run() {
if (handle != 0) {
NativeApi.free(handle);
handle = 0;
}
}
}
public ResourceHolder() {
this.state = new State(NativeApi.allocate());
this.cleanable = cleaner.register(this, state);
}
@Override
public void close() {
cleanable.clean();
}
}
The Cleaner mechanism runs cleanup actions on the GC’s schedule, but the conventional discipline is to also implement AutoCloseable and rely on try-with-resources for explicit cleanup. The Cleaner is the safety net for the case where the user forgets close().
The conventional discipline: implement AutoCloseable for any resource-holding type; use try-with-resources everywhere; reach for Cleaner only when the resource is genuinely external (native memory, file handles in low-level APIs).
Reference types: strong, weak, soft, phantom
Java admits four reference strengths through java.lang.ref:
| Reference | Class | Behaviour |
|---|---|---|
| Strong | (the default) | The object is reachable; the GC will not reclaim it |
| Soft | SoftReference<T> | The GC reclaims when memory is tight; otherwise keeps |
| Weak | WeakReference<T> | The GC reclaims promptly when only weak references remain |
| Phantom | PhantomReference<T> | Cannot be dereferenced; admits notification at finalisation |
The principal uses:
SoftReferencefor memory-sensitive caches: keep entries while memory is plentiful, evict as memory becomes scarce.WeakReferencefor canonicalising maps, breaking GC cycles between caches and their entries (the standard library’sWeakHashMapis built on this).PhantomReferencefor resource-cleanup implementations that need to observe the GC reclaiming an object.
import java.lang.ref.WeakReference;
WeakReference<Image> cached = new WeakReference<>(loadImage(path));
Image image = cached.get();
if (image == null) {
image = loadImage(path);
cached = new WeakReference<>(image);
}
The mechanism is rare in application code; the conventional uses are in libraries that need to integrate carefully with the GC.
Off-heap memory: NIO direct buffers and MemorySegment
For very-large data and zero-copy I/O, Java admits off-heap memory — memory allocated outside the GC’s heap.
NIO direct buffers
ByteBuffer.allocateDirect(size) allocates a buffer in native memory:
ByteBuffer buf = ByteBuffer.allocateDirect(1024 * 1024);
// the buffer is in native memory, not the Java heap
The buffer’s contents are not subject to GC moves and may be passed directly to native I/O routines. The trade-off is the cost of allocation (slower than on-heap allocation) and the fact that the memory is reclaimed only when the buffer is itself GC-collected (or explicitly through internal cleaners).
Memory-mapped files
FileChannel.map(...) returns a MappedByteBuffer that maps a file into memory:
try (RandomAccessFile file = new RandomAccessFile(path, "r");
FileChannel channel = file.getChannel()) {
MappedByteBuffer buf = channel.map(FileChannel.MapMode.READ_ONLY, 0, channel.size());
// read from buf as if it were a byte array
}
The OS handles paging; the JVM treats the buffer as ordinary memory. The mechanism is the conventional choice for large-file access.
MemorySegment (Java 14+, stabilised in Java 22)
The Foreign Function and Memory API (Java 22) provides MemorySegment and Arena for explicit native-memory management:
import java.lang.foreign.*;
try (Arena arena = Arena.ofConfined()) {
MemorySegment segment = arena.allocate(1024);
// use segment as a managed view into 1024 bytes of native memory
} // arena is closed; segment is freed
The mechanism is the modern replacement for sun.misc.Unsafe and a substantial part of the foreign-memory story. It is principally useful for native-code interop and for off-heap data structures in performance-critical code.
The classpath, modules, and class loading
Memory in the JVM is also affected by class loading: classes are loaded on demand, with their metadata stored in the metaspace. Long-running applications that load many classes (typically through reflection or dynamic class generation) may exhaust the metaspace; the conventional defence is -XX:MaxMetaspaceSize.
The module system (Java 9+) admits stronger encapsulation, which has indirect memory benefits — fewer reachable classes mean less metadata in the metaspace.
Common allocation-related defects
| Defect | Description |
|---|---|
| Allocating in a hot loop | String s = a + b + c in a loop allocates per iteration. Use StringBuilder. |
| Boxing in collections | List<Integer> boxes every element. For numeric workloads, use primitive arrays or specialised libraries (Eclipse Collections, fastutil). |
| Closures capturing locals | A lambda capturing a local creates a closure object; the local is stored on the heap. Hot paths should minimise captures. |
| Inadvertent retention through static fields | A static field holds a reference forever; storing per-request data in a static is a memory leak. |
Map with custom keys without hashCode | Equal keys with unequal hashes produce a leak: entries pile up but are unfindable. Always override hashCode when overriding equals. |
Forgetting try-with-resources | Streams, sockets, file handles — all leak resources without explicit close. |
| Listener leaks | An object subscribed to a long-lived publisher is kept alive by the publisher’s reference. Use WeakReference or unsubscribe explicitly. |
The combination of try-with-resources, AutoCloseable, immutable collections, the java.util.concurrent collections (which avoid the synchronized-collection performance traps), and tooling (VisualVM, JFR for memory profiling) covers most of the practical memory and resource concerns in Java. The cases that remain — fine-grained allocation control, off-heap data structures, native interop — are reduced by careful API design and by libraries that handle the low-level details.