Understanding JVM Architecture

Sahiladhav
16 min readMay 4, 2021

--

Whether you use Java for programming or not, you may have heard of Java Virtual Machine (JVM) at one time or another.

JVM is the backbone of the Java ecosystem program, and enables Java-based software programs to follow “write once, run anywhere”. You can write Java code on one machine, and apply it to another machine using JVM.

JVM was originally designed to support Java only. In time, however, many other languages, such as Scala, Kotlin, and Groovy, adopted the Java platform. All of these languages ​​collectively are known as JVM languages.

In this article, we will learn more about JVM, how it works, and the various components that makeup it.

What is the Java Virtual Machine?

In programming languages ​​such as C and C ++, the code is first integrated into the machine-specific machine code. These languages ​​are called compiled languages.

On the other hand, in languages ​​such as JavaScript and Python, the computer automatically performs instructions without the need for an encryption algorithm. These languages ​​are called interpreted languages.

Java uses a combination of both strategies. The Java code is first integrated into a byte code to generate a class file. This class file is then translated by the Java JavaScript platform below. The same class file can be created on any type of JVM that works on any platform and application.

Similar to virtual machines, JVM creates a remote space in the host machine. This space can be used to run Java applications regardless of platform or machine application.

Java Virtual Machine Architecture

The JVM consists of three distinct components:

  1. Class Loader
  2. Runtime Memory/Data Area
  3. Execution Engine

Let’s take a look at each of them in more detail.

Class Loader

JVM resides in RAM. During the process, using the Class Loader system, the class files are brought to RAM. This is called the Java phase transition function. It loads, links, and initializes the class file (.class) when it refers to a class for the first time at runtime (not compile time).

1.1) Loading

Loading compiled classes (.class files) into memory is a big task of Class Loader. Usually, the class loading process starts from loading the main class (i.e. class with static main() method declaration). All the subsequent class loading attempts are done according to the class references in the already-running classes as mentioned in the following cases:

  • When bytecode make a static reference to a class (e.g. System.out)
  • When bytecode create a class object (e.g. Person person = new Person("Sahil"))

There are 3 types of class loaders and follow 4 major principles.

1.1.1) Visibility Principle

This principle states that Child Class Loader can see the class loaded by Parent Class Loader, but a Parent Class Loader cannot find the class loaded by Child Class Loader.

1.1.2) Uniqueness Principle

This principle states that a class loaded by a parent should not be loaded by Child Class Loader again and ensure that duplicate class loading does not occur.

1.1.3) Delegation Hierarchy Principle

In order to satisfy the above 2 principles, JVM follows a hierarchy of delegation to choose the class loader for each class loading request. Here, starting from the lowest child level, Application Class Loader delegates the received class loading request to Extension Class Loader, and then Extension Class Loader delegates the request to Bootstrap Class Loader. If the requested class found in the Bootstrap path, the class is loaded. Otherwise, the request again transfers back to the Extension Class Loader level to find the class from the Extension path or custom-specified path. If it also fails, the request comes back to Application Class Loader to find the class from the System classpath and if Application Class Loader also fails to load the requested class, then we get the run time exception — java.lang.ClassNotFoundException.

Java Class Loaders — Delegation Hierarchy Principle (Image: StackOverflow.com)

1.1.4) No Unloading Principle

Even though a Class Loader can load a class, it cannot unload a loaded class. Instead of unloading, the current class loader can be deleted, and a new class loader can be created.

  • Bootstrap Class Loader: This is the root class loader. It is the superclass of Extension Class Loader and loads the standard Java packages like java.lang, java.net, java.util, java.io, and so on. These packages are present inside the rt.jar file and other core libraries present in the $JAVA_HOME/jre/lib directory.
  • Extension Class Loader: This is the subclass of the Bootstrap Class Loader and the superclass of the Application Class Loader. This loads the extensions of standard Java libraries which are present in the $JAVA_HOME/jre/lib/ext directory.
  • System/Application Class Loader: This is the final class loader and the subclass of Extension Class Loader. It loads the files present on the classpath. By default, the classpath is set to the current directory of the application. The classpath can also be modified by adding the -classpath or -cp command line option.

The JVM uses the ClassLoader.loadClass() method for loading the class into memory. It tries to load the class based on a fully qualified name.

If a parent class loader is unable to find a class, it delegates the work to a child class loader. If the last child class loader isn’t able to load the class either, it throws NoClassDefFoundError or ClassNotFoundException.

1.2) Linking

Linking involves in verifying and preparing a loaded class or interface, its direct superclasses and superinterfaces, and its element type as necessary, while following the below properties.

  • A class or interface must be completely loaded before it is linked.
  • A class or interface must be completely verified and prepared before it initialized (in the next step).
  • If an error occurs during linking, it is thrown at a point in the program where some action will be taken by the program that might, directly or indirectly, require linkage to the class or interface involved in the error.

Linking occurs in 3 stages as below.

  • Verification: ensure the correctness of .class file (is the code properly written according to Java Language Specification? is it generated by a valid compiler according to JVM specifications?). This is the most complicated test process of the class load processes, and takes the longest time. Even though linking slows down the class loading process, it avoids the need to perform these checks for multiple times when executing bytecode, hence makes the overall execution efficient and effective. If verification fails, it throws runtime errors (java.lang.VerifyError). For instance, the following checks are performed.
- consistent and correctly formatted symbol table
- final methods / classes not overridden
- methods respect access control keywords
- methods have correct number and type of parameters
- bytecode doesn’t manipulate stack incorrectly
- variables are initialized before being read
- variables are a value of the correct type
  • Preparation: In this phase, the JVM allocates memory for the static fields of a class or interface, and initializes them with default values.
  • For example, assume that you have declared the following variable in your class:
private static final boolean enabled = true;

During the preparation phase, JVM allocates memory for the variable enabled and sets its value to the default value for a boolean, which is false.

  • Resolution: n this phase, symbolic references are replaced with direct references present in the runtime constant pool.
  • For example, if you have references to other classes or constant variables present in other classes, they are resolved in this phase and replaced with their actual references.

1.3) Initialization

Initialization involves executing the initialization method of the class or interface (known as <clinit>). This can include calling the class's constructor, executing the static block, and assigning values to all the static variables. This is the final stage of class loading.

For example, when we declared the following code earlier:

private static final boolean enabled = true;

The variable enabled was set to its default value of false during the preparation phase. In the initialization phase, this variable is assigned its actual value of true.

Note: the JVM is multi-threaded. It can happen that multiple threads are trying to initialize the same class at the same time. This can lead to concurrency issues. You need to handle thread safety to ensure that the program works properly in a multi-threaded environment.

Runtime Data Area

Runtime Data Areas are the memory areas assigned when the JVM program runs on the OS. In addition to reading .class files, the Class Loader subsystem generates corresponding binary data and saves the following information in the Method area for each class separately.

  • The fully qualified name of the loaded class and its immediate parent class
  • Whether .class file is related to a Class/Interface/Enum
  • Modifiers, static variables, and method information, etc.

Then, for every loaded .class file, it creates exactly one object of Class to represent the file in the Heap memory as defined in java.lang package. This Class object can be used to read class level information (class name, parent name, methods, variable information, static variables, etc.) later in our code.

Let’s look at each one individually.

2.1) Method Area (Shared among Threads)

This is a shared resource (only 1 method area per JVM). All JVM threads share this same Method area, so the access to the Method data and the process of dynamic linking must be thread-safe.

Method area stores class-level data (including static variables) such as:

  • Classloader reference
  • Run time constant pool — Numeric constants, field references, method references, attributes; As well as the constants of each class and interface, it contains all references for methods and fields. When a method or field is referred to, the JVM searches the actual address of the method or field on the memory by using the runtime constant pool.
  • Field data — Per field: name, type, modifiers, attributes
  • Method data — Per method: name, return type, parameter types (in order), modifiers, attributes
  • Method code — Per method: bytecodes, operand stack size, local variable size, local variable table, exception table; Per exception handler in exception table: start point, endpoint, PC offset for handler code, constant pool index for exception class being caught

2.2) Heap Area (Shared among Threads)

All the objects and their corresponding instance variables are stored here. This is the run-time data area from which memory for all class instances and arrays is allocated.

For example, assume that you are declaring the following instance:

Employee employee = new Employee();

In this code example, an instance of Employee is created and loaded into the heap area.

The heap is created on the virtual machine start-up, and there is only one heap area per JVM.

Note: Since the Method and Heap areas share the same memory for multiple threads, the data stored here is not thread-safe.

2.3) Stack Area (per Thread)

Whenever a new thread is created in the JVM, a separate runtime stack is also created at the same time. All local variables, method calls, and partial results are stored in the stack area.

If the processing being done in a thread requires a larger stack size than what’s available, the JVM throws a StackOverflowError.

For every method call, one entry is made in the stack memory which is called the Stack Frame. When the method call is complete, the Stack Frame is destroyed.

The Stack Frame is divided into three sub-parts:

  • Local Variables — It has an index starting from 0. For a particular method, how many local variables are involved and the corresponding values are stored here. 0 is the reference of a class instance where the method belongs. From 1, the parameters sent to the method are saved. After the method parameters, the local variables of the method are saved.
  • Operand Stack — This acts as a runtime workspace to perform any intermediate operation if there’s a requirement. Each method exchanges data between the Operand stack and the local variable array and pushes or pops other methods to invoke results. The necessary size of the Operand stack space can be determined during compiling. Therefore, the size of the Operand stack can also be determined during compiling.
  • Frame Data — All symbols corresponding to the method are stored here. This also stores the catch block information in case of exceptions.

Since these are runtime stack frames, after a thread terminates, its stack frame will also be destroyed by JVM.

A stack can be a dynamic or fixed size. If a thread requires a larger stack than allowed a StackOverflowError is thrown. If a thread requires a new frame and there isn’t enough memory to allocate it then an OutOfMemoryError is thrown.

For example, assume that you have the following code:

double calculateNormalisedScore(List<Answer> answers) {

double score = getScore(answers);
return normalizeScore(score);
}
double normalizeScore(double score) {

return (score – minScore) / (maxScore – minScore);
}

In this code example, variables like answers and score are placed in the Local Variables array. The Operand Stack contains the variables and operators required to perform the mathematical calculations of subtraction and division.

Note: Since the Stack Area is not shared, it is inherently threaded safe.

2.4) PC Registers (per Thread)

For each JVM thread, when the thread starts, a separate PC (Program Counter) Register gets created in order to hold the address of currently-executing instruction (memory address in the Method area). If the current method is native then the PC is undefined. Once the execution finishes, the PC register gets updated with the address of the next instruction.

2.5) Native Method Stack (per Thread)

There is a direct mapping between a Java thread and a native operating system thread. After preparing all the state for a Java thread, a separate native stack also gets created in order to store native method information (often written in C/C++) invoked through JNI (Java Native Interface).

Once the native thread has been created and initialized, it invokes the run()method in the Java thread. When the run() method returns, uncaught exceptions (if any) are handled, then the native thread confirms whether the JVM needs to be terminated as a result of the thread terminating (i.e. is it the last non-daemon thread). When the thread terminates, all resources for both the native and Java threads are released.

The native thread is reclaimed once the Java thread terminates. The operating system is therefore responsible for scheduling all threads and dispatching them to any available CPU.

3) Execution Engine

Once the bytecode has been loaded into the main memory, and details are available in the runtime data area, the next step is to run the program. The Execution Engine handles this by executing the code present in each class.

However, before executing the program, the bytecode needs to be converted into machine language instructions. The JVM can use an interpreter or a JIT compiler for the execution engine.

3.1) Interpreter

The interpreter interprets the bytecode and executes the instructions one by one. Hence, it can interpret one bytecode line quickly, but executing the interpreted result is a slower task. The disadvantage is that when one method is called multiple times, each time a new interpretation and a slower execution are required.

3.2) Just-In-Time (JIT) Compiler

The JIT Compiler overcomes the disadvantage of the interpreter. The Execution Engine first uses the interpreter to execute the byte code, but when it finds some repeated code, it uses the JIT compiler.

The JIT compiler then compiles the entire bytecode and changes it to native machine code. This native machine code is used directly for repeated method calls, which improves the performance of the system.

The JIT Compiler has the following components:

  1. Intermediate Code Generator — generates intermediate code
  2. Code Optimizer — optimizes the intermediate code for better performance
  3. Target Code Generator — converts intermediate code to native machine code
  4. Profiler — finds the hotspots (code that is executed repeatedly)

To better understand the difference between interpreter and JIT compiler, assume that you have the following code:

int sum = 10;
for(int i = 0 ; i <= 10; i++) {
sum += i;
}
System.out.println(sum);

An interpreter will fetch the value of sum from memory for each iteration in the loop, add the value of i to it, and write it back to memory. This is a costly operation because it is accessing the memory each time it enters the loop.

However, the JIT compiler will recognize that this code has a HotSpot, and will perform optimizations on it. It will store a local copy of sum in the PC register for the thread and will keep adding the value of i to it in the loop. Once the loop is complete, it will write the value of sum back to memory.

Note: a JIT compiler takes more time to compile the code than for the interpreter to interpret the code line by line. If you are going to run a program only once, using the interpreter is better.

3.3) Garbage Collector (GC)

The Garbage Collector (GC) collects and removes unreferenced objects from the heap area. It is the process of reclaiming the runtime unused memory automatically by destroying them.

Garbage collection makes Java memory efficient because because it removes the unreferenced objects from heap memory and makes free space for new objects. It involves two phases:

  1. Mark — in this step, the GC identifies the unused objects in memory
  2. Sweep — in this step, the GC removes the objects identified during the previous phase

Garbage Collections are done automatically by the JVM at regular intervals and do not need to be handled separately. It can also be triggered by calling System.gc(), but the execution is not guaranteed.

The JVM contains 3 different types of garbage collectors:

  1. Serial GC — This is the simplest implementation of GC, and is designed for small applications running on single-threaded environments. It uses a single thread for garbage collection. When it runs, it leads to a “stop the world” event where the entire application is paused. The JVM argument to use Serial Garbage Collector is -XX:+UseSerialGC
  2. Parallel GC — This is the default implementation of GC in the JVM, and is also known as Throughput Collector. It uses multiple threads for garbage collection but still pauses the application when running. The JVM argument to use Parallel Garbage Collector is -XX:+UseParallelGC.
  3. Garbage First (G1) GC — G1GC was designed for multi-threaded applications that have a large heap size available (more than 4GB). It partitions the heap into a set of equal size regions and uses multiple threads to scan them. G1GC identifies the regions with the most garbage and performs garbage collection on that region first. The JVM argument to use G1 Garbage Collector is -XX:+UseG1GC

Note: There is another type of garbage collector called Concurrent Mark Sweep (CMS) GC. However, it has been deprecated since Java 9 and completely removed in Java 14 in favor of G1GC.

4) Java Native Interface (JNI)

At times, it is necessary to use native (non-Java) code (for example, C/C++). This can be in cases where we need to interact with hardware or to overcome the memory management and performance constraints in Java. Java supports the execution of native code via the Java Native Interface (JNI).

JNI acts as a bridge for permitting the supporting packages for other programming languages such as C, C++, and so on. This is especially helpful in cases where you need to write code that is not entirely supported by Java, like some platform-specific features that can only be written in C.

You can use the native keyword to indicate that the method implementation will be provided by a native library. You will also need to invoke System.loadLibrary() to load the shared native library into memory and make its functions available to Java.

5) Native Method Libraries

Native Method Libraries are libraries that are written in other programming languages, such as C, C++, and assembly. These libraries are usually present in the form of .dll or .so files. These native libraries can be loaded through JNI.

JVM Threads

We discussed how a Java program gets executed, but didn’t specifically mention the executors. Actually, to perform each task we discussed earlier, the JVM concurrently runs multiple threads. Some of these threads carry the programming logic and are created by the program (application threads), while the rest is created by JVM itself to undertake background tasks in the system (system threads).

The major application thread is the main thread which is created as part of invoking public static void main(String[]) and all other application threads are created by this main thread. Application threads perform tasks such as executing instructions starting with main() method, creating objects in the Heap area if it finds a new keyword in any method logic, etc.

The major system threads are as follows.

  • Compiler threads: At runtime, a compilation of bytecode to native code is undertaken by these threads.
  • GC threads: All the GC-related activities are carried out by these threads.
  • Periodic task thread: The timer events (i.e. interrupts) to schedule execution of periodic operations are performed by this thread.
  • Signal dispatcher thread: This thread receives signals sent to the JVM process and handles them inside the JVM by calling the appropriate JVM methods.
  • VM thread: As a pre-condition, some operations need the JVM to arrive at a safe point where modifications to the Heap area does no longer happen. Examples of such scenarios are “stop-the-world” garbage collections, thread stack dumps, thread suspension, and biased locking revocation. These operations can be performed on a special thread called VM thread.

Common JVM Errors

  • ClassNotFoundExcecption — This occurs when the Class Loader is trying to load classes using Class.forName(), ClassLoader.loadClass() or ClassLoader.findSystemClass() but no definition for the class with the specified name is found.
  • NoClassDefFoundError — This occurs when a compiler has successfully compiled the class, but the Class Loader is not able to locate the class file at the runtime.
  • OutOfMemoryError — This occurs when the JVM cannot allocate an object because it is out of memory, and no more memory could be made available by the garbage collector.
  • StackOverflowError — This occurs if the JVM runs out of space while creating new stack frames while processing a thread.

Conclusion

In this article, we discussed the Java Virtual Machine’s architecture and its various components. Often we do not dig deep into the internal mechanics of the JVM or care about how it works while our code is working.

It is only when something goes wrong, and we need to tweak the JVM or fix a memory leak, that we try to understand its internal mechanics.

This is also a very popular interview question, both at junior and senior levels for backend roles. A deep understanding of the JVM helps you write better code and avoid pitfalls related to stack and memory errors.

Thank you for staying with us so far. Hope you liked the article. Like and Comment down your feedback!!

Authors:

  • Aaditi Badgujar
  • Sahil Adhav
  • Anmol Warikoo
  • Antima Modak
  • Aryan Aher

--

--

Responses (4)