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:
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 with name="Tahosin" and age=22 is 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
ifstatement 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:
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())
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
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
}
}
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)