Java

JVM Memory Areas and Class Loaders: Heap, Stack, Metaspace, and Execution

Executive Summary

JVM memory areas, class loaders, and the JIT together explain what happens when java Main runs. First, the JVM loads Main.class through a hierarchy of class loaders. The bootstrap loader handles the JDK core, and the platform loader handles JDK modules like java.sql. Then the application loader takes your classpath or module path code. Each loader delegates upward, so core types load exactly once. Loading runs three phases: load the bytes, link with verification, and initialize the static state. Also, a class loaded by two different loaders is two different classes, the root of the classic ClassCastException mystery.

At runtime, data lives in five JVM memory areas. The heap, shared and GC-managed, holds every object and array. Per-thread stacks hold frames of locals, roughly a megabyte per platform thread and heap-stored for virtual threads. Metaspace, in native memory, holds class metadata. Finally, there are the PC register and native method stacks. Meanwhile, execution starts interpreted, and the JIT compiles the hot methods to native code after they prove hot. That is why Java programs warm up. The tools that watch all of this ship with the JDK. Use jstat for live heap stats, jcmd for commands and thread dumps, and jmap for heap histograms. The practical rules are short. Read the three OutOfMemoryError families by area. Size -Xmx below the container limit, never below the evidence of the profiling article. Finally, treat custom class loaders as framework territory.

What Happens When You Run java

The moment the setup article’s java command executes, the machine runs the same pipeline for every class your program needs, in order:

  java Main
     |
     |  1. LOAD: find Main.class bytes, wrap them in a Class object in metaspace
     v
  2. LINK: verify the bytecode is safe, prepare static fields, resolve references
     |
     |  3. INITIALIZE: run static initializers, top to bottom
     v
  4. EXECUTE: main() starts on the "main" thread, interpreted first,
     |          JIT-compiled after a method proves hot
     v
  classes loaded lazily, on first use, the whole run long

Verification deserves one paragraph because it is the JVM’s security story. Before any bytecode runs, the verifier checks that it is well-formed and that stack usage matches what the code claims. It also checks that types line up and that access rules are respected. This is why a corrupted or hand-crafted class file fails at load. It is also the machinery the serialization article’s warnings about bytecode trusted from strangers were really about. For the compiler’s side of the pipeline, from source to bytecode, and a broader tour of the JIT tiers, see Understanding JVM Internals: From Source Code to Runtime.

Class Loaders: The Delegation Hierarchy

In fact, every class has a loader, and the loaders form a chain that each asks its parent first. The hierarchy, printable from any program:

class ShowLoaders {
    public static void main(String[] args) {
        System.out.println(String.class.getClassLoader());
        // null: the bootstrap loader, C++ code, loads java.base's core types

        System.out.println(javax.sql.DataSource.class.getClassLoader());
        // jdk.internal.loader.ClassLoaders$PlatformClassLoader
        // the platform loader: JDK modules like java.sql, java.xml

        System.out.println(ShowLoaders.class.getClassLoader());
        // jdk.internal.loader.ClassLoaders$AppClassLoader
        // the application loader: YOUR classpath or module path classes
    }
}

The delegation rule is the design. When asked for a class, a loader first asks its parent. It loads the class itself only if the parent cannot. That is why your own class named String never wins. The bootstrap loader answers java.lang.String before the JVM ever consults your classpath. As a result, the security-sensitive core stays the JDK’s. The modules article’s module path plugs into exactly this machinery: an application loader that reads module-info and resolves from a module graph instead of scanning a classpath. Similarly, the reflection article’s Class.forName(“…”) is you asking some loader to run this pipeline on demand.

One consequence separates beginners from production engineers: identity is loader plus class name. For instance, the same class file loaded by two different loaders produces two distinct classes, mutually cast-incompatible. This sounds exotic until you meet it in the wild. For example, application servers and plugin systems load the same library in two isolated loaders. They then fail with a ClassCastException for a type that looks identical. The fix is always the same: one loader owns shared API classes.

JVM Memory Areas: Where Your Data Actually Lives

The JVM specification defines runtime data areas, and each one produces a distinct failure when it runs out. The map:

Area Holds Ownership When it fills
Heap Every object and array, always Shared by all threads, GC-managed OutOfMemoryError: Java heap space
Java stack Frames: local variables, partial results, one frame per method call One per thread, ~1MB default per platform thread StackOverflowError
Metaspace Class metadata: the Class objects, method bytecode, field layouts Shared, allocated in native memory OutOfMemoryError: Metaspace
PC register Which instruction the thread is on One per thread Never fills alone
Native stacks Frames for native, JNI, code One per thread Native OOM or crash

The two errors you can produce on demand, and should once, each name their area:

// StackOverflowError: frames consumed the stack - too deep, not too big
void recurse() { recurse(); }          // a million frames later: StackOverflowError

// OutOfMemoryError: Java heap space - objects consumed the heap
var keep = new ArrayList<byte[]>();
while (true) { keep.add(new byte[1_000_000]); }   // reachable, so GC cannot reclaim

Read the difference carefully, because misreading it wastes whole afternoons. StackOverflowError means the call depth outran the frame budget, an infinite recursion or an accidental cycle. In contrast, heap exhaustion means reachable objects outran the heap. The two bugs look nothing alike in a stack trace. The table also quietly answers two earlier mysteries. The first is the threads article’s platform-thread cost, a megabyte of stack reserved per thread. The second is the virtual threads article’s cheapness: a virtual thread’s stack is heap-stored, small, and grows on demand. That is exactly why a million of them fit where a few thousand platform threads stopped.

Execution: Interpreter, JIT, and Why Java Warms Up

To begin, javac compiles your source to bytecode, a portable instruction set the JVM executes. For the first runs of any method, an interpreter walks those instructions one by one. Meanwhile, a second engine watches: the JIT, or just-in-time compiler. It counts invocations and loop iterations. When a method gets hot, the JIT compiles it to optimized native code for the actual CPU. The practical consequences you can observe:

javac Main.java
javap -c Main.class          // -c: disassemble, see the bytecode javac produced

//  public static void main(java.lang.String[]);
//    Code:
//       0: getstatic     #7    // Field System.out
//       3: ldc           #13   // String hello
//       5: invokevirtual #19   // println(String)
//       8: return

Three observable facts follow from this design, and all three matter in the profiling article. First, Java programs warm up: the first thousand runs of a hot path run slower than the next million. So benchmarks need warm-up phases, and production services show better latency after traffic arrives. Second, JIT-optimized code can be startlingly fast. It inlines small methods and eliminates dead work until hot Java rivals C for sustained compute. Third, a pause or deoptimization can occur when the JVM changes a method’s compiled form mid-flight. The garbage collection article covers that properly.

Watching the Machine: The JDK’s Own Instruments

Everything above is observable with tools that ship in the JDK’s bin directory. Among them, the jcmd entry point is the pocketknife:

jps -l                       // which JVMs are running? get the pid

jcmd <pid> VM.uptime          // how long has it run
jcmd <pid> Thread.print       // the thread dump: Part 5's states, live
jcmd <pid> GC.class_histogram // the heap's classes by instance count
jcmd <pid> VM.flags            // every flag the JVM started with

jstat -gc <pid> 1000           // heap stats every second, live
jmap -histo <pid>              // object histogram: what owns the heap

Thread.print deserves the emphasis. It is the thread dump the locks and executor articles kept referencing: every thread with its state from the lifecycle article. For example, BLOCKED here means a lock, and WAITING there means a join. Later, the profiling article turns it from a firehose into a diagnosis. The ClassLoader API is the same machinery visible from inside the program, getClassLoader() on any Class.

How Real Systems Do This

Production JVMs run on flags and container limits. Every deployment shows two settings: heap sizing with -Xmx and -Xms, and container awareness, which has quietly become mandatory. Modern JVMs respect cgroup memory limits, but the flag has to fit. For example, -Xmx2g inside a container limited to 1.5 gigabytes is a JVM that cannot OOM gracefully. That is because the container runtime kills it first, with exit code 137 and no Java stack trace anywhere. The metaspace story has its own production history. It replaced the old PermGen in Java 8 and moves class metadata to native memory that grows by default. Also, the flags that bound it, such as -XX:MaxMetaspaceSize, exist for the workloads that generate classes at runtime.

The metaspace leak I debugged came from a code-generation library: a proxy framework that created a new class per interface configuration. It sat behind a feature that let customers define their own integration types. Every new configuration meant a new generated class and a permanent metaspace resident. That happened because generated classes unload only when their loader is unreachable, and the framework reused one loader for everything.

The service ran beautifully for weeks, then degraded and died with OutOfMemoryError: Metaspace. Afterward, the GC.class_histogram showed six figures of generated class names. The fix was loader lifecycle: discardable child loaders per tenant whose classes unload with them. The fix also added MaxMetaspaceSize as a guardrail, so the failure mode became a clear error instead of a slow death. The lesson generalizes past metaspace. Every OutOfMemoryError family names its area, and the histogram tells you who filled it. The fix is almost always a lifecycle question, not a sizing question: who owns this memory, and when does it end?

Decision Framework

  1. Are you choosing a heap size? Start from the container or machine limit, and leave roughly a quarter for metaspace, stacks, and native overhead. Never let -Xmx exceed the container limit.
  2. Are you seeing StackOverflowError? It is depth, not size. Find the recursion or the accidental cycle in the stack trace, and only consider -Xss when deep recursion is genuinely the design.
  3. Are you seeing an OutOfMemoryError? Read its family first: Java heap space, Metaspace, or GC overhead limit. Each points at a different area and a different fix.
  4. Is a class identity mystery, ClassCastException for seemingly identical types, on the wall? Two loaders loaded one name: trace getClassLoader() on both sides and move the shared type to the parent loader.
  5. Are you benchmarking? Respect warm-up: measure after the JIT has compiled the hot paths. Otherwise, your numbers measure the interpreter, not your code.
  6. Are you tempted to write a custom class loader? Stop unless you are building a framework’s isolation, plugins, or hot reload. After all, delegation and visibility bugs are subtle, and the application loader is almost always enough.
  7. Do you need to see inside a running JVM? jcmd and jstat first, both live and safe, and heap dumps when the histogram is not enough.
  8. Are you about to tune a flag from a blog post? The profiling article’s rule applies here first: no tuning without a measurement that says the default lost.

When NOT to Use This

  • Do not tune memory flags speculatively. The defaults are measured work from a large engineering effort, and evidence, not folklore, is the bar for changing them.
  • Do not set -Xmx larger than the container limit, ever. The result is an unkillable-looking 137 exit with no Java diagnostics, the worst failure mode available.
  • Do not write custom class loaders for application logic. They are framework machinery, and the delegation model punishes casual use with visibility bugs.
  • Do not chase JIT behavior in application code. Micro-optimizations the JIT already performs, like manual inlining and loop unrolling, mostly cost readability and gain nothing measurable.
  • Do not diagnose GC problems from this article alone. The next article covers collection itself, and heap histograms without it are half a diagnosis.

Common Mistakes

  • Confusing the two exhaustion errors. StackOverflowError is unbounded depth, while heap OOM is unbounded reachability. The fixes are a termination condition and a reference audit respectively.
  • Assuming a bigger heap always helps. It delays the failure and lengthens collections. Worse, if the real bug is a leak, it just moves the wall further out.
  • Ignoring metaspace because “classes are small”: one class is small, but a code generator producing thousands per day is a native-memory leak with a slow fuse.
  • Setting -Xss to hide a stack overflow. The recursion is the bug. A bigger stack only turns a fast crash into a slower one with the same root cause.
  • Believing class identity is name-only. Identity is loader plus name, so two loaders means two classes no matter how identical the files look.
  • Benchmarking without warm-up. The first measurements describe the interpreter and the JIT’s cold paths, and they will mislead you about steady-state performance.
  • Reading a thread dump as noise. BLOCKED means a lock, and WAITING means a join or a pool. These states are the Part 5 vocabulary appearing in production.

Key Takeaways

  • Running a class is a pipeline: load the bytes, link with verification, initialize static state, then execute. Every class the program touches takes that pipeline lazily.
  • Class loaders delegate upward: bootstrap for the core, platform for JDK modules, application for your code. Also, a class loaded by two loaders is two classes.
  • Data lives in five JVM memory areas: shared heap for objects, per-thread stacks for frames, metaspace for class metadata, plus the PC register and native stacks.
  • Each area fails with its own signature: StackOverflowError for depth, heap space OOM for reachability, Metaspace OOM for class overload.
  • Execution is interpreted first, JIT-compiled when hot: Java warms up, hot paths run near-native, and benchmarks must respect the warm-up.
  • Virtual thread stacks live on the heap. That is why a million virtual threads fit where thousands of platform threads stopped.
  • jcmd, jstat, and jmap watch the live machine. Thread dumps, heap stats, and histograms are one command away, no agents required.
  • Every memory problem is a lifecycle question first: who owns this memory and when does it end, before how much do we have.

FAQ

What are the main parts of the JVM?

Three groups. The class loading subsystem, with bootstrap, platform, and application loaders using delegation, prepares code. The runtime data areas, heap, stacks, metaspace, PC registers, and native stacks, hold it. Finally, the execution engine, interpreter plus JIT, runs it. HotSpot is the standard implementation of the JVM specification.

What are the memory areas in the JVM?

There are five runtime data areas. The heap, shared by all threads, holds every object and array. Each thread gets one Java stack, holding frames of local variables. Metaspace, in native memory, holds class metadata. Finally, there are the per-thread PC register and native method stacks. Each fills with its own error signature, StackOverflowError, heap space OOM, or Metaspace OOM.

What is class loading in Java?

The pipeline that turns a .class file into runnable code. It loads the bytes into a Class object, links with bytecode verification, and initializes static state. Loaders delegate to parents, bootstrap for the JDK core, platform for JDK modules, application for your code, and a class’s identity is its loader plus its name.

What is metaspace in Java?

The native-memory area that stores class metadata, introduced in Java 8 to replace PermGen. It holds Class objects, method bytecode, and field layouts. It grows by default and is bounded with -XX:MaxMetaspaceSize, and it leaks when code generates classes whose loaders never become unreachable.

What does the JIT compiler do in Java?

It watches the interpreter run and compiles hot methods, those with high invocation or loop counts, into optimized native code for the actual CPU. Along the way, it inlines small methods and eliminates dead work. That is why Java programs warm up: steady-state performance arrives after the hot paths prove hot.

Conclusion

You have seen the whole machine now. Loaders prepare code with a delegation hierarchy and an identity rule. Five JVM memory areas each have their own failure signature. Finally, an execution engine starts interpreting and finishes compiling. This article retired several mysteries: stack versus heap, thread cost, warm-up, and the thread dump’s vocabulary. The next article builds on the same ones.

The next article covers garbage collection. It explains how the JVM decides your objects are dead, why generations and collectors exist, and what a GC pause actually is. It also shows how to read collection logs and choose between Serial, Parallel, G1, and ZGC instead of guessing.

Open the hood once, and every crash message becomes an address.

Last updated on 1 September 2026.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *