Guide contents

Lesson 40 of 71

Stack, Heap, Metaspace, and Code Cache

A Java process uses much more memory than its heap. Thread stacks, class metadata, compiled code, direct buffers, and the JVM's own structures all live outside -Xmx. This lesson names each area, shows how it fails, and shows how to measure all of them, which is the starting point for diagnosing a container that is killed for using too much memory.

Area Holds Sized by Failure
Thread stack One frame per active method call -Xss / -XX:ThreadStackSize StackOverflowError
Heap Objects and arrays -Xms, -Xmx, -XX:MaxRAMPercentage OutOfMemoryError: Java heap space
Metaspace Class metadata -XX:MaxMetaspaceSize (unlimited by default) OutOfMemoryError: Metaspace
Compressed class space Class structures addressed by compressed class pointers -XX:CompressedClassSpaceSize (1 GB default) OutOfMemoryError: Compressed class space
Code cache JIT-compiled machine code -XX:ReservedCodeCacheSize Warning, JIT stops compiling
Direct memory ByteBuffer.allocateDirect buffers -XX:MaxDirectMemorySize OutOfMemoryError with "direct buffer memory"

Examples run on JDK 25 on Linux x64 with G1, the default collector. Examples that depend on a memory limit set it on the command line (shown in the lead-in), so their output is fixed. The output of Examples 7 and 8 depends on the machine, so it is shown as a representative excerpt.

Example 1: frames and StackOverflowError

Each method call pushes a frame onto the calling thread's stack. A frame holds the local variable slots, the operand stack, and a return address (see Bytecode and Class Loading). Deep recursion exhausts the stack and throws StackOverflowError, an Error that can be caught because the JVM reserves guard pages to run the handler.

Java
public class Main {
    static int depth;

    static void recurse() {
        depth++;
        recurse();
    }

    public static void main(String[] args) {
        try {
            recurse();
        } catch (StackOverflowError e) {
            System.out.println("caught StackOverflowError");
            System.out.println("depth above 1000: " + (depth > 1000));
        }
    }
}
Output
caught StackOverflowError
depth above 1000: true

The exact depth depends on frame size, which depends on whether the method runs interpreted or JIT-compiled, so it changes between runs and must not be printed. The default stack size on Linux x64 is 1 MB (-Xss1m). Each platform thread reserves that much virtual memory; the operating system commits pages only as the stack grows.

Example 2: stack size per thread

The Thread constructor with a stackSize argument requests a stack size for one thread. The Javadoc calls it a hint that some platforms ignore; HotSpot on Linux honors it. This lets one deep-recursion task run on a larger stack without raising -Xss for every thread.

Java
public class Main {
    static int measure(long stackSize) throws InterruptedException {
        int[] depth = new int[1];
        Runnable task = new Runnable() {
            void recurse(int d) {
                depth[0] = d;
                recurse(d + 1);
            }
            public void run() {
                try { recurse(1); } catch (StackOverflowError ignored) { }
            }
        };
        Thread t = new Thread(null, task, "probe", stackSize);
        t.start();
        t.join();
        return depth[0];
    }

    public static void main(String[] args) throws InterruptedException {
        int small = measure(256 * 1024);
        int large = measure(16 * 1024 * 1024);
        System.out.println("16 MB stack goes deeper than 256 KB: " + (large > small));
    }
}
Output
16 MB stack goes deeper than 256 KB: true

Virtual threads (Virtual Threads in Depth) do not have a fixed native stack. When a virtual thread unmounts, its frames are copied to heap objects, so a million virtual threads cost heap, not a million reserved stacks.

Example 3: heap exhaustion and array limits

The heap holds every object. When the collector cannot free enough space for an allocation, the JVM throws OutOfMemoryError: Java heap space. A second kind comes from arrays larger than the VM supports, regardless of heap size. Run with -Xmx16m.

Java
import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<byte[]> hold = new ArrayList<>();
        try {
            while (true) {
                hold.add(new byte[1024 * 1024]);
            }
        } catch (OutOfMemoryError e) {
            int held = hold.size();
            hold.clear();                     // free memory before doing anything else
            System.out.println("message: " + e.getMessage());
            System.out.println("held fewer than 16 buffers: " + (held < 16));
        }

        try {
            int[] huge = new int[Integer.MAX_VALUE];
            System.out.println(huge.length);
        } catch (OutOfMemoryError e) {
            System.out.println("message: " + e.getMessage());
        }
    }
}
Output
message: Java heap space
held fewer than 16 buffers: true
message: Requested array size exceeds VM limit

Catching OutOfMemoryError is safe only in a demo like this, where the program owns all the garbage. In a server the error can hit any thread in the middle of any operation, so production systems use -XX:+HeapDumpOnOutOfMemoryError and -XX:+ExitOnOutOfMemoryError (or -XX:+CrashOnOutOfMemoryError) and let a supervisor restart the process. Another heap message, GC overhead limit exceeded, is thrown when the collector in use supports -XX:+UseGCOverheadLimit (on by default) and the JVM spends almost all its time collecting while recovering little heap. The Parallel collector has long supported it; check the release notes of your JDK for other collectors.

Example 4: the memory pools

The java.lang.management API lists memory pools with their type: heap or non-heap. The names depend on the collector and on code cache segmentation.

Java
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
import java.util.Comparator;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<MemoryPoolMXBean> pools = ManagementFactory.getMemoryPoolMXBeans().stream()
                .sorted(Comparator.comparing((MemoryPoolMXBean p) -> p.getType().toString())
                        .thenComparing(MemoryPoolMXBean::getName))
                .toList();
        for (MemoryPoolMXBean pool : pools) {
            System.out.println(pool.getType() + ": " + pool.getName());
        }
    }
}
Output
Heap memory: G1 Eden Space
Heap memory: G1 Old Gen
Heap memory: G1 Survivor Space
Non-heap memory: CodeHeap 'non-nmethods'
Non-heap memory: CodeHeap 'non-profiled nmethods'
Non-heap memory: CodeHeap 'profiled nmethods'
Non-heap memory: Compressed Class Space
Non-heap memory: Metaspace

The code cache holds machine code from the JIT compilers. With tiered compilation it is split into three segments: JVM-internal code (non-nmethods), code with profiling counters (profiled), and fully optimized code (non-profiled). -XX:ReservedCodeCacheSize defaults to 240 MB with tiered compilation. When it fills, the JVM prints CodeCache is full. Compiler has been disabled. and the application keeps running in slower code, which shows as a gradual latency rise rather than an error. With -XX:+UseParallelGC the heap pools are named PS Eden Space, PS Survivor Space, and PS Old Gen.

Example 5: metaspace and class metadata

Since Java 8, class metadata lives in metaspace, native memory outside the heap that replaced the permanent generation. It grows as classes load and shrinks only when a class loader becomes unreachable and is collected with all its classes. Metaspace has no limit by default, so a leak of class loaders (a redeploy that keeps the old loader alive, or a library that generates proxies without caching them) grows the process until the operating system or container stops it. -XX:MaxMetaspaceSize=256m turns that into OutOfMemoryError: Metaspace.

Java
import java.lang.management.ClassLoadingMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
import java.lang.reflect.Proxy;

public class Main {
    public interface Handler { String handle(); }

    public static void main(String[] args) {
        ClassLoadingMXBean classes = ManagementFactory.getClassLoadingMXBean();
        int before = classes.getLoadedClassCount();

        for (int i = 0; i < 50; i++) {
            ClassLoader fresh = new ClassLoader(Main.class.getClassLoader()) { };
            Handler h = (Handler) Proxy.newProxyInstance(
                    fresh, new Class<?>[] { Handler.class }, (p, m, a) -> "ok");
            h.handle();
        }

        int after = classes.getLoadedClassCount();
        System.out.println("at least 50 more classes: " + (after - before >= 50));
        for (MemoryPoolMXBean pool : ManagementFactory.getMemoryPoolMXBeans()) {
            if (pool.getName().equals("Metaspace")) {
                System.out.println("metaspace in use: " + (pool.getUsage().getUsed() > 0));
            }
        }
    }
}
Output
at least 50 more classes: true
metaspace in use: true

Each new loader gets its own proxy class, because a proxy class is cached per loader. The interface is public because a proxy for a non-public interface must be defined by the loader of that interface. Repeating this without letting the loaders die is the shape of a metaspace leak. jcmd <pid> VM.metaspace and jcmd <pid> GC.class_histogram show which loaders and classes hold the space.

Example 6: direct memory

ByteBuffer.allocateDirect allocates memory outside the heap, which NIO channels can pass to the operating system without copying. The JVM counts it against -XX:MaxDirectMemorySize, which defaults to the maximum heap size. The memory is freed only when the small ByteBuffer object on the heap is collected, so a heap with little garbage can hold a lot of direct memory. Run with -XX:MaxDirectMemorySize=8m (the checked run also sets -Xmx64m).

Java
import java.lang.management.BufferPoolMXBean;
import java.lang.management.ManagementFactory;
import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.List;

public class Main {
    public static void main(String[] args) {
        List<ByteBuffer> buffers = new ArrayList<>();
        try {
            while (true) {
                buffers.add(ByteBuffer.allocateDirect(1024 * 1024));
            }
        } catch (OutOfMemoryError e) {
            String message = e.getMessage();
            System.out.println("buffers allocated: " + buffers.size());
            System.out.println("message starts: " + message.substring(0, message.indexOf(" (")));
        }
        for (BufferPoolMXBean pool : ManagementFactory.getPlatformMXBeans(BufferPoolMXBean.class)) {
            if (pool.getName().equals("direct")) {
                System.out.println("direct pool count: " + pool.getCount() + ", bytes: " + pool.getMemoryUsed());
            }
        }
    }
}
Output
buffers allocated: 8
message starts: Cannot reserve 1048576 bytes of direct buffer memory
direct pool count: 8, bytes: 8388608

Before throwing, the JDK triggers a GC and retries, in case dead buffers are holding the limit. Older JDKs used the shorter message Direct buffer memory, so alerting should match on "direct buffer memory" case-insensitively. Netty and other network libraries pool direct buffers to avoid this allocation and release cost.

Example 7: native memory tracking

Native memory tracking (NMT) makes the JVM account for its own native allocations by category. Start the JVM with -XX:NativeMemoryTracking=summary (a few percent overhead) and query it with jcmd <pid> VM.native_memory summary. The same command can run inside the process through the DiagnosticCommand MBean.

Java
import java.lang.management.ManagementFactory;
import javax.management.ObjectName;

public class Main {
    public static void main(String[] args) throws Exception {
        ObjectName dcmd = new ObjectName("com.sun.management:type=DiagnosticCommand");
        String report = (String) ManagementFactory.getPlatformMBeanServer().invoke(
                dcmd, "vmNativeMemory",
                new Object[] { new String[] { "summary" } },
                new String[] { String[].class.getName() });
        report.lines()
              .filter(l -> l.startsWith("Total") || l.startsWith("-"))
              .forEach(System.out::println);
    }
}

Representative output with -XX:NativeMemoryTracking=summary -Xmx256m:

Text
Total: reserved=1716411KB, committed=303619KB
-                 Java Heap (reserved=262144KB, committed=221184KB)
-                     Class (reserved=1048689KB, committed=561KB)
-                    Thread (reserved=18490KB, committed=762KB)
-                      Code (reserved=248207KB, committed=8063KB)
-                        GC (reserved=46376KB, committed=45576KB)
-                  Compiler (reserved=5241KB, committed=5241KB)
-                    Symbol (reserved=1237KB, committed=1237KB)
-    Native Memory Tracking (reserved=268KB, committed=268KB)
-        Shared class space (reserved=16384KB, committed=13880KB, readonly=0KB)
-                 Metaspace (reserved=65549KB, committed=3021KB)

The excerpt is shortened; the real report lists more categories and the numbers differ on every machine. Reserved memory is address space; committed memory is backed by RAM and counts against a container limit. jcmd <pid> VM.native_memory baseline followed later by summary.diff shows which category grows. NMT does not see memory that native libraries allocate with malloc directly; that appears only in the process RSS.

Example 8: containers and MaxRAMPercentage

On Linux the JVM reads cgroup limits (container support has been on by default since JDK 10, -XX:+UseContainerSupport). It sizes the default heap from the container memory limit, not the host's RAM, and sizes thread pools and GC threads from the CPU limit. The default maximum heap is 25% of available memory (-XX:MaxRAMPercentage=25), which leaves most of a container unused. Services usually set it between 50 and 75, leaving room for the non-heap areas above.

Java
public class Main {
    public static void main(String[] args) {
        Runtime rt = Runtime.getRuntime();
        System.out.println("processors: " + rt.availableProcessors());
        System.out.println("max heap MB: " + rt.maxMemory() / (1024 * 1024));
    }
}

Run as docker run --memory=1g --cpus=2 ... java -XX:MaxRAMPercentage=75 Main:

Text
processors: 2
max heap MB: 768

The heap figure is approximate: the JVM rounds the heap to its alignment, and some collectors report slightly less than the configured maximum. java -XshowSettings:system -version prints the container limits the JVM detected. -XX:ActiveProcessorCount=<n> overrides the CPU count. Total container memory has to cover heap plus metaspace, code cache, thread stacks, direct buffers, GC structures, and native libraries; when it does not, the kernel OOM killer ends the process with no Java error at all.

Interview points

  • Areas: per-thread stacks; shared heap, metaspace, code cache; direct memory and other native memory.
  • StackOverflowError comes from deep recursion; -Xss (default 1 MB on Linux x64) or a per-thread stack size raises the limit.
  • OOM kinds: Java heap space, GC overhead limit exceeded, Metaspace, Compressed class space, Requested array size exceeds VM limit, direct buffer memory, and "unable to create native thread".
  • Metaspace is unlimited by default; class loader leaks grow it.
  • A full code cache disables the JIT and slows the application without an error.
  • NMT (-XX:NativeMemoryTracking=summary, jcmd VM.native_memory) splits native memory by category.
  • In containers the JVM reads cgroup limits; MaxRAMPercentage defaults to 25.

Summary

  • Process memory = heap + metaspace + code cache + thread stacks + direct buffers + GC and JVM structures + native libraries.
  • Each area has its own flag and its own failure message.
  • MemoryPoolMXBean, BufferPoolMXBean, and jcmd expose usage at run time.
  • Size the heap as a percentage of the container limit and leave room for non-heap memory.