Back to Synchronization
Q3Question 3 of 8

How do you design a thread-safe class?

Key Concepts
  • Immutability is the best strategy: if nothing changes, nothing can be corrupted. Records and final fields are inherently thread-safe.
  • Thread confinement means state is never shared: stack-local variables and ThreadLocal give each thread its own copy.
  • Delegation means using existing thread-safe classes (ConcurrentHashMap, AtomicInteger) instead of writing your own synchronization.
  • Locking with synchronized or Lock is the last resort: guard all shared mutable state with a consistent lock.
  • Anti-patterns include synchronized(this) (exposes lock), synchronized(new Object()) (creates a new lock each time), and leaking mutable internal state.
  • Always prefer the simplest strategy that works: immutability before confinement before delegation before locking.

Definition

Design thread-safe classes using four strategies in order of preference: immutability, confinement, delegation to thread-safe classes, and locking as a last resort.
  • Immutability – no mutable state at all. If nothing changes, nothing can be corrupted. Strings, records with immutable fields, and BigDecimal are examples. Zero synchronization needed.
  • Confinement – mutable state is never shared between threads. Each thread has its own copy via stack-local variables or ThreadLocal. No sharing means no race condition.
  • Delegation – delegate to existing thread-safe classes like ConcurrentHashMap, AtomicInteger, LongAdder, and BlockingQueue. Let well-tested library code handle synchronization.
  • Locking – guard all shared mutable state with a consistent lock: synchronized(privateFinalLock). This is the most error-prone strategy and should be used only when the others do not fit.

Immutability is like a printed bank statement: nobody can change it, so you can hand it to 100 people with zero risk. Confinement is like each teller having their own calculator: nobody shares, so nobody fights over it. Delegation is like buying a professional vault (ConcurrentHashMap) built by experts with built-in security: do not build your own vault. Locking is like building a custom safe with your own padlock: it works, but you might forget to lock it or hold the key too long.

Understanding the Concepts

What is the immutability strategy?

Immutable objects have no mutable state. Once constructed, their fields never change. Any number of threads can read an immutable object simultaneously with zero synchronization.

JAVA
package com.bank.model;

import java.math.BigDecimal;
import java.time.LocalDateTime;

// Java record: all fields are implicitly final
public record TradeConfirmation(
    String tradeId,
    String symbol,
    BigDecimal amount,
    LocalDateTime timestamp
) {
    // No setters. No mutable state.
    // String, BigDecimal, LocalDateTime are all immutable.
    // 100 threads can read this simultaneously: zero risk.
}

The rules for creating an immutable class: all fields are final, no setters, the class is final (or sealed), and mutable objects passed to the constructor are defensively copied.

What is the confinement strategy?

If mutable state is never shared between threads, no synchronization is needed. Stack-local variables are automatically confined to one thread. ThreadLocal provides per-thread copies of shared resources.

JAVA
package com.bank.service;

import java.math.BigDecimal;
import java.text.SimpleDateFormat;

public class ConfinementDemo {
    // Stack confinement: local variables are never visible to other threads
    public BigDecimal calculateFee(BigDecimal amount) {
        BigDecimal feeRate = new BigDecimal("0.001"); // local variable
        return amount.multiply(feeRate); // only this thread sees feeRate
    }

    // ThreadLocal confinement: each thread gets its own instance
    private static final ThreadLocal<SimpleDateFormat> dateFormat =
        ThreadLocal.withInitial(() -> new SimpleDateFormat("yyyy-MM-dd"));

    public String formatDate(java.util.Date date) {
        // SimpleDateFormat is NOT thread-safe, but ThreadLocal makes it safe
        return dateFormat.get().format(date);
    }
}

What is the delegation strategy?

Delegation means using existing thread-safe classes instead of writing your own synchronization. The java.util.concurrent package provides battle-tested implementations.

JAVA
package com.bank.service;

import java.math.BigDecimal;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.atomic.AtomicLong;

public class TradeCache {
    private final ConcurrentHashMap<String, BigDecimal> prices =
        new ConcurrentHashMap<>();
    private final AtomicLong tradeCount = new AtomicLong(0);

    public void recordTrade(String symbol, BigDecimal price) {
        prices.put(symbol, price);         // thread-safe: ConcurrentHashMap
        tradeCount.incrementAndGet();      // thread-safe: AtomicLong
    }

    public BigDecimal getPrice(String symbol) {
        return prices.get(symbol);         // lock-free read
    }

    public long getTradeCount() {
        return tradeCount.get();           // volatile read
    }
    // ZERO synchronized blocks in this class. All safety delegated.
}

What is the locking strategy?

When the other strategies do not fit (complex multi-field updates, compound operations), use explicit locking. Use a private final Object as the lock.

JAVA
package com.bank.service;

import java.math.BigDecimal;

public class AccountService {
    private final Object lock = new Object(); // private, final, dedicated
    private BigDecimal balance;                // guarded by 'lock'
    private String lastTransactionId;          // guarded by 'lock'

    public AccountService(BigDecimal initialBalance) {
        this.balance = initialBalance;
    }

    public void debit(String txnId, BigDecimal amount) {
        synchronized (lock) {
            if (balance.compareTo(amount) < 0) {
                throw new IllegalStateException(
                    "Insufficient funds: " + balance + " < " + amount);
            }
            balance = balance.subtract(amount);
            lastTransactionId = txnId;
        }
    }

    public BigDecimal getBalance() {
        synchronized (lock) {
            return balance;
        }
    }
}

What are the anti-patterns?

Several common mistakes silently break thread safety.

JAVA
package com.bank.service;

public class AntiPatterns {
    // WRONG: synchronized(this) exposes the lock
    public synchronized void debit() {
        // External code can do: synchronized(this.accountService) { sleep(10s); }
        // Blocks all your synchronized methods for 10 seconds!
    }

    // WRONG: new lock every time
    public void transfer() {
        synchronized (new Object()) { // new Object each call: NO synchronization!
            // Every thread creates its own lock object: different objects!
        }
    }

    // WRONG: non-final lock can be reassigned
    private Object lock = new Object();
    public void process() {
        synchronized (lock) {
            // If another thread does: lock = new Object();
            // Now threads synchronize on DIFFERENT objects!
        }
    }
}

When should you use each strategy?

| Scenario | Strategy | Example |
|---|---|---|
| Data never changes after creation | Immutability | Trade confirmation record |
| Each thread needs its own copy | Confinement (ThreadLocal) | SimpleDateFormat, database connection |
| Single-variable atomic operations | Delegation (Atomic classes) | Transaction counter |
| Key-value cache with concurrent access | Delegation (ConcurrentHashMap) | Price cache |
| Multi-field compound update | Locking (synchronized) | Balance + lastTxnId update |

What should a senior engineer know?

DIAGRAM
- **Composition of thread-safe components is not automatically thread-safe**. A class with two AtomicIntegers is not thread-safe if an invariant relates them (e.g., `low <= high`). You need a lock to maintain the cross-field invariant.
- **Do not leak mutable state**. If `getAccounts()` returns the internal `List<Account>`, external code can modify it. Return `List.copyOf()` or `Collections.unmodifiableList()`.
- **Document thread-safety contracts**. Use annotations like `@ThreadSafe`, `@NotThreadSafe`, and `@GuardedBy("lock")` from the `javax.annotation.concurrent` package.

Visual Overview

DIAGRAM
  Thread-Safety Strategy Preference Order:

  ┌─────────────────────────────────────────┐
  │  1. IMMUTABILITY                         │  Best: zero synchronization
  │     final fields, records, String        │
  ├─────────────────────────────────────────┤
  │  2. CONFINEMENT                          │  Good: no sharing
  │     stack locals, ThreadLocal            │
  ├─────────────────────────────────────────┤
  │  3. DELEGATION                           │  Good: expert-built safety
  │     ConcurrentHashMap, AtomicInteger     │
  ├─────────────────────────────────────────┤
  │  4. LOCKING                              │  Last resort: error-prone
  │     synchronized(privateFinalLock)       │
  └─────────────────────────────────────────┘

Key Takeaway

Thread-safe class design follows four strategies in preference order. Immutability is the best: if nothing changes, nothing can corrupt. Records with immutable fields are freely shareable across threads with zero synchronization. Confinement is next: stack-local variables and ThreadLocal give each thread its own copy, eliminating sharing entirely. Delegation comes third: use ConcurrentHashMap, AtomicInteger, and BlockingQueue instead of writing your own synchronization. Locking is the last resort: synchronized(privateFinalLock) guards shared mutable state, but is the most error-prone approach.

The anti-patterns are equally important: synchronized(this) exposes the lock to external code. synchronized(new Object()) creates a new lock every time, providing no synchronization at all. Returning mutable internal state from a getter lets external code corrupt your data.

Remember one sentence: prefer immutability over confinement over delegation over locking, and always use a private final lock object when locking is necessary.

---

Share
Use ← → arrow keys to navigate