Lesson 6 of 71
Immutability, Defensive Copying, and Object Lifecycle
An immutable object is one whose state cannot change after its constructor returns. Immutable objects are safe as map keys, safe to share between threads without locks, and their state at any point is the state set by the constructor. This lesson shows how to build them, how copies and views differ, and what happens to an object from creation until the garbage collector reclaims it. It builds on Object Contracts.
| Topic | What it covers |
|---|---|
| Immutable class rules | final class and fields, no setters, no leaked mutable state |
| Defensive copies | Copy mutable inputs in, copy or wrap mutable outputs |
| Views vs copies | Collections.unmodifiableList vs List.copyOf |
| Deep immutability | Immutable fields that point at immutable objects |
| Lifecycle | Creation, reachability, garbage |
| Cleanup | finalize (deprecated), Cleaner, AutoCloseable |
| Reference types | WeakReference, SoftReference |
Example 1: rules for an immutable class
An immutable class follows five rules:
- Declare the class
final, so a subclass cannot add mutable state or override methods. - Make every field
private final. - Provide no methods that change fields.
- Return new objects from "modifying" operations.
- Do not let references to mutable internal objects escape (Examples 2 and 3).
public class ImmutableMoney {
static final class Money {
private final String currency;
private final long cents;
Money(String currency, long cents) {
if (cents < 0) throw new IllegalArgumentException("negative");
this.currency = currency;
this.cents = cents;
}
Money plus(Money other) {
if (!currency.equals(other.currency)) throw new IllegalArgumentException("currency");
return new Money(currency, cents + other.cents);
}
@Override public String toString() { return currency + " " + cents; }
}
public static void main(String[] args) {
Money price = new Money("EUR", 1000);
Money total = price.plus(new Money("EUR", 250));
System.out.println("price: " + price);
System.out.println("total: " + total);
System.out.println("same object: " + (price == total));
}
}price: EUR 1000
total: EUR 1250
same object: falseplus returns a new Money and leaves price unchanged. String and Integer follow the same rules. A final field also gets a guarantee from the memory model: other threads see its constructed value without synchronization, as long as this does not escape the constructor (Safe Publication explains this).
Example 2: defensive copies in and out
A final field that holds a mutable object, such as an ArrayList or an array, makes the reference fixed but not the contents. If the constructor stores the caller's list, the caller can still change it. If a getter returns the internal list, every caller can change it. A defensive copy is a copy made at the boundary so that outside code never holds a reference to internal state.
import java.util.ArrayList;
import java.util.List;
public class DefensiveCopies {
static final class LeakyOrder {
private final List<String> items;
LeakyOrder(List<String> items) { this.items = items; }
List<String> items() { return items; }
}
static final class SafeOrder {
private final List<String> items;
private final int[] quantities;
SafeOrder(List<String> items, int[] quantities) {
this.items = new ArrayList<>(items); // copy in
this.quantities = quantities.clone(); // copy in
}
List<String> items() { return new ArrayList<>(items); } // copy out
int quantity(int i) { return quantities[i]; }
}
public static void main(String[] args) {
List<String> input = new ArrayList<>(List.of("book"));
LeakyOrder leaky = new LeakyOrder(input);
input.add("pen");
leaky.items().add("cup");
System.out.println("leaky: " + leaky.items());
List<String> input2 = new ArrayList<>(List.of("book"));
int[] qty = {1};
SafeOrder safe = new SafeOrder(input2, qty);
input2.add("pen");
qty[0] = 99;
safe.items().add("cup");
System.out.println("safe: " + safe.items() + " qty=" + safe.quantity(0));
}
}leaky: [book, pen, cup]
safe: [book] qty=1The copy is made before validation in a real constructor, so that another thread cannot change the input between the check and the copy. Arrays are exposed through accessor methods such as quantity(i), because any array returned to a caller can be written.
Example 3: unmodifiable views versus copies
Collections.unmodifiableList(list) returns a view: a wrapper that throws UnsupportedOperationException on writes but reads through to the original list. Changes to the original show up in the view. List.copyOf(list) (Java 10) returns an unmodifiable copy that is independent of the original, rejects null elements, and returns its argument unchanged when the argument is already an unmodifiable list created by List.of or List.copyOf.
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class ViewsVersusCopies {
public static void main(String[] args) {
List<String> source = new ArrayList<>(List.of("a", "b"));
List<String> view = Collections.unmodifiableList(source);
List<String> copy = List.copyOf(source);
source.add("c");
System.out.println("view: " + view);
System.out.println("copy: " + copy);
try {
view.add("x");
} catch (UnsupportedOperationException e) {
System.out.println("view.add: UnsupportedOperationException");
}
try {
copy.add("x");
} catch (UnsupportedOperationException e) {
System.out.println("copy.add: UnsupportedOperationException");
}
System.out.println("copyOf of copy is same object: " + (List.copyOf(copy) == copy));
List<String> withNull = new ArrayList<>();
withNull.add(null);
try {
List.copyOf(withNull);
} catch (NullPointerException e) {
System.out.println("copyOf rejects null elements");
}
}
}view: [a, b, c]
copy: [a, b]
view.add: UnsupportedOperationException
copy.add: UnsupportedOperationException
copyOf of copy is same object: true
copyOf rejects null elementsThe try and catch blocks catch the thrown exception so the program continues; Exceptions covers them. For a field, List.copyOf in the constructor plus returning the field directly is the shortest correct pattern: one copy in, no copy out, because the stored list cannot change.
| Method | Independent of source | Writes | Nulls |
|---|---|---|---|
Collections.unmodifiableList |
No (view) | Throw | Allowed |
List.copyOf |
Yes | Throw | Rejected |
new ArrayList<>(list) |
Yes | Allowed | Allowed |
Example 4: shallow versus deep immutability
A class is shallowly immutable when its fields cannot be reassigned but the objects they point to can change. It is deeply immutable when every object reachable from it is also immutable. List.copyOf freezes the list structure, not the elements.
import java.util.ArrayList;
import java.util.List;
public class ShallowDeep {
static final class Line {
private final StringBuilder text;
Line(String s) { this.text = new StringBuilder(s); }
void append(String s) { text.append(s); }
@Override public String toString() { return text.toString(); }
}
static final class Document {
private final List<Line> lines;
Document(List<Line> lines) { this.lines = List.copyOf(lines); }
List<Line> lines() { return lines; }
}
static final class SafeDocument {
private final List<String> lines; // String is immutable
SafeDocument(List<String> lines) { this.lines = List.copyOf(lines); }
List<String> lines() { return lines; }
}
public static void main(String[] args) {
List<Line> input = new ArrayList<>(List.of(new Line("hello")));
Document doc = new Document(input);
doc.lines().get(0).append(" world");
System.out.println("shallow: " + doc.lines());
SafeDocument safe = new SafeDocument(List.of("hello"));
System.out.println("deep: " + safe.lines());
}
}shallow: [hello world]
deep: [hello]Document cannot gain or lose lines, but each Line can still change. Storing immutable element types (String, boxed numbers, immutable classes) makes the whole object graph immutable. Records, taught in Records and Sealed Types, are shallowly immutable in the same way.
Example 5: creation, reachability, and garbage
new allocates memory on the heap, sets every field to its default value (0, false, null), runs the constructor chain, and returns a reference. An object is reachable while a chain of references leads to it from a GC root, such as a local variable of a running method or a static field. An unreachable object is garbage, and the garbage collector may reclaim its memory at any later time. GC Fundamentals covers roots and collection algorithms.
A WeakReference points to an object without keeping it reachable. Once only weak references remain, the collector clears them. The example uses this to observe when an object becomes garbage.
import java.lang.ref.WeakReference;
public class Reachability {
static Object staticRoot;
public static void main(String[] args) {
Object local = new Object();
Object viaStatic = new Object();
staticRoot = viaStatic;
WeakReference<Object> weakLocal = new WeakReference<>(local);
WeakReference<Object> weakStatic = new WeakReference<>(viaStatic);
WeakReference<Object> weakGarbage = new WeakReference<>(new Object());
viaStatic = null;
System.gc();
System.out.println("held by local variable, cleared: " + (weakLocal.get() == null));
System.out.println("held by static field, cleared: " + (weakStatic.get() == null));
System.out.println("unreachable, cleared: " + (weakGarbage.get() == null));
System.out.println("local still used: " + (local != null));
}
}held by local variable, cleared: false
held by static field, cleared: false
unreachable, cleared: true
local still used: trueSystem.gc() is a request, not a command; the JVM may ignore it, and -XX:+DisableExplicitGC turns it off. With the default G1 collector it runs a full collection, which is why the unreachable object is cleared here. Production code never relies on System.gc().
Example 6: finalize, Cleaner, and AutoCloseable
Object.finalize was a hook that the collector called before reclaiming an object. It was deprecated in Java 9 and deprecated for removal in Java 18 (JEP 421), because it runs at an unpredictable time or never, on an unspecified thread, can resurrect the object, and slows collection. Its replacements are:
AutoCloseable: an interface with one method,close(). A try-with-resources statement,try (Resource r = ...) { ... }, callsclose()when the block ends, normally or by exception. This is the primary cleanup mechanism.java.lang.ref.Cleaner(Java 9): registers a cleanup action that runs once, either whenclose()calls it or after the object becomes unreachable. It is a safety net for resources a caller forgot to close.
import java.lang.ref.Cleaner;
public class CleanupDemo {
static final Cleaner CLEANER = Cleaner.create();
static final class NativeBuffer implements AutoCloseable {
// State must not reference the NativeBuffer, or it never becomes unreachable.
private static final class State implements Runnable {
private final String name;
State(String name) { this.name = name; }
@Override public void run() { System.out.println("released " + name); }
}
private final Cleaner.Cleanable cleanable;
NativeBuffer(String name) {
this.cleanable = CLEANER.register(this, new State(name));
System.out.println("allocated " + name);
}
@Override public void close() {
cleanable.clean(); // runs the action at most once
}
}
public static void main(String[] args) {
try (NativeBuffer a = new NativeBuffer("a");
NativeBuffer b = new NativeBuffer("b")) {
System.out.println("using a and b");
}
NativeBuffer c = new NativeBuffer("c");
c.close();
c.close(); // second call does nothing
System.out.println("done");
}
}allocated a
allocated b
using a and b
released b
released a
allocated c
released c
doneResources in a try-with-resources header close in reverse order of declaration, so b is released before a. Cleanable.clean() unregisters the action and runs it once, so a second close() is safe. The cleanup action is a static nested class: a lambda or inner class that captured this would keep the object reachable forever. Suppressed exceptions from close() are covered in Exceptions.
Example 7: WeakReference and SoftReference for caches
The two reference types differ in when the collector clears them:
| Type | Cleared when | Typical use |
|---|---|---|
WeakReference |
At the next collection that finds the referent only weakly reachable | Metadata keyed by an object's lifetime (WeakHashMap) |
SoftReference |
Only when memory is low; all are cleared before OutOfMemoryError |
Memory-sensitive caches |
The example fills a small heap with large blocks held only by soft references. The collector clears soft references to make room, and the program counts how many survived.
import java.lang.ref.SoftReference;
import java.lang.ref.WeakReference;
import java.util.ArrayList;
import java.util.List;
public class SoftAndWeak {
public static void main(String[] args) {
List<SoftReference<byte[]>> cache = new ArrayList<>();
for (int i = 0; i < 40; i++) {
cache.add(new SoftReference<>(new byte[4 * 1024 * 1024])); // 160 MB total
}
int alive = 0;
for (SoftReference<byte[]> ref : cache) {
if (ref.get() != null) alive++;
}
System.out.println("soft refs created: " + cache.size());
System.out.println("some soft refs cleared: " + (alive < cache.size()));
WeakReference<byte[]> weak = new WeakReference<>(new byte[1024]);
System.gc();
System.out.println("weak ref cleared after gc: " + (weak.get() == null));
}
}soft refs created: 40
some soft refs cleared: true
weak ref cleared after gc: trueThe loop allocates 160 MB on a 64 MB heap without OutOfMemoryError, because the collector clears soft references before it gives up. A soft cache has drawbacks: the collector, not the application, decides what to evict, and a full heap of soft entries causes long collections. Bounded caches with explicit eviction are more predictable; one is built in HashMap Internals.
Interview points
- Immutable class:
finalclass,private finalfields, no mutators, defensive copies of mutable inputs and outputs. finalon a field fixes the reference, not the object it points to.Collections.unmodifiableListis a read-only view that reflects changes to the source;List.copyOfis an independent copy that rejects nulls.- Shallow immutability leaves mutable elements exposed; deep immutability needs immutable element types.
- An object is garbage when no chain of references leads to it from a GC root; when it is reclaimed is up to the collector.
finalizeis deprecated for removal (JEP 421, Java 18); useAutoCloseablewith try-with-resources, andCleaneras a safety net.- A
Cleaneraction must not reference the object it cleans, or the object never becomes unreachable. - Soft references are cleared under memory pressure, weak references at the next collection that finds them weakly reachable.
Summary
- Immutable objects are safe to share and to use as keys.
- Copy mutable inputs in the constructor and do not return mutable internals.
List.copyOfreturns its argument when it is already an unmodifiableList.oforList.copyOflist.newsets fields to defaults, runs constructors, and returns a reference.System.gc()is a hint the JVM may ignore.- Try-with-resources closes resources in reverse order of declaration.
Cleanable.clean()runs its action at most once.
