; last updated - 11 minutes read

My previous article gave you a short introduction on reading Java bytecode. Now let's do it the other way round: today we're going to write bytecode ourselves.

So grab your hex editor and get prepared to type endless sequences of cryptic numbers!

Wait - that's not how we're going to do it. Actually, I'm mostly interested in showing you the general idea. We're going to cut a couple of corners. We'll add code to an existing method of a class on your hard disk. And we'll use a framework that does the hard work for us. You don't have to download a hex editor. The Java class file format is a bit convoluted, so editing a Java class in a hex editor isn't too much fun.

Observe the difference between a high-level language like Java and a low-level language like Java bytecode or assembler. Bytecode programmers have to think a lot smaller. A single line of Java code can translate to half a dozen bytecode instructions. Remember the for loop of the previous article: Depending on what you count, it consists of five to eight instructions. Programming in assembler is even worse: a single high-level instruction can trigger thousands of machine-code instructions, especially on older CPUs. Which in turn also means that high-level languages tend to be slow. They do a lot of things that could be streamlined in assembler. But nowadays hardly anybody wants to pay the price: your program may run faster, but you don't program it quickly. The effort only pays in special areas such as games and hardware drivers.

Apart from showing you the different state of mind you need to adopt to program in a low-level language, the article also shows you how to get started with the ASM library. I picked a very small task: modifying a method of a class at runtime.

The challenge

Have a look at Circle.java. It's a small Swing application drawing a circle. I didn't use one of the standard methods, but chose to implement a simple algorithm calculating the coordinates of the pixels on the circle:

public class Circle extends JPanel { private static final long serialVersionUID = 1L; protected void paintComponent(Graphics g) { super.paintComponent(g); for (int y = 0; y < 71; y++) { calculateAndDrawPixel(g, y); } } /** We are going to implement this method in native bytecode */ private void calculateAndDrawPixel(Graphics g, int y) { // We are going to implement // the next two lines // in native bytecode: int x = (int) Math.sqrt(10000 - y * y); draw8Pixels(x, y, g); }; private void draw8Pixels(int x, int y, Graphics g) { drawPixel(x + 200, y + 200, g); drawPixel(200 - x, y + 200, g); drawPixel(x + 200, 200 - y, g); drawPixel(200 - x, 200 - y, g); drawPixel(y + 200, x + 200, g); drawPixel(200 - y, x + 200, g); drawPixel(y + 200, 200 - x, g); drawPixel(200 - y, 200 - x, g); } private void drawPixel(int x, int y, Graphics g) { g.drawLine(x, y, x, y); } }

We'll re-implement the shortest method in Java bytecode. In a way, this article is about two lines of Java code:

int x = (int) Math.sqrt(10000 - y * y); draw8Pixels(x, y, g);

Side remark: how this algorithm works

Just in case you're interested in how the program works: it's a nice application of the Pythagorean theorem which draws an 45° slice of the circle.

To get the full circle, we use the symmetries of the circle. draw8Pixels draws 8 pixels, one pixel in each 45° slice of the circle.

Cheat sheet of the bytecode instructions we need

Be that as it may, the algorithm isn't today's topic. Today we want to re-implement calculateAndDrawPixel(Graphics g, int y) in native bytecode:

int x = (int) Math.sqrt(10000 - y * y); draw8Pixels(x, y, g);

Can you figure out the bytecode already? For convenience, I'll give you a quick reference of the bytecodes:

  • iload_x pushes the variable with the index x on the stack.
  • istore_x removes the topmost stack element and stores it into variable #x.
  • sipush x stores a constant number on the stack.
  • imul pulls the two topmost elements from the stack and puts the product on the stack.
  • isub pulls the two topmost elements from the stack and puts the difference on the stack.
  • i2d and d2i convert the data type of the topmost stack element from integer to double and conversely.
  • invokestatic calls a static method (e.g. Math.sqrt()).
  • invokespecial isn't half as special as the name indicates: it simply calls a method in an object.

Live demo - your live demo, that is :)

The article aims at demonstrating the different approach of Java programming and programming in bytecode or assembler language. Readers of my previous article already know something about bytecode. Why don't you try to implement the bytecode version now? I'll show the master solution later. I'll tell you something about ASM before (so you don't see the master solution on the screen when you implement the algorithm yourself).

ASM

The natural approach to bytecode programming is to grab a hex editor and start coding. That's what we did in the old days. Java has roughly 190 bytecode instructions, so it's possible (if tedious) to look up the number of each instruction. But that's only the start. The Java class format is very baroque. There's the class header, the constant pool, the variable pool, each method has a declaration, there's the line number table... you get the idea: there are quite a few things to observe to get everything right.

That's why I prefer to cheat. For one, ASM is a library allowing you to write or manipulate byte or in Java. It works on a very low-level, machine-oriented level, which makes it difficult to get started. So I cheated a second time. I prepared stripped-down version of the circle class, containing the entire code including the declaration of calculateAndDrawPixel(), but without the implementation. We'll use ASM to fill the gap.

By the way, I cheated a third time. Andrey Loskutov's Byte Code Outline Eclipse plugin has an option to display ASM code instead of bytecode. All I had to do was to install the plugin and copy the Java ASM code that, in turn, generates the bytecode. Highly recommended!

But still, you need an annoying amount of boilerplate code just to get the ASM code running (see ClassAdapter.java and Main.java. Basically, it's a user defined class loader and an ASM class that intercept class loading and allows us to insert our own bytecode.

The bytecode version of int x = (int) Math.sqrt(10000 - y * y);

Working with a stack machine means we have to analyze carefully in which order we process the program. It's not as linear as you might think. A pseudo-code program looks like so:

y y² 10000 - y² Math.sqrt(10000 - y²) assign the result to x

Now we have to take into account we have to push everything on the stack before we can work with it. Calculating y*y amounts to push y on the stack twice and to call mul. After that, the topmost stack element is y².

Master solution of the byte code

If my explanation was good, you figured out everything by now:

private void calculateAndDrawPixel(java.awt.Graphics g, int y); 0 sipush 10000 ; push 10000 on the stack 3 iload_2 [y] ; push y on the stack 4 iload_2 [y] ; push y on the stack 5 imul ; y,y -> y² 6 isub ; 10000, y² -> 10000 - y² 7 i2d ; convert topmost element to double 8 invokestatic java.lang.Math.sqrt(double) : double [32] ; Math.sqrt() consumes the topmost stack ; element and replaces it with the ; square root 11 d2i ; convert the square root to int 12 istore_3 [x] ; pop in square root from stack ; and store it in variable x 13 aload_0 [this]; push this on stack 14 iload_3 [x] ; push x on stack 15 iload_2 [y] ; push y on stack 16 aload_1 [g] ; push graphics object on stack 17 invokespecial Circle.draw8Pixels(int, int, java.awt.Graphics) ; consumes the four topmost stack elements ; and doesn't push anything on stack ; (return value is void) ; ; now the stack is empty again 20 return ; return to the calling method

If we had a bytecode compiler at hand, we'd be done. However, the equivalent ASM code looks a bit different. There's some boilerplate code and a class containing the ASM version of the bytecode.

Master solution of the ASM code

Like said above, the boilerplate code is a user-defined class loader. It hands the byte stream over to ASM, which applies a visitor pattern to every class, method and field it sees. In our case, there's a method visitor that's called only when it sees a method called calculateAndDrawPixel. You could also say: our method visitor intercepts the class loading process when it deals with calculateAndDrawPixel. The disadvantage of our approach is we always add code to the existing method. The new code is inserted before the existing code. It's like weaving an AspectJ @Before aspect into the method. Our approach doesn't allow us to replace methods or to add code at the end of existing methods.

The advantage of our approach is we don't have to deal with method signatures, stack frames and things like that. Our visitor comes remarkably close to the ideal of 100% payload code:

import org.objectweb.asm.Label; import org.objectweb.asm.MethodVisitor; import static org.objectweb.asm.Opcodes.*; public class AdaptingMethodVisitor extends MethodVisitor { @Override public void visitCode() { Label l0 = new Label(); mv.visitLabel(l0); mv.visitLineNumber(16, l0); mv.visitIntInsn(SIPUSH, 10000); mv.visitVarInsn(ILOAD, 2); mv.visitVarInsn(ILOAD, 2); mv.visitInsn(IMUL); mv.visitInsn(ISUB); mv.visitInsn(I2D); mv.visitMethodInsn(INVOKESTATIC, "java/lang/Math", "sqrt", "(D)D", false); mv.visitInsn(D2I); mv.visitVarInsn(ISTORE, 3); Label l1 = new Label(); mv.visitLabel(l1); mv.visitLineNumber(17, l1); mv.visitVarInsn(ALOAD, 0); mv.visitVarInsn(ILOAD, 3); mv.visitVarInsn(ILOAD, 2); mv.visitVarInsn(ALOAD, 1); mv.visitMethodInsn(INVOKESPECIAL, "de/beyondjava/demos/bytecode/CircleWithoutImplementation", "draw8Pixels", "(IILjava/awt/Graphics;)V", false); Label l2 = new Label(); mv.visitLabel(l2); mv.visitEnd(); } public AdaptingMethodVisitor(MethodVisitor mv) { super(ASM5, mv); } }

Most lines simple insert a bytecode instruction (mv.visitVarInsn({{bytecode and parameters}}). The first three lines are slightly unexpected. They add a label and a line number. Most Java class files contain a table of line numbers. Debuggers need this table to display the correct line. The JVM itself doesn't need line numbers, so the lines numbers are stored as a separate table which tells the debugger which line begins at which byte of the class file. Labels are also used as GOTO goals.

The method calls need some explanation. The JVM knows several different kinds of method calls. InvokeStatic is the simplest variant: it calls a static method. In theory, Java calls are always InvokeVirtual. It checks whether the method has been redefined by a derived class. However, in many situations the compiler already knows it doesn't have to check the entire type hierarchy, so it can use the faster InvokeSpecial. There's also InvokeInterface for calling a method defined in an interface.

Method calls have to know the exact signature of the method. The name of the method isn't enough because of polymorphism. The signature is given in a very shorthand form: (D)D is a method receiving and return a double.

There's a very good introduction to method calls on the RebelLabs blog.

Wrapping it up

My GitHub project is a good starting point to experiment with bytecode yourself. The article also prepared you for the next article of the series. We're going all the way down to machine code. Stay tuned!

Dig deeper

My GitHub project

Andrey Loskutov's Byte Code Outline Eclipse plugin

Anton Arhipov's Java Bytecode Fundamentals

Anton Arhipov at the RebelLabs blog on method call bytecodes.

A Java Programmer’s Guide to Byte Code

A quick guide on writing byte code with ASM

A Java Programmer's Guide to Assembler Language

A Child’s Garden of Cache Effects


Comments