Immutable Classes in Java | Deep Copy, Shallow Copy & Defensive Copying
An immutable class in Java is a class whose instances cannot be modified once they are created. Immutability is a cornerstone of robust software design, particularly in multi-threaded environments where shared mutable state is the primary source of race conditions and data corruption.
This guide explores the core mechanics of immutability, analyzes how mutable object references can silently break immutability, and demonstrates how to implement robust immutable classes using Defensive Copying and Deep Copying.
1. Why Do We Need Immutability?
In standard Java classes, objects are mutable by default. Their internal state (instance variables) can be modified at any point during execution via direct access or setter methods. While mutability is useful for dynamic data updates, it introduces significant risks:
- Thread Safety Issues: When multiple threads access and modify a shared object simultaneously, it leads to race conditions. Ensuring thread safety with mutable objects requires complex synchronization mechanisms (e.g.,
synchronizedblocks, locks), which degrade performance. - State Corruption: If an object's state is modified unexpectedly in one part of an application, it can cause silent, hard-to-debug failures in other parts of the system that rely on that same object.
- Security Vulnerabilities: External code can modify the internal state of objects passed into or returned from API boundaries, compromising system integrity.
The Immutable Guarantee
An immutable object guarantees that its state remains constant throughout its entire lifecycle. Because it cannot be modified, it is inherently thread-safe and can be shared freely across threads without synchronization.
2. The Mental Model & Real-World Analogy
Real-Life Analogy: The Signed Contract
Think of an immutable object as a legally signed contract.
Once all parties sign a physical contract, you cannot erase or modify its clauses. If you want to change a clause, you cannot edit the existing paper; you must draft and sign a completely new contract with the updated terms.
Similarly, if you want to change the state of an immutable object, you do not modify its existing variables. Instead, you construct a brand-new object containing the updated values.
3. The Naive (Mutable) Approach
To understand how to build an immutable class, let us first examine a standard, highly mutable class design.
// A highly mutable class
public class Student {
private int age;
private String name;
public Student(int age, String name) {
this.age = age;
this.name = name;
}
// Getters
public int getAge() { return age; }
public String getName() { return name; }
// Setters (Expose state to mutation)
public void setAge(int age) { this.age = age; }
public void setName(String name) { this.name = name; }
}
Why This Class is Mutable
- Setters: The presence of
setAge()andsetName()allows any external code to modify the object's state at any time. - Inheritance: The class is not marked
final. A subclass can extendStudent, override its methods, and introduce mutable behaviors.
4. The 4 Golden Rules of Immutability
To transform a mutable class into a strictly immutable one, you must enforce the following four design rules:
5. The Vulnerability: Mutable Reference Fields (The Shallow Copy Trap)
Applying the four golden rules works perfectly when a class contains only primitive types (like int, double) or inherently immutable objects (like String). However, a major vulnerability arises when the class contains a reference to a mutable custom object.
Consider a custom, mutable College class:
// This class is mutable
public class College {
public String name;
public String address;
public College(String name, String address) {
this.name = name;
this.address = address;
}
}
Now, let us design an apparently "immutable" Student class that references this mutable College object:
// Naive attempt at an immutable class
public final class Student {
private final int age;
private final String name;
private final College college; // Reference to a mutable object!
public Student(int age, String name, College college) {
this.age = age;
this.name = name;
this.college = college; // Shallow copy assignment
}
public int getAge() { return age; }
public String getName() { return name; }
public College getCollege() { return college; } // Exposing the internal reference!
}
How the Immutability is Broken
Even though Student is final, has no setters, and has final fields, its state can still be mutated externally. This is because of Shallow Copying:
College myCollege = new College("IIT Guwahati", "Assam");
Student s1 = new Student(28, "Aditya", myCollege);
// Mutation Vector 1: Modifying the original reference passed to the constructor
myCollege.name = "IIT Bombay";
System.out.println(s1.getCollege().name); // Outputs: IIT Bombay (State changed!)
// Mutation Vector 2: Modifying the reference returned by the getter
s1.getCollege().name = "IIT Delhi";
System.out.println(s1.getCollege().name); // Outputs: IIT Delhi (State changed!)
6. Deep Dive: Memory Layout of the Shallow Copy Vulnerability
To understand why this leak occurs, we must look at how Java manages memory in the Stack and the Heap.
When we perform a shallow copy, the Student object's internal college field points to the exact same memory address in the Heap as the external myCollege reference.
Memory Flow of Shallow Copy Assignment
When the getter getCollege() is called, it returns the internal reference. If we assign this to a temporary variable or modify it directly:
Because the reference is shared, modifying the object via Temp or C directly alters the internal state of the Student object, breaking the immutability contract.
7. The Solution: Defensive Copying (Deep Copy)
To protect the integrity of an immutable class containing mutable references, we must implement Defensive Copying at two critical boundaries:
- The Constructor Boundary: Do not store the reference passed by the caller. Create a brand-new copy of the object and store the copy.
- The Getter Boundary: Do not return the actual internal reference. Create a brand-new copy of the internal object and return that copy to the caller.
This process of duplicating the actual data structures rather than just copying their references is known as a Deep Copy.
8. Step-by-Step Code Evolution (Robust Immutable Class)
Here is the fully secured, production-grade implementation of the Student class using defensive copying.
The Mutable Dependency Class
public class College {
private String name;
private String address;
public College(String name, String address) {
this.name = name;
this.address = address;
}
// Getters and Setters
public String getName() { return name; }
public void setName(String name) { this.name = name; }
public String getAddress() { return address; }
public void setAddress(String address) { this.address = address; }
}
The Robust Immutable Class
public final class Student {
private final int age;
private final String name;
private final College college;
public Student(int age, String name, College college) {
this.age = age;
this.name = name;
// RULE: Defensive Copy in Constructor
// Instead of this.college = college, we instantiate a new College object.
if (college != null) {
this.college = new College(college.getName(), college.getAddress());
} else {
this.college = null;
}
}
public int getAge() {
return age;
}
// String is inherently immutable in Java, so returning it directly is safe.
public String getName() {
return name;
}
// RULE: Defensive Copy in Getter
// Instead of returning the actual reference, we return a newly constructed copy.
public College getCollege() {
if (this.college == null) {
return null;
}
return new College(this.college.getName(), this.college.getAddress());
}
}
9. Memory Layout of Deep Copy & Defensive Copying
Let us trace the memory allocation of our robust implementation to see how defensive copying isolates the student's state.
Step 1: Creating the Initial College Object
College c = new College("IIT Guwahati", "Assam");
Step 2: Constructing the Student Object (Defensive Copy in Constructor)
Student s1 = new Student(28, "Aditya", c);
The constructor intercepts the reference c, reads its properties, and creates a completely new College object (@002) in the heap.
If the caller modifies the original college object (c.setName("IIT Bombay")), it only affects @001. The student's college (@002) remains untouched.
Step 3: Accessing the College Object (Defensive Copy in Getter)
College temp = s1.getCollege();
temp.setName("IIT Delhi");
The getter intercepts the access request and creates another new College object (@003) in the heap to return to the caller.
Any modifications made to temp only affect @003. The internal state of Student (@002) remains completely isolated and secure.
10. Shallow Copy vs. Deep Copy: Tabular Synthesis
11. Real-World Pitfalls & Thread Safety Benefits
Performance Trade-offs
While defensive copying guarantees absolute immutability, it comes with a performance cost. Every time a constructor is called or a getter is accessed, new objects are allocated on the heap. In high-throughput systems, this can lead to:
- Increased memory footprint.
- Frequent Garbage Collection (GC) pauses due to short-lived objects.
Alternative Design Pattern: If performance is a critical bottleneck, consider making the referenced class (
College) immutable as well. If bothStudentandCollegeare strictly immutable, defensive copying is no longer necessary, and references can be shared safely without copying.
Thread Safety and Race Conditions
In multi-threaded applications, multiple threads often read from and write to shared objects. If one thread is reading an object while another is writing to it, it can lead to inconsistent states or application crashes.
By using immutable objects:
- Thread A and Thread B can read the object simultaneously without any locks or synchronization.
- Since the object's state cannot change, race conditions are completely eliminated.
12. Summary and Key Takeaways
- Immutability Definition: An immutable object is an object whose state cannot be changed after it is constructed.
- The Core Rules: To make a class immutable, mark the class as
final, make all fieldsprivateandfinal, do not provide setters, and initialize all fields via the constructor. - The Reference Leak: If an immutable class contains references to mutable objects, simply marking the reference fields as
finaldoes not prevent their internal properties from being modified. - Shallow Copy Danger: Returning or storing direct references to mutable objects shares the same memory address between the class and the outside world, breaking immutability.
- Defensive Copying: To secure mutable fields, you must perform a deep copy by instantiating new objects in both the constructor and the getter methods.
- Deep Copy Isolation: Deep copying duplicates the actual data into a new memory address, ensuring that the internal state of the immutable class is completely isolated from external modifications.
- Concurrency Benefit: Immutable objects are inherently thread-safe, making them highly valuable in multi-threaded programming to prevent race conditions without the overhead of synchronization.