Skip to content
academia.sh

Lesson 06 / 10

Operators and Expressions

One-character forms leave silent steps in the class file: adding an int and a long inserts a conversion, count += 300 puts in a narrowing never written in the source and the result comes out 45, short-circuit shows up as a branch instruction — with constants, no step is added at all.

Contents

The previous lesson measured where a name is written in the class file: into a field, or into a slot. Once names have settled into their places, next come the operators that combine them. An operator is one character in the source; in the class file it is one or several instructions, and part of those instructions is never written in the source at all. This lesson counts that part.

The Programming Fundamentals course established operators, precedence, short-circuit evaluation, and explicit/implicit conversion as concepts — those are not repeated here. There, when a conversion is widening versus narrowing, and why narrowing can produce silent information loss, was explained. What is measured here is who adds that narrowing: a line that writes no conversion anywhere in the source can carry a conversion instruction in the class file.

An Operator Is a Shorthand

Java’s arithmetic operators only work at four widths: int, long, float, and double. There is no separate addition instruction for byte, short, and char. The consequence is this: values in smaller types are promoted to int before entering the operation, and if the two operands have separate widths, the narrower one is converted to the wider one. This is called numeric promotion, and it is invisible in the source.

Promotion has a reverse too. Compound assignment — +=, -=, *= — has to preserve the left side’s type. When an int is added to a byte variable with +=, the addition is done at int width, then the result is narrowed back to byte. This narrowing is not written in the source; if it were written, the compiler would not want it anyway. The same operation written as count = count + 300 would give a compile error, because there the programmer would have to write the narrowing.

The real distinction between the two directions is safety. A crossing from a narrow type to a wide one is widening and loses no information; the compiler does it without asking. A crossing from a wide type to a narrow one is narrowing, and bits that do not fit are discarded; the compiler does not normally do this, it asks the programmer to write it. Compound assignment is the one gap in this rule: the left side’s type is already known, so the compiler puts the narrowing in itself, right where it would otherwise ask for it. The measurement looks for this gap’s counterpart in the class file.

The lesson measures these two directions on two fields of the record carried through the course: int count and long weight. The width difference between them is the narrowest example on which promotion can be measured.

The Measurement Core

The core is the previous lessons’ core; this lesson reads two measurements. The first is conversion instructions — instructions in the class file that convert one width to another form a separate family, and their names carry the two types converted (i2l, i2b). The second is branch instruction count: every instruction that carries the flow from one point to another is counted.

  • BS11 — The right column lists conversions, dynamic calls, and calls in the class file; the reader drops what was written in the source, and what is left is what the compiler added.
  • BS12 — All measured methods are static and their bodies are a single line; the difference in instruction count comes not from method-call overhead but only from the expression itself.
// Gauge.java — reads the conversion, call, and branch instruction an operator leaves in 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, int branches, List<String> added) {}

    static List<Method> read(String source, String className) throws Exception {
        Path d = Files.createTempDirectory("gauge");
        Files.writeString(d.resolve(className + ".java"), source);
        PrintWriter sink = new PrintWriter(Writer.nullWriter());
        if (ToolProvider.findFirst("javac").orElseThrow().run(sink, sink, "-d", d.toString(),
                d.resolve(className + ".java").toString()) != 0)
            throw new IllegalStateException("did not compile: " + className);
        List<Method> methods = new ArrayList<>();
        for (MethodModel m : ClassFile.of().parse(d.resolve(className + ".class")).methods()) {
            if (m.code().isEmpty()) continue;
            List<String> added = new ArrayList<>();
            int instructions = 0, branches = 0;
            for (CodeElement e : m.code().get()) {
                if (e instanceof Instruction) instructions++;
                if (e instanceof BranchInstruction) branches++;
                if (e instanceof ConvertInstruction c)
                    added.add("convert " + c.opcode().name().toLowerCase(Locale.ROOT));
                else if (e instanceof InvokeDynamicInstruction id)
                    added.add("dynamic:" + id.name().stringValue());
                else if (e instanceof InvokeInstruction iv)
                    added.add(iv.name().stringValue());
            }
            methods.add(new Method(m.methodName().stringValue(), instructions, branches, added));
        }
        return methods;
    }
}

Thirteen Expressions, What They Leave in the Class File

The measured class carries thirteen methods, each a single expression. narrow and explicitNarrow do the same work; the only difference is whether the narrowing is written in the source. lateConvert and earlyConvert do the same multiplication; the only difference is whether the conversion is asked for before or after the multiplication. constantArithmetic, finalConstantProduct, and variableProduct give the same product in three separate forms.

// Operators.java — thirteen expressions, what they leave in the class file
public class Operators {
    static final String SOURCE = """
        class Warehouse {
            static long promote(int count, long weight) { return count + weight; }
            static byte narrow(byte count) { count += 300; return count; }
            static byte explicitNarrow(byte count) { count = (byte) (count + 300); return count; }
            static int smallSum(byte count, short shelf) { return count + shelf; }
            static long lateConvert(int count, int unit) { return count * unit; }
            static long earlyConvert(int count, int unit) { return (long) count * unit; }
            static String concat(String name, int count) { return name + count; }
            static String constantConcat() { return "bo" + "lt"; }
            static int constantArithmetic() { return 24 * 60 * 60; }
            static final int SHELVES = 24;
            static int shelfCount = 24;
            static int finalConstantProduct() { return SHELVES * 60 * 60; }
            static int variableProduct() { return shelfCount * 60 * 60; }
            static boolean shortCircuit(int count, long weight) { return count > 0 && weight > 0; }
            static boolean fullEval(int count, long weight) { return (count > 0) & (weight > 0); }
        }
        """;

    public static void main(String[] args) throws Exception {
        System.out.printf("%-18s %6s %4s  %s%n", "method", "instr", "br", "step not written in source");
        for (Gauge.Method y : Gauge.read(SOURCE, "Warehouse")) {
            if (y.name().startsWith("<")) continue;
            System.out.printf("%-18s %6d %4d  %s%n", y.name(), y.instructions(), y.branches(),
                    y.added().isEmpty() ? "-" : String.join(", ", y.added()));
        }
    }
}
method              instr   br  step not written in source
promote                 5    0  convert i2l
narrow                  7    0  convert i2b
explicitNarrow          7    0  convert i2b
smallSum                4    0  -
lateConvert             5    0  convert i2l
earlyConvert            6    0  convert i2l, convert i2l
concat                  4    0  dynamic:makeConcatWithConstants
constantConcat          2    0  -
constantArithmetic      2    0  -
finalConstantProduct      2    0  -
variableProduct         6    0  -
shortCircuit           10    3  -
fullEval               14    4  -

The first row is numeric promotion’s name in the class file. In the expression count + weight, count is an int, weight is a long; the addition instruction cannot take both at once, so the compiler inserts an i2l in between. It writes no conversion in the source. One of five instructions is a conversion the programmer did not write.

The second row is this lesson’s quietest step. The form count += 300 produces seven instructions and one of them is an i2b: the addition is done at int width, the result is narrowed to byte. The compiler adds the narrowing on its own. This is the class file’s counterpart of the silent information loss explained in the previous course — the concept was established there, here its point of insertion is shown.

The row below measures how exactly identical this is. explicitNarrow writes the narrowing in the source and comes out the same as narrow in the class file: the same 7 instructions, the same i2b. The two forms are indistinguishable in the class file. += is not an operator, it is a shorthand for this line; what it shortens has a conversion inside it, and the shorthand hides it.

The fourth row shows that promotion does not always produce an instruction. When a byte and a short are added, both are promoted to int, but there is no conversion at all in the class file: four instructions, zero added steps. These types are already loaded at int width, so promotion pays no instruction. Promotion exists everywhere as a rule; as an instruction it shows up only when a width actually changes.

The next two rows compare conversion’s location. Both return the same product as a long. lateConvert carries a single i2l, and that conversion is after the multiplication: two ints are multiplied, the result is converted to long. earlyConvert carries two i2ls — one the conversion written in the source, the other the promotion added for the second operand — and the multiplication is now done at long width. One instruction more, but a separate computation.

The concatenation rows tie the same + operator to two separate outcomes. Concatenation involving a variable leaves a dynamic call in four instructions; concatenating two constants is two instructions and no added step at all. The same operator, one a call, one zero steps.

The last two rows are short-circuit evaluation’s trace in the class file. The form using && produces 10 instructions and 3 branches, the form using & produces 14 instructions and 4 branches. The & form has to convert both comparisons to a boolean value; && puts in a branch that never enters the second comparison at all when the first comes out false. Short circuit is not a run-time convenience, it is a jump sitting in the class file.

The Added Conversion’s Visible Result

Every step counted so far has a counterpart that changes a value.

  • BS13 — In the overflow measurement, record count is 100000, unit weight is 50000 grams; the product is too large to hold at int width, and the only difference between the two forms is where the conversion sits.
  • BS14 — In the short-circuit measurement, the warehouse carries three records and two of them have a count field of zero; the right operand is a method call, and that method increments a counter every time it is called, so how many times the skipped branch was skipped can be counted.
// Result.java — the visible result of an added conversion and a skipped branch
public class Result {
    record Item(String name, byte count, long weight) {}

    static int calls = 0;

    static boolean isHeavy(Item k) { calls++; return k.weight() > 0; }

    public static void main(String[] args) {
        byte count = 1;
        count += 300;
        System.out.println("byte count = 1; count += 300      ->  " + count);

        int stock = 100000, unit = 50000;
        System.out.println("long lateConvert(100000, 50000)   ->  " + (long) (stock * unit));
        System.out.println("long earlyConvert(100000,50000)   ->  " + (long) stock * unit);

        Item[] warehouse = { new Item("bolt", (byte) 0, 40L),
                              new Item("screw", (byte) 3, 90L),
                              new Item("nut", (byte) 0, 12L) };
        calls = 0;
        int a = 0;
        for (Item k : warehouse) if (k.count() > 0 && isHeavy(k)) a++;
        int shortCircuit = calls;
        calls = 0;
        int b = 0;
        for (Item k : warehouse) if ((k.count() > 0) & isHeavy(k)) b++;
        System.out.println("&&  result: " + a + "  isHeavy calls: " + shortCircuit);
        System.out.println("&   result: " + b + "  isHeavy calls: " + calls);
    }
}
byte count = 1; count += 300      ->  45
long lateConvert(100000, 50000)   ->  705032704
long earlyConvert(100000,50000)   ->  5000000000
&&  result: 1  isHeavy calls: 1
&   result: 1  isHeavy calls: 3

The first line is what the i2b instruction costs. 300 was added to a count, and the result came out 45. No conversion is written in the source, the compiler produces no warning, and the program keeps running. The decision was made not by the programmer but by the added step.

The second and third lines show what conversion’s location costs. When the product is computed at int width, the result is 705032704; at long width, 5000000000. It is not enough for the left-side variable to be long — the result’s width cannot be rescued by a conversion done after the multiplication. This is the clearest measurement showing that promotion applies per operation.

The last two lines are short circuit’s run-time counterpart. Both forms find the same result: 1 record. The number of calls paid is separate: the && form runs the right operand 1 time, the & form runs it 3 times. A branch-instruction difference in the class file turns here into a two-method-call difference. If the right operand carried a side effect, the two forms would not give the same program; the choice between & and && is not a matter of style.

The Bounding Measurement: No Added Step on Constants

Three rows in the table give the same product in three separate forms, and in all three the added-step count is zero. Instruction counts are separate: 2, 2, and 6.

  • BS15 — All three methods return the same number; the only difference between them is whether the factors are known at compile time.

constantArithmetic finishes in two instructions: there is no multiplication instruction in the class file, the result is written directly. Constant folding, introduced in the compilation chain, turns into something measured here. finalConstantProduct is also two instructions — because SHELVES is a static final field and its initial value is known at compile time; the compiler substitutes not the name but the value and does the folding. variableProduct is six instructions: because shelfCount can change later, its value is read and the two multiplications are done at run time.

This trio bounds the lesson’s thesis. An added step is not a rule, it is a conditional counterpart: a conversion is added when operand widths diverge, a narrowing is added when the left side is narrow, a dynamic call is added when one of the operands is a variable. If all operands are known at compile time, nothing is left to add — on the contrary, the operation written in the source then drops out of the class file. An added step is sometimes a removal.

The boundary has a practical counterpart too: declaring a constant static final is not just a declaration of immutability, it grants the compiler permission to substitute that value. The same constant declared without final pays two multiplications at run time. The difference in this measurement is four instructions and is invisible by looking at the source.

Summary

  • Numeric promotion is a conversion instruction in the class file: when an int and a long are added, one of five instructions is an i2l never written in the source.
  • Compound assignment adds the narrowing on its own; count += 300 leaves one i2b in seven instructions and makes the result 45, and the compiler produces no warning. The form that writes the narrowing in the source is indistinguishable from this in the class file.
  • Promotion does not always produce an instruction: when a byte and a short are added, there is no conversion at all in the class file, because both are already loaded at int width.
  • Promotion applies per operation: the same product gives 705032704 at int width, 5000000000 at long width, and assigning the result to a long variable does not fix this.
  • Short circuit is a branch instruction: the && form produces 10 instructions and 3 branches, the & form 14 instructions and 4 branches; at run time the right operand runs 1 and 3 times.
  • No added step is left when all operands are known at compile time: the same product is 2 instructions with constants, 6 with a variable, and zero added steps in all three.

Next Step

This lesson measured a branch instruction for the first time — one that carries the flow from one point to another — and it came up as an operator’s byproduct. The next lesson makes the branch its main subject: the same behavior written with if and switch compiles to two separate instructions in the class file, and the two instructions’ lookup cost is not the same. What a labeled jump earns in nested loops is measured in instruction count, why the enhanced for loop produces separate instructions over an array versus a list is shown — and what the gain costs in readability is written in the same place.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close