LLD
Machine Coding
Interview Course
Java โ€ข Interview Prep
๐Ÿ”„ Concurrency Deep Dive/

Immutability: The Simplest Thread Safety

Lesson 1 of 8

Immutability: The Simplest Thread Safety


Before We Start โ€” What You Need to Know

Before we dive into concurrency, let's make sure we're on the same page with a few basics:

  • Thread: Think of a thread as a worker inside your program. Your main() method runs on one thread, but you can create more workers that run code *at the same time*.
  • Shared state: When two threads can both read and write the same variable or object, that's shared state. Shared state is where almost every concurrency bug comes from.
  • Race condition: When two threads try to modify the same data simultaneously, the final result depends on *which thread happens to run first* โ€” that's a race condition, and it's unpredictable.

This lesson is your first step. We're going to learn the easiest way to make code thread-safe โ€” by making objects that simply *cannot* be changed after creation.


What is it? (The Analogy)

Imagine you're a teacher handing out a printed exam paper to 30 students. Once the paper is printed, nobody โ€” not you, not the students, not the principal โ€” can change the questions on it. Every student gets the exact same questions, and nobody can mess up another student's copy.

Now imagine instead you wrote the questions on a whiteboard, and all 30 students are reading from the same board. If someone accidentally erases a question while others are still reading โ€” chaos! That whiteboard is *mutable shared state*, and the printed paper is *immutable*.

Immutability means: once an object is created, it can never be changed. No setter methods, no modifying fields, no sneaky workarounds. If you need a "modified" version, you create a brand-new object. This is the simplest and most powerful way to avoid threading bugs, because if nobody can change the data, nobody can corrupt it.


The Problem It Solves

Here's what goes wrong when objects are mutable and shared across threads:

java
1// BROKEN: Mutable class shared between threads
2class GameScore {
3    private int homeScore;
4    private int awayScore;
5
6    public void setHomeScore(int score) { this.homeScore = score; }
7    public void setAwayScore(int score) { this.awayScore = score; }
8    public int getHomeScore() { return homeScore; }
9    public int getAwayScore() { return awayScore; }
10
11    public String toString() {
12        return homeScore + " - " + awayScore;
13    }
14}
15
16// Thread 1: Updating the score to 3-2
17// Thread 2: Reading the score for display
18GameScore score = new GameScore();
19
20// Thread 1 does:
21score.setHomeScore(3);
22// << Thread 2 reads here and sees 3-0 (WRONG! away score not updated yet)
23score.setAwayScore(2);

What went wrong? Thread 2 read the score *between* the two updates. It saw 3-0 instead of 3-2. This is called a torn read โ€” you see a half-updated object that was never a valid state.

With mutable objects, you'd need locks, synchronized blocks, or other complex machinery to fix this. But with immutability, the problem simply *cannot exist*.


How it works โ€” Step by Step

  1. **Declare the class final** โ€” so nobody can subclass it and add mutable behavior.
  2. **Make all fields private final** โ€” fields are set once in the constructor and never again.
  3. No setter methods โ€” if there's no way to change a field, it can't be corrupted.
  4. Deep-copy mutable inputs โ€” if the constructor receives a mutable object (like a List or Date), make a copy so the caller can't change it from the outside.
  5. Deep-copy mutable outputs โ€” if a getter returns a mutable object, return a copy so the caller can't change your internals.
  6. Set all fields in the constructor โ€” the object is fully formed the moment it's created.

Aha! Once an immutable object is fully constructed, it's safe to share across any number of threads with zero synchronization. No synchronized, no volatile, no locks โ€” nothing. The Java Memory Model guarantees that all threads will see the correct, fully-constructed state of a final field.


Let's Build It Together

Let's build an immutable FoodOrder class for a delivery app โ€” step by step:

java
1import java.util.List;
2import java.util.ArrayList;
3import java.util.Collections;
4
5// Step 1: Make the class final โ€” no sneaky subclasses
6public final class FoodOrder {
7
8    // Step 2: All fields are private and final
9    private final String orderId;
10    private final String customerName;
11    private final List<String> items;       // Careful โ€” List is mutable!
12    private final double totalPrice;
13    private final long createdAtMillis;
14
15    // Step 3: Constructor sets everything โ€” object is born complete
16    public FoodOrder(String orderId, String customerName,
17                     List<String> items, double totalPrice) {
18        this.orderId = orderId;
19        this.customerName = customerName;
20        // Step 4: DEEP COPY the mutable list!
21        // If we just did this.items = items, the caller could
22        // modify their list and corrupt our "immutable" object.
23        this.items = new ArrayList<>(items);
24        this.totalPrice = totalPrice;
25        this.createdAtMillis = System.currentTimeMillis();
26    }
27
28    // Step 5: Getters only โ€” no setters anywhere
29    public String getOrderId() { return orderId; }
30    public String getCustomerName() { return customerName; }
31
32    // Step 6: Return an UNMODIFIABLE view of the list
33    // If we returned the raw list, callers could do getItems().add("hack")
34    public List<String> getItems() {
35        return Collections.unmodifiableList(items);
36    }
37
38    public double getTotalPrice() { return totalPrice; }
39    public long getCreatedAtMillis() { return createdAtMillis; }
40
41    // Want to "modify" an order? Create a new one!
42    public FoodOrder withExtraItem(String item) {
43        List<String> newItems = new ArrayList<>(this.items);
44        newItems.add(item);
45        return new FoodOrder(this.orderId, this.customerName,
46                             newItems, this.totalPrice + 2.99);
47    }
48
49    @Override
50    public String toString() {
51        return "Order " + orderId + " for " + customerName
52             + ": " + items + " ($" + totalPrice + ")";
53    }
54}

Now let's use it in a multithreaded scenario:

java
1public class DeliveryApp {
2    public static void main(String[] args) throws InterruptedException {
3        // Create an immutable order
4        FoodOrder order = new FoodOrder(
5            "ORD-42", "Alice",
6            List.of("Pizza", "Garlic Bread"), 18.99
7        );
8
9        // Share it across 10 threads โ€” NO synchronization needed!
10        for (int i = 0; i < 10; i++) {
11            final int threadNum = i;
12            new Thread(() -> {
13                // Every thread sees the exact same, complete order
14                System.out.println("Thread " + threadNum
15                    + " sees: " + order);
16                // Nobody can corrupt it โ€” there are no setters!
17            }).start();
18        }
19
20        // "Modify" by creating a new object
21        FoodOrder upgraded = order.withExtraItem("Tiramisu");
22        System.out.println("Original: " + order);  // unchanged!
23        System.out.println("Upgraded: " + upgraded); // new object
24    }
25}

What Happens Under the Hood

Why is immutability thread-safe at the hardware level? Here's what's really going on:

  1. CPU caches: Modern CPUs have their own caches (L1, L2, L3). When Thread A writes to a variable, the new value might sit in Thread A's CPU cache and *not* be visible to Thread B on a different core. This is called a visibility problem.
  1. Instruction reordering: The CPU and the JVM compiler can reorder instructions for performance. So even if your code says "set X then set Y," the CPU might do Y first. With mutable objects, this means another thread might see a half-constructed state.
  1. **The final field guarantee: The Java Memory Model (JLS 17.5) has a special rule: when a constructor finishes, all final fields are guaranteed to be visible to any thread that can see the object.** The JVM inserts a memory barrier (called a *store-store fence*) at the end of the constructor. This fence forces all final field writes to be flushed from the CPU cache to main memory before the object reference is published.
  1. No mutation = no race: If nobody ever writes to the object again, there can never be a conflict between readers. You don't need synchronized, volatile, or any locks.

Danger zone: This guarantee *only* works if the this reference doesn't escape the constructor. If you pass this to another thread inside the constructor (e.g., registering a listener), the other thread might see a partially-constructed object even with final fields.


When to Use vs When NOT to Use

Use Immutability WhenDon't Use Immutability When
Objects are shared across threadsYou need to update the object millions of times per second (too many allocations)
You want zero-overhead thread safetyThe object is very large and copying is expensive
Value objects: Money, Coordinates, ConfigYou need a mutable accumulator (use AtomicInteger instead)
Keys in HashMaps or elements in HashSetsThe object is only used by one thread (confinement is simpler)
Data transfer between layers (DTOs)You need circular references
Caching โ€” immutable objects are safely cacheable

Common Mistakes & Gotchas

  • Forgetting to deep-copy mutable fields. If your constructor does this.items = items, the caller still holds a reference to the same list and can mutate it. Always copy: this.items = new ArrayList<>(items).
  • Returning mutable internals from getters. Returning the raw List lets callers do getItems().add("hack"). Return Collections.unmodifiableList(items) or List.copyOf(items).
  • **Using Date or Calendar fields.** These are mutable! Use java.time.Instant or java.time.LocalDateTime instead โ€” they're already immutable.
  • **Thinking final means deeply immutable.** final List<String> items means you can't reassign items to a different list, but you can still do items.add("oops"). final protects the *reference*, not the *contents*.
  • **Confusing String immutability with wrapper safety.** String is immutable in Java (great!), but StringBuilder is not. Know the difference.

Interview Tip

Interviewers love to ask: *"How would you make this class thread-safe?"* The strongest first answer is: "Make it immutable." It shows you understand that the simplest concurrency solution is to remove the problem entirely. Then explain that you'd use final fields, deep copies for mutable inputs/outputs, and no setters. If immutability isn't practical (the object must be updated frequently), *then* discuss locks, atomics, or other strategies. Starting with immutability shows maturity โ€” locks are a last resort, not a first instinct.


Quick Quiz

  1. You have a class with private final List<String> names. A getter returns this.names directly. Is this truly immutable? Why or why not?
  1. Why does Java's String class not need any synchronization to be used across threads?
  1. If you need to "update" an immutable BankAccount (change the balance), how do you do it without setters?

Summary โ€” Key Takeaways

  • Immutable objects cannot be modified after creation โ€” no setters, all fields final, deep-copy mutable inputs and outputs.
  • Immutable objects are automatically thread-safe with zero synchronization overhead, thanks to the Java Memory Model's final field guarantee.
  • Use the "withX" pattern to create modified copies instead of mutating in place (e.g., withExtraItem() returns a new FoodOrder).
  • This is your first line of defense โ€” always ask "can this be immutable?" before reaching for locks or atomics.