Your CPU Never Executes Java. The JVM Rewrites It at Runtime.
How Java Actually Runs
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
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 Three Pillars Inside the JVM
The JVM is not just one thing. It is made up of three major components working together:
- The Class Loader
- Runtime Data Areas (Stack and Heap)
- The Execution Engine
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
.classfile 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!
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.
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
}
}
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 |
|-----------------|
When add() returns, its frame vanishes:
||
|main() frame |<-- now this is the top
|result = 8 |
|args = ref |
|-----------------|
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);
What happens here? Two things:
- The
Studentobject withname="Tahosin"andage=22is created on the Heap - The variable
son 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)
}
}
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!
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).
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
}
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
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());
}
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.
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!
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
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.
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
}
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
}
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
}
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
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"
Here is what this looks like in memory:
HEAP: Strin
Comments
No comments yet. Start the discussion.