MA-JAVA0 · Module 23
Races, shared state, and synchronized
Topics: Race · Critical section · synchronized · Visibility
Overview
Session 23 — Races, shared state, and synchronized
Two threads that never touch the same variables can run in peace. Trouble starts when they read and write the same field. This session walks through a lost-update at the Lantern Dock ticket counter, then repairs it with Java’s intrinsic lock (synchronized). You will also meet visibility: without a lock (or another happens-before edge), one thread may never see another’s writes.
Time: about 95 minutes.
Concepts
1. The Lantern Dock counter
Imagine a ferry booth with a field int remaining (seats left today). Two clerks—two threads—each sell one seat:
read remaining → compute remaining - 1 → write remaining
If both read 5 before either writes, both write 4. One sale vanished. That is a race: the outcome depends on interleaving, and some interleavings are wrong.
Races are not compiler errors. The program “works” on a quiet laptop and fails on a busy server. That is why we practise the broken version on paper first, then add a lock.
2. Critical sections
A critical section is a stretch of code that must appear atomic to other threads: no other thread may be in the same protected region on the same lock at the same time.
For the booth, “read–decrement–write remaining” is the critical section. Printing a receipt after the decrement can often stay outside the lock so you do not hold the lock while talking to a slow printer.
Too large a lock (the entire run() method) serialises the program—you paid for threads and got a queue. Too small a lock (only the write) still lets two reads of 5 sneak in. Match the lock to the data invariant you must keep (remaining never skips a sale).
3. synchronized methods and blocks
Every Java object has an intrinsic lock (monitor). synchronized acquires it.
Instance method — lock is this:
public synchronized boolean sellOne() {
if (remaining <= 0) {
return false;
}
remaining--;
return true;
}
Static method — lock is the Class object (LanternBooth.class). Use this for static fields, not as a global “lock the world” habit.
Block — you choose the lock object:
synchronized (gate) {
remaining--;
}
All code that mutates remaining must use the same lock object. Locking this in sellOne and a different private Object gate in refundOne does not protect the field.
Prefer a private final lock if the class is public and callers might synchronize on the instance for other reasons:
private final Object gate = new Object();
4. Reentrancy and this
The thread that already holds a monitor may enter another synchronized method on the same object. That is reentrancy. It avoids self-deadlock when sellOne calls logSale and logSale is also synchronized. It does not mean two threads can be inside at once.
5. Visibility, not only mutual exclusion
Locks do two jobs:
- Exclusion — one thread in the critical section.
- Visibility — when a thread releases a monitor, writes it made become visible to the next thread that acquires the same monitor.
Without that happens-before edge, a thread may keep reading a cached remaining forever (especially a boolean done flag). Session 24’s volatile is a lighter tool for simple flags, not a substitute for locking a multi-step update.
6. What synchronized does not do
- It does not make
i++on anint[]element safe unless every access uses the same lock. - It does not order unrelated objects. Two booths, two locks—fine. One booth, two locks—bug.
- It does not replace immutable design. If clerks only read a
recordsnapshot published after construction, you may need no lock at all.
7. Common confusions
“I used AtomicInteger, so the class is thread-safe.” One atomic field plus a second field (String lastBuyer) updated in a separate statement is still a race across fields. One lock (or one concurrent structure that covers the whole invariant) is the fix.
“synchronized on a local variable.” synchronized (new Object()) is useless: each call gets a fresh lock that nobody else waits on.
“Making the field volatile fixes remaining++.” volatile makes the latest write visible; ++ is still read–modify–write. Use a lock or AtomicInteger.
8. What you should be able to do
State the lost-update story in one paragraph, wrap the update in synchronized on a single private lock, and keep slow I/O outside that block. Next session: waiting when remaining == 0.
Examples
Example A — lost updates (broken on purpose)
public class RacyBooth {
static int remaining = 20;
static void sellMany(int n) {
for (int i = 0; i < n; i++) {
remaining = remaining - 1;
}
}
public static void main(String[] args) throws InterruptedException {
Thread a = new Thread(() -> sellMany(10));
Thread b = new Thread(() -> sellMany(10));
a.start();
b.start();
a.join();
b.join();
System.out.println("remaining=" + remaining);
}
}
Each clerk “sells” ten seats from a shared int. You hope for remaining=0. You may see 1, 2, … leftover seats that nobody accounted for. remaining = remaining - 1 is not one JVM instruction; two threads can both read the same old value.
This program is a lab, not a pattern to copy into production.
Example B — same booth, one lock
public class LockedBooth {
private int remaining = 20;
private final Object gate = new Object();
public boolean sellOne() {
synchronized (gate) {
if (remaining <= 0) {
return false;
}
remaining--;
return true;
}
}
public int seatsLeft() {
synchronized (gate) {
return remaining;
}
}
}
Every read and write of remaining uses gate. seatsLeft is synchronized too: without that, a reader might see a stale count even when sellers are locked.
In main, two threads loop on sellOne() until it returns false, then join, then print seatsLeft(). You should get 0.
Example C — keep I/O outside the lock
public class ReceiptAfterSale {
private int remaining = 5;
private final Object gate = new Object();
public Integer takeSeatNumber() {
synchronized (gate) {
if (remaining <= 0) {
return null;
}
int assigned = remaining;
remaining--;
return assigned;
}
}
public void sellAndPrint() {
Integer seat = takeSeatNumber();
if (seat == null) {
System.out.println("sold out");
return;
}
System.out.println("print slow receipt for seat " + seat);
}
}
The lock covers assigning the seat. Printing happens afterward so one slow printer does not freeze the other clerk.
Example D — the useless lock
synchronized (new Object()) {
remaining--;
}
Each entry creates a new lock object. No other thread will ever wait on it. If you see this in a review, treat it as unsynchronized.
Practice check — races and synchronized
Multiple-choice check from the Matheion app lesson. Pick an answer for each question, then submit.
1.Two threads both execute `remaining = remaining - 1` on a shared `int` with **no** lock. Why can a sale disappear?
2.A `synchronized` **instance** method acquires the monitor of…
3.Which lock is **useless** for sharing `remaining`?
4.`volatile` on `remaining` **alone** makes `remaining++` thread-safe.
5.Why might you assign a seat **inside** `synchronized` but print the receipt **outside**?