How does the "final" keyword in Java work? (I can still modify an object.)

Learn how does the "final" keyword in java work? (i can still modify an object.) with practical examples, diagrams, and best practices. Covers java, final development techniques with visual explana...

Understanding Java's 'final' Keyword: Why Can I Still Modify an Object?

Hero image for How does the "final" keyword in Java work? (I can still modify an object.)

Explore the true meaning of the 'final' keyword in Java, distinguishing between immutability of references and mutability of objects, and how it impacts object-oriented programming.

The final keyword in Java is a fundamental concept often misunderstood by new and even experienced developers. Many assume that declaring a variable final makes the object it refers to immutable. However, this is a common misconception. While final indeed prevents reassignment of the reference, it does not inherently prevent modifications to the object's internal state if that object is mutable. This article will clarify the precise behavior of final with primitive types, object references, and methods, and demonstrate why you can still modify an object even if its reference is final.

Final Variables: Primitives vs. Object References

The behavior of the final keyword differs significantly depending on whether it's applied to a primitive type or an object reference. Understanding this distinction is crucial for writing correct and predictable Java code.

When final is applied to a primitive type (like int, boolean, double), the value of that primitive variable becomes constant and cannot be changed after its initial assignment. Any attempt to reassign it will result in a compile-time error.

public class FinalPrimitive {
    public static void main(String[] args) {
        final int MAX_VALUE = 100;
        System.out.println("Initial MAX_VALUE: " + MAX_VALUE);
        // MAX_VALUE = 200; // Compile-time error: cannot assign a value to final variable MAX_VALUE
    }
}

Demonstrating final with a primitive type

However, when final is applied to an object reference, it means that the reference itself cannot be changed to point to a different object. The reference variable will always point to the same object that it was initially assigned. It does not mean that the state of the object it points to cannot be modified. If the object itself is mutable (i.e., it has methods that allow its internal state to be changed), then those changes are perfectly valid, even if the reference to that object is final.

import java.util.ArrayList;
import java.util.List;

public class FinalObjectReference {
    public static void main(String[] args) {
        final List<String> names = new ArrayList<>();
        System.out.println("Initial list: " + names);

        // This is allowed: modifying the object's state
        names.add("Alice");
        names.add("Bob");
        System.out.println("List after adding elements: " + names);

        // This is NOT allowed: reassigning the final reference
        // names = new ArrayList<>(); // Compile-time error: cannot assign a value to final variable names
    }
}

Demonstrating final with an object reference

flowchart TD
    A[Declare final variable] --> B{Is it a primitive type?}
    B -- Yes --> C[Value is constant, cannot be changed]
    B -- No --> D[It's an object reference]
    D --> E[Reference cannot be reassigned to another object]
    E --> F{Is the referenced object mutable?}
    F -- Yes --> G[Object's internal state CAN be modified]
    F -- No --> H[Object's internal state CANNOT be modified (it's immutable)]
    G --> I["Example: final List<String> list = new ArrayList<>(); list.add(\"item\"); is OK."]
    E --> J["Example: final List<String> list; list = new LinkedList<>(); is NOT OK."]

Decision flow for final keyword behavior

Achieving Immutability

If the goal is to create an object whose state cannot be changed after creation, simply declaring its reference final is not enough. True immutability requires careful design of the class itself. An immutable class ensures that once an instance is created, its internal state can never be altered.

To make a class immutable, you typically need to follow these guidelines:

1. Declare all fields final

This ensures that the references to other objects or primitive values within the immutable object cannot be changed.

2. Declare all fields private

This prevents direct access and modification from outside the class.

3. Don't provide setter methods

There should be no methods that allow modification of the object's state after construction.

4. Make the class final

This prevents other classes from extending it and potentially overriding methods in a way that breaks immutability.

5. Handle mutable object fields carefully

If your class holds references to mutable objects (e.g., Date, ArrayList), you must perform a 'defensive copy' in the constructor and in any getter methods. This prevents external code from modifying the internal state of your immutable object through those mutable references.

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public final class ImmutablePerson {
    private final String name;
    private final int age;
    private final List<String> hobbies;

    public ImmutablePerson(String name, int age, List<String> hobbies) {
        this.name = name;
        this.age = age;
        // Defensive copy for mutable list
        this.hobbies = new ArrayList<>(hobbies);
    }

    public String getName() {
        return name;
    }

    public int getAge() {
        return age;
    }

    public List<String> getHobbies() {
        // Return an unmodifiable view to prevent external modification
        return Collections.unmodifiableList(hobbies);
    }

    public static void main(String[] args) {
        List<String> initialHobbies = new ArrayList<>();
        initialHobbies.add("Reading");
        initialHobbies.add("Hiking");

        final ImmutablePerson person = new ImmutablePerson("Jane Doe", 30, initialHobbies);
        System.out.println("Person: " + person.getName() + ", " + person.getAge() + ", Hobbies: " + person.getHobbies());

        // Attempt to modify the original list used to construct the object
        initialHobbies.add("Swimming");
        System.out.println("Original hobbies list after modification: " + initialHobbies);
        System.out.println("Person's hobbies (should be unchanged): " + person.getHobbies());

        // Attempt to modify the list returned by the getter
        try {
            person.getHobbies().add("Running");
        } catch (UnsupportedOperationException e) {
            System.out.println("Caught expected exception: Cannot modify unmodifiable list.");
        }

        // person.age = 31; // Compile-time error: cannot assign a value to final variable age
        // person = new ImmutablePerson("John Doe", 25, new ArrayList<>()); // Compile-time error
    }
}

Example of an immutable class in Java

Final Methods and Classes

Beyond variables, the final keyword can also be applied to methods and classes, serving different but related purposes in terms of restricting modification or extension.

Final Methods

When a method is declared final, it cannot be overridden by subclasses. This is useful for preventing unwanted behavior changes in subclasses, ensuring that a particular implementation remains consistent across the inheritance hierarchy. It's often used for methods that are critical to the class's invariant or security.

class Parent {
    public final void importantMethod() {
        System.out.println("This is a critical method that cannot be overridden.");
    }

    public void regularMethod() {
        System.out.println("This method can be overridden.");
    }
}

class Child extends Parent {
    // public void importantMethod() { // Compile-time error: cannot override final method
    //     System.out.println("Trying to override.");
    // }

    @Override
    public void regularMethod() {
        System.out.println("Child's overridden regular method.");
    }

    public static void main(String[] args) {
        Child c = new Child();
        c.importantMethod();
        c.regularMethod();
    }
}

Using final with methods

Final Classes

Declaring a class final prevents it from being subclassed. This is a powerful mechanism to ensure that the class's behavior cannot be extended or modified through inheritance. It's commonly used for security reasons (e.g., String, Integer, System classes) or when a class is designed to be a complete, self-contained unit that should not be altered.

final class SealedClass {
    public void display() {
        System.out.println("This is a sealed class.");
    }
}

// class AttemptToExtend extends SealedClass { // Compile-time error: cannot inherit from final SealedClass
//     // ...
// }

public class FinalClassExample {
    public static void main(String[] args) {
        SealedClass s = new SealedClass();
        s.display();
    }
}

Using final with classes