Lesson 06 / 13
The `final` Keyword
A single keyword places three separate restrictions and enforces all three at compile time: reassignment, overriding, and extension. A constant final field's value is written into the reading class, and the old value stays in place when the source changes; a final reference, in turn, does not freeze the object.
Contents
The previous lesson’s last measurement read a value without ever initializing the class it
belonged to: Base.CEILING was a static final field, and the correct number arrived without
the initialization block ever running. The reason for that result sat in a single word,
final, and it showed up there only as a side effect.
This lesson measures that word directly. final can be written in three places — at the head
of a variable, a method, and a class — and it places three separate restrictions. What the
three share is that all three are decided at compile time: none of them is a condition
tested at runtime. There are three questions: how many of these restrictions stop at the
compiler, where does a constant field’s value get written, and what does a final reference
protect?
The Measurement Core
The measurement does three things: it compiles a source and reports whether it compiled, reads
the final flags in the class file it produces, and runs the compiled classes in a separate
loader.
- CI26 — In the compile trials, the oracle is the rig itself: a single line changes in every trial, so a compile stopping can be tied to only that line. The result is read through a single exit code; the error text is not read, because that text is locale-dependent.
- CI27 — Every trial compiles in its own temporary directory; trials do not see each other’s class files.
- CI28 — In the embedding measurement,
Fixedis compiled twice,Readeronce; the class file read on the second run is the same file as the first. - CI29 — The run happens in a separate loader, so the class loaded on the first run does not carry over to the second.
- CI30 — No environment-dependent data is read; what is counted is the number of trials that compile, the flags in the class file, and the value read.
// Gauge.java — compile result, the final flag in the class file, and the reading side's trace
import java.io.PrintWriter;
import java.io.Writer;
import java.lang.classfile.*;
import java.lang.classfile.instruction.*;
import java.lang.reflect.AccessFlag;
import java.net.URL;
import java.net.URLClassLoader;
import java.nio.file.*;
import java.util.*;
import java.util.spi.ToolProvider;
class Gauge {
static Path newDir() throws Exception { return Files.createTempDirectory("gauge"); }
static boolean compile(Path d, String name, String source) throws Exception {
Path f = d.resolve(name + ".java");
Files.writeString(f, source);
PrintWriter sink = new PrintWriter(Writer.nullWriter());
return ToolProvider.findFirst("javac").orElseThrow()
.run(sink, sink, "-d", d.toString(), "-cp", d.toString(), f.toString()) == 0;
}
static String finalFlags(Path d, String className) throws Exception {
ClassModel cm = ClassFile.of().parse(d.resolve(className + ".class"));
List<String> flags = new ArrayList<>();
if (cm.flags().has(AccessFlag.FINAL)) flags.add("class " + className);
for (FieldModel x : cm.fields())
if (x.flags().has(AccessFlag.FINAL)) flags.add("field " + x.fieldName().stringValue());
for (MethodModel x : cm.methods())
if (x.flags().has(AccessFlag.FINAL)) flags.add("method " + x.methodName().stringValue());
return String.join(", ", flags);
}
static List<String> traces(Path d, String className, String method) throws Exception {
ClassModel cm = ClassFile.of().parse(d.resolve(className + ".class"));
List<String> trace = new ArrayList<>();
for (MethodModel m : cm.methods()) {
if (!m.methodName().stringValue().equals(method) || m.code().isEmpty()) continue;
for (CodeElement e : m.code().get()) {
if (e instanceof FieldInstruction f)
trace.add("field read " + shortName(f.owner().asInternalName())
+ "." + f.name().stringValue());
else if (e instanceof ConstantInstruction c)
trace.add("constant load " + c.constantValue());
else if (e instanceof InvokeInstruction iv)
trace.add(switch (iv.opcode()) {
case INVOKEVIRTUAL -> "virtual call ";
case INVOKESTATIC -> "static call ";
default -> "direct call ";
} + shortName(iv.owner().asInternalName()) + "." + iv.name().stringValue());
}
}
return trace;
}
static Object run(Path d, String className, String method) throws Exception {
try (URLClassLoader cl = new URLClassLoader(new URL[] {d.toUri().toURL()}, null)) {
var m = cl.loadClass(className).getDeclaredMethod(method);
m.setAccessible(true);
return m.invoke(null);
}
}
static String shortName(String internal) { return internal.substring(internal.lastIndexOf('/') + 1); }
}
Three Restrictions, All at Compile Time
The first measurement compiles nine sources. The trials are set up in pairs: next to every
final trial sits its twin, whose only difference is that word’s absence.
// Restriction.java — final places three separate restrictions, all at compile time
import java.nio.file.Path;
public class Restriction {
static final String[][] TRIALS = {
{"reassigning a local variable", "class T { void f() { int y = 1; y = 2; } }"},
{"reassigning a final local", "class T { void f() { final int y = 1; y = 2; } }"},
{"reassigning a field", "class T { int a; T() { a = 1; } void f() { a = 2; } }"},
{"reassigning a final field", "class T { final int a; T() { a = 1; } void f() { a = 2; } }"},
{"assigning a final field in constructor", "class T { final int a; T() { a = 1; } }"},
{"overriding a method", "class A { int f() { return 1; } } class T extends A { @Override int f() { return 2; } }"},
{"overriding a final method", "class A { final int f() { return 1; } } class T extends A { int f() { return 2; } }"},
{"extending a class", "class A { } class T extends A { }"},
{"extending a final class", "final class A { } class T extends A { }"},
};
public static void main(String[] args) throws Exception {
System.out.printf("%-42s %s%n", "spelling tried", "does it compile");
for (String[] d : TRIALS)
System.out.printf("%-42s %s%n", d[0], Gauge.compile(Gauge.newDir(), "T", d[1]));
String source = """
final class Flagged {
final int fixedField = 1;
int otherField = 2;
final int locked() { return 1; }
int free() { final int y = 1; return y; }
}
""";
Path d = Gauge.newDir();
Gauge.compile(d, "Flagged", source);
System.out.println();
System.out.println("carrying the final flag in the class file:");
System.out.println(" " + Gauge.finalFlags(d, "Flagged"));
Path e = Gauge.newDir();
Gauge.compile(e, "A", """
class A { final int locked() { return 1; } int free() { return 2; } }
class Caller {
static int a(A x) { return x.locked(); }
static int b(A x) { return x.free(); }
}
""");
System.out.println();
System.out.printf("%-22s : %s%n", "final method call", Gauge.traces(e, "Caller", "a"));
System.out.printf("%-22s : %s%n", "non-final method call", Gauge.traces(e, "Caller", "b"));
}
}
spelling tried does it compile reassigning a local variable true reassigning a final local false reassigning a field true reassigning a final field false assigning a final field in constructor true overriding a method true overriding a final method false extending a class true extending a final class false carrying the final flag in the class file: class Flagged, field fixedField, method locked final method call : [virtual call A.locked] non-final method call : [virtual call A.free]
Four of nine trials stopped, and all four were a final trial. Their twins compiled
without a hitch; the only difference is that word. The fifth row shows the restriction’s exact
place: assigning to a final field is not forbidden, reassigning it is. The field can be
assigned once, in the constructor, and if it is not assigned there, compilation stops just the
same; the restriction is not “never write to it,” it is “write to it exactly once.”
The seventh row ties into the course’s question. Once a method is declared final, no other
answer can be written for it from a subclass. In the previous lesson, a call had two
possible answers, and the runtime type decided which one arrived. final brings that number
down to one: whatever the runtime type is, a single body remains, and the question loses
its meaning. final does not move the runtime type’s decision to the declared type; it
removes the decision.
The output’s last two lines sharpen this reading one step further. A final method’s call and
a non-final one’s are both written to the class file as a virtual call; there is no
difference between the two lines at all. So the compiler does not change the call’s mechanism,
it reduces the set of bodies the call could select to one. This is the difference from the
previous lesson’s static call: there, the writing stayed unchanged but the target
changed; here, target selection stays in place, but only a single candidate remains to be
selected.
The middle block separates where the restrictions stop. Three things carry the final flag
in the class file: the class itself, fixedField, and the locked method. The final int y
declaration inside free‘s body is not on the list. A local variable’s restriction is
never written to the class file at all; it lives only in the compiler’s memory and disappears
once compilation ends. The other two stay as flags, because another compile will read
them: a compiler writing a subclass has to be able to see locked’s flag.
Where Is the Constant Value Written
The previous lesson’s CEILING measurement left a question open: given the class was never
initialized, where did the value come from? The measurement below puts two fields side by
side. CEILING takes its value directly from a number; FLOOR takes the same number from a
method call. Both are static final.
// Embed.java — a constant final field's value is written into the reading class
import java.nio.file.Path;
public class Embed {
static String constant(int n) {
return """
class Fixed {
static final int CEILING = %d;
static final int FLOOR = compute();
static int compute() { return %d; }
}
""".formatted(n, n);
}
static final String READER = """
class Reader {
static int ceiling() { return Fixed.CEILING; }
static int floor() { return Fixed.FLOOR; }
static String value() { return "CEILING=" + ceiling() + " FLOOR=" + floor(); }
}
""";
public static void main(String[] args) throws Exception {
Path d = Gauge.newDir();
Gauge.compile(d, "Fixed", constant(100));
Gauge.compile(d, "Reader", READER);
for (String y : new String[] {"ceiling", "floor"})
System.out.println("trace inside Reader." + y + "(): " + Gauge.traces(d, "Reader", y));
System.out.println();
System.out.println("first run : " + Gauge.run(d, "Reader", "value"));
Gauge.compile(d, "Fixed", constant(200));
System.out.println("Fixed recompiled with 200, Reader not recompiled");
System.out.println("second run : " + Gauge.run(d, "Reader", "value"));
}
}
trace inside Reader.ceiling(): [constant load 100] trace inside Reader.floor(): [field read Fixed.FLOOR] first run : CEILING=100 FLOOR=100 Fixed recompiled with 200, Reader not recompiled second run : CEILING=100 FLOOR=200
The first two lines answer the question. ceiling’s class file carries no reference to the
Fixed class at all; there is only the number 100 itself. floor‘s method, in turn,
carries a field read, and that read’s owner is Fixed. In the source the two lines are
identical — both write a field name after a dot — but one has copied the value into the
reading class, the other has left a link.
The cost of copying shows on the second run. Fixed was recompiled with the value 200;
Reader was not recompiled and sits on disk in its old form. When run, FLOOR gives the new
value, 200: the link stayed in place, and was read through it. CEILING, though, still
says 100. Someone looking at the source will see 200 inside Fixed and read 100 in the
running program.
This is the sharpest form of the question this course asks. The decision here is split not
between declared type and runtime type, but between compile time and runtime — yet the
result is the same: a programmer who does not know which side is answering assumes the value
they see in the source is the one running. The defect is not in whoever changed Fixed
either; it is in Reader not being recompiled, and it produces no error message anywhere.
A Bounding Measurement: A final Reference Does Not Freeze the Object
All three restrictions have been measured, and all three stop at compile time. Now the
claim’s boundary: what happens to the inside of an object bound to a final name?
// Freeze.java — a final reference does not freeze the object
import java.util.Arrays;
public class Freeze {
static class Entry {
String name;
int count;
Entry(String name, int count) { this.name = name; this.count = count; }
@Override public String toString() { return name + ":" + count; }
}
static final String BODY = """
class Trial {
static class Entry { int count; }
void f() { final Entry k = new Entry(); %s }
}
""";
public static void main(String[] args) throws Exception {
final Entry k = new Entry("bolt", 12);
final int[] weight = {10, 20, 30};
Entry first = k;
System.out.println("start : " + k + " " + Arrays.toString(weight));
k.name = "nut";
k.count = 99;
weight[0] = 99;
System.out.println("after change : " + k + " " + Arrays.toString(weight));
System.out.println("reference same : " + (k == first));
System.out.println();
for (String[] y : new String[][] {{"k.count = 1;", "changing a field"},
{"k = new Entry();", "rebinding the reference"}})
System.out.printf("%-26s -> does it compile: %s%n", y[1],
Gauge.compile(Gauge.newDir(), "Trial", BODY.formatted(y[0])));
}
}
start : bolt:12 [10, 20, 30] after change : nut:99 [99, 20, 30] reference same : true changing a field -> does it compile: true rebinding the reference -> does it compile: false
k is a final local variable, and both of its fields changed; weight is final too, and
its first element changed. Even so, k == first is still true: the reference never
changed. What is protected is not the object, it is the link between the name and the
object.
The last two lines reduce the distinction to a single measurement. On the same final
declaration, the line changing a field compiles, the line rebinding the reference does not.
final places a restriction on top of the assignment model measured in the Java Fundamentals
course, and that restriction is only worth something at binding: when the value itself is
a reference, the object that reference points to sits outside this restriction.
Two consequences follow from this. First, a final field is not immutability; for a type
to be immutable, its fields being final is not enough — the objects those fields point to
also have to not change. Second, there is a case where the measurement cannot be observed: had
a number or a string been used instead of weight, the distinction would never have shown at
all, because those values have no changeable inside. Whether the restriction is enough depends
not on the word final, but on the type.
Summary
finalplaces three separate restrictions, and four of nine trials stop for exactly this word: reassignment, overriding, and extension.- The restriction is not “never assign,” it is “assign exactly once”; a
finalfield can be assigned in the constructor. - A
finalmethod reduces the number of answers the runtime type could give from two to one; it does not move the decision to the other side, it removes it. - The class file carries the
finalflag on the class, the field, and the method; a local variable’s restriction is never written, because no other compile needs to read it. - A constant
static finalfield’s value is written into the reading class: a reader not recompiled after the source becomes 200 keeps reading 100, while a computed field gives 200. - A
finalreference does not freeze the object; its fields and an array’s elements change, only the link between the name and the object is preserved.
Next Step
In this lesson’s measurement, one restriction was never written into the class file at all: a
final local variable’s restriction stopped only at the compiler and vanished once
compilation ended. So what is that restriction for? There is a place in Java where the
compiler wants this very promise on its own, without it being written: a class defined
inside a method body, when it uses a local variable from outside, requires that variable never
be assigned again. The reason for the requirement is that the variable gets copied in there —
and carrying that copy produces new class files never written in the source. The next lesson
counts those files: how many separate files does a class written nested inside another
produce, which of them carry a hidden field pointing to the enclosing instance, and when does
the difference between that hidden field and the copied value become visible?
To keep your progress and take notes, Log in
My notes
Log in to take notes.