Contents

A final local variable can be assigned exactly once. The assignment can happen at the declaration or later — but once a value is set, any subsequent assignment is a compile error. Final local variables improve readability by making it immediately clear that a variable's value is stable throughout the rest of the method.

public class InvoiceCalculator { public double calculate(double subtotal, double taxRate, double discount) { final double tax = subtotal * taxRate; // assigned once final double discounted = subtotal - discount; final double total = discounted + tax; // tax = 0; // COMPILE ERROR — final variable already assigned // Final variables can be assigned later, but only once final String summary; if (total > 1000) { summary = "Large order: $" + total; } else { summary = "Regular order: $" + total; } // summary = "other"; // COMPILE ERROR System.out.println(summary); return total; } } Using final on local variables is a matter of style, not correctness — the compiler enforces single-assignment either way. Many teams use it on every local that should not change to reduce cognitive load: readers can skip reasoning about mutation for those variables.

A final instance field must be assigned exactly once, either at the declaration site or in every constructor of the class. A final field that is not assigned at declaration is called a blank final — the compiler ensures that every constructor path assigns it before the constructor returns. Final fields are the foundation of immutable objects.

public class Point { // Assigned at declaration — simple case public final int x; public final int y; public Point(int x, int y) { this.x = x; this.y = y; // After the constructor returns, x and y can never change } } // Blank final — assigned in constructor, not at declaration public class Connection { private final String host; // blank final private final int port; // blank final private final java.time.Instant openedAt; // blank final public Connection(String host, int port) { // Must assign all blank finals before constructor ends this.host = host; this.port = port; this.openedAt = java.time.Instant.now(); } // Overloaded constructor — also assigns all blank finals public Connection(String hostPort) { String[] parts = hostPort.split(":"); this.host = parts[0]; this.port = Integer.parseInt(parts[1]); this.openedAt = java.time.Instant.now(); } // Getters only — no setters needed or possible public String getHost() { return host; } public int getPort() { return port; } public java.time.Instant getOpenedAt(){ return openedAt; } } // Static final — class-level constant, initialized once public class Config { public static final int MAX_RETRY = 3; private static final String LOG_PREFIX = "[CONFIG]"; // Static blank final — assigned in static initializer public static final java.util.Map<String, String> DEFAULTS; static { java.util.Map<String, String> m = new java.util.LinkedHashMap<>(); m.put("timeout", "30"); m.put("retries", "3"); DEFAULTS = java.util.Collections.unmodifiableMap(m); } }

Method parameters can also be declared final, preventing reassignment within the method body. The caller is unaffected — parameter finalness is purely a local concern. This is most useful as a signal of intent in long methods and to prevent accidental shadowing or mutation of the parameter inside the method.

// final parameters — compiler rejects accidental reassignment public String formatName(final String firstName, final String lastName) { // firstName = firstName.trim(); // COMPILE ERROR if final String trimmedFirst = firstName.trim(); // must use a new variable String trimmedLast = lastName.trim(); return trimmedLast + ", " + trimmedFirst; } // Useful to prevent the classic loop-variable mutation bug public void processItems(final java.util.List<String> items) { // items = new java.util.ArrayList<>(); // would compile without final — bad! for (String item : items) { System.out.println(item.toUpperCase()); } // Without final, a developer could accidentally reassign items // and the original list would be unaffected — confusing bug } Many style guides do not require final on parameters because it adds visual noise without adding safety (parameters are always local; callers can't see reassignments). Use it selectively where it genuinely signals "this must not change" — for example, when the parameter is captured by an inner class or lambda.

Java 8 relaxed the requirement for lambda and inner-class captures: instead of requiring final, the compiler accepts any local variable that is effectively final — one that is never reassigned after its initial assignment, even without the final keyword. The compiler enforces this automatically. If you try to reassign a captured variable, you get the error "local variables referenced from a lambda expression must be final or effectively final".

import java.util.function.*; // Effectively final — never reassigned, can be captured int threshold = 100; Predicate<Integer> isLarge = n -> n > threshold; // OK — threshold is eff. final System.out.println(isLarge.test(150)); // true // NOT effectively final — reassigned after initialization int limit = 50; limit = 75; // this reassignment makes limit NOT effectively final // Predicate<Integer> p = n -> n > limit; // COMPILE ERROR // Practical example: capturing a loop-body variable java.util.List<Supplier<String>> greetings = new java.util.ArrayList<>(); String[] names = {"Alice", "Bob", "Carol"}; for (String name : names) { // name is effectively final — reassigned by the loop but each iteration // creates a fresh binding, making each iteration's name effectively final greetings.add(() -> "Hello, " + name + "!"); } greetings.forEach(g -> System.out.println(g.get())); // Hello, Alice! // Hello, Bob! // Hello, Carol! // Classic pitfall: traditional for-loop index is NOT effectively final java.util.List<Supplier<Integer>> suppliers = new java.util.ArrayList<>(); for (int i = 0; i < 3; i++) { // int capturedI = i; // workaround: create a fresh final copy // suppliers.add(() -> capturedI); // works // suppliers.add(() -> i); // COMPILE ERROR — i is mutated by i++ }

This is the most important thing to understand about final: it prevents reassignment of the reference, not mutation of the object. A final List cannot be replaced with a different List, but you can still call add(), remove(), and clear() on it. True immutability requires both a final field and an immutable object.

import java.util.*; public class OrderSnapshot { // final reference — cannot point to a different list private final List<String> items; public OrderSnapshot(List<String> items) { // Defensive copy — copy the input so callers can't mutate via their reference this.items = new ArrayList<>(items); } // PROBLEM: returning the internal list lets callers mutate it public List<String> getItemsBad() { return items; // caller can call items.add("hack") — bad! } // CORRECT: return an unmodifiable view public List<String> getItems() { return Collections.unmodifiableList(items); } } // Demonstration OrderSnapshot snap = new OrderSnapshot(List.of("Widget", "Gadget")); // snap.items = new ArrayList<>(); // COMPILE ERROR — final reference snap.getItemsBad().add("Exploit"); // SUCCEEDS — mutates the internal list! System.out.println(snap.getItems()); // [Widget, Gadget, Exploit] — leaked mutation // True immutability: final field + unmodifiable/immutable object public final class ImmutableOrder { private final List<String> items; public ImmutableOrder(List<String> items) { this.items = List.copyOf(items); // List.copyOf returns unmodifiable copy } public List<String> getItems() { return items; } // safe — already unmodifiable } final gives you reference immutability. Object immutability requires the object itself to be designed without mutation methods. For collections, use List.of(), List.copyOf(), or Collections.unmodifiableList(). For custom objects, make all fields private final and provide no setters.

A final method cannot be overridden by any subclass. This is used to lock down critical algorithms — particularly Template Method skeleton methods in abstract classes — and to prevent subclasses from breaking invariants the superclass establishes. Every private method is implicitly final (it cannot be overridden anyway, since subclasses cannot see it).

public abstract class AuditedRepository<T> { // final — the audit trail logic must not be bypassed by subclasses public final T save(T entity) { T saved = doSave(entity); // delegate to overridable step auditLog("SAVE", saved); // always runs — cannot be skipped return saved; } public final boolean delete(Object id) { boolean deleted = doDelete(id); if (deleted) auditLog("DELETE", id); return deleted; } // Subclass provides storage implementation protected abstract T doSave(T entity); protected abstract boolean doDelete(Object id); // Private helper — implicitly final private void auditLog(String action, Object target) { System.out.printf("[AUDIT] %s %s at %s%n", action, target, java.time.Instant.now()); } } // Subclass can customise HOW data is stored, but CANNOT remove audit logging public class InMemoryRepo<T> extends AuditedRepository<T> { private final java.util.Map<Object, T> store = new java.util.HashMap<>(); @Override protected T doSave(T entity) { store.put(entity.hashCode(), entity); return entity; } @Override protected boolean doDelete(Object id) { return store.remove(id) != null; } // @Override public T save(T e) { ... } // COMPILE ERROR — save() is final }

A final class cannot be subclassed at all. This is a strong design statement: the class is complete, its behaviour is fully defined, and no extension is needed or safe. The JDK's most important final classes are String, Integer (and other wrapper types), LocalDate, and Optional. Sealing a class as final also lets the JIT compiler make stronger inlining optimisations since it knows no subtype can override the methods.

// final class — cannot be extended public final class Money { private final long cents; private final String currency; public Money(long cents, String currency) { this.cents = cents; this.currency = currency; } public Money add(Money other) { if (!this.currency.equals(other.currency)) throw new IllegalArgumentException("Currency mismatch"); return new Money(this.cents + other.cents, currency); } public Money subtract(Money other) { if (!this.currency.equals(other.currency)) throw new IllegalArgumentException("Currency mismatch"); return new Money(this.cents - other.cents, currency); } public double getAmount() { return cents / 100.0; } public String getCurrency() { return currency; } @Override public String toString() { return String.format("%s %.2f", currency, getAmount()); } @Override public boolean equals(Object o) { if (!(o instanceof Money m)) return false; return cents == m.cents && currency.equals(m.currency); } @Override public int hashCode() { return java.util.Objects.hash(cents, currency); } } // class ExtendedMoney extends Money { } // COMPILE ERROR — Money is final Java 17 introduced sealed classes as a more flexible alternative to final. A sealed class restricts which classes can extend it, rather than forbidding extension entirely. This is ideal when you want to define a closed set of subtypes — like an algebraic data type — while still allowing subclassing within that set. See the Sealed Classes article for details.

The Java Memory Model gives final fields a special guarantee: once a constructor completes, the values written to final fields are visible to all threads without any additional synchronization. This is what makes immutable objects safe to share between threads without locking. Non-final fields have no such guarantee — without synchronization, another thread may see a stale or partially constructed value.

// UNSAFE — non-final fields may be seen as default values by other threads public class UnsafeHolder { public int value; // non-final public String label; // non-final } // SAFE — final fields are guaranteed visible after construction public final class SafeHolder { public final int value; public final String label; public SafeHolder(int value, String label) { this.value = value; this.label = label; } // Once published (e.g., via a volatile write or synchronized block), // ALL threads will see the correct values of value and label. } // Lazy initialisation using final — the double-checked locking pattern // is safe ONLY when the field is volatile; for immutable objects, just // use final on the holder reference: public class Singleton { // The JMM guarantees this will be seen correctly by all threads // once the static initializer completes private static final Singleton INSTANCE = new Singleton(); private Singleton() {} public static Singleton getInstance() { return INSTANCE; } } The final field safety guarantee applies only when the object reference is not leaked from the constructor (no this escape). If a constructor publishes this to another thread before completing (e.g., by registering itself in a static collection), the final-field guarantee is void and other threads may see uninitialised values.