The final Keyword in Java Explained — Variables, Methods and Classes
Most Java developers use final casually — slapping it on a constant here, accepting an IDE suggestion there — without really understanding what contract they're making. That's a missed opportunity, because final is one of the clearest ways to communicate intent in your code. When a teammate reads final, they instantly know: this was a deliberate decision, not an accident.
The problem final solves is subtle but critical: uncontrolled mutability and unexpected inheritance. Without final, any value can be quietly reassigned mid-method, any method can be silently overridden in a subclass, and any class can be extended in ways its original author never intended. These aren't hypothetical bugs — they're the source of real security vulnerabilities (the String class is final for exactly this reason) and the cause of countless hard-to-trace defects in large codebases.
By the end of this article you'll know exactly when and why to apply final to variables, method parameters, methods, and classes. You'll understand the difference between a final reference and a truly immutable object, dodge the two gotchas that catch almost every intermediate developer, and be ready to answer the tricky interview questions that separate candidates who memorise syntax from those who understand design.
final Variables — Locking a Value in Place (and What That Really Means)
A final variable can be assigned exactly once. After that first assignment, any attempt to reassign it is a compile-time error — the compiler catches it before your code ever runs. This is powerful because the protection is guaranteed, not just hoped for.
There are three flavours: final local variables (inside a method), final instance fields (per object), and final static fields (class-level constants). Each has a different point at which the 'one assignment' must happen.
For static final fields the assignment must happen either at the declaration site or inside a static initialiser block. For instance final fields it must happen either at the declaration site, inside an instance initialiser block, or in every constructor. The compiler tracks every possible code path and complains if any path leaves the field unassigned.
The trickiest part — and the one most developers misunderstand — is that final on a reference variable locks the reference, not the object it points to. A final List can still have items added to it. Final means 'this variable will always point to this object', not 'this object will never change'. Keep that distinction sharp.
import java.util.ArrayList; import java.util.List; public class FinalVariableDemo { // static final = a true compile-time constant shared across all instances // Convention: ALL_CAPS with underscores for static final constants private static final int MAX_LOGIN_ATTEMPTS = 3; // instance final field — must be set in every constructor private final String username; public FinalVariableDemo(String username) { // This is the ONE allowed assignment for this instance field this.username = username; } public void demonstrateFinalBehaviour() { // final local variable — assigned once, never changed final double taxRate = 0.2; double itemPrice = 49.99; double taxAmount = itemPrice * taxRate; // The line below would cause a compile error: cannot assign a value to final variable taxRate // taxRate = 0.25; <-- COMPILE ERROR if you uncomment this System.out.println("Username : " + username); System.out.println("Max attempts : " + MAX_LOGIN_ATTEMPTS); System.out.println("Tax rate : " + taxRate); System.out.println("Tax on $" + itemPrice + " = $" + taxAmount); // KEY POINT: final reference != immutable object // The list reference is locked, but the list contents are NOT locked final List<String> sessionTokens = new ArrayList<>(); sessionTokens.add("token-abc-123"); // perfectly legal — we're modifying the object sessionTokens.add("token-xyz-789"); // still legal // This WOULD be a compile error — reassigning the reference itself // sessionTokens = new ArrayList<>(); <-- COMPILE ERROR System.out.println("Session tokens: " + sessionTokens); } public static void main(String[] args) { FinalVariableDemo demo = new FinalVariableDemo("alice"); demo.demonstrateFinalBehaviour(); } }
Max attempts : 3
Tax rate : 0.2
Tax on $49.99 = $9.998
Session tokens: [token-abc-123, token-xyz-789]
final Methods — Sealing a Behaviour Your Subclasses Can't Override
When you mark a method final, you're telling every subclass: 'You can inherit this behaviour, but you cannot replace it.' That's a powerful design statement.
The most common reason to do this is correctness: some methods encode logic so fundamental to how the class works that allowing a subclass to override them would break guarantees the class depends on. The classic example is the equals/hashCode contract — if a framework class defines a final equals() it's protecting the integrity of hash-based collections.
The second reason is security. If a class handles authentication or encryption, a rogue subclass overriding a critical method could silently bypass security checks. Making those methods final closes that door entirely.
Final methods also give the JIT compiler a hint. Because the compiler knows at runtime there's exactly one version of a final method, it can inline the call — replacing the method invocation with the method body directly — which can improve performance in tight loops. This is a micro-optimisation in most apps, but it's the real reason some core Java library methods are final.
Note that a final method in a non-final class is completely normal. You're locking one specific behaviour while still allowing the class to be extended in other ways.
// Base class — defines a payment workflow class PaymentProcessor { private static final double FRAUD_THRESHOLD = 10_000.00; // This method defines steps that MUST happen in this order for every payment. // Subclasses can customise HOW they charge, but not WHAT checks surround it. public final boolean processPayment(String customerId, double amount) { // Step 1: fraud check — no subclass can skip or alter this if (!passesFraudCheck(customerId, amount)) { System.out.println("[BLOCKED] Fraud check failed for customer: " + customerId); return false; } // Step 2: delegate to subclass-specific charge logic boolean charged = chargeCustomer(customerId, amount); // Step 3: audit log — no subclass can skip this either auditLog(customerId, amount, charged); return charged; } // private fraud check — already hidden from subclasses private boolean passesFraudCheck(String customerId, double amount) { // Simplified: flag anything over the threshold return amount <= FRAUD_THRESHOLD; } // Subclasses override THIS to provide their own charging mechanism protected boolean chargeCustomer(String customerId, double amount) { System.out.println("[DEFAULT] Charging $" + amount + " to customer: " + customerId); return true; } private void auditLog(String customerId, double amount, boolean success) { System.out.println("[AUDIT] Customer: " + customerId + " | Amount: $" + amount + " | Success: " + success); } } // Subclass provides a Stripe-specific charge implementation class StripePaymentProcessor extends PaymentProcessor { @Override protected boolean chargeCustomer(String customerId, double amount) { System.out.println("[STRIPE] Sending charge request for $" + amount + " — customer: " + customerId); return true; // simulate a successful Stripe API call } // The line below would cause a compile error: // Cannot override the final method from PaymentProcessor // // @Override // public boolean processPayment(String customerId, double amount) { ... } } public class PaymentProcessor { public static void main(String[] args) { PaymentProcessor stripe = new StripePaymentProcessor(); System.out.println("--- Transaction 1: Normal amount ---"); stripe.processPayment("cust-001", 149.99); System.out.println(); System.out.println("--- Transaction 2: Suspicious amount ---"); stripe.processPayment("cust-002", 15_000.00); } }
[STRIPE] Sending charge request for $149.99 — customer: cust-001
[AUDIT] Customer: cust-001 | Amount: $149.99 | Success: true
--- Transaction 2: Suspicious amount ---
[BLOCKED] Fraud check failed for customer: cust-002
final Classes — Why String, Integer and Math Are All Sealed
A final class cannot be subclassed. Full stop. Any attempt to extend it produces an immediate compile error. This is the most drastic use of final, and it should be a deliberate, considered decision.
The Java standard library uses this extensively. String is final because if it weren't, a malicious or careless developer could create a subclass that overrides equals() or hashCode() in inconsistent ways, quietly breaking every HashMap or Set that holds strings. Integer, Double, and all other wrapper types are final for the same reason. Math is final because it's a pure utility class — there's nothing meaningful to extend.
When should you make your own classes final? Consider it when: the class is a pure value type (like a Money or Coordinate class), when it's a utility class with only static methods, or when correctness of the entire system depends on the class behaving in exactly one way. Immutability and final often go together — if you're building a truly immutable class (all fields are final, no setters, defensive copies in the constructor), marking the class final is the last line of defence against a subclass introducing mutable state.
The flip side: final classes hurt testability. You can't mock a final class with most mocking frameworks without extra configuration. So final should be intentional, not reflexive.
// final class — this is an immutable value type. // Making it final prevents subclasses from adding mutable fields // or overriding equals/hashCode in ways that break collections. public final class Money { private final String currencyCode; // e.g. "USD", "EUR" private final long amountInCents; // store as cents to avoid floating-point errors public Money(String currencyCode, long amountInCents) { if (currencyCode == null || currencyCode.isBlank()) { throw new IllegalArgumentException("Currency code must not be blank"); } if (amountInCents < 0) { throw new IllegalArgumentException("Amount must not be negative"); } // Both fields are final — assigned here and never changed this.currencyCode = currencyCode.toUpperCase(); this.amountInCents = amountInCents; } // Returns a NEW Money object — the original is never mutated public Money add(Money other) { if (!this.currencyCode.equals(other.currencyCode)) { throw new IllegalArgumentException( "Cannot add " + this.currencyCode + " and " + other.currencyCode); } return new Money(this.currencyCode, this.amountInCents + other.amountInCents); } public String getFormattedAmount() { // Divide by 100 to display as decimal dollars/euros/etc. return currencyCode + " " + String.format("%.2f", amountInCents / 100.0); } @Override public boolean equals(Object obj) { if (this == obj) return true; if (!(obj instanceof Money)) return false; Money other = (Money) obj; // Because the class is final, we KNOW obj is exactly Money — not a sneaky subclass return this.amountInCents == other.amountInCents && this.currencyCode.equals(other.currencyCode); } @Override public int hashCode() { return 31 * currencyCode.hashCode() + Long.hashCode(amountInCents); } @Override public String toString() { return getFormattedAmount(); } // --- Attempting to extend Money would cause a compile error: --- // class TaxedMoney extends Money { } <-- COMPILE ERROR: cannot inherit from final Money public static void main(String[] args) { Money productPrice = new Money("USD", 1999); // $19.99 Money shippingCost = new Money("USD", 599); // $5.99 Money totalCost = productPrice.add(shippingCost); System.out.println("Product price : " + productPrice); System.out.println("Shipping cost : " + shippingCost); System.out.println("Total : " + totalCost); // Prove immutability — productPrice is unchanged after add() System.out.println("Original price still: " + productPrice); Money anotherTotal = new Money("USD", 2598); System.out.println("Equals check : " + totalCost.equals(anotherTotal)); } }
Shipping cost : USD 5.99
Total : USD 25.98
Original price still: USD 19.99
Equals check : true
final Method Parameters — A Habit Worth Building
You can mark method parameters as final too. This means the parameter variable itself can't be reassigned inside the method body. The object it refers to can still be modified — same rule as final local variables.
This isn't enforced by the JVM at runtime — it's a compile-time guard for you and your teammates. Its biggest value is clarity and bug prevention in longer methods. When you see a final parameter, you know immediately: this variable represents the input that came in, not some transformed version of it. There's no guessing whether the method changed the reference partway through.
It's especially useful in anonymous inner classes and lambda-adjacent code. Before Java 8, any local variable captured by an anonymous inner class had to be explicitly final. From Java 8 onwards, the rule relaxed to 'effectively final' (never reassigned, even without the keyword), but the intent is the same.
Some teams enforce final on all method parameters via a code style rule (Checkstyle supports this). Others consider it visual noise. The pragmatic middle ground: use it when a method is long enough that parameter shadowing is a real risk, and always use it when you're capturing the parameter in an inner class or lambda.
import java.util.List; public class OrderCalculator { // final parameters — the references 'items' and 'discountPercent' cannot be // reassigned inside this method. Prevents accidental shadowing in long methods. public double calculateTotal(final List<String> items, final double discountPercent) { // This would be a compile error: // discountPercent = 0.0; <-- COMPILE ERROR: cannot assign a value to final variable double subtotal = items.size() * 9.99; // simplified: each item is $9.99 double discount = subtotal * (discountPercent / 100.0); double total = subtotal - discount; System.out.println("Items ordered : " + items.size()); System.out.println("Subtotal : $" + String.format("%.2f", subtotal)); System.out.println("Discount (" + discountPercent + "%) : -$" + String.format("%.2f", discount)); System.out.println("Total : $" + String.format("%.2f", total)); return total; } // Demonstrating 'effectively final' in a lambda capture public void printItemsAsync(final List<String> items) { // 'items' is final — safe to capture in the lambda below Runnable printer = () -> { for (String item : items) { System.out.println(" - " + item); } }; printer.run(); } public static void main(String[] args) { OrderCalculator calculator = new OrderCalculator(); List<String> cart = List.of("Java Book", "Mechanical Keyboard", "USB Hub"); System.out.println("=== Order Summary ==="); calculator.calculateTotal(cart, 10.0); // 10% discount System.out.println(); System.out.println("=== Your Items ==="); calculator.printItemsAsync(cart); } }
Items ordered : 3
Subtotal : $29.97
Discount (10.0%) : -$3.00
Total : $26.97
=== Your Items ===
- Java Book
- Mechanical Keyboard
- USB Hub
| Aspect | final Variable | final Method | final Class |
|---|---|---|---|
| What it prevents | Reassigning the variable reference | Subclasses overriding the method | Any class extending this class |
| Compile error trigger | Second assignment to the variable | @Override in a subclass | extends FinalClassName |
| Runtime cost | None — compile-time only | Enables JIT inlining (minor speedup) | None — compile-time only |
| Applies to objects? | Locks reference, NOT object contents | N/A | N/A |
| Common real-world use | Constants, immutable fields | Template Method pattern, security | Value types, utility classes |
| Java stdlib examples | Math.PI, Integer.MAX_VALUE | Object.getClass() | String, Integer, Math |
🎯 Key Takeaways
- final on a variable locks the reference, not the object — a final List can still have items added; use Collections.unmodifiableList() or List.of() if you need the contents locked too.
- final methods protect critical algorithm steps from being overridden by subclasses — this is the engine behind the Template Method design pattern and is essential for security-sensitive code.
- final classes are a deliberate design choice for value types and utility classes — String, Integer, and Math are final to guarantee correctness in collections and across threads, not just for performance.
- A final instance field must be assigned in every constructor — miss one constructor path and the compiler will tell you immediately, which is one of the few times Java's strictness saves you from a nasty NPE.
⚠ Common Mistakes to Avoid
- ✕Mistake 1: Thinking final means the object is immutable — Symptom: developer declares final List
names and then wonders why names.add('Alice') compiles fine. The final keyword locks the reference (names will always point to the same List), not the object's contents. Fix: for a truly read-only list, use List.of() or Collections.unmodifiableList() and assign that to a final variable. Both layers are needed. - ✕Mistake 2: Forgetting that final instance fields must be assigned in EVERY constructor — Symptom: compile error 'variable userId might not have been initialised' when you add a second constructor and forget to assign the final field in it. Fix: always assign every final field in every constructor, or use constructor chaining (this(...)) to funnel all construction through one path that does the assignment.
- ✕Mistake 3: Making a class final and then discovering you can't mock it in tests — Symptom: Mockito throws 'Cannot mock/spy class X because it is final' and the test suite breaks. This is especially painful when you add final to a class late in the project. Fix: either use Mockito's MockMaker inline extension (mockito-inline dependency) which CAN mock final classes, or — better design — depend on an interface rather than the concrete final class, so tests can swap in a test double.
Interview Questions on This Topic
- QCan you explain the difference between a final variable holding a primitive and a final variable holding an object reference? What can and can't you change in each case?
- QWhy is the String class declared final in Java? What security or correctness problems would arise if it weren't?
- QIf a class is not final but all of its constructors are private, can it be subclassed? How does this compare to making the class explicitly final — and when would you choose one approach over the other?
Frequently Asked Questions
Can a final variable be declared without being initialised immediately?
Yes — this is called a blank final variable. For instance fields, the assignment must happen in every constructor. For static fields, it must happen in a static initialiser block. For local variables, you must assign before the first use. The compiler tracks every code path and will refuse to compile if any path leaves a final variable unassigned.
What is the difference between final and effectively final in Java?
A final variable is explicitly declared with the final keyword and the compiler enforces that it's only assigned once. An effectively final variable has no final keyword but is never reassigned after its initial assignment — the compiler recognises it as safe to capture in lambdas and anonymous inner classes. The runtime behaviour is identical; the difference is only whether you write the keyword or not.
Does making a method final improve performance in Java?
Potentially, yes, but in modern Java the JIT compiler is smart enough to devirtualise and inline method calls even without the final keyword when it can prove at runtime that there's only one implementation. So final for performance is rarely necessary today. The real reasons to use final on methods are design clarity and security — preventing subclasses from changing behaviour you need to rely on.
Written and reviewed by senior developers with real-world experience across enterprise, startup and open-source projects. Every article on TheCodeForge is written to be clear, accurate and genuinely useful — not just SEO filler.