Loading content...
Understanding thread safety is one thing. Engineering thread-safe systems is another entirely.
Up to this point, we've explored what thread safety means, how shared mutable state creates hazards, and how race conditions manifest. Now we transition from diagnosis to treatment—from understanding problems to systematically designing solutions.
Great concurrent code isn't the result of sprinkling locks everywhere until bugs disappear. It's the result of deliberate design: choosing the right architecture, applying proven patterns, documenting invariants, and building systems where thread safety is a natural consequence of the structure rather than a patched-on afterthought.
By the end of this page, you will understand the core principles of thread-safe design, when and how to apply different concurrency strategies, how to document thread safety contracts, and a systematic methodology for building reliable concurrent systems. You'll develop the engineering mindset needed to make sound concurrency decisions.
Before diving into techniques, let's establish the mental framework that guides thread-safe design.
Principle 1: Thread Safety is a Design Property, Not a Patch
Thread safety should be considered from the very first design decisions—not added after the fact. Retrofitting thread safety onto a design that assumed single-threaded execution is expensive, error-prone, and often results in poor performance due to coarse-grained locking.
Principle 2: Simplicity is the Ultimate Sophistication
The best concurrent code is often the simplest. Complex synchronization schemes are hard to reason about, hard to test, and hard to maintain. Prefer:
For every piece of state and every operation, ask: "What if another thread is accessing this right now?" If the answer is "bad things happen" or "I'm not sure," you have a design problem to address.
We introduced three fundamental strategies earlier: immutability, confinement, and synchronization. Let's explore each in depth, with practical guidance on when and how to apply them.
Immutability is the gold standard for thread safety. An immutable object cannot be corrupted by concurrent access because there's nothing to corrupt—no state changes after construction.
When to use immutability:
How to implement immutability correctly:
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
// COMPREHENSIVE IMMUTABLE CLASSpublic final class ImmutableOrder { private final String orderId; private final String customerId; private final List<OrderItem> items; // Must be immutable too! private final BigDecimal total; private final Instant createdAt; public ImmutableOrder(String orderId, String customerId, List<OrderItem> items, BigDecimal total) { // Validate invariants in constructor if (orderId == null || orderId.isEmpty()) { throw new IllegalArgumentException("orderId required"); } this.orderId = orderId; this.customerId = customerId; // DEFENSIVE COPY: Don't let caller mutate our list this.items = List.copyOf(items); // Immutable copy in Java 10+ this.total = total; this.createdAt = Instant.now(); } // GETTERS ONLY - no setters public String getOrderId() { return orderId; } public String getCustomerId() { return customerId; } // Return immutable view (List.copyOf already returns immutable) public List<OrderItem> getItems() { return items; } public BigDecimal getTotal() { return total; } public Instant getCreatedAt() { return createdAt; } // "MODIFICATION" creates new object public ImmutableOrder withItems(List<OrderItem> newItems) { BigDecimal newTotal = calculateTotal(newItems); return new ImmutableOrder(orderId, customerId, newItems, newTotal); } // Derived state computation (always consistent) public int getItemCount() { return items.size(); } private static BigDecimal calculateTotal(List<OrderItem> items) { return items.stream() .map(OrderItem::getSubtotal) .reduce(BigDecimal.ZERO, BigDecimal::add); }} // OrderItem must also be immutable!public final class OrderItem { private final String productId; private final int quantity; private final BigDecimal unitPrice; public OrderItem(String productId, int quantity, BigDecimal unitPrice) { this.productId = Objects.requireNonNull(productId); this.quantity = quantity; this.unitPrice = Objects.requireNonNull(unitPrice); } public BigDecimal getSubtotal() { return unitPrice.multiply(BigDecimal.valueOf(quantity)); } // Getters only...}Critical requirements for immutability:
this escape during constructionWhen designing a class that will be used in concurrent contexts, follow this systematic approach.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117
/** * A thread-safe bounded counter with minimum and maximum limits. * * <h2>Thread Safety</h2> * This class is <b>unconditionally thread-safe</b>. All operations are atomic * and can be called from any thread without external synchronization. * * <h3>Invariants</h3> * <ul> * <li>min <= value <= max (always maintained)</li> * <li>min < max (validated at construction)</li> * </ul> * * <h3>Implementation Notes</h3> * Uses compare-and-swap for lock-free operation. Under contention, * operations may retry, but forward progress is guaranteed. * * @ThreadSafe */public final class BoundedCounter { // STATE: Single AtomicInteger is easiest to reason about // INVARIANT: value is always in [min, max] private final AtomicInteger value; private final int min; private final int max; /** * Creates a bounded counter. * * @param initial initial value; must satisfy min <= initial <= max * @param min minimum value (inclusive) * @param max maximum value (inclusive); must be > min * @throws IllegalArgumentException if constraints are violated */ public BoundedCounter(int initial, int min, int max) { // VALIDATE invariants at construction if (min >= max) { throw new IllegalArgumentException("min must be < max"); } if (initial < min || initial > max) { throw new IllegalArgumentException("initial must be in [min, max]"); } this.min = min; this.max = max; this.value = new AtomicInteger(initial); } /** * Returns the current value. * <p> * Note: In a concurrent environment, the value may change immediately * after this method returns. */ public int get() { return value.get(); } /** * Atomically increments if not at maximum. * * @return true if incremented, false if already at max */ public boolean tryIncrement() { while (true) { int current = value.get(); if (current >= max) { return false; // At limit } if (value.compareAndSet(current, current + 1)) { return true; // Success } // CAS failed, retry } } /** * Atomically decrements if not at minimum. * * @return true if decremented, false if already at min */ public boolean tryDecrement() { while (true) { int current = value.get(); if (current <= min) { return false; // At limit } if (value.compareAndSet(current, current - 1)) { return true; // Success } // CAS failed, retry } } /** * Atomically sets value if it doesn't violate bounds. * * @param newValue desired value * @return true if set, false if newValue would violate bounds */ public boolean trySet(int newValue) { if (newValue < min || newValue > max) { return false; } value.set(newValue); return true; } /** * Returns the percentage of capacity used. */ public double getUtilization() { int current = value.get(); return (double)(current - min) / (max - min); }}If your class will mostly be used in single-threaded contexts, consider making it thread-compatible (not internally synchronized) with clear documentation. Users can add external synchronization if needed. Don't pay synchronization costs when they're not needed.
One of the trickiest aspects of concurrent design is composition. Thread-safe components don't automatically compose into thread-safe systems.
If you have two thread-safe counters, counterA and counterB, incrementing both in sequence is NOT atomic. Between the increments, another thread could read an inconsistent state where one is incremented and the other isn't.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
// PROBLEM: Two thread-safe components don't composepublic class ShoppingCart { private final ConcurrentMap<String, Integer> items = new ConcurrentHashMap<>(); private final AtomicInteger totalItems = new AtomicInteger(0); // BUG: These two updates aren't atomic together! public void addItem(String productId, int quantity) { items.merge(productId, quantity, Integer::sum); // Atomic // << READER THREAD SEES INCONSISTENT STATE >> totalItems.addAndGet(quantity); // Atomic, but not with above! } // Reader might see items.values().sum() != totalItems.get()} // SOLUTION 1: External locking for compound operationspublic class ShoppingCartSync { private final Map<String, Integer> items = new HashMap<>(); private int totalItems = 0; private final Object lock = new Object(); public void addItem(String productId, int quantity) { synchronized (lock) { items.merge(productId, quantity, Integer::sum); totalItems += quantity; // Both update atomically } } public int getTotalItems() { synchronized (lock) { return totalItems; } }} // SOLUTION 2: Immutable snapshotspublic class ShoppingCartImmutable { private final AtomicReference<CartState> state = new AtomicReference<>(CartState.EMPTY); public void addItem(String productId, int quantity) { while (true) { CartState current = state.get(); CartState next = current.withItem(productId, quantity); if (state.compareAndSet(current, next)) { return; // Atomically swapped to new state } // CAS failed, retry with latest state } } public CartState getSnapshot() { return state.get(); // Immutable, safe to use }} // SOLUTION 3: Delegate to single thread-safe objectpublic class ShoppingCartDelegated { private final ConcurrentMap<String, Integer> items = new ConcurrentHashMap<>(); public void addItem(String productId, int quantity) { items.merge(productId, quantity, Integer::sum); } // Compute totalItems from source of truth (items map) public int getTotalItems() { return items.values().stream().mapToInt(Integer::intValue).sum(); }}Guidelines for composing thread-safe systems:
Single source of truth — Derive all state from one authoritative source rather than maintaining synchronized duplicates.
Immutable composition — Compose immutable objects; swap entire state atomically using AtomicReference.
Same-lock composition — When multiple variables must be consistent, protect them with the same lock.
Transaction boundaries — Define clear transaction boundaries that encompass all related operations.
Avoid splitting invariants — If an invariant spans multiple objects, they must share synchronization.
Thread safety documentation is not optional—it's essential for maintainability and correctness. Undocumented thread safety assumptions lead to bugs when other developers (including future you) make incorrect assumptions.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162
/** * Thread-safe cache with automatic expiration. * * <h2>Thread Safety</h2> * This class is <b>unconditionally thread-safe</b>. Concurrent calls to * {@link #get}, {@link #put}, and {@link #remove} are safe without external * synchronization. * * <h3>Atomicity Guarantees</h3> * <ul> * <li>{@link #put} is atomic: the value is immediately visible to readers</li> * <li>{@link #computeIfAbsent} is atomic: factory is called at most once per key</li> * <li>{@link #getAll()} returns a <b>snapshot</b>; modifications to the cache * after the call are not visible in the returned map</li> * </ul> * * <h3>Caveats</h3> * The values stored in the cache are not copied. If you store mutable objects, * you are responsible for their thread safety. Consider storing immutable values. * * <h3>Locking Details (Implementation Note)</h3> * This class uses a striped lock approach with 16 segments. High concurrency * on different keys should not cause contention. Segment lock is the key's * hash modulo number of segments. * * @param <K> key type * @param <V> value type * * @ThreadSafe */public class ExpiringCache<K, V> { // ...} /** * Mutable user session data. * * <h2>Thread Safety</h2> * This class is <b>thread-compatible</b>. Instances are designed to be confined * to a single request thread. If you must share a session across threads, * external synchronization is required for all access. * * <h3>Typical Usage</h3> * <pre> * // In request handler (single-threaded context) * UserSession session = sessionStore.get(sessionId); * session.setAttribute("cart", cart); // Safe: single thread * </pre> * * <h3>Multi-threaded Usage (if required)</h3> * <pre> * synchronized (session) { * session.setAttribute("key", value); * doSomething(session.getAttribute("key")); * } * </pre> * * @NotThreadSafe // Using custom or standard annotations */public class UserSession { // ...}Consider using thread safety annotations from JCIP (Java Concurrency in Practice): @ThreadSafe, @NotThreadSafe, @Immutable, @GuardedBy. These serve as documentation and can be checked by static analysis tools.
Here are battle-tested patterns for common thread-safe design challenges.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980
// PATTERN 1: Lazy Holder Idiom (best for lazy singletons)public class ExpensiveService { // Lazy initialization without synchronization overhead private static class Holder { static final ExpensiveService INSTANCE = new ExpensiveService(); } private ExpensiveService() { // Expensive initialization } public static ExpensiveService getInstance() { return Holder.INSTANCE; // Class initialization is thread-safe }} // PATTERN 2: Immutable Holder for atomic multi-field updatepublic class ServerConfig { // Multiple related fields that must update atomically private static final class ConfigHolder { final String host; final int port; final Duration timeout; ConfigHolder(String host, int port, Duration timeout) { this.host = host; this.port = port; this.timeout = timeout; } } private final AtomicReference<ConfigHolder> config = new AtomicReference<>(); public void updateConfig(String host, int port, Duration timeout) { config.set(new ConfigHolder(host, port, timeout)); // Atomic! } public String getHost() { return config.get().host; } public int getPort() { return config.get().port; }} // PATTERN 3: Copy-on-Write for rarely-updated, frequently-readpublic class ListenerRegistry { // Volatile ensures visibility of the immutable list reference private volatile List<Listener> listeners = List.of(); public void addListener(Listener listener) { synchronized (this) { List<Listener> newList = new ArrayList<>(listeners); newList.add(listener); listeners = List.copyOf(newList); // Publish immutable copy } } public void notifyAll(Event event) { // No synchronization needed - list is immutable for (Listener listener : listeners) { listener.onEvent(event); } }} // PATTERN 4: Read-Write separation with snapshotspublic class MetricsRegistry { private final ConcurrentMap<String, AtomicLong> counters = new ConcurrentHashMap<>(); // Hot path - high-frequency writes public void increment(String metric) { counters.computeIfAbsent(metric, k -> new AtomicLong()).incrementAndGet(); } // Cold path - occasional reads for reporting public Map<String, Long> getSnapshot() { return counters.entrySet().stream() .collect(Collectors.toUnmodifiableMap( Map.Entry::getKey, e -> e.getValue().get() )); }}We've covered the principles and practices of designing thread-safe systems. Let's consolidate the key takeaways:
Module Complete:
You've now completed the Thread Safety module. You understand:
The next module will explore Synchronization Primitives in depth—the specific tools (mutexes, semaphores, monitors) you'll use when synchronization is necessary.
Congratulations! You've mastered the fundamentals of thread safety. You can now identify thread safety issues, choose appropriate strategies, design thread-safe classes, and document thread safety contracts. These skills form the foundation for all concurrent programming.