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
countvalue is separate; the only difference is the base the values start from. In each warehouse everycountis 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 addsInteger.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.valueOfdoes 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;equalsgave 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.