Skip to main content

Command Palette

Search for a command to run...

Java - Mutable vs Immutable Objects

Interview Deep Dive

Updated
16 min read

"If you can't explain immutability under pressure, you don't understand it well enough."

Mutability is one of the most deceptively simple topics in Java. It touches memory layout, thread safety, API design, security, and GC performance. This article covers 25+ curated interview questions — each with working code, detailed explanations, and the reasoning interviewers are looking for.


Core Concepts

What Makes an Object Mutable or Immutable?

The Immutability Checklist

Mutable vs Immutable — At a Glance

Aspect Mutable Immutable
State after creation Can change Never changes
Thread safety Requires synchronization Inherently thread-safe
HashMap key safety ⚠️ Dangerous ✅ Safe
Memory pattern Modify in-place Create new copies
JDK examples StringBuilder, ArrayList, Date String, Integer, LocalDate
Caching Risky (shared state) Safe (can freely share)
GC pressure Lower (reuse) Higher (new objects per change)

Beginner Level

Q1: What is a mutable object? What is an immutable object?

A: A mutable object can have its state changed after construction. An immutable object's state is fixed at creation — any "modification" returns a new object.

// MUTABLE — state changes in-place
StringBuilder sb = new StringBuilder("hello");
sb.append(" world");  // sb itself is modified
System.out.println(sb); // "hello world"

// IMMUTABLE — original is untouched, new object created
String s = "hello";
String s2 = s.concat(" world");  // Returns NEW String
System.out.println(s);  // "hello" — unchanged!
System.out.println(s2); // "hello world"

Q2: Name all immutable classes in the JDK.

A: The main ones:

Category Immutable Classes
Wrappers Integer, Long, Double, Float, Short, Byte, Character, Boolean
Strings String
Math BigInteger, BigDecimal
Time (java.time) LocalDate, LocalTime, LocalDateTime, Instant, Duration, Period, ZonedDateTime
Collections List.of(), Set.of(), Map.of() return unmodifiable collections
Other Optional, URI, URL, UUID, Pattern, InetAddress
Records All record classes (Java 16+)

Q3: Why is String immutable in Java?

A: Three critical reasons:

  1. String Pool / Interning — The JVM deduplicates identical strings. If strings were mutable, changing one would corrupt all references sharing the same pooled instance.

  2. Security — Strings are used for class loading, file paths, network connections, and credentials. Immutability prevents tampering after validation.

  3. Thread safety — Strings can be freely shared across threads without synchronization.

  4. hashCode cachingString caches its hashCode on first computation. This is safe only because the content never changes.

String a = "hello";
String b = "hello";
System.out.println(a == b); // true — same pooled instance

// If String were mutable:
// a.setChar(0, 'H'); // Would corrupt b as well!

Q4: What is the difference between String, StringBuilder, and StringBuffer?

A:

Feature String StringBuilder StringBuffer
Mutability Immutable Mutable Mutable
Thread-safe Yes (immutable) No Yes (synchronized)
Performance Slow for concat Fast Slower than StringBuilder
Since JDK 1.0 JDK 1.5 JDK 1.0
// ❌ Creates ~1000 intermediate String objects
String result = "";
for (int i = 0; i < 1000; i++) {
    result += i;  // Each += creates a NEW String
}

// ✅ Mutates a single buffer — O(n) vs O(n²)
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++) {
    sb.append(i);
}
String result = sb.toString();

Q5: How do you create an immutable class in Java?

A: Follow all five rules:

// 1. Class is final — prevents subclassing
public final class Money {
    // 2. All fields are private and final
    private final BigDecimal amount;
    private final Currency currency;

    // 3. Constructor performs defensive copy of mutable inputs
    public Money(BigDecimal amount, Currency currency) {
        this.amount = amount;  // BigDecimal is already immutable
        this.currency = currency;  // Currency is already immutable
    }

    // 4. No setters — only getters
    public BigDecimal getAmount() { return amount; }
    public Currency getCurrency() { return currency; }

    // 5. "Modification" returns a new object
    public Money add(Money other) {
        if (!this.currency.equals(other.currency)) {
            throw new IllegalArgumentException("Currency mismatch");
        }
        return new Money(this.amount.add(other.amount), this.currency);
    }
}

Intermediate Level

Q6: What is a defensive copy? When is it needed?

A: A defensive copy creates a new independent copy of a mutable object to prevent external code from modifying an immutable object's internal state.

// ❌ BROKEN immutability — Date is mutable!
public final class Event {
    private final Date startDate;

    public Event(Date startDate) {
        this.startDate = startDate;  // Stores the SAME reference
    }

    public Date getStartDate() {
        return startDate;  // Leaks the SAME reference
    }
}

// Attack:
Date date = new Date();
Event event = new Event(date);
date.setYear(2099);  // Mutates event's internal state!
event.getStartDate().setYear(1900);  // Also mutates it!

// ✅ CORRECT — defensive copies on BOTH sides
public final class Event {
    private final Date startDate;

    public Event(Date startDate) {
        this.startDate = new Date(startDate.getTime());  // Copy IN
    }

    public Date getStartDate() {
        return new Date(startDate.getTime());  // Copy OUT
    }
}

Key rule: Defensive copy in the constructor, defensive copy in the getter. Miss either and immutability is broken.


Q7: Are Java Records truly immutable?

A: Records are shallowly immutable — their fields are final, but if a field references a mutable object, that object can still be modified.

// Shallowly immutable — the List field is mutable!
record Team(String name, List<String> members) {}

Team team = new Team("Alpha", new ArrayList<>(List.of("Alice", "Bob")));
team.members().add("Charlie");  // ⚠️ Mutates the record's data!
System.out.println(team.members()); // [Alice, Bob, Charlie]

// ✅ Fix: defensive copy in compact constructor
record Team(String name, List<String> members) {
    Team {
        members = List.copyOf(members);  // Unmodifiable copy
    }
}

Q8: What happens when you use a mutable object as a HashMap key?

A: The HashMap breaks. When the key's hashCode changes after insertion, the entry becomes unretrievable — it's in the wrong bucket.

public class MutableKey {
    private String name;

    public MutableKey(String name) { this.name = name; }
    public void setName(String name) { this.name = name; }

    @Override
    public int hashCode() { return name.hashCode(); }

    @Override
    public boolean equals(Object o) {
        return o instanceof MutableKey mk && name.equals(mk.name);
    }
}

Map<MutableKey, String> map = new HashMap<>();
MutableKey key = new MutableKey("alice");
map.put(key, "value");

System.out.println(map.get(key)); // "value" ✅

key.setName("bob");  // ⚠️ Mutate the key!

System.out.println(map.get(key)); // null ❌ — lost!
System.out.println(map.size());   // 1 — entry exists but unreachable

// Even creating a NEW key with the original value doesn't help:
System.out.println(map.get(new MutableKey("alice"))); // null ❌
// The entry is in "alice"'s bucket, but equals() now checks "bob"

Rule: Always use immutable objects as HashMap/HashSet keys.


Q9: How does immutability relate to thread safety?

A: Immutable objects are inherently thread-safe because there's no shared mutable state to synchronize.

// MUTABLE — needs synchronization
class MutableCounter {
    private int count = 0;

    // Without synchronized, this is a race condition
    public synchronized void increment() { count++; }
    public synchronized int getCount() { return count; }
}

// IMMUTABLE — no synchronization needed
final class ImmutableCounter {
    private final int count;

    public ImmutableCounter(int count) { this.count = count; }
    public int getCount() { return count; }
    public ImmutableCounter increment() {
        return new ImmutableCounter(count + 1);  // New object
    }
}

// Used across threads without locks:
// AtomicReference<ImmutableCounter> ref = new AtomicReference<>(new ImmutableCounter(0));
// ref.updateAndGet(ImmutableCounter::increment);

Q10: What is the String interning pool? How does immutability enable it?

A: The JVM maintains a pool of unique String instances. Identical string literals share the same object. This is only safe because String is immutable.

String a = "hello";            // Goes to pool
String b = "hello";            // Reuses same pooled instance
String c = new String("hello"); // Separate heap object
String d = c.intern();         // Returns pooled instance

System.out.println(a == b);    // true  — same pool entry
System.out.println(a == c);    // false — c is on heap
System.out.println(a == d);    // true  — intern() returns pool entry

Q11: What is the difference between unmodifiable and immutable collections?

A: An unmodifiable wrapper prevents modification through that reference, but the underlying collection can still change. A truly immutable collection can never change.

// UNMODIFIABLE — wrapper, not a copy
List<String> original = new ArrayList<>(List.of("a", "b"));
List<String> unmodifiable = Collections.unmodifiableList(original);

// unmodifiable.add("c");  // ❌ UnsupportedOperationException
original.add("c");         // ✅ Modifies the original
System.out.println(unmodifiable); // [a, b, c] — ⚠️ changed!

// IMMUTABLE — true copy, no back-door
List<String> immutable = List.copyOf(original);
original.add("d");
System.out.println(immutable); // [a, b, c] — unchanged ✅
Method Creates copy? Truly immutable?
Collections.unmodifiableList()
List.of() N/A (factory)
List.copyOf()

Advanced Level

Q12: Explain the final keyword's role in immutability. Does final make an object immutable?

A: No. final on a variable means the reference cannot be reassigned. The object it points to can still be mutated.

final List<String> list = new ArrayList<>();
// list = new ArrayList<>();  // ❌ Can't reassign reference
list.add("hello");             // ✅ Can mutate the object!

final StringBuilder sb = new StringBuilder("hello");
sb.append(" world");           // ✅ Object mutated through final reference

final is necessary but not sufficient for immutability:

  • final fields → prevents reassignment (needed)

  • final class → prevents subclassing (needed)

  • But you still need: no setters, defensive copies, and no mutable field leaks


Q13: Can you break immutability using reflection?

A: Yes. Reflection can bypass final field protections:

String s = "hello";
Field valueField = String.class.getDeclaredField("value");
valueField.setAccessible(true);
valueField.set(s, "HACKED".getBytes());

System.out.println(s); // "HACKED" — immutability broken!

// Even worse — this corrupts the string pool:
String pooled = "hello";
System.out.println(pooled); // "HACKED" — all references affected!

Note: Java 9+ module system (--illegal-access=deny) and Java 17+ strong encapsulation make this harder but not impossible with --add-opens.


Q14: What is the Flyweight pattern and how does immutability enable it?

A: The Flyweight pattern shares immutable objects to reduce memory. Java uses this for Integer caching (-128 to 127) and Boolean.TRUE/Boolean.FALSE.

Integer a = 127;
Integer b = 127;
System.out.println(a == b);  // true — cached (same object)

Integer c = 128;
Integer d = 128;
System.out.println(c == d);  // false — outside cache range

// Boolean — always cached
Boolean t1 = Boolean.valueOf(true);
Boolean t2 = Boolean.valueOf(true);
System.out.println(t1 == t2); // true — same singleton

This caching is only safe because Integer and Boolean are immutable.


Q15: How does immutability affect garbage collection?

A: Immutability creates a trade-off:

Pros for GC:

  • Short-lived immutable objects (young gen) are collected cheaply

  • No need to track mutations (write barriers are simpler)

  • Safe for string deduplication (-XX:+UseStringDeduplication)

Cons for GC:

  • Each "modification" creates a new object → higher allocation rate

  • More GC pressure under high-throughput mutation workloads

// ❌ High GC pressure — creates 10,000 String objects
String result = "";
for (int i = 0; i < 10_000; i++) {
    result = result + i;  // New String each iteration
}

// ✅ Low GC pressure — one mutable buffer
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10_000; i++) {
    sb.append(i);  // Mutates in-place
}

Q16: What is a "effectively immutable" object? How does the JMM treat it?

A: An object whose fields are never modified after construction (even if not declared final) is "effectively immutable." The Java Memory Model guarantees safe publication only for truly immutable objects (all final fields). Effectively immutable objects need explicit safe publication.

// Truly immutable — JMM guarantees safe publication
final class SafePoint {
    private final int x, y;
    SafePoint(int x, int y) { this.x = x; this.y = y; }
}

// Effectively immutable — NOT guaranteed safe without volatile/sync
class UnsafePoint {
    private int x, y;  // Not final!
    UnsafePoint(int x, int y) { this.x = x; this.y = y; }
    // Never modified after construction, but JMM doesn't know that
}

// Safe publication for effectively immutable objects:
volatile UnsafePoint point = new UnsafePoint(1, 2); // ✅ volatile

Q17: Design an immutable class with a mutable collection field.

A:

public final class StudentRoster {
    private final String className;
    private final List<String> students;
    private final Map<String, Integer> grades;

    public StudentRoster(String className, List<String> students,
                         Map<String, Integer> grades) {
        this.className = className;
        // Defensive copy: create independent unmodifiable copies
        this.students = List.copyOf(students);
        this.grades = Map.copyOf(grades);
    }

    public String getClassName() { return className; }
    public List<String> getStudents() { return students; }  // Already unmodifiable
    public Map<String, Integer> getGrades() { return grades; }  // Already unmodifiable

    // "Modification" returns new instance
    public StudentRoster addStudent(String name, int grade) {
        var newStudents = new ArrayList<>(students);
        newStudents.add(name);
        var newGrades = new HashMap<>(grades);
        newGrades.put(name, grade);
        return new StudentRoster(className, newStudents, newGrades);
    }
}

Brain Teasers & Trick Questions

Q18: What is the output of this code?

String s1 = "hello";
String s2 = "hel" + "lo";
String s3 = "hel";
String s4 = s3 + "lo";

System.out.println(s1 == s2);
System.out.println(s1 == s4);
System.out.println(s1 == s4.intern());

A:

true   — compile-time constant folding: "hel" + "lo" → "hello" (same pool entry)
false  — s3 is a variable, so s3 + "lo" uses StringBuilder at runtime → new heap object
true   — intern() returns the pool entry

Q19: Is BigDecimal truly immutable? What about this code?

BigDecimal bd = new BigDecimal("1.0");
BigDecimal bd2 = bd.setScale(2);

System.out.println(bd);
System.out.println(bd2);
System.out.println(bd == bd2);

A: Yes, BigDecimal is immutable. setScale() returns a new BigDecimal:

1.0
1.00
false

The method name setScale is misleading — it doesn't mutate; it returns a new instance. This is a common interview trick.


Q20: Can an immutable object contain a reference to a mutable object and still be safe?

A: Only if the mutable object is never exposed and never modified after construction.

// ❌ UNSAFE — leaks the mutable Date
public final class Broken {
    private final Date created;
    public Broken() { this.created = new Date(); }
    public Date getCreated() { return created; }  // Leaks!
}

// ✅ SAFE — uses immutable java.time instead
public final class Fixed {
    private final Instant created;
    public Fixed() { this.created = Instant.now(); }
    public Instant getCreated() { return created; }  // Instant is immutable
}

Best practice: Prefer immutable field types (Instant over Date, List.of() over ArrayList).


Q21: What happens here? Does the array make this class mutable?

public final class Scores {
    private final int[] values;

    public Scores(int[] values) {
        this.values = values.clone();  // Defensive copy ✅
    }

    public int[] getValues() {
        return values;  // ⚠️ Is this safe?
    }
}

A: No! The getter leaks the internal array. Arrays are always mutable in Java.

Scores s = new Scores(new int[]{90, 85, 95});
int[] leaked = s.getValues();
leaked[0] = 0;  // Mutates internal state!
System.out.println(s.getValues()[0]); // 0 — broken!

Fix: Return a defensive copy:

public int[] getValues() {
    return values.clone();  // Defensive copy OUT
}

Q22: What is the output?

String s = "Java";
s.concat(" Rocks");
s.toUpperCase();
s.replace('a', 'o');

System.out.println(s);

A: Output is Java. Every method on String returns a new String — the original is never modified. The return values are discarded.

This is one of the most common interview tricks. Many candidates expect "JOVO Rocks".


Q23: Why does this immutable class break in a multi-threaded context?

public final class ImmutableHolder {
    private final int x;
    private final int y;

    public ImmutableHolder(int x, int y) {
        this.x = x;
        // Leaking 'this' before construction completes:
        SomeRegistry.register(this);
        this.y = y;
    }
}

A: The constructor leaks this before y is assigned. Another thread accessing the registered object may see y = 0 (default value) instead of the intended value. This violates the JMM's final field semantics — the guarantee only holds if this doesn't escape during construction.

Rule: Never let this escape from a constructor.


Code Analysis Questions

Q24: Design a thread-safe mutable counter vs an immutable counter. Which is better when?

// Approach 1: Mutable + synchronized
class SyncCounter {
    private int count = 0;
    public synchronized void increment() { count++; }
    public synchronized int get() { return count; }
}

// Approach 2: Mutable + atomic
class AtomicCounter {
    private final AtomicInteger count = new AtomicInteger(0);
    public void increment() { count.incrementAndGet(); }
    public int get() { return count.get(); }
}

// Approach 3: Immutable + CAS
final class ImmutableState {
    final int count;
    final String label;
    ImmutableState(int count, String label) {
        this.count = count;
        this.label = label;
    }
}

class StateHolder {
    private final AtomicReference<ImmutableState> state =
        new AtomicReference<>(new ImmutableState(0, "init"));

    public void increment() {
        state.updateAndGet(s -> new ImmutableState(s.count + 1, s.label));
    }
}
Approach Best When
synchronized Simple, low contention
AtomicInteger Single field, high contention
Immutable + AtomicReference Multiple fields that must change atomically

Q25: What is the copy-on-write pattern?

A: A hybrid approach: shared immutable state for reads, copy + mutate for writes. Used by CopyOnWriteArrayList.

// Simplified copy-on-write pattern
public class CowList<E> {
    private volatile Object[] elements = new Object[0];

    public E get(int index) {
        return (E) elements[index];  // Lock-free read
    }

    public synchronized void add(E element) {
        Object[] newArray = Arrays.copyOf(elements, elements.length + 1);
        newArray[elements.length] = element;
        elements = newArray;  // Atomic swap via volatile
    }
}

Java - Basics

Part 1 of 1