14.2 Synchronization, Communication, and Deadlocks
After completing Thread Creation and Lifecycle, allocate approximately two hours for this section and run three experiments using Java 17 platform threads. The goal is to protect invariants with a consistent locking strategy, correctly wait for conditions, and ensure that multi-lock operations follow a stable order.
Two hammers and the same piece of iron
Thread safety also starts with rules. Three reproducible experiments show how to protect counters and ensure visibility, how to make producers and consumers wait for conditions, and how to lock two accounts simultaneously without deadlocking.
1. First, write the invariants, then identify the critical section
The rules for the library's collection are:
Before borrowing, stock > 0; after borrowing, stock is exactly reduced by 1; stock is never negative.The rules for bank transfers are:
Debits and credits are inseparable; the total balance remains unchanged; no balance is negative.Invariants are conditions that must hold after every public operation. Critical sections are blocks of code that must be executed as a unit to maintain those conditions.
2. Why does count++ lose updates?
final class UnsafeCounter {
private int count;
void increment() {
count++;
}
}count++ includes read, compute, and write-back. Possible issues may arise:
Thread A reads 100
Thread B reads 100
Thread A writes back 101
Thread B writes back 101Two calls only increased the value by 1. This is a race condition.
Don't report a specific error count as "expected output." An unsynchronized program might yield 19,973 one moment and 20,000 the next; JIT optimization, core count, and scheduling all affect the results. Even if you run the code ten thousand times and get the right answer, it still lacks synchronization guarantees.
3. synchronized's Two Guarantees
final class Counter {
private int value;
synchronized void increment() {
value++;
}
synchronized int value() {
return value;
}
}An instance synchronized method acquires the monitor of this:
- Mutual Exclusion: At any given time, only one thread can hold a monitor;
- Visibility: A release of a monitor happens-before another thread successfully acquires the same monitor.
Getters aren't mechanically locked "just because." The correct rule is: all reads and writes involved in the same invariant must follow the same synchronization protocol. If a write holds the lock but a read bypasses it without another publication relationship such as join(), the read lacks the required visibility guarantee.
The object locked determines whether exclusivity is enforced
private final Object lock = new Object();
void increment() {
synchronized (lock) {
value++;
}
}| Code | Lock Object |
|---|---|
synchronized void f() | this |
static synchronized void f() | The declaring class’s Class object |
synchronized (lock) | The object resulting from lock expression |
Locking two different objects doesn't cause mutual exclusion. Private lock objects should not be exposed to callers. Built-in locks are reentrant, allowing the same thread to reacquire the monitor it already holds; the lock is automatically released when leaving the synchronized block, including in the case of an exception.
Critical Section Boundaries
boolean borrow() {
synchronized (lock) {
if (stock == 0) {
return false;
}
stock--;
return true;
}
}Checks and modifications must occur within the same critical section. Logging formatting and similar non-critical computations can be moved outside the lock, but business atomicity must never be broken to shrink the lock. Avoid calling remote services, disk I/O, or unknown callbacks while holding a lock, as this would increase latency and deadlock risks.
Experiment 1: Safe Counting and State Publication
import java.util.concurrent.CountDownLatch;
public class SynchronizationDemo {
private static final class Counter {
private int value;
synchronized void increment() {
value++;
}
synchronized int value() {
return value;
}
}
private static volatile boolean running = true;
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Thread first = incrementer(counter);
Thread second = incrementer(counter);
first.start();
second.start();
first.join();
second.join();
CountDownLatch ready = new CountDownLatch(1);
Thread watcher = new Thread(() -> {
ready.countDown();
while (running) {
Thread.onSpinWait();
}
}, "status-watcher");
watcher.start();
ready.await();
running = false;
watcher.join();
if (counter.value() != 20_000 || watcher.isAlive()) {
throw new AssertionError("Synchronization invariant failed");
}
System.out.println("Safe count: " + counter.value());
System.out.println("Stop flag: " + !running);
System.out.println("Synchronization check passed");
}
private static Thread incrementer(Counter counter) {
return new Thread(() -> {
for (int i = 0; i < 10_000; i++) {
counter.increment();
}
});
}
}Run:
chapter14_sync_root=$(mktemp -d)
mkdir -p "$chapter14_sync_root/out"
# Save the above code as "$chapter14_sync_root/SynchronizationDemo.java"
javac --release 17 -Xlint:all -Werror \
-d "$chapter14_sync_root/out" \
"$chapter14_sync_root/SynchronizationDemo.java"
java -ea -cp "$chapter14_sync_root/out" SynchronizationDemo
rm -rf -- "$chapter14_sync_root"Output:
Safe count: 20000
Stop flag: true
Synchronization check passedCounting is protected by a monitor, with two join() ensuring the main thread waits for work to complete before reading. running is an independent status flag, and writing to false happens-before the work thread's subsequent read of the same volatile field.
The busy waiting in the example serves only to demonstrate visibility on its own; real services should avoid idle consumption of core resources and instead prefer blocking queues, latches, or interruptible blocking operations.
4. What can volatile do
private volatile boolean running = true;A write to a volatile field happens-before any subsequent read of the same field. It's commonly used to:
- Cancellation or status flags;
- Publish a snapshot reference that is fully constructed and no longer modified in place;
- Some strictly designed single-variable state machines.
It can't protect:
volatile int count;
count++; // Compound read-modify-write
if (!initialized) { // Check and then execute
initialize();
}
left -= amount; // Multi-field invariant
right += amount;Calling volatile "immediately refreshes main memory" is inaccurate and misleading, as it might suggest transactional properties. When evaluating correctness, list the required happens-before edges rather than guessing when cache lines are refreshed.
AtomicInteger.incrementAndGet() can atomically update a single counter, but "inventory and loan records must be consistent" spans multiple values and requires higher-level locks or transactions. Atomic classes do not guarantee being faster than synchronized under all contention scenarios; the choice should be based on semantics and measured performance.
5. Common happens-before relationships in Java 17 for beginners
| Previous action | Next action |
|---|---|
| Unlock a monitor | Successfully locks the same monitor afterward |
| Write a volatile field | Then read the same volatile field |
Actions before calling thread.start() | Actions in the new thread |
| Actions taken before work thread termination | Actions taken by another thread after returning from join() |
| Actions before submitting a task to the executor | Actions within the task |
| Actions in the task | Actions after successfully returning from the corresponding Future.get() |
This table is more useful than "variables will eventually be seen": if there's no explicit edge, don't rely on visibility.
6. Conditional Waiting: What Happens When the Queue Is Full
Mutual exclusion can only make threads queue up to enter the critical section; it cannot express "even if we get the lock, we can't proceed." When a producer encounters a full queue, it must wait for "the queue to become non-full"; when a consumer encounters an empty queue, it must wait for "the queue to become non-empty."
The precise meaning of wait()
When calling monitor.wait() within synchronized (monitor):
- The caller must hold
monitor; otherwise, throwIllegalMonitorStateException; - It atomically releases
monitorand enters waiting, but retains any other locks held by the thread; - After notification, timeout, interruption, or a spurious wakeup, the thread first competes to reacquire
monitor; - After regaining the monitor,
wait()returns or throws an exception.
Thread.sleep() does not release held locks, so it cannot replace wait().
The condition must use while:
synchronized T take() throws InterruptedException {
while (elements.isEmpty()) {
wait();
}
T value = elements.removeFirst();
notifyAll();
return value;
}There are two reasons: permitting spurious wakeups; even if the notification is legitimate, another consumer might have already acquired the lock and removed the element.
notify() selects an arbitrary waiter, without guaranteeing randomness or fairness. notifyAll() wakes all waiters to compete for the monitor, which doesn't guarantee fairness, but in cases where multiple conditions share the same monitor, it's less likely to wake the "wrong category" leaving no progress.
Experiment 2: Implementing a Bounded Blocking Queue
This experiment is for understanding the monitor protocol. In practice, preferred usage should be of well-tested ArrayBlockingQueue or LinkedBlockingQueue.
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.Objects;
public class BlockingQueueDemo {
private static final class MyBlockingQueue<T> {
private final Deque<T> elements = new ArrayDeque<>();
private final int capacity;
MyBlockingQueue(int capacity) {
if (capacity <= 0) {
throw new IllegalArgumentException("capacity Must be greater than 0");
}
this.capacity = capacity;
}
synchronized void put(T value) throws InterruptedException {
Objects.requireNonNull(value, "Queue does not accept null");
while (elements.size() == capacity) {
wait();
}
elements.addLast(value);
notifyAll();
}
synchronized T take() throws InterruptedException {
while (elements.isEmpty()) {
wait();
}
T value = elements.removeFirst();
notifyAll();
return value;
}
synchronized int size() {
return elements.size();
}
}
public static void main(String[] args) throws InterruptedException {
MyBlockingQueue<Integer> queue = new MyBlockingQueue<>(2);
List<Integer> consumed = new ArrayList<>();
Thread consumer = new Thread(() -> {
try {
for (int i = 0; i < 5; i++) {
consumed.add(queue.take());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "consumer");
Thread producer = new Thread(() -> {
try {
for (int i = 1; i <= 5; i++) {
queue.put(i);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "producer");
consumer.start();
producer.start();
producer.join();
consumer.join();
if (!consumed.equals(List.of(1, 2, 3, 4, 5)) || queue.size() != 0) {
throw new AssertionError("Blocking queue does not maintain FIFO Invariant");
}
System.out.println("Consumption order: " + consumed);
System.out.println("Final size: " + queue.size());
System.out.println("Blocking queue check passed");
}
}Run:
chapter14_queue_root=$(mktemp -d)
mkdir -p "$chapter14_queue_root/out"
# Save the above code as "$chapter14_queue_root/BlockingQueueDemo.java"
javac --release 17 -Xlint:all -Werror \
-d "$chapter14_queue_root/out" \
"$chapter14_queue_root/BlockingQueueDemo.java"
java -ea -cp "$chapter14_queue_root/out" BlockingQueueDemo
rm -rf -- "$chapter14_queue_root"Output:
Consumption order: [1, 2, 3, 4, 5]
Final size: 0
Blocking queue check passedThe queue rejects null, otherwise null might denote an element and easily confuse with some queue API's "no result" indication. The test uses joins, not sleep: its assertions verify FIFO and an empty final queue; the put condition loop enforces the capacity bound. These assertions do not prove that blocking occurred in every run. Only the consumer writes consumed, and the main thread reads after join, so this List needs no concurrent implementation.
This teaching queue responds to interruption only inside wait(); waiting to enter synchronized is not interruptible. Tasks restore interrupt status, but the example has no additional cancellation protocol. If the producer exits prematurely, the consumer might wait forever. Production systems must also design shutdown mechanisms, such as poison pills, shutdown flags, canceling all tasks, or using timeout-supported standard queue operations.
7. Deadlocks and Stable Lock Ordering
Two threads might each hold a lock and wait for the other:
Thread A: holds account 1, waiting for account 2
Thread B: holds account 2, waiting for account 1"Locking two accounts at once" isn't enough. All threads must follow the same global order. A stable, unique ID provides a simple order, don't use business balances that might be identical and are inherently unstable.
Experiment 3: Transfer Funds in Stable Order
import java.util.ArrayList;
import java.util.List;
import java.util.SplittableRandom;
public class BankTransferDemo {
private static final class Account {
private final int id;
private int balance;
Account(int id, int balance) {
if (balance < 0) throw new IllegalArgumentException("Negative balance");
this.id = id;
this.balance = balance;
}
}
public static void main(String[] args) throws InterruptedException {
List<Account> accounts = new ArrayList<>();
for (int id = 0; id < 10; id++) {
accounts.add(new Account(id, 1_000));
}
List<Thread> workers = new ArrayList<>();
for (int workerId = 0; workerId < 5; workerId++) {
int seed = workerId;
Thread worker = new Thread(() -> transferBatch(accounts, seed), "transfer-" + workerId);
workers.add(worker);
worker.start();
}
for (Thread worker : workers) {
worker.join();
}
int total = accounts.stream().mapToInt(account -> account.balance).sum();
long negative = accounts.stream().filter(account -> account.balance < 0).count();
if (total != 10_000 || negative != 0) {
throw new AssertionError("Transfer invariant failure");
}
System.out.println("Balance conservation: " + total);
System.out.println("Negative balance accounts: " + negative);
System.out.println("Transfer check passed");
}
private static void transferBatch(List<Account> accounts, int seed) {
SplittableRandom random = new SplittableRandom(seed);
for (int i = 0; i < 100; i++) {
Account from = accounts.get(random.nextInt(accounts.size()));
Account to = accounts.get(random.nextInt(accounts.size()));
transfer(from, to, random.nextInt(1, 26));
}
}
private static void transfer(Account from, Account to, int amount) {
java.util.Objects.requireNonNull(from, "from");
java.util.Objects.requireNonNull(to, "to");
if (amount <= 0) throw new IllegalArgumentException("Non-positive amount");
if (from == to) {
return;
}
if (from.id == to.id) throw new IllegalArgumentException("Duplicate account id");
Account first = from.id < to.id ? from : to;
Account second = from.id < to.id ? to : from;
synchronized (first) {
synchronized (second) {
if (from.balance >= amount) {
int targetBalance = Math.addExact(to.balance, amount);
from.balance -= amount;
to.balance = targetBalance;
}
}
}
}
}Run:
chapter14_transfer_root=$(mktemp -d)
mkdir -p "$chapter14_transfer_root/out"
# Save the above code as "$chapter14_transfer_root/BankTransferDemo.java"
javac --release 17 -Xlint:all -Werror \
-d "$chapter14_transfer_root/out" \
"$chapter14_transfer_root/BankTransferDemo.java"
java -ea -cp "$chapter14_transfer_root/out" BankTransferDemo
rm -rf -- "$chapter14_transfer_root"Output:
Balance conservation: 10000
Negative balance accounts: 0
Transfer check passedBalances use integer cents. Construction rejects negative balances; transfers reject non-positive amounts and duplicate IDs on distinct objects. Target overflow fails before deduction. The example creates only IDs 0 through 9 and a total balance of 10000, so normal tasks cannot overflow. Insufficient funds leave balances unchanged. This is mutual exclusion within one process, without a persistent transaction.
The random seed gives each worker a repeatable transfer sequence, but thread interleaving still varies. The output only verifies invariants unaffected by scheduling.
After all worker threads join(), the main thread can read the balance. If the system needs to provide a balance snapshot during a transfer, the read must use the same locking strategy, or an alternative design that provides a consistent snapshot must be used.
Other Deadlock Prevention Measures
- Don't wait for network, file, or user input during hold periods;
- Don't call unknown external callbacks during lock retention;
- Use a concise method to bundle multiple locks in a specific order, preventing callers from manually combining them;
- Use
ReentrantLock.tryLock(timeout, unit)for timed acquisition orlockInterruptibly()for interruptible waiting; - When stuck online, obtain a thread dump and look for the
BLOCKEDthread and mutually held monitors.
8. Prioritize reducing shared state
Locking isn't the only solution:
| Question | Simpler alternative solution |
|---|---|
| Data created never changes | Immutable object |
| Data is used by only one work thread | Thread confinement |
| Producer hands work to consumer | BlockingQueue |
| Thread Waiting for a One-Time Event | CountDownLatch |
| Concurrent Mapping | Atomic Combination Method of ConcurrentHashMap |
| Independent single-value count | AtomicInteger/LongAdder, verify semantics first |
| Simple Composite Invariant | synchronized |
| Multi-condition, timeout, interruptible acquisition | ReentrantLock/Condition |
LongAdder.sum() is not an atomic snapshot during concurrent updates, so it cannot replace precise balance operations. Using ConcurrentHashMap does not mean that logic across multiple calls is automatically atomic. Prefer using compute, merge, putIfAbsent, etc., single-atomic combinations, rather than first containsKey then put.
9. Language Difference Window
CPython
The GIL is not a business lock. Checking inventory and then deducting it still needs synchronization across both steps; scheduling behavior of count += 1 in one release is not a portable guarantee. With the GIL enabled, I/O and extensions that release it permit concurrency; CPU parallelism often uses processes or such extensions. Since 3.13, CPython also offers a free-threaded build that can disable the GIL. Shared invariants still need locks or queues.
C++
In C++, conflicting actions in different threads form a data race when at least one is non-atomic and neither happens-before the other. Such a race causes undefined behavior. Use std::mutex or atomic types that satisfy the required memory ordering. Java's volatile cannot be directly equivalent to C++'s arbitrary std::atomic usage.
10. Common Failures
| Phenomenon | Checkpoint |
|---|---|
| Occasional undercount | Do all read-write operations follow the same locking protocol |
Still undercounts when converted to volatile int | ++ remains a composite operation |
wait() Throw monitor exception | Does the calling thread hold that object’s monitor |
| The thread still doesn't move after notification | Has notification been sent to different objects; could the condition still be invalid |
Use if to wait and occasionally encounter out-of-bounds | Change to while and recheck the condition |
During sleep(), others can't get the lock | sleep() doesn't release the monitor |
| Two opposing transfer transactions stall | Is the order of multiple locks globally consistent |
| Separating a check from its update to shorten a critical section | Critical section has violated invariants |
11. Practice
- Add timeout support to
MyBlockingQueuewithoffer. Explanation: First, clearly define how timeout, spurious wakeups, and remaining wait time are handled, then decide whether to implement it. - Change the queue call end to
ArrayBlockingQueue<Integer>and compare the number of maintained invariants. - Add a consistent balance snapshot method to the transfer experiment. Explain how the snapshot adheres to the same lock ordering.
- Construct a minimal deadlock example of "lock A then lock B" versus "lock B then lock A", running only in isolated processes; use thread dumps to identify circular waiting, don't include it in automated success tests.
Put the three invariants back into the stress test
The counting experiment must adhere to the same monitor protocol for all writes and reads; replacing int with volatile int still does not make ++ an atomic operation. Don't give an unsafe program a fixed "wrong answer", even if it occasionally returns 20000, it doesn't establish correctness.
A blocking queue must check conditions using while while holding the same monitor. Separate producer-first and consumer-first, and test capacity 1; output order may vary, but FIFO order, capacity, and the final empty invariant must remain unchanged. In real projects, prefer the standard BlockingQueue; hand-written versions are only for understanding the protocol.
The transfer experiment repeatedly performs reverse transfers, and after all threads join, it checks the total balance and non-negative balance. To read a consistent snapshot during runtime, reads must acquire all relevant locks and follow the same stable order. Lock ordering only makes sense when every path adheres to it.
Check the official rules for Java 17
- JLS Chapter 17: Threads and Locks: Memory model, data races, locks, and visibility.
- JLS 14.19: synchronized statement.
Object.wait/notifyAll: Monitor ownership, wake-up, and interrupts.BlockingQueue: The queue contract preferred for production code.
Next Step: Set limits for workstations and waiting areas
Concurrent operations need the same lock object and the same access protocol. A monitor protects the integrity of rules, volatile publishes individual state changes, and conditional waiting expresses "we can't proceed yet." Next, we delegate the task to a long-running thread and establish clear contracts for the waiting zone and overload behavior.