| Home | Resources | Papers | Essays | Short Fiction | About |

JVM

Table of Contents

Introduction

In the chapter on compilation we learned that Java does not create fully formed native binary executables, but instead generates .class files which are used when executing a program. It was mentioned that these class files consisted of "bytecode" which would be fed into Java's runtime engine. We've been using this runtime engine constantly throughout this course, but we've not investigated exactly what it is. Now, we'll be taking a deep-ish dive into Java's runtime engine, the Java Virtual Machine (JVM).

This chapter relies heavily on information provided by the Java Virtual Machine Specification which I have hosted on this website for convenience.

Java's Architecture

Java began development as an object-oriented programming language that would act as a competitor to languages like C++. It initially focused on building software for networked consumer devices. This meant that code written in Java would ideally work on any platform that supported it, without the code itself needing to be targeted to fit platform-specific differences. Java's desire to be inherently cross-platform made it fundamentally distinct from C++, where code had to be compiled into an executable for a particular architecture before it is run. The idea can be visuallized as follows:

+----------+  +----------+  +----------+  +----------+
|  your    |  |  your    |  |  your    |  |  your    |
|  Java    |  |  Java    |  |  Java    |  |  Java    |
| program  |  | program  |  | program  |  | program  |
+----------+  +----------+  +----------+  +----------+
     |             |             |             |
+----------+  +----------+  +----------+  +----------+
|  Java    |  |  Java    |  |  Java    |  |  Java    |
| Platform |  | Platform |  | Platform |  | Platform |
| for      |  | for      |  | for your |  | for your |
| Linux    |  | Win32    |  |Television|  | Toaster  |
+----------+  +----------+  +----------+  +----------+
|  Linux   |  |PC Running|  |   Your   |  |  Your    |
|   Box    |  |Windows NT|  |Television|  | Toaster  |
+----------+  +----------+  +----------+  +----------+

As long as the Java program was written correctly in the first place, it should be capable of being run, without changes, on every single valid Java platform.

The Java ecosystem can be broken down into a few component parts:

  • Java programming language
  • Java class file format
  • Java API
  • Java Virtual Machine

Putting it all together we get:

     compile-time environment                           run-time environment
 - - - - - - - - - - - - - - - - -              - - - - - - - - - - - - - - - - -
|  Your program's source files    |            |  Your program's class files     |
|                                 |            |                                 |
|  +------+ +------+ +------+     |            |  +-------+ +-------+ +-------+  |
|  |A.java| |B.java| |C.java|     |            |  |A.class| |B.class| |C.class|  |
|  +------+ +------+ +------+     |            |  +-------+ +-------+ +-------+  |
|       |        |       |        |            |        \       |      /         |
|       +--------+-------+        | Your class |         v      v     v          |
|                |                | files move |       +---------------+         |
|                v                | locally or |       |     Java      |         |
|         +------------+          | through a  |       |    Virtual    |         |
|         |    Java    |          |  network   |       |    Machine    |         |
|         |  compiler  |          |            |       +---------------+         |
|         +------------+          |            |                ^                |
|                |                |            |                |                |
|                v                |            |  +------------+ +------------+  |
|  +-------+ +-------+ +-------+  |            |  |Object.class| |String.class|  |
|  |A.class| |B.class| |C.class|  |            |  +------------+ +------------+  |
|  +-------+ +-------+ +-------+  |            |      Java API's class files     |
|  Your program's class files     |            |                                 |
 - - - - - - - - - - - - - - - - -              - - - - - - - - - - - - - - - - -

The Java Virtual Machine

We are particularly interested in the last link on the chain, the Java Virtual Machine, or JVM for short. Every single time we've invoked java to run one of our programs we've been spinning up a JVM! At base, the JVM is an abstract computing machine. It is abstract because it is not literally a machine, but a computational model that represents one. The JVM, much like a real computer, "has an instruction set and manipulates various memory areas at run time" (§ 1.2, JVM spec). The major advantage of a machine-independent computing engine is that the code that is written for it can completely ignore machine-specific details if desired.

Reflect on all of the code you have written so far for this course, have you ever written any code that specifically targetted x86_64 systems?

One thing about the JVM that might seem incredibly strange is it knows nothing about the Java programming language! The JVM has been developed to be a totally independent virtual machine. In fact, there have been many non-Java JVM-based languages created that make use of this reality—the most famous being kotlin, clojure, and scala.

JVM Architecture

Taking a bird's eye view of the JVM, we can represent its architecture visually like so:

  Your                                       The
program's                                 Java API's
class files                               class files
     |                                        |
     |              +----------+              |
     +------------->|  class   |<-------------+
                    |  loader  |
                    +----------+
                          |
                      bytecodes
                          |
                          v
                    +----------+
                    | execution|
                    |  engine  |
                    +----------+

The JVM can be broken down into two major parts: something that loads class files (the class loader) and something that executes the instructions contained in those class files (the execution engine).

JVM Implementation

The Java Virtual Machine Specification describes in meticulous detail the internals of class files and the way in which they must be processed by an aspiring JVM. What the specification does not describe is how exactly that must be gone about, neither does it describe the various extra features that can be filled by a JVM developer. This means that much of the JVM specification implementation agnostic. As long as class files can be read and the class libraries of the Java SE Platform can be executed, then the program in question is a valid JVM.

HotSpot JDK

There are many JVMs that have been in use historically and that are in use today. In this class, all of our calls to java have actually been calls to the HotSpot JVM. You can verify this with a call to your java executable:

josephraskind@stargazer:/tmp/jvm$ java --version
openjdk 22.0.1 2024-04-16
OpenJDK Runtime Environment (build 22.0.1+8-16)
OpenJDK 64-Bit Server VM (build 22.0.1+8-16, mixed mode, sharing)

We can see above that right now I have openjdk 22.0.1 installed on my machine which is using the OpenJDK Runtime Environment. The internal VM implementation of the OpenJDK Runtime Environment is called HotSpot.

Different JVM interpretations will focus on different features/development environments. HotSpot is used due to its position as the primary reference implementation. GraalVM has its own unique JIT compiler which rivals HotSpot's. Eclipse OpenJ9, originally developed for IBM, has a low memory footprint and fast startup and sees adoption in cloud environments. Azul Zulu provides a OpenJDK-verified JVM implementation which does not require the same commercial licensing agreements as HotSpot.

JVM as Java Interpreter

It is often said that the JVM is a "java interpreter". This is not true in the general case as not all JVMs are strictly interpreters. JVMs read JVM bytecode, but how they go about reading that bytecode is up to the developers. Many JVMs actually compile JVM bytecode down to native code as needed in a process called Just-in-Time compilation (more on this later). There are other JVMs which actually directly run JVM bytecode on hardware, although this is rare. As was mentioned before, the JVM is also not Java-specific—all valid JVMs should be able to process code generated from clojure, scala, kotlin, etc.

The class File

The major "atomic" unit in the JVM is the class file. About ~280 pages of the JVM spec is dedicated to detailing the innerworkings of the class file structure and how it should be processed within a JVM. Each class file contains the definition of a single class, interface, or module. Class files are distinct from executables that are generated from compiling C or C++ insofar as they are meant to be loaded and executed by a JVM whereas C/C++ binaries are meant to run directly on the target machine.

Bytecode

We can take a look at the internal of a class file by using the javap program which comes with an installation of the OpenJDK. First we write a simple Java program:

1: public class Add{
2:     public int add(int x, int y){
3:         int sum = x + y;
4:         return sum;
5:     }
6: }

We then compile it with javac:

josephraskind@stargazer:/tmp/jvm$ javac Add.java 
josephraskind@stargazer:/tmp/jvm$ ls
Add.class  Add.java

This will create a class file whose bytecode we can investigate with javap -c:

 1: Compiled from "Add.java"
 2: public class Add {
 3:   public Add();
 4:     Code:
 5:        0: aload_0
 6:        1: invokespecial #1                  // Method java/lang/Object."<init>":()V
 7:        4: return
 8: 
 9:   public int add(int, int);
10:     Code:
11:        0: iload_1
12:        1: iload_2
13:        2: iadd
14:        3: istore_3
15:        4: iload_3
16:        5: ireturn
17: }

What we find is something that looks vaguely like assembly code, but is missing a lot of th e more verbose syntax associated with x86 or arm. We see that Add is provided a default constructor (Lines 3-7) and we see that the add method is represented as a set of integer loads, an integer addition, an integer store, and return. But how does this tell the JVM how to load this class? In reality, there's more information in the class file than this dump is showing. If we add the -v flag to the command we get:

 1: Classfile /tmp/jvm/Add.class
 2:   Last modified Jul 31, 2026; size 242 bytes
 3:   MD5 checksum 11450389ce554c0e8392091021b12d00
 4:   Compiled from "Add.java"
 5: public class Add
 6:   minor version: 0
 7:   major version: 66
 8:   flags: (0x0021) ACC_PUBLIC, ACC_SUPER
 9:   this_class: #7                          // Add
10:   super_class: #2                         // java/lang/Object
11:   interfaces: 0, fields: 0, methods: 2, attributes: 1
12: Constant pool:
13:    #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
14:    #2 = Class              #4             // java/lang/Object
15:    #3 = NameAndType        #5:#6          // "<init>":()V
16:    #4 = Utf8               java/lang/Object
17:    #5 = Utf8               <init>
18:    #6 = Utf8               ()V
19:    #7 = Class              #8             // Add
20:    #8 = Utf8               Add
21:    #9 = Utf8               Code
22:   #10 = Utf8               LineNumberTable
23:   #11 = Utf8               add
24:   #12 = Utf8               (II)I
25:   #13 = Utf8               SourceFile
26:   #14 = Utf8               Add.java
27: {
28:   public Add();
29:     descriptor: ()V
30:     flags: (0x0001) ACC_PUBLIC
31:     Code:
32:       stack=1, locals=1, args_size=1
33:          0: aload_0
34:          1: invokespecial #1                  // Method java/lang/Object."<init>":()V
35:          4: return
36:       LineNumberTable:
37:         line 1: 0
38: 
39:   public int add(int, int);
40:     descriptor: (II)I
41:     flags: (0x0001) ACC_PUBLIC
42:     Code:
43:       stack=2, locals=4, args_size=3
44:          0: iload_1
45:          1: iload_2
46:          2: iadd
47:          3: istore_3
48:          4: iload_3
49:          5: ireturn
50:       LineNumberTable:
51:         line 3: 0
52:         line 4: 4
53: }
54: SourceFile: "Add.java"

We now see there's a lot more going on! We can see that the class file gives the JVM information on the accessibility of the class (ACC_PUBLIC on Line 8), it collects all of the constants that can be referenced (Lines 12-26), it provides type information on all of the methods (for example (II)I for a method that takes two ints and returns an int on Line 40), and much more.

The Class Loader

How exactly do classes enter the JVM's runtime environment? It's done through the class loader. The class loader takes the requisite class files, interprets them, and turns them into an in-memory representation that functions as the classes you have defined.

When the JVM is launched one of the first things it does is it begins to load all of the Java standard library files. If you downloaded a version of the jdk onto your machine you can see the actual files that the JVM loads in path-to-jdk/jmods/java.base.jmod:

josephraskind@stargazer:/tmp/jvm$ ls ~/jdk22/jdk-22.0.1/jmods/
java.base.jmod            jdk.attach.jmod                     jdk.jdwp.agent.jmod
java.compiler.jmod        jdk.charsets.jmod                   jdk.jfr.jmod
java.datatransfer.jmod    jdk.compiler.jmod                   jdk.jlink.jmod
                                    ...
jdk.accessibility.jmod    jdk.jdi.jmod                        jdk.zipfs.jmod

What we see above are the collection of java packages that come pre-archived in a jdk installation. The first file java.base.jmod contains all of the essential class files that the JVM needs to get started. We can list them out with jmod list:

josephraskind@stargazer:/tmp/jvm$ jmod list ~/jdk22/jdk-22.0.1/jmods/java.base.jmod 
classes/module-info.class
classes/META-INF/services/java.nio.file.spi.FileSystemProvider
classes/com/sun/crypto/provider/AEADBufferedStream.class
  ...
lib/tzdb.dat

There are about 7.6k files so it wouldn't do any good to list them here. If we unzip the file we can find the Object.class file buried within:

josephraskind@stargazer:/tmp/jvm$ jmod extract ~/jdk22/jdk-22.0.1/jmods/java.base.jmod --dir ./class_files

Now we can look at the Object.class file:

josephraskind@stargazer:/tmp/jvm$ javap ./class_files/classes/java/lang/Object.class 
Compiled from "Object.java"
public class java.lang.Object {
  public java.lang.Object();
  public final native java.lang.Class<?> getClass();
  public native int hashCode();
  public boolean equals(java.lang.Object);
  protected native java.lang.Object clone() throws java.lang.CloneNotSupportedException;
  public java.lang.String toString();
  public final native void notify();
  public final native void notifyAll();
  public final void wait() throws java.lang.InterruptedException;
  public final void wait(long) throws java.lang.InterruptedException;
  public final void wait(long, int) throws java.lang.InterruptedException;
  protected void finalize() throws java.lang.Throwable;
}

A full bytecode dump would be hundreds of lines, so I leave that as an exercise for the reader.

We can test that standard library methods are loaded before anything else using the following code:

 1: public class ClassLoaderDemo {
 2:     public static void main(String[] args){
 3: 
 4:         // Our own class -- loaded by the application class loader
 5:         System.out.printf("ClassLoaderDemo loaded from: %s%n", ClassLoaderDemo.class.getClassLoader());
 6: 
 7:         // A core Java class -- loaded by the bootstrap loader (null)
 8:         System.out.printf("String loaded from: %s%n", String.class.getClassLoader());
 9:     }
10: }

If we compile and run the above:

loaded from: nulljosephraskind@stargazer:/tmp/jvm$ java ClassLoaderDemo.java 
ClassLoaderDemo loaded from: com.sun.tools.javac.launcher.MemoryClassLoader@1d7acb34
String loaded from: null

We see that our ClassLoaderDemo was loaded in from an in-memory process that exists during the JVM's runtime whereas String seems to have existed primordially. This is because the boostrap class loader works before anything else to set up the Java Standard Environment.

Java vs C++

Java belongs to the branch of languages called "managed runtime languages". This is because Java was built on the idea that it would be run in tandem with the JVM. Other languages that fit the bill would be Python, Lisp, Perl, Bash, etc. These languages are in direct contrast to "unmanaged runtime languages" like C, C++, Rust, etc. Managed runtime languages utilize powerful abstractions to separate the programmer as much as possible from low-level implementation details. For example, in this course we have never once seriously thought about how memory is freed up after we allocate it. This is a major benefit of Java and other managed runtime languages—the technique is called "garbage collection". We also get other goodies like runtime type-checking, array bounds checking, I/O exceptions, and much more. Not to mention the fact that Java has an incredibly robust "reflection" API that lets programmers manipulate and inspect types on the fly.

What we gain in functionality, we lose in speed. Unlike C++, and other unmanaged runtimes, there is massive overhead associated with running the JVM. Although it appears to be non-existant for our simple programs, it can really start to crop up on enterprise-level applications. JVMs have different ways of combatting this problem, but I will mention only one here: Just-in-Time compilation.

Just-in-Time Compilation

Just-in-Time, or JIT, compilation refers to a technique whereby a JVM identifies portions of code which are running "hot" and converting them directly to native binary. "Hotness" varies by JVM settings, but it essentially boils down to how many times a method, or block of code, is executed within a certain span of time. Take for example a MatrixMult class which contains a static method:

 1: public class MatrixMult{
 2:     static int[][] matrixMult(int[][]mat1, int[][]mat2){
 3:         final int M = mat1.length;
 4:         final int N = mat2.length;
 5:         final int K = mat2[0].length;
 6:         int[][] matRes = new int[M][K];
 7:         for(int i = 0; i < M; i++){
 8:             for(int j = 0; j < K; j++){
 9:                 matRes[i][j] = 0;
10:                 for(int k = 0; k < N; k++){
11:                     matRes[i][j] += mat1[i][k] * mat2[k][j];
12:                 }
13:             }
14:         }
15:         return matRes;
16:     }
17: }

We can then write a Driver class which sets up using the multiplication method for X number of times:

 1: public class Driver{
 2:     public static void main(String[] args){
 3:         final int X = Integer.parseInt(args[0]);
 4:         int[][] mat = {{1,2,3,4,5},{1,2,3,4,5},{1,2,3,4,5},{1,2,3,4,5},{1,2,3,4,5}};
 5:         long startTime = System.currentTimeMillis();
 6:         for(int i = 0; i < X; i++)
 7:             MatrixMult.matrixMult(mat, mat);
 8:         System.out.printf("Time Elapsed: %fs%n", ((System.currentTimeMillis()-startTime)/1000.0));
 9:     }
10: }

After compiling and running the code normally we get:

josephraskind@stargazer:/tmp/jvm$ java Driver.java 10000000
Time Elapsed: 4.625000s

Not too shabby for running 10 million matrix multiplication method calls. A lot of this has to do with the fact that JITting is turned on by default. If I run it with JIT explicitly turned off:

josephraskind@stargazer:/tmp/jvm$ java -Xint Driver.java 10000000
Time Elapsed: 42.209000s

we see that the above takes 42s, a nearly 10X speed difference! We can confirm that the JIT compiler made the difference using the -XX:+PrintCompilation flag with java:

josephraskind@stargazer:/tmp/jvm$ java -XX:+PrintCompilation Driver.java 10000000 > compilation.txt
josephraskind@stargazer:/tmp/jvm$ grep -e "MatrixMult" ./compilation.txt 
737 1537       3       MatrixMult::matrixMult (105 bytes)
741 1538 %     4       MatrixMult::matrixMult @ 52 (105 bytes)
750 1539       4       MatrixMult::matrixMult (105 bytes)
756 1537       3       MatrixMult::matrixMult (105 bytes)   made not entrant

We can see that MatrixMult was natively compiled during the execution which heavily impacted the running time.

Exercises

  1. The chapter describes three distinct stages a Java program passes through before producing output: writing source code, compiling to bytecode, and executing on the JVM. For each of the following errors, identify which stage it would be caught at and explain why:
    • A missing semicolon at the end of a statement
    • A NullPointerException when calling a method on an uninitialized variable
    • Passing a String where an int is expected
    • Dividing by zero
    • Referencing a variable that was never declared
  2. The chapter draws a distinction between the compile-time environment and the runtime environment. In your own words explain why these are two separate environments rather than one. What is the practical benefit of separating compilation from execution? Think about what this means for distributing a Java program to someone who uses a different operating system than you.
  3. The chapter states that the JVM is responsible for executing bytecode regardless of the underlying hardware or operating system. In your own words explain what the JVM must know about the machine it is running on, and what it deliberately hides from your program. Why is it significant that the same .class file produced on a Linux machine can run on a Windows machine without recompilation? What would break this guarantee?
  4. The chapter states that the bootstrap class loader returns null when queried in Java. Run the ClassLoaderDemo program from the chapter and observe the output. Why does Java represent the bootstrap loader as null rather than as a proper object? What does this tell you about the relationship between the bootstrap loader and the Java object system?
  5. The chapter contrasts interpretation and JIT compilation as two strategies for executing bytecode. In your own words describe the tradeoff between the two. Under what circumstances would a pure interpreter be preferable to a JIT compiler? Under what circumstances would the reverse be true? Where does HotSpot's mixed-mode approach sit on this spectrum and why?
  6. The chapter explains that Java's "write once, run anywhere" guarantee is made possible by the JVM acting as an intermediary between bytecode and the underlying hardware. Using the platform diagram from the chapter as a reference, explain what would have to be true of a new device (say, a smartwatch) for it to run Java programs. Who would be responsible for making that possible and what would they need to produce?
  7. The chapter mentions that the bootstrap class loader loads java.base which contains classes like Object, String, and Thread. Consider what would happen if a programmer were able to replace the bootstrap loader's version of String with their own custom implementation. What could go wrong?
  8. The chapter describes the JVM as an abstraction layer that hides the details of the underlying hardware. This is a recurring theme throughout the textbook — abstraction allowing programmers to work at a higher level without worrying about lower-level details. List three other abstractions you have encountered in this course and for each one identify what lower-level detail it is hiding from the programmer.
Contact: [email protected] | rss feed | Compiled with org-mode | Licensed under CC BY-NC-SA 4.0