Guide contents

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:

  1. Declare the class final, so a subclass cannot add mutable state or override methods.
  2. Make every field private final.
  3. Provide no methods that change fields.
  4. Return new objects from "modifying" operations.
  5. Do not let references to mutable internal objects escape (Examples 2 and 3).
Java
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));
    }
}
Output
price: EUR 1000
total: EUR 1250
same object: false

plus 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.

Java
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));
    }
}
Output
leaky: [book, pen, cup]
safe: [book] qty=1

The 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.

Java
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");
        }
    }
}
Output
view: [a, b, c]
copy: [a, b]
view.add: UnsupportedOperationException
copy.add: UnsupportedOperationException
copyOf of copy is same object: true
copyOf rejects null elements

The 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.

Java
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());
    }
}
Output
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.

Java
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));
    }
}
Output
held by local variable, cleared: false
held by static field, cleared: false
unreachable, cleared: true
local still used: true

System.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 = ...) { ... }, calls close() 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 when close() calls it or after the object becomes unreachable. It is a safety net for resources a caller forgot to close.
Java
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");
    }
}
Output
allocated a
allocated b
using a and b
released b
released a
allocated c
released c
done

Resources 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.

Java
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));
    }
}
Output
soft refs created: 40
some soft refs cleared: true
weak ref cleared after gc: true

The 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: final class, private final fields, no mutators, defensive copies of mutable inputs and outputs.
  • final on a field fixes the reference, not the object it points to.
  • Collections.unmodifiableList is a read-only view that reflects changes to the source; List.copyOf is 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.
  • finalize is deprecated for removal (JEP 421, Java 18); use AutoCloseable with try-with-resources, and Cleaner as a safety net.
  • A Cleaner action 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.copyOf returns its argument when it is already an unmodifiable List.of or List.copyOf list.
  • new sets 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.