Product
|
Cloud costs
|
released
June 21, 2020
|
3
min read
|

JVM Architecture 101: Get to Know Your Virtual Machine

Updated

A beginner’s crash course in Java Virtual Machine (JVM) architecture and Java bytecode 101

Java applications are all around us, they’re on our phones, on our tablets, and on our computers. In many programming languages this means compiling the code multiple times in order for it to run on different OSes. For us as developers, maybe the coolest thing about Java is that it’s designed to be platform-independent (as the old saying goes, “Write once, run anywhere”), so we only need to write and compile our code once.


How is this possible? Let’s dig into the Java Virtual Machine (JVM) to find out.

Psst! Looking for a solution to help you identify and resolve application errors? Harness Service Reliability Management provides code-level insights into all errors, exceptions, and slowdowns at all stages of the software delivery lifecycle.

The JVM Architecture

It may sound surprising, but the JVM itself knows nothing about the Java programming language. Instead, it knows how to execute its own instruction set, called Java bytecode, which is organized in binary class files. Java code is compiled by the javac command into Java bytecode, which in turn gets translated into machine instructions by the JVM at runtime.

Threads

Java is designed to be concurrent, which means that different calculations can be performed at the same time by running several threads within the same process. When a new JVM process starts, a new thread (called the main thread) is created within the JVM. From this main thread, the code starts to run and other threads can be spawned. Real applications can have thousands of running threads that serve different purposes. Some serve user requests, others execute asynchronous backend tasks, etc.

Stack and Frames

Each Java thread is created along with a frame stack designed to hold method frames and to control method invocation and return. A method frame is used to store data and partial calculations of the method to which it belongs. When the method returns, its frame is discarded. Then, its return value is passed back to the invoker frame that can now use it to complete its own calculation.

JVM Process Structure

The JVM playground for executing a method is the method frame. The frame consists of two main parts:

  1. Local Variables Array – where the method’s parameters and local variables are stored
  2. Operand Stack – where the method’s computations are performed

Frame structure

Almost every bytecode command manipulates at least one of these two. Let’s see how.

How It Works

Let’s go over a simple example to understand how the different elements play together to run our program. Assume we have this simple program that calculates the value of 2+3 and prints the result:

class SimpleExample {
       public static void main(String[] args) {
              int result = add(2,3);
              System.out.println(result);
       }
       public static int add(int a, int b) {
              return a+b;
       }
}

To compile this class we run javac SimpleExample.java, which results in the compiled file SimpleExample.class. We already know this is a binary file that contains bytecode. So how can we inspect the class bytecode? Using javap.

javap is a command line tool that comes with the JDK and can disassemble class files. Calling javap -c -p prints out the disassembled bytecode (-c) of the class, including private (-p) members and methods:

Compiled from "SimpleExample.java"
class SimpleExample {  
       SimpleExample();    
              Code:      
                     0: aload_0      
                     1: invokespecial #1                  // Method java/lang/Object."<init>":()V      
                     4: return  public
static void main(java.lang.String[]);    
              Code:      
                     0: iconst_2      
                     1: iconst_3      
                     2: invokestatic  #2                  // Method add:(II)I      
                     5: istore_1      
                     6: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;      
                     9: iload_1      
                    10: invokevirtual #4                  // Method java/io/PrintStream.println:(I)V      
                    13: return  
public static int add(int, int);    
                Code:      
                    0: iload_0      
                    1: iload_1      
                    2: iadd      
                    3: ireturn
}

Now what happens inside the JVM at runtime? java SimpleExample starts a new JVM process and the main thread is created. A new frame is created for the main method and pushed into the thread stack.

public static void main(java.lang.String[]);  
       Code:    
                    0: iconst_2    
                    1: iconst_3    
                    2: invokestatic  #2                  // Method add:(II)I    
.                   5: istore_1    
                    6: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;    
                    9: iload_1    
                  10: invokevirtual #4                  // Method java/io/PrintStream.println:(I)V  
                  13: return

The main method has two variables: args and result. Both reside in the local variable table. The first two bytecode commands of main, iconst_2, and iconst_3, load the constant values 2 and 3 (respectively) into the operand stack. The next command invokestatic invokes the static method add. Since this method expects two integers as arguments, invokestatic pops two elements from the operand stack and passes them to the new frame created by the JVM for add. main’s operand stack is empty at this point.

public static int add(int, int);  
       Code:    
                   0: iload_0    
                   1: iload_1    
                   2: iadd    
                   3: ireturn

In the add frame, these arguments are stored in the local variable array. The first two bytecode commands, iload_0 and iload_1 load the 0th and the 1st local variables into the stack. Next, iadd pops the top two elements from the operand stack, sums them up, and pushes the result back into the stack. Finally, ireturn pops the top element and passes it to the calling frame as the return value of the method, and the frame is discarded.

public static void main(java.lang.String[]);  
       Code:    
                  0: iconst_2    
                  1: iconst_3    
                  2: invokestatic  #2                  // Method add:(II)I    
                  5: istore_1    
                  6: getstatic     #3                  // Field java/lang/System.out:Ljava/io/PrintStream;    
                  9: iload_1    
                10: invokevirtual #4                  // Method java/io/PrintStream.println:(I)V    
                13: return

main’s stack now holds the return value of add. istore_1 pops it and sets it as the value of the variable at index 1, which is result. getstatic pushes the static field java/lang/System.out of type java/io/PrintStream onto the stack. iload_1 pushes the variable at index 1, which is the value of result that now equals 5, onto the stack. So at this point the stack holds 2 values: the ‘out’ field and the value 5. Now invokevirtual is about to invoke the PrintStream.println method. It pops two elements from the stack: the first one is a reference to the object for which the println method is going to be invoked. The second element is an integer argument to be passed to the println method, that expects a single argument. This is where the main method prints the result of add. Finally, the return command finishes the method. The main frame is discarded, and the JVM process ends.

This is it. All in all, not too complex.

“Write Once, Run Anywhere”

So what makes Java platform-independent? It all lies in the bytecode.


As we saw, any Java program compiles into standard Java bytecode. The JVM then translates it into the specific machine instructions at runtime. We no longer need to make sure our code is machine-compatible. Instead, our application can run on any device equipped with a JVM, and the JVM will do it for us. It’s the job of the JVM’s maintainers to provide different versions of JVMs to support different machines and operating systems.
This architecture enables any Java program to run on any device having a JVM installed on it. And so the magic happens.

Final Thoughts

Java developers can write great applications without understanding how the JVM works.
However, digging into the JVM architecture, learning its structure, and realizing how it interprets your code will help you become a better developer. It will also help you tackle really complex problem from time to time 🙂

Sign up now

Sign up for our free plan, start building and deploying with Harness, take your software delivery to the next level.

Get a demo

Sign up for a free 14 day trial and take your software development to the next level

Documentation

Learn intelligent software delivery at your own pace. Step-by-step tutorials, videos, and reference docs to help you deliver customer happiness.

Case studies

Learn intelligent software delivery at your own pace. Step-by-step tutorials, videos, and reference docs to help you deliver customer happiness.

We want to hear from you

Enjoyed reading this blog post or have questions or feedback?
Share your thoughts by creating a new topic in the Harness community forum.

Sign up for our monthly newsletter

Subscribe to our newsletter to receive the latest Harness content in your inbox every month.

Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.
Service Reliability Management