☕ Java

Java Compilation Process

Most developers know you run javac and get a .class file. But what actually happens inside the compiler? The Java compilation process is a multi-stage pipeline — each stage catching a different class of error, transforming your source code step by step into bytecode that the JVM can execute.

Why Understanding Compilation Matters

Most Java developers treat compilation as a black box — type javac, get a .class file, move on. That works until something goes wrong. A cryptic compiler error, an unexpected ClassNotFoundException at runtime, a performance issue that only appears in production — understanding the compilation pipeline tells you exactly where in the process things broke and why. The Java compiler (javac) transforms human-readable .java source code into JVM bytecode through five distinct stages. Each stage has a specific job, catches a specific class of errors, and hands off a transformed representation to the next stage.

Stage 1 — Lexical Analysis (Tokenization)

The compiler's first job is to break your raw source code into tokens — the smallest meaningful units of the language. This is called lexical analysis, and the component that does it is called the lexer (or scanner). The lexer reads your source file character by character and groups characters into tokens. Every keyword, identifier, operator, literal, and punctuation mark becomes a separate token. Whitespace and comments are discarded entirely at this stage — they're irrelevant to execution.
Java
// Source code:
int sum = a + b;

// After lexical analysis, becomes a token stream:
// [KEYWORD: int] [IDENTIFIER: sum] [OPERATOR: =]
// [IDENTIFIER: a] [OPERATOR: +] [IDENTIFIER: b] [PUNCTUATION: ;]

// Comments and whitespace are stripped:
// int   sum=a+b;  →  same token stream as  int sum = a + b;

// Lexer errors — invalid characters or malformed literals:
int x = 0xFF_GG;  // ← 'G' is not a valid hex digit
// Error: illegal character

Stage 2 — Syntax Analysis (Parsing)

The token stream from the lexer is handed to the parser. The parser's job is to check that the tokens follow Java's grammar rules — and to build an Abstract Syntax Tree (AST) representing the structure of your program. The AST is a tree where each node represents a language construct: a class declaration, a method, an assignment, a binary expression. The tree structure captures the nesting and hierarchy of your code — which statement is inside which method, which expression is on which side of an operator. Syntax errors are caught here — missing braces, malformed expressions, incorrect statement structure.
Java
// Source code:
int sum = a + b;

// Abstract Syntax Tree (simplified):
//
//        AssignmentExpr
//        /            //   Variable         BinaryExpr
//   (sum)            /       //              Variable    Variable
//                (a)          (b)
//                     [op: +]

// Syntax errors caught at this stage:
int sum = a +;        // ← missing right operand
// Error: illegal start of expression

if (x > 0            // ← missing closing parenthesis
// Error: ')' expected

public class Foo {
    void bar() {
    // ← missing closing brace
// Error: reached end of file while parsing

Stage 3 — Semantic Analysis

A program can be syntactically valid but semantically meaningless. Semantic analysis is where the compiler checks that your code actually makes sense — types are compatible, variables are declared before use, methods are called with the right arguments, access modifiers are respected. This is Java's most powerful compile-time safety net. The semantic analyzer walks the AST and enforces Java's type system and scoping rules. The majority of Java compile errors come from this stage.
Java
// Type mismatch — syntactically valid, semantically wrong:
int age = "twenty-five";
// Error: incompatible types: String cannot be converted to int

// Undeclared variable:
System.out.println(score);  // score was never declared
// Error: cannot find symbol: variable score

// Wrong argument type:
Math.sqrt("hello");
// Error: no suitable method found for sqrt(String)

// Access modifier violation:
public class Account {
    private double balance;
}
Account a = new Account();
System.out.println(a.balance);  // balance is private
// Error: balance has private access in Account

// Method must return a value:
public int add(int a, int b) {
    int sum = a + b;
    // missing return statement
}
// Error: missing return statement

Stage 4 — Intermediate Code Generation and Optimization

Once the AST passes semantic analysis, the compiler performs a set of optimizations and transformations before generating bytecode. These are source-level optimizations — things the compiler can figure out at compile time without running the program. Key optimizations at this stage: Constant folding — Evaluates constant expressions at compile time so they don't need to be computed at runtime. Dead code elimination — Removes code that can never be reached. String concatenation optimization — Converts + string concatenation chains into StringBuilder calls. Autoboxing/unboxing insertion — Inserts the necessary Integer.valueOf() and intValue() calls where primitives and wrapper types are mixed.
Java
// Constant folding:
int seconds = 60 * 60 * 24;  // you write this
int seconds = 86400;          // compiler replaces it with this

// Dead code elimination:
if (false) {
    System.out.println("never runs");  // compiler removes this entirely
}

// String concatenation optimization:
String result = "Hello" + ", " + name + "!";  // you write this
// Compiler rewrites as:
String result = new StringBuilder()
    .append("Hello")
    .append(", ")
    .append(name)
    .append("!")
    .toString();

// Constant string folding:
String s = "Hello" + " " + "World";  // you write this
String s = "Hello World";             // compiler collapses it at compile time

Stage 5 — Bytecode Generation

The final stage: the compiler walks the optimized AST and emits JVM bytecode — one instruction at a time. The output is written to a .class file, one per class (including inner classes, which get their own separate .class files). The bytecode is structured according to the JVM specification. Each method's bytecode is stored in a Code attribute inside the .class file, along with metadata: the maximum stack depth, the number of local variable slots, and a line number table that maps bytecode offsets back to source lines (which is how Java stack traces show you exact line numbers in production).
Java
// Source:
public int add(int a, int b) {
    return a + b;
}

// Generated bytecode (via javap -c):
public int add(int, int);
  Code:
     0: iload_1      // push parameter a onto operand stack
     1: iload_2      // push parameter b onto operand stack
     2: iadd         // pop both, add, push result
     3: ireturn      // return the int on top of the stack

// Each instruction is 1 byte (the opcode) + optional operands
// iload = load integer local variable
// iadd  = integer add
// ireturn = return integer value

The .class File — What Gets Written to Disk

The .class file isn't just raw bytecode. It's a structured binary format with a specific layout the JVM reads sequentially:
Java
// Structure of a .class file:
//
// ┌─────────────────────────────────────┐
// │  Magic Number: 0xCAFEBABE (4 bytes) │  ← JVM validity check
// ├─────────────────────────────────────┤
// │  Minor Version (2 bytes)            │  ← Java version compatibility
// │  Major Version (2 bytes)            │  ← e.g. 65 = Java 21
// ├─────────────────────────────────────┤
// │  Constant Pool                      │  ← all strings, class names,
// │  (variable size)                    │    method signatures, constants
// ├─────────────────────────────────────┤
// │  Access Flags (2 bytes)             │  ← public? abstract? final?
// ├─────────────────────────────────────┤
// │  This Class / Super Class           │  ← class hierarchy info
// ├─────────────────────────────────────┤
// │  Interfaces                         │  ← implemented interfaces
// ├─────────────────────────────────────┤
// │  Fields                             │  ← field names, types, modifiers
// ├─────────────────────────────────────┤
// │  Methods                            │  ← method bytecode + metadata
// ├─────────────────────────────────────┤
// │  Attributes                         │  ← source file name, line numbers
// └─────────────────────────────────────┘

// Check the major version of any .class file:
javap -verbose HelloWorld.class | grep "major version"
// major version: 65  ← compiled with Java 21

Compile-Time vs Runtime — What Each Catches

A common source of confusion: some errors are caught at compile time, others only surface at runtime. Knowing which is which helps you understand why certain bugs are harder to find.
Java
// ── COMPILE-TIME ERRORS (javac catches these) ──────────────────

// Type mismatch
int x = "hello";              // Error: incompatible types

// Missing return
public int getValue() { }     // Error: missing return statement

// Undeclared variable
System.out.println(missing);  // Error: cannot find symbol

// Wrong number of arguments
Math.max(1, 2, 3);            // Error: no suitable method found


// ── RUNTIME ERRORS (only appear when running) ────────────────────

// Null pointer
String s = null;
s.length();                   // RuntimeException: NullPointerException

// Array out of bounds
int[] arr = new int[3];
arr[5] = 10;                  // RuntimeException: ArrayIndexOutOfBoundsException

// Invalid cast
Object obj = "hello";
Integer i = (Integer) obj;    // RuntimeException: ClassCastException

// Division by zero
int result = 10 / 0;          // RuntimeException: ArithmeticException

Incremental Compilation and Build Tools

Running javac directly works for small projects. For anything real-world, build tools handle compilation — and they do it smarter. Maven and Gradle both support incremental compilation: they track which .java files have changed since the last build and only recompile those files (plus anything that depends on them). On a large codebase with thousands of classes, this turns a 3-minute full build into a 5-second incremental build. The Java compiler itself also supports annotation processing — a hook that lets tools like Lombok, MapStruct, and Dagger generate additional source files during compilation. Those generated files are then compiled in the same pass, which is why Lombok annotations like @Getter and @Builder produce real methods with zero runtime overhead.
Shell
# Compile a single file:
javac HelloWorld.java

# Compile all files in a directory:
javac src/**/*.java

# Specify output directory for .class files:
javac -d out src/**/*.java

# Compile with a specific Java version target:
javac --release 17 HelloWorld.java

# Enable annotation processing:
javac -processor com.example.MyProcessor HelloWorld.java

# Show verbose compilation details:
javac -verbose HelloWorld.java

# Maven incremental build (only recompiles changed files):
mvn compile

# Gradle incremental build:
./gradlew compileJava