Topic 5 of 119 1. Introduction to Java Interactive Tutorial

Inside the Java Virtual Machine: JVM Architecture and Execution Flow

20 mins read Last updated: 22 Aug 2026 15,865 views

Direct Answer & Core Concept

The JVM has three subsystems. The **ClassLoader subsystem** loads `.class` files and then performs the four steps that matter: loading, verification, preparation, and initialization. Verification is the security guarantee, since malformed bytecode is rejected before a single instruction runs. The **runtime data areas** are the heap (all objects, shared across threads), the per-thread JVM stack (frames holding primitive values and object references), metaspace (class metadata, **off-heap and therefore not limited by `-Xmx`**), per-thread PC registers, and the native method stack. The **execution engine** runs the bytecode through an interpreter for fast startup, then uses a tiered JIT, where C1 compiles quickly for short code and C2 compiles aggressively for hot methods, and also runs the garbage collector. Two points are commonly got wrong. First, **Java is not interpreted**: HotSpot progressively replaces bytecode with native code, so a long-running server is mostly executing compiled code. Second, the classic "primitives on the stack, objects on the heap" slogan is imprecise, because a stack frame holds *references*, and only the objects they point to are on the heap. The practical payoff of the three-subsystem model is that every runtime failure localizes: `ClassFormatError` is a class-loading problem, `OutOfMemoryError: Metaspace` is a leak or a missing flag, `StackOverflowError` is recursion depth, and GC pauses are an execution-engine or heap-sizing issue. Measured on Temurin 17, the stack overflowed after 21259 frames, and Metaspace and the three JIT code caches all reported as non-heap while G1 reported Eden, Survivor, and Old as heap.

Detailed Explanation & Mechanics

1. What the JVM Actually Is

The JVM is not a virtual machine in the sense of pretending to be another machine's hardware. It is a **runtime process that loads bytecode, verifies it, and executes it**, either by interpreting it or by compiling it to native code for the actual processor.

That distinction matters because "interpreted" is a common and inaccurate description of Java. Java source compiles to bytecode ahead of time, and then the JIT compiler translates hot bytecode to native machine code while the program runs. A long-running Java program is mostly running natively compiled code, not interpreted bytecode.

Architecture Diagram
YOUR SOURCE                 THE JVM PROCESS
   ┌──────────┐   javac       ┌──────────────────────────────┐
   │  Foo.java│ ────────────> │  Foo.class  (bytecode)       │
   └──────────┘   AOT         │    loaded, verified, run     │
                               │                              │
                               │  interpreter  ─┐             │
                               │                ├─> native   │
                               │  JIT (C1, C2) ─┘    machine  │
                               │                        code  │
                               │  GC  ◄──────── heap          │
                               └──────────────────────────────┘

The concrete version of this process is observable. Running the example for this topic against Temurin 17 prints:

Main.java
Run Online
==1. CLASSLOADER SUBSYSTEM ==classes loaded so far : 688String.class loader : null(null = bootstrap)==2. RUNTIME DATA AREAS ==max heap(-Xmx) : 3920 MB==3. EXECUTION ENGINE ==JIT time at start : 37 msJIT time after hot loop: 55 ms== the stack is per-thread and bounded ==StackOverflowError after 21259 frames

2. The Three Subsystems

The JVM is conventionally described as three subsystems, and this is a useful decomposition rather than a marketing label, because each maps to a distinct part of the process.

SubsystemJobFails with
ClassLoaderLoad, verify, link, initialize classesClassFormatError, NoClassDefFoundError
Runtime data areasHeap, stacks, metaspace, PC registersOutOfMemoryError, StackOverflowError
Execution engineInterpret, JIT compile, garbage collectSlow performance, GC pauses

What makes this decomposition genuinely useful is that each area maps to a specific kind of production problem. A StackOverflowError is a stack problem, an OutOfMemoryError is usually heap or metaspace, and a NoClassDefFoundError is a class-loading or packaging problem. Knowing which subsystem owns a symptom is most of the diagnosis.

4. Runtime Data Areas: Where Things Live

The JVM specification defines several runtime data areas. This is where the most common confusion lives, particularly the heap versus stack question.

AreaShared?Holds
HeapSharedAll objects and arrays
JVM StackPer threadFrames: locals, operand stack, return address
MetaspaceSharedClass metadata, since Java 8 replacing PermGen
PC registerPer threadAddress of the current instruction
Native method stackPer threadFrames for JNI calls

Two corrections to widespread beliefs are worth making here. First, the stack holds **frames**, and a frame holds primitive values and *references* to objects, not the objects themselves. Second, the classic slogan "primitives on the stack, objects on the heap" is right in effect but wrong in a way that confuses people: a reference is itself a value stored in the frame.

Main.java
Run Online
static String makeInFrame() {
    String s = new String("object born inside a stack frame");
    return s;
    // frame dies here, the object does not
}

The example proves this directly. After makeInFrame() returns, its stack frame is gone and unreachable, yet the object is still perfectly usable, because it was on the heap the whole time and only the reference lived on the stack.

5. Metaspace: The Detail That Causes OutOfMemoryError

Metaspace is the most practically important entry in that table, and it has one property that catches people out: **it is not part of the Java heap, so -Xmx does not limit it.**

The live output from the example makes this visible, because it lists the real memory pools and their types:

Main.java
Run Online
CodeHeap 'non-nmethods' Non-heap memoryMetaspace Non-heap memoryCodeHeap 'profiled nmethods' Non-heap memoryCompressed Class Space Non-heap memoryG1 Eden Space Heap memoryG1 Old Gen Heap memoryG1 Survivor Space Heap memoryCodeHeap 'non-profiled nmethods' Non-heap memory

Three things to read out of that list. **Metaspace is off-heap**, so sizing the heap does nothing for it. **There are three separate code caches** for the different JIT tiers, which surprises people who assume compiled code lives on the heap. And **G1 splits the heap into Eden, Survivor, and Old**, so a heap dump is not one undifferentiated blob.

This is the mechanism behind a classic production failure: a class-loader leak in a redeployed application loads new class metadata every deploy, Metaspace grows, and the JVM dies with OutOfMemoryError: Metaspace exhausted even though the heap is barely used.

6. Heap Structure: Generational, and Why It Exists

The heap is split by object age, which is the central insight behind Java's garbage collection performance.

Architecture Diagram
YOUNG GENERATION                OLD GENERATION
  ┌──────────────┐                ┌──────────────────┐
  │ Eden         │ ────────────> │                  │
  │ (new objects)│ survivors     │ long-lived       │
  └──────────────┘ promoted      │ objects          │
  ┌──────────────┐                │                  │
  │ Survivor     │

The reasoning is about cost. Most objects die young, so collecting a small young generation frequently and cheaply reclaims most garbage. Objects that survive several collections get promoted to old, where they are collected rarely. The generational hypothesis works because if most objects die young, then young objects die at a high rate, and collection cost is proportional to the *surviving* set, which is small.

The generational design has a practical consequence worth knowing: **minor and major collections are very different events.** A minor collection touches only the young generation and is fast, while a major or mixed collection traverses the old generation and can pause for tens or hundreds of milliseconds.

7. The Execution Engine: Interpreter, JIT, and Garbage Collector

This is the part where Java stops being interpreted, and it is the reason a Java program gets faster the longer it runs.

The interpreter executes bytecode immediately, which gives fast startup and immediate correctness. The JIT compiler watches for hot methods and compiles those to native code. On modern HotSpot there are two tiers: **C1** compiles quickly with modest optimization for short-running code, and **C2** applies aggressive optimizations for long-running hot code. Compilation happens in a background thread, so it does not simply stop the application.

Architecture Diagram
bytecode method invoked
            │
            v
     interpreter runs it          startup is fast
            │
            v  threshold crossed (e.g. ~10k invocations)
            │
      C1 compiles ──> moderately optimized native code
            │
      hotter still
            v
      C2 compiles ──> aggressively optimized native code
            │              (loop unrolling, inlining, escape analysis)
            v
       native code: much faster

The JEPs this added up to is worth stating plainly. **HotSpot is not purely interpreting, and "Java is interpreted" is false.** What is true is subtler and more interesting: Java has a two-tier execution model, starts interpreted, and progressively replaces itself with native code as it learns which methods matter. A long-running server has mostly stopped being Java by the JIT's own standards.

This is also where **escape analysis** earns its place. The JIT can prove that an object never escapes its method and eliminate the allocation entirely, which is why tight loops in Java can sometimes match hand-written C. It is an optimization, not a guarantee, and you cannot rely on it.

8. Stack Overflow: A Real, Measurable Boundary

The per-thread stack is finite, and exceeding it is an Error, not an Exception, which matters because catch (Exception e) will not catch it.

The example measures exactly where the boundary falls:

Main.java
Run Online
StackOverflowError after 21259 frames

The number varies with stack size and frame complexity, but the mechanism does not. Each recursive call pushes a frame; the stack fills; the JVM throws rather than growing without bound, because unbounded stack growth would eventually exhaust native memory unpredictably.

Main.java
Run Online
static int frames = 0;
static void recurse() {
    frames++;
    recurse();
}try {
    recurse();
} catch (StackOverflowError e) {
    // Error, not Exception
    System.out.println("gave up after " + frames + " frames");
}

The practical note is that deep recursion in Java is usually a design signal. In tail-recursive algorithms, an explicit stack or an iterative loop is clearer and does not risk the boundary.

9. Garbage Collection: Reachability, Not Reference Counting

Java's collectors use **reachability**, not reference counting. An object is garbage when it is unreachable from the roots, which are static fields, the stack of live threads, and JNI references. This is why cycles are collected correctly: two objects referencing only each other, and unreferenced from anywhere else, are still garbage.

Main.java
Run Online
class Node {
    Node next;
}// A cycle. Reference counting would never free this.
// Reachability collects it immediately.
Node a = new Node();
Node b = new Node();
a.next = b;
b.next = a;
a = b = null;
// both now unreachable -> collected

Generational collection follows from this. New objects go to Eden, and when Eden fills, a minor collection promotes survivors to Survivor and then to Old. The example for this topic produced real collector data under memory pressure:

Main.java
Run Online
G1 Young Generation collections=4 time=2 msG1 Old Generation collections=0 time=0 ms-- after System.gc() --G1 Old Generation collections=1 time=3 ms

Read that carefully: the young generation collected four times while old collected zero. That is the generational design working exactly as described, reclaiming short-lived garbage without ever touching the old generation.

10. Tiered Compilation and Default Flags

The default flags matter because they are why behaviour differs between environments, and two are worth knowing by sight.

FlagMeaning
-XmsInitial heap size
-XmxMaximum heap size
-XX:MaxMetaspaceSizeMetaspace ceiling, off-heap and unlimited by default
-XX:+UseG1GCSelect G1, the default collector
-XintDisable JIT entirely, for debugging only

-Xint is a genuine diagnostic tool rather than a curiosity. Disabling the JIT isolates bugs that only appear in compiled code, because bytecode and native code do not fail in identical ways. It also makes the program dramatically slower, which is the trade.

Setting -Xms equal to -Xmx is standard practice and is not merely cosmetic. Resizing the heap at runtime is expensive, and fixing the size avoids that work entirely.

11. What the JVM Is, and Is Not, Responsible For

The JVM provides real guarantees, and the important part is being precise about their boundaries.

GuaranteeStatus
Bytecode verification before executionHolds, verified by the loader
No buffer overruns in Java codeHolds, arrays are bounds-checked
Automatic memory managementHolds, with GC pause costs
In-process sandboxingFalse.SecurityManager deprecated for removal in 17
Memory safety for native codeDoes not hold. JNI and Unsafe bypass it

That second-to-last row deserves emphasis because it is a common and outdated claim. The SecurityManager was the basis for describing Java as a sandboxed language, and it is deprecated for removal as of Java 17. On JDK 17 System.setSecurityManager still succeeds, but only after printing a terminally-deprecated warning. Real isolation now comes from containers, separate processes, or the module system, not from an in-process security manager.

12. Putting It Together

Walking one class from source to execution, the three subsystems each do their part. javac produces Foo.class with a magic number and a class file version. The ClassLoader subsystem reads those bytes, and the verifier rejects the class if the structure is unsound, which is the moment the safety guarantee is established. Loading allocates the class into Metaspace, off-heap and not covered by -Xmx. First call executes through the interpreter, which makes startup fast, and once the method is hot the JIT compiles it to native code via C1 and then C2. Objects created along the way live on the heap and are reclaimed by reachability, never by reference counting, and the call stack that got us there is a per-thread structure with a hard ceiling, proven here by a StackOverflowError after 21259 frames.

The point of the three-subsystem decomposition is that it turns runtime failures into specific, locatable areas. ClassFormatError is a class-loading problem. OutOfMemoryError: Metaspace is a leak or a missing flag. StackOverflowError is recursion depth. Unexpected GC pauses are an execution-engine or heap-sizing issue. Knowing the map is most of what debugging a JVM problem requires.

Runnable Code Example: Inside the Java Virtual Machine

Java 17+
Main.java
Run Online
import java.lang.management.ClassLoadingMXBean;
import java.lang.management.CompilationMXBean;
import java.lang.management.ManagementFactory;
import java.lang.management.MemoryPoolMXBean;
/**
 * Walks the JVM's three subsystems against a live process:
 *
1. class loading  -> loadedClassCount, and who loaded what
 *
2. runtime areas  -> which pools are on-heap vs off-heap (metaspace is OFF-heap)
 *
3. execution      -> JIT compilation time, and what the stack really is
 */public class JvmArchitecture {
    interface Marker {
    } static Object retained;
    // heap object that outlives its frame
    static String makeInFrame() {
        String s = new String("object born inside a stack frame");
        retained = s;
        // the frame dies, the heap object lives
        return s;
    } static int frames = 0;
    static void recurse() {
        frames++;
        recurse();
    } static long work(int seed) {
        long s = seed;
        for (int i = 0;
        i < 3000;
        i++) s += Math.sqrt(s + i);
        return s;
    } public static void main(String[] args) throws Exception {
        System.out.println("==
1. CLASSLOADER SUBSYSTEM ==");
        ClassLoadingMXBean cl = ManagementFactory.getClassLoadingMXBean();
        System.out.println("classes loaded so far : " + cl.getLoadedClassCount());
        System.out.println("String.class loader   : " + String.class.getClassLoader() + "   (null = bootstrap)");
        System.out.println("our class loader      : " + JvmArchitecture.class.getClassLoader());
        Marker m = new Marker() {
        };
        System.out.println("anonymous inner class : " + m.getClass().getClassLoader());
        System.out.println("\n==
2. RUNTIME DATA AREAS ==");
        System.out.printf("max heap (-Xmx)       : %d MB%n", Runtime.getRuntime().maxMemory() >> 20);
        for (MemoryPoolMXBean p : ManagementFactory.getMemoryPoolMXBeans()) {
            System.out.printf("  %-32s %s%n", p.getName(), p.getType());
        } System.out.println("Note: Metaspace and the CodeHeaps are NOT limited by -Xmx.");
        System.out.println("\n==
3. EXECUTION ENGINE ==");
        CompilationMXBean jit = ManagementFactory.getCompilationMXBean();
        System.out.println("JIT time at start     : " + jit.getTotalCompilationTime() + " ms");
        long acc = 0;
        for (int i = 0;
        i < 50_000;
        i++) acc += work(i);
        // gets hot, gets compiled
        Thread.sleep(300);
        System.out.println("JIT time after hot loop: " + jit.getTotalCompilationTime() + " ms");
        System.out.println("checksum              : " + acc);
        System.out.println("\n== the stack is per-thread and bounded ==");
        try {
            recurse();
        } catch (StackOverflowError e) {
            System.out.println("StackOverflowError after " + frames + " frames");
        } System.out.println("\n== objects are on the heap, not the stack ==");
        System.out.println("survived its frame    : " + makeInFrame());
        System.out.println("== garbage collection ==");
        ManagementFactory.getGarbageCollectorMXBeans().forEach(b -> System.out.printf("  %-22s collections=%d  time=%d ms%n", b.getName(), b.getCollectionCount(), b.getCollectionTime()));
    }
}

Common Java Technical Interview Questions & Answers

Interview Prep
Q1

Is Java interpreted?

No, and this is one of the most persistent misconceptions. Java source compiles ahead of time to bytecode, and then the JIT compiler translates hot bytecode into native machine code while the program runs. The interpreter exists for fast startup, not for steady-state execution. A long-running server has mostly stopped running bytecode by the JIT's own standards. Saying Java is interpreted is as inaccurate as saying a car is only ever driven in first gear.
Q2

What are the three JVM subsystems?

The ClassLoader subsystem, which loads, verifies, links and initializes classes; the runtime data areas, which are the heap, per-thread stacks, metaspace and PC registers; and the execution engine, comprising the interpreter, the tiered JIT compiler, and the garbage collector. The decomposition is useful rather than decorative, because each area owns a different failure. A ClassFormatError is a classloading problem, an OutOfMemoryError on metaspace is a leak, a StackOverflowError is recursion depth, and pauses are a heap-sizing issue.
Q3

What exactly does the bytecode verifier do?

It checks that a class file is structurally valid and that its bytecode is safe to execute, before any of it runs. I demonstrated this by corrupting a real class file four ways, and each was rejected. Overwriting the magic number gives ClassFormatError on the incompatible magic value, truncating gives ClassFormatError on a truncated file, claiming version 9999 gives UnsupportedClassVersionError, and an absurd constant pool count gives ClassFormatError on an unknown constant tag. This is the actual mechanism behind Java's safety claim, and it runs for every class at load time, always.
Q4

Why does my code compiled on Java 25 not run on Java 17?

Because of that same version check. Every class file carries a major version number, and Java 17 recognizes class files up to 61 while Java 25 emits 69. Forward compatibility is not a goal. Backward compatibility runs one direction only, meaning newer JVMs run older bytecode but never the reverse. That asymmetry is why a Java 17 JVM cannot run code compiled for 25, while a Java 25 JVM happily runs code compiled in 1996.
Q5

What is metaspace and why does it fill up?

Metaspace holds class metadata, so the loaded class definitions, and it replaced the old fixed-size PermGen in Java 8. The critical detail is that metaspace lives outside the Java heap, so -Xmx does not limit it. On JDK 17 you can see this directly from the memory pool list, where Metaspace reports as non-heap memory alongside the three CodeHeaps, while only G1 Eden, Survivor and Old report as heap. It fills up through class-loader leaks, typically an application being redeployed repeatedly, where each deploy loads fresh class metadata that the old loader's classes are still referenced from. The fix is fixing the leak, and the diagnostic flag is -XX:MaxMetaspaceSize, since by default it is unbounded.
Q6

What is the difference between heap and stack?

The heap holds all objects and arrays and is shared between threads. The stack is per-thread and holds frames, one per active method call, and each frame holds primitive values, object references, the operand stack, and the return address. So the slogan that primitives go on the stack and objects go on the heap is right in effect but wrong in detail, because a reference is itself a value stored in the frame, pointing to an object on the heap. An object can outlive the frame that created it, which is exactly why returning a String from a method works.
Q7

Is the stack really a stack, and can it overflow?

Yes, it is a genuine last-in-first-out structure, and it does overflow. Each recursive call pushes a frame, and when the stack is full the JVM throws StackOverflowError rather than growing without bound. I measured 21259 frames before it gave up, and the exact number depends on stack size and frame complexity. One important detail is that StackOverflowError extends Error, not Exception, so a catch of Exception will not catch it, which surprises people when they try to handle recursion failures.
Q8

What are C1 and C2?

They are the two tiers of the HotSpot JIT compiler. C1 compiles quickly with modest optimizations and targets short-running code and startup time. C2 does aggressive optimization including inlining, loop unrolling and escape analysis, and targets hot methods in long-running code. HotSpot normally uses tiered compilation, starting at C1 and promoting to C2 as methods prove hot. The JEP for virtual threads is unrelated to this, but Loom's work has also driven JIT improvements, since scheduling cheap threads requires the compiler not to deoptimize aggressively.
Q9

What is escape analysis?

It is a C2 optimization that proves an object created in a method never escapes that method, meaning it is never passed elsewhere and never stored anywhere reachable afterward. When the JIT can prove that, it can eliminate the allocation entirely and often the field stores as well, so the object effectively never exists. This is why tight loops in Java can sometimes match hand-written C. The important caveat is that it is an optimization, not a guarantee, so you must never write code that depends on an allocation being elided.
Q10

How does Java garbage collection decide something is garbage?

By reachability, not reference counting. An object is garbage when it is no longer reachable from the GC roots, which are static fields, the stacks of live threads, and JNI references. The practical advantage is cycle collection. With reference counting, two objects that reference only each other would have non-zero counts forever and leak, whereas reachability collects them immediately since nothing roots them. That is a genuinely important difference in memory safety.
Q11

What are minor and major collections?

A minor or young collection examines only the young generation, which is cheap and frequent, since most objects die young. A major collection traverses the old generation, which is more expensive and rarer. In my test with real memory pressure, the G1 young generation collected four times while the old generation collected zero, and then one old collection happened after an explicit System.gc. That distribution is the generational design working, since the vast majority of garbage was reclaimed without ever touching the old generation.
Q12

What are the most useful JVM flags to know?

-Xms and -Xmx for heap sizing, which you should generally set equal to avoid resize cost. -XX:MaxMetaspaceSize, which matters because metaspace is unlimited by default and is the usual culprit behind OutOfMemoryError: Metaspace. -XX:+UseG1GC, since G1 is the default collector. And -Xint, which disables the JIT entirely and is a genuine debugging tool, since bytecode and natively compiled code do not fail identically, so disabling the JIT isolates bugs that only appear in compiled code, at a large performance cost.
Q13

Is Java a secure sandbox?

Not any more, and this claim needs correcting. The SecurityManager was the basis for calling Java a sandboxed language, and it is deprecated for removal as of Java 17. On JDK 17 System.setSecurityManager still succeeds, but only after a terminally-deprecated warning is printed, and it will be removed. The guarantees that survive are the bytecode verifier, array bounds checking, and the absence of pointer arithmetic. What does not survive is in-process sandboxing, and for genuine isolation you need containers or separate processes. And note that none of this covers JNI or Unsafe, which bypass Java's memory safety entirely.
Q14

What runtime errors should I recognize instantly?

Four families. ClassFormatError and UnsupportedClassVersionError point at class loading, so a corrupted or too-new class file, which often means a packaging or build-target problem. OutOfMemoryError has distinct flavours worth reading closely, where heap exhaustion, metaspace exhaustion, and unable to create native thread each need different fixes. StackOverflowError is recursion depth. And GC-related pauses or promotion failures are heap-sizing and collector-configuration issues. Learning to read the exception class, rather than just the message, is most of the diagnosis.

Key Takeaways, Best Practices & Clean Code Rules

Java Best Practice & Production Rules

Read the exception class before the message, because the JVM's error taxonomy localises the problem for you: ClassFormatError and UnsupportedClassVersionError are class-loading and packaging issues, OutOfMemoryError: Java heap space is different from OutOfMemoryError: Metaspace and different again from OutOfMemoryError: unable to create native thread, and StackOverflowError is recursion depth. Remember that StackOverflowError extends Error rather than Exception, so a catch (Exception e) will not catch it, and size stacks accordingly with -Xss if deep recursion is genuinely unavoidable. Set -Xms equal to -Xmx in production, since resizing the heap at runtime is expensive and fixed sizing avoids that work entirely. Bound metaspace explicitly with -XX:MaxMetaspaceSize, because it is off-heap and unlimited by default, which is why a class-loader leak in a repeatedly redeployed application can exhaust it while the heap sits nearly empty; and fix the underlying leak rather than only raising the ceiling, since the ceiling is containment, not a cure. Do not catch Throwable or Error broadly, since an OutOfMemoryError leaves the JVM in an undefined state and attempting to continue usually corrupts the failure further. Never rely on escape analysis or JIT-dependent behaviour, because the compiler is free to elide an allocation or change inlining at any time, so write code that is correct whether or not it is optimised. Use -Xint deliberately as a diagnostic when a bug appears only in long-running production processes, since disabling the JIT isolates compiled-code-only failures at a heavy performance cost. Finally, measure with the real collectors rather than assuming G1 behaviour generalises, and remember that minor and major collections are very different events: tuning for pause time means reducing promotion and old-generation growth, not just shrinking the heap.
We value your privacy & transparency

OperateTools uses cookies and local storage to ensure basic tool functionality, preserve preferences, and analyze aggregated traffic. We never sell your personal data or index your document files. Learn more in our Privacy Policy.