Introduction to JVM

Table of Contents

JVM Function and Its Components

The primary function of the Java Virtual Machine (JVM) is to load and execute your application. The application is typically a .class file, which is generated by compiling a .java source file. When you run the command java MyApp, a new JVM instance is created to execute the program.

So, how does the JVM instance load and execute a class file? This process involves three main components: the Class Loader, the Runtime Data Area, and the Execution Engine.

The Class Loader is responsible for locating and loading .class files into memory. Once loaded, these files contain bytecode—intermediate instructions that are understood by the JVM. These bytecode instructions are then passed to the Execution Engine.

The Execution Engine interprets or compiles the bytecode into native machine code, depending on the implementation. To execute this native code, the Execution Engine interacts with the underlying operating system. This is often done through native method calls, allowing the JVM to leverage platform-specific functionality while maintaining platform independence at the bytecode level.

Class Loader Subsystem

The Class Loader subsystem has three main phases: Load, Link, and Initialize.

Load Phase

The Load phase is responsible for loading bytecode into memory. There are three types of class loaders involved in this phase:

  • Bootstrap Class Loader: Loads core Java classes found in the rt.jar file. These classes are part of the JVM itself.
  • Extension Class Loader: Loads classes from the jre/lib/ext directory. These are typically optional extension libraries.
  • Application Class Loader: Loads classes from the paths specified in the CLASSPATH environment variable, including your application classes and dependencies (e.g., from pom.xml).

Link Phase

The Link phase consists of three sub-phases: Verify, Prepare, and Resolve.

  • Verify: Ensures the bytecode loaded by the class loader adheres to the JVM specification and is safe to execute.
  • Prepare: Allocates memory for all static variables of the class and sets them to their default values.
  • Resolve: Converts symbolic references (e.g., class names) into direct references in memory. If the class references another class, those references are resolved during this phase.

In Java, class references are initially stored as symbolic references. This means that instead of directly pointing to a memory address, they refer to the class using a symbolic name (e.g., com.example.OtherClass). These symbolic references act as placeholders and are resolved during the Resolve sub-phase.

During resolution, the JVM looks up the symbolic name in the Metaspace—a region of memory dedicated to storing class metadata, such as methods and static fields. If the referenced class (e.g., OtherClass) has already been loaded, the JVM retrieves its memory address. Otherwise, the JVM loads it first, and then resolves the reference to point directly to the loaded class.

Initialize Phase

In the Initialize phase, static initializers and static blocks in the class are executed. Static variables are also assigned their defined values during this phase.

Runtime Data Areas

Metaspace

The Metaspace is the area of memory where class-related metadata is stored. This includes:

  • Class names, superclass names, and implemented interfaces
  • Information about Static variables(actual data stored in heap)
  • Bytecode for methods and constructors
  • Constant pool (literals and symbolic references to classes)
  • Field information (names, types, access modifiers)
  • Method metadata

Metaspace automatically grows as needed, unlike the older PermGen memory space.

1public class HelloWorld {
2    static String message = "Hello";
3    
4    public static void main(String[] args) {
5        System.out.println(message);
6    }
7}

In the Metaspace for the above example, you would find:

  • Metadata for the HelloWorld class
  • The static field message
  • Bytecode for the main method
  • Constant pool entry for the string literal "Hello"

Heap

The Heap is where all object data and instance variables are stored. This memory area is shared among all threads and can be tuned using the -Xms (initial size) and -Xmx (maximum size) JVM options.

Program Counter (PC) Registers

Each thread has its own PC Register, which contains the address of the next bytecode instruction to execute. It helps the JVM keep track of execution flow in multithreaded environments.

Java Stacks

Each thread has its own Java stack, which stores stack frames for method execution. A stack frame contains method arguments, local variables, the return address, and intermediate results.

Execution Engine

The execution engine is the core component of the JVM responsible for executing bytecode. It consists of:

  • Interpreter
  • Just-In-Time (JIT) Compiler
  • HotSpot Profiler
  • Garbage Collector

Interpreter

Once the bytecode is loaded, the interpreter reads and executes each instruction line by line. It determines what native operations are needed and performs them via the Native Method Interface, which connects to native libraries present in the JVM.

For example, on Windows systems, you'll find native libraries as .dll files in the JRE's bin folder, while Linux systems use .so or .a modules.

The interpreter is useful for short-lived or rarely used code. However, it has slower performance overall because it repeatedly interprets every instruction.

JIT Compiler

The Just-In-Time (JIT) compiler improves performance by compiling frequently executed (hot) bytecode instructions into native machine code at runtime. Once compiled, these sections are executed directly, bypassing interpretation.

This is particularly beneficial for performance-critical methods or loops. The native code produced by JIT runs significantly faster than interpreted bytecode. Compilation only occurs for hot methods, as determined by the HotSpot Profiler.

HotSpot Profiler

The HotSpot Profiler monitors bytecode execution and collects runtime statistics, such as:

  • Which methods and loops are frequently used (hot spots)
  • Type information
  • Branching behavior

This profiling data is used by the JIT compiler to apply advanced optimizations like inlining, loop unrolling, and branch prediction.

Interpreter vs. JIT Compiler

Both the interpreter and JIT compiler are used in the JVM for different purposes:

  • Fast Startup: The interpreter enables immediate execution of bytecode, making it ideal for short-lived applications or command-line tools.
  • Resource Efficiency: Most code is executed only a few times. The interpreter avoids wasting time and memory by not compiling such code.
  • Better Optimizations: Runtime profiling data gathered by the interpreter and HotSpot Profiler allows the JIT to perform smarter optimizations.

This hybrid approach allows the JVM to balance quick startup with long-term performance.

Metaspace

When you compile Java source code using javac, the compiler produces a .class file. This file is a binary representation of the class and contains the class header, constant pool, field and method definitions, bytecode instructions, and additional metadata such as annotations, line numbers, and exception tables.

The .class file itself is a static, on-disk artifact defined by the JVM specification. It does not execute on its own; instead, it describes how the class should behave once loaded by the Java Virtual Machine.

When a program runs, the JVM performs class loading. A ClassLoader locates and reads the .class file—whether from disk, a JAR, or another source—and parses its binary structure according to the Class File Format. From this, the JVM constructs an internal runtime representation of the class.

This internal representation, often referred to as the class descriptor, is stored in Metaspace. Metaspace is a native-memory region used by the JVM to hold class metadata, including method and field information, the runtime constant pool, the class hierarchy, and the bytecode for each method. Once loading is complete, the JVM can execute methods directly from the metadata stored in Metaspace and no longer needs to access the .class file.

At the same time, the JVM creates a corresponding java.lang.Class<?> object on the Java heap. This heap object acts as a handle that points to the class's runtime metadata stored in Metaspace and is the object through which reflection and runtime type checks operate.

During the linking and resolution phases, symbolic references in the constant pool are resolved into direct references to runtime structures. After resolution, references to a class effectively become pointers to its runtime metadata in Metaspace, accessed indirectly via the associated Class<Foo> object on the heap.

In summary, saying that a class is "loaded into Metaspace" means that the JVM has parsed the .class file, created an internal runtime representation of the class, stored its metadata in Metaspace, and established a corresponding java.lang.Class<?> object on the heap that serves as a gateway to that metadata.

1class Example {
2    static int counter = 10;
3    static final String msg = "Hello";
4}

In this example, the class Example has a static integer field counter and a static final string msg. The metadata describing these fields — such as their names, types, and modifiers — is stored in Metaspace. The actual value of counter(which is 10) and the string object representing "Hello" reside in the Java heap.

In essence, the .class file on disk serves as the source of bytecode and metadata, while the JVM's Metaspace is where this information resides and is used at runtime. Once loaded, all necessary data for class execution exists entirely in memory, and the original.class file is no longer required.

Therefore, while the bytecode originates from the.class file, it ultimately resides and runs inside the JVM's Metaspace — the region of memory dedicated to holding each class's runtime representation.

JIT warmup

JIT warmup refers to the initial phase in a virtual machine (such as the JVM) where code execution transitions from interpreted mode to optimized native machine code. During this phase, the Just-In-Time (JIT) compiler identifies frequently executed paths (known as "hotspots") and compiles them into efficient machine code.

At startup, applications typically run in interpreted or lightly optimized mode, which results in slower performance. As the program executes and more runtime information is collected, the JIT compiler incrementally applies optimizations. In the JVM, this often involves tiered compilation, where simpler optimizations (C1) are applied quickly, followed by more aggressive optimizations (C2) once sufficient profiling data is available.

This process leads to a gradual improvement in performance over time, often referred to as the "warmup curve." While longer warmup periods can produce highly optimized code, they also introduce startup latency, which can be problematic for short-lived applications or latency-sensitive services.

JIT compilation relies on runtime profiling data (such as branch behavior, method call frequency, and inlining opportunities) to make optimization decisions tailored to the actual workload and hardware. This is why optimization cannot be performed fully ahead of time.

To mitigate warmup overhead, techniques such as pre-warming applications before serving traffic or using snapshot-based approaches likeCRaC (Coordinated Restore at Checkpoint) are commonly used to start systems in an already optimized state.

On-stack replacement

On-Stack Replacement (OSR) is a Just-In-Time (JIT) compilation technique that allows the JVM to switch from interpreted execution to compiled code in the middle of a method's execution. This enables long-running code to benefit from optimization without waiting for the method to return and be invoked again.

Normally, JIT compilation happens at method boundaries. A method is first executed in interpreted mode, and once it becomes “hot” (frequently called), it is compiled so that future invocations run faster. However, this approach is inefficient for methods that contain long-running loops, as a single invocation could spend a significant amount of time executing in the slower interpreter.

OSR addresses this by detecting “hot” loops during execution. The JVM tracks back-edges (backward jumps in bytecode) to identify loops that iterate many times. When a loop crosses a certain threshold, the JIT compiler is triggered to optimize that specific execution path.

Instead of waiting for the method to finish, the JVM performs a mid-execution transition. It extracts the current execution state, including local variables and the current program counter, and compiles a version of the code that can resume exactly from that point.

The runtime then replaces the existing interpreted stack frame with a new compiled frame, populated with the extracted state. Execution continues seamlessly in optimized native code, without restarting the method or losing progress.

In summary, OSR improves performance for long-running computations by enabling dynamic optimization within a single method invocation, ensuring that hot loops do not remain stuck in interpreted execution.

Constant Folding

Constant folding is a compiler optimization technique in Java where expressions involving only constant values are evaluated at compile time instead of at runtime. This allows the compiler to replace the expression with its computed result directly in the bytecode.

For example, in the expression int secondsInHour = 60 * 60;, the compiler computes the value 3600 during compilation and stores it directly. Similarly, string concatenations involving only literals, such as "Hello, " + "World", are folded into a single string "Hello, World".

This optimization reduces the number of operations performed at runtime, resulting in more efficient execution and smaller bytecode. However, constant folding only applies when all values are known at compile time. Expressions that depend on variables, user input, or runtime data cannot be folded unless those variables are declared final and their values are compile-time constants.

In addition to compile-time optimizations performed by the Java compiler, the Just-In-Time (JIT) compiler may apply more advanced constant folding at runtime based on actual execution behavior.

Loop Unrolling

Loop unrolling is an optimization technique that reduces the overhead of loop execution by performing more work in each iteration. Instead of executing a single operation per iteration, the loop body is expanded to handle multiple elements at once, decreasing the total number of iterations.

This improves performance in several ways. Fewer iterations mean fewer loop condition checks and counter updates, reducing control overhead. It also lowers the number of branch instructions, which can help minimize CPU pipeline stalls caused by branch mispredictions. Additionally, having more operations in a single iteration can expose instruction-level parallelism, allowing the CPU to execute instructions more efficiently.

For example, a standard loop:

1for (int i = 0; i < 100; i++) {
2    doWork(i);
3}

Can be unrolled to process multiple elements per iteration:

1for (int i = 0; i < 100; i += 4) {
2    doWork(i);
3    doWork(i + 1);
4    doWork(i + 2);
5    doWork(i + 3);
6}

In practice, manual loop unrolling is rarely needed in Java because the HotSpot Just-In-Time (JIT) compiler can automatically apply this optimization when it determines that it is beneficial. The decision depends on factors such as loop size, complexity, and runtime profiling data.

Dead Code Elimination

Dead code elimination (DCE) is an optimization technique in Java where the compiler or runtime removes code that does not affect the program’s observable behavior. By eliminating unnecessary instructions, the runtime can execute more efficiently and produce smaller bytecode or machine code.

One common form of dead code is unreachable code, which can never be executed. Examples include statements after a return, or code inside a if (false) block. Another form is unused computations, where a value is calculated but never read, such as assigning to a variable that is never used again.

The Java compiler (javac) performs only limited dead code elimination. It mainly removes code based on compile-time constants, such as conditions involving static final values. This enables simple forms of conditional compilation.

More advanced dead code elimination is performed by the Just-In-Time (JIT) compiler at runtime. Using profiling information, the JIT can identify branches that are never taken, methods that are never invoked, or redundant computations, and remove or optimize them dynamically.