---
title: 'The `final` Keyword'
source: 'https://academia.sh/en/courses/java-object-model/final-keyword'
course: 'Object-Oriented Java'
language: en
updated: '2026-08-17T18:09:40+00:00'
license: 'CC BY-SA 4.0'
---

# 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.

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.

```java
// 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.

```java
// 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, **re**assigning 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`.

```java
// 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?

```java
// 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?
