Skip to content
academia.sh

Lesson 04 / 10

Primitive Types and Wrappers

A one-letter source difference is a method call in the class file: the line Integer count = 5 adds Integer.valueOf, the reverse direction adds Integer.intValue; the same 200 boxings produce between 100 and 200 objects depending on the warehouse, and where the cache ends, identity and value come apart.

Contents

The previous lesson measured that name resolution finishes at compile time: an import line leaves no trace in the class file, because the compiler resolves the name and puts its fully qualified counterpart in its place. One question is left. If name resolution finishes at compile time, when and where does the value itself the name carries get decided?

Java does not give a single answer to this question. When int count = 5 is written, the value sits in the method’s own local slot, as the number itself. When Integer count = 5 is written, an object is found on the heap and the local slot holds only that object’s reference. The difference in the source is one letter; the difference in the class file is a method call. The Programming Fundamentals course established basic data types and implicit conversion as concepts — that is not repeated here. There, what a value’s type determines was explained; here, what is measured is how many steps the compiler puts into the class file for this crossing.

Two Type Families

Java’s types split in two. Primitive types number eight and carry nothing but a number; reference types carry an object’s address. Every primitive type has a wrapper class: a reference type that holds the same value inside an object.

Primitive type Width (bits) Wrapper
boolean unspecified Boolean
byte 8 Byte
short 16 Short
char 16 Character
int 32 Integer
long 64 Long
float 32 Float
double 64 Double

The distinction’s visibility in the source is low, because the crossing between the two families is never written. An int value enters a spot expecting an Integer on its own (autoboxing), and an Integer enters a spot expecting an int on its own (unboxing). In both directions, only an equals sign appears in the source.

This is also where the distinction’s reason to exist lies. A primitive type is not an object: it has no methods, it cannot be null, and it cannot enter any spot expecting a reference. A list, a map, or any structure taking a type variable stores only references; to put an int value in a list, an object to wrap it is needed first. The wrapper fills this gap, and autoboxing is a shorthand that erases that filling from the source. The result is this: a source that writes List<Integer> does not do the same work as one that writes int — there is an object layer in between, and that layer’s cost sits in the class file.

A single data carrier is used across the course: Item, a warehouse record with three fields — String name, int count, long weight (grams). This lesson measures boxing on the count field.

The Measurement Core

The measurement compiles a given source text by calling the compiler from within the program, reads the class file it produces, and returns two things for every method: how many instructions were produced and which calls that method contains. The compiler and the class file reader are part of the standard library.

  • BS1 — The oracle is the setup itself: since we wrote the measured source ourselves, we know what we wrote. The right column lists all calls in the class file; the reader drops what was written in the source, and what is left is what the compiler added.
  • BS2 — The measured source text is fixed and is given to the same compiler on every run; the numbers come from a single compiler run and are deterministic within that run.
  • BS3 — The constructor (<init>) is not taken into the table; members not written in the source are a separate matter.
// Gauge.java — 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 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;
            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 TypeCheckInstruction t && t.opcode() == Opcode.CHECKCAST)
                    added.add("type check " + shortName(t.type().asInternalName()));
            }
            methods.add(new Method(m.methodName().stringValue(), instructions, added));
        }
        return methods;
    }

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

The Call Assignment Adds

The measured source carries five methods. box binds an int value to an Integer name, unbox does the reverse. arraySum and listSum write the same loop; the only difference is whether what is walked is an int[] or a List<Integer>. tally updates a counter with a count value.

// Boxing.java — the trace the primitive-to-wrapper crossing leaves in the class file
public class Boxing {
    static final String SOURCE = """
        import java.util.List;
        import java.util.Map;
        class Warehouse {
            static Object box(int count) { Integer k = count; return k; }
            static int unbox(Integer k) { int count = k; return count; }
            static int arraySum(int[] counts) { int t = 0; for (int x : counts) t += x; return t; }
            static int listSum(List<Integer> counts) { int t = 0; for (int x : counts) t += x; return t; }
            static void tally(Map<String, Integer> counter, String name, int count) { counter.put(name, count); }
        }
        """;

    public static void main(String[] args) throws Exception {
        System.out.printf("%-14s %6s  %s%n", "method", "instr", "call in class file");
        int total = 0;
        for (Gauge.Method y : Gauge.read(SOURCE, "Warehouse")) {
            if (y.name().equals("<init>")) continue;
            System.out.printf("%-14s %6d  %s%n", y.name(), y.instructions(),
                    y.added().isEmpty() ? "-" : String.join(", ", y.added()));
            total += y.added().size();
        }
        System.out.println("total: " + total);
    }
}
method          instr  call in class file
box                 5  Integer.valueOf
unbox               5  Integer.intValue
arraySum           24  -
listSum            20  List.iterator, Iterator.hasNext, Iterator.next, type check Integer, Integer.intValue
tally               7  Integer.valueOf, Map.put
total: 9

box’s source writes no call at all; the class file has Integer.valueOf. unbox writes no call either; the class file has Integer.intValue. Autoboxing is not a language convenience, it is a method call invisible in the source, and the reverse holds too. The tally row shows how BS1 gets read: Map.put is written in the source and is dropped; Integer.valueOf is not written and stays.

The bottom two rows pay off the course’s second thesis. arraySum produces 24 instructions and carries no step unwritten in the source: the enhanced for turns into a counted loop over an array, and the summation is done directly on numbers. listSum produces 20 instructions — fewer — but carries five steps not written in the source: an iterator is taken, queried twice, the returned object is checked against Integer, and opened into a number with intValue. Fewer instructions, more added steps. Instruction count and added-step count do not move in the same direction; a measurement cannot be compared without saying which one is being counted.

Four of these five steps sit inside the loop body, meaning they are paid per element. The class file does not show this; what it shows is the step’s existence. Seeing how many times it is paid requires running it.

Same Number of Boxings, Two Distinct Object Counts

Integer.valueOf does not produce a new object on every call. The specification requires that for int values, boxing of every value between -128 and 127 return the same object; outside this range is left to the implementation. This has a measurable consequence: the same number of boxings produces a distinct number of objects depending on where the values fall.

  • BS4 — All three warehouses carry 100 records, and each record’s count value is separate; the only difference is the base the values start from. In each warehouse every count is boxed twice, meaning all three measurements make 200 boxing calls.
  • BS5 — Distinct object count is found by gathering the produced wrappers in a map keyed by their identity; no object’s identity value is printed, only how many distinct objects there are is counted.
// Production.java — same number of boxings, two separate object counts
import java.util.IdentityHashMap;
import java.util.Map;

public class Production {
    record Item(String name, int count, long weight) {}

    static Item[] warehouse(int n, int base) {
        Item[] d = new Item[n];
        for (int i = 0; i < n; i++) d[i] = new Item("K" + i, base + i, 1000L + i);
        return d;
    }

    static int distinctObjects(Item[] warehouse) {
        Map<Integer, Boolean> identity = new IdentityHashMap<>();
        for (int round = 0; round < 2; round++)
            for (Item k : warehouse) identity.put(k.count(), Boolean.TRUE);   // every put is one valueOf
        return identity.size();
    }

    public static void main(String[] args) {
        System.out.printf("%-22s %8s %8s %10s%n", "warehouse", "boxings", "values", "distinct obj");
        for (int base : new int[] {1, 100, 1000}) {
            Item[] d = warehouse(100, base);
            System.out.printf("count %4d..%-4d %8d %8d %10d%n",
                    base, base + 99, 2 * d.length, d.length, distinctObjects(d));
        }
        Item[] big = warehouse(100, 1000);
        long t = 0;
        for (Item k : big) t += k.count();
        System.out.println("primitive sum: " + t + " | wrappers produced: 0");
    }
}
warehouse               boxings   values distinct obj
count    1..100       200      100        100
count  100..199       200      100        172
count 1000..1099      200      100        200
primitive sum: 104950 | wrappers produced: 0

The same code runs in all three rows, the same number of boxings is done, and the same number of distinct values is boxed. The number of objects produced is 100, 172, and 200. Nothing in the source produces this difference; the difference is entirely a decision made by the added step, that is, by Integer.valueOf.

The middle row shows the boundary directly. When count values spread between 100 and 199, the 28 values below 128 come back from the cache and one object forms for each; the remaining 72 values produce two distinct objects across the two rounds. Total 28 plus 144, that is 172. The number itself marks a threshold.

The last row is the measure’s other end. When the same warehouse is summed over int, no wrapper is produced at all: the line t += k.count() has no boxing, because long and int are both primitive types. The cost of using a wrapper in a loop can be that one object is produced at every step of that loop — and this is written nowhere in the source.

Where Identity and Value Come Apart

This difference in object production does not stay a number only; it has a visible consequence. When two reference types are compared with ==, what is tested is not their values but whether they are the same object. In the cache range, two boxings give the same object so == seems to give the correct result; outside the range the same code gives the wrong result.

// Cache.java — where a wrapper's identity and value come apart
public class Cache {
    record Item(String name, int count, long weight) {}

    static Item searchByIdentity(Item[] warehouse, Integer wanted) {
        for (Item k : warehouse) {
            Integer count = k.count();          // boxing: Integer.valueOf
            if (count == wanted) return k;       // two wrappers: identity comparison
        }
        return null;
    }

    static Item searchByValue(Item[] warehouse, Integer wanted) {
        for (Item k : warehouse) {
            Integer count = k.count();
            if (count.equals(wanted)) return k;  // value comparison
        }
        return null;
    }

    public static void main(String[] args) {
        Item[] warehouse = { new Item("bolt", 127, 40L), new Item("screw", 128, 90L) };
        System.out.printf("%-10s %-12s %-12s%n", "wanted", "by identity", "by value");
        for (Integer wanted : new Integer[] {127, 128}) {
            Item a = searchByIdentity(warehouse, wanted), b = searchByValue(warehouse, wanted);
            System.out.printf("count %-5d %-12s %-12s%n", wanted,
                    a == null ? "not found" : a.name(), b == null ? "not found" : b.name());
        }
        System.out.println();
        Integer a = 127, b = 127, c = 128, d = 128;
        System.out.println("Integer 127 == 127 : " + (a == b) + " | equals: " + a.equals(b));
        System.out.println("Integer 128 == 128 : " + (c == d) + " | equals: " + c.equals(d));
        Long e = 127L, f = 127L;
        Double g = 1.0, h = 1.0;
        System.out.println("Long    127 == 127 : " + (e == f) + " | equals: " + e.equals(f));
        System.out.println("Double  1.0 == 1.0 : " + (g == h) + " | equals: " + g.equals(h));
    }
}
wanted     by identity  by value    
count 127   bolt         bolt        
count 128   not found    screw       

Integer 127 == 127 : true | equals: true
Integer 128 == 128 : false | equals: true
Long    127 == 127 : true | equals: true
Double  1.0 == 1.0 : false | equals: true

searchByIdentity finds the record with count 127, fails to find the one with count 128. There is no difference at all between the two searches in the source; the result changed because the sought value went up by one. This is the clearest example of the course’s fourth reading: the decision was given not by the programmer, but by the added step. The same code works correctly on small warehouses, incorrectly on large ones, and the compiler produces no warning.

The bottom table draws the boundary. For Integer and Long, 127 is equal by identity, 128 is not; for Double, even 1.0 is not equal, because fractional wrappers carry no cache. The equals column is correct in all four rows: value comparison is not affected by which object boxing returns. The rule follows from here — wrappers are compared with equals, not ==; == is a value comparison only for primitive types.

The same observation also gives the bounding measurement: there is a range where this lesson’s measured added step has no effect on identity at all. Between -128 and 127, boxing produces no new object, == behaves as expected, and object count equals value count. The added step’s visibility depends on which range the value falls into.

Summary

  • The crossing between a primitive type and its wrapper is an equals sign in the source, a call in the class file: boxing adds Integer.valueOf, unboxing adds Integer.intValue.
  • The same loop produces 24 instructions and 0 added steps over an array, 20 instructions and 5 added steps over List<Integer>; fewer instructions, more added steps.
  • Integer.valueOf does not produce a new object on every call: the same 200 boxings leave 100, 172, or 200 distinct objects depending on the values’ range.
  • The specification requires the same object to be returned between -128 and 127; == looks correct in this range, gives the wrong result outside it, and the compiler produces no warning.
  • Wrappers are compared with equals; equals gave the correct result in all four measurements, == is a value comparison only for primitive types.

Next Step

This lesson measured which form a value is held in: the number itself, or an object wrapping the number. What is left is the name itself. The next lesson writes the same number of names into three separate places — inside a method, on an instance, and on the class itself — and counts where these three names land in the class file: which one is written into a slot, which one into a field, why a name opened inside a block never shows up as a name at all in the class file, and why redeclaring the same name in an inner block gives a compile error.

To keep your progress and take notes, Log in

My notes

Log in to take notes.

Start typing to search.

↑↓ Esc navigate · open · close