AlgoInsight

Free lesson · Java Basic To Advance

How Java Works

Easy9 min readjava

The Full Picture

Topics 1.1-1.7 established what Java is, what's installed when you install Java, and how to write and run a basic program. This final Topic in Module 1 puts it all together: what actually happens, end to end, when you run a Java program.

This is the Topic where everything we've covered connects. By the end, you should be able to trace a Java program from source code on disk to native CPU instructions executing — and explain it to an interviewer. Nikhil joins for the execution-engine sections since runtime model material is his domain.

The Lifecycle of a Java Program

A Java program goes through several distinct phases from source to execution:

  1. Write — you write .java source files
  2. Compile — javac produces .class bytecode files
  3. Package — optionally, bundle .class files into JAR archives
  4. Load — the JVM reads .class files into memory
  5. Verify — the JVM checks the bytecode for safety
  6. Link — resolve references between classes
  7. Initialize — run static initializers
  8. Execute — run bytecode through interpretation and JIT compilation
  9. Manage memory — garbage collection runs throughout
  10. Exit — when main() returns or System.exit() is called

Most of these happen invisibly. Understanding them is essential for senior interviews.

graph TD
    A[Source .java files] -->|javac compiler| B[Bytecode .class files]
    B -->|optional: jar tool| C[JAR archive]
    C -->|java command starts JVM| D[Class Loader]
    B -->|java command starts JVM| D
    D --> E[Bytecode Verifier]
    E --> F[Linker]
    F --> G[Class Initializer]
    G --> H[Execution Engine]
    H -->|interpretation| I[Bytecode interpreted]
    H -->|JIT compilation| J[Native code execution]
    K[Garbage Collector] -.->|background| H

    style B fill:#7c3aed,color:#fff
    style H fill:#10b981,color:#fff
    style K fill:#f59e0b,color:#fff
This diagram shows the complete lifecycle of a Java program. You start with Java source files, which the javac compiler turns into bytecode .class files. These can optionally be packaged into a JAR archive. When you run the java command, the JVM starts and the class loader reads the bytecode into memory. The bytecode verifier checks it for safety. The linker resolves references between classes. The class initializer runs static initializers. Then the execution engine takes over, running bytecode through a combination of interpretation and JIT compilation. Throughout execution, the garbage collector runs in the background reclaiming memory.

Phase 1: Compilation

When you run javac HelloWorld.java, the compiler does several things:

Lexical analysis (tokenization). The source text is broken into tokens — public, class, HelloWorld, {, etc.

Parsing. Tokens are organized into an Abstract Syntax Tree (AST) representing the program's structure.

Semantic analysis. The compiler checks types, resolves names, verifies that everything the code refers to exists, and enforces language rules.

Code generation. The AST is translated into JVM bytecode.

Output. The bytecode is written to .class files — one per class defined in the source.

The output .class file is a structured binary format defined by the JVM specification. It contains:

graph TD
    A[Source code text] --> B[Tokens]
    B --> C[AST]
    C --> D[Symbol table + type checks]
    D --> E[Bytecode]
    E --> F[.class file]

    style A fill:#7c3aed,color:#fff
    style C fill:#3b82f6,color:#fff
    style E fill:#10b981,color:#fff
    style F fill:#f59e0b,color:#fff
This diagram shows the compilation pipeline. Source code text is tokenized into atomic chunks. The tokens are parsed into an Abstract Syntax Tree representing the program's structure. The compiler then analyzes the AST for semantic correctness — building a symbol table and checking that all types are valid. Finally, bytecode is generated and written to .class files. Each step transforms the program into a representation closer to what the JVM can execute.

You can inspect the result of compilation with javap. Running javap -v HelloWorld shows the full class file structure including the constant pool and bytecode.

Phase 2: Loading

When you run java HelloWorld, the JVM starts and begins loading classes. Loading is more sophisticated than it sounds.

Class loaders form a hierarchy:

Loading is lazy. Classes aren't loaded all at once. They're loaded when first referenced. The first reference to String triggers loading of String. The first reference to your HelloWorld class triggers loading of HelloWorld.

Delegation. When a class loader is asked to load a class, it first asks its parent. Only if the parent can't find the class does the child loader try. This prevents user code from overriding core classes — you can't create your own java.lang.String and have it replace the real one.

This hierarchy matters for advanced use cases (custom class loaders for plugins, hot reloading, application servers). For most code, it's invisible.

Interactive animation: Class loading, parents first - bootstrap, platform and application loaders, which is why you cannot replace java.lang.String.

Phase 3: Verification

Before executing loaded bytecode, the JVM verifies it. The bytecode verifier checks:

Verification protects the JVM from malformed or malicious bytecode. Even if someone hand-crafts a .class file to violate Java's rules, the verifier catches it before execution. This is part of Java's security guarantee.

The verifier rejects code that fails any check with a VerifyError. This is rare in practice because javac produces correct bytecode, but it can happen with manually-modified class files or class files from incompatible Java versions.

Phase 4: Linking and Initialization

Linking resolves symbolic references in the bytecode to actual memory addresses. When code references java.lang.String.length(), the linker finds the actual String.length method and replaces the symbolic reference with a direct one.

Initialization runs the class's static initializers — static blocks and static field initializers — exactly once per class loader.

public class Example {
    private static int counter;

    static {
        // This block runs once when the class is first loaded
        counter = 42;
        System.out.println("Static initializer ran");
    }
}

The order matters: static initializers run in source order, top to bottom. Cross-class initialization order is more complex (the JVM has specific rules to avoid circular dependencies).

Phase 5: Execution (Nikhil's Domain)

Nikhil here. Execution is where the JVM does its most sophisticated work. We covered the components in Topic 1.5 — here we look at how they interact during actual program execution.

Initial execution: interpretation.

When a method is first called, the JVM's interpreter executes its bytecode one instruction at a time. Interpretation is slow — there's overhead per instruction — but it works for any code, requires no compilation time, and gathers data about how the code behaves.

Tiered compilation: Sparkplug and TurboFan-equivalents.

(Java doesn't use those exact names — those are V8's components from the JavaScript world — but Java has the same concept.)

As code runs, the JVM counts how often each method is called. Methods called frequently are "hot" and benefit from compilation. The JVM has multiple JIT compilers at different levels:

The progression: a method starts interpreted. After being called enough times, C1 compiles it. After being called many more times (and observing what types it sees), C2 compiles it with full optimization.

graph LR
    A[Method called once] -->|interpreted| B[Bytecode interpretation]
    B -->|called many times| C[C1 compilation<br/>fast, basic opts]
    C -->|called many many times<br/>+ stable types| D[C2 compilation<br/>aggressive opts]
    D -->|type assumptions break| B

    style B fill:#94a3b8,color:#fff
    style C fill:#3b82f6,color:#fff
    style D fill:#10b981,color:#fff
This diagram shows tiered JIT compilation in the JVM. A newly-called method starts in the interpreter — its bytecode is executed one instruction at a time. As the method is called repeatedly, the JVM identifies it as a hot method and compiles it with C1, the client compiler, producing moderately-optimized native code. If the method continues to be called frequently and the JVM observes stable type behavior, C2 the server compiler kicks in with aggressive optimizations. If C2's assumptions about types or behavior turn out to be wrong, the optimized code is invalidated and execution falls back to the interpreter — a process called deoptimization.

Deoptimization.

C2's optimizations are speculative. It compiles based on assumptions: "this method always receives integers," "this object is always a specific subclass," "this branch is never taken." If those assumptions break, the optimized code is invalidated and execution falls back to interpreted or C1-compiled code.

Deoptimization is expensive — work is wasted. Stable code that doesn't trigger deoptimization performs best. Code that mixes types, has surprising polymorphism, or has rare exceptional paths can defeat optimization.

You can observe JIT activity with JVM flags:

java -XX:+PrintCompilation HelloWorld
# Prints each method as it's compiled

java -XX:+UnlockDiagnosticVMOptions -XX:+LogCompilation HelloWorld
# Detailed log of compilation decisions

For performance-sensitive code, watching JIT activity is the key diagnostic technique.

Phase 6: Memory Management

Throughout execution, the garbage collector runs in parallel reclaiming memory. We cover GC in detail in Module 19. The key concepts at this stage:

For a basic program like HelloWorld, the GC barely runs — the program ends before significant garbage accumulates. For long-running applications, GC behavior is a major performance factor.

Phase 7: Exit

When main() returns normally, or System.exit(code) is called, or all non-daemon threads finish, the JVM begins shutdown:

  1. Shutdown hooks run (registered with Runtime.getRuntime().addShutdownHook())
  2. Finalizers run (legacy mechanism, deprecated)
  3. The JVM exits with an exit code

The exit code is 0 by default (success), or whatever was passed to System.exit(code). The shell sees this exit code in $? (Unix) or %ERRORLEVEL% (Windows).

Putting It All Together

A trace of what happens when you run java HelloWorld:

  1. Shell starts the java process
  2. JVM initializes — sets up memory regions, starts threads, loads core libraries
  3. Application class loader looks for HelloWorld.class in the classpath
  4. JVM loads HelloWorld.class — reads the file, parses the class structure
  5. Bytecode verifier checks HelloWorld's bytecode for safety
  6. Linker resolves references in HelloWorld to other classes (it references System, String, PrintStream)
  7. Those referenced classes get loaded, verified, linked, initialized in turn (most are already loaded from earlier startup)
  8. JVM runs static initializers on HelloWorld (none in this case)
  9. JVM looks up the main method
  10. JVM begins executing main's bytecode in the interpreter
  11. main calls System.out.println — interpreted bytecode invokes the method
  12. println writes "Hello, world!" to standard output
  13. main returns
  14. JVM begins shutdown
  15. JVM exits with code 0

For HelloWorld, this all happens in tens of milliseconds. For a large enterprise application, the equivalent process can take seconds (JVM startup is part of why Java has a reputation for slow startup).

Interactive animation: java HelloWorld traced end to end - start the JVM, load and verify, initialize, run main, then exit with code 0.

Common Pitfalls

Warning: Confusing compile-time errors with runtime errors. Compile-time errors come from javac. Runtime errors come from the JVM during execution. The two have different causes and fixes.

Thinking JIT compilation is the same as javac compilation. They're different. javac produces bytecode; JIT produces native code at runtime. Both are "compilation" but at different stages.

Underestimating verification. It happens to every class, every time. It's part of why Java is safer to run untrusted code than C.

Forgetting static initialization order. Static initializers run when a class is first loaded, not when the program starts. Cross-class initialization can produce surprising ordering bugs.

Why This Matters in Interviews

Interview: "Walk me through what happens when you run a Java program" is a common senior-level interview question. The expected answer covers: compilation to bytecode, JVM startup, class loading (with the hierarchy), verification, linking and initialization, execution through interpretation and JIT, and finally exit. Candidates who can articulate this entire flow signal real depth.

Topic 26.12 has the full senior-candidate answer with sample phrasing.

Try It

Exercise: Trace the execution of HelloWorld in real time:

  1. Compile HelloWorld with javac HelloWorld.java
  2. Run with class loading traced: java -verbose:class HelloWorld 2>&1 | head -40
    • You'll see every class that gets loaded — note the order and the loader
  3. Run with JIT compilation traced: java -XX:+PrintCompilation HelloWorld
    • For a tiny program, you won't see much, but try running a longer-running program
  4. Run with GC tracing: java -Xlog:gc HelloWorld
    • You'll see GC events (probably none for HelloWorld since it ends too quickly)

Try the same with a longer program — write a loop that creates many objects, run it with these flags, and see what the JVM is doing internally. The flags expose the machinery that's normally hidden.

This is Module 1 complete. Module 2 covers Java's syntax fundamentals — variables, types, operators — which is where you start writing real code rather than just understanding the platform.

Back to Java Basic To Advance