JVM memory areas

Six areas and six OutOfMemoryErrors, each triggered; Native Memory Tracking explaining a 329 MB process with a 256 MB heap; and what objects cost, from a 16-byte Integer to compressed oops.

7 min read⚙️ JVM Internals and Performance

"The JVM has a heap" is true and not very useful. A running Java process uses memory in half a dozen distinct areas, each with its own limit, its own failure message, and its own flag — and most memory incidents are someone looking at the wrong one. A service with a 256 MB heap that uses 329 MB of RAM is not leaking. It is a normal JVM, and this lesson shows where the other 73 MB went.

Every output below is from Java 21 on Linux, in a container.

The areas

areaholdslimited byshared by
Heapevery object and array-Xmxall threads
Thread stackseach thread's frames: locals, operands, return addresses-Xss per thread, times the number of threadsone per thread
Metaspaceclass metadata: methods, fields, bytecode, constant pools-XX:MaxMetaspaceSize (unbounded by default)all threads
Code cachemachine code produced by the JIT-XX:ReservedCodeCacheSizeall threads
Direct memoryByteBuffer.allocateDirect, used by NIO and network libraries-XX:MaxDirectMemorySize (defaults to the heap limit)all threads
The JVM itselfGC data structures, symbols, JIT compiler memory, internal buffersnothing you set directly

The heap is the only one most people configure, and it is only part of the process's footprint.

Measuring all of them: Native Memory Tracking

The JVM can account for its own memory. Start it with -XX:NativeMemoryTracking=summary and ask a running process with jcmd. A small program with a 256 MB heap limit, 60 MB of live objects, one 32 MB direct buffer and a pool of 50 threads:

plaintext
$ jcmd <pid> VM.native_memory summary scale=MB
Total: reserved=1834MB, committed=264MB
-                 Java Heap (reserved=256MB, committed=149MB)
-                     Class (reserved=1024MB, committed=0MB)
-                    Thread (reserved=142MB, committed=6MB)
-                      Code (reserved=242MB, committed=7MB)
-                        GC (reserved=54MB, committed=51MB)
-                  Internal (reserved=1MB, committed=1MB)
-                     Other (reserved=32MB, committed=32MB)
-                    Symbol (reserved=1MB, committed=1MB)
-        Shared class space (reserved=16MB, committed=13MB, readonly=0MB)
-               Arena Chunk (reserved=2MB, committed=2MB)
-                 Metaspace (reserved=64MB, committed=0MB)
 
process RSS: 329 MB

Two words carry the reading:

  • Reserved is address space the JVM has claimed and not necessarily used. It is large and mostly harmless — 1,834 MB here.
  • Committed is memory actually backed by RAM. That is the number that counts against a container's limit.

Now the details. The heap has committed 149 MB for 60 MB of live data, because the collector keeps room to work in. The direct buffer is the 32 MB under Other. The GC's own bookkeeping is 51 MB — remembered sets, card tables and marking bitmaps, sized with the heap. And the process's resident size, 329 MB, is larger again than NMT's 264 MB committed, because NMT does not see memory allocated by native libraries through malloc, or the pages of mapped JAR files.

Six ways to run out

Each area fails with its own message, and the message says which limit was hit. Each of these was triggered deliberately:

plaintext
java.lang.OutOfMemoryError: Java heap space   (after 31 allocations)
java.lang.OutOfMemoryError: Requested array size exceeds VM limit
java.lang.OutOfMemoryError: Metaspace   (after 10550 allocations)
java.lang.OutOfMemoryError: Cannot reserve 1048576 bytes of direct buffer memory (allocated: 67108864, limit: 67108864)   (after 64 allocations)
java.lang.StackOverflowError: null
java.lang.OutOfMemoryError: unable to create native thread: possibly out of memory or process/resource limits reached
messagewhat ran outthe usual causethe usual fix
Java heap spacethe heap (-Xmx64m: 31 × 1 MB arrays)a leak, or a heap too small for the live datafind the leak, or size the heap
Requested array size exceeds VM limitnothing yet — the request itself is impossiblea size computed from bad inputvalidate the size before allocating
Metaspaceclass metadata (MaxMetaspaceSize=32m: about 5,000 generated proxy classes)a class loader leak, or runaway class generationfind who creates class loaders and never releases them
Cannot reserve … direct buffer memorydirect memory (MaxDirectMemorySize=64m)buffers not released, or a limit below what a network library needsrelease buffers; size the limit deliberately
StackOverflowErrorone thread's stack (-Xss256k)unbounded recursionfix the recursion; raising -Xss only delays it
unable to create native threadthe operating system's thread or process limit (a container with --pids-limit 400)an unbounded thread poolbound the pool

Only the first is about the heap. Raising -Xmx in response to any of the other five does nothing useful — and for the last one, a bigger heap leaves less memory for thread stacks.

Object layout: what an object really costs

A Java object is not just its fields. Every object has a header — a mark word for locking and GC state, and a pointer to its class — and objects are padded to a multiple of 8 bytes. With default settings on a 64-bit JVM, the header is 12 bytes: an 8-byte mark word and a 4-byte compressed class pointer.

Measured by counting the bytes each allocation added, averaged over a million allocations of each:

plaintext
                               default    -XX:-UseCompressedOops
  new Object()                  16 bytes   16 bytes
  { int a; }                    16 bytes   16 bytes
  { long a; }                   24 bytes   24 bytes
  { int a; Object ref; }        24 bytes   24 bytes
  { Object a, b, c; }           24 bytes   40 bytes
  Integer (outside the cache)   16 bytes   16 bytes
  new int[0]                    16 bytes   16 bytes
  new int[10]                   56 bytes   56 bytes
  new long[10]                  96 bytes   96 bytes
  new Object[10]                56 bytes   96 bytes

Reading it:

  • An empty object is 16 bytes: the 12-byte header, padded. An int fits in the padding, so { int a; } is also 16.
  • A long does not fit: 12 + 8 = 20, padded to 24.
  • An Integer is 16 bytes to hold a 4-byte value. A List<Integer> of a million numbers is about 16 MB of Integer objects plus 4 MB of references, where an int[] of the same numbers is 4 MB.
  • An array has a 16-byte header (12, plus a 4-byte length), then its elements: int[10] is 16 + 40 = 56.

Compressed oops store references as 32-bit values that the JVM scales into a heap of up to about 32 GB. Turn them off, or configure a heap above that threshold, and every reference doubles to 8 bytes: three references went from 24 to 40 bytes, and Object[10] from 56 to 96. That is why a heap of 32 GB can hold fewer objects than one of 31 GB, and why heaps just above that size are a known trap.

Stacks, Metaspace and the code cache, briefly

  • A thread stack is reserved per thread — the default depends on the platform: ThreadStackSize printed 2040 KB on the ARM64 Linux container used here, and it is 1 MB on x86-64 Linux — and committed as it is used. NMT's 142 MB reserved against 6 MB committed for 50 threads is typical: idle threads use little. Thousands of platform threads still add up, which is part of what virtual threads change.
  • Metaspace grows with the number of classes loaded, and shrinks only when a class loader becomes unreachable and its classes are unloaded. Frameworks that generate classes — proxies, bytecode enhancers, scripting engines — are where it grows unexpectedly. The class loading lesson covers the loader leak that pins it.
  • The code cache holds JIT-compiled code. When it fills, the JVM prints a warning that the compiler has been disabled, and the service keeps running — interpreted, many times slower. The JIT lesson shows how large that difference is.
Progress is saved on this device and to your account when signed in.