Skip to content

VOL 01 / CH 14 / LESSON 02

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:

text
Before borrowing, stock > 0; after borrowing, stock is exactly reduced by 1; stock is never negative.

The rules for bank transfers are:

text
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? ​

java
final class UnsafeCounter {
    private int count;

    void increment() {
        count++;
    }
}

count++ includes read, compute, and write-back. Possible issues may arise:

text
Thread A reads 100
Thread B reads 100
Thread A writes back 101
Thread B writes back 101

Two 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 ​

java
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 ​

java
private final Object lock = new Object();

void increment() {
    synchronized (lock) {
        value++;
    }
}
CodeLock 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 ​

java
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 ​

java
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:

bash
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:

text
Safe count: 20000
Stop flag: true
Synchronization check passed

Counting 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 ​

java
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:

java
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 actionNext action
Unlock a monitorSuccessfully locks the same monitor afterward
Write a volatile fieldThen read the same volatile field
Actions before calling thread.start()Actions in the new thread
Actions taken before work thread terminationActions taken by another thread after returning from join()
Actions before submitting a task to the executorActions within the task
Actions in the taskActions 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):

  1. The caller must hold monitor; otherwise, throw IllegalMonitorStateException;
  2. It atomically releases monitor and enters waiting, but retains any other locks held by the thread;
  3. After notification, timeout, interruption, or a spurious wakeup, the thread first competes to reacquire monitor;
  4. 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:

java
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.

java
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:

bash
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:

text
Consumption order: [1, 2, 3, 4, 5]
Final size: 0
Blocking queue check passed

The 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:

text
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 ​

java
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:

bash
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:

text
Balance conservation: 10000
Negative balance accounts: 0
Transfer check passed

Balances 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 or lockInterruptibly() for interruptible waiting;
  • When stuck online, obtain a thread dump and look for the BLOCKED thread and mutually held monitors.

8. Prioritize reducing shared state ​

Locking isn't the only solution:

QuestionSimpler alternative solution
Data created never changesImmutable object
Data is used by only one work threadThread confinement
Producer hands work to consumerBlockingQueue
Thread Waiting for a One-Time EventCountDownLatch
Concurrent MappingAtomic Combination Method of ConcurrentHashMap
Independent single-value countAtomicInteger/LongAdder, verify semantics first
Simple Composite Invariantsynchronized
Multi-condition, timeout, interruptible acquisitionReentrantLock/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 ​

PhenomenonCheckpoint
Occasional undercountDo all read-write operations follow the same locking protocol
Still undercounts when converted to volatile int++ remains a composite operation
wait() Throw monitor exceptionDoes the calling thread hold that object’s monitor
The thread still doesn't move after notificationHas notification been sent to different objects; could the condition still be invalid
Use if to wait and occasionally encounter out-of-boundsChange to while and recheck the condition
During sleep(), others can't get the locksleep() doesn't release the monitor
Two opposing transfer transactions stallIs the order of multiple locks globally consistent
Separating a check from its update to shorten a critical sectionCritical section has violated invariants

11. Practice ​

  1. Add timeout support to MyBlockingQueue with offer. Explanation: First, clearly define how timeout, spurious wakeups, and remaining wait time are handled, then decide whether to implement it.
  2. Change the queue call end to ArrayBlockingQueue<Integer> and compare the number of maintained invariants.
  3. Add a consistent balance snapshot method to the transfer experiment. Explain how the snapshot adheres to the same lock ordering.
  4. 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 ​

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.

Continue learning about thread pools and task results

Built with VitePress | Software Systems Atlas