The phrase "Java memory model" points at two entirely different things, and mixing them up wastes a lot of debugging time. This page is about the first one: the runtime data areas the JVM carves out of a process - which region an allocation lands in, who is allowed to reclaim it, and which number goes up when it grows. That is a layout question, not a concurrency question.

Two different things share the name

The Java Memory Model in the JSR-133 sense is a specification of visibility: which writes one thread is guaranteed to see from another, what orderings the compiler and CPU are permitted to invent, and which constructs create the ordering edges that make a publication safe. It says nothing about where bytes live. That subject is developed in Java Memory Model - happens-before, visibility, and data races, and again from the hardware side in Java Memory Model architecture. If you arrived here looking for happens-before edges, those are the pages you want.

The other memory model is the one in chapter 2.5 of the JVM specification: the set of runtime data areas a JVM creates when it starts and destroys when it exits. This is the model you need when a container gets killed with exit code 137 and there is no Java stack trace, when resident memory is triple the heap, or when a service that ran for six months on a 31GB heap starts throwing OutOfMemoryError after someone bumped it to 33GB. None of those are visibility problems. All of them are layout problems.

The rest of this page walks the regions one at a time: what each holds, what sizes it, what fails when it runs out, and which tool attributes it.

Advertisement

The runtime data areas and who owns each

The clean split is between areas that belong to a single thread and areas shared by the whole process, because ownership determines lifecycle. Per-thread areas are created when the thread starts and torn down when it exits, with no garbage collector involved at all. Shared areas outlive any individual thread and therefore need some reclamation story - a collector, a classloader becoming unreachable, or an explicit free.

Per thread: the program counter register (one word, pointing at the current bytecode); the JVM stack, which holds a frame per active method call; and the native method stack, used when execution crosses into JNI. Every one of these scales with thread count, and none of them is bounded by -Xmx.

Shared across the process: the heap, where every object and array lives; the method area, which HotSpot implements as Metaspace plus a compressed class space; the runtime constant pool, which lives inside the method area; and - not in the specification at all, but very much in the process - the JIT code cache holding compiled machine code, plus the collector's own bookkeeping structures.

The practical consequence is that a heap dump shows you exactly one of these areas. A profiler that opens an .hprof file can tell you which objects are retaining which, and is completely blind to a thread leak, a classloader leak, a runaway code cache, or a pinned direct buffer. When the heap looks healthy and the process does not, the answer is always in one of the regions the heap dump cannot see.

JVM memory regionsHeapyoung + oldStackper-thread framesMetaspace + off-heapclass metadata + directEach region has its own GC or lifecycle; understand which holds your allocations
Four main memory regions.

Per-thread stacks, and the two failures they cause

A JVM stack is a stack of frames, one per method invocation in progress. Each frame carries a local variable array, an operand stack for intermediate values, and a reference into the runtime constant pool of the frame's class. Frame size is not dynamic: javac computes max_locals and max_stack for every method and stores them in the Code attribute, so the JVM knows before the call how many slots the frame needs. What varies is how many frames deep you go.

-Xss sets the requested stack size for each Java thread, typically defaulting to around a megabyte on 64-bit HotSpot, rounded up to page granularity with a guard page at the end. Two very different failures come out of this one flag, and they pull it in opposite directions.

StackOverflowError - one thread went too deep

A single thread exhausted its own stack. Maximum depth is roughly -Xss divided by average frame size, so a few thousand to a few tens of thousands of frames for ordinary code. Unbounded recursion is the textbook cause, but in production the usual culprits are mutual recursion through equals or toString on a cyclic object graph, a deeply layered proxy and filter chain, or a recursive descent parser fed hostile input. Raising -Xss buys depth linearly and is a legitimate fix when the depth is genuinely required - a deep expression tree, for instance. It is not a fix for a cycle.

OutOfMemoryError: unable to create new native thread

This one has nothing to do with -Xmx, and the message misleads people into raising the heap, which makes it worse. Every platform thread reserves its full -Xss of address space plus an OS thread structure. Ten thousand threads at one megabyte each is ten gigabytes of reservation before a single object is allocated. The failure can also come from a process limit rather than memory at all - ulimit -u, kernel.threads-max, or a container's pids cgroup ceiling. Note the tension: raising -Xss to cure the first failure brings the second one closer.

Virtual threads change this arithmetic rather than tuning it. Their stacks are heap-allocated continuation chunks that grow and shrink on demand instead of fixed reservations, so a million of them do not reserve a million megabytes - the cost moves onto the heap, where the collector can manage it. See Java Virtual Threads. The native method stack is separate again, sized by the operating system rather than -Xss, which is why a JNI library that recurses can blow a stack the JVM never sized.

The heap as a layout, not an algorithm

Two flags describe the heap and they mean genuinely different things. -Xmx is a reservation: at startup the JVM reserves that much contiguous virtual address space so the heap has a single unbroken range. -Xms is the initial commitment: memory actually backed by pages. The gap between them is why virtual size in top is an enormous and useless number while resident size is the one that gets you killed. Setting -Xms equal to -Xmx avoids the repeated commit-and-uncommit cycles as the heap grows; adding -XX:+AlwaysPreTouch goes further and writes every page at startup, trading a slower boot for the absence of first-touch page-fault stalls later.

Contiguity is not a cosmetic detail. A single unbroken range is what makes the pointer compression in the next section possible, and it is why a JVM will sometimes fail to start with a large -Xmx on a fragmented address space even though the machine has the RAM.

Within that range, the classic generational layout divides the heap into a young generation - Eden plus two survivor spaces - and an old generation, as address ranges. Region-based collectors invert this: G1, ZGC and Shenandoah split the reservation into fixed-size regions and treat "young" or "old" as a label attached to a region rather than a range of addresses, which is what lets the young generation resize itself without moving anything. That is a layout difference. What the collectors then do with those regions - marking, evacuation, pause targets, humongous handling - belongs to JVM GC architecture, Java G1 GC and ZGC architecture, and this page defers to them entirely.

One layout detail worth knowing because it silently consumes Eden: each thread bump-allocates from its own thread-local allocation buffer, and when the next object will not fit in the remaining space, that remainder is filled with a dummy object and the buffer is retired. -XX:TLABWasteTargetPercent (about 1% by default) governs how much of that waste the JVM tolerates before it prefers to allocate outside the buffer instead; -Xlog:gc+tlab=trace reports the actual waste. On workloads with many threads and a small Eden, retirement waste is a measurable slice of the young generation.

In a container, the flag that decides heap size is usually not -Xmx at all. With container support active, the JVM reads the cgroup memory limit rather than host RAM and applies -XX:MaxRAMPercentage, which defaults to 25. A four-gigabyte container therefore gets a one-gigabyte heap unless somebody says otherwise, and the three gigabytes nobody accounted for are the reason so many services are simultaneously heap-starved and nowhere near their container limit.

Advertisement

Compressed oops and the 32GB cliff

An "oop" is an ordinary object pointer - the JVM's word for a reference. On a 64-bit JVM a raw reference is eight bytes, which on a pointer-dense object graph is an enormous tax compared to the 32-bit JVMs it replaced. Compressed oops recover most of it: when the heap is small enough, references are stored as 32-bit values and widened on use.

The ceiling follows directly from the arithmetic. A 32-bit value has about 4.3 billion distinct states, and objects are aligned to eight-byte boundaries (-XX:ObjectAlignmentInBytes, default 8), so the low three bits of every address are always zero and carry no information. Shift them out and each 32-bit value addresses one of 2^32 eight-byte slots: 32GB of heap. That is where the number comes from, and it is not a tunable in itself.

Decoding comes in two flavours. If the JVM can map the reservation such that the heap base sits at zero, decoding is a single shift - essentially free. If it cannot, decoding is a shift plus an add against a base register, still cheap but not free. Which one you got depends on where the reservation landed, so it is worth checking rather than assuming; -Xlog:gc+heap+coops=info prints the mode and base the JVM chose at startup.

The cliff is what makes this operationally interesting. Cross 32GB and compression switches off entirely - every reference field in every object doubles from four bytes to eight, and the class pointer in each object header widens with them. On a graph of many small objects with several references each, hash map nodes and linked structures being the worst case, the live set inflates by something in the region of fifteen to twenty percent. The result is genuinely counterintuitive: a 33GB heap can hold less live data than a 31GB one, and a team that raised the heap to cure an OOM can find they have made the OOM arrive sooner. If you need to cross, cross properly - forty-eight gigabytes or more, so the extra capacity outweighs what compression gave you.

There is one lever on the ceiling. Raising -XX:ObjectAlignmentInBytes to 16 shifts out an extra bit and doubles the addressable range to 64GB, at the cost of up to fifteen bytes of padding per object instead of seven. That pays off when your objects are large and few, and backfires when they are small and numerous - measure before adopting it. Separately, ZGC does not participate in this scheme at all: its references carry colour metadata in the pointer bits and are always 64-bit, so the 32GB boundary simply is not a feature of a ZGC heap. Ongoing work under Project Lilliput attacks the same overhead from the other end, shrinking the object header itself rather than the references into it.

The native regions other pages own

Metaspace holds class metadata - the runtime representation of classes, methods, field descriptors and constant pools. It moved out of the heap in Java 8, replacing the old fixed PermGen, precisely because class metadata has a lifecycle tied to classloaders rather than to object reachability: a class is reclaimable only when its entire defining loader becomes unreachable, which is a coarser condition than any individual object's. It grows on demand and is unbounded unless you set -XX:MaxMetaspaceSize. The chunk and arena allocator underneath it, and the classloader leak patterns that make it grow forever, are developed in JVM Metaspace architecture.

The code cache holds machine code emitted by the JIT compilers, along with interpreter stubs and adapters. It is a bounded native region, and when it fills the JVM stops compiling and quietly reverts to interpreted execution - a performance collapse with no exception and no obvious symptom other than throughput falling off a cliff. How the tiers fill it, how the sweeper reclaims it, and what to set -XX:ReservedCodeCacheSize to are covered in JVM JIT architecture.

Direct buffers - allocation you can see, freeing you cannot

ByteBuffer.allocateDirect(n) allocates n bytes of native memory outside the Java heap and hands back a small DirectByteBuffer wrapper whose own heap footprint is a couple of hundred bytes. The point is I/O. A heap byte[] can be moved by the collector at any time, so it cannot be handed to a kernel call directly; when you write a heap-backed buffer to a socket, NIO copies it into a native scratch buffer first and caches that scratch buffer per thread. A direct buffer skips the copy - and the scratch-buffer cache is itself a quiet source of native growth that people rarely account for.

The budget is -XX:MaxDirectMemorySize. Leave it unset and it defaults to roughly the value of -Xmx, which means a thirty-gigabyte heap silently authorises a thirty-gigabyte direct allocation budget on top of it. Nothing complains until resident memory is sixty gigabytes and the kernel has an opinion.

The reclamation mechanism is the part that catches people, and the common summary - "not managed by GC, freed by GC" - is wrong in both halves. The wrapper registers a Cleaner, which is a phantom reference on a ReferenceQueue. The native block is freed only after the wrapper becomes unreachable and a reference-handler thread dequeues the phantom reference and runs the cleanup. Now notice the incentive problem: the wrapper is a tiny short-lived object in Eden, so a process pinning gigabytes of native memory exerts almost no heap pressure. The collector has no reason to run. You get:

java.lang.OutOfMemoryError: Direct buffer memory
        at java.base/java.nio.Bits.reserveMemory(Bits.java:175)
        at java.base/java.nio.DirectByteBuffer.<init>(DirectByteBuffer.java:118)
        at java.base/java.nio.ByteBuffer.allocateDirect(ByteBuffer.java:317)

...with a heap that is twenty percent used. The reservation path has a fallback for exactly this: when the budget is exhausted it calls System.gc() and retries with a backoff, hoping to make the wrappers unreachable and drain the queue. Which leads to a nasty interaction worth knowing about - -XX:+DisableExplicitGC, widely set to stop badly behaved libraries triggering full collections, removes the only mechanism that reclaims direct memory under pressure and converts a recoverable stall into a hard failure. If you need to suppress library calls to System.gc(), prefer -XX:+ExplicitGCInvokesConcurrent, which keeps the reclamation path alive without the stop-the-world cost.

The engineering answer is not to rely on any of this. Allocate long-lived direct buffers once and pool them: Netty does exactly that with reference-counted pooled buffers, at the price of manual release discipline. For new code the modern option is an explicit lifetime instead of a reachability-based one - a confined or shared Arena in the foreign function and memory API, where closing the arena frees the segment deterministically and further access throws instead of corrupting. See Java FFM architecture.

Why RSS exceeds -Xmx

A JVM's resident set is routinely well above its maximum heap, and the surprise is only ever a surprise once. Everything on this list is resident, and only the first item is bounded by -Xmx:

  • Committed heap - at most -Xmx, often less if -Xms is lower and the heap has not grown.
  • Collector bookkeeping - the card table costs roughly one byte per 512 bytes of heap; G1's remembered sets can run to several percent of heap on reference-dense graphs; concurrent collectors add mark bitmaps and forwarding structures. This is the item people forget most often, and it scales with the heap, so a bigger heap costs more than the heap.
  • Thread stacks - -Xss multiplied by thread count, in committed pages rather than reservation, plus the JVM's own GC, JIT compiler and service threads.
  • Code cache and compiler arenas - the emitted code plus the scratch memory C2 uses while compiling, which can spike on large methods.
  • Metaspace and compressed class space - proportional to the number of loaded classes, not to instances.
  • Direct buffers and memory-mapped files - a mapped file's resident pages count against you even though you never allocated them.
  • Native allocator overhead - glibc malloc keeps up to eight arenas per core, and memory freed into an arena is frequently never returned to the OS. Setting MALLOC_ARENA_MAX=2, or linking jemalloc or tcmalloc, often reclaims a surprising amount of apparent leakage.
  • JNI allocations - anything a native library mallocs on its own account.

For container sizing the practical rule is to leave a real headroom margin over -Xmx rather than a token one: a few hundred megabytes for a modest service, considerably more for one with thousands of threads or heavy direct-buffer I/O. Get it wrong and the kernel OOM killer terminates the process with exit code 137 - no exception, no heap dump, nothing in the GC log, because from the JVM's point of view nothing was wrong. That silence is the signature of a native-memory problem rather than a heap one.

Native Memory Tracking - attributing the rest

Native Memory Tracking is the JVM's own accounting of everything above, and it is the tool that turns "resident memory is growing and the heap is fine" into a specific answer. Enable it at launch - it cannot be switched on later, which is the argument for enabling it pre-emptively on any service you expect to have to debug:

# at launch; summary costs a few percent, detail costs more
-XX:NativeMemoryTracking=summary

# then, against a running process
jcmd <pid> VM.native_memory summary

# leak hunting: take a baseline, wait, diff it
jcmd <pid> VM.native_memory baseline
sleep 3600
jcmd <pid> VM.native_memory summary.diff

The report breaks the process down by category - Java Heap, Class, Thread, Code, GC, Compiler, Internal, Symbol and a few others - and gives reserved and committed figures for each. Read the committed column; reserved is address space and does not touch your resident set. The baseline-and-diff workflow is what makes it diagnostic rather than merely descriptive: a growing Class figure means classes are being loaded and not unloaded, which is a classloader leak; a growing Thread figure means threads are being created and not joined; a flat report with growing resident memory means the growth is somewhere NMT cannot see.

That blind spot is the most important thing to understand about the tool. NMT tracks allocations the JVM makes through its own accounting hooks. Memory that a JNI library, a JDBC driver's native layer, or a compression codec allocates with plain malloc appears in resident size and never in the NMT report. So when the NMT total sits well below RSS, the gap itself is the finding, and the investigation moves to pmap, to allocator fragmentation, and to a native profiler such as jemalloc's heap profiling. Enabling detail mode also adds its own overhead to the very numbers you are reading, which the report accounts for under its own category.

Alongside NMT, jcmd <pid> GC.heap_info gives the region-level heap picture, and Java Flight Recorder supplies the allocation and GC event stream for the heap side of the problem. NMT covers what the heap tools structurally cannot.

Putting the regions together

The regions are worth reasoning about as one budget rather than a set of independent flags, because they compete for exactly one resource - the container's memory limit. A concrete shape for a service in a 4GB container: an -Xmx of 2560m with -Xms matched to it so the heap never has to grow into contested territory, a -XX:MaxDirectMemorySize set explicitly to something like 256m rather than inheriting the heap size by default, -XX:MaxMetaspaceSize capped so a classloader leak fails loudly and early instead of consuming the machine, and a thread count kept low enough that -Xss times threads stays in the low hundreds of megabytes. What remains covers collector structures, the code cache and allocator overhead.

The value of setting each of those explicitly is not that the defaults are wrong. It is that an explicit ceiling converts a silent, gradual consumption of the whole container into a specific, attributable Java-level error at a known boundary - a Metaspace OOM naming the leak, or a Direct buffer memory OOM naming the buffer, instead of exit code 137 and no evidence at all.

The JVM's runtime data areas are a different subject from the happens-before memory model that shares its name. Per-thread stacks are sized by -Xss and produce two opposite failures - StackOverflowError from depth, native-thread OOM from count - neither of them fixable by raising the heap. The heap is a reservation with a hard behavioural boundary at 32GB, where compressed oops switch off and a bigger heap can hold less live data. Direct buffers are freed by a phantom reference on a queue rather than by visible GC pressure, which is why they can exhaust native memory with an idle collector. Everything outside the heap is why RSS exceeds -Xmx, and Native Memory Tracking is the only tool that attributes it - as long as you remember it cannot see plain malloc from a native library.