Lesson 10 / 10
Value and Reference Passing
Java has a single passing model, and the class file writes it down: five arguments of five separate types are loaded the same way, writing to a parameter's field is visible to the caller, rebinding the parameter is not, and on an immutable type the two situations cannot be told apart.
Contents
The previous lesson measured that a string can come out the same by == and equal by
equals; two names sometimes held a single object. The same question can be asked at a
call’s boundary too.
Pass by value and pass by reference were established as concepts in the Programming Fundamentals course and are not repeated here. The question here is: which of these concepts does Java realize, and how do we read this from the class file? Someone looking at the source cannot see, when a record is given to a method, whether the object passes or the reference leading to it does; the class file can.
Arguments Are Loaded in a Single Form
- BS35 — The measured source is written inside the lesson; every extra found in the class file is what the compiler added.
- BS36 — Two measurements from the
Gaugecore are used in this lesson: instruction count and added steps per method, and a method’s instruction-name sequence. - BS37 — The five parameters in the call measurement are deliberately separate types: two primitive types (one occupying two slots), a class, an array, and an immutable class.
// Gauge.java — shared measurement core: reads what the compiler puts 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, List<String> added) {}
static ClassModel compile(String source, String className) throws Exception {
Path d = Files.createTempDirectory("gauge");
Path k = d.resolve(className + ".java");
Files.writeString(k, source);
PrintWriter sink = new PrintWriter(Writer.nullWriter());
if (ToolProvider.findFirst("javac").orElseThrow()
.run(sink, sink, "-d", d.toString(), k.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()));
}
methods.add(new Method(m.methodName().stringValue(), instructions, added));
}
return methods;
}
static List<String> instructionNames(String source, String className, String method) throws Exception {
List<String> names = new ArrayList<>();
for (MethodModel m : compile(source, className).methods())
if (m.methodName().stringValue().equals(method))
for (CodeElement e : m.code().orElseThrow())
if (e instanceof Instruction i)
names.add(i.opcode().name().toLowerCase(Locale.ROOT));
return names;
}
static String shortName(String internal) { return internal.substring(internal.lastIndexOf('/') + 1); }
}
// Passing.java — argument loading and writing to a parameter in the class file
public class Passing {
static final String K = """
class PassingSample {
static class Item { String name; int count; long weight; }
static void takeNumber(int count) {}
static void takeWeight(long weight) {}
static void takeItem(Item k) {}
static void takeArray(int[] d) {}
static void takeText(String s) {}
static void call(int count, long weight, Item k, int[] d, String s) {
takeNumber(count); takeWeight(weight); takeItem(k); takeArray(d); takeText(s);
}
static void changeField(Item k) { k.count = 99; }
static void rebind(Item k) { k = new Item(); k.count = 99; }
static void incrementNumber(int count) { count = count + 1; }
static void extendText(String s) { s = s + " x"; }
}
""";
public static void main(String[] args) throws Exception {
System.out.println("call: " + Gauge.instructionNames(K, "PassingSample", "call"));
System.out.println();
System.out.printf("%-16s %6s %s%n", "method", "instr", "step not written in source");
for (Gauge.Method y : Gauge.read(K, "PassingSample"))
if (y.name().equals("changeField") || y.name().equals("rebind")
|| y.name().equals("incrementNumber") || y.name().equals("extendText"))
System.out.printf("%-16s %6d %s%n", y.name(), y.instructions(),
y.added().isEmpty() ? "-" : String.join(", ", y.added()));
for (String y : new String[] {"changeField", "rebind"})
System.out.printf("%-16s %s%n", y, Gauge.instructionNames(K, "PassingSample", y));
}
}
call: [iload_0, invokestatic, lload_1, invokestatic, aload_3, invokestatic, aload, invokestatic, aload, invokestatic, return] method instr step not written in source changeField 4 - rebind 8 new PassingSample$Item, PassingSample$Item.<init> incrementNumber 5 - extendText 4 dynamic:makeConcatWithConstants changeField [aload_0, bipush, putfield, return] rebind [new, dup, invokespecial, astore_0, aload_0, bipush, putfield, return]
The first row is this lesson’s share of the course’s thesis. Five arguments of five separate
types pass, and there are five loading instructions in the class file: iload, lload,
and three aload. All do the same work — putting the value in a local variable slot onto the
stack — and the only difference between them is the value’s width. An integer takes one slot,
a long integer takes two; this is why the third argument loads with aload_3 — the second
argument has already taken up two numbers.
What is not here speaks louder than what is. There is no instruction that copies an object. There is no separate instruction that passes a reference by its address. The class file does not have two separate paths named “pass by value” and “pass by reference”; there is one path, and that path is copying the value sitting in the slot and putting it on the stack. For a record, an array, and a string, the value sitting in the slot is a reference; what gets copied is not the object, it is that reference. Java’s passing model is this single sentence, and the rest of the measurement is this sentence’s consequences.
The table below separates two outcomes. changeField is 4 instructions and no step is
added: load the reference, load the value, write to the field. The write goes into the
object with putfield. rebind, by contrast, is 8 instructions and adds two steps: an
object production and a constructor call. The instruction sequence writes the continuation —
astore_0, meaning the new reference is written over the slot. The slot is the called
method’s own slot; there is no link at all between it and the caller’s slot.
The last two rows are the same distinction’s counterpart on a primitive type and a string:
incrementNumber writes to the slot, extendText builds a new string with a dynamic call
and again writes to the slot.
The Visible Change and the Invisible
- BS38 — No identity number is printed in the visibility measurement; identity is only
asked with
==. - BS39 — Before every measurement, the field is set to a known value, so what changed can be separated from what did not.
- BS40 — In the immutable-type measurement, the primitive type and the string are tried in the same form. For both, only rebinding can be written, because a string has no writable member.
// Visible.java — which change is visible to the caller
class Item {
String name;
int count;
long weight;
}
public class Visible {
static boolean sameObject(Item incoming, Item outside) { return incoming == outside; }
static void changeField(Item k) { k.count = 99; }
static void rebind(Item k) { k = new Item(); k.count = 99; }
static void swap(Item a, Item b) { Item g = a; a = b; b = g; }
static void writeArray(int[] d) { d[0] = 99; }
static void incrementNumber(int count) { count = count + 1; }
static void extendText(String s) { s = s + " x"; }
public static void main(String[] args) {
Item k = new Item();
k.name = "north";
k.count = 12;
System.out.println("is the parameter the same object as outside: " + sameObject(k, k));
changeField(k);
System.out.println("count after changeField : " + k.count);
k.count = 12;
rebind(k);
System.out.println("count after rebind : " + k.count);
Item a = new Item();
a.name = "north";
Item b = new Item();
b.name = "south";
swap(a, b);
System.out.println("a.name, b.name after swap : " + a.name + " " + b.name);
int[] d = {12, 18};
writeArray(d);
System.out.println("d[0] after writeArray : " + d[0]);
int count = 12;
incrementNumber(count);
String text = "north";
extendText(text);
System.out.println("number and text after : " + count + " " + text);
}
}
is the parameter the same object as outside: true count after changeField : 99 count after rebind : 12 a.name, b.name after swap : north south d[0] after writeArray : 99 number and text after : 12 north
The first line confirms the previous section’s sentence at run time: the reference in the
parameter and the reference outside point to the same object. The object was not copied.
This is why the second line is not surprising — writing to the field is visible to the
caller, count became 99.
The third line places the distinction. rebind also writes 99 to a field, but because it
first binds the parameter to a new object, what it writes to is a separate object; the
caller’s record stays 12. What makes the difference is not the write itself, it is
which object is written to. The fourth line is the best-known form of this: a method
swapping two parameters does nothing to the caller, because what it swaps is two slots.
The fifth line is the same rule applied to an array: an array is an object too, the reference
in the parameter points to the same array, and the write d[0] = 99 is visible to the caller.
An applicable rule follows from here. A method’s caller-visible effect arises only when it writes into a reachable object; an assignment to the parameter itself is never visible. If we do not want the object the caller gave to change, either the object must have no mutable member, or the method must build its own copy.
On an Immutable Type, the Distinction Cannot Be Observed
The last line is the lesson’s bounding measurement, and it places two values side by side: the number 12, the text north. Neither changed.
That the number did not change is expected; count is a primitive type, and the value
sitting in the slot is the number itself. The real result is in the text. extendText took a
reference, the object that reference pointed to was the same as the caller’s, and the caller
again saw no change at all. The previous lesson measured the answer: a string has no writable
member. The line s = s + " x" does not write into the object, it cannot; it builds a new
string and puts it in the parameter’s slot.
What this means for the measurement is: this lesson’s question cannot be asked of an immutable type. A string parameter cannot be told apart from a primitive-type parameter, so looking at a string cannot decide which model Java uses. What makes the distinction observable is not the passing form, it is whether the object has a mutable member.
Summary
- There is a single form of passing an argument in the class file: putting the value sitting in the slot onto the stack. Five arguments of five separate types pass with five loading instructions; the only difference is how many slots the value occupies.
- On reference types, the value sitting in the slot is a reference; what gets copied is not the object, it is that reference, and the parameter and the outside name point to the same object.
- Writing to a parameter’s field goes into the object with
putfieldand is visible to the caller; rebinding the parameter writes to the method’s own slot withastoreand is not visible. - A method swapping two parameters does nothing to the caller; a method writing to an element of an array, by contrast, has a visible effect, because an array is an object too.
- The distinction cannot be observed on an immutable type: a string parameter cannot be told apart from a primitive-type parameter, because there is no member to write into the object.
Course Wrap-Up
The course opened with a single question: how many steps, and which steps, does a written line turn into in the class file? Ten lessons asked this question in ten separate forms and got the answer, every time, from a run measurement. Gathered together, a single rule comes out: in Java, syntax is a shorthand. The source does not show the whole of the steps placed into the class file; what it shows is in what form those steps will be requested.
| Lesson | Measured form | Added step | Bounding measurement |
|---|---|---|---|
| Source, Bytecode, and the Virtual Machine | comparing the source text with the class file | 8 of 12 methods, 13 added steps total | a concatenation sitting in the source is entirely absent from the class file |
| Program Life Cycle | entry point and initialization order | a member is added: default constructor, 3 fields and 5 methods | once a constructor is written, the added constructor disappears |
| Packages and Imports | import versus fully qualified name | zero steps: two files byte-for-byte identical | a package name grows the file by six bytes |
| Primitive Types and Wrappers | the crossing between a primitive type and its wrapper | Integer.valueOf and Integer.intValue |
== is correct in the cache range, wrong outside it |
| Variables and Scope | where a declaration sits | a field instruction, or just a slot | block scope produces the same 20 instructions, with one fewer slot |
| Operators and Expressions | numeric promotion and compound assignment | i2l and i2b conversions, a branch on short circuit |
no step is added on two constants |
| Control Flow | selection and loop forms | two separate lookup instructions for selection; 5 steps for the enhanced for over a list |
the same loop produces 24 instructions and zero steps over an array |
| Arrays | array production, length, and access | array production; arraylength; a per-element write |
covariance’s type check does not show up in instruction count |
| Strings | concatenation, the pool, and the builder | a dynamic call; production and three calls in the builder | concatenating two constants is 2 instructions, zero steps |
| Value and Reference Passing | argument loading and writing to a parameter | production and a slot write on rebind | on an immutable type, the distinction cannot be observed |
The table’s last column is the course’s method. Every lesson also measured a situation where the added step was absent or disappeared, because something found everywhere explains nothing. Constant folding, a written constructor, the cache range, a primitive-typed array — each draws the rule’s boundary. The course’s second reading comes from here too: instruction count and added-step count do not move in the same direction, and fewer instructions does not mean less work.
In this course, the class was used only as a container. Yet through the measurements the class itself produced questions too: a constructor not written in the source, three fields and five methods not written in the source, a change visible to the caller when a record’s field is written. The next course, Object-Oriented Java, takes the class out of being a container and makes it the subject. The same measure will do its job there too — who decides which method a call goes to, and when, can be read from the class file just the same.
To keep your progress and take notes, Log in
My notes
Log in to take notes.