Java developers write Java code every day.
User user = new User();
user.setName("Krrish");
We know what this code means at the Java level.
But what actually happens after we compile it?
Where does User come from?
Where is the object created?
What happens when a method is called?
How does Java turn bytecode into CPU instructions?
Where does garbage collection fit in?
And why can the same Java application sometimes become dramatically faster after running for a while?
You don't need to know every implementation detail of the JVM to write Java.
But once you understand the JVM, many things that previously seemed like Java "magic" become mechanical.
The JVM is not just a thing that runs .class files.
It is a sophisticated runtime system containing:
Class Loading
↓
Memory Management
↓
Bytecode Execution
↓
JIT Compilation
↓
Garbage Collection
↓
Native Machine Code
Let's understand what is actually happening.
From Java Code to the JVM
Start with a simple class.
public class Hello {
public static void main(String[] args) {
System.out.println("Hello");
}
}
We compile it:
javac Hello.java
The result is:
Hello.class
The .class file contains bytecode.
It is not CPU-specific machine code.
We can inspect it:
javap -c Hello
You might see something similar to:
public static void main(java.lang.String[]);
Code:
0: getstatic #7
3: ldc #13
5: invokevirtual #15
8: return
The important idea is this:
Java Source
↓
javac
↓
Bytecode
↓
JVM
↓
Machine Code
↓
CPU
The JVM is the runtime that gives this bytecode meaning.
Why Bytecode?
Suppose you compile Java on Linux.
The resulting bytecode can run on Windows.
Why?
Because the bytecode isn't directly written for an x86 CPU or an ARM CPU.
Instead:
Java Bytecode
↓
JVM Linux
↓
CPU instructions
or:
Java Bytecode
↓
JVM Windows
↓
CPU instructions
The JVM provides the platform-specific layer.
This is the practical meaning behind:
Write Once, Run Anywhere
The JVM implementation changes between platforms.
The bytecode does not have to.
The JVM Is a Runtime
A useful mental model is:
JVM
|
+-------+-------+
| |
Class Loading Execution
| |
| +-----+-----+
| | |
| Interpreter JIT
| |
+---------+-----------+
|
Memory
|
GC
The JVM has several major responsibilities:
-
Load classes
-
Verify bytecode
-
Allocate memory
-
Execute bytecode
-
Compile hot code
-
Manage threads
-
Perform garbage collection
-
Interact with native code
Let's start with class loading.
Class Loading
When you write:
User user = new User();
the JVM needs to know what User actually is.
It needs the class definition.
That is the job of the Class Loader subsystem.
Conceptually:
User.class
↓
Class Loader
↓
Class representation inside JVM
The JVM does not necessarily load every class in your application immediately.
Classes are generally loaded when they are needed.
The Class Loading Process
Class loading can be understood through three broad stages:
Loading
↓
Linking
↓
Initialization
Linking itself consists of:
Verification
Preparation
Resolution
So:
Loading
↓
Verification
↓
Preparation
↓
Resolution
↓
Initialization
Loading
The class loader finds the class definition and creates the JVM's internal representation of the class.
For example:
User user = new User();
eventually requires the JVM to load User.
The class loader searches according to its class-loading mechanism and class path/module configuration.
Parent Delegation
Class loaders normally follow a parent-delegation model.
Conceptually:
Application Class Loader
|
↓
Platform Class Loader
|
↓
Bootstrap Class Loader
Suppose your application requests:
java.lang.String
The application class loader does not simply load its own version.
The request is delegated upward.
This is important for security and consistency.
Otherwise, an application could potentially provide its own implementation of a fundamental class such as:
java.lang.String
and interfere with the Java runtime.
The parent gets the first opportunity to load the class.
Loading Is Not Initialization
This distinction is important.
Consider:
class Config {
static int value = 100;
static {
System.out.println("Initialized");
}
}
Loading the class does not necessarily mean that all static initialization has already executed.
Initialization happens when the JVM determines that the class needs initialization according to the Java language and JVM rules.
During initialization:
static int value = 100;
is executed.
And:
static {
System.out.println("Initialized");
}
runs as well.
Bytecode Verification
The JVM does not blindly execute arbitrary bytecode.
The class file is verified.
The JVM checks that the bytecode obeys JVM constraints.
This is one of the reasons Java's execution model can provide strong safety guarantees.
Think of it as:
.class file
↓
"Is this valid bytecode?"
↓
YES
↓
Continue
Runtime Data Areas
Once classes and code are running, we need memory.
The JVM runtime has several important memory areas.
A useful simplified picture is:
JVM
|
+------------+------------+
| |
Shared Memory Per-Thread
| |
Heap JVM Stack
Metaspace PC Register
Code Cache Native Stack
The most important distinction is:
Heap
is shared between threads.
While:
JVM Stack
is associated with an individual thread.
The Heap
When you create an object:
User user = new User();
the object is generally allocated on the heap.
Conceptually:
Stack
|
| user
|
v
Heap
|
+---- User object
The variable user is a reference.
The actual object lives in heap memory.
This distinction is extremely important.
Stack vs Heap
Consider:
public void process() {
int count = 10;
User user = new User();
}
A simplified mental model is:
Thread Stack
-------------------
count = 10
user ----------+
-----------------|
|
↓
Heap
+-------------+
| User object |
+-------------+
The local variable count belongs to the current stack frame.
The reference user also belongs to the current stack frame.
The object created by new User() is allocated on the heap in the normal conceptual model.
There is an important optimization we'll discuss later:
JIT escape analysis can sometimes eliminate or transform an allocation.
So the simplified picture is useful, but it is not a guarantee of physical memory layout.
Every Thread Has a Stack
Suppose we have:
Thread A
Thread B
Thread C
Each thread has its own JVM stack.
Thread A → Stack A
Thread B → Stack B
Thread C → Stack C
This is why local variables are naturally isolated between threads.
For example:
void calculate() {
int x = 10;
}
The local variable x belongs to the invocation's stack frame.
It is not a globally shared variable.
Stack Frames
Every method invocation creates a stack frame.
Consider:
main()
↓
process()
↓
calculate()
The stack might conceptually look like:
+----------------+
| calculate() |
+----------------+
| process() |
+----------------+
| main() |
+----------------+
When calculate() returns:
+----------------+
| process() |
+----------------+
| main() |
+----------------+
The frame disappears.
This is why stack memory is naturally associated with method execution.
What's Inside a Stack Frame?
A JVM stack frame contains information needed to execute a method.
A simplified representation is:
+----------------------+
| Local Variable Array |
+----------------------+
| Operand Stack |
+----------------------+
| Reference Information|
+----------------------+
The exact implementation is JVM-specific, but the conceptual model is useful.
Local Variables
For:
int x = 10;
int y = 20;
the method needs somewhere to hold local values.
Operand Stack
JVM bytecode uses an operand stack heavily.
For example, conceptually:
load x
load y
add
store result
The operand stack might behave like:
10
10 20
30
This is one reason JVM bytecode is often described as stack-based bytecode.
The Program Counter
Every JVM thread also has a program counter.
Conceptually:
Thread
|
+-- PC Register
It identifies the bytecode instruction currently being executed, or the relevant execution position.
When the JVM moves from:
instruction 10
to:
instruction 11
the execution position changes accordingly.
Method Area and Metaspace
The JVM also needs somewhere to maintain class-related information.
Modern HotSpot JVMs use Metaspace for class metadata.
For example:
class User {
private String name;
public void print() {
System.out.println(name);
}
}
The JVM needs information about:
-
The class
-
Its methods
-
Its fields
-
Runtime constant pool information
-
Method metadata
A simplified picture is:
Heap
|
Objects
Metaspace
|
Class metadata
Metaspace uses native memory rather than being part of the Java heap.
This distinction matters when diagnosing memory problems.
You can have:
Java Heap
within its configured limits while:
Metaspace
is consuming significant native memory.
What Happens When new Runs?
Consider:
User user = new User();
A simplified sequence is:
new User()
↓
Class must be available
↓
Memory is requested
↓
User object is initialized
↓
Reference is returned
↓
user points to object
Conceptually:
Stack
user ──────────────┐
↓
Heap
+-----------+
| User |
| name=null |
+-----------+
Then:
user.setName("Krrish");
changes the object's state.
Garbage Collection
Now suppose:
User user = new User();
user = null;
What happens to the object?
The object may become unreachable.
Stack
user → null
Heap
User object
↑
|
nobody
The object is now eligible for garbage collection.
Notice the wording:
eligible for GC
It does not mean:
immediately deleted.
The JVM decides when and how garbage collection happens.
GC Roots
The JVM determines reachability starting from special references called GC roots.
Conceptually:
GC Roots
|
+---- Object A
|
+---- Object B
If an object can be reached from GC roots, it is considered reachable.
If it cannot:
GC Roots
X
Unreachable Object
it can eventually be reclaimed.
Typical GC roots include things such as:
-
References from active thread stacks
-
Static references
-
JNI-related references
-
Other JVM-managed root references
Why Generational Garbage Collection?
Most Java objects are short-lived.
Consider:
for (...) {
new RequestContext();
}
Many of those objects might live only for the duration of a request.
It would be inefficient to treat every object as equally long-lived.
So generational collectors exploit the observation that:
Most objects die young.
A simplified heap model is:
Young Generation
|
+-- Eden
+-- Survivor
+-- Survivor
Old Generation
New allocations generally start in the young generation.
Eden
Imagine:
new User();
new Order();
new Request();
new Response();
These objects are typically allocated in the young generation, often initially in Eden.
Eventually Eden fills.
The JVM performs a young-generation collection.
Objects that are still alive can survive and move through survivor regions or otherwise be promoted depending on the collector.
Eventually long-lived objects may become part of the old generation.
A simplified picture:
Eden
↓
Survivor
↓
Survivor
↓
Old Generation
The exact mechanics depend on the garbage collector.
Garbage Collectors
The JVM does not have only one garbage collector.
Modern HotSpot provides several collectors, including:
Serial
Parallel
G1
ZGC
Shenandoah
They make different trade-offs.
For example:
Throughput
↕
Latency
↕
Memory overhead
A throughput-oriented application and a latency-sensitive application may want different GC behavior.
G1 Garbage Collector
G1 divides the heap into regions rather than treating the young and old generations as a single pair of contiguous physical areas.
Conceptually:
+----+----+----+----+
| R1 | R2 | R3 | R4 |
+----+----+----+----+
| R5 | R6 | R7 | R8 |
+----+----+----+----+
Regions can have roles such as:
Eden
Survivor
Old
Humongous
G1 tries to collect regions that provide good reclamation relative to the pause-time goals.
The important lesson isn't memorizing the region algorithm.
It is understanding that modern GC is much more sophisticated than:
"Stop everything and delete unused objects."
Stop-The-World
Some JVM operations require application threads to pause.
This is called:
Stop-The-World
Conceptually:
Application Threads
████████████████████
↓
STOP
↓
GC / JVM operation
↓
RESUME
████████████████████
Modern collectors perform significant work concurrently with application threads, but pauses have not disappeared entirely.
This matters when you're diagnosing latency.
A service might be perfectly fast most of the time and still experience:
99.9 percentile latency spikes
because of JVM pauses or other runtime effects.
The Execution Engine
So far we have loaded classes and discussed memory.
Now the JVM needs to execute bytecode.
This is the job of the execution engine.
A simplified model is:
Bytecode
|
+--------+
| |
Interpreter JIT
The Interpreter
The interpreter executes bytecode instructions directly.
Conceptually:
Bytecode
↓
Interpret
↓
Execute
This gives the JVM an important advantage:
fast startup.
The JVM doesn't need to compile every method into highly optimized machine code before your application can begin.
But interpretation isn't necessarily the fastest way to execute frequently used code.
That's where JIT compilation comes in.
JIT Compilation
JIT means:
Just-In-Time compilation
The JVM observes application execution.
Suppose:
for (int i = 0; i < 10_000_000; i++) {
calculate(i);
}
The JVM notices that calculate() is executed extremely frequently.
This is a hot method.
Instead of repeatedly interpreting it, the JVM can compile it into native machine code.
Conceptually:
Bytecode
↓
Interpreter
↓
Hot code detected
↓
JIT Compiler
↓
Native Machine Code
Future executions can use the compiled code.
This is one reason Java applications can become faster after warming up.
Why Java Can Become Faster After Startup
Imagine:
Application starts
↓
Mostly interpreted execution
↓
JVM observes execution
↓
Hot methods identified
↓
JIT compilation
↓
Optimized native code
↓
Faster execution
This is also why benchmarks of Java applications need to account for warm-up.
Running something once and declaring:
"Java is slow"
can be a very misleading benchmark.
JIT Optimizations
The JIT doesn't merely translate bytecode mechanically.
It can perform sophisticated optimizations.
One important optimization is method inlining.
Suppose:
int add(int a, int b) {
return a + b;
}
and:
int result = add(x, y);
The JIT may effectively turn the call into something closer to:
int result = x + y;
This removes method-call overhead and can expose additional optimization opportunities.
Other optimizations can include:
-
Constant folding
-
Dead-code elimination
-
Loop optimizations
-
Escape analysis
-
Lock optimizations
-
Devirtualization
-
Method inlining
Escape Analysis
Consider:
public int calculate() {
Point p = new Point(10, 20);
return p.x + p.y;
}
The object p never escapes the method.
The JIT can analyze this.
It may determine that the allocation does not need to exist as a normal heap object in the optimized execution path.
The result can be optimized substantially.
This is why:
Java source code
does not always map directly to:
physical heap allocations
The JVM is allowed to optimize aggressively as long as observable program behavior remains correct.
Polymorphism and the JVM
Java developers use polymorphism constantly.
Animal animal = new Dog();
animal.speak();
At the Java level, this looks simple.
But the JVM needs to determine which implementation of speak() should execute.
Conceptually:
animal.speak()
↓
What is the runtime type?
↓
Dog.speak()
This is called dynamic dispatch.
But there is an interesting optimization opportunity.
If the JVM discovers that a call site almost always targets one implementation, it may optimize the call.
For example:
animal.speak()
↓
Usually Dog.speak()
↓
JIT optimizes
If assumptions later become invalid, the JVM can deoptimize and return to a more general execution path.
This is one reason the JVM is more dynamic than a simple:
bytecode → machine code
translator.
Deoptimization
JIT compilation is based on observations.
Suppose the JVM observes:
99.9% of calls → Dog.speak()
It can optimize around that assumption.
Later:
animal = new Cat();
Now the assumption may no longer hold.
The JVM can deoptimize the compiled code and continue using a more general execution strategy.
So the runtime is continuously adapting.
This gives us a useful mental model:
Execute
↓
Observe
↓
Optimize
↓
Assume
↓
Assumption changes
↓
Deoptimize
↓
Re-optimize
The Code Cache
Where does compiled native code go?
HotSpot maintains a code cache for generated native code.
Conceptually:
Metaspace
|
Class metadata
Heap
|
Objects
Code Cache
|
JIT compiled code
This is another important reason why JVM memory is more complicated than simply:
-Xmx = total JVM memory
Your process can consume memory outside the Java heap.
Native Memory
A Java process uses more memory than the heap.
For example:
JVM Process
|
+-- Java Heap
+-- Metaspace
+-- Code Cache
+-- Thread Stacks
+-- Native Libraries
+-- GC structures
+-- JVM internal structures
+-- Direct Buffers
This explains a common production mystery:
-Xmx = 4G
but
Process RSS = 6G
There is no contradiction.
-Xmx controls the maximum Java heap size.
It does not mean:
"The entire JVM process can never exceed 4 GB."
Direct Memory
Consider Java NIO.
Libraries can allocate memory outside the Java heap.
For example:
ByteBuffer.allocateDirect(...)
This uses off-heap memory.
That memory still belongs to your process.
Therefore, when investigating memory usage, you need to consider both:
Heap
and:
Native / off-heap memory
Strings
Strings are another interesting part of JVM internals.
Consider:
String a = "Java";
String b = "Java";
String literals can use the JVM's string pool.
Conceptually:
String Pool
|
"Java"
/ \
a b
Both references can refer to the same interned string.
But:
String a = new String("Java");
creates a distinct String object even though the literal "Java" itself may already be in the pool.
The key lesson:
String literal
and:
new String(...)
are not equivalent from an object-identity perspective.
Object Layout
Consider:
class User {
int id;
String name;
}
A simplified object layout might look like:
+----------------------+
| Object Header |
+----------------------+
| id |
+----------------------+
| reference to name |
+----------------------+
| Alignment / Padding |
+----------------------+
The actual layout depends on:
-
JVM implementation
-
Architecture
-
Object alignment
-
Compressed references
-
Field layout rules
The object header contains JVM metadata used for things such as object identity and synchronization.
This becomes particularly relevant when you have millions of small objects.
A seemingly tiny Java object may consume significantly more memory than just the sum of its fields.
Compressed References
On some 64-bit JVM configurations, references can be represented using compressed forms.
Why?
Because a full 64-bit reference consumes more space than necessary for many heaps.
Using compressed references can reduce object size and improve cache efficiency.
This is another example of why:
Java field declaration
doesn't directly tell you:
physical memory consumption
Synchronization
Consider:
synchronized void process() {
// ...
}
The JVM must coordinate access between threads.
Object and monitor-related mechanisms are involved.
Historically, JVMs used techniques such as biased locking and lightweight/heavyweight locking strategies.
Modern JVM implementations have evolved these mechanisms significantly, so it's better to understand synchronization conceptually rather than memorizing old HotSpot implementation diagrams.
At the high level:
Thread A
|
acquire lock
↓
Critical Section
↓
release lock
Thread B
|
waits / contends
The JVM and OS cooperate to implement this efficiently.
Java Memory Model
JVM internals are not complete without understanding the Java Memory Model.
Consider:
boolean ready = false;
Thread A:
ready = true;
Thread B:
while (!ready) {
}
Can Thread B always observe the change?
Not simply because the CPU eventually "should see it."
The Java Memory Model defines visibility and ordering rules.
This is why constructs such as:
volatile
synchronized
AtomicInteger
Lock
matter.
For example:
volatile boolean ready;
provides specific visibility and ordering guarantees.
The JVM is allowed to reorder operations as long as the resulting behavior remains valid under the Java Memory Model.
This is a major reason why concurrent Java programming requires more than understanding threads alone.
JNI
Java can also interact with native code.
For example:
public native void execute();
The implementation may exist in C or C++.
Conceptually:
Java
↓
JNI
↓
Native Code
↓
Operating System
This is useful for:
-
Native libraries
-
Operating-system integration
-
High-performance native components
-
Existing C/C++ libraries
But it also introduces complexity because native memory isn't managed in the same way as ordinary Java heap objects.
JVM Startup
When you execute:
java MyApplication
a lot happens before your main() method begins doing useful work.
Conceptually:
Start JVM
↓
Initialize runtime
↓
Create JVM structures
↓
Load required classes
↓
Initialize classes
↓
Create main thread
↓
Execute main()
Then the application begins its normal execution.
What Actually Happens During a Method Call?
Suppose:
service.process(order);
At the source-code level, this is one line.
Internally, many things can be involved:
Resolve method
↓
Determine target
↓
Create / use stack frame
↓
Execute bytecode
↓
Interpreter initially
↓
JIT observes hot path
↓
Compile
↓
Optimized native execution
And eventually:
CPU
executes machine instructions.
The important point is that the JVM is constantly making decisions around your code.
A Complete Mental Model
Put everything together:
Java Source
|
javac
|
Bytecode
|
Class Loader
|
+-------+-------+
| |
Metadata Heap
Metaspace |
| |
| Objects
|
Execution
|
+-----+------+
| |
Interpreter JIT
| |
+-----+------+
|
Machine Code
|
CPU
Meanwhile:
Threads
|
+-- JVM Stacks
|
+-- PC Registers
|
+-- Native Stacks
And:
Garbage Collector
|
↓
Heap Management
|
↓
Object Reclamation
And:
Native Memory
|
+-- Metaspace
+-- Code Cache
+-- Thread stacks
+-- Direct memory
+-- JVM internals
Now the JVM starts to look less like magic and more like an operating system for Java programs.
Why This Matters in Production
Understanding JVM internals changes how you diagnose problems.
Suppose your application crashes with:
java.lang.OutOfMemoryError: Java heap space
You know to investigate the heap.
But:
java.lang.OutOfMemoryError: Metaspace
points somewhere else.
And:
unable to create native thread
may indicate native memory or operating-system resource pressure rather than ordinary heap exhaustion.
Likewise:
CPU suddenly increases
could involve:
-
Hot loops
-
JIT compilation
-
GC activity
-
Lock contention
-
Application code
-
Native code
And:
Latency suddenly spikes
could involve:
-
GC pauses
-
Lock contention
-
CPU saturation
-
Safepoints
-
I/O
-
JIT/deoptimization
-
Application behavior
JVM knowledge gives you a map of where to look.
JVM Troubleshooting Toolkit
You don't need to memorize every JVM flag.
Start with the tools.
jps
See running Java processes:
jps -lv
jstack
Inspect thread stacks:
jstack <pid>
Useful for investigating:
deadlocks
blocked threads
thread states
stuck requests
jmap
Inspect heap-related information:
jmap -histo <pid>
Useful for seeing which classes are consuming many objects.
jcmd
A very useful general-purpose JVM diagnostic tool:
jcmd <pid> VM.flags
jcmd <pid> GC.heap_info
jcmd <pid> Thread.print
Java Flight Recorder
For deeper production investigation, Java Flight Recorder (JFR) can provide detailed runtime information with relatively low overhead.
It can help investigate:
CPU
GC
allocations
locks
threads
I/O
JIT activity
The Most Important JVM Concepts
If you are a Java developer, you don't need to memorize the entire JVM specification.
Build these mental models:
1. Bytecode
↓
JVM executes it
2. Class Loading
↓
Classes become available
3. Stack
↓
Thread-local execution state
4. Heap
↓
Objects and arrays
5. Metaspace
↓
Class metadata
6. GC
↓
Reclaims unreachable objects
7. Interpreter
↓
Fast startup
8. JIT
↓
Optimized execution
9. Native Memory
↓
More than just the heap
10. Java Memory Model
↓
Correct concurrency
The Big Picture
The JVM is often introduced as:
"Java runs inside the JVM."
That description is technically correct but not very useful.
A better mental model is:
Java Source
↓
Compiler
↓
Portable Bytecode
↓
JVM
|
+-- Class Loader
|
+-- Runtime Memory
|
+-- Interpreter
|
+-- JIT Compiler
|
+-- Garbage Collector
|
+-- Thread Runtime
|
+-- Native Interface
|
↓
Machine Code
↓
CPU
The JVM is constantly doing three broad things:
UNDERSTAND
the program
MANAGE
its runtime state
OPTIMIZE
its execution
That is the real reason Java can provide both a relatively portable execution model and highly optimized native performance.
Once you understand this, things like:
-Xmx
-Xms
-Xss
-XX:MaxMetaspaceSize
GC logs
JFR
heap dumps
thread dumps
JIT compilation
GC pauses
stop looking like mysterious JVM options.
They become controls and observations of a runtime system you understand.
And that is the real value of learning JVM internals.
You don't learn JVM internals just to answer an interview question.
You learn them so that when a Java application uses 8 GB of memory, suddenly consumes 100% CPU, develops 2-second latency spikes, or gets stuck with hundreds of threads, you have a mental model of what could actually be happening inside the process.
That is when Java stops being just a programming language and the JVM becomes part of your engineering toolkit.