Loading
Loading
Concurrency in Object Design
Guarding the state that must be shared: critical sections, how big a lock should be, bounded queues for handing work between threads, and how deadlock happens.
By the end of this chapter
A lock gives two guarantees. Mutual exclusion: only one thread at a time runs code guarded by it. Visibility: everything a thread wrote before releasing the lock is visible to the next thread that acquires it. Together they let you treat a sequence of steps as if it were one.
final class Account { private long balanceCents; // invariant: never negative synchronized void deposit(long cents) { balanceCents += cents; } synchronized boolean withdraw(long cents) { if (balanceCents < cents) return false; // check ... balanceCents -= cents; // ... and act, under one lock return true; } synchronized long balance() { return balanceCents; }}How much state one lock covers is a trade-off. One lock for everything is easy to reason about and serialises all work. One lock per small piece of state allows parallelism, but every operation that spans pieces now has to take several locks, and that is where ordering bugs live.
Approaches
Low traffic, or operations that genuinely touch everything. Start here when correctness matters more than throughput.
Most operations touch one aggregate. The usual sweet spot, because aggregate boundaries are also consistency boundaries.
Many small keys, too many to give each a lock. Hash the key to one of N locks.
// Coarse: claims for different shows wait for each other.final class SeatRegistry { private final Map<ShowId, Set<SeatId>> taken = new HashMap<>(); synchronized boolean claim(ShowId show, SeatId seat) { return taken.computeIfAbsent(show, s -> new HashSet<>()).add(seat); }} // Finer: only claims for the same show contend, and the library// does the locking.final class ConcurrentSeatRegistry { private final ConcurrentMap<ShowId, Set<SeatId>> taken = new ConcurrentHashMap<>(); boolean claim(ShowId show, SeatId seat) { return taken .computeIfAbsent(show, s -> ConcurrentHashMap.newKeySet()) .add(seat); // atomic: false if already taken }}Often the cleanest answer to shared state is to not share it, and pass work between threads instead. Producers put tasks on a queue; consumers take them off. The queue is the only shared object, it is thread-safe by construction, and neither side needs to know the other exists or how fast it runs.
final class ThumbnailPipeline { private static final Upload STOP = new Upload("__stop__"); private final BlockingQueue<Upload> queue = new ArrayBlockingQueue<>(500); // Producer side: called from request threads. boolean submit(Upload upload) throws InterruptedException { return queue.offer(upload, 2, TimeUnit.SECONDS); // false = shed load } // Consumer side: each of N worker threads runs this. void runWorker() throws InterruptedException { while (true) { Upload next = queue.take(); // blocks while empty if (next == STOP) return; render(next); } } void shutdown(int workers) throws InterruptedException { for (int i = 0; i < workers; i++) queue.put(STOP); // one pill per worker }}Concept
A bounded queue makes a fast producer feel a slow consumer: when the queue is full, the producer waits, times out or is refused. That is the system telling the truth about its capacity. An unbounded queue hides the mismatch until memory runs out, and by then every queued item is late anyway.
Before and after
Unbounded queue depth
Bounded (1,000) queue depth
import queueimport threading jobs: queue.Queue = queue.Queue(maxsize=500)STOP = object() def worker() -> None: while True: job = jobs.get() # blocks while empty try: if job is STOP: return render(job) finally: jobs.task_done() def submit(job) -> None: jobs.put(job, timeout=2) # raises queue.Full after 2 s: shed load threads = [threading.Thread(target=worker, daemon=True) for _ in range(4)]Deadlock needs four conditions at once: resources are held exclusively, a thread holds one resource while waiting for another, nothing can force a thread to give a resource up, and there is a cycle of threads each waiting for the next. Remove any one condition and deadlock is impossible. The cycle is usually the easiest one to remove.
// Deadlock-prone: lock order depends on argument order.void transfer(Account from, Account to, long cents) { synchronized (from) { synchronized (to) { from.debit(cents); to.credit(cents); } }} // Fixed: every thread locks in the same global order (by id).// Assumes ids are unique and from != to.void transfer(Account from, Account to, long cents) { Account first = from.id() < to.id() ? from : to; Account second = first == from ? to : from; synchronized (first) { synchronized (second) { from.debit(cents); to.credit(cents); } }}T1 runs transfer(A, B); T2 runs transfer(B, A), at the same momentT1 holds AT1 enters the outer block on A.T2 holds BT2 enters the outer block on B.T1 waits for BHeld by T2.T2 waits for AHeld by T1. The cycle is closed.Both threads wait forever. With id ordering, both lock A first, so one simply waits for the other to finish.| Technique | Condition it removes | Cost |
|---|---|---|
| Global lock ordering | Circular wait | Every multi-lock path must follow the order |
| tryLock with timeout, then release and retry | Hold-and-wait | Retry logic; possible livelock without back-off |
| One coarser lock over both objects | Hold-and-wait | Less parallelism |
| Single owner thread fed by a queue | Shared exclusive resources altogether | Throughput limited to one thread per owner |
Sign in to keep a note here. It is free, and nobody else can see it.
Sign inWhat this chapter was for:
The invariant fits a single atomic operation such as putIfAbsent or compute. Prefer this to a lock you wrote yourself.
The bounded queue caps memory and pushes the overload back to the callers, who can retry, degrade or fail fast. The unbounded one absorbs it silently until the process dies.