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
- 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
NullPointerExceptionwhen calling a method on an uninitialized variable - Passing a
Stringwhere anintis expected - Dividing by zero
- Referencing a variable that was never declared
- 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.
- 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
.classfile produced on a Linux machine can run on a Windows machine without recompilation? What would break this guarantee? - The chapter states that the bootstrap class loader returns
nullwhen queried in Java. Run theClassLoaderDemoprogram from the chapter and observe the output. Why does Java represent the bootstrap loader asnullrather than as a proper object? What does this tell you about the relationship between the bootstrap loader and the Java object system? - 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?
- 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?
- The chapter mentions that the bootstrap class loader loads
java.basewhich contains classes likeObject,String, andThread. Consider what would happen if a programmer were able to replace the bootstrap loader's version ofStringwith their own custom implementation. What could go wrong? - 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.