Guide contents

Lesson 11 of 31

Thread-Safe Components in Java

Interviewers often end a design with the question "now make it thread-safe". A weak answer wraps every method in one big lock and stops. A strong answer says what is shared, what invariant the lock protects, how big the critical section is, and what the caller sees. This article gives a vocabulary and a set of small, verified components for that answer.

Every example waits for its threads with Future.get before it prints, so the output does not depend on scheduling.

What Java guarantees

Java does not reject data races at compile time. Two threads that touch the same variable, where at least one writes and nothing orders the accesses, form a data race. The Java memory model defines what such a program may observe: a thread can read a stale value, and the compiler and the processor can reorder operations. What the language provides instead is a set of happens-before edges. If action A happens-before action B, then B sees everything A wrote.

Edge What it orders
Unlock of a lock, then a later lock of the same lock writes inside the first critical section are visible in the second
Write of a volatile field, then a later read of it writes before the volatile write are visible after the read
Thread.start, and Thread.join everything before start is visible to the thread; everything the thread did is visible after join
Submitting a task to an ExecutorService, and Future.get the task sees what the submitter wrote; the caller of get sees what the task wrote

volatile gives visibility, not atomicity. count++ on a volatile field is still three steps (read, add, write), and two threads can both read the same value and lose an update.

A data race is not the only bug. A logic race happens when every single access is safe but the combination is not: two threads each check a balance, each decide there is enough money, and both withdraw. The first example forces exactly that interleaving with a CyclicBarrier, a rendezvous point that releases its waiting threads only when all of them have arrived. The balance is an AtomicLong, so no individual read or write is a data race, and the bug is still there.

Java
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.atomic.AtomicLong;

final class UnsafeAccount {
    private final AtomicLong cents;
    private final Runnable betweenCheckAndAct; // a test hook that makes the race repeatable

    UnsafeAccount(long cents, Runnable betweenCheckAndAct) {
        this.cents = new AtomicLong(cents);
        this.betweenCheckAndAct = betweenCheckAndAct;
    }

    boolean withdraw(long amount) {
        if (cents.get() < amount) { // check
            return false;
        }
        betweenCheckAndAct.run();
        cents.addAndGet(-amount);   // act
        return true;
    }

    long balance() {
        return cents.get();
    }
}

final class SafeAccount {
    private long cents; // guarded by this

    SafeAccount(long cents) {
        this.cents = cents;
    }

    synchronized boolean withdraw(long amount) {
        if (cents < amount) {
            return false;
        }
        cents -= amount; // check and act in one critical section
        return true;
    }

    synchronized long balance() {
        return cents;
    }
}

public class Main {
    static void rendezvous(CyclicBarrier barrier) {
        try {
            barrier.await(5, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new IllegalStateException(e);
        } catch (BrokenBarrierException | TimeoutException e) {
            throw new IllegalStateException(e);
        }
    }

    public static void main(String[] args) throws Exception {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var barrier = new CyclicBarrier(2); // both threads pass the check before either acts
            var unsafe = new UnsafeAccount(100, () -> rendezvous(barrier));
            var first = executor.submit(() -> unsafe.withdraw(80));
            var second = executor.submit(() -> unsafe.withdraw(80));
            System.out.println("unsafe, both accepted: " + (first.get() && second.get()));
            System.out.println("unsafe balance: " + unsafe.balance());

            var safe = new SafeAccount(100);
            var a = executor.submit(() -> safe.withdraw(80));
            var b = executor.submit(() -> safe.withdraw(80));
            int accepted = (a.get() ? 1 : 0) + (b.get() ? 1 : 0);
            System.out.println("safe, accepted: " + accepted);
            System.out.println("safe balance: " + safe.balance());
        }
    }
}
Output
unsafe, both accepted: true
unsafe balance: -60
safe, accepted: 1
safe balance: 20

Two withdrawals of 80 from a balance of 100 both succeed in the unsafe version and leave -60. The safe version holds one lock across the check and the update, so exactly one withdrawal succeeds. Most of this article is about choosing that scope.

synchronized, atomics and LongAdder: the basics

synchronized gives a thread exclusive access to a block or method, using the monitor of one object. Every Java object has one. The simplest component keeps its state in private fields and marks every method that touches them synchronized. Java places the lock next to the data by convention only, and the compiler does not check it. Two habits keep it safe:

  1. Make the guarded fields private and name the lock in a comment (// guarded by lock). Code outside the class cannot reach the field without calling a method that locks.
  2. Hold the lock for as short a time as possible, and never call unknown code (a callback, an overridable method) while holding it. You cannot know what locks that code will take.

For a single number, a lock is not needed: AtomicLong performs read-modify-write steps atomically, using compare-and-swap or an equivalent processor instruction. LongAdder spreads an update over several internal cells, which lowers contention when many threads write and one thread reads the total at the end.

Java
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;

final class SynchronizedCounter {
    private long count; // guarded by this

    synchronized void increment() {
        count++;
    }

    synchronized long get() {
        return count;
    }
}

public class Main {
    /** Runs the task 1000 times on each of 4 threads and waits for all of them. */
    static void hammer(Runnable task) throws Exception {
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<?>> futures = new ArrayList<>();
            for (int t = 0; t < 4; t++) {
                futures.add(executor.submit(() -> {
                    for (int i = 0; i < 1000; i++) {
                        task.run();
                    }
                }));
            }
            for (var future : futures) {
                future.get();
            }
        }
    }

    public static void main(String[] args) throws Exception {
        var synchronizedCounter = new SynchronizedCounter();
        hammer(synchronizedCounter::increment);

        var atomic = new AtomicLong();
        hammer(atomic::incrementAndGet);

        var adder = new LongAdder();
        hammer(adder::increment);

        System.out.println("synchronized = " + synchronizedCounter.get());
        System.out.println("AtomicLong   = " + atomic.get());
        System.out.println("LongAdder    = " + adder.sum());
    }
}
Output
synchronized = 4000
AtomicLong   = 4000
LongAdder    = 4000

All three print 4000 because the threads are finished (Future.get) before the totals are read, and the sum does not depend on interleaving. In Java 21, a virtual thread that blocks while it holds a synchronized monitor pins its carrier thread, so code that holds a lock across blocking calls on virtual threads should use ReentrantLock instead. The critical sections above never block.

Read-write locks: many readers, one writer

When reads dominate, ReentrantReadWriteLock allows any number of concurrent readers or exactly one writer. A configuration store is the classic case.

Java
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.locks.ReentrantReadWriteLock;

final class Config {
    private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
    private final Map<String, String> values = new HashMap<>(); // guarded by lock

    Optional<String> get(String key) {
        lock.readLock().lock();
        try {
            return Optional.ofNullable(values.get(key));
        } finally {
            lock.readLock().unlock();
        }
    }

    void set(String key, String value) {
        lock.writeLock().lock();
        try {
            values.put(key, value);
        } finally {
            lock.writeLock().unlock();
        }
    }
}

public class Main {
    public static void main(String[] args) throws Exception {
        var config = new Config();
        config.set("mode", "fast");

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<Optional<String>>> readers = new ArrayList<>();
            for (int i = 0; i < 3; i++) {
                readers.add(executor.submit(() -> config.get("mode")));
            }
            for (var reader : readers) {
                System.out.println("reader saw " + reader.get());
            }
        }

        config.set("mode", "safe");
        System.out.println("after update " + config.get("mode"));
    }
}
Output
reader saw Optional[fast]
reader saw Optional[fast]
reader saw Optional[fast]
after update Optional[safe]

get returns an immutable String inside an Optional. Returning the internal HashMap, or a view of it, would let the caller read it with no lock held, which is a data race. Returning immutable values or copies is the price for keeping the lock private.

A read lock cannot be upgraded: a thread that holds the read lock and then asks for the write lock blocks forever, because the write lock waits for all readers to leave, including the requester. Release the read lock first, then take the write lock and check the condition again.

A read-write lock does more bookkeeping than a plain lock. Measure before choosing it in real code, and in an interview state the read/write ratio that justifies it.

Lock granularity

Granularity is how much data one lock covers.

  • Coarse: one lock for the whole component. Simple and correct, but every operation serializes.
  • Fine: a lock per entry or field. More parallelism, and a deadlock risk when one operation needs two locks.
  • Sharded: a fixed number of locks, each covering a slice of the key space. Operations on different shards run in parallel, and one operation usually needs only one lock.

The rule is to start coarse, keep the critical section small, and shard only when you can name the contention. Sharding also has a limit: any operation that must see all shards (a total count, a snapshot) has to lock them one after another.

The class below shards a counter map by key to show the idea. ConcurrentHashMap is the production answer to the same problem: it locks at the level of individual hash bins and uses compare-and-swap for empty bins, and merge updates one key atomically. Its bulk reads, such as size and iteration, are weakly consistent.

Java
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

final class ShardedCounter {
    private static final class Shard {
        private final Map<Long, Long> counts = new HashMap<>(); // guarded by this

        synchronized void increment(long key) {
            counts.merge(key, 1L, Long::sum);
        }

        synchronized long get(long key) {
            return counts.getOrDefault(key, 0L);
        }

        synchronized long sum() {
            long sum = 0;
            for (long value : counts.values()) {
                sum += value;
            }
            return sum;
        }
    }

    private final Shard[] shards;

    ShardedCounter(int shardCount) {
        if (shardCount <= 0) {
            throw new IllegalArgumentException("shardCount must be positive");
        }
        shards = new Shard[shardCount];
        for (int i = 0; i < shardCount; i++) {
            shards[i] = new Shard();
        }
    }

    private Shard shardFor(long key) {
        // A deterministic mapping; real code would hash the key.
        return shards[(int) Math.floorMod(key, (long) shards.length)];
    }

    void increment(long key) {
        shardFor(key).increment(key);
    }

    long get(long key) {
        return shardFor(key).get(key);
    }

    /** Locks shards one at a time, so the result is not a single atomic snapshot. */
    long total() {
        long total = 0;
        for (var shard : shards) {
            total += shard.sum();
        }
        return total;
    }
}

public class Main {
    public static void main(String[] args) throws Exception {
        var sharded = new ShardedCounter(4);

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<?>> futures = new ArrayList<>();
            for (int t = 0; t < 4; t++) {
                int thread = t;
                futures.add(executor.submit(() -> {
                    for (int i = 0; i < 100; i++) {
                        long key = (thread * 100L + i) % 8;
                        sharded.increment(key);
                    }
                }));
            }
            for (var future : futures) {
                future.get();
            }
        }

        System.out.println("key 3 = " + sharded.get(3));
        System.out.println("total = " + sharded.total());
    }
}
Output
key 3 = 50
total = 400

Each thread touches keys spread over all shards, so two threads collide only when they hit the same shard at the same moment. The doc comment on total states the trade: it is correct once writers stop, and only approximately consistent while they run. State the guarantee in the design: a hidden weak guarantee is a bug, a documented one is a design decision. The same idea appears in design KV store with TTL.

Deadlock and lock ordering

A deadlock needs a cycle: thread A holds lock 1 and waits for lock 2, while thread B holds lock 2 and waits for lock 1. The standard cure is a global lock order: every code path that needs several locks takes them in the same order, for example ascending by ID.

A money transfer between two accounts is the textbook case. If transfer(a, b) locks a then b, and a concurrent transfer(b, a) locks b then a, they can deadlock. A plain lock call in that situation waits forever. tryLock with a timeout returns false instead, so a thread can detect the cycle, release what it holds, and retry.

Ordering by ID removes the cycle. The program below does that with one monitor per account, locked with nested synchronized blocks, lower ID first, whatever the direction of the transfer. Two threads transfer in opposite directions, and the program always finishes because there is one acquisition order. The sum of the two balances is invariant, which is an invariant to assert in a test. The result is an enum because a rejected transfer is an expected outcome and not a bug.

Java
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

enum TransferResult { OK, SAME_ACCOUNT, INSUFFICIENT }

final class Account {
    private final int id; // unique; defines the lock order
    private final Object lock = new Object();
    private long cents; // guarded by lock

    Account(int id, long cents) {
        this.id = id;
        this.cents = cents;
    }

    static TransferResult transfer(Account from, Account to, long cents) {
        if (from.id == to.id) {
            return TransferResult.SAME_ACCOUNT;
        }
        // Always lock the lower id first, whatever the direction of the transfer.
        var first = from.id < to.id ? from : to;
        var second = first == from ? to : from;
        synchronized (first.lock) {
            synchronized (second.lock) {
                if (from.cents < cents) {
                    return TransferResult.INSUFFICIENT;
                }
                from.cents -= cents;
                to.cents += cents;
                return TransferResult.OK;
            }
        }
    }

    long balance() {
        synchronized (lock) {
            return cents;
        }
    }
}

public class Main {
    public static void main(String[] args) throws Exception {
        var a = new Account(1, 10_000);
        var b = new Account(2, 10_000);

        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            var t1 = executor.submit(() -> {
                for (int i = 0; i < 500; i++) {
                    Account.transfer(a, b, 10);
                }
            });
            var t2 = executor.submit(() -> {
                for (int i = 0; i < 500; i++) {
                    Account.transfer(b, a, 10);
                }
            });
            t1.get(30, TimeUnit.SECONDS);
            t2.get(30, TimeUnit.SECONDS);
        }

        System.out.println("a = " + a.balance() + ", b = " + b.balance());
        System.out.println(Account.transfer(a, a, 1));
        System.out.println(Account.transfer(a, b, 1_000_000));
    }
}
Output
a = 10000, b = 10000
SAME_ACCOUNT
INSUFFICIENT

Other ways to stay out of trouble:

  • One lock for the whole invariant. If two fields must change together, guard them with one lock instead of two.
  • Never hold a lock across a call to foreign code, including callbacks and queue operations that may block.
  • Use tryLock with a timeout when you can back off and retry rather than wait.
  • Locks are reentrant. synchronized and ReentrantLock let the thread that holds the lock take it again, so a method that calls another locking method on the same object does not deadlock. This also hides design mistakes: a callback that re-enters the component in the middle of an update sees half-updated state.

Keep locks out of the public API

Compare two ways of exposing a shared inventory. The leaky form makes its lock and its map public fields. Callers must know about the lock, can hold it across slow work, can forget it (a data race), and can lock in a bad order.

The encapsulated form has three advantages. The lock scope is exactly one method, so callers cannot hold it across slow work. You can change the strategy (coarse, sharded, lock-free) without changing any caller. And each method can be atomic with respect to its own invariant: reserve checks and decrements under one lock, so two callers cannot both take the last unit.

Inventory is a plain object that threads share by reference. Its methods lock internally, so callers never write a lock. A sealed result type lists every outcome of reserve, and a switch over it must cover all of them. (ConcurrentHashMap.compute is an alternative that makes the check and the update atomic per key without an explicit lock.)

Java
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

sealed interface ReserveResult permits Reserved, UnknownSku, OutOfStock {}

record Reserved() implements ReserveResult {}

record UnknownSku(String sku) implements ReserveResult {}

record OutOfStock(String sku, int available) implements ReserveResult {}

final class Inventory {
    private final Object lock = new Object();
    private final Map<String, Integer> stock = new HashMap<>(); // guarded by lock

    void add(String sku, int qty) {
        synchronized (lock) {
            stock.merge(sku, qty, Integer::sum);
        }
    }

    ReserveResult reserve(String sku, int qty) {
        synchronized (lock) {
            Integer available = stock.get(sku);
            if (available == null) {
                return new UnknownSku(sku);
            }
            if (available < qty) {
                return new OutOfStock(sku, available);
            }
            stock.put(sku, available - qty); // check and update under one lock
            return new Reserved();
        }
    }

    int stock(String sku) {
        synchronized (lock) {
            return stock.getOrDefault(sku, 0);
        }
    }
}

public class Main {
    static String describe(ReserveResult result) {
        return switch (result) {
            case Reserved r -> "reserved";
            case UnknownSku(var sku) -> "unknown sku " + sku;
            case OutOfStock(var sku, var available) -> "out of stock: " + sku + " has " + available;
        };
    }

    public static void main(String[] args) throws Exception {
        var inventory = new Inventory();
        inventory.add("widget", 10);

        int reserved = 0;
        try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
            List<Future<Integer>> futures = new ArrayList<>();
            for (int t = 0; t < 4; t++) {
                futures.add(executor.submit(() -> {
                    int won = 0;
                    for (int i = 0; i < 5; i++) {
                        if (inventory.reserve("widget", 1) instanceof Reserved) {
                            won++;
                        }
                    }
                    return won;
                }));
            }
            for (var future : futures) {
                reserved += future.get();
            }
        }

        System.out.println("reserved " + reserved + ", left " + inventory.stock("widget"));
        System.out.println(describe(inventory.reserve("gadget", 1)));
        System.out.println(describe(inventory.reserve("widget", 5)));
    }
}
Output
reserved 10, left 0
unknown sku gadget
out of stock: widget has 0

Twenty attempts compete for ten units. Exactly ten succeed, whichever threads win, so the printed totals are stable even though the interleaving is not. Because the check and the decrement happen under one lock, overselling is impossible. That is what "atomic operation" means in a component: one public method, one critical section.

Interior versus external synchronization

There are two places the lock can live.

External synchronization. The type is a plain class with no lock. Whoever shares it decides how: wrap each call in synchronized, hand it to a single owner thread, or share nothing. ArrayList and HashMap work this way.

Interior synchronization. The type locks inside its own methods. Callers can share it freely, but every caller pays for the lock, and callers cannot combine two calls into one atomic step. ConcurrentHashMap and Collections.synchronizedList work this way. A synchronized wrapper still needs the caller's own lock for iteration and for any two-step action.

Aspect External (caller locks) Interior (type locks)
Single-threaded cost none a lock per call
Compound operations caller holds one lock over many calls not possible without adding a new method
Ease of sharing caller adds the locking share the object directly
Lock-order control caller component
Best for data structures, algorithms services, registries, buses, queues

The compound-operation row matters most. With interior locking, "check the stock, then reserve it" as two calls is a race, even though each call is individually safe. The fix is a method that does both, like reserve above. So a rule of thumb: build the core data structure with external synchronization (it stays simple and testable, see testability and dependency injection), then wrap it with interior synchronization at the service boundary, exposing operations that match the business transactions.

In a cache, for instance, the core getOrCompute(key, loader) knows nothing about threads, and a SharedCache wrapper adds the lock and exposes the same method as one atomic step. A slow loader then runs while the lock is held and blocks every other key. Per-key locking, or a CompletableFuture stored per key as a pending marker, removes that cost.

Making output deterministic

To keep tests and examples stable:

  • Wait for every task before printing or asserting: Future.get, Thread.join, or leaving a try-with-resources block around an executor.
  • Print aggregates, not interleavings. Totals, sorted lists and final states are stable. The order in which threads reach a println is not.
  • Use TreeMap or sort before printing. HashMap iteration order is unspecified.
  • Inject the clock rather than reading the system time, see testability and dependency injection.
  • Test the invariant, not the schedule. "Total balance is unchanged" holds for every schedule.
  • Force an interleaving with a latch or barrier instead of sleep, and give every wait a timeout.

Interview points

  • A data race is unordered access to shared state; a logic race is a correct-looking check-then-act that is not atomic. Atomics fix the first and not the second.
  • volatile gives visibility and no atomicity.
  • Happens-before edges: lock/unlock, volatile, start/join, executor submit and Future.get.
  • Lock ordering by a unique key prevents deadlock; tryLock with a timeout detects it.
  • synchronized and ReentrantLock are reentrant; a read lock cannot be upgraded to a write lock.
  • Sharding trades cross-shard consistency for parallelism; state what total guarantees.
  • Never hold a lock while calling foreign code, and never return a guarded mutable collection.
  • Interior locking cannot make two calls atomic; add a method that does both.

Summary

  • Java allows data races and defines them through the memory model; happens-before edges (locks, volatile, join, Future.get) make writes visible.
  • Logic races across several operations are the designer's job: one lock, one invariant, one critical section.
  • Use synchronized or ReentrantLock for compound state, AtomicLong or LongAdder for counters, and ReentrantReadWriteLock only for read-heavy workloads.
  • Return immutable values or copies so the guarded state does not escape.
  • Start with one coarse lock. Shard by key when contention is real, and document what cross-shard operations guarantee.
  • Prevent deadlock with a global lock order, one lock per invariant, no callbacks under a lock, and timeouts when backing off is possible.
  • Keep locks private. Expose operations that are atomic with respect to their invariant, not locks.
  • Build the core without locks and wrap it with interior synchronization at the service boundary.
  • Make examples deterministic by waiting for tasks and printing sorted aggregates. See also event bus and observers and blocking queue.