Oh, those bits and bytes
The time is right. Your work is done. The last letter of your Java code has been written. You let the IDE compile the code and a new running version of your app is ready to be released. You’ve done this a thousand times, there’s nothing new on the horizon. The question of what lies beneath, what happens under the hood, has never occurred to you. Until now!
Gentlemen, start your engines!
Once you compile your Java code, the Java compiler transforms the source code to bytecode. This bytecode consist of a list of instructions of one byte each. The instruction set can be run by the Java virtual machine (JVM). If ran, the JVM does interpret this code and compiles it further down to machine code. Machine code itself is a language to directly instruct the CPU.
Nothing in this world is hidden forever
Ok, sounds awesome, but how do the instructions actually look like? Let’s expand our knowledge by example. Imagine a class with following method:
public static int add(final int a, final int b) {
return a + b;
}
After compilation, we could look at the bytecode by using the Java Class File Disassembler. The disassembler breaks down one or more class files; if we enter the javap -c className.class
command, we produce following output:
public static int add(int, int);
Code:
0: iload_0
1: iload_1
2: iadd
3: ireturn
}
Surprisingly enough, the instruction set is easily readible. You could even follow the code as you read it. Take variable a
and b
, add them together and return the result. This so called bytecode seems not much harder to read and write than Java itself!
Stop. Revive. Survive.
Let me stop you right there. Even if it looks real simple, there are two caveats you should know. First of all, the representation of the Java Class File Disassembler is not how bytecode really looks. As said before, the instructions are all one byte in size each. As a byte consists of 8 bits, there are 256 possible instructions. The iload_0
instruction consists of 0001 1010 bits for example. So above example does actually look like 0001 1010 0001 1011 0110 0000 1010 1100. Most people would express those bits as hexidecimal characters, thus the example can be represented as 1a 1b 60 ac as well.
I’m in the middle of a revelation
Maybe the Java Class File Disassembler does make the representation easier, but still, why should you not write the instructions yourself[1]? Well, that’s where the other caveat does pop up. The JVM does work entirely different than Java code, because it is a stack-based machine. It consists, among other things, of a last-in-first-out operand stack and local variables. That means once the byte code is run by the JVM, the result of the instructions is pulled on and off a memory stack in execution order. Local variables are used to store and retrieve intermediate results. The local variabels itself is an array, starting from index 0.
There are some important thing you need to know:
-
Every thread has its own stack.
-
Writing from the stack to the local variables is done with
store
instructions, the other way around is done withload
instructions. -
There are different instructions for different types that do the same thing. So for example the
istore_0
instruction stores an integer value into variable 0, where thefstore_0
instruction stores a float value into local variable 0. -
When a method is called:
-
A private stack frame within its thread’s stack is created[2].
-
The parameters are stored in the local variables.
-
I wouldn’t know where to start
Now we know all the basics, let’s visualize above example. To make it more clear, let’s say we call the add function with "3" and "5" as arguments, see figure 1.
As you can see, the local variables are filled at the beginning. With the iload_0
instruction, the first integer is loaded from the local variables and pushed to the stack. To use the other variable as well, the second integer is loaded with the iload_1
instruction. Loading items from the local variables does not remove them from the local variables array itself. Because a stack works last-in-first-out, by loading the second integer on the stack, the first integer is located 'one below' the second on the stack. With the iadd
instruction, the last two values of the stack are taken, added together, and the result is pushed back on the stack. A thing to keep in mind here, every stack-instruction changes the values on the stack. Then the ireturn
instruction is used to pull the calculated value of "5" from the stack. This return value will be put on the callers stack frame, but that is out of the scope of this example.
One more time? For the audience?
To dive further in the subject, we need a more interesting method. A pure function to calculate a number of the famous Fibonacci sequence suits our needs very well:
public static float fib(final float n) {
if (n <= 1) {
return n;
}
return fib(n - 1) + fib(n - 2);
}
When converted to bytecode, the bytecode is already a lot harder to understand:
public static float fib(float);
Code:
0: fload_0
1: fconst_1
2: fcmpg
3: ifgt 8
6: fload_0
7: freturn
8: fload_0
9: fconst_1
10: fsub
11: invokestatic #13 // Method fib:(F)F
14: fload_0
15: fconst_2
16: fsub
17: invokestatic #13 // Method fib:(F)F
20: fadd
21: freturn
There are some new instructions we didn’t talk about yet. But do not fear, we will conquer this bytecode beast! To make it easier to understand, let’s break it in smaller pieces.
Fragment after fragment we make sense of everything
The Fibonacci sequences actually consists of four logical rules. The incoming "n" is returned if it’s smaller than one. If that’s not the case, the Fibonacci function calls it own function twice with a minus one and a minus two argument. The result of the function calls is added up and returned. So let’s start.
public static float fib(final float n) {
if (n <= 1) {
return n;
}
public static float fib(float);
Code:
0: fload_0
1: fconst_1
2: fcmpg
3: ifgt 8
6: fload_0
7: freturn
The first instruction loads the content of incoming "n" from local variable 0 into the stack. The fconst_1
instruction pushes a hardcoded "1" to the stack as well. Those two values are compared with fcmpg
. This instruction behaves like the iadd
instruction we saw before. It takes the last two values of the stack and does a calculation. In this case, if the first variable, that is "n", is bigger than the second variable, that is the hardcoded "1", it will return a one; if it is equal, it returns a zero, if it’s smaller it returns a minus one value.
Because now we got either a -1, 0 or 1 on the stack, something interesting happens. An if-greater-than-zero comparison with ifgt
is executed, where the result could be true or false. So with this instruction we suddenly got two code paths.
In case it’s false, it just goes on to the next instruction. Because the previous instructions changed the stack, it load again the "n" value to the stack and returns this value.
To go to the instructions when the comparison is true, the ifgt
instruction has an "8" behind its command. Once true, it continues its way from there. Next part is following function call.
fib(n - 1)
Code:
8: fload_0
9: fconst_1
10: fsub
11: invokestatic #13 // Method fib:(F)F
If we look at the bytecode on the right side, it loads again the "n" value to the stack. Just with previous code block, it pushes a hardcoded "1" to the stack as well. With the fsub
instruction, it subtracts the hardcoded "1" from "n". Now the new value on the stack is one less than n, exciting things are starting to happen.
To go recursively, the code needs to call its own function again. The invokestatic
does just that, it calls a function and puts the result of the function to its stack. The function in question is fib(float) of course, indicated with "#13"[3]. The n-minus-one value is used as input argument. The function itself is run in another stack frame, so we don’t need to bother about this.
fib(n - 2)
Code:
14: fload_0
15: fconst_2
16: fsub
17: invokestatic #13 // Method fib:(F)F
Now let’s see what happens next. Another time the "n" value is loaded from the local variables to the stack. This time a hardcoded "2" is loaded and used to subtract two from "n". After subtraction, This n-minus-two-value is used to call fib(float). Again, the function is ran in another stack frame, so only the return value is important to us.
//return firstResult + secondResult
return fib(n - 1) + fib(n - 2);
Code:
20: fadd
21: freturn
And then we encounter the hardest part, so brace yourself. Remember we are still going stack based! Like I just said, both the functions return a value to the stack. Both functions return a number and, once executed, are below each other at the stack. So by adding and returning, we covered the whole instruction set of the Fibonacci function.
I didn’t go into much detail of the recursion itself. The funny thing is, actually you don’t need to. Just like any recursive function, once the function calls itself, just start reading from the very beginning until you encounter a 'return' statement.
When the student is truly ready… The teacher will disappear
Understanding the Java bytecode is no easy thing, but can be done after some serious practice. This introduction does not even cover all basic knowledge, like going into detail how objects are handled[4]. I can imagine reading it yourself is still quite overwhelming. If you are stuck, draw a picture like figure 1 and start from the very first instruction. You will get there after some good old thinking! Once you have mastered the tools, the world turns into your playground!