Definition
When a Java program runs, it goes through these stages:
javaccompiles your.javasource into.classfiles containing bytecode.- The JVM starts and the class loader loads those
.classfiles into memory. - The bytecode verifier checks the loaded code is safe and well-formed.
- The JVM allocates memory areas: heap, metaspace, and per-thread stacks.
- The interpreter begins running bytecode one instruction at a time.
- The JIT compiler detects frequently called methods and compiles them into native machine code for speed.
This is why the JVM is not a simple interpreter. It is a hybrid system that interprets cold code and compiles hot code.
Think of a bank teller processing loan applications. On her first day, she reads the procedure manual line by line for every application (interpretation). After processing hundreds of applications, she memorizes the common steps and does them from muscle memory without checking the manual (JIT compilation). She still checks the manual for rare edge cases. The JVM works the same way: slow and careful at first, fast on the paths it uses most.
Understanding the Concepts
What does "end to end" mean here?
When an interviewer asks this question, they want you to trace the full journey of your code from the moment you write it to the moment the CPU executes the instructions. Most developers know Java is "compiled and then run," but the actual journey has several distinct stages, and understanding each one helps you debug class loading errors, tune JIT performance, and reason about memory usage.
What happens at compile time?
Compile time is the phase before your program runs. You use javac, which is a JDK tool, to turn your source code into bytecode.
- You write
LoanProcessor.javain your editor. - You run
javac LoanProcessor.java. javacreads the source, checks the syntax and types, and producesLoanProcessor.class.- The
.classfile contains bytecode, which is a set of instructions for the JVM, not for any specific CPU. - This is why the same
.classfile runs on Windows, Linux, and Mac without recompiling.
// A simple loan processor
public class LoanProcessor {
public double calculateEMI(double principal, double annualRate, int months) {
double monthlyRate = annualRate / 12 / 100;
double emi = principal * monthlyRate
* Math.pow(1 + monthlyRate, months)
/ (Math.pow(1 + monthlyRate, months) - 1);
return Math.round(emi * 100.0) / 100.0;
}
}
# Compile: this produces LoanProcessor.class
javac LoanProcessor.java
# You can inspect the bytecode with javap
javap -c LoanProcessor
The output of javap shows instructions like dload, dmul, invokestatic instead of x86 or ARM instructions. These are JVM bytecode instructions.
What happens when the JVM starts?
When you run java LoanProcessor, the operating system starts the JVM process. The JVM then performs several setup steps before your main method runs.
1. Class loading
The class loader reads .class files from disk and loads them into memory. It uses a delegation chain:
- Bootstrap ClassLoader loads core Java classes like
java.lang.Stringandjava.lang.Object. - Platform ClassLoader loads standard library extensions like
java.sql. - Application ClassLoader loads your classes from the classpath.
Classes are loaded lazily. The JVM does not load every class at startup. It loads a class only when your code references it for the first time. If LoanProcessor uses BigDecimal, that class is loaded the first time the code reaches a line that mentions BigDecimal.
2. Bytecode verification
Before any loaded class can run, the bytecode verifier checks it:
- Does each method have a valid stack map?
- Are all variable types consistent?
- Does the code attempt to forge object references or jump to invalid positions?
This is a real security guarantee. Even if someone manually edits a .class file to bypass a balance check, the verifier catches the invalid bytecode and refuses to run it.
3. Memory allocation
The JVM sets up its runtime memory areas:
- Heap is the shared memory where all objects live. Every
Accountobject, everyString, and everyBigDecimalis allocated on the heap. - Metaspace (called PermGen before Java 8) stores class metadata, method bytecode, and the constant pool.
- Thread stacks are private to each thread. Each method call creates a stack frame containing local variables and the return address. When the method returns, the frame is popped.
- Program counter tracks which bytecode instruction each thread is currently executing.
How does execution work?
Once a class is loaded, verified, and memory is ready, execution begins.
The interpreter
The interpreter reads bytecode instructions one at a time and executes each one. This is simple and starts quickly, but it is slow for repeated operations because it re-reads the same instructions every time.
The profiler
While interpreting, the JVM continuously counts how often each method and loop runs. When a method's invocation count crosses a threshold (typically around 10,000 in HotSpot), the JVM marks it as "hot."
The JIT compiler
The Just-In-Time compiler takes hot methods and compiles them directly into native machine code for the CPU. This compiled code is stored in a memory region called the code cache.
HotSpot uses two JIT compilers working together (tiered compilation):
- C1 (client compiler) compiles quickly with basic optimizations. Used for warm-up.
- C2 (server compiler) compiles slowly but produces highly optimized native code. Used for hot methods.
A method might start interpreted, get compiled by C1, and then recompiled by C2 as it gets hotter.
Why is this better than pure interpretation?
A pure interpreter reads and decodes the same bytecode every time a method runs. The JIT compiler does the work once and then the CPU runs native instructions directly. For a banking batch job running millions of interest calculations, the JIT-compiled version can be 10 to 50 times faster than the interpreted version.
Can you show the JIT in action?
public class EMIBenchmark {
public static void main(String[] args) {
LoanProcessor processor = new LoanProcessor();
// Warm up: let the JIT compile the hot method
for (int i = 0; i < 100_000; i++) {
processor.calculateEMI(500_000, 8.5, 240);
}
// Now measure: the method is running as native code
long start = System.nanoTime();
double emi = 0;
for (int i = 0; i < 1_000_000; i++) {
emi = processor.calculateEMI(500_000, 8.5, 240);
}
long elapsed = (System.nanoTime() - start) / 1_000_000;
System.out.println("EMI: " + emi);
System.out.println("1M calculations in " + elapsed + " ms");
}
}
# Run with JIT logging to see when methods get compiled
java -XX:+PrintCompilation EMIBenchmark
In the output, you will see lines like com.bank.LoanProcessor::calculateEMI (42 bytes) showing the JIT compiler compiling that method to native code.
What are deoptimization and speculative optimization?
The JIT compiler sometimes makes assumptions to generate faster code. For example, if it observes that a method is always called with the same argument type, it generates code optimized for that type. If a future call violates that assumption, the JVM "deoptimizes" the method back to interpreted mode, recompiles with updated assumptions, and continues.
This is why the JVM can sometimes match or beat statically compiled languages like C for long-running workloads. It optimizes based on actual runtime behavior, not just static analysis.
What are common mistakes developers make?
- Benchmarking without warm-up. If you measure performance on the first few calls, you measure the interpreter, not the JIT. Always warm up the JVM before measuring.
- Thinking javac optimizes code. It does almost nothing. The real optimization happens at runtime in the JIT compiler.
- Assuming all code is JIT-compiled. Only hot methods get compiled. Cold methods (called rarely) stay interpreted, which is fine because they do not affect overall performance.
- Ignoring class loading costs. The first time a class is used, it must be loaded from disk, verified, and initialized. This is why the first request to a web application is slower than subsequent ones.
What should a senior engineer know?
- Tiered compilation is the default since Java 8. C1 and C2 work together. You can disable it with
-XX:-TieredCompilationif you need to debug JIT behavior. - The code cache has a size limit. If it fills up, the JVM stops compiling. Monitor with
-XX:+PrintCodeCacheor JMX. - Ahead-of-time compilation (GraalVM native image) skips the interpreter and JIT entirely by compiling to native code at build time. This eliminates warm-up but loses runtime optimization opportunities.
- The
-XX:+PrintCompilationflag shows every method the JIT compiles. The-XX:+PrintInliningflag shows which methods are inlined into callers. - On-stack replacement (OSR) lets the JIT compile a long-running loop even while it is still executing, without waiting for the method to be called again.
Visual Overview
LoanProcessor.java (your source code)
│
│ javac (JDK tool, compile time)
▼
LoanProcessor.class (bytecode on disk)
│
│ java command starts JVM (runtime begins)
▼
┌──────────────────────────────────────────┐
│ 1. CLASS LOADER │
│ Bootstrap → Platform → Application │
│ Loads .class lazily on first use │
├──────────────────────────────────────────┤
│ 2. BYTECODE VERIFIER │
│ Checks safety: types, stack, jumps │
├──────────────────────────────────────────┤
│ 3. MEMORY SETUP │
│ Heap (objects) │
│ Metaspace (class metadata) │
│ Thread stacks (method frames) │
├──────────────────────────────────────────┤
│ 4. EXECUTION ENGINE │
│ Interpreter (cold code, slow) │
│ │ profiler counts invocations │
│ ▼ │
│ JIT Compiler (hot code, fast) │
│ C1 → basic optimization │
│ C2 → aggressive optimization │
│ │ │
│ ▼ │
│ Native machine code (code cache) │
├──────────────────────────────────────────┤
│ 5. GARBAGE COLLECTOR │
│ Reclaims unreachable objects │
└──────────────────────────────────────────┘
│
▼
Output: EMI = 4364.07, 1M calcs in 12 ms
Key Takeaway
When you run a Java program, the journey has two phases: compile time and runtime.
At compile time, javac reads your .java source and produces .class bytecode. This bytecode is not native code for any CPU. It is a portable instruction set that any JVM can understand, which is why the same .class file runs on any operating system.
At runtime, the JVM takes over. The class loader reads your .class files into memory, but only when each class is first used. The bytecode verifier checks that the loaded code is safe. Then the JVM allocates memory: a shared heap for objects, metaspace for class metadata, and a private stack for each thread.
Execution starts in the interpreter, which reads bytecode one instruction at a time. This is slow but starts immediately. The JVM watches which methods run frequently and marks them as hot. The JIT compiler then compiles those hot methods into native machine code that runs directly on the CPU.
This is exactly why a banking batch job gets faster the longer it runs. The first thousand interest calculations are interpreted. Then the JIT compiles the calculation method, and the remaining million calculations run as native code at full hardware speed.
Remember: the JVM is not a simple interpreter. It is a hybrid system that interprets cold code and compiles hot code. That is the key insight interviewers are looking for.