Skip to content
academia.sh

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, Fixed is compiled twice, Reader once; 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

  • final places 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 final field can be assigned in the constructor.
  • A final method 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 final flag 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 final field’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 final reference 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.

Start typing to search.

↑↓ Esc navigate · open · close