Java final vs immutable: What's the Difference?
java final vs immutable: Understand the difference between Java's final modifier and true immutability, and when each matters in your code.
java final vs immutable requires a clear understanding of the core syntax, runtime behavior, and practical implementation patterns demonstrated in the examples below.
In Java, final and immutability are often mentioned together, but they solve different problems. A final variable cannot be reassigned, while an immutable object cannot change its state after creation. The two concepts interact, but one does not guarantee the other. This article explains what each mechanism actually provides, how they relate, and where the distinction affects your design decisions.
What final Actually Does
The final keyword is a compile-time constraint on the reference or declaration. When applied to a variable, it prevents reassignment. For example:
final int maxRetries = 3; maxRetries = 5; // compilation error
This works for local variables, fields, and parameters. A final field must be assigned exactly once, either in its declaration or in every constructor of the class. final also applies to methods (preventing override) and classes (preventing inheritance), but those uses are unrelated to immutability.
Crucially, final only restricts the reference. If the reference points to a mutable object, the object itself can still change:
final List<String> names = new ArrayList<>(); names.add("Alice"); // allowed names = new ArrayList<>(); // compilation error
Here names is final, but the list is fully mutable. The final keyword does not make the object immutable.
What Immutability Means in Java
An immutable object is one whose state cannot be changed after construction. All fields are set in the constructor and never modified afterward. The class typically has no setters, and any methods that would normally change state return a new instance instead.
A classic example:
public final class Money { private final int amount; private final String currency; public Money(int amount, String currency) { this.amount = amount; this.currency = currency; } public int getAmount() { return amount; } public String getCurrency() { return currency; } public Money add(Money other) { if (!currency.equals(other.currency)) { throw new IllegalArgumentException("Currency mismatch"); } return new Money(amount + other.amount, currency); } }
Notice that the class is also declared final to prevent subclasses from introducing mutable state. All fields are final, which is a common but not sufficient condition for immutability. The class must also ensure that any mutable fields are not exposed or modified.
The Relationship Between final and Immutable
final fields are a necessary part of most immutable classes because they guarantee that the field is assigned once and never reassigned. However, final alone does not make an object immutable. The object's internal state can still be mutable if the field references a mutable object.
Consider:
public final class MutableHolder { private final List<String> items; public MutableHolder(List<String> items) { this.items = items; } public List<String> getItems() { return items; } }
The items field is final, but the list itself is mutable. Callers can modify it through getItems(), and the original list passed to the constructor can also be modified externally. This class is not immutable.
To make it immutable, you must copy the list in the constructor and return an unmodifiable view or a copy in the getter:
public final class ImmutableHolder { private final List<String> items; public ImmutableHolder(List<String> items) { this.items = List.copyOf(items); // Java 10+ } public List<String> getItems() { return items; } }
List.copyOf creates an unmodifiable copy, so the original list and the internal list are independent.
Practical Example: Building an Immutable Class
When designing an immutable class, follow these rules:
- Declare the class
finalor make constructors private and use static factories. - Make all fields
finaland private. - Do not provide setters.
- For mutable fields, use defensive copies in constructors and getters.
- Ensure no methods modify the object's state.
Here is a more complete example with a mutable field:
public final class Person { private final String name; private final int age; private final List<String> hobbies; public Person(String name, int age, List<String> hobbies) { this.name = name; this.age = age; this.hobbies = new ArrayList<>(hobbies); // defensive copy } public String getName() { return name; } public int getAge() { return age; } public List<String> getHobbies() { return Collections.unmodifiableList(hobbies); } }
The defensive copy in the constructor prevents the caller from modifying the internal list after creation. The unmodifiable view in the getter prevents callers from modifying the list through the getter.
Performance and Memory Implications
Immutable objects have several performance and memory advantages, but they are not free. Because their state never changes, they can be safely cached and reused. For example, a String is immutable, so the JVM can intern strings and share them across the application. Similarly, immutable value objects can be used as keys in HashMap without worrying about hash code changes.
In concurrent code, immutable objects eliminate the need for synchronization. They can be published to multiple threads without locks, and no thread can corrupt their state. This often leads to simpler and faster code than using locks or atomic variables.
The main cost is allocation: every modification creates a new object. For high-frequency operations, this can increase garbage collection pressure. However, modern JVMs handle short-lived objects efficiently, and the benefits in correctness and maintainability often outweigh the allocation cost.
final fields themselves have no runtime cost in modern JVMs. The compiler and JIT can optimize based on the knowledge that a final field will not change, and final fields are guaranteed to be visible to other threads after construction (safe publication).
Concurrency and Thread Safety
Immutability is the simplest path to thread safety. An immutable object is automatically safe to share between threads because no thread can change its state. This eliminates race conditions, visibility issues, and the need for locks.
final fields play a role in safe publication. The Java Memory Model guarantees that when an object is constructed, any final fields are visible to all threads that access the object after construction. This is a stronger guarantee than for non-final fields, which require proper synchronization or other happens-before edges.
For example, if you publish an immutable object through a volatile reference or a concurrent collection, the final fields are guaranteed to be visible without additional synchronization. This is why immutable objects are often used as messages in actor systems or as configuration snapshots.
When to Use final vs Designing for Immutability
The choice between using final and designing for immutability depends on the role of the variable or object.
Use final for:
- Constants and configuration values that should never be reassigned.
- Local variables that should not be accidentally changed.
- Method parameters that should not be reassigned (though this is a style choice).
- Fields that are part of an immutable class's state.
- Preventing method overriding or class inheritance when that is the goal.
Design objects for immutability when:
- The object represents a value, such as a date, amount, or identifier.
- The object will be shared across threads.
- The object is used as a key in a hash-based collection.
- The object's state is small and changes infrequently.
- You want to avoid defensive copies and synchronization in concurrent code.
A common mistake is to use final on a mutable object and assume it is immutable. That leads to subtle bugs when the object's state changes unexpectedly. Always consider whether the referenced object itself is mutable.
Common Pitfalls: final Array or Collection
A final array is a classic trap. The array reference cannot be reassigned, but the array's elements can be changed:
final int[] numbers = {1, 2, 3}; numbers[0] = 99; // allowed
To make an array immutable, you must either copy it on every access or use an immutable wrapper. The same applies to collections. A final ArrayList is still mutable.
When returning a collection from an immutable class, always return an unmodifiable view or a copy. Using Collections.unmodifiableList is a view: it throws UnsupportedOperationException on modification attempts, but the underlying list can still change if it is referenced elsewhere. For a truly immutable snapshot, use List.copyOf (Java 10+) or Collections.unmodifiableList with a private copy.
Advanced: Defensive Copying and Unmodifiable Views
Defensive copying is essential when an immutable class accepts a mutable object in its constructor. Without a copy, the caller can retain a reference and modify the object after construction, breaking immutability.
public final class Record { private final Date createdAt; // Date is mutable public Record(Date createdAt) { this.createdAt = new Date(createdAt.getTime()); // copy } public Date getCreatedAt() { return new Date(createdAt.getTime()); // copy on read } }
In this example, Date is mutable, so both the constructor and the getter create copies. This ensures that the internal state cannot be modified externally. The cost is an extra allocation on each read, but it preserves the immutability contract.
Unmodifiable views like Collections.unmodifiableList are not copies. They wrap an existing list and prevent modifications through the view, but the underlying list can still change if another reference exists. For a truly immutable collection, use List.copyOf or Set.copyOf, which create new unmodifiable collections with no reference to the original.
The distinction between final and immutability becomes critical when you design APIs that are shared across threads or long-lived. A final reference gives the compiler a constraint, but immutability gives the runtime a guarantee about state. Choosing the right mechanism prevents subtle bugs and makes your code easier to reason about.