Unit 29: Immutability
Learning Objectives
After this unit, students should be able to:
- explain immutability as a design principle and articulate how it reduces bugs arising from aliasing, mutation, and unintended side effects.;
- design and implement immutable classes in Java, including the correct use of
final, factory methods, and copy-on-write semantics; - distinguish between
finaland true immutability, and identify common pitfalls wherefinalfields do not guarantee immutability; - reason about safe sharing of objects and internal representations, including when and why structural sharing is correct and efficient;
- recognize the role of immutability in program reasoning and concurrency, and explain why immutable objects are inherently thread-safe;
- design classes with controlled extension using
sealedkeyword; - implement a simpler immutable classes with record class.
Overview
In earlier units, we saw how abstraction, typing, and reuse help manage software complexity. In this unit, we introduce another powerful strategy: avoiding change.
Many subtle bugs arise from mutation, especially when objects are aliased and updated through multiple references. When an object can change over time, reasoning about program behavior becomes significantly harder.
Immutability avoids this problem by ensuring that an object's observable state never changes after creation1. Updates instead produce new objects, eliminating aliasing bugs and enabling safe sharing without defensive copying.
In this unit, we learn how to design immutable classes in Java and why immutability is a key tool for writing simpler, safer, and more robust programs.
Avoiding Change
Another useful strategy to reduce bugs when code complexity increases is to avoid change altogether. This can be done by making our classes immutable. We create an instance of an immutable class, the instance cannot have any observable changes outside its abstraction barrier. This means that every call to the instance's method must behave the same way throughout the lifetime of the instance. An object can be logically immutable even if it mutates private, unobservable state, as long as its externally visible behavior remains unchanged.
There are many advantages to making classes immutable when possible. To start, let's revisit a common bug due to aliasing. Recall the following example from Unit 9, where we create two circles c1 and c2 centered at the origin (0, 0).
1 2 3 | |
Let's say that we have the moveTo method in both Circle and Point, to move the circle and point respectively.
| Mutable Point | |
|---|---|
1 2 3 4 5 6 7 8 9 | |
| Mutable Circle | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
Suppose we want to move c1 and only c1 to be centered at (1,1).
1 | |
The line of code above surprisingly moved the center of both c1 and c2, due to both circles c1 and c2 sharing the same point. We have explored a solution below:
1 2 3 4 5 6 7 | |
This approach avoids sharing references by creating separate copies of the points so that no two references point to the same instance, avoiding aliasing altogether. This partial fix, however, comes with extra costs in computational resources as the number of objects may proliferate.
This is also not a complete solution because we can still move c2 without calling c2.moveTo(1, 1) but by calling the code below.
1 | |
Let's now see how immutability can help us resolve our problem.
Immutable Points and Circles
Let's start by making our Point class immutable. We start by making the fields final to signal our intention that we do not intend to assign another value to them. Now that the x and y cannot be re-assigned (a new value or even the same value), to move a point, we shouldn't re-assign to the fields x and y anymore. Instead, we return a new Point instance to prevent mutating the current instance, as follows:
| Immutable Point | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | |
Note that, to prevent subclasses from overriding methods in a way that breaks immutability, it is recommended that we declare immutable classes as as final to disallow inheritance.
Now, let's make Circle immutable:
| Immutable Circle | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | |
With both Point and Circle immutable, we can be sure that once an instance is created, it remains unchanged (outside the abstraction barrier):
1 2 3 4 | |
To update the variable c1, we need to explicitly reassign it.
1 | |
Now, c1 moves to a new location, but c2 remains unchanged.
Compare our new immutable approach to the two approaches above. The first shares all the references and is bug-prone. The second creates a new copy of the instance every time and is resource-intensive. Our third approach, using immutable classes, allows us to share all the references until we need to modify the instance, in which case we make a copy. Such a copy-on-write semantic allows us to avoid aliasing bugs without creating excessive copies of objects.
Note that the final keyword prevents assigning new values to the field. Unfortunately, it does not prevent the field from being mutated. So, to ensure that the classes we create are immutable, we have to ensure that the fields are themselves immutable.
Advantages of Being Immutable
We have seen how making our classes immutable helps us remove the risk of potential bugs when we use composition and aliasing. Immutability has other advantages as well.
Ease of Understanding
Code written with immutable objects is easier to reason with and easier to understand. Suppose we create a Circle and assign it to a local variable:
1 | |
We pass c around to many other methods. These other methods may invoke c's methods; we may invoke c's methods locally as well. But, despite putting c through so much, unless we have explicitly re-assigned c, we can guarantee that c is still a circle centered at (0,0) with a radius of 8. This immutable property makes it significantly easier to read, understand, and debug our code.
Without this property, we have to trace through all the methods that we pass c to, and each call of c's methods to make sure that none of these codes modifies c.
Enabling Safe Sharing of Objects
Making a class immutable allows us to safely share instances of the class, therefore reducing the need to create multiple copies of the same object. For instance, the origin (0, 0) is commonly used. If the instance is immutable, we can just create and cache a single copy of the origin, and always return this copy when the origin is required.
Let's modify our Point class so that it creates a single copy of the origin and returns the same copy every time the origin is required.
| Immutable Point with shared ORIGIN | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 | |
We made a few changes in the above:
- We made the constructor for
Pointprivate so that one cannot call the constructor directly. - We provide a class factory method named
offor the client to create aPointinstance. Theofmethod returns the same instanceORIGINevery timePoint.of(0, 0)is called.
Such a design pattern is only safe when the class is immutable. Consider the mutable version of Point — calling Point.of(0, 0).moveTo(1, 1) would change every reference to the origin to (1, 1), causing chaos in the code!
Enabling Safe Sharing of Internals
Immutable instances can also share their internals freely. Consider an immutable implementation of our Seq<T>, called ImmutableSeq<T>. Let's start with a simple version first.
| ImmutableSeq<T> v0.1 | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 | |
There are a few things to note here.
Varargs. The parameter to the class factory method of has the form T... items. The triple . notation is a Java syntax for a variable number of arguments of the same type (T). Often called varargs, this is just syntactic sugar for passing in an array of items to a method. The method is called variadic method. We can then call of with a variable number of arguments, such as:
1 2 3 4 | |
We can also call of with a single array as an argument! This is the reason why we need to copy the content of the parameter items in of.
1 2 3 | |
@SafeVarargs. Since the varargs is implemented as an array, and array and generics do not mix well in Java, the compiler would throw us an unchecked warning. In this instance, however, we know that our code is safe because we never put anything other than items of type T into the array. We can use the @SafeVarargs annotation to tell the compiler that we know what we are doing and this varargs is safe.
Notice that we removed the set method and there is no other way an external client can modify the array once it is created. This, of course, assumes that we will only be inserting an immutable object into our immutable array. Unfortunately, this cannot be enforced by the compiler as the generic type T can be anything.
Now, suppose that we wish to support a subarray method, that returns a new array containing only a range of elements in the original array. It behaves as follows:
1 2 3 4 5 | |
A typical way to implement subarray is to allocate a new T[] and copy the elements over. This operation can be expensive if our ImmutableSeq has millions of elements. But, since our class is immutable and the internal field array is guaranteed not to mutate, we can safely let b and c refer to the same array from a, and only store the starting and ending index.
| ImmutableSeq<T> v0.2 (with sharing) | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 | |
Enabling Safe Concurrent Execution
We will explore concurrent execution of code towards the end of the course, but making our classes immutable goes a long way in reducing bugs related to concurrent execution. Without going into details (you will learn this later), concurrent programming allows multiple threads of code to run in an interleaved fashion, in an arbitrary interleaving order. If we have complex code that is difficult to debug to begin with, imagine having code where we have to ensure its correctness regardless of how the execution interleaves! Immutability helps us ensure that regardless of how the code interleaves, our objects remain unchanged.
Final ≠ Immutable
When creating an immutable class, we need to be careful to distinguish between the keywords that help us avoid accidentally making things easily mutable and the actual concept of an immutable class. For instance, it is insufficient to simply declare all fields with final keywords. Just because we cannot accidentally update the field, does not mean that the field is immutable. Consider the same Circle above but with a getter for the center point and now imagine that the Point is mutable.
| Circle with Mutable Point | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
We can then simply retrieve the center point and mutate it externally.
1 2 | |
On the other hand, it is not even necessary to use the final keyword to make an immutable class. We simply have to have a class that prevents any and all kinds of sharing by copying all the parameters before assigning them to the fields and copying all return values. Assume that all classes have a correctly implemented clone() method. Then the following Circle is immutable even with a getter and no final keyword on the fields. We still need the final keyword on the class to disallow inheritance.
| Immutable Circle with Cloning | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 | |
That does not mean that the final keyword is not important. It helps accidental re-assignment and in some cases, that is sufficient especially if the fields are of primitive type. Once we have created one immutable class, we can then create other larger immutable classes by only using immutable classes as fields.
Performance Trade-offs of Immutability
While immutability offers significant benefits in correctness, reasoning, and safety, it is not without cost. Because immutable objects cannot be modified in place, updates typically require creating new objects, which may increase memory allocation and garbage collection overhead.
In performance-critical code, such as tight loops, low-level data processing, or numerical computations, this additional allocation can be expensive compared to mutating an existing object. You have seen an example of this issue when we discussed wrapper classes, which are immutable.
In such cases, a carefully designed mutable implementation may be more efficient. Hence, immutability should be viewed as a design trade-off, not a universal rule. When correctness, simplicity, and safe sharing are priorities, immutability is often the better choice. When performance is critical and mutation can be tightly controlled within a well-defined abstraction barrier, mutability may be justified.
In practice, many systems combine both approaches: using mutable objects internally for efficiency, while exposing immutable interfaces to clients.
Semi-Controlled Extension
Java 17 introduced the concept of sealed class (JEP 409) that allows for a more controlled extension of classes. Recap that the keyword final prevents any extension. In some cases, we want some extension to allowable sub-classes. This can be specified by sealed keyword.
Consider the immutable circle from above. We are not able to extend this into an immutable ColoredCircle even if both Circle and ColoredCircle are implemented by the same implementer.
| Incorrect ColoredCircle | |
|---|---|
1 2 3 | |
If we want to allow Circle to be extended but only by ColoredCircle, then we can specify Circle as a sealed class and ColoredCircle is an allowed subclass.
| Immutable Circle | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | |
In the example above, the sealed keyword prohibits extension except by allowed subclasses. The allowed subclasses is specified by the permits keyword2. In this case, we allow only ColoredCircle to extend Circle. Now, the following code almost compiles.
| Almost Correct ColoredCircle | |
|---|---|
1 2 3 | |
Since the intention is to close the loophole, we need to ensure the the ColoredCircle class has no further unregulated subclasses. In other words, we need to ensure that ColoredCircle is declared with final keyword.
| Correct ColoredCircle | |
|---|---|
1 2 3 | |
However, this may be too strong. In some cases, we still want to have yet another controlled extension. As such, ColoredCircle is allowed to be another sealed class and its subclasses will be checked accordingly.
Unfortunately, for backward compatibility some sealed class should be allowed to have non-sealed subclass. However, this should still be done in a controlled manner. The proper way to do this is to add non-sealed keyword to any subclasses that are not guaranteed to be final or sealed. We are not allowed to omit the keywords.
In other words, the permitted subclasses must satisfy the following constraints.
- They must directly extend the sealed class.
- They must have exactly one of the following modifiers:
- final: cannot extend further.
- sealed: further controlled extension.
- non-sealed: further uncontrolled extension. This is a bad practice and we prohibit its usage.
With respect to compilation, the Java compiler wants to check that all classes are correct at compile-time. This means that partial compilation (i.e., compilation of sealed class without the known subclassses) are not allowed for sealed class. This adds another requirement that all permitted subclasses must be accessible by the sealed class at compile-time.
Shortcut Record
Another feature added in Java 17 is a special kind of immutable class called Records (JEP 395) with common structure. Notice how we often create an immutable class with the following structure.
| Common Class Structure | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | |
A record class simplifies the creation of such name by removing often repeated constructs. Using record class, we can declare the class above as follows.
| Record Class | |
|---|---|
1 2 3 | |
The following members are automatically generated by the record class.
- The fields as mentioned in the header. The fields are automatically private and final with the same name and type.
- The public accessor method with the same name and type of the fields.
- The canonical constructor with the same signature as the header. The constructor assigns the input parameter to the correct field based on the position of the field in the header.
- The implementation of equals and hashCode methods. two record classes are equal if they are of the same type3 and contain equal component values.
- The implementation of toString method that includes the string representation of all the record class's components, with their names.
We can then simplify the immutable circle with immutable point as follows.
| Record Class | |
|---|---|
1 2 | |
If we want to have the getArea and moveTo method, we can add them into the class definition.
| Record Class | |
|---|---|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | |
We will often use record class to quickly create some immutable classes.
-
Note that this is a looser definition than some other definitions of immutability. Java tutorial, for instance, defines immutability as preventing any change to the object, including private state. Our definition allows private state to change as long as the observable behavior remains unchanged. ↩
-
The feature is so new that the
permitskeyword has not been colored by the syntax highlighter. ↩ -
This is an explicit choice by Java. The equality requires exactly the same type and not subtype. ↩