Skip to content
academia.sh

Lesson 01 / 10

Source, Bytecode, and the Virtual Machine

The source written in Java is not the thing that runs: eight of twelve methods carry a step in the class file that is absent from the source, 13 added steps counted in total; a concatenation sitting in the source, by contrast, is entirely absent from the class file.

Contents

The Python curriculum closed with five courses, and its closing course defined a project’s number with a single question: how many distinct outcomes does the same input give. Something was shared across everything measured through that curriculum — the source written stayed close to what the interpreter saw. When asked which method got called under a syntax form, the answer was searched for at runtime, because the distance between the source text and the thing running was short.

In Java, that distance is not short. Between the source and the thing that runs sits a compiler and the class file it produces; the Java virtual machine reads not the source but that file. The difference in between is not a difference in form: the class file holds things not written in the source. This lesson’s question, and the whole course’s, comes from here — the line written turns into how many steps, and which ones, in the class file?

The Chain and This Lesson’s Share

The How Computers Work course established the compiler, interpreter, and virtual machine trio, the concept of bytecode, and just-in-time compilation; it also covered compilation stages in a separate lesson. These are not repeated here. What was established there was the concept; what is measured here is the file a compiler actually produces for this source.

The chain holds three stops. Source text is what the human writes. The compiler turns it into a class file; this file carries bytecode, that is, a sequence of instructions written in the virtual machine’s instruction set. The virtual machine loads the file and executes the instructions. Only the first of the two passes is this lesson’s subject: the difference between the source and the class file.

The difference being measurable comes from a convenience. The class file’s format is defined, and the standard library carries both an interface reading that file and a compiler callable from within a program. No external tool is needed for the measurement: we hand over a text, compile it, read the file that comes out. Since we write the source ourselves, we know what we wrote; whatever extra we find in the file is what the compiler put there.

The Measurement Core

Every measurement in this course passes through a single helper class. Gauge compiles a given source text, reads the class file it produces, and returns two things for every method: how many instructions were produced, and which step not written in the source was added. An added step is one of five kinds — a call, a dynamic call, an object or array creation, a type check, a numeric conversion.

The measurement’s assumptions:

  • LR1 — The oracle is the rig itself: since we wrote the source ourselves, we know what we wrote, so whatever extra is found in the class file is not an inference, it is a dump.
  • LR2 — The compiler and the class-file reader are part of the standard library and are called from within the program; no external tool is used.
  • LR3 — The counted unit is the instruction. The numbers come from one compiler run and are deterministic within that run; the lesson’s thesis does not rest on a single instruction count, it rests on the direction between the numbers.
  • LR4 — The measurement reads no environment-dependent data: duration, memory address, identity, and path are not written.
  • LR5 — The measured source carries twelve methods, and these methods cover the syntax forms the course will pay off lesson by lesson; it is not the language’s whole syntax.
// Gauge.java — the measurement core: reads what the compiler adds to the class file
import java.io.PrintWriter;
import java.io.Writer;
import java.lang.classfile.*;
import java.lang.classfile.instruction.*;
import java.nio.file.*;
import java.util.*;
import java.util.spi.ToolProvider;

class Gauge {
    record Method(String name, int instructions, List<String> added) {}

    static ClassModel compile(String source, String className) throws Exception {
        Path d = Files.createTempDirectory("gauge");
        Path f = d.resolve(className + ".java");
        Files.writeString(f, source);
        PrintWriter sink = new PrintWriter(Writer.nullWriter());
        if (ToolProvider.findFirst("javac").orElseThrow()
                .run(sink, sink, "-d", d.toString(), f.toString()) != 0)
            throw new IllegalStateException("did not compile: " + className);
        return ClassFile.of().parse(d.resolve(className + ".class"));
    }

    static List<Method> read(String source, String className) throws Exception {
        List<Method> methods = new ArrayList<>();
        for (MethodModel m : compile(source, className).methods()) {
            if (m.code().isEmpty()) continue;
            List<String> added = new ArrayList<>();
            int instructions = 0;
            for (CodeElement e : m.code().get()) {
                if (e instanceof Instruction) instructions++;
                if (e instanceof InvokeInstruction iv)
                    added.add(shortName(iv.owner().asInternalName()) + "." + iv.name().stringValue());
                else if (e instanceof InvokeDynamicInstruction id)
                    added.add("dynamic:" + id.name().stringValue());
                else if (e instanceof NewObjectInstruction n)
                    added.add("new " + shortName(n.className().asInternalName()));
                else if (e instanceof NewPrimitiveArrayInstruction a)
                    added.add("new array " + a.typeKind().name().toLowerCase(Locale.ROOT));
                else if (e instanceof NewReferenceArrayInstruction a)
                    added.add("new array " + shortName(a.componentType().asInternalName()));
                else if (e instanceof TypeCheckInstruction t && t.opcode() == Opcode.CHECKCAST)
                    added.add("type check " + shortName(t.type().asInternalName()));
                else if (e instanceof ConvertInstruction c)
                    added.add("convert " + c.opcode().name().toLowerCase(Locale.ROOT));
            }
            methods.add(new Method(m.methodName().stringValue(), instructions, added));
        }
        return methods;
    }

    static String shortName(String internal) { return internal.substring(internal.lastIndexOf('/') + 1); }
}

The measured source also sits in a separate file, because this lesson will read it twice.

// Source.java — the measured source text: twelve methods, twelve syntax forms
class Source {
    static final String SAMPLE = """
        import java.util.List;
        class Sample {
            static int add(int a, int b) { return a + b; }
            static Object box(int n) { Integer k = n; return k; }
            static int unbox(Integer k) { int n = k; return n; }
            static String concat(String a, int n) { return a + n; }
            static String constantConcat() { return "sim" + "ple"; }
            static String loopConcat(String[] d) {
                String s = "";
                for (String x : d) s += x;
                return s;
            }
            static int arraySum(int[] d) { int t = 0; for (int x : d) t += x; return t; }
            static int listSum(List<Integer> l) { int t = 0; for (int x : l) t += x; return t; }
            static long widen(byte b, long u) { return b + u; }
            static byte narrow(byte b) { b += 300; return b; }
            static int variadic(int... numbers) { return numbers.length; }
            static int variadicCall() { return variadic(1, 2, 3); }
        }
        """;
}

Method by Method: How Many Instructions, Which Step

// Main.java — instruction count and step not in source, for every method
public class Main {
    public static void main(String[] args) throws Exception {
        System.out.printf("%-18s %6s  %s%n", "method", "instrs", "step not in source");
        int written = 0, withAdded = 0, total = 0;
        for (Gauge.Method y : Gauge.read(Source.SAMPLE, "Sample")) {
            System.out.printf("%-18s %6d  %s%n", y.name(), y.instructions(),
                    y.added().isEmpty() ? "-" : String.join(", ", y.added()));
            if (y.name().equals("<init>")) continue;
            written++;
            if (!y.added().isEmpty()) withAdded++;
            total += y.added().size();
        }
        System.out.println("written methods: " + written
                + " | carrying an added step: " + withAdded
                + " | total added steps: " + total);
    }
}
method             instrs  step not in source
<init>                  3  Object.<init>
add                     4  -
box                     5  Integer.valueOf
unbox                   5  Integer.intValue
concat                  4  dynamic:makeConcatWithConstants
constantConcat          2  -
loopConcat             24  dynamic:makeConcatWithConstants
arraySum               24  -
listSum                20  List.iterator, Iterator.hasNext, Iterator.next, type check Integer, Integer.intValue
widen                   5  convert i2l
narrow                  7  convert i2b
variadic                3  -
variadicCall           16  new array int, Sample.variadic
written methods: 12 | carrying an added step: 8 | total added steps: 13

Reading the Numbers

Eight of the twelve written methods carry a step in the class file not present in the source; 13 added steps in total. This is the measure the course will carry: an added step is the rule, not the exception. In every row of the table, the method we wrote sits on the left, what the compiler put there sits on the right.

Reading the rows one by one shows where the addition comes from. Integer k = n is an assignment in the source, and an Integer.valueOf call in the class file. a + n is written like an operator, and is a dynamic call in the class file. On the b + u line, a conversion instruction is added so a byte value can be added to a long. variadic(1, 2, 3) stands like a three-argument call; in the class file, an array is created first, then a single-argument call is made — this is why a three-instruction method’s call costs 16 instructions.

The four methods carrying no added step give just as much information: add, constantConcat, arraySum, and variadic. What they share is this — none of them asks another party a question at runtime. Adding two integers, reading an array’s element, asking an array’s length: all of these are met by the virtual machine’s direct instructions, and no call steps in between. This is where the added step’s condition is read: the compiler puts a step in between when it cannot find a match for the written form in the instruction set.

This fourth method also shows where an added step lands. variadic is three instructions and carries no step; its call, though, costs 16 instructions and carries an array creation. The cost of a method taking a variable number of arguments does not sit in its definition, it sits where it is called. Looking only at a form’s definition, when searching for its cost, is misleading.

The table also has a method never written in the source at all: <init>, a three-instruction constructor. No constructor is written for the Sample class in the source; the class file has one, and its only job is to call the base class’s constructor. The next lesson measures when this line disappears.

The sharpest reading comes from comparing two rows. arraySum and listSum carry the same loop form in the source — both are the enhanced for loop. On an array, this loop produces 24 instructions and zero added steps; on a list, 20 instructions and five added steps. That is, fewer instructions, more added steps: instruction count and added-step count do not move in the same direction. On the array, the virtual machine’s direct instructions are enough; on the list, an iterator chain is set up — iterator, hasNext, next, a type check, and an intValue. The two measurements say separate things, and one measurement cannot be compared to another without saying what it counts.

The same observation can be built the other way too. loopConcat and arraySum carry equal instruction counts — both 24 — but one carries a dynamic call, the other carries no step at all. Equal instruction count does not mean equal work. Counting instructions is one measure; counting added steps is another, and neither substitutes for the other.

A Bounding Measurement: What Sits in Source, Missing from the Class File

Every row up to here showed an addition. The claim’s boundary sits in the same table too: constantConcat‘s source writes a concatenation, and its class file carries 2 instructions and zero added steps. Given the same + operator turns into a dynamic call in concat, what happened here? The class file’s instruction names give the answer.

// Trace.java — is the concatenation sitting in the source present in the class file
import java.lang.classfile.*;
import java.lang.classfile.constantpool.*;
import java.util.*;

public class Trace {
    static List<String> opcodes(ClassModel cm, String method) {
        List<String> a = new ArrayList<>();
        for (MethodModel m : cm.methods())
            if (m.methodName().equalsString(method) && m.code().isPresent())
                for (CodeElement e : m.code().get())
                    if (e instanceof Instruction i) a.add(i.opcode().name().toLowerCase(Locale.ROOT));
        return a;
    }

    public static void main(String[] args) throws Exception {
        ClassModel cm = Gauge.compile(Source.SAMPLE, "Sample");
        System.out.println("constantConcat -> " + opcodes(cm, "constantConcat"));
        System.out.println("concat         -> " + opcodes(cm, "concat"));
        Set<String> strings = new HashSet<>();
        for (PoolEntry pe : cm.constantPool())
            if (pe instanceof StringEntry se) strings.add(se.stringValue());
        for (String s : List.of("simple", "sim", "ple"))
            System.out.printf("class file contains string literal \"%s\": %s%n", s, strings.contains(s));
    }
}
constantConcat -> [ldc, areturn]
concat         -> [aload_0, iload_1, invokedynamic, areturn]
class file contains string literal "simple": true
class file contains string literal "sim": false
class file contains string literal "ple": false

constantConcat consists of two instructions: load a constant, return. There is no concatenation in there. Furthermore, the concatenation’s operands are not there either: the class file carries the string "simple"; "sim" and "ple" are not found in it. Two constants have been folded into a single constant during compilation — this is constant folding, which the How Computers Work course established as a concept, and here it appears not as a concept but as a measured absence.

This is where the course’s second claim is born: an added step is not always an addition; sometimes it is a subtraction. The same + operator gives two separate results in two methods — zero steps in one, a dynamic call in the other. What creates the distinction is not the operator itself, it is whether its operands are known at compile time. Looking at the written form cannot tell us what will happen in the class file.

Which Machine Was the Translation Written For

The instruction names say a second thing too. aload_0 and iload_1 refer to a local variable slot by number, not a processor register. ldc pushes a value onto the stack, areturn takes one off the stack and returns. This instruction set belongs to a stack machine, and nowhere in it does a real hardware name appear.

The same holds for names. In the table, the listSum row writes List.iterator and Iterator.hasNext: the class file carries the method it will call not by address, but by name. What that name corresponds to in code is resolved not at compile time, but while the file is being loaded.

This is portability’s measure: the compiler wrote not to a machine with hardware, but to a machine with a specification. The class file is not translated to a real processor; that translation is taken on by the virtual machine itself, while running — this is the job the How Computers Work course established under the heading of just-in-time compilation. What this course measures is not that second pass, it is the first.

Summary

  • In Java, a compiler and a class file sit between the source and the thing that runs; the virtual machine reads not the source, but the bytecode in the class file.
  • The measurement core Gauge compiles a source text and, by reading the class file, dumps the instruction count and the steps not written in the source for every method.
  • Eight of the twelve written methods carry an added step, 13 added steps are counted in total; there is even a three-instruction constructor never written in the source at all.
  • The same enhanced for loop produces 24 instructions and zero added steps on an array, 20 instructions and five added steps on a list: instruction count and added steps do not move in the same direction.
  • A concatenation sitting in the source is entirely absent from the class file; two constants fold into one, and the operands are not found in the file. An added step is sometimes a subtraction.
  • The class file’s instructions refer to a slot number and a stack operation, and carry the method they will call by name: what got translated was written for a machine with a specification, not a machine with hardware.

Next Step

This lesson measured what the class file is, but never looked at when the instructions inside it run. The table held a constructor never written in the source, and when it gets called was never said; where a class’s own setup work sits was never asked either. The next lesson measures the program’s life cycle: which signature does the virtual machine count as the entry point, in what order do the static initializer and the constructor run, and how many members does a class with two names written in the source carry on its class-file surface?

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close