skip to content

Beyond the Java heap, what else consumes memory in a running JVM process, and how do you put an explicit bound on each part when budgeting a service's total memory footprint?

level: seniorimportance: should knowfreq 50%

answer

  1. metaspace, code cache, stacks, direct buffers, GC structures, native
  2. metaspace unbounded by default
  3. MaxDirectMemorySize defaults to -Xmx
  4. stack cost = threads x -Xss, fix the pools
  5. NMT summary + jcmd VM.native_memory

basics

~20 s

Metaspace and compressed class space, the JIT code cache, one stack per thread, direct and mapped byte buffers, garbage-collector bookkeeping, and native library allocations. Bound them with -XX:MaxMetaspaceSize, -XX:ReservedCodeCacheSize, -Xss plus bounded thread pools, and -XX:MaxDirectMemorySize; measure the rest with Native Memory Tracking.

solid answer

~50 s

The main non-heap consumers, with their bounds: - **Metaspace** holds class metadata in native memory and is unbounded by default; cap it with -XX:MaxMetaspaceSize. Compressed class space is a separate reservation, sized by -XX:CompressedClassSpaceSize. - **Code cache** holds JIT-compiled methods; -XX:ReservedCodeCacheSize defaults to around 240 MB with tiered compilation. - **Thread stacks**: one per thread at -Xss (commonly 1 MB), so the real bound is the number of threads - fix the pools, not just the flag. - **Direct and mapped byte buffers**: -XX:MaxDirectMemorySize defaults to the maximum heap size, which is rarely what you intended; mapped files add page-cache-backed resident memory. - **GC bookkeeping**: mark bitmaps, remembered sets and forwarding structures scale with heap, typically a few percent and more for some collectors. - **Native**: JNI libraries, glibc malloc arenas (MALLOC_ARENA_MAX), symbol and string tables. Budget as heap plus the sum of these plus slack, and verify with -XX:NativeMemoryTracking and jcmd VM.native_memory rather than trusting the arithmetic.

code

text · 10 lines
text
-Xmx2g
-XX:MaxMetaspaceSize=256m
-XX:ReservedCodeCacheSize=192m
-XX:MaxDirectMemorySize=256m
-Xss512k                       # with bounded pools, not unbounded threads
-XX:NativeMemoryTracking=summary

# under load:
jcmd <pid> VM.native_memory summary
#  -> Java Heap / Class / Thread / Code / GC / Internal, reserved + committed

go deeper

for a junior

Be able to list the main non-heap regions and say that the heap flag does not cover them.

for a middle

Pair each region with the flag that bounds it and know which defaults are unbounded or derived from the heap.

for a senior

Build a defensible total-memory budget and verify it with Native Memory Tracking under representative load, including soak-time growth.

for a principal

Standardise the budget template across services so container limits, non-heap caps and collector overhead are set coherently rather than per-team.

## Why the inventory matters Any statement of the form 'this service needs N gigabytes' is wrong unless it accounts for everything the operating system charges the process for. The heap is the part everyone tunes and often not much more than half of the total. Each of the remaining regions has a default, and most of those defaults are either unbounded or derived from the heap size, which is how a carefully chosen -Xmx still produces an unpredictable footprint. ## Class metadata Class metadata - the runtime representation of loaded classes, methods and constant pools - lives in metaspace, which is native memory outside the heap. Its default maximum is effectively unlimited, so a framework that generates proxies, or a classloader that is never released, grows native memory until something external intervenes. -XX:MaxMetaspaceSize converts that into a clear, attributable failure at a chosen boundary. On 64-bit JVMs using compressed class pointers there is also a separate compressed class space, reserved at 1 GB by default and controlled by -XX:CompressedClassSpaceSize; it is reserved address space rather than committed memory, but it matters for address-space accounting. ## Compiled code The JIT compiler stores compiled methods in the code cache, reserved at roughly 240 MB under tiered compilation and settable with -XX:ReservedCodeCacheSize. Large applications with many hot methods can genuinely fill a modest cache; when it fills, the JVM disables compilation and performance collapses, so shrinking this to save memory is a trade with a sharp edge. -XX:+UseCodeCacheFlushing lets it evict cold code instead. ## Thread stacks Each platform thread gets its own stack, sized by -Xss and typically 512 KB to 1 MB by default. The stack is reserved on creation and becomes resident as it is actually used, so a thousand threads is a gigabyte of address space and a smaller but real resident cost. The controlling variable is almost always thread count, not stack size: bounded pools, avoiding a thread per connection, and using virtual threads where the workload suits them do far more than shaving -Xss, which mainly risks StackOverflowError on deep recursion. ## Off-heap buffers Direct byte buffers are allocated outside the heap and are reclaimed only when their Java handles become unreachable and are processed, so their lifetime is coupled to collection timing in a way ordinary allocation is not. Their maximum defaults to the value of -Xmx, meaning a service with a 4 GB heap may implicitly permit another 4 GB of direct memory; -XX:MaxDirectMemorySize should be set deliberately. Memory-mapped files add another category: resident memory backed by the page cache, which the kernel can reclaim but which still shows up in process accounting and in container charging. ## Collector overhead Every collector keeps metadata proportional to the heap: mark bitmaps, per-region remembered sets, forwarding or relocation tables, and card structures. A few percent of heap is a reasonable planning figure for a region-based collector, with some collectors and some workloads considerably higher. This is memory you cannot switch off, and it is one reason a very large heap costs more than its nominal size. ## Native and allocator overhead JNI libraries, compression and cryptography implementations, and drivers allocate native memory the JVM does not track. On glibc, each thread can acquire its own malloc arena, so many-threaded services show resident memory noticeably above the sum of the JVM's own accounting; MALLOC_ARENA_MAX limits this. Symbol tables, string tables and JVM-internal structures add a smaller fixed cost. ## Turning the list into a number Budget as: maximum heap, plus metaspace cap, plus code cache, plus thread count times stack size, plus direct memory cap, plus a collector-overhead allowance, plus native slack. Then stop trusting the arithmetic and measure: run with -XX:NativeMemoryTracking=summary and take jcmd VM.native_memory summary snapshots under representative load, which attributes reserved and committed memory by category and immediately shows which assumption was wrong. Use the baseline and diff facility to see which category grows over a long soak - that is how an unbounded consumer is identified before it terminates the process.

  • Why is -XX:MaxDirectMemorySize worth setting explicitly?
    Because its default is the maximum heap size, so a service with a 4 GB heap silently permits up to another 4 GB of off-heap buffers - memory that no one budgeted. Setting it to a realistic value both caps the footprint and turns runaway buffer usage into an explicit Java-level failure that names direct memory instead of an opaque container kill.
  • Is lowering -Xss a good way to reduce a service's memory footprint?
    Rarely the first move. Stack memory becomes resident only as it is used, so the saving is usually smaller than the arithmetic suggests, while a smaller stack raises the risk of StackOverflowError in deep recursion or heavily layered frameworks. Reducing the number of threads addresses the same cost without that risk.

saying these in an interview costs you the question

  • Expecting resident memory to be close to -Xmx
  • Not knowing metaspace is unbounded by default
  • Assuming direct byte buffers are covered by the heap maximum
  • Trimming -Xss as the primary footprint fix instead of bounding thread counts
  • Estimating the budget on paper without ever running Native Memory Tracking

context