05 Java Bytecode Techniques Without Accumulating Small Streams, There's No Forming Rivers and Seas

05 Java Bytecode Techniques- Without Accumulating Small Streams, There’s No Forming Rivers and Seas #

Bytecode, known as “bytecode” in English, is the intermediate code format generated after Java code is compiled. The JVM needs to read and interpret the bytecode in order to execute the corresponding tasks.

From the perspective of technical personnel, Java bytecode is the instruction set of the JVM. The JVM loads the bytecode format class files, verifies them, and then converts them into native machine code for execution through the JIT compiler. In simple terms, bytecode is like the bricks of the Java application building we write. Without the support of bytecode, the code we write will have no place and cannot be run. Therefore, Java bytecode can also be said to be the instruction format executed by the JVM.

So why do we need to master it?

No matter what programming language is used, for outstanding and ambitious programmers, they can delve into some technical details and interpret and understand the intermediate form of code before it is executed when needed. For Java, the intermediate code format is Java bytecode. Understanding bytecode and its working principles is crucial for writing high-performance code and also has some effect on in-depth analysis and troubleshooting. Therefore, for us to have an in-depth understanding of the JVM, understanding bytecode is also a basic skill for a solid foundation. At the same time, for us developers, not understanding the underlying principles and implementation details of the platform is definitely not a long-term strategy if we want to advance in our careers. After all, we all hope to become better programmers, right?

Any developer with practical experience knows that it is impossible for a business system to be without bugs. Understanding bytecode and what bytecode the Java compiler generates is essential for having a solid understanding of the JVM, which is extremely useful when troubleshooting and analyzing errors, and can also help solve problems better.

For the field of tools and program analysis, bytecode is an essential basic knowledge. It is common to modify bytecode to adjust the behavior of a program. To understand tools and technologies such as profilers, mock frameworks, AOP, and other similar tools, a complete understanding of Java bytecode is necessary.

4.1 Introduction to Java Bytecode #

There is an interesting thing. As the name suggests, Java bytecode is composed of single-byte instructions, theoretically supporting a maximum of 256 operation codes (opcodes). In practice, Java only uses about 200 opcodes, and some opcodes are reserved for debugging operations.

An opcode, referred to as an instruction below, is mainly composed of a type prefix and an operation name.

For example, the ‘i’ prefix represents ‘integer’, so ‘iadd’ is easy to understand, which means performing addition on integers.

According to the nature of the instructions, they can be divided into four main categories:

  1. Stack manipulation instructions, including instructions that interact with local variables.
  2. Program flow control instructions.
  3. Object manipulation instructions, including method invocation instructions.
  4. Arithmetic and type conversion instructions.

In addition, there are instructions that perform specific tasks, such as synchronization instructions and exception-related instructions. These instructions will be explained in detail later.

4.2 Obtaining a Bytecode Listing #

The javap tool can be used to obtain the instruction listing in a class file. javap is a built-in tool in the standard JDK specifically used for decompiling class files.

Let’s start from scratch and create a simple class, and then gradually expand it.

package demo.jvm0104;

public class HelloByteCode {
    public static void main(String[] args) {
        HelloByteCode obj = new HelloByteCode();
    }
}

The code is very simple, there is just a new statement inside the main method. Then we compile this class:

javac demo/jvm0104/HelloByteCode.java

Using javac to compile, or automatically compiling in an integrated development tool such as IDEA or Eclipse, is basically equivalent. As long as the corresponding class can be found.

If the -d parameter is not specified for javac, the compiled .class file will be generated in the same directory as the source code.

Note: The javac tool enables optimization by default, and the generated bytecode does not contain the local variable table. It is equivalent to erasing the names of local variables. If you need this debugging information, please use the -g option when compiling. If you are interested, you can try both methods and compare the results.

For detailed usage of the JDK’s built-in tools, please use javac -help or javap -help to view them; others are similar.

Then use the javap tool to perform decompilation and obtain the bytecode listing:

javap -c demo.jvm0104.HelloByteCode
# Or:
javap -c demo/jvm0104/HelloByteCode
javap -c demo/jvm0104/HelloByteCode.class

javap is quite smart. It can decompile successfully using package names or relative paths. The decompiled result is as follows:

Compiled from "HelloByteCode.java"
public class demo.jvm0104.HelloByteCode {
  public demo.jvm0104.HelloByteCode();
    Code:
       0: aload_0
       1: invokespecial #1                  // Method java/lang/Object."<init>":()V
       4: return

  public static void main(java.lang.String[]);
    Code:
       0: new           #2                  // class demo/jvm0104/HelloByteCode
       3: dup
       4: invokespecial #3                  // Method "<init>":()V
       7: astore_1
       8: return
}

Okay, we have successfully obtained the bytecode listing, and now we will interpret it.

4.3 Interpreting the Bytecode Listing #

As can be seen from the decompiled code listing, there is a default constructor public demo.jvm0104.HelloByteCode(), as well as the main method.

When we first learned Java, we knew that if no constructor is defined, there will be a default parameterless constructor. This knowledge is verified here again. Well, this is easier to understand! We have confirmed that there is a default constructor here by looking at the compiled class file, so it is generated by the Java compiler, not automatically generated by the JVM at runtime.

The automatically generated constructor should have an empty method body, but here we see some instructions inside. Why is that?

Reviewing Java Knowledge #

In Java, every constructor first calls the constructor of the super class, right? However, this is not automatically executed by the JVM, but controlled by program instructions. So there are some bytecode instructions in the default constructor that perform this task.

Basically, these instructions execute the super() call:

public demo.jvm0104.HelloByteCode();
    Code:
        0: aload_0
        1: invokespecial #1 // Method java/lang/Object."<init>":()V
        4: return

As for the resolved java/lang/Object, needless to say, it inherits from the Object class by default. This verifies the knowledge point once again, and it is determined during compilation.

Next, let’s look at the main method:

public static void main(java.lang.String[]);
    Code:
        0: new #2 // class demo/jvm0104/HelloByteCode
        3: dup
        4: invokespecial #3 // Method "<init>":()V
        7: astore_1
        8: return

In the main method, an instance of the class is created, and then return is called. We will explain the instructions inside later.

4.4 Viewing the Constant Pool Information in the Class File #

You may have heard of the constant pool, which is also referred to as the runtime constant pool in most cases. But where do the constants in the runtime constant pool come from? They primarily come from the constant pool structure in the class file.

To view the constant pool information, we need to use a magical parameter:

javap -c -verbose demo.jvm0104.HelloByteCode

When decompiling the class, if we specify the -verbose option, it will output additional information.

The result is as follows:

Classfile /XXXXXXX/demo/jvm0104/HelloByteCode.class
  Last modified 2019-11-28; size 301 bytes
  MD5 checksum 542cb70faf8b2b512a023e1a8e6c1308
  Compiled from "HelloByteCode.java"
public class demo.jvm0104.HelloByteCode
  minor version: 0
  major version: 52
  flags: ACC_PUBLIC, ACC_SUPER
Constant pool:
   #1 = Methodref #4.#13 // java/lang/Object."<init>":()V
   #2 = Class #14 // demo/jvm0104/HelloByteCode
   #3 = Methodref #2.#13 // demo/jvm0104/HelloByteCode."<init>":()V
   #4 = Class #15 // java/lang/Object
   #5 = Utf8 <init>
   #6 = Utf8 ()V
   #7 = Utf8 Code
   #8 = Utf8 LineNumberTable
   #9 = Utf8 main
  #10 = Utf8 ([Ljava/lang/String;)V
  #11 = Utf8 SourceFile
  #12 = Utf8 HelloByteCode.java
  #13 = NameAndType #5:#6 // "<init>":()V
  #14 = Utf8 demo/jvm0104/HelloByteCode
  #15 = Utf8 java/lang/Object
{
  public demo.jvm0104.HelloByteCode();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1 // Method java/lang/Object."<init>":()V
         4: return
      LineNumberTable:
        line 3: 0

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new #2 // class demo/jvm0104/HelloByteCode
         3: dup
         4: invokespecial #3 // Method "<init>":()V
         7: astore_1
         8: return
      LineNumberTable:
        line 5: 0
        line 6: 8
}
SourceFile: "HelloByteCode.java"

Among them, a lot of information about the class file is displayed: the compilation time, MD5 checksum, from which .java source file it is compiled, which version of the Java language specification it conforms to, and so on.

You can also see the ACC_PUBLIC and ACC_SUPER access flags. The ACC_PUBLIC flag is easily understood: it indicates that this class is a public class.

But what about the ACC_SUPER flag? This is due to historical reasons. The ACC_SUPER flag was introduced in the bug fix of JDK 1.0 to fix the issue of the invokespecial instruction calling super class methods. Starting from Java 1.1, the compiler generally automatically generates the ACC_SUPER flag.

Some students might have noticed that many instructions are numbered with #1, #2, #3, and so on. This is a reference to the constant pool. What are there in the constant pool?

Constant pool:
   #1 = Methodref #4.#13 // java/lang/Object."<init>":()V
   #2 = Class #14 // demo/jvm0104/HelloByteCode
   #3 = Methodref #2.#13 // demo/jvm0104/HelloByteCode."<init>":()V
   #4 = Class #15 // java/lang/Object
   #5 = Utf8 <init>
   ......

This is an excerpt from the constant pool, which shows the definitions of constants in the constant pool. Constants can be combined, and a constant definition can refer to other constants.

For example, let’s look at the first line: #1 = Methodref #4.#13 // java/lang/Object."<init>":()V. Here is the explanation:

  • #1 is the constant number, which can be referenced elsewhere in the file.
  • = is the separator.
  • Methodref indicates that this constant refers to a method. Which class and method does it refer to? The class is #4, and the method signature is #13. The comment after the double slashes already provides a readable explanation of them.

You can try to analyze other constant definitions. By practicing and reviewing your knowledge, you can effectively improve your memory and understanding.

In summary, the constant pool is a big dictionary of constants. It manages various types of constants used in the program with numbers as identifiers, which makes it easy to refer to constants in bytecode operations.

4.5 Viewing Method Information #

When using the javap command with the -verbose option, it displays additional information. For example, more information about the main method is printed:

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1

You can see the method description: ([Ljava/lang/String;)V:

  • The parameter information is enclosed in parentheses and represents the input parameters.
  • The square bracket indicates an array.
  • L represents an object.
  • The java/lang/String is the class name.
  • The V after the parentheses indicates that the method returns void.
  • The method access flags are easy to understand: flags: ACC_PUBLIC, ACC_STATIC means the method is public and static.

You can also see how many stack depths are required and how many slots need to be kept in the local variable table when executing the method: stack=2, locals=2, args_size=1. Combining all these, it represents a method:

public static void main(java.lang.String[])

Note: In practice, we usually combine the method modifiers, name, parameter types list, and return type together, called the “method signature”. These pieces of information can fully represent a method.

If we go back a little bit, we can see the bytecode of the automatically generated default constructor:

  public demo.jvm0104.HelloByteCode();
    descriptor: ()V
    flags: ACC_PUBLIC
    Code:
      stack=1, locals=1, args_size=1
         0: aload_0
         1: invokespecial #1 // Method java/lang/Object."<init>":()V
         4: return

You may notice something strange. The number of parameters in the default constructor, which takes no arguments, is not 0: stack=1, locals=1, args_size=1. This is because in Java, there is no this reference for static methods. For non-static methods, this is stored in the local variable table slot #0. This will be explained in more detail later.

For those who have experience with reflection programming, it may be easier to understand: Method#invoke(Object obj, Object... args). Those who have experience with JavaScript may also find it similar: fn.apply(obj, args) or fn.call(obj, arg1, arg2).

4.6 Thread Stack and Bytecode Execution Model #

To understand bytecode technology in depth, we need to have a basic understanding of the bytecode execution model.

JVM is a stack-based computer. Each thread has its own thread stack (JVM stack), which is used to store frames. Each method invocation automatically creates a frame on the stack. A frame consists of an operand stack, a local variable array, and a reference to a class (a reference to the corresponding class in the runtime constant pool).

We have already seen these contents in the bytecode of the decompiled code earlier.

The local variable array is also called the local variable table, which includes method parameters and local variables. The size of the local variable array is determined at compile time based on the number of local variables and parameters, as well as the number of bytes occupied by each variable/parameter. The operand stack is a LIFO stack structure used to push and pop values. Its size is also determined at compile time.

Some opcodes/instructions can push values onto the operand stack. Other opcodes/instructions can fetch operands from the stack, process them, and then push the result back onto the stack. The operand stack is also used to receive the return values when invoking other methods.

4.7 Bytecode Interpretation in the Method Body #

After seeing the examples above, attentive students may wonder what the numbers in front of the bytecode instructions mean. They seem like indices, but the spacing between them is not consistent. Let’s take a look at the bytecode of the main method:

         0: new #2 // class demo/jvm0104/HelloByteCode
         3: dup
         4: invokespecial #3 // Method "<init>":()V
         7: astore_1
         8: return

The reason for the inconsistent spacing is that some opcodes carry operands, which also occupy space in the bytecode array.

For example, the new instruction occupies three slots: one for the opcode instruction itself, and two for the operands.

Therefore, the index of the next instruction dup starts from 3. If we transform this method into a visualized array, it would look like this:

2087a5ff-61b1-49ab-889e-698a73ceb41e.jpg

Each opcode/instruction has its corresponding hexadecimal (HEX) representation. If we represent the method as a HEX string, it would look like this:

b75bd86b-45c4-4b05-9266-1b7151c7038f.jpg

We can even open the class file in a HEX editor and find the corresponding strings:

9f8bf31f-e936-47c6-a3d1-f0c0de0fc898.jpg (This image is generated using the hex-view plugin of the open-source text editor Atom)

In a more straightforward way, we can directly modify the bytecode using a HEX editor. Although there are risks involved in doing so, it can be interesting if only one value is modified.

In fact, there are better ways to edit and modify bytecode using tools like ASM and Javassist, which provide a convenient and safe way to programmatically edit bytecode. Class loaders and Agents can also be used for bytecode manipulation, which will be discussed in the next lesson. Other topics will be explored in the future.

4.8 Object Initialization Instructions: new Instruction, init, and clinit Introduction #

We all know that new is a keyword in the Java programming language, but in bytecode, there is also an instruction called new. When we create an instance of a class, the compiler generates bytecode like this:

0: new #2 // class demo/jvm0104/HelloByteCode
3: dup
4: invokespecial #3 // Method "<init>":()V

When you see the new, dup, and invokespecial instructions together, it means that an instance of a class is being created.

Why are there three instructions instead of one? This is because:

  • The new instruction only creates an object without calling the constructor.
  • The invokespecial instruction is used to call special methods, such as constructors in this case.
  • The dup instruction is used to duplicate the value on top of the stack.

Since the constructor call does not return a value, if there is no dup instruction, the operand stack will be empty after calling methods on the object and initializing it. This will cause problems after initialization, as the following code cannot manipulate the object.

That’s why the reference needs to be duplicated beforehand, so that after the constructor returns, the object instance can be assigned to a local variable or a field. Therefore, the next instruction is usually one of the following:

  • astore {N} or astore_{N} – Assign to a local variable, where {N} is the position in the local variable table.
  • putfield – Assign a value to an instance field.
  • putstatic – Assign a value to a static field.

When calling a constructor, another method called <init> may also be executed, possibly even before executing the constructor itself.

Another method that may be executed is the static initialization method <clinit>; however, <clinit> cannot be directly called. It is triggered by these instructions: new, getstatic, putstatic, or invokestatic.

In other words, creating a new instance of a class, accessing static fields, or calling static methods will trigger the static initialization method of that class if it has not been initialized yet. Actually, there are some cases that trigger static initialization. For more details, please refer to the JVM specification: [http://docs.oracle.com/javase/specs/jvms/se8/html/]

4.9 Stack Manipulation Instructions #

There are many instructions that can manipulate the method stack. Some basic stack manipulation instructions have been mentioned before: they push values onto the stack or retrieve values from the stack. In addition to these basic operations, there are also instructions that can manipulate the stack memory. For example, the swap instruction is used to exchange the values of the top two elements on the stack. Here are some examples:

The most basic instructions are dup and pop.

  • The dup instruction duplicates the value of the top element on the stack.
  • The pop instruction removes the topmost value from the stack.

There are also more complex instructions, such as swap, dup_x1, and dup2_x1.

  • As the name suggests, the swap instruction exchanges the values of the top two elements on the stack, for example, swapping A and B (shown in example 4 in the figure).
  • The dup_x1 instruction duplicates the value of the top element on the stack and inserts it two times at the top of the stack (shown in example 5 in the figure).
  • The dup2_x1 instruction duplicates the values of the top two elements on the stack and inserts them below the third value (shown in example 6 in the figure).

9d1a9509-c0ca-4320-983c-141257b0ddf5.jpg

The dup_x1 and dup2_x1 instructions may seem a bit complex. And why are these instructions designed to duplicate the topmost value in the stack?

Let’s take a practical example: how to swap the values of two variables of type double?

It is important to note that a double value occupies two slots in the stack, which means that if there are two double values in the stack, they will occupy four slots.

To perform the swap, you might think of using the swap instruction, but the problem is that swap only works for single-word values (usually 32-bit 4-byte values, while 64-bit values are double-word). Therefore, it cannot handle values of type double, and there is no swap2 instruction in Java.

So what should we do? The solution is to use the dup2_x2 instruction to duplicate the double value at the top of the operand stack to just below the bottom double value, and then use the pop2 instruction to pop the top double value from the stack. The result is the swapping of the two double values. The diagram below illustrates this:

17ee9537-a42f-4a49-bb87-9a03735ab83a.jpg

Supplementary Explanation of dup, dup_x1, and dup2_x1 Instructions #

For detailed explanations of these instructions, please refer to the JVM specification:

dup Instruction

Official explanation: Duplicates the value on top of the stack and pushes the duplicated value onto the stack.

Changes to the values on the operand stack (square brackets indicate the newly inserted value):

..., value →
..., value [,value]

dup_x1 Instruction

Official explanation: Duplicates the value on top of the stack and inserts the duplicated value below the top two values. Changes to the values in the operand stack (newly inserted values are marked with square brackets):

..., value2, value1 →
..., [value1,] value2, value1

dup2_x1 Instruction

The official explanation is: “Duplicates one 64-bit value or two 32-bit values at the top of the stack and inserts the duplicated values, in the original order, one word beneath the original value at the top of the stack.”

Changes to the values in the operand stack (newly inserted values are marked with square brackets):

# Scenario 1: value1, value2, and value3 are all values of Group 1 (32-bit elements)
..., value3, value2, value1 →
..., [value2, value1,] value3, value2, value1

# Scenario 2: value1 is a value of Group 2 (64-bit, long, or double), value2 is a value of Group 1 (32-bit element)
..., value2, value1 →
..., [value1,] value2, value1

Table 2.11.1-B Mapping between Actual Types and JVM Computational Types and Groups

Actual Type JVM Computational Type Group
boolean int 1
byte int 1
char int 1
short int 1
int int 1
float float 1
reference reference 1
returnAddress returnAddress 1
long long 2
double double 2

4.10 Local Variable Table #

The stack is mainly used to execute instructions, while local variables are used to save intermediate results, and the two can interact directly with each other.

Let’s write a more complex example:

First, write a class for calculating the moving average:

package demo.jvm0104;
//Moving average
public class MovingAverage {
    private int count = 0;
    private double sum = 0.0D;
    public void submit(double value){
        this.count ++;
        this.sum += value;
    }
    public double getAvg(){
        if(0 == this.count){ return sum;}
        return this.sum/this.count;
    }
}

Second, write a class to invoke it:

package demo.jvm0104;
public class LocalVariableTest {
    public static void main(String[] args) {
        MovingAverage ma = new MovingAverage();
        int num1 = 1;
        int num2 = 2;
        ma.submit(num1);
        ma.submit(num2);
        double avg = ma.getAvg();
    }
}

In the main method, two numbers are submitted to an instance of the MovingAverage class, and the current average value is calculated.

Then we need to compile (remember the -g option to generate debug information mentioned earlier).

javac -g demo/jvm0104/*.java

And then decompile using javap:

javap -c -verbose demo/jvm0104/LocalVariableTest

Look at the bytecode for the main method:

  public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=2, args_size=1
         0: new           #2                  // class demo/jvm0104/MovingAverage
         3: dup
         4: invokespecial #3                  // Method demo/jvm0104/MovingAverage."<init>":()V
         7: astore_1
         8: aload_0
         9: getfield      #4                  // Field numbers:[I
        12: astore_2
        13: aload_2
        14: arraylength
        15: istore_3
        16: iconst_0
        17: istore        4
        19: iload         4
        21: iload_3
        22: if_icmpge     36
        25: aload_2
        26: iload         4
        28: iaload
        29: i2d
        30: aload_1
        31: invokevirtual #5                  // Method demo/jvm0104/MovingAverage.submit:(D)V
        34: iinc          4, 1
        37: iload         4
        39: iload_3
        40: if_icmplt     25
        43: aload_1
        44: invokevirtual #6                  // Method demo/jvm0104/MovingAverage.getAvg:()D
        47: dstore        4
        49: return
      LineNumberTable:
        line 7: 0
        line 9: 8
        line 10: 13
        line 11: 19
        line 12: 25
        line 11: 34
        line 14: 43
        line 15: 49
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
            0      50     0  args   [Ljava/lang/String;
            8      42     1    ma   Ldemo/jvm0104/MovingAverage;
           13      37     2 numbers   [I
           19      31     3 length   I
           25      25     4 number   I
           43       7     4   avg   D

帶标签的这些指令在目标地址有两个选项:要么跳转,要么返回。 例如, if_icmpge 就是 比较前两个 int 型数值,大于跳转。

<block-y> 包含了所有相邻的(不包括(那是图 <block-x + 1> 的一部分))指令序列,探测的区块(包含了跳转值、标签等), 形成一个闭合的控制区域。

这里的两个图 是一个典型的迭代循环,列表上的操作举例较全面。

JVM 中所有的循环都会编译成这样的基本结构。 Same compilation and decompilation:

javac -g demo/jvm0104/*.java
javap -c -verbose demo/jvm0104/ForLoopTest

Because numbers is a static attribute in this class, the corresponding byte code is as follows:

        0: new           #2                  // class demo/jvm0104/MovingAverage
        3: dup
        4: invokespecial #3                  // Method demo/jvm0104/MovingAverage."<init>":()V
        7: astore_1
        8: getstatic     #4                  // Field numbers:[I
        11: astore_2
        12: aload_2
        13: arraylength
        14: istore_3
        15: iconst_0
        16: istore        4
        18: iload         4
        20: iload_3
        21: if_icmpge     43
        24: aload_2
        25: iload         4
        27: iaload
        28: istore        5
        30: aload_1
        31: iload         5
        33: i2d
        34: invokevirtual #5                  // Method demo/jvm0104/MovingAverage.submit:(D)V
        37: iinc          4, 1
        40: goto          18
        43: aload_1
        44: invokevirtual #6                  // Method demo/jvm0104/MovingAverage.getAvg:()D
        47: dstore_2
        48: return
      LocalVariableTable:
        Start  Length  Slot  Name   Signature
           30       7     5 number   I
            0      49     0  args   [Ljava/lang/String;
            8      41     1    ma   Ldemo/jvm0104/MovingAverage;
           48       1     2   avg   D

The instructions at positions [8~16] are used for loop control. Starting from the declaration of the code at the top and looking down to the LocalVariableTable at the end:

  • Slot number 0 is occupied by the args parameter of the main method.
  • Slot number 1 is occupied by ma.
  • Slot number 5 is occupied by number.
  • Slot number 2 is occupied by avg after the for loop.

So, who occupies the intermediate slots 2, 3, and 4? By analyzing the byte code instructions, we can see that there are 3 anonymous local variables (astore_2, istore_3, istore_4, etc.) in slots 2, 3, and 4.

  • The variable in slot number 2 stores the reference value of numbers and occupies slot number 2.
  • The variable in slot number 3 is used by the arraylength instruction to determine the length of the loop.
  • The variable in slot number 4 is the loop counter and is incremented after each iteration using the iinc instruction.

If we have an older version of JDK, we will find three variables in slots 2, 3, and 4 that are not mentioned in the source code: arr$, len$, i$, which are the loop variables.

The first instruction in the loop body is used to compare the loop counter with the array length:

    18: iload         4
    20: iload_3
    21: if_icmpge     43

This instruction loads the values in slot numbers 4 and 3 from the local variable table onto the stack, and calls the if_icmpge instruction to compare their values.

Interpretation of if_icmpge: if, integer, compare, greater equal. If the value of one number is greater than or equal to another value, the program execution flow jumps to pc=43 to continue execution.

In this example, if the value in slot number 4 is greater than or equal to the value in slot number 3, the loop ends. The code at position 43 corresponds to the code after the loop. If the condition is not met, the loop proceeds to the next iteration.

After the loop body is executed, its loop counter is incremented by 1, and the loop jumps back to the starting point to re-evaluate the loop condition:

    37: iinc          4, 1   // increment the value in slot number 4 by 1
    40: goto          18     // jump to the beginning of the loop

4.12 Arithmetic and Type Conversion Instructions #

There are many instructions in Java bytecode that can perform arithmetic operations. In fact, a large part of the instruction set is about mathematical operations. For all numeric types (int, long, double, float), there are instructions for addition, subtraction, multiplication, division, and negation.

But what about byte, char, and boolean? The JVM treats them as int. Additionally, there are instructions for type conversion between data types.

Arithmetic opcodes and types 30666bbb-50a0-4114-9675-b0626fd0167b.jpg

When we want to assign an int value to a long variable, a type conversion occurs.

Type conversion opcodes e8c82cb5-6e86-4d52-90cc-40cde0fabaa0.jpg

In the previous example, when passing an int value as an argument to the submit() method that actually expects a double, we can see that a type conversion opcode was used before actually calling the method:

31: iload 5 33: i2d 34: invokevirtual #5 // Method demo/jvm0104/MovingAverage.submit:(D)V

This code snippet is loading the value of a local variable of type int onto the stack as an integer, and then converting it to a double value using the i2d instruction, in order to pass it as a parameter to the submit method.

The only instruction that does not require loading a value onto the operand stack is iinc, which directly performs arithmetic on the value in the LocalVariableTable. All other operations are performed on the stack.

4.13 Method Invocation Instructions and Parameter Passing #

The previous section briefly mentioned method invocation, such as how constructors are called using the invokespecial instruction.

Here are various instructions used for method invocation:

  • invokestatic - as the name suggests, this instruction is used to invoke a static method of a class and it is the fastest among all method invocation instructions.
  • invokespecial - we have already learned about this one. The invokespecial instruction is used to invoke constructors, but it can also be used to invoke private methods within the same class and visible superclass methods.
  • invokevirtual - this instruction is used to invoke public, protected, and package-private methods if the target object is a specific type.
  • invokeinterface - when the method being called belongs to an interface, the invokeinterface instruction is used.

So what’s the difference between invokevirtual and invokeinterface? That’s a good question. Why do we need both invokevirtual and invokeinterface instructions? After all, all interface methods are public and we could just use invokevirtual, right?

This is done for optimization of method invocation. The JVM needs to resolve the method before it can invoke it.

  • With invokestatic, the JVM knows exactly which method to call because it’s a static method and can only belong to one class.
  • When using invokespecial, the amount of lookup is also minimal and the resolution is easier, so the required method can be found faster at runtime.

The difference between invokevirtual and invokeinterface is not as obvious. Imagine that the class definition contains a method definition table where all the methods have position numbers. In the following example: Class A has method1 and method2, and subclass B inherits from A, inheriting method1, overriding method2, and declaring method3.

Please note that method1 and method2 are at the same index position in both class A and class B.

class A
    1: method1
    2: method2
class B extends A
    1: method1
    2: method2
    3: method3

So, when method2 is called at runtime, it is always found at position 2.

Now let’s explain the fundamental difference between invokevirtual and invokeinterface.

Assume there is an interface X that declares methodX, and let class B implement interface X on top of the previous hierarchy:

class B extends A implements X
    1: method1
    2: method2
    3: method3
    4: methodX

The new method methodX is located at index 4, and in this case, it appears to be no different from method3.

But if there is another class C that also implements the X interface, without inheriting A or B:

class C implements X
    1: methodC
    2: methodX

The interface method positions in class C are different from those in class B. This is why the runtime imposes more restrictions on invokinterface. Compared to invokevirtual, which is fixed for a specific type’s method table and can be found accurately every time, invokinterface is less efficient. For a detailed analysis and discussion, please refer to the first link in the reference materials.

4.14 The invokedynamic instruction added in JDK7 #

Prior to JDK7, the bytecode instruction set of the Java Virtual Machine (JVM) only contained the four instructions mentioned earlier (invokestatic, invokespecial, invokevirtual, invokeinterface). With the release of JDK 7, a new bytecode instruction called invokedynamic was added. This addition was one of the improvements made to support dynamically typed languages and also served as the foundation for lambda expressions supported since JDK 8.

Why was this instruction added?

In Java, without changing the bytecode, there are only two ways to invoke a method m of class A at the Java language level:

  • Use A a = new A(); a.m(), which creates an instance of class A and directly invokes the method.
  • Use reflection, obtain a Method object with A.class.getMethod and then invoke the method using Method.invoke.

Both of these methods explicitly associate the method m with the type A. What if there is a class B with the same method signature for m? How can we dynamically specify which method (A’s or B’s) to call at runtime? This operation is very common in dynamically typed languages like JavaScript or languages with function pointers/method delegates like C#, but there is no direct way to achieve this in Java. In Java, it is generally recommended to use a public interface IC that both A and B implement, and then define method m in IC. This allows us to operate on A and B as type IC at runtime, so both have the method m. However, this “strong constraint” brings many additional operations.

With the new invokedynamic instruction, together with the new method handles (Method Handles), which can describe a method m independent of type A, even excluding the method name, it becomes possible to specify at runtime which class should receive the method call. Prior to this, only reflection could achieve similar functionality. This instruction enables the appearance of JVM-based dynamic languages, making the JVM more powerful. Moreover, by implementing the dynamic method invocation mechanism on the JVM, it does not disrupt the original invocation mechanism. This not only well supports dynamic languages on the JVM like Scala and Clojure, but also supports dynamic lambda expressions in code.

RednaxelaFX commented:

Simply put, in the past, certain features were hardcoded in the bytecode and could not be changed. Therefore, this time, the strategy for translating the lambda syntax to bytecode is to use invokedynamic as a trick, hiding the actual translation strategy in the implementation of the JDK library (metafactory), which can be changed at any time, while externally, everyone sees a fixed invokedynamic.

Reference materials #