Exploring Java bytecode
Java is a phenomenal language in a lot of ways. It has a relatively simple syntax. It's well documented and super easy to get familiar with. Getting started is easy. You write your source in a *.java file and run it through the java compiler using javac which generates bytecode as a *.class file. You then execute the code on the Java Virtual Machine (JVM) using the java command on the generated class file.
What i find most interesting in this process is the generated bytecode. It's almost invisible in our workflow (If you are using an IDE, you probably will never see this). However, a lot of work is being done at this stage in the background. In this article, I'll be looking deeper into the java bytecode to get a bit of a feel for what goes on under the hood.
Disassembling classes[1]
In order to peek into *.class files after *.java files have been compiled, I'll need to invoke the javap command, also known as The Java Class File Disassembler.
To see this in action, I'll be compiling this sample program
/**
* Demo class
*/
public class SampleClass{
public static void main(String[] args){
int i = 0;
System.out.println(i);
}
}
using the familiar javac command in the terminal window.
javac SampleClass.java
This will compile SampleClass.java into bytecode. Next we need to disassemble the source using the javap command.
javap SampleClass
This prints out all of the public, protected and package-private methods and fields available in the class.
Compiled from "SampleClass.java"
public class SampleClass {
public SampleClass();
public static void main(java.lang.String[]);
}
This doesn't really show a whole lot, but can be useful in getting more information about a class that has not been well documented. For example, notice the empty public constructor which was automatically generated since no other constructor was specified.
To get more information out of javap, I'll be using the -c flag which prints out the disassembled bytecode.
javap -c SampleClass
Compiled from "SampleClass.java"
public class SampleClass {
public SampleClass();
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_0
1: istore_1
2: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
5: iload_1
6: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
9: return
}
Now things are getting interesting. What he have here are the bytecode instructions in SampleClass.
Interpreting bytecode instructions
Walking through the bytecode we can see that each numbered line contains a mnemonic. This is really just a readable form of an opcode. Opcodes are usually single-byte instructions that tell the JVM what to do. They are usually followed by one or more operands. The full list of mnemonics and opcodes can be found here courtesy of Wikipedia.
Looking at the first part of our our disassembled class, it's clear that this belongs to the default constructor.
public SampleClass();
Code:
// aload_0 means load local variable
// of index 0 onto the stack. It's pushing
// an implicit reference to this onto the stack
0: aload_0
// invokespecial is used here to call
// the instance initialization method
// init() to create an object
1: invokespecial #1 // Method java/lang/Object."<init>":()V
// return void. Nothing left to do here
4: return
The story here is that a reference to this is being pushed to the stack, the object is then instantiated and initialized through invokespecial. Since this was just the default constructor, there was nothing left to do so the all to familiar return was invoked next.
Next, the main method.
public static void main(java.lang.String[]);
Code:
// iconst_0 pushes a 0 to the stack
// From int i = 0; in the code.
0: iconst_0
// The 0 is then popped from the
// stack and stored in variable 1
// which in this demo happens to be i
1: istore_1
// This reaches in to the magical realm of
// Asgard and you guessed it... fetches the
// static field System.out
2: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
// Next the content of variable 1 is loaded
5: iload_1
// The method println is invoked
6: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
// nothing left to do, so void is returned
9: return
Before any value can be used, it needs to be pushed on to the stack. Which is why int i = 0; corresponds two operations. 0 is pushed to the stack first, then the value is stored in a memory address.
Before any value can be used, it needs to be pushed onto the stack.
This is because the JVM uses a a stack-based machine architecture with a Last in first out (LIFO) stack. Operations are carried out by popping the stack which contains the instruction and address of the values then pushing the result.
Optimizing code using disassembly
Removing the extra layer of abstraction we are so used to is actually pretty helpful in optimizing our code. For one, it takes away all forms of hand holding in terms of memory allocation. This allows us to really look at the mess that we leave behind for the garbage collector to clean up. Lets take a look at a "relatively" harmless example to demonstrate.
/**
* Demo class
*/
public class SampleClass{
public static void main(String[] args){
String str_greeting = "Hello ";
str_greeting = str_greeting + " world";
System.out.println("Oh! " + str_greeting);
}
}
This simple program declares a string with the content "Hello" and concatenates it with another string " world". We then print the value concatenated with "Oh! ". It's looks pretty straight forward without any surprises but lets take a look at what javap has to say.
Compiled from "SampleClass.java"
public class SampleClass {
public SampleClass();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public static void main(java.lang.String[]);
Code:
// This pushes the string constant
// "Hello" onto the stack
0: ldc #2 // String Hello
// "Hello" is stored in local variable 1
2: astore_1
// A new Object of type String builder is created
3: new #3 // class java/lang/StringBuilder
// dup duplicates the item at the top of the stack
// This is because a reference to the new object is popped
// by init. This allows the compiler to hold a reference for
// future use.
6: dup
// String builder is initialized
7: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
// "Hello" is loaded from memory
10: aload_1
// Appended to the String builder
11: invokevirtual #5 // Method java/lang/StringBuilder.append:
// (Ljava/lang/String;)Ljava/lang/StringBuilder;
// World is pushed to the stack
14: ldc #6 // String world
// World is appended to the string builder
16: invokevirtual #5 // Method java/lang/StringBuilder.append:
// (Ljava/lang/String;)Ljava/lang/StringBuilder;
// The toString method is called
// on the string builder
19: invokevirtual #7 // Method java/lang/StringBuilder.toString:
// ()Ljava/lang/String;
// The result is stored in the location of variable 1
22: astore_1
23: getstatic #8 // Field java/lang/System.out:Ljava/io/PrintStream;
// Another string builder is created here
// This is used to concatenate the String in
// The print statement
26: new #3 // class java/lang/StringBuilder
29: dup
30: invokespecial #4 // Method java/lang/StringBuilder."<init>":()V
33: ldc #9 // String Oh!
35: invokevirtual #5 // Method java/lang/StringBuilder.append:
// (Ljava/lang/String;)Ljava/lang/StringBuilder;
38: aload_1
39: invokevirtual #5 // Method java/lang/StringBuilder.append:
//(Ljava/lang/String;)Ljava/lang/StringBuilder;
42: invokevirtual #7 // Method java/lang/StringBuilder.toString:
// ()Ljava/lang/String;
45: invokevirtual #10 // Method java/io/PrintStream.println:
// (Ljava/lang/String;)V
48: return
}
It looks like there is a lot that goes on behind the scenes. The compiler does a fantastic job of optimizing the byte code, but there are a few concerns. Every time we modify a String, a new String builder is created. This is because Strings are inherently immutable. Whenver we attempt to modify a string, a String builder is created, the value of the string is appended to the builder, our modifications are made and then the result is copied into memory.
There are lots of ways to go about optimizing this program. One such way would be to explicitly declare and use one StringBuilder.
/**
* Demo class
*/
public class SampleClass{
public static void main(String[] args){
StringBuilder builder = new StringBuilder();
builder.append("Hello ");
builder.append(" world");
builder.insert(0, "Oh! ");
System.out.println(builder.toString());
}
}
This results in the following disassembled bytecode.
Compiled from "SampleClass.java"
public class SampleClass {
public SampleClass();
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 java/lang/StringBuilder
3: dup
4: invokespecial #3 // Method java/lang/StringBuilder."<init>":()V
7: astore_1
8: aload_1
9: ldc #4 // String Hello
11: invokevirtual #5 // Method java/lang/StringBuilder.append:
// (Ljava/lang/String;)Ljava/lang/StringBuilder;
14: pop
15: aload_1
16: ldc #6 // String world
18: invokevirtual #5 // Method java/lang/StringBuilder.append:
// (Ljava/lang/String;)Ljava/lang/StringBuilder;
21: pop
22: aload_1
23: iconst_0
24: ldc #7 // String Oh!
26: invokevirtual #8 // Method java/lang/StringBuilder.insert:
// (ILjava/lang/String;)Ljava/lang/StringBuilder;
29: pop
30: getstatic #9 // Field java/lang/System.out:
// Ljava/io/PrintStream;
33: aload_1
34: invokevirtual #10 // Method java/lang/StringBuilder.toString:
// ()Ljava/lang/String;
37: invokevirtual #11 // Method java/io/PrintStream.println:
// (Ljava/lang/String;)V
40: return
}
In this case, although we have the same number of concatenations. No new String builder instances are created. This potentially means fewer short term memory allocations and less work for the garbage collected to do in the future.
What do I benefit from this?
Some may argue that paging through disassembled bytecode might not be very practical in very large applications and how we don't need to micro optimize our code since the JVM is now very highly optimized in terms of speed and memory management. However, I like to think that good code is just "good code". Understanding the work that is being done under the hood will ultimately help you write better code.
... good code is just "good code". Understanding the work that is being done under the hood will ultimately help you write better code.
From a more practical perspective these techniques will be most useful in edge cases such as when developing latency-sensitive applications or in memory constrained situations where you have a very small heap. Whatever the reason, at the end of the day, it's just another useful tool in the belt that's nice to have when you need it.
While my goal in writing this is purely for educational purposes, I'm sure that by now you've noticed the tremendous potential this has for misuse. Decompiling/Disassembling compiled applications and/or libraries may be considered reverse engineering subject to terms and conditions stipulated in license agreements. This may be considered illegal activity in your region. Be responsible and do NOT decompile anything that does not belong to you. ↩︎