DEV Community

Cover image for Your CPU Never Executes Java. The JVM Rewrites It at Runtime.
S M Tahosin
S M Tahosin

Posted on

Your CPU Never Executes Java. The JVM Rewrites It at Runtime.

You write System.out.println("Hello World"); and hit Run.

Something happens. Text appears on your screen. You smile. You move on.

But have you ever stopped and asked yourself... what actually happened between you pressing Run and that text showing up? Because I promise you, there is a ridiculous amount of machinery working behind the scenes. And understanding that machinery is the difference between a coder who writes Java and a developer who truly gets Java.

I spent two weeks going through the OpenJDK source code, reading JVM specifications, running bytecode experiments, and breaking things on purpose. What I found was fascinating, and today I want to walk you through all of it. No boring textbook stuff. Just real, practical, "oh wait, THAT is what is happening?" kind of learning.

Let's open the hood on the Java Virtual Machine.


Wait, Java Does NOT Run Your Code Directly?

Here is the first thing that surprises most beginners. When you write Java code and click Run, the Java compiler does NOT turn your code into machine code. Not directly, anyway.

Instead, the javac compiler converts your .java file into something called bytecode, stored in a .class file. This bytecode is a set of instructions that no physical computer on Earth can execute directly.

So who runs it?

The JVM (Java Virtual Machine). It is a virtual computer that lives inside your real computer. It reads the bytecode, understands it, and figures out how to run it on whatever machine you are using. Windows, Mac, Linux... the JVM handles the translation.

This is why Java's famous tagline is "Write Once, Run Anywhere."

Your Code (.java)
       |
       v
  javac compiler
       |
       v
  Bytecode (.class)
       |
       v
  JVM interprets/compiles
       |
       v
  Your CPU executes it
Enter fullscreen mode Exit fullscreen mode

Think of it like this. You write a letter in English. A translator (javac) converts it into a universal language (bytecode). Then a local interpreter (JVM) reads that universal language and speaks it in whatever local language (machine code) your computer understands.

The JVM Architecture showing how Java source code flows through the compiler into bytecode and then into the JVM for execution


The Three Pillars Inside the JVM

The JVM is not just one thing. It is made up of three major components working together:

1. The Class Loader

Before your code can run, the JVM needs to load it into memory. That is the Class Loader's job.

It works in three phases:

  • Loading: Finds the .class file and reads the bytecode
  • Linking: Verifies the bytecode is valid (no hacks!), allocates memory for static variables, and resolves symbolic references
  • Initialization: Runs static blocks and assigns values to static variables

Here is something cool. The Class Loader does not load ALL your classes at startup. It loads them on demand, only when your code actually uses them. This is called lazy loading, and it keeps your program starting up fast.

// This class is NOT loaded until this line actually executes
MyHeavyClass obj = new MyHeavyClass();

// If this line is inside an if-block that never runs,
// the class never gets loaded at all!
Enter fullscreen mode Exit fullscreen mode

2. Runtime Data Areas (Stack and Heap)

This is where your program's data actually lives during execution. The JVM divides memory into several areas, but the two big ones you need to understand are the Stack and the Heap.

3. The Execution Engine

This is the brain that actually runs your bytecode. It has an Interpreter, a JIT Compiler, and a Garbage Collector. We will dig into all of these.

But first, let's talk about the Stack and the Heap. Because if you do not understand these two, you will keep writing bugs without knowing why.


Stack vs Heap: Where Your Data Actually Lives

This is the concept that separates beginners from intermediate Java developers. I am going to break it down in the simplest way I can.

Stack vs Heap memory diagram showing how stack frames contain local variables and references, while heap contains the actual objects

The Stack: Your Method's Personal Workspace

Every time you call a method, the JVM creates a new stack frame and pushes it onto the Stack. This frame holds:

  • Local variables (primitives like int, boolean, char)
  • References to objects (just the address, not the object itself!)
  • The return address (where to go back after the method finishes)

When the method finishes? The frame gets popped off. Gone. Memory freed instantly.

public class StackDemo {
    public static void main(String[] args) {
        int result = add(5, 3);  // main() frame is on the stack
        System.out.println(result);
    }

    static int add(int a, int b) {  // add() frame pushed on top
        int sum = a + b;  // sum lives in add()'s stack frame
        return sum;  // add() frame gets popped, sum disappears
    }
}
Enter fullscreen mode Exit fullscreen mode

Here is what the Stack looks like during execution:

|                    |
|  add() frame       |  <-- top (current method)
|    a = 5           |
|    b = 3           |
|    sum = 8         |
|--------------------|
|  main() frame      |  <-- waiting for add() to finish
|    result = ?      |
|    args = ref      |
|--------------------|
Enter fullscreen mode Exit fullscreen mode

When add() returns, its frame vanishes:

|                    |
|  main() frame      |  <-- now this is the top
|    result = 8      |
|    args = ref      |
|--------------------|
Enter fullscreen mode Exit fullscreen mode

The Heap: Where Objects Actually Live

Every time you use the new keyword, the JVM creates an object on the Heap.

Student s = new Student("Tahosin", 22);
Enter fullscreen mode Exit fullscreen mode

What happens here? Two things:

  1. The Student object with name="Tahosin" and age=22 is created on the Heap
  2. The variable s on the Stack stores the memory address (reference) of that object

The variable s does NOT contain the Student. It contains something like 0x7f3a2b which is the address where the Student object lives in the Heap.

public class HeapDemo {
    public static void main(String[] args) {
        Student s1 = new Student("Alice");
        Student s2 = new Student("Bob");
        Student s3 = s1;  // s3 points to the SAME object as s1

        System.out.println(s1 == s3);  // true  (same reference)
        System.out.println(s1 == s2);  // false (different objects)
    }
}
Enter fullscreen mode Exit fullscreen mode

Quick Decision Table

What you created Where it lives When it dies
int x = 10; Stack When the method returns
new Student() Heap When Garbage Collector cleans it
String name = "hi"; Heap (String Pool) When no references remain
Method parameters Stack When the method returns
Instance variables Heap (inside the object) When the object is garbage collected

The StackOverflowError You Keep Getting

Now you know why this happens:

public static void infinite() {
    infinite();  // Each call adds a new frame to the stack
}
// Eventually the stack runs out of space = StackOverflowError!
Enter fullscreen mode Exit fullscreen mode

Every recursive call pushes a new frame. The Stack has a fixed size. Call too many methods without returning, and BOOM. Now you actually understand the error instead of just googling it.


How Java Cleans Up After You: Garbage Collection

In C or C++, you have to manually free memory when you are done with it. Forget to do it? Memory leak. Do it wrong? Segfault. Your program crashes, your weekend is ruined.

Java says "nah, I got this" and handles it automatically through Garbage Collection (GC).

Garbage Collection diagram showing Young Generation (Eden, Survivor spaces), Old Generation, and the friendly robot janitor sweeping away unused objects

How Does the GC Know What to Clean?

Simple rule: if an object on the Heap has no references pointing to it from the Stack (or from other reachable objects), it is garbage. The GC will eventually destroy it and free the memory.

public void createGarbage() {
    Student s = new Student("Alice");  // Object created on heap
    s = null;  // Reference removed. The Student object is now garbage!
    // GC will clean it up at some point
}
Enter fullscreen mode Exit fullscreen mode

The Generational Approach

The JVM does not just have one big heap. It splits the heap into generations based on a simple observation: most objects die young.

Think about it. A temporary String you create inside a loop? Dead within milliseconds. A database connection pool? That lives for hours. Treating them the same would be wasteful.

So the Heap is divided into:

Young Generation (for short-lived objects):

  • Eden Space: Brand new objects are born here
  • Survivor Space S0 and S1: Objects that survive one GC cycle get moved here

Old Generation (for long-lived objects):

  • Objects that survive many GC cycles get "promoted" here

Here is the flow:

New object created
       |
       v
   Eden Space (Young Gen)
       |
       | Minor GC runs
       v
   Survived? ----No----> DELETED
       |
      Yes
       |
       v
   Survivor Space (S0/S1)
       |
       | Survived many cycles?
       v
      Yes -----> Old Generation
                       |
                       | Major GC runs (less frequent, more expensive)
                       v
                  Still alive? --No--> DELETED
Enter fullscreen mode Exit fullscreen mode

Minor GC vs Major GC

Type Where How often Speed
Minor GC Young Generation Very frequent Fast (milliseconds)
Major GC Old Generation Rare Slow (can pause your app!)

This is why you see performance tips like "avoid creating unnecessary objects in loops." Every object you create ends up in Eden, triggers more Minor GCs, and slows things down.

// BAD: Creates 10,000 String objects in the loop
for (int i = 0; i < 10000; i++) {
    String s = "Item " + i;  // Each + creates a new String
    process(s);
}

// BETTER: Reuse a StringBuilder
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
    sb.setLength(0);  // Reset without creating new object
    sb.append("Item ").append(i);
    process(sb.toString());
}
Enter fullscreen mode Exit fullscreen mode

The JIT Compiler: Java's Secret Speed Weapon

Here is something that blows people's minds. Java can actually run faster than C in certain situations. How? Because of the JIT (Just-In-Time) Compiler.

JIT Compilation diagram showing how the Interpreter runs bytecode slowly at first, then the Hot Spot Detector identifies frequently called methods, and the JIT Compiler converts them to fast native machine code

The Problem with Pure Interpretation

When the JVM first starts running your bytecode, it uses an Interpreter. The interpreter reads each bytecode instruction one by one and executes it. This works, but it is slow. Way slower than running native machine code directly.

The JIT Solution

The JVM watches your code while it runs. It counts how many times each method gets called. When a method crosses a certain threshold (around 10,000 calls by default), the JVM says "hey, this method is HOT. It gets called all the time. Let me compile it to native machine code so it runs directly on the CPU."

This compiled native code gets cached. Next time that method is called, the JVM skips the interpreter entirely and runs the native code at full CPU speed.

// This method gets called 1 million times in a loop
public static int fibonacci(int n) {
    if (n <= 1) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

// First few thousand calls: interpreted (slow)
// After ~10,000 calls: JIT-compiled to native code (FAST)
// The JIT even optimizes things like inlining the recursive calls!
Enter fullscreen mode Exit fullscreen mode

What Makes JIT Special

The JIT compiler has an advantage that ahead-of-time compilers like C's gcc do not have. The JIT can see runtime behavior.

  • Which branch of an if statement is taken 99% of the time? Optimize for that.
  • Is this method always called with the same argument? Specialize for it.
  • Is this virtual method always resolving to the same implementation? Inline it.

These are called speculative optimizations. The JIT makes bets based on what it observes at runtime, and those bets usually pay off big time.

// The JIT sees that 'animal' is always a Dog in practice
Animal animal = getAnimal();
animal.makeSound();

// So it optimizes this to directly call Dog.makeSound()
// without going through the virtual method dispatch table
// That is called "devirtualization" and it is insanely fast
Enter fullscreen mode Exit fullscreen mode

Java is ALWAYS Pass-by-Value (Yes, Always)

This is the most confusing topic in Java for beginners, and I see wrong explanations everywhere. Let me set the record straight.

Java is always pass-by-value. There is no pass-by-reference in Java. Period. But what gets passed by value changes depending on what you are working with.

Pass by Value diagram showing how primitives get copied (changes inside method don't affect original) and object references get copied (mutations visible, reassignment not)

With Primitives: The Value Gets Copied

public static void main(String[] args) {
    int x = 10;
    changeValue(x);
    System.out.println(x);  // Still 10!
}

static void changeValue(int num) {
    num = 99;  // This changes the LOCAL copy, not the original
}
Enter fullscreen mode Exit fullscreen mode

When you pass x to changeValue(), Java copies the value 10 into the parameter num. They are completely separate. Changing num does nothing to x.

With Objects: The Reference Value Gets Copied

public static void main(String[] args) {
    Dog myDog = new Dog("Buddy");
    changeName(myDog);
    System.out.println(myDog.getName());  // "Fido" -- Changed!
}

static void changeName(Dog d) {
    d.setName("Fido");  // Modifying the SAME object both references point to
}
Enter fullscreen mode Exit fullscreen mode

Wait, if Java is pass-by-value, why did the name change?

Because Java copied the reference value (the memory address, like 0x1234) into the parameter d. Now both myDog and d hold the same address. They both point to the same Dog object on the heap. So when you call d.setName("Fido"), you are reaching through the copied address to modify the actual object.

The Proof: Reassignment Does Not Work

public static void main(String[] args) {
    Dog myDog = new Dog("Buddy");
    replaceDog(myDog);
    System.out.println(myDog.getName());  // Still "Buddy"!
}

static void replaceDog(Dog d) {
    d = new Dog("Max");  // This reassigns the LOCAL reference only
    // myDog in main() still points to the original Dog
}
Enter fullscreen mode Exit fullscreen mode

If Java was pass-by-reference, myDog would now be "Max." But it is not. The parameter d was just a copy of the reference. Reassigning d to a new Dog only changes where the local copy points. The original myDog reference is untouched.

Here is the mental model:

Before replaceDog():
  myDog -----> [Dog: "Buddy"]  (address: 0x1234)

Inside replaceDog():
  d (copy) --> [Dog: "Buddy"]  (address: 0x1234)  // same object

After d = new Dog("Max"):
  myDog -----> [Dog: "Buddy"]  (address: 0x1234)  // unchanged
  d ---------> [Dog: "Max"]    (address: 0x5678)  // new object, local only
Enter fullscreen mode Exit fullscreen mode

The String Pool: A Memory Hack You Should Know About

Strings are the most used objects in Java. Creating a new String object for every "hello" in your program would be a massive waste of memory.

So Java maintains a special area in the Heap called the String Pool (also called the String Intern Pool).

String a = "hello";
String b = "hello";
String c = new String("hello");

System.out.println(a == b);   // true  -- same object in String Pool
System.out.println(a == c);   // false -- c is a separate object on heap
System.out.println(a.equals(c));  // true -- same VALUE

// You can force a String into the pool
String d = c.intern();
System.out.println(a == d);   // true -- now d points to the pooled "hello"
Enter fullscreen mode Exit fullscreen mode

Here is what this looks like in memory:

HEAP:
  String Pool:
    +-------------------+
    |   "hello"  (0x100)|  <-- a and b point here
    +-------------------+
    |   "world"  (0x200)|
    +-------------------+

  Regular Heap:
    +-------------------+
    |   "hello"  (0x300)|  <-- c points here (separate object!)
    +-------------------+

STACK:
  a = 0x100  (String Pool)
  b = 0x100  (String Pool)
  c = 0x300  (Regular Heap)
  d = 0x100  (String Pool, after intern())
Enter fullscreen mode Exit fullscreen mode

Why This Matters

This is why every Java tutorial tells you to compare Strings with .equals() instead of ==.

  • == compares references (are they the same object in memory?)
  • .equals() compares values (do they contain the same characters?)
// This works by accident (both from String Pool)
String x = "hello";
String y = "hello";
System.out.println(x == y);  // true, but this is FRAGILE

// This breaks (new creates a separate object)
String z = new String("hello");
System.out.println(x == z);  // false!

// ALWAYS use .equals() for String comparison
System.out.println(x.equals(z));  // true, RELIABLE
Enter fullscreen mode Exit fullscreen mode

Bonus: Prove It All Yourself

Here is a complete program you can run on your machine to see all of this in action:

public class JVMExplorer {
    public static void main(String[] args) {
        System.out.println("========================================");
        System.out.println("  EXPERIMENT 1: Stack vs Heap");
        System.out.println("========================================");

        int primitive = 42;  // Lives on the Stack
        String obj = new String("Hello JVM!");  // Object on Heap, reference on Stack

        System.out.println("Primitive value: " + primitive);
        System.out.println("Object value: " + obj);
        System.out.println("Object hashCode: " + System.identityHashCode(obj));
        System.out.println();

        System.out.println("========================================");
        System.out.println("  EXPERIMENT 2: String Pool");
        System.out.println("========================================");

        String s1 = "Java";
        String s2 = "Java";
        String s3 = new String("Java");

        System.out.println("s1 == s2 (both from pool): " + (s1 == s2));
        System.out.println("s1 == s3 (pool vs new):    " + (s1 == s3));
        System.out.println("s1.equals(s3):             " + s1.equals(s3));
        System.out.println("s1 == s3.intern():         " + (s1 == s3.intern()));
        System.out.println();

        System.out.println("========================================");
        System.out.println("  EXPERIMENT 3: Pass-by-Value Proof");
        System.out.println("========================================");

        int num = 100;
        modifyPrimitive(num);
        System.out.println("After modifyPrimitive: num = " + num);

        int[] arr = {1, 2, 3};
        modifyArray(arr);
        System.out.println("After modifyArray: arr[0] = " + arr[0]);

        int[] arr2 = {10, 20, 30};
        reassignArray(arr2);
        System.out.println("After reassignArray: arr2[0] = " + arr2[0]);
        System.out.println();

        System.out.println("========================================");
        System.out.println("  EXPERIMENT 4: Garbage Collection");
        System.out.println("========================================");

        Runtime runtime = Runtime.getRuntime();
        long beforeMem = runtime.totalMemory() - runtime.freeMemory();

        // Create a bunch of objects
        for (int i = 0; i < 100000; i++) {
            String garbage = new String("Temporary object #" + i);
        }

        long afterMem = runtime.totalMemory() - runtime.freeMemory();
        System.out.println("Memory before: " + beforeMem / 1024 + " KB");
        System.out.println("Memory after creating 100k objects: " + afterMem / 1024 + " KB");

        System.gc();  // Suggest GC to run (no guarantee!)
        long afterGC = runtime.totalMemory() - runtime.freeMemory();
        System.out.println("Memory after GC: " + afterGC / 1024 + " KB");
        System.out.println("GC freed roughly: " + (afterMem - afterGC) / 1024 + " KB");
        System.out.println();

        System.out.println("========================================");
        System.out.println("  EXPERIMENT 5: JVM Runtime Info");
        System.out.println("========================================");

        System.out.println("Available processors: " + runtime.availableProcessors());
        System.out.println("Max memory: " + runtime.maxMemory() / (1024 * 1024) + " MB");
        System.out.println("Total memory: " + runtime.totalMemory() / (1024 * 1024) + " MB");
        System.out.println("Free memory: " + runtime.freeMemory() / (1024 * 1024) + " MB");
        System.out.println("Java version: " + System.getProperty("java.version"));
        System.out.println("JVM name: " + System.getProperty("java.vm.name"));
    }

    static void modifyPrimitive(int x) {
        x = 999;  // Only modifies local copy
    }

    static void modifyArray(int[] a) {
        a[0] = 999;  // Modifies the actual array (same reference)
    }

    static void reassignArray(int[] a) {
        a = new int[]{999, 888, 777};  // Reassigns local reference only
    }
}
Enter fullscreen mode Exit fullscreen mode

Run this. Read the output. Change things. Break things. That is how you actually learn the JVM.


The Complete Cheat Sheet

Concept What Actually Happens
javac MyApp.java Compiles to bytecode (.class), NOT machine code
java MyApp JVM loads bytecode, interprets it, JIT compiles hot methods
int x = 10; Value 10 stored directly on the Stack
new Student() Object created on the Heap, reference stored on Stack
Method call New stack frame pushed
Method return Stack frame popped, local vars gone
String s = "hi" Checks String Pool first, reuses if found
new String("hi") Always creates new object on Heap (bypasses pool)
Pass primitive to method Value is COPIED
Pass object to method Reference is COPIED (same object, new pointer)
No more references to object GC eventually frees the memory
Hot method (10,000+ calls) JIT compiles to native machine code
StackOverflowError Too many method calls, stack ran out of space
OutOfMemoryError Heap is full, GC cannot free enough space

Wrapping Up

Java is not just a language. It is an entire platform with a virtual machine that does an incredible amount of work to make your code run fast, safe, and portable. Most developers use Java for years without understanding any of this, and that is fine for writing basic code. But the moment you hit a weird bug, a performance issue, or a tricky interview question, this knowledge becomes your superpower.

Here is what you should take away from this:

  • Your code does not run directly on the CPU. The JVM is the middleman.
  • Stack is for method execution and local primitives. Heap is for objects.
  • The Garbage Collector handles memory management, but creating fewer short-lived objects helps performance.
  • The JIT Compiler makes Java fast by converting hot methods to native code at runtime.
  • Java is always pass-by-value. Always. For objects, it copies the reference, not the object.
  • Use .equals() for String comparison, not ==.

Now go experiment. Run that JVMExplorer class. Add your own tests. See what happens when you create circular references. See what happens when you exhaust the stack. The best way to understand the JVM is to push it to its limits and watch how it responds.


What part of this surprised you the most? Did you already know about the JIT Compiler, or was that new? I would love to hear your thoughts and questions in the comments. And if you have a tricky JVM behavior you have encountered, share it below. Let's learn from each other.

Top comments (0)