Java Final Object Mutation: What Final Actually Prevents
java final object mutation: Understand how the final keyword in Java restricts reference reassignment but not object state changes, and learn when mutation through fin...
When you declare a variable final in Java, you might assume the object it points to becomes immutable. That assumption is wrong, and it leads to subtle bugs in production code. The final keyword only prevents you from reassigning the variable to a different object. It has no effect on the internal state of the object itself. This article explains exactly what java final object mutation means, where it matters, and how to avoid the confusion that comes from expecting immutability from final alone.
What final Actually Restricts
The Java Language Specification defines final variables as those that can be assigned only once. Once a final variable has been initialized, any attempt to assign a new value to it causes a compile-time error. Consider this minimal example:
final StringBuilder builder = new StringBuilder("hello"); builder.append(" world"); // allowed builder = new StringBuilder("goodbye"); // compile error
The first operation mutates the StringBuilder object. The second operation tries to point builder to a different object. That second line fails to compile because builder is final. The object itself is still mutable; final only guards the reference variable.
This distinction is the core of java final object mutation. The reference is fixed, but the object's fields, array elements, or collection contents can change freely.
Mutating Objects Through Final References
Because final does not create a deep freeze, you can call any public method that modifies the object's state. For example, a final ArrayList still allows add, remove, and clear:
final List<String> names = new ArrayList<>(); names.add("Alice"); names.add("Bob"); names.clear(); System.out.println(names.size()); // prints 0
Each operation changes the list's internal array and size field. The final reference names still points to the same ArrayList instance, but that instance is no longer the empty list it was after construction. This behavior is often surprising when developers expect final to make the list read-only.
If you need a truly immutable collection, you must choose an immutable implementation, such as List.of() or Collections.unmodifiableList(). Those wrappers throw UnsupportedOperationException on mutation attempts. But the wrapper itself is not a final concept; it is a runtime behavior.
Final Fields and the Object State
When final appears on a field, it also restricts reassignment of that field. However, the object referenced by that field can still change. A common pattern is to expose a final collection field and assume it cannot be modified:
public class ShoppingCart { private final List<Item> items = new ArrayList<>(); public List<Item> getItems() { return items; } }
Here items is final, but callers of getItems() can call cart.getItems().add(new Item(...)). The final keyword does not protect the list from external mutation. To prevent that, you would return an unmodifiable view:
public List<Item> getItems() { return Collections.unmodifiableList(items); }
This is a design decision, not something final gives you automatically.
Arrays and Final References
Arrays are objects in Java, and a final array reference does not make the array's elements immutable. You can still assign values to individual indices:
final int[] numbers = new int[3]; numbers[0] = 10; // allowed numbers[1] = 20; // allowed numbers = new int[5]; // compile error
The array object is mutable by design. The final reference only prevents you from pointing numbers at a different array. This is especially relevant when you pass arrays to methods or store them in fields. A final array field does not guarantee that the array contents remain unchanged across the lifetime of the object.
If you need a fixed-size sequence that cannot be altered, consider using a List with an immutable implementation, or copy the array on every access. But copying has a runtime cost, so weigh that against the need for defensive copying.
Common Pitfalls with Final and Collections
One frequent mistake is using final on a collection field and then believing that the collection cannot be modified by other code. This leads to unexpected ConcurrentModificationException or data corruption when multiple threads share the object. Another pitfall is assuming that final makes a custom object immutable. For example:
final Person person = new Person("Alice"); person.setName("Bob"); // allowed if Person has a setter
The Person object's internal name field changes. If you want Person to be immutable, you must design it without setters and with final fields for its own state. But even then, if Person contains a mutable object like a Date or a List, that inner object can be mutated unless you also protect it.
This cascading immutability requirement is why the term "deep immutability" exists. final only provides shallow immutability for the reference, not for the object graph.
Concurrency Implications of Final References
In multithreaded code, final has a special memory visibility guarantee. The Java Memory Model ensures that a properly constructed object's final fields are visible to all threads after construction, without additional synchronization. This is a powerful property, but it applies only to the field values, not to the state of objects referenced by those fields.
For example, if you have:
public class Config { private final List<String> values; public Config(List<String> values) { this.values = values; } }
The values reference is safely published, but the ArrayList object itself is not thread-safe. If another thread modifies the list after construction, you can still see inconsistent state. The final reference only guarantees that the reference is visible, not that the object's contents are consistent.
If you need a thread-safe immutable configuration, you should copy the input list into an immutable structure during construction, for example using List.copyOf(values). That way, the object referenced by the final field is itself immutable and safe to share.
Designing for True Immutability
When you want an object to be truly immutable, you need to apply final at the right level and also ensure that all fields reference immutable objects. Here is a pattern that works:
public final class Point { private final int x; private final int y; public Point(int x, int y) { this.x = x; this.y = y; } public int getX() { return x; } public int getY() { return y; } }
The class is final to prevent subclassing, and the fields are final and primitive. There is no way to change x or y after construction. This is a simple immutable object.
For objects that contain collections, you must defensively copy or use immutable collection factories. For example:
public final class User { private final String name; private final List<String> roles; public User(String name, List<String> roles) { this.name = name; this.roles = List.copyOf(roles); } public List<String> getRoles() { return roles; } }
Here List.copyOf creates an unmodifiable list, so the roles field references an immutable object. Even if the original list passed to the constructor is later modified, the User object is unaffected. This is the kind of design you need when you want to rely on immutability for safety.
When Mutation Through Final References Is Acceptable
Not every final reference needs to point to an immutable object. Sometimes you want to fix the identity of an object but still allow its state to change. For example, a final StringBuilder used as a buffer in a single-threaded context is fine. The final reference prevents you from accidentally swapping the buffer, while still letting you append data.
Similarly, a final Random instance can be used to generate numbers without worrying about reassignment. The Random object is mutable internally, but that mutation is part of its normal operation.
The key is to be explicit about what you are protecting. If you only need to prevent reassignment, final is sufficient. If you need to prevent state changes, you must choose immutable types and copy defensively.
Final Reference and Performance Tradeoffs
Using final on a reference does not change the runtime behavior of object mutation. The compiler and JVM may use the knowledge that a variable is final for optimizations, but the object's methods still execute normally. There is no performance penalty for calling a mutating method on a final reference.
However, the choice between defensive copying and allowing mutation has a real cost. Copying a collection on every access or at construction adds allocation and copy overhead. If you are building a high-throughput system, you need to measure whether that cost is acceptable. In many cases, immutable collections like List.of are optimized for small sizes and can be cheaper than a mutable ArrayList that grows dynamically.
But do not assume that final itself has a performance benefit. The JIT compiler already does escape analysis and can optimize references regardless of final. The main benefit of final is clarity and maintainability: it signals to the reader that the reference must not be reassigned.