The final keyword in Java
Final variables, fields, parameters, methods and classes: effectively final for lambdas, the memory model guarantee for final fields, final reference vs immutable object, constant inlining across jars, and final vs finally vs finalize.
On this page
🔒 What is the final keyword?
final means "this can be assigned or defined only once". A final variable can't be reassigned, a final method can't be overridden, and a final class can't be extended. For example, final BigDecimal amount = new BigDecimal("499.00"); stops anyone from pointing amount at a different number later, but it says nothing about whether the object it points to can change.
That last sentence is where most interview follow-ups live. final looks like the simplest keyword in Java, yet it touches the compiler (constant folding), the memory model (safe publication of final fields), the JIT (inlining), lambdas (effectively final) and API design (String is final for a reason). Interviewers use it to separate candidates who know the rule from candidates who know what the rule buys you.
What you’ll learn
- Apply
finalto variables, blank finals, parameters, methods and classes, and know what each one forbids - Explain why lambdas capture only effectively final locals
- Describe the final field freeze (JLS 17.5) and why it makes immutable objects safe to publish
- Tell a final reference from an immutable object (
final ListvsList.of) - Spot the constant inlining trap: changing a
static finalin a library without recompiling clients - Separate
final,finallyandfinalize(), and replace finalizers with try-with-resources orCleaner
For this lesson, we'll focus on final variables, fields, parameters, methods and classes, effectively final and lambdas, the Java Memory Model guarantee for final fields, final references versus immutable objects, compile-time constants and the cross-jar inlining trap, and the classic final vs finally vs finalize question.
Below the line (out of scope)
- Designing fully immutable classes and records: covered in Records in Java
static finalconstants and class initialisation: the init side is covered in The static keyword in Java- The full Java Memory Model (happens-before,
volatile): Concurrency module
The 30-second answer
final on a variable means it's assigned exactly once; on a method, it can't be overridden; on a class, it can't be subclassed. A final reference is not an immutable object: final List<String> tags can still be added to. Local variables used in lambdas must be final or effectively final, because lambdas capture a copy of the value. Final fields get a special Java Memory Model guarantee: once the constructor finishes without leaking this, every thread sees their initialised values without synchronization. A static final primitive or String initialised with a constant expression is a compile-time constant, inlined into callers, so changing it in a library requires recompiling clients. finally is the always-runs block, and finalize() is deprecated for removal since Java 18.
Interview radar
Asked very often: Does final make an object immutable, and why must lambdas capture only final or effectively final locals?
Senior rounds probe: What is the final field freeze in the Java Memory Model, and how does it make immutable objects safe to publish without synchronization?
Production angle: The constant inlining trap where a static final changed in a shared jar keeps its old value until every client is recompiled.
01Picture it first: a name tag glued to a locker
Think of a final variable as a name tag glued to one locker. You can't peel it off and stick it on a different locker. But the locker still opens: anyone can put things in or take things out. If you want the contents fixed too, you need a locker that has no door, which is what an immutable object is.
The diagram below shows the difference with a list of tags on an invoice.
A final reference named tags is locked to one ArrayList on the heap: reassigning it to a new list is a compile error, but calling add on the list succeeds and the list grows from one element to two; a second panel shows an unmodifiable List.of list where add throws UnsupportedOperationException
Notice two things. final protects the arrow, not the box at the end of it. And making the box unchangeable is a property of the object's class (List.of, String, records with immutable components), not of the variable that points to it.
02Under the hood: final variables, fields and parameters
Try it yourself first
Before reading on: which of the four marked lines fail to compile?
final class Invoice {
private final String id; // blank final: assigned in the constructor
private final List<String> tags = new ArrayList<>();
Invoice(String id) { this.id = id; }
void update(final String note) {
tags.add(note); // 1
tags = new ArrayList<>(); // 2
note = note.trim(); // 3
final int max;
max = 5; // 4
}
}
Lines 2 and 3 fail; 1 and 4 compile. Line 1 mutates the list, which final allows. Line 2 reassigns a final field. Line 3 reassigns a final parameter. Line 4 is a blank final local: declared without a value and assigned exactly once later, which is legal.
The compiler enforces this with definite assignment analysis (JLS chapter 16). A blank final field must be assigned exactly once on every path through every constructor, and never read before that:
final class Payment {
private final String status;
Payment(boolean captured) {
if (captured) status = "CAPTURED";
else status = "PENDING"; // both branches assign: compiles
// a third assignment here would fail: "variable status might already have been assigned"
}
}
Notice three things:
finalon a local or parameter is purely a compile-time check. The bytecode has no trace of it: a final local and a normal local produce identical class files, apart from a final local with a constant initialiser, whichjavacinlines.finalon a field is recorded in the class file (ACC_FINAL) and has runtime meaning, which we'll see in the memory model section.- Final parameters are a style choice. Some teams mark every parameter
final; I prefer a linter rule against reassigning parameters, which gives the same safety without the noise.
Effectively final and lambdas
Since Java 8, a local variable that's never reassigned after initialisation is effectively final, and you can capture it in a lambda or anonymous class without writing final:
int retries = 3; // effectively final
Runnable job = () -> System.out.println("retries=" + retries);
job.run(); // prints: retries=3
// retries++; // would make it not effectively final: the lambda line stops compiling
// // "local variables referenced from a lambda expression must be final or effectively final"
Why the rule? A lambda may run later, on another thread, after the method has returned and its stack frame is gone. So Java captures a copy of the value, passed as a constructor argument to the generated lambda class or as an extra argument to the synthetic lambda method. If the variable could change afterwards, the copy and the original would silently disagree. Forcing it to be effectively final makes the copy indistinguishable from the original.
Many candidates "fix" the lambda error with a one-element array: int[] total = {0}; list.forEach(x -> total[0] += x);. It compiles, because the reference is effectively final, but now you've shared mutable state between the lambda and the method, and with a parallel stream it's a data race. Use a reduction (mapToInt(...).sum()), or an AtomicInteger or LongAdder if you really must share.
03The memory model guarantee for final fields
Here's the part that makes final fields more than documentation. Normally, if one thread creates an object and publishes the reference without synchronization (a data race), another thread can see the reference before it sees the field writes made in the constructor. The JIT and the CPU are allowed to reorder those writes.
Final fields are the exception (JLS 17.5). When a constructor finishes, there is a conceptual freeze of its final fields. Any thread that obtains a reference to the object after that, even through a racy path, is guaranteed to see the final fields' constructed values, and everything reachable through them as of the freeze.
final class Money {
private final long minorUnits; // final: always visible once the reference is
private final String currency; // final: same guarantee
private String note; // not final: another thread may see null
Money(long minorUnits, String currency, String note) {
this.minorUnits = minorUnits;
this.currency = currency;
this.note = note;
}
}
static Money lastPrice; // shared, not volatile: a data race on purpose
// Thread A
lastPrice = new Money(49_900, "INR", "festive sale");
// Thread B
Money m = lastPrice;
if (m != null) {
long amt = m.minorUnits; // guaranteed 49900
String cur = m.currency; // guaranteed "INR"
String n = m.note; // may be null on weakly ordered hardware
}
The diagram traces that handoff from the constructing thread to the reading thread.
Thread A writes the final fields, a store-store barrier freezes them at the end of the constructor, and the reference is published through a racy static field, after which Thread B reads the reference and is guaranteed to see the frozen final fields while the non-final note may still be null
In HotSpot, this guarantee is implemented with a store-store barrier at the end of a constructor that wrote a final field, which is free on x86 and a cheap fence on ARM. This is why immutable objects with all-final fields (String, Integer, LocalDate, records) are thread-safe even when published carelessly.
The guarantee has one condition: don't let this escape the constructor. If the constructor registers this with a listener or stores it in a static field before it finishes, another thread can see the object before the freeze, and final fields may still read as 0 or null.
04Real world: compile-time constants and the inlining trap
Try it yourself first
Before reading on: a shared library ships ApiVersions.CURRENT = "v1". You upgrade only the library jar to a version where it's "v2", without recompiling your service. What does your service print?
// in shared-lib.jar
public class ApiVersions {
public static final String CURRENT = "v1"; // later changed to "v2"
public static final int TIMEOUT_MS = 2000; // later changed to 5000
}
// in your service, compiled against the old jar
System.out.println(ApiVersions.CURRENT + " " + ApiVersions.TIMEOUT_MS);
// prints: v1 2000 (even with the new jar on the classpath)
It still prints v1 2000. A field that is final, of a primitive type or String, and initialised with a constant expression is a constant variable (JLS 4.12.4). javac doesn't emit a getstatic for it; it copies the value into the caller's constant pool. Here it even folded the concatenation, and javap -c on the client shows a single ldc "v1 2000". The diagram shows how the old value survives the jar swap.
Compile time: the client is compiled against shared-lib v1 and javac copies the literal v1 into the client's class file. Runtime: shared-lib v2 is on the classpath with CURRENT equal to v2, but the client never reads the field and still prints v1
I've seen this cause a confusing incident: a platform team bumped a public static final int MAX_BATCH = 500 to 100 in a shared jar to protect a downstream database, rolled the new jar to every service, and the database still received batches of 500 from the services that weren't rebuilt. Only a full rebuild picked it up. The fix for values that may change is to make them not constant variables: read them from configuration, expose them through a static method, or initialise them with a non-constant expression such as Integer.valueOf(500) or String.valueOf("v1").
Inlining also explains a few other facts: switch case labels must be constant expressions, annotation values must be constants, and reading a constant variable doesn't initialise its class (no getstatic, no trigger). static final Integer X = 5 or static final List<String> L = List.of() are not constant variables, because the type isn't primitive or String.
05Advanced: final methods, final classes and the edge cases
Final methods and final classes
A final method can't be overridden. Use it in a class designed for extension when a method holds an invariant subclasses must not change, like the template method in an abstract PaymentProcessor whose process() always validates, then charges, then records. Private methods can't be overridden anyway, so final adds nothing there; on a static method, final stops subclasses from hiding it.
A final class can't be extended. String, Integer, LocalDate and every record are final. For String, it's essential: the class caches its hash code, is shared through the string pool, is used as a HashMap key and in security checks such as file paths and class names. A subclass could override equals or hashCode, or add mutable state, and break all of that. If you want a class to be immutable, making it final (or giving it only private constructors) is part of the recipe. Java 17 added a middle ground: sealed classes list exactly which subclasses are allowed (see Sealed classes and interfaces).
"Make methods final for speed" was advice from the late 1990s. Today, HotSpot's class hierarchy analysis notices when a virtual method has only one loaded implementation and inlines it as if it were final, with a deoptimization trap if a subclass is loaded later. Final methods save that bookkeeping, but the measurable difference in normal code is close to zero. Use final for design, not performance. The one real runtime cost of final fields is the constructor barrier, which is free on x86.
final vs finally vs finalize
They share letters and nothing else:
| What it is | Where | |
|---|---|---|
final |
modifier: assign once / don't override / don't extend | variables, fields, methods, classes |
finally |
block that runs after try/catch, whether or not an exception was thrown |
exception handling |
finalize() |
Object method the GC might call before reclaiming an object |
deprecated since Java 9, deprecated for removal since Java 18 (JEP 421) |
finally runs on normal completion, on an exception and on return, but not if the JVM halts (System.exit, Runtime.halt, a crash or a killed process). A return inside finally overrides the try's result and swallows any exception:
static int code() {
try { throw new IllegalStateException("boom"); }
finally { return 42; } // the exception disappears
}
System.out.println(code()); // prints: 42
finalize() was a bad idea from the start: no guarantee it ever runs, no ordering, it can resurrect objects, it slows down GC, and it's a security hole (finalizer attacks on partially constructed objects). For resources, use try-with-resources; for a safety net, use java.lang.ref.Cleaner. You can run Java 18+ with --finalization=disabled to check whether your code still depends on finalizers.
Final isn't absolute: reflection
Deep reflection can still change a non-static final instance field: field.setAccessible(true); field.set(obj, newValue) works on ordinary classes. It fails for static final fields, record fields and hidden classes. And if the field is a constant variable, code that reads it was inlined at compile time, so the change is invisible to those reads, which is why serialization libraries that set final fields through reflection only work reliably on non-constant fields. JEP 500 ("Prepare to Make Final Mean Final") starts closing this door: on JDK 26, mutating a final field reflectively prints a warning, and a later release plans to block it unless you opt in with --enable-final-field-mutation.
A few smaller rules that come up as follow-ups:
- An interface field is implicitly
public static final. - A
finallocal in acatch(or a multi-catch parameter, which is implicitly final) can't be reassigned. - Try-with-resources accepts an effectively final variable since Java 9:
try (conn) { ... }. finalandabstractcan't be combined on a method or class: one says "no overriding", the other says "must be overridden".
06How to say it in the interview
When I ask about final, I'm listening for four things: the reference versus object distinction, why lambdas need effectively final, the memory model guarantee for final fields, and the constant inlining trap. If you also separate final, finally and finalize cleanly and know finalize is on its way out, you've covered the whole keyword.
In the interview
"Final means assign once for variables, no overriding for methods, and no subclassing for classes. A final reference doesn't make the object immutable: a final list can still be added to, so immutability comes from the class, like List.of or a record. Lambdas can capture only final or effectively final locals because they capture a copy of the value. Final fields have a special memory model guarantee: after the constructor finishes, any thread that sees the reference sees the initialised final fields, which is what makes immutable objects safe to share, as long as this doesn't escape the constructor. Static final primitives and Strings with constant initialisers are inlined into callers, so changing one in a library needs a client recompile. And finally is the always-runs block, while finalize is deprecated for removal since Java 18: use try-with-resources or Cleaner."
On the whiteboard
final var : assign once (definite assignment), compile-time only
final field : ACC_FINAL + JMM freeze at end of ctor (JLS 17.5)
-> safe publication, if `this` doesn't escape
final method : no override (CHA makes perf gain ~0)
final class : no subclass (String, Integer, records)
final ref != immutable object: final List -> add() OK
lambda capture: final / effectively final (copy of value)
constant var : final + primitive/String + constant expr
-> inlined (ldc), change needs client recompile
finally: always runs (not on System.exit); return in finally swallows
finalize: deprecated for removal (JEP 421, Java 18) -> Cleaner, TWR
Further reading (7)
- JLS §4.12.4 final Variables (constant variables, effectively final)
- JLS §17.5 final Field Semantics
- JLS §13.4.9 final Fields and static Constant Variables (binary compatibility)
- JEP 421: Deprecate Finalization for Removal
- JEP 500: Prepare to Make Final Mean Final
- Effective Java, 3rd ed., Item 17: Minimize mutability; Item 8: Avoid finalizers and cleaners
- Baeldung: The "final" Keyword in Java