Java Tutorial
🔍

Java Immutable Class

Java Immutable Class

An immutable class is one whose objects, once constructed, never change - not a field, not the contents of anything that field points to, nothing. String, Integer, LocalDate, and BigDecimal are all immutable: every "modifying" method on them - toUpperCase(), plus(), withYear() - returns a brand-new object and leaves the original untouched. The appeal is not abstract. An object that cannot change cannot be corrupted by another part of the program, cannot be caught mid-update by a second thread, and cannot have its hashCode() quietly go stale after it has been used as a map key. Building one correctly takes more than writing final on every field - and exactly where the extra work is needed is what most of this article is about.

What Is an Immutable Class?

A class is immutable when every object of that class has the same observable state for its entire lifetime - from the moment its constructor finishes to the moment it is garbage collected. This is a stronger statement than "the fields are final": a final field cannot be reassigned, but if it refers to a mutable object - a List, a Date, an array - that object's contents can still change, which means the "immutable" object's observable state changes too.

final class Coupon {
    private final String code;
    private final double discountPercent;
    private final LocalDate expiryDate;
    // constructor sets all three, once; only accessor methods after that
}

Basic Overview - The Rules That Make a Class Immutable

RULE 1 - DON'T PROVIDE MUTATOR METHODS
  Fresher view  : no setCode(), no setDiscountPercent() - only getters
  Deeper view   : this includes methods that LOOK read-only but mutate
                  something reachable from a field - e.g., a method
                  that calls .add() on a field that is a List

RULE 2 - MAKE EVERY FIELD private AND final
  Fresher view  : every field is set once, in the constructor, and
                  never reassigned anywhere else
  Deeper view   : 'final' here prevents REASSIGNING the field - it
                  says nothing about whether the OBJECT the field
                  refers to can itself be mutated (Rules 4 and 5 cover that)

RULE 3 - MAKE THE CLASS final (OR PREVENT SUBCLASSING ANOTHER WAY)
  Fresher view  : 'final class Coupon' - no one can extend Coupon
  Deeper view   : an immutable class's guarantees are about its OWN
                  code - a subclass could add mutable state, or
                  override a method to behave differently, breaking
                  the guarantee for anyone holding a Coupon reference
                  that is secretly a mutable subclass

RULE 4 - DEFENSIVE COPY ON INPUT
  Fresher view  : if the constructor receives a List, Date, or array,
                  copy it - don't just store the reference you were given
  Deeper view   : without this, the CALLER still holds a reference to
                  the same object - and can mutate it AFTER construction,
                  changing the "immutable" object's state from outside

RULE 5 - DEFENSIVE COPY ON OUTPUT
  Fresher view  : if a getter would return a List, Date, or array,
                  return a copy (or an unmodifiable view of a copy) -
                  not the internal field itself
  Deeper view   : without this, ANY caller of the getter receives a
                  reference to the SAME internal object - and can
                  mutate it, changing the object's state from outside,
                  through a method that looks like a harmless read

A fresher can start with rules 1 through 3 - no setters, private final fields, final class - and that alone covers any class whose fields are all primitives, String, or other immutable types (which is a large fraction of real "data holder" classes). Rules 4 and 5 are where this topic stops being mechanical: they apply only when a field's type is itself mutable, they are easy to forget precisely because the class still looks correct without them, and they are the single most common gap between "I made the fields final" and "this class is actually immutable."

Why Immutable Classes Matter

The thread-safety argument is the one most often quoted, and it is real: an object with no mutable state has nothing for two threads to race over. No synchronized, no lock, no volatile - reading an immutable object from any thread, at any time, always sees the same values it had at construction, because there is no other state it could have.

The less-discussed argument is what immutability does for single-threaded correctness. An immutable object can be handed to another method, stored in a cache, used as a map key, or returned from a getter, with zero concern about what the recipient might do to it - because nothing the recipient does can affect the object's state as seen by anyone else. A mutable object passed the same way creates a question that has to be answered every time: does the recipient own this, or are we sharing it, and if we're sharing it, who's allowed to change it?

There is also a failure-atomicity benefit that is easy to miss. If an immutable class validates its invariants in the constructor and throws on invalid input, then every Coupon object that exists is, by construction, valid - there is no Coupon floating around in a temporarily-invalid state, because there is no "later" in which its state could have drifted from valid to invalid in the first place. During code reviews, a value type passed between layers - a DTO, a calculation result, a configuration snapshot - that has no real reason to change after creation is a common candidate for this treatment: making it immutable documents "nothing downstream should be mutating this" as a compiler-enforced fact, not a comment.

How It Works

The Core Rules in Code

A traditional (non-record) immutable class with only primitive and already-immutable fields needs nothing beyond rules 1 through 3 - no defensive copies are needed because nothing about String or LocalDate can be mutated in the first place.

1// File: ImmutableCouponDemo.java 2 3import java.time.LocalDate; 4import java.util.Objects; 5 6public class ImmutableCouponDemo { 7 8 // final - rule 3: Coupon cannot be subclassed 9 static final class Coupon { 10 11 // private final - rule 2: set once, in the constructor 12 private final String code; 13 private final double discountPercent; 14 private final LocalDate expiryDate; // LocalDate is itself immutable 15 16 Coupon(String code, double discountPercent, LocalDate expiryDate) { 17 if (discountPercent < 0 || discountPercent > 100) { 18 throw new IllegalArgumentException("discountPercent must be 0-100: " + discountPercent); 19 } 20 this.code = code; 21 this.discountPercent = discountPercent; 22 this.expiryDate = expiryDate; // no defensive copy needed - LocalDate cannot mutate 23 } 24 25 // rule 1: accessors only - no setCode(), setDiscountPercent(), etc. 26 String getCode() { return code; } 27 double getDiscountPercent() { return discountPercent; } 28 LocalDate getExpiryDate() { return expiryDate; } 29 30 boolean isExpired(LocalDate today) { 31 return today.isAfter(expiryDate); 32 } 33 34 @Override 35 public boolean equals(Object obj) { 36 if (this == obj) return true; 37 if (!(obj instanceof Coupon other)) return false; 38 return Double.compare(discountPercent, other.discountPercent) == 0 39 && code.equals(other.code) 40 && expiryDate.equals(other.expiryDate); 41 } 42 43 @Override 44 public int hashCode() { 45 return Objects.hash(code, discountPercent, expiryDate); 46 } 47 48 @Override 49 public String toString() { 50 return "Coupon[code=" + code + ", discountPercent=" + discountPercent + ", expiryDate=" + expiryDate + "]"; 51 } 52 } 53 54 public static void main(String[] args) { 55 Coupon festiveOffer = new Coupon("DIWALI25", 25.0, LocalDate.of(2026, 11, 15)); 56 57 System.out.println(festiveOffer); 58 System.out.println("Expired on 2026-12-01? " + festiveOffer.isExpired(LocalDate.of(2026, 12, 1))); 59 System.out.println("Expired on 2026-10-01? " + festiveOffer.isExpired(LocalDate.of(2026, 10, 1))); 60 61 System.out.println(); 62 63 System.out.println("=== Validation runs in the constructor - invalid state is impossible ==="); 64 try { 65 new Coupon("BAD100", 150.0, LocalDate.of(2026, 12, 31)); 66 } catch (IllegalArgumentException e) { 67 System.out.println("Caught: " + e.getMessage()); 68 } 69 } 70}
Output:
Coupon[code=DIWALI25, discountPercent=25.0, expiryDate=2026-11-15]
Expired on 2026-12-01? true
Expired on 2026-10-01? false

=== Validation runs in the constructor - invalid state is impossible ===
Caught: discountPercent must be 0-100: 150.0

Defensive Copies - Why Both Directions Matter

The moment a field's type is mutable - any collection, java.util.Date, an array - rules 1 through 3 alone are not enough. The class below, BrokenSchedule, follows all three: no setters, private final fields, declared final. It is still not immutable, because its stops field is a reference to whatever List the caller passed in - and that list can be mutated from outside, both before and after the "immutable" object exists.

1// File: DefensiveCopyDemo.java 2 3import java.util.ArrayList; 4import java.util.Collections; 5import java.util.List; 6 7public class DefensiveCopyDemo { 8 9 // Looks immutable - final fields, no setters, final class. 10 // Is NOT immutable - 'stops' is the CALLER'S list, shared directly. 11 static final class BrokenSchedule { 12 private final String routeName; 13 private final List<String> stops; 14 15 BrokenSchedule(String routeName, List<String> stops) { 16 this.routeName = routeName; 17 this.stops = stops; // stores the caller's list directly 18 } 19 20 List<String> getStops() { 21 return stops; // returns the SAME list reference 22 } 23 24 @Override 25 public String toString() { 26 return "BrokenSchedule[" + routeName + ", stops=" + stops + "]"; 27 } 28 } 29 30 // Genuinely immutable - defensive copy on input AND output 31 static final class SafeSchedule { 32 private final String routeName; 33 private final List<String> stops; 34 35 SafeSchedule(String routeName, List<String> stops) { 36 this.routeName = routeName; 37 // DEFENSIVE COPY ON INPUT - a new ArrayList, independent of 38 // whatever list the caller passed in, wrapped unmodifiable 39 this.stops = Collections.unmodifiableList(new ArrayList<>(stops)); 40 } 41 42 List<String> getStops() { 43 return stops; // already a copy AND unmodifiable - safe to return 44 } 45 46 @Override 47 public String toString() { 48 return "SafeSchedule[" + routeName + ", stops=" + stops + "]"; 49 } 50 } 51 52 public static void main(String[] args) { 53 List<String> originalStops = new ArrayList<>(List.of("Indiranagar", "MG Road", "Majestic")); 54 55 System.out.println("=== BrokenSchedule - mutation AFTER construction leaks in ==="); 56 BrokenSchedule broken = new BrokenSchedule("Route 500", originalStops); 57 System.out.println("Before mutation: " + broken); 58 59 originalStops.add("Whitefield"); // caller mutates THEIR OWN list 60 System.out.println("After mutation : " + broken); 61 System.out.println("'Immutable' object changed - because it shared the caller's list"); 62 63 System.out.println(); 64 65 System.out.println("=== SafeSchedule - mutation AFTER construction has no effect ==="); 66 List<String> moreOriginalStops = new ArrayList<>(List.of("Indiranagar", "MG Road", "Majestic")); 67 SafeSchedule safe = new SafeSchedule("Route 500", moreOriginalStops); 68 System.out.println("Before mutation: " + safe); 69 70 moreOriginalStops.add("Whitefield"); 71 System.out.println("After mutation : " + safe); 72 System.out.println("Object unchanged - the constructor copied the list"); 73 74 System.out.println(); 75 76 System.out.println("=== getStops() on SafeSchedule also resists mutation ==="); 77 try { 78 safe.getStops().add("Electronic City"); 79 } catch (UnsupportedOperationException e) { 80 System.out.println("Caught: " + e.getClass().getSimpleName() + " - getStops() returned an unmodifiable view"); 81 } 82 } 83}
Output:
=== BrokenSchedule - mutation AFTER construction leaks in ===
Before mutation: BrokenSchedule[Route 500, stops=[Indiranagar, MG Road, Majestic]]
After mutation : BrokenSchedule[Route 500, stops=[Indiranagar, MG Road, Majestic, Whitefield]]
'Immutable' object changed - because it shared the caller's list

=== SafeSchedule - mutation AFTER construction has no effect ===
Before mutation: SafeSchedule[Route 500, stops=[Indiranagar, MG Road, Majestic]]
After mutation : SafeSchedule[Route 500, stops=[Indiranagar, MG Road, Majestic]]
Object unchanged - the constructor copied the list

=== getStops() on SafeSchedule also resists mutation ===
Caught: UnsupportedOperationException - getStops() returned an unmodifiable view

BrokenSchedule and SafeSchedule have identical fields, identical constructors apart from one line, and identical-looking getters. The entire difference - whether the class is actually immutable - comes down to that one line: this.stops = stops; versus this.stops = Collections.unmodifiableList(new ArrayList<>(stops));.

with Methods - Returning New Instances Instead of Mutating

Once a class is immutable, any "change" has to produce a new object. The conventional name for a method that does this is withX - it copies every field except the one being changed, and constructs a new instance.

1// File: WithMethodsDemo.java 2 3public class WithMethodsDemo { 4 5 static final class DiscountTier { 6 private final String tierName; 7 private final double discountPercent; 8 private final double minimumOrderValue; 9 10 DiscountTier(String tierName, double discountPercent, double minimumOrderValue) { 11 this.tierName = tierName; 12 this.discountPercent = discountPercent; 13 this.minimumOrderValue = minimumOrderValue; 14 } 15 16 // Each "with" method returns a NEW DiscountTier with ONE field 17 // changed - 'this' is never modified 18 DiscountTier withDiscountPercent(double newDiscountPercent) { 19 return new DiscountTier(tierName, newDiscountPercent, minimumOrderValue); 20 } 21 22 DiscountTier withMinimumOrderValue(double newMinimumOrderValue) { 23 return new DiscountTier(tierName, discountPercent, newMinimumOrderValue); 24 } 25 26 @Override 27 public String toString() { 28 return "DiscountTier[" + tierName + ", " + discountPercent + "%, min=Rs." + minimumOrderValue + "]"; 29 } 30 } 31 32 public static void main(String[] args) { 33 DiscountTier original = new DiscountTier("SILVER", 5.0, 500.0); 34 System.out.println("original : " + original); 35 36 DiscountTier promo = original.withDiscountPercent(10.0).withMinimumOrderValue(750.0); 37 System.out.println("promo : " + promo); 38 39 System.out.println(); 40 System.out.println("=== 'original' is completely unchanged ==="); 41 System.out.println("original : " + original); 42 } 43}
Output:
original  : DiscountTier[SILVER, 5.0%, min=Rs.500.0]
promo     : DiscountTier[SILVER, 10.0%, min=Rs.750.0]

=== 'original' is completely unchanged ===
original  : DiscountTier[SILVER, 5.0%, min=Rs.500.0]

original.withDiscountPercent(10.0).withMinimumOrderValue(750.0) chains two "with" calls - the first produces a new DiscountTier with the updated discount, and the second produces yet another new DiscountTier from that one, with the updated minimum order value. original itself is referenced by neither of these new objects' construction in any way that could affect it - it remains exactly as it was.

Why Immutability Enables Caching, Hashing, and Safe Sharing

Several behaviors elsewhere in the JDK exist specifically because certain types are immutable - they would be incorrect, not just unoptimized, if those types could change after construction.

WHY String CACHES ITS hashCode():
  String computes its hashCode() lazily, on the FIRST call, and stores
  it in a private field for every later call. This caching is ONLY
  correct because String is immutable - if a String's characters could
  change, a cached hashCode would go stale the instant they did, and
  every HashMap or HashSet using that String as a key would then be
  looking in the wrong bucket for it.

WHY A MUTABLE HASHMAP KEY IS DANGEROUS:
  Map<List<String>, String> cache = new HashMap<>();
  List<String> key = new ArrayList<>(List.of("a", "b"));
  cache.put(key, "value");

  key.add("c");                  // mutates the key AFTER insertion

  cache.get(key);                 // returns null
  cache.containsKey(key);         // false
  // hashCode(key) has CHANGED since insertion, so HashMap looks in a
  // DIFFERENT bucket than the one "value" was actually stored in.
  // "value" is still in the map - permanently unreachable through
  // this key.

  An immutable key type makes this entire category of bug structurally
  impossible - hashCode() cannot change after insertion if nothing
  about the object can change at all.

THREAD SAFETY WITHOUT SYNCHRONIZATION:
  Once an immutable object is fully constructed and a reference to it
  is visible to another thread, every field that thread reads reflects
  the value set during construction - there is no "in-progress
  mutation" to observe, because no mutation ever happens after
  construction. This is why immutable objects are the simplest category
  of thread-safe object: there is no shared mutable state to protect,
  so there is no synchronized block to get right.

STRING POOLING:
  String literals are stored in a shared pool - two separate "INR"
  literals in source code can refer to the SAME underlying String
  object, because String guarantees that object's content can never
  change. Sharing a MUTABLE object this way - across unrelated code,
  with no copying - would mean one piece of code could silently change
  data that every other piece of code holding the same reference
  depends on.

Real-World Example - Groww Portfolio Snapshot

An investment app needs to record a user's mutual fund holdings at a point in time - for history charts, audit trails, and "your portfolio one year ago" comparisons - without that historical record ever being at risk of being altered by later code that touches the user's current holdings. A PortfolioSnapshot that is genuinely immutable, with defensive copies on both sides of its holdings map, makes "altered by later code" structurally impossible.

1// File: PortfolioSnapshot.java 2 3import java.time.Instant; 4import java.util.Collections; 5import java.util.HashMap; 6import java.util.Map; 7 8public final class PortfolioSnapshot { 9 10 private final String userId; 11 private final Instant capturedAt; 12 private final Map<String, Integer> holdings; // fund name -> units held 13 14 public PortfolioSnapshot(String userId, Instant capturedAt, Map<String, Integer> holdings) { 15 this.userId = userId; 16 this.capturedAt = capturedAt; 17 // Defensive copy on input, wrapped unmodifiable - this snapshot's 18 // state is independent of whatever map the caller passed in, and 19 // cannot be mutated through this field either 20 this.holdings = Collections.unmodifiableMap(new HashMap<>(holdings)); 21 } 22 23 public String getUserId() { return userId; } 24 public Instant getCapturedAt() { return capturedAt; } 25 26 public Map<String, Integer> getHoldings() { 27 return holdings; // already a copy AND unmodifiable 28 } 29 30 public int totalUnits() { 31 return holdings.values().stream().mapToInt(Integer::intValue).sum(); 32 } 33 34 // "with" method - a NEW snapshot reflecting one updated holding, 35 // leaving THIS snapshot completely unchanged 36 public PortfolioSnapshot withUpdatedHolding(String fundName, int units, Instant capturedAt) { 37 Map<String, Integer> updated = new HashMap<>(this.holdings); 38 updated.put(fundName, units); 39 return new PortfolioSnapshot(this.userId, capturedAt, updated); 40 } 41 42 @Override 43 public String toString() { 44 return "PortfolioSnapshot[userId=" + userId + ", capturedAt=" + capturedAt + ", totalUnits=" + totalUnits() + "]"; 45 } 46}
1// File: PortfolioHistoryService.java 2 3import java.time.Instant; 4import java.util.ArrayList; 5import java.util.HashMap; 6import java.util.List; 7import java.util.Map; 8 9public class PortfolioHistoryService { 10 11 private final List<PortfolioSnapshot> history = new ArrayList<>(); 12 13 public void capture(PortfolioSnapshot snapshot) { 14 history.add(snapshot); 15 } 16 17 public static void main(String[] args) { 18 PortfolioHistoryService service = new PortfolioHistoryService(); 19 20 Map<String, Integer> initialHoldings = new HashMap<>(); 21 initialHoldings.put("Nifty50 Index Fund", 120); 22 initialHoldings.put("Gold ETF", 30); 23 24 PortfolioSnapshot januarySnapshot = new PortfolioSnapshot( 25 "USER-7842", Instant.parse("2026-01-01T00:00:00Z"), initialHoldings); 26 service.capture(januarySnapshot); 27 28 System.out.println("=== January snapshot ==="); 29 System.out.println(januarySnapshot); 30 System.out.println("Gold ETF units: " + januarySnapshot.getHoldings().get("Gold ETF")); 31 32 System.out.println(); 33 34 System.out.println("=== Mutating the ORIGINAL map after construction has no effect ==="); 35 initialHoldings.put("Gold ETF", 999); 36 initialHoldings.put("New Fund", 50); 37 System.out.println(januarySnapshot); 38 System.out.println("Gold ETF units: " + januarySnapshot.getHoldings().get("Gold ETF")); 39 System.out.println("New Fund present? " + januarySnapshot.getHoldings().containsKey("New Fund")); 40 41 System.out.println(); 42 43 System.out.println("=== getHoldings() returns an unmodifiable view ==="); 44 try { 45 januarySnapshot.getHoldings().put("Hacked Fund", 1); 46 } catch (UnsupportedOperationException e) { 47 System.out.println("Caught: " + e.getClass().getSimpleName()); 48 } 49 50 System.out.println(); 51 52 System.out.println("=== withUpdatedHolding() produces a NEW snapshot ==="); 53 PortfolioSnapshot februarySnapshot = januarySnapshot.withUpdatedHolding( 54 "Gold ETF", 45, Instant.parse("2026-02-01T00:00:00Z")); 55 service.capture(februarySnapshot); 56 57 System.out.println("January : " + januarySnapshot); 58 System.out.println("February: " + februarySnapshot); 59 60 System.out.println(); 61 System.out.println("=== History now contains both snapshots, independently ==="); 62 System.out.println("History size: " + service.history.size()); 63 } 64}
Output:
=== January snapshot ===
PortfolioSnapshot[userId=USER-7842, capturedAt=2026-01-01T00:00:00Z, totalUnits=150]
Gold ETF units: 30

=== Mutating the ORIGINAL map after construction has no effect ===
PortfolioSnapshot[userId=USER-7842, capturedAt=2026-01-01T00:00:00Z, totalUnits=150]
Gold ETF units: 30
New Fund present? false

=== getHoldings() returns an unmodifiable view ===
Caught: UnsupportedOperationException

=== withUpdatedHolding() produces a NEW snapshot ===
January : PortfolioSnapshot[userId=USER-7842, capturedAt=2026-01-01T00:00:00Z, totalUnits=150]
February: PortfolioSnapshot[userId=USER-7842, capturedAt=2026-02-01T00:00:00Z, totalUnits=165]

=== History now contains both snapshots, independently ===
History size: 2

Every line of PortfolioSnapshot traces back to one of the five rules: no setters (rule 1), private final fields including the map reference itself (rule 2), final class (rule 3), new HashMap<>(holdings) in the constructor (rule 4), and Collections.unmodifiableMap(...) making the same copy safe to hand out from getHoldings() (rule 5). withUpdatedHolding never touches this.holdings - it builds a fresh map from a copy of it, and hands that to a brand-new PortfolioSnapshot.

Immutable Class vs final Class vs final Fields

What It MeansWhat It PreventsWhat It Does NOT Prevent
final fieldThe field cannot be reassigned after constructionthis.list = anotherList later in any methodthis.list.add(...) - the OBJECT the field refers to can still be mutated
final classThe class cannot be extendedA subclass adding mutable state or overriding behaviorThe class's OWN methods from mutating its own fields' contents, if those fields are mutable types
Immutable classNo object of this class ever has different observable state after constructionBoth of the above, AND mutation of anything reachable from any field, from anywhere, ever-

The middle row is where the most common misconception lives: a class with every field final and the class itself final LOOKS like it satisfies every requirement - and for fields of immutable types, it does. The moment one field is a List, a Date, or an array, final on that field guarantees only that the reference never changes; the object on the other end of that reference is a completely separate question, answered by rules 4 and 5, not by final at all.

Best Practices

Prefer fields of types that are already immutable. String, the boxed primitive wrapper types, LocalDate/LocalDateTime/Instant, BigDecimal, and records (Java 16+) all need no defensive copying when used as fields - rules 4 and 5 simply do not apply to them. A class built entirely from such types satisfies all five rules the moment rules 1 through 3 are satisfied.

Remember that records automate rules 1 through 3, but not 4 and 5. A record gives every component a private final field, no setters, and an implicitly final class - for free. If a record component's type is mutable - a List, an array, a Date - the record's canonical constructor still stores whatever reference it is given, and the generated accessor still returns that same reference, exactly like BrokenSchedule above. A record with a mutable component type needs a compact constructor performing the same defensive copy this article describes - records narrow the gap, they do not remove it.

Validate every invariant in the constructor, and nowhere else. Because an immutable object's state can never be corrected after construction, a constructor that lets invalid data through creates an object that is permanently invalid - there is no setter to call later to fix it. Coupon's discount-percentage check above is the entire validation surface for that class; if it passes, every Coupon that will ever exist with that data is valid, for its whole lifetime.

Provide withX methods for the changes callers actually need, rather than forcing manual reconstruction. A caller that needs "the same DiscountTier, but with a different minimum order value" should not need to read all of DiscountTier's fields and call its full constructor - withMinimumOrderValue(750.0) says what changed, and nothing else, while the class's invariants (whatever they are) still get re-checked by the constructor it delegates to.

Common Mistakes

Mistake 1 - A final Field Referencing a Mutable Object

1import java.util.ArrayList; 2import java.util.List; 3 4// WRONG - 'items' is final, so it can never be REASSIGNED. But it is 5// an ArrayList - its CONTENTS can still change, through this class's 6// own methods or through anyone holding the same reference. 7final class WishlistBroken { 8 private final List<String> items; 9 10 WishlistBroken(List<String> items) { 11 this.items = items; // no copy 12 } 13 14 void addItem(String item) { 15 items.add(item); // mutates the 'final' field's referent directly 16 } 17 18 List<String> getItems() { return items; } 19} 20 21// CORRECT - if the class needs to support adding items, that itself 22// is a sign it should NOT be immutable - OR, if it should be 23// immutable, "adding an item" becomes a "with" method returning a 24// NEW Wishlist with a NEW, larger list 25final class WishlistFixed { 26 private final List<String> items; 27 28 WishlistFixed(List<String> items) { 29 this.items = java.util.Collections.unmodifiableList(new ArrayList<>(items)); 30 } 31 32 WishlistFixed withItem(String item) { 33 List<String> updated = new ArrayList<>(items); 34 updated.add(item); 35 return new WishlistFixed(updated); 36 } 37 38 List<String> getItems() { return items; } 39}

Mistake 2 - A Getter Returning the Internal Mutable Field Directly

1import java.util.HashMap; 2import java.util.Map; 3 4// WRONG - the constructor copies 'attributes' correctly, but 5// getAttributes() then hands out the INTERNAL map directly. A caller 6// of getAttributes() can mutate it, and that mutation affects THIS 7// object's internal state from then on. 8final class ProductBroken { 9 private final Map<String, String> attributes; 10 11 ProductBroken(Map<String, String> attributes) { 12 this.attributes = new HashMap<>(attributes); // copy on input - good 13 } 14 15 Map<String, String> getAttributes() { 16 return attributes; // MUTABLE map, returned directly - bad 17 } 18} 19 20// CORRECT - wrap the internal copy as unmodifiable (or return a 21// further copy) from the getter as well 22final class ProductFixed { 23 private final Map<String, String> attributes; 24 25 ProductFixed(Map<String, String> attributes) { 26 this.attributes = java.util.Collections.unmodifiableMap(new HashMap<>(attributes)); 27 } 28 29 Map<String, String> getAttributes() { 30 return attributes; // already unmodifiable - safe 31 } 32}

Mistake 3 - Storing a java.util.Date Field Without Defensive Copies

1import java.util.Date; 2 3// WRONG - java.util.Date is MUTABLE: it has setTime(), and several 4// deprecated but still-present setters. Storing one directly, and 5// returning it directly, both leak a reference to a mutable object - 6// exactly the same problem as a List or Map field, just less obvious 7// because Date "looks like" a simple value. 8final class EventBroken { 9 private final String name; 10 private final Date scheduledAt; 11 12 EventBroken(String name, Date scheduledAt) { 13 this.name = name; 14 this.scheduledAt = scheduledAt; // no copy 15 } 16 17 Date getScheduledAt() { 18 return scheduledAt; // returns the mutable Date directly 19 } 20} 21 22// CORRECT - copy on input AND output, using Date's millisecond value 23final class EventFixed { 24 private final String name; 25 private final Date scheduledAt; 26 27 EventFixed(String name, Date scheduledAt) { 28 this.name = name; 29 this.scheduledAt = new Date(scheduledAt.getTime()); // copy on input 30 } 31 32 Date getScheduledAt() { 33 return new Date(scheduledAt.getTime()); // copy on output 34 } 35} 36 37// BEST - avoid java.util.Date entirely. java.time.Instant, 38// java.time.LocalDate, and java.time.LocalDateTime are all immutable, 39// so neither copy above is needed in the first place 40final class EventBest { 41 private final String name; 42 private final java.time.Instant scheduledAt; 43 44 EventBest(String name, java.time.Instant scheduledAt) { 45 this.name = name; 46 this.scheduledAt = scheduledAt; // Instant is immutable - safe as-is 47 } 48 49 java.time.Instant getScheduledAt() { return scheduledAt; } 50}

Mistake 4 - Assuming a Record With a Mutable Component Is Fully Immutable

1import java.util.List; 2import java.util.ArrayList; 3 4// WRONG - 'tags' is a List, a mutable type. The record gives 'tags' a 5// private final FIELD - the reference cannot be reassigned - but the 6// canonical constructor stores whatever List reference it is given, 7// and the generated accessor tags() returns that same reference. 8record ProductBroken(String name, List<String> tags) {} 9 10List<String> mutableTags = new ArrayList<>(List.of("sale", "trending")); 11ProductBroken product = new ProductBroken("Shoes", mutableTags); 12 13mutableTags.add("clearance"); // mutates the SHARED list 14product.tags().add("flash-sale"); // mutates it AGAIN, via the accessor 15// product.tags() now reflects BOTH external mutations 16 17// CORRECT - a compact constructor performing the SAME defensive copy 18// this article has used throughout, plus an overridden accessor 19record ProductFixed(String name, List<String> tags) { 20 ProductFixed { 21 tags = List.copyOf(tags); // defensive copy on input, unmodifiable 22 } 23 // List.copyOf() already returns an unmodifiable list, so the 24 // generated accessor needs no further change for output safety 25}

Interview Questions

Q1. What is an immutable class, and what are the core rules for creating one?

An immutable class is one where every object has the same observable state for its entire lifetime - no field, and nothing reachable from any field, ever changes after construction. The core rules: provide no mutator methods; make every field private and final; make the class final (or otherwise prevent subclasses from breaking the guarantee); make a defensive copy of any mutable object passed into the constructor before storing it; and make a defensive copy (or return an unmodifiable view of the internal copy) of any mutable object returned from a getter. The first three rules are sufficient when every field's type is itself immutable; the last two become necessary the moment any field's type is mutable.

Q2. Why is making a field final not sufficient to make a class immutable?

final on a field guarantees only that the field cannot be reassigned after the constructor runs - this.list = anotherList later in the class would be a compile error. It says nothing about the object the field refers to. If that object is a List, an array, or a Date, its contents can still be changed - through the class's own methods calling mutators on it, or through any external code that holds a reference to the same object, whether because the constructor stored the caller's reference directly or because a getter returned the internal reference directly. final controls the field; defensive copies control the object.

Q3. What is a defensive copy, and why are two needed - one for input, one for output?

A defensive copy is a new, independent object containing the same data as a mutable object the class has been given, or is about to give out - created specifically so that no one outside the class holds a reference to the same object the class itself holds. The input copy, made in the constructor, ensures the caller's continued access to the object they passed in cannot affect this object's state afterward. The output copy, made in (or before) any getter that would otherwise return a mutable field, ensures that a caller of the getter cannot affect this object's state by mutating what they were handed. Either one alone leaves a path for external mutation; both together close it.

Q4. Why does String cache its hashCode, and why would this be unsafe for a mutable class?

String.hashCode() computes the hash once, on first use, and stores it in a field for all subsequent calls - this is correct only because a String's characters can never change, so the cached value can never become wrong. A mutable class caching its hashCode() the same way would produce a stale value the moment any field used in the hash computation changed - and that stale value could then cause HashMap and HashSet to look in the wrong bucket for an object whose hash code no longer matches where it was originally stored, effectively losing the object inside the collection without it being removed.

Q5. How do records relate to immutability - do they provide it automatically?

Records automate the first three rules of immutability: every component becomes a private final field, no setters are generated, and every record is implicitly final. For components of already-immutable types, this is the entire job - the record is fully immutable with zero extra code. For components of mutable types - List, Map, arrays, Date - records do NOT automatically apply rules 4 and 5; the canonical constructor stores whatever reference it receives, and the generated accessor returns that same reference, exactly as a hand-written class without defensive copies would. A compact constructor performing the same copy described in this article - often using List.copyOf() or similar - is still required for genuine immutability in that case.

Q6. Why are immutable objects considered thread-safe without synchronization?

Thread-safety problems arise from multiple threads observing or modifying shared mutable state at the same time - one thread reading a value while another is in the middle of changing it, or two threads changing it simultaneously with one update lost. An immutable object has no state that ever changes after construction, so there is no "in the middle of changing it" for any thread to observe, and no concurrent modification possible at all - any number of threads reading the same immutable object at the same time simply read the same, unchanging values. This eliminates the entire category of bug that synchronized, locks, and volatile exist to prevent, for that object - not by handling the race correctly, but by ensuring there is no race to handle.

FAQs

Is making a class final required for immutability?

final on the class is the standard way to satisfy rule 3 - preventing a subclass from adding mutable state or overriding a method in a way that breaks the guarantee. It is not the ONLY way: a class with all-private constructors (so it can only be instantiated through static factory methods, and never subclassed outside the file) achieves the same goal without the final keyword itself. In practice, final is by far the simplest and most common approach, and records satisfy this rule automatically by being implicitly final.

Can an immutable class have mutable fields if they're never exposed?

If a field's type is mutable but the field is never read from outside the constructor (used only internally, during construction, to compute other final fields, and then discarded), it does not threaten immutability - because nothing about the object's observable state depends on it after construction. However, if the mutable object is STORED as a field and reachable later - even if no getter directly returns it, but it is used by other methods that could expose its state indirectly - the same defensive-copy reasoning applies. The safest framing: any field whose type is mutable needs rules 4 and 5 considered, regardless of whether a getter for it exists.

Why is java.util.Date often cited as a mistake in immutable class design?

Because Date looks like a simple value - "a point in time" - but has mutator methods (setTime(), and older deprecated setters for year, month, and so on) that most developers do not expect a "value type" to have. A field of type Date, stored and returned without defensive copies, is exactly as exposed to external mutation as a field of type List - the mistake is identical, but Date disguises it better, which is why this specific case shows up so often in both real bugs and interview questions. java.time.Instant, LocalDate, and LocalDateTime are immutable replacements that avoid the issue entirely.

Do immutable objects use more memory because of with methods creating new instances?

Each withX call does allocate a new object, where a mutable design would modify one object in place - so yes, there is more allocation. In practice, for the kinds of objects immutability is recommended for (configuration snapshots, value types, DTOs passed between layers), these objects tend to be small and short-lived, and the JVM's generational garbage collector is specifically optimized for large numbers of small, short-lived objects. The cost is real but is rarely the bottleneck it might seem; the debugging time saved by eliminating "who mutated this and when" questions is the trade typically being made.

Can an immutable class be serialized safely?

Yes, and immutable classes are generally easier to serialize correctly than mutable ones - there is no risk of serializing an object mid-mutation, and deserialization producing a fully-formed, valid object in one step matches how immutable objects are meant to be constructed. One detail worth checking for hand-written immutable classes (as opposed to records, which the JDK has updated serialization support for) is that the deserialization process bypasses the normal constructor by default - if the constructor performs validation or defensive copying that matters for correctness, a readObject method may be needed to re-apply it during deserialization too.

How do immutable objects work with equals() and hashCode() in collections like HashSet?

Immutable objects are close to ideal HashSet elements and HashMap keys specifically because their hashCode() cannot change after insertion - the object will always be found in the bucket corresponding to the hash code it had when it was added, because that hash code can never become outdated. Coupon's equals() and hashCode() in the example above are based on code, discountPercent, and expiryDate - none of which can change after construction, so two Coupon objects that are equal() today will remain equal() (and hash identically) for as long as both objects exist, which is exactly the contract HashSet and HashMap rely on.

Summary

An immutable class guarantees that every object of that class has the same state for its entire lifetime - and that guarantee is built from five rules working together, not any one of them alone. No setters and final fields handle the easy case - fields of types that cannot themselves be mutated. The moment a field's type IS mutable - a List, a Map, an array, or java.util.Date - defensive copies on both input and output are what close the remaining gap, and skipping either one leaves a class that looks immutable while still being fully mutable through a side door.

Records automate the easy case completely and remove most of the boilerplate from the hard case, but do not remove the defensive-copying decision itself - a record with a List component still needs a compact constructor copying it, for exactly the reason a hand-written class would.

The question worth asking about any class whose objects are meant to represent a fixed value: for every field, is its type something that can be mutated after construction - and if so, does this class's constructor and getters ever hand out a reference to that exact object, to or from the outside? If the answer to the second question is ever yes, the class is not immutable yet, regardless of how many final keywords it has.

What to Read Next