Polyglot
Languages Java memory
Java § memory

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:

GCGenerational?Pause timesThroughputNotes
Serialyeslow (small heap)lowSingle-threaded; the default for very small machines
ParallelyeshighhighMultiple threads; the throughput-oriented default
G1 (Garbage-First)partialmoderatemoderateThe default in most modern JVMs
ZGCno (region-based)very lowmoderateSub-millisecond pauses, large heaps
Shenandoahnovery lowmoderateConcurrent compaction; similar to ZGC
Epsilonn/an/an/aA 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:

ReferenceClassBehaviour
Strong(the default)The object is reachable; the GC will not reclaim it
SoftSoftReference<T>The GC reclaims when memory is tight; otherwise keeps
WeakWeakReference<T>The GC reclaims promptly when only weak references remain
PhantomPhantomReference<T>Cannot be dereferenced; admits notification at finalisation

The principal uses:

  • SoftReference for memory-sensitive caches: keep entries while memory is plentiful, evict as memory becomes scarce.
  • WeakReference for canonicalising maps, breaking GC cycles between caches and their entries (the standard library’s WeakHashMap is built on this).
  • PhantomReference for 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.

DefectDescription
Allocating in a hot loopString s = a + b + c in a loop allocates per iteration. Use StringBuilder.
Boxing in collectionsList<Integer> boxes every element. For numeric workloads, use primitive arrays or specialised libraries (Eclipse Collections, fastutil).
Closures capturing localsA lambda capturing a local creates a closure object; the local is stored on the heap. Hot paths should minimise captures.
Inadvertent retention through static fieldsA static field holds a reference forever; storing per-request data in a static is a memory leak.
Map with custom keys without hashCodeEqual keys with unequal hashes produce a leak: entries pile up but are unfindable. Always override hashCode when overriding equals.
Forgetting try-with-resourcesStreams, sockets, file handles — all leak resources without explicit close.
Listener leaksAn 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.