How Java Works

Introduction

Java works through a combination of compilation and runtime execution. When you write a Java program, the source code is first compiled into bytecode by the Java compiler. That bytecode is then loaded and executed by the Java Virtual Machine (JVM). The JVM can initially interpret the bytecode and later use Just-In-Time (JIT) compilation to convert frequently executed code into native machine instructions. This process allows Java programs to remain portable while still achieving good runtime performance.

The process can be understood as two major phases. The first is compile time, where Java source code is converted into bytecode. The second is runtime, where the JVM loads, verifies, and executes that bytecode.

Java Execution Pipeline

Whiteboard
Whiteboard diagram

Step 1: Writing Java Source Code

The process begins when you write Java source code in a file with the .java extension. For example, a simple application might contain a class named HelloShop with a main method that prints a message.

The source code is written using Java's syntax and is understandable to developers, but the CPU cannot execute it directly. It must first be processed by the Java compiler.

Step 2: Compilation to Bytecode

The Java compiler, javac, converts Java source code into bytecode. During compilation, the compiler parses the source code, checks its syntax and types, and generates a class file containing bytecode.

For example, if the source file is HelloShop.java, you can compile it with:

javac HelloShop.java

If compilation succeeds, the compiler produces HelloShop.class. This class file contains bytecode rather than native instructions for a specific processor.

Bytecode is designed for an abstract machine represented by the JVM. It does not contain instructions specifically designed for an x86, ARM, or another physical CPU. This is one of the key reasons Java applications can be moved between different platforms.

Step 3: Understanding Java Bytecode

Java bytecode is an intermediate instruction format that sits between Java source code and native machine code. Instead of directly targeting CPU registers and hardware instructions, bytecode uses instructions designed for the JVM's execution model.

The JDK provides a tool called javap that can be used to inspect the bytecode generated by the compiler. This can be useful when learning how Java code is represented internally or when investigating compiler behavior.

The important idea is that the CPU does not directly understand Java bytecode. The JVM provides the software environment that loads and executes it.

Step 4: Class Loading

When you run a Java application, the JVM needs to locate the classes required by the program. The Class Loader is responsible for finding and loading these class files into memory.

Class loading generally happens when classes are needed rather than requiring every class available to an application to be loaded immediately. When HelloShop starts, the JVM loads the application class and subsequently loads other required classes as they are referenced.

Java uses a class-loader hierarchy. The bootstrap loader handles core Java classes, the platform loader handles platform classes, and the application loader handles application classes and libraries available to the application.

The delegation mechanism allows a class loader to ask its parent to load a class before attempting to load it itself. This helps prevent application classes from accidentally replacing important built-in classes.

Step 5: Bytecode Verification

After a class is loaded, the JVM verifies its bytecode before executing it. The bytecode verifier checks whether the class follows the rules required by the JVM.

Verification can check things such as whether instructions use the correct types, whether local variables are used appropriately, whether jumps point to valid locations, whether access rules are respected, and whether the class file has a valid format supported by the JVM.

If invalid bytecode violates the required rules, the JVM can reject the class rather than executing it. This verification process is an important part of the JVM's controlled execution environment.

Step 6: Bytecode Execution

Once the class has been loaded and verified, the JVM begins executing its bytecode. The Execution Engine is responsible for this process.

The JVM can begin by interpreting bytecode instructions. The interpreter processes instructions and performs their corresponding operations. This allows the application to start running without waiting for the entire program to be converted into native machine code.

Interpretation is useful for getting a program started quickly, but repeatedly interpreting heavily used code can be less efficient than executing optimized native instructions.

Step 7: JIT Compilation

The JVM can improve performance by identifying frequently executed code, often referred to as hot code or hot methods. The Just-In-Time (JIT) compiler can compile this frequently executed bytecode into native machine instructions for the processor on which the application is running.

Once optimized native code is available, subsequent executions of that code can use the compiled version instead of repeatedly interpreting the same bytecode. Code that is rarely executed may remain interpreted because compiling it could cost more than the performance benefit it provides.

This means the JVM does not necessarily compile every part of an application immediately. It can make compilation decisions based on the application's actual runtime behavior.

JIT Warmup

JIT compilation introduces the idea of warmup. When an application first starts, frequently executed methods may initially run through the interpreter while the JVM observes their behavior. Once the JVM determines that particular code is worth optimizing, the JIT compiler can compile it into native code.

This behavior is particularly useful for long-running applications such as backend services. Such applications can spend some time warming up and then benefit from optimized native code for a long period.

Short-lived applications may experience less benefit because they can finish execution before significant JIT optimization takes place.

Garbage Collection

While a Java application runs, it continuously creates objects that are generally stored in the heap. Some objects eventually become unreachable because the application no longer needs them. The JVM uses garbage collection to identify such objects and reclaim their memory.

Developers therefore normally do not need to manually release ordinary Java objects. Garbage collection is handled by the JVM, although applications can still experience performance and latency effects depending on the amount of memory being used and the garbage collector configuration.

Putting the Process Together

The complete process starts with Java source code and ends with instructions executed by the CPU. The compiler handles the transformation from source code to portable bytecode, while the JVM handles the runtime portion by loading and verifying that bytecode, executing it through interpretation, and optimizing frequently executed code through JIT compilation.

For a simple program such as HelloShop, the overall sequence is straightforward: HelloShop.java is compiled into HelloShop.class, the JVM loads the class when the application starts, verifies the bytecode, begins execution, and may later compile frequently executed methods into native machine code.

Write Once, Run Anywhere

One of Java's most important characteristics becomes clear from this process. The compiled class file contains portable bytecode rather than instructions designed specifically for one operating system or processor.

The same HelloShop.class file can generally be executed on Windows, macOS, and Linux when a compatible JVM is available. The JVM implementation is responsible for interacting with the particular operating system and CPU.

This means the application bytecode remains portable while the JVM provides the platform-specific implementation required to execute it.

JVM and Platform-Specific Execution

The JVM itself is not platform-independent in the same sense as Java bytecode. A JVM implementation must be built for the platform on which it will run. For example, different JVM builds exist for Windows, Linux, macOS, x86 processors, and ARM processors.

The important distinction is therefore portable bytecode and platform-specific JVM. Java source code is compiled into a common bytecode format, and the appropriate JVM translates and executes that bytecode using the capabilities of the underlying platform.

Ahead-of-Time Compilation

The traditional Java execution model uses bytecode and runtime JIT compilation, but Java applications can also use Ahead-of-Time (AOT) compilation in certain scenarios. AOT compilation produces native machine code before the application starts, which can reduce startup time and eliminate some of the JIT warmup cost.

Technologies such as GraalVM Native Image can compile Java applications into native executables. This approach can be useful for applications where startup time and memory usage are particularly important, such as command-line tools and some serverless workloads.

AOT compilation is not a complete replacement for the traditional JVM model. Long-running applications can benefit significantly from the runtime profiling and optimization capabilities of JIT compilation, while AOT compilation can be useful when fast startup and a smaller runtime footprint are more important.

Common Tools Used to Understand Java Execution

The JDK provides several tools that help developers understand and work with the Java execution process. The Java compiler converts source code into bytecode, the Java launcher starts an application, and javap can inspect the generated bytecode. These tools are particularly useful when learning what happens between writing Java source code and executing the resulting application.

Interview Tip

A common interview question is "How does Java work?" A strong answer is: Java source code is compiled into platform-independent bytecode by the Java compiler. When the application runs, the JVM loads and verifies the bytecode and begins executing it through the interpreter. Frequently executed code is identified as hot code and can be compiled into optimized native machine code by the JIT compiler. The JVM also manages runtime memory and garbage collection.

Another common question is "Why is Java platform-independent?" The answer is that Java source code is compiled into portable bytecode rather than platform-specific machine code. A compatible JVM exists for each supported platform and is responsible for executing that bytecode using the underlying operating system and hardware.

Key Takeaways

  • Java source code is compiled into bytecode by the Java compiler.
  • Bytecode is designed to be portable across supported platforms.
  • The Class Loader loads required classes into the JVM.
  • The bytecode verifier checks loaded classes before execution.
  • The interpreter can begin executing bytecode immediately.
  • The JIT compiler optimizes frequently executed code into native machine instructions.
  • Garbage collection automatically manages memory for unreachable objects.
  • The JVM is platform-specific, while Java bytecode is portable.
  • Java's portability comes from separating bytecode from the underlying operating system and hardware.
  • AOT compilation provides an alternative approach when fast startup and a smaller runtime footprint are important.

Conclusion

Java works by separating compilation from execution. The Java compiler transforms source code into portable bytecode, and the JVM takes responsibility for loading, verifying, executing, and optimizing that bytecode at runtime. The interpreter allows execution to begin quickly, while the JIT compiler improves performance by turning frequently executed code into optimized native machine instructions.

This architecture explains both Java's portability and its runtime performance. The same bytecode can run across different platforms because the platform-specific JVM handles the underlying hardware and operating-system differences. Understanding this flow provides the foundation for understanding the JVM, bytecode, JIT compilation, garbage collection, and many of the performance characteristics of Java applications.