JVM Internals & Performance

The Java Memory Model

15 min Lesson 2 of 13

The Java Memory Model

Every byte your Java program allocates lives somewhere — in the heap, on the stack, or in metaspace. Knowing exactly where each kind of data lives, why it lives there, and what happens when those regions fill up is prerequisite knowledge for writing high-performance, low-GC-pressure Java. This lesson gives you that map.

The Big Picture: JVM Runtime Data Areas

The JVM specification defines several runtime data areas. From a day-to-day performance standpoint, three matter the most:

  • The Heap — where all object instances and arrays live.
  • The JVM Stack (one per thread) — where method frames, local variables, and operand stacks live.
  • Metaspace (replaced PermGen in Java 8) — where class metadata lives.

Two smaller areas complete the picture: the PC Register (each thread's program counter) and Native Method Stacks (for JNI code). These are rarely tuned directly.

Memory areas are not independent. A 32-thread server application has 32 stacks running concurrently, all sharing a single heap. Stack memory is consumed linearly with thread count; heap pressure accumulates from every thread allocating objects. Both must be sized together.

The Heap

The heap is the central allocator for all Java objects. When you write new ArrayList<>(), the resulting object lands on the heap — and stays there until the garbage collector proves it is unreachable.

Inside the heap the JVM subdivides space into generations (for generational collectors like G1 and Parallel GC) or into regions (G1) / segments (ZGC, Shenandoah). The classical model that still informs reasoning has three zones:

  • Young Generation (Eden + two Survivor spaces) — where every new object is first allocated. Minor GC runs here frequently; most objects die young ("generational hypothesis").
  • Old Generation (Tenured) — objects that survive enough minor GC cycles are promoted here. Major GC (or full GC) reclaims this region.

Key heap flags (G1 as example):

# Set initial and maximum heap size (keep them equal in production to avoid resize pauses) java -Xms4g -Xmx4g -XX:+UseG1GC -jar app.jar # Inspect current heap usage at runtime with JMX or: jcmd <pid> VM.native_memory summary
Set -Xms == -Xmx in production. If initial and maximum heap differ the JVM must resize the heap at runtime, which requires a stop-the-world pause. Fixing them at the same value eliminates resize pauses and also prevents the OS from reclaiming and then re-granting memory under load.

Object Layout on the Heap

Understanding how objects are laid out in memory helps explain why some patterns allocate far more than they appear to. Every heap object has a header (typically 12 bytes with compressed OOPs enabled, or 16 bytes without) followed by instance fields, with padding added for alignment. This means a class with a single byte field still occupies 16 bytes minimum.

// Appears tiny — but occupies ~16 bytes on the heap (header + field + padding) public class Flag { private boolean active; } // Arrays also carry a header plus element storage // int[1000] = ~16 bytes header + 4000 bytes = ~4016 bytes int[] counters = new int[1000];

Tools like JOL (Java Object Layout) print the exact layout at runtime:

// Add jol-core to your build, then: System.out.println(ClassLayout.parseInstance(new Flag()).toPrintable());

This matters when designing cache-friendly data structures or when tracking apparent heap bloat that comes from object header overhead on millions of small objects.

The JVM Stack

Each thread owns its own JVM stack, created when the thread starts and destroyed when it terminates. The stack is composed of frames: one frame is pushed for each method call and popped when that method returns or throws.

A frame contains:

  • Local variable array — holds method parameters and declared locals. Primitives (int, long, double, etc.) are stored directly as values here.
  • Operand stack — the working space for bytecode operations.
  • Reference to the runtime constant pool — symbolic links resolved to concrete memory addresses.
public static long sumRange(int start, int end) { // 'start', 'end', and 'sum' live in the local variable array of THIS frame // They are on the stack — zero heap allocation long sum = 0; for (int i = start; i <= end; i++) { sum += i; } return sum; }

The critical implication: primitives in local variables do not touch the heap. Only references to objects are stored in the local variable array — the object they point to lives on the heap, but the reference (a 4- or 8-byte pointer) sits on the stack.

Stack size is controlled per thread with -Xss (default is typically 512 KB – 1 MB). Deep recursion grows the stack until a StackOverflowError is thrown. More threads means more total stack memory even if each thread's stack is idle.

Creating thousands of threads is expensive, and not just in CPU scheduling cost. With the default 512 KB stack size, 2,000 threads consume 1 GB of native memory for stacks alone, before any heap allocation. Virtual threads (Project Loom, GA in Java 21) solve this by giving each virtual thread a tiny, growable stack managed by the JVM rather than the OS.

Metaspace

Before Java 8, class metadata was stored in the Permanent Generation (PermGen) — a fixed region inside the heap. It was a notorious source of OutOfMemoryError: PermGen space in applications that load many classes (OSGi containers, hot-redeploy servers, heavy reflection). Java 8 replaced it with Metaspace.

Metaspace stores:

  • Class structures (field descriptors, method descriptors, vtable)
  • Method bytecode and JIT-compiled native code pointers
  • Runtime constant pool entries
  • Annotations

The key difference from PermGen: Metaspace lives in native memory (outside the heap) and grows automatically by default. This eliminates the old fixed-size problem, but introduces a new one: an application with a classloader leak will silently consume native memory until the host OS runs out.

# Cap metaspace to prevent runaway native memory consumption java -XX:MaxMetaspaceSize=256m -jar app.jar # Monitor metaspace usage jstat -gcmetacapacity <pid>

Metaspace is reclaimed when a class's ClassLoader itself becomes unreachable. In a plain Spring Boot application this rarely happens (one classloader for the life of the process). In OSGi, Jakarta EE hot-deploy, or frameworks that generate proxy classes at runtime (Hibernate, ByteBuddy), classloader leaks are real and will exhaust Metaspace over time.

String Pool and the Constant Pool

String literals are interned into the String pool — a JVM-managed table of deduplicated String objects stored on the heap (post-Java 7). Two identical literals share the same heap object:

String a = "hello"; // interned in the String pool String b = "hello"; // same reference as 'a' String c = new String("hello"); // FORCES a new heap object — avoids the pool System.out.println(a == b); // true — same pooled object System.out.println(a == c); // false — c is a separate heap object System.out.println(a.equals(c)); // true — same content

Explicit new String(…) is almost always wrong. It bypasses the pool, wastes heap, and is the reason the rule "compare strings with equals(), not ==" exists in the first place.

Escape Analysis and Stack Allocation

The JIT compiler performs escape analysis: if it can prove that an object never "escapes" the creating method (is not stored in a field, passed to another thread, or returned), it may allocate that object on the stack or even eliminate the allocation entirely (scalar replacement).

// The JIT may eliminate the 'Point' allocation entirely if it does not escape public static double distance(double x1, double y1, double x2, double y2) { // 'p' is local — the JIT can allocate it on the stack or inline it // No heap allocation needed double dx = x2 - x1; double dy = y2 - y1; return Math.sqrt(dx * dx + dy * dy); }

This is why tight inner loops that create small helper objects often perform better than naïve analysis suggests — the JIT may remove the allocation entirely. Measuring with JMH (covered in lesson 6) is the only reliable way to verify this behaviour.

Practical Memory Sizing Strategy

  1. Profile first — use jmap -histo, VisualVM, or async-profiler to identify what is actually on the heap before tuning flags.
  2. Size the heap from live data set — measure the live set after a full GC; target 2–3× that as your heap maximum to give the GC room to breathe.
  3. Cap Metaspace — always set -XX:MaxMetaspaceSize in production to catch classloader leaks early.
  4. Account for native memory — stack memory + metaspace + direct buffers + JIT code cache all live outside the heap. Total JVM RSS = heap + native. Don't let the container OOM killer find this out the hard way.
Container deployments add a wrinkle. Before Java 10, the JVM read /proc/meminfo for total machine RAM and defaulted -Xmx to 25% of that — ignoring container memory limits. Java 10+ honours cgroup limits via -XX:+UseContainerSupport (on by default since Java 11). Always verify your containerised JVM is reading the right limit with java -XX:+PrintFlagsFinal -version | grep MaxHeapSize.

Summary

The heap is the home of all object instances; its generational structure enables efficient garbage collection. The per-thread stack holds frames, primitives, and references — without touching the heap for value types. Metaspace replaced PermGen and stores class metadata in native memory that must be capped to catch leaks. Understanding where data lives lets you predict allocation pressure, size memory regions correctly, and interpret GC logs intelligently — skills you will apply through the rest of this tutorial.