In the first part, we discussed the bytecode and some parts that it contains, namely debug information and constant pool. In this part, we will discuss the bytecode execution.
Below is the example Java class and the verbose javap
output of its bytecode.
Our main
focus will be on the Code
section of the main method.
Main method
Let’s take a closer look at the main
method.
We can already see something familiar: descriptor: ([Ljava/lang/String;)V
.
Descriptor specifies the types of the method’s arguments and its return type.
Inside the parentheses we can see the argument types. In our case, there is only one: [Ljava/lang/String;
.
When a type descriptor begins with [
, it indicates an array.
Object types follow a specific format: they start with L
and end with ;
. In this case,
the construction specifies a String type: Ljava/lang/String;
.
The V
outside the parentheses means a void
return type of our method.
Therefore, the main
method accepts an array of String: [Ljava/lang/String;
and returns void
.
Now, let’s move on to the Code
section.
The first line we encounter is as follows:
This information is required for each method:
args_size
specifies the maximum number of slots required by the arguments of this method during program execution.
locals
indicates the maximum number of slots needed for local variables in this method.
stack
specifies the maximum number of slots for the operand stack required during the execution of the method’s code.
Frames, stack, and locals
There are typically one or more threads running in our application.
Let’s imagine the following situation: a single thread is executing the code in a method called amazingMethod
:
In this method, we create a local variable a
of type int
with an initial value of 0.
Later, we modify the value of a
to 15. When amazingMethod
calls itself, the variable a
still
retains the value of 15. After the recursive call returns, a
continues to hold the value of 15.
When we enter the recursive call and reach the first line of amazingMethod
, it does not have access to
the variable a
that was previously equal to 15, even though the variable still exists.
Instead, we create a new local variable a
and initialize it with 0. This local variable is only visible
and accessible within this method, specifically within a particular method call. When a method is called,
that method call has its own status, which is stored in memory as a “frame”.
In the example above, when we invoke amazingMethod
, a frame is created and placed on top of the frame stack.
This frame stores all the local variables for this method within this call. For example, a
equals 15 at
this moment, and this state is saved.
Each time when we call amazingMethod
recursively, a new frame is created and placed on top of the previous one,
and it stores its own local variable a
. When the method returns, the top frame is removed, and we end up in
a previous frame where the local variables have been waiting for us, untouched. The variable a
still equals 15.
Local variables are stored in each frame as an array. Only the values (or references) of the variables are stored, not their names. When we refer to a local variable in our bytecode, we access it by its index.
Actually, the bytecode operations that accept or return values do not operate directly on the variables themselves. Instead, there is a special structure within each frame called the “operand stack” that the bytecode operates on. When we simply use a variable in our Java code, the bytecode has to care for loading the value of that variable into the operand stack first. The bytecode performs operations or calls methods that may return a result, and this result is placed on top of the operand stack. The result remains stored until we utilize it by executing another instruction. That instruction can also be simply writing the result into a local variable.
So, what happens when we invoke a method that accepts two integer values and returns one?
Let’s assume we’re calling method sum
. This method call will take the two top integer values from the operand stack.
If these values are not present or have different types, the method call will fail. However, if these values are
available, they will be used by the method call, and eventually, a single resulting integer value will be placed on
top of the operand stack.
What if the value we need to use is not on the top but located deeper within the stack? There are a couple of bytecode instructions available to manipulate the state of the stack. For example, we can swap the two top values, remove the top value, or duplicate it.
As a result, what looks like a single line of Java code may require multiple bytecode instructions to achieve.
What does our bytecode do?
For reference, here is the original Java code of the main method:
Below is the part of the bytecode output that includes the Code
section of the main
method:
Let’s now go line by line and see how the local variable array and the operand stack change throughout the execution.
Step 1
0
is the address of the start of current bytecode instruction. The instruction begins with i
,
indicating that iconst
operates on an integer value. In this case, the instruction iconst_1
is used to
load a constant integer value of 1
onto the operand stack.
Stack: int
Locals: [Ljava/lang/String;
Step 2
istore_1
instructs to store the integer value that is currently at the top of the operand stack as a variable.
It removes the top integer value from the operand stack and saves it in the local variable array at index 1
.
Why not 0
? Because we are in the main
method, which accepts one argument: an array of strings.
The method arguments are also stored in the local variable array, so index 0
is already occupied.
Stack:
Locals: String[], int
Step 3
iinc
instructs to increment an integer value. The values 1, 1
specify the index of the variable to be
incremented and the value by which it should be incremented, respectively.
Stack:
Locals: String[], int
Step 4
Suddenly, after address 2
, we see address 5
. This shift in addresses is due to the previous operation, iinc
,
which had additional values provided and occupied extra space. As a result, the addresses of the subsequent
bytecode operations got shifted. The iload_1
instruction is used to load the integer value from index 1
in
the local variables onto the operand stack.
Stack: int
Locals: String[], int
Step 5
This instruction performs the same action as the previous instruction, once again.
Stack: int, int
Locals: String[], int
Step 6
The imul
operation tales two integer values from the top of the operand stack, multiplies them,
and places the resulting integer value on top of the operand stack.
Stack: int
Locals: String[], int
Step 7
This operation takes the top integer value from the operand stack and save it in a variable at index 2
.
Stack:
Locals: String[], int, int
Step 8
This instruction loads the integer variable from index 2
onto the operand stack.
Stack: int
Locals: String[], int, int
Step 9
This instruction loads the integer variable from index 1
onto the operand stack.
Stack: int, int
Locals: String[], int, int
Step 10
This operation takes two integer values from the operand stack. It subtracts the integer value loaded last from the integer value loaded first. The resulting integer is loaded onto the operand stack.
Stack: int
Locals: String[], int, int
Step 11
This operation takes the top integer value from the operand stack and stores it into a variable under index 3
.
Stack:
Locals: String[], int, int, int
Step 12
This operation loads the value of a static field onto the operand stack. The reference #2
directs us to
the constant pool to determine which field exactly needs to be loaded. We immediately see which one it is
thanks to the hint: // Field java/lang/System.out:Ljava/io/PrintStream;
.
So, it is the static field of type java.io.PrintStream
located in class java.lang.System
and called out.
Stack: PrintStream
Locals: String[], int, int, int
Step 13
The address of this operation is 16
, as the previous operation used a reference to the constant pool,
which required additional space.
This operation loads the integer value stored at index 3
in the local variable array onto the operand stack.
Stack: int, PrintStream
Locals: String[], int, int, int
Step 14
This instruction invokes a virtual method of an object whose reference should be located in the operand stack
below all the arguments. The specific method being invoked is determined by the reference #3
in the constant pool.
The referenced value is the following: java/io/PrintStream.println:(I)V
.
In summary, we are invoking the println
method, which accepts an integer and returns void
, on an instance
of the PrintStream
that was earlier loaded onto the operand stack. The integer value at the top of the operand
stack is consumed by the method as an argument. The method does not return any value, but it does print something
in the console.
Stack:
Locals: String[], int, int, int
Step 15
This operation returns void from the method. The top frame gets removed from the frame stack.