Thread Synchronization & Inter-Thread Communication

A self-contained Java concurrency study guide: a hand-copyable cheat sheet, from-scratch explanations, runnable code for every concept, and a graded hands-on lab. Built for Java 17+ (works on Java 8+).

Source key: everything is from the original session deck unless marked [+], which flags context, corrections, or examples added beyond the source. Branding, agendas, and setup chatter stripped.
Part I

Notebook Summary copy by hand

High-density cheat sheet. Monospace blocks copy cleanly into a notebook; doodle lines are ~10-second sketches.

A · Concurrency bugs

CONCURRENCY ISSUES  (shared data + many threads)
2 bug types:
  1. Thread interference (RACE CONDITION)  -> atomicity problem
  2. Memory consistency error             -> visibility problem

RACE CONDITION = 2+ threads read+write shared data at once -> unpredictable
  cause: op NOT atomic
  atomic = single step, all-or-nothing, no interrupt
  i++  = read -> modify -> write  (3 steps!) -> thread slips between
  Inventory: incr + decr on shared obj -> lost update -> wrong final
  final SHOULD be 1 or -1; race makes ops overwrite each other

CRITICAL SECTION = code touching shared resource, must be protected
  race = threads enter critical section WITHOUT sync
doodle: two arrows -> one box "items" -> collide = ⚡ (2 threads hit same var) doodle: i++ = [read]->[+1]->[write], red gap between read & write = intruder slips in

B · synchronized & monitors

SYNCHRONIZED (fix for races)
  every Java object -> has a MONITOR (intrinsic lock)
  enter sync region -> ACQUIRE monitor; others BLOCK till RELEASE
  lock is PER INSTANCE (diff objs = diff locks, no blocking)
  gives: mutual exclusion + visibility (BOTH bugs fixed)
  2 forms:
    METHOD -> locks whole method (this)
    BLOCK  -> synchronized(obj){...}  shortest hold = better
  Inventory fix: sync getItems/increment/decrement
    -> 1 thread at a time, read sees latest write
doodle: box = object, key on door, 1 stick figure inside, line waiting outside

C · Memory consistency & volatile

MEMORY CONSISTENCY ERROR
  = threads have inconsistent views; write not propagated -> stale read
  SmartFan: main flips fanOn, fanThread never sees -> never stops
  causes: uncoordinated updates · no sync · instruction reorder ·
          cache coherence · delayed visibility · optimistic compiler
  (root: per-core caches + JVM may hoist read out of loop)

VOLATILE
  visibility   -> write seen by all threads immediately (main mem)
  non-blocking -> unlike synchronized, visibility ONLY
  NOT atomic   -> i++ still needs sync
  use: shared var, care only about visibility (flags, e.g. fanOn)
doodle: main-mem box ↕ 2 core-cache boxes; volatile = arrows forcing sync ↕↕
volatile synchronized
Gives visibility visibility + atomicity
Blocks? no yes
Scope 1 variable block of ops

D · Rule of thumb & synchronized limits

RULE OF THUMB
  every shared var modified by >=1 thread ->
    EITHER  lock-guarded (synchronized) -> mutual exclusion + atomic
    OR      volatile -> happens-before, visibility
  shortcut: compound op? -> lock | just a flag? -> volatile

SYNCHRONIZED LIMITS (-> why advanced locks exist)
  1. no flexible acquire -> held till exit, cant try (bathroom lock)
  2. no fairness         -> not request order, starvation (swing bully)
  3. not interruptible   -> blocked thread cant be interrupted (stuck in line)
  4. no explicit lock/unlock -> implicit at block edges (auto door)

E · Advanced locks

REENTRANT LOCK (java.util.concurrent.locks)
  flexible alt to synchronized:
    tryLock() · tryLock(time,unit) · lockInterruptibly() · fairness
  must unlock() explicitly -> lock(); try{...} finally{unlock();}
  reentrant = same thread can re-acquire its own lock

SEMAPHORE (java.util.concurrent)
  lock = 1 thread; semaphore = up to N threads (permit counter)
  parking lot: 3 spots, car parks -> counter--, full -> wait
  init(1) = binary semaphore ~= lock
doodle: parking lot, 3 boxes, 2 cars parked + 1 free, line waiting at gate
Tool Use / hook
ReadWriteLock many readers OR 1 writer (library reading vs checkout)
CountDownLatch wait for event, count→0 then go (starting pistol, one-shot)
CyclicBarrier all reach point then go; resets/reusable (hikers checkpoint)
latch = counts DOWN once (one-shot) | barrier = CYCLIC (reusable)

F · Inter-thread comm & Producer-Consumer

wait() / notify() / notifyAll()  — on Object, via shared obj
  wait()      -> thread waits till woken; 0 CPU; RELEASES monitor
  notify()    -> wake 1 waiting thread
  notifyAll() -> wake ALL waiting threads
  MUST hold monitor (be in synchronized) else IllegalMonitorStateException
  best practice: wait() inside while(cond), not if

PRODUCER-CONSUMER (shared buffer)
  producer -> puts data | consumer -> takes data
  problems: OVERRUN (prod too fast) · UNDERRUN (cons too fast)
  Solution A — wait/notify:
    full  -> producer.wait(); after produce -> notify() consumer
    empty -> consumer.wait(); after consume -> notify() producer
  Solution B — BlockingQueue:
    thread-safe, auto-blocks, no manual wait/notify
    impls: ArrayBlockingQueue, LinkedBlockingQueue -> prefer in real code
doodle: [Producer] -> [ buffer ▢▢▢ ] -> [Consumer]; full=prod waits, empty=cons waits
Part II

Detailed Study Notes

One subsection per concept. Each opens with a one-line claim, builds the idea from scratch, then flags why it matters.

1 · Why concurrent code breaks

Sharing mutable data across threads creates two distinct bug families: race conditions (atomicity) and memory consistency errors (visibility).

Single-threaded code is predictable: statements run top to bottom. The moment two threads touch the same mutable data, correctness stops depending on your code's written order and starts depending on timing, CPU caches, and compiler optimizations.

Interference is like two people editing the same spreadsheet cell and overwriting each other. Consistency is like editing the cell while my screen still shows the old number because it never refreshed. A program can have one, the other, or both.

Why it mattersThese two categories map to the two tools you'll learn — synchronized/locks fix atomicity, volatile/synchronized fix visibility. Diagnosing which bug you have tells you which tool to reach for.

2 · Race conditions & atomicity

A race condition is when 2+ threads access and modify shared data simultaneously, producing unpredictable results.

In the Inventory Counter demo, one IncrementingThread and one DecrementingThread share a single counter. Run it repeatedly and the final value keeps changing instead of settling. The cause: increment() and decrement() are not atomic.

An atomic operation finishes in one indivisible step — it can't be interrupted or left half-done. [+] Although items++ looks like one line, the CPU runs three steps: read the value → add onewrite it back. A second thread can interleave between any two steps. If both read 0 before either writes, one write overwrites the other and an update is lost. The deck's walkthrough shows the result landing on 1 or -1 wrongly — logically it should be exactly one of those depending on order, but the race makes the operations clobber each other.

Pitfall"It's one line, so it's safe." Atomicity is about machine steps, not source-line length.
Interview angleBe ready to decompose i++ into read-modify-write — it's the canonical proof you understand races.

Code: InventoryCounter (broken)

3 · Critical sections

A critical section is code that accesses shared resources and must be protected from concurrent access.

Races happen precisely when threads enter a critical section without synchronization. The fix isn't shorter code — it's ensuring only one thread is inside at a time. Printer analogy: many people, one printer; with no coordination jobs overlap and pages mix. The rule "one person at a time" guarantees correct output. In Java, synchronized enforces that rule.

Why it mattersIdentifying the critical section is the first step of every fix — you guard that, nothing more, nothing less.

4 · The synchronized keyword & monitors

Every Java object owns a monitor (intrinsic lock); synchronized uses it so only one thread runs a guarded region on that object at a time.

When a thread enters a synchronized region it acquires the monitor tied to the object. Any other thread trying to enter the same — or any other — synchronized region on that same object blocks until release on exit.

Two forms: synchronized methods (lock = the instance, this) and synchronized blocks (synchronized(obj){…}, lock a chosen span on a chosen object). [+] Blocks are usually preferred — hold the lock for the shortest necessary window. Fixing the counter: synchronize getItems(), increment(), decrement() → one thread at a time, and a read sees the latest write.

Key nuance [+]The lock is per instance — two different counters have independent monitors and never block each other. Entering/exiting a monitor also establishes a happens-before edge, so synchronized fixes both atomicity and visibility for the guarded data.

Code: InventoryCounter (synchronized method & block)

5 · Memory consistency errors

A memory consistency error is when threads hold inconsistent views: one updates a shared variable but the change never propagates.

The SmartFan demo: a fan thread loops watching a fanOn flag. The main thread flips it, but the fan thread reads a stale cached copy, never reacts, and the program won't even terminate. The deck lists six causes: uncoordinated updates, lack of synchronization, instruction reordering (Java Memory Model), cache coherence issues, delayed visibility, and optimistic compiler behavior.

[+] Hardware view: each core has its own cache; a write may sit in one core's cache and not reach others promptly. The JVM may also hoist a repeated read out of a loop into a register — which is exactly how while(!fanOn) spins forever.

Pitfall"Only writers need protection." The reading thread needs the visibility guarantee too, or it never learns about the write.

Code: SmartFan (broken) · SmartFan (volatile)

6 · The volatile keyword

volatile guarantees visibility — a write is immediately seen by all threads and reads come from main memory, not a stale cache.

It is non-blocking (unlike synchronized) and not atomic — compound ops like i++ still need a lock. Declaring fanOn volatile fixes SmartFan: the change is instantly visible, the fan reacts, the program ends.

Trade-offs [+]Cheaper than locking (no blocking, no context switches) but strictly weaker — visibility only, never mutual exclusion. Correct use: a boolean stop/status flag. Misuse: volatile int count; count++; is still racy.
Interview anglevolatile = visibility, non-blocking, one variable. synchronized = visibility + atomicity, blocking, guards multiple ops.

Code: SmartFan (volatile)

7 · The rule of thumb

Every shared variable modified by at least one thread must be EITHER lock-guarded OR volatile.

Lock-guarded → mutual exclusion + atomicity. volatile → a happens-before relationship giving visibility. [+] Shortcut: compound action on the variable → lock; plain flag you write and others read → volatile.

Why it mattersThis single rule prevents the majority of beginner concurrency bugs. If you can't point to which half a shared variable satisfies, it's a latent bug.

8 · Limitations of synchronized

synchronized is simple but rigid — four gaps motivate the advanced locks.
Limitation Consequence Analogy
No flexible acquisition Held till exit; can't try without blocking Bathroom lock — everyone waits, even for a quick check
No fairness Not in request order; starvation possible Swing the stronger kids always grab
Not interruptible Blocked waiter can't be interrupted Stuck in line, can't step out for an urgent call
No explicit lock/unlock Implicit at block edges; no early/ordered unlock Door that only locks on entry, unlocks on exit
Why it mattersThese exact gaps are what java.util.concurrent was built to close.

9 · ReentrantLock

ReentrantLock is a flexible alternative to synchronized that closes its four gaps.

It adds tryLock() (non-blocking attempt), tryLock(time, unit) (bounded wait), lockInterruptibly() (interruptible acquisition), and configurable fairness. [+] The cost: you must unlock() explicitly — always in a finally so it releases even on exceptions. "Reentrant" = the same thread can re-acquire a lock it already holds without deadlocking itself.

Pitfall [+]Forgetting finally { lock.unlock(); } leaks the lock and hangs every other thread. This is the #1 ReentrantLock bug.

Code: Restaurant (ReentrantLock)

10 · Semaphore

A lock admits one thread; a semaphore admits up to N — it's a counter of permits.

Parking-lot analogy: three spots, a long line of cars; only three park at once. Each acquire decrements the counter; when full, newcomers wait for a release. [+] A semaphore of 1 behaves like a lock (binary semaphore). Use semaphores to throttle concurrency — cap DB connections or in-flight API calls.

Why it mattersMany real bottlenecks aren't "one at a time" but "at most N at a time" — semaphores model that directly.

Code: ParkingLot (Semaphore)

11 · Other specialized locks

Three more coordination tools, each for a distinct shape of problem.
Tool Use case Analogy
ReadWriteLock Many readers OR one writer Library: many read, one at checkout
CountDownLatch Wait for an event before proceeding Starting pistol; count→0 then go (one-shot)
CyclicBarrier All threads reach a point before continuing Hikers' checkpoint; resets & reusable
Interview angle [+]A latch counts down once and is done; a barrier is cyclic and reusable. ReadWriteLock shines on read-heavy data since concurrent reads don't block each other.

Code: CountDownLatch & CyclicBarrier demos

12 · Inter-thread communication: wait() / notify() / notifyAll()

These let threads signal each other through a shared object, so one can pause until another reports a change.

wait() parks the current thread until woken, consuming no CPU, and [+] releases the monitor so others can progress. notify() wakes one waiter; notifyAll() wakes all. Rule: you must hold the object's monitor (be inside synchronized on it) or you get IllegalMonitorStateException.

Interview angle [+]These live on Object (not Thread) because the mechanism is tied to the object's monitor, which every object has. Always wait() inside a while(condition) loop, not an if, to survive spurious wakeups. Prefer notifyAll() when multiple conditions share one lock.

Code: Producer-Consumer (wait/notify)

13 · The Producer-Consumer problem

Producer and consumer threads share a buffer; coordination prevents overflow and underflow.

The producer adds data; the consumer removes it. Two challenges: buffer overrun (producer too fast → full buffer) and buffer underrun (consumer too fast → empty buffer).

Solution A — wait/notify: producer wait()s when full and notify()s after producing; consumer wait()s when empty and notify()s after consuming. Solution B — BlockingQueue: a thread-safe queue that auto-blocks on full/empty, needs no manual wait/notify; implementations include ArrayBlockingQueue and LinkedBlockingQueue, and it scales easily to many producers/consumers.

Interview angle [+]Prefer BlockingQueue in real code — manual wait/notify is easy to deadlock or mis-signal. Use the raw mechanism to prove understanding or for behavior the queue can't express.

Code: Producer-Consumer (wait/notify) · Producer-Consumer (BlockingQueue)


Key takeaways

Part III

Code Samples runnable · Java 17+

Real, compilable code for every concept the notes reference. Standard library only. Each has a filename, run command, inline comments, and expected output.

1 · InventoryCounter — the race (broken)

InventoryCounter.javarun: java InventoryCounter.java
public class InventoryCounter {
    private int items = 0;                 // shared mutable state
    public void increment() { items++; }    // read-modify-write (NOT atomic)
    public void decrement() { items--; }
    public int  getItems()  { return items; }

    public static void main(String[] a) throws InterruptedException {
        InventoryCounter c = new InventoryCounter();
        // each thread does 1,000,000 ops on the SAME object
        Thread inc = new Thread(() -> { for (int i=0;i<1_000_000;i++) c.increment(); });
        Thread dec = new Thread(() -> { for (int i=0;i<1_000_000;i++) c.decrement(); });
        inc.start(); dec.start();
        inc.join();  dec.join();          // wait for both to finish
        System.out.println("items = " + c.getItems()); // expect 0... rarely is
    }
}
Expected output (varies every run)items = -48217 // or 13902, or 0... never reliably 0

Why: 1M increments and 1M decrements should cancel to 0. But items++ and items-- each read-modify-write; when they interleave, updates are lost, so the total drifts. The exact number changes per run because thread scheduling is nondeterministic.

2 · InventoryCounter — fixed with synchronized

SyncCounter.javarun: java SyncCounter.java
public class SyncCounter {
    private int items = 0;
    // METHOD form: lock is 'this' instance's monitor
    public synchronized void increment() { items++; }
    public synchronized void decrement() { items--; }
    public synchronized int  getItems()  { return items; }

    // BLOCK form does the same thing, finer-grained:
    private final Object lock = new Object();
    public void incrementBlock() { synchronized (lock) { items++; } }

    public static void main(String[] a) throws InterruptedException {
        SyncCounter c = new SyncCounter();
        Runnable up = () -> { for(int i=0;i<1_000_000;i++) c.increment(); };
        Runnable dn = () -> { for(int i=0;i<1_000_000;i++) c.decrement(); };
        Thread t1=new Thread(up), t2=new Thread(dn);
        t1.start(); t2.start(); t1.join(); t2.join();
        System.out.println("items = " + c.getItems());
    }
}
Expected output (every run)items = 0

Why: only one thread can hold the instance's monitor at a time, so each items++/-- completes fully before the other thread runs — no lost updates. Synchronizing getItems() too guarantees the read sees the latest write.

3 · SmartFan — the visibility bug (broken)

SmartFan.javarun: java SmartFan.java
public class SmartFan {
    private static boolean fanOn = false;   // shared flag, NOT volatile

    public static void main(String[] a) throws InterruptedException {
        Thread fan = new Thread(() -> {
            while (!fanOn) { /* busy-wait: may read a CACHED false forever */ }
            System.out.println("Fan is ON! Cooling the room...");
        });
        fan.start();
        Thread.sleep(1000);
        System.out.println("User: Turning the fan ON...");
        fanOn = true;                          // main writes; fan thread may never see it
    }
}
Expected output (buggy)User: Turning the fan ON... // "Fan is ON!" never prints; program hangs and never exits

Why: the JIT can hoist fanOn into a register and loop on a stale false, never re-reading main memory. The write is real but invisible to the fan thread. [+] On some JVMs/timings it may eventually print — visibility without volatile is "undefined," not "guaranteed broken." That's why you can't rely on it.

4 · SmartFan — fixed with volatile

SmartFanVolatile.javarun: java SmartFanVolatile.java
public class SmartFanVolatile {
    private static volatile boolean fanOn = false; // visibility guaranteed

    public static void main(String[] a) throws InterruptedException {
        Thread fan = new Thread(() -> {
            while (!fanOn) { }       // now always re-reads from main memory
            System.out.println("Fan is ON! Cooling the room...");
        });
        fan.start();
        Thread.sleep(1000);
        System.out.println("User: Turning the fan ON...");
        fanOn = true;                 // instantly visible to fan thread
        fan.join();
    }
}
Expected output (every run)User: Turning the fan ON... Fan is ON! Cooling the room...

Why: volatile forces every read to come from main memory and every write to flush to it, establishing happens-before. The fan thread now sees true immediately and exits cleanly.

5 · ReentrantLock — tryLock & the finally idiom

Restaurant.javarun: java Restaurant.java
import java.util.concurrent.locks.ReentrantLock;
import java.util.concurrent.TimeUnit;

public class Restaurant {
    private static final ReentrantLock table = new ReentrantLock(true); // true = fair

    static void dine(String who) {
        try {
            // try up to 1s to get the table; don't block forever
            if (table.tryLock(1, TimeUnit.SECONDS)) {
                try {
                    System.out.println(who + " is dining");
                    Thread.sleep(300);
                } finally {
                    table.unlock();          // ALWAYS release in finally
                }
            } else {
                System.out.println(who + " gave up waiting");
            }
        } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
    }

    public static void main(String[] a) {
        for (String n : new String[]{"Ann","Bo","Cy"})
            new Thread(() -> dine(n)).start();
    }
}
Expected output (order fair, exact timing varies)Ann is dining Bo is dining Cy is dining

Why: tryLock(1, SECONDS) gives the flexibility synchronized lacks — a bounded wait instead of blocking forever. The finally { unlock(); } is mandatory; without it a thrown exception would leak the lock. new ReentrantLock(true) grants the table in request order (fairness).

6 · Semaphore — the parking lot (N permits)

ParkingLot.javarun: java ParkingLot.java
import java.util.concurrent.Semaphore;

public class ParkingLot {
    private static final Semaphore spots = new Semaphore(3); // 3 permits

    static void park(int car) {
        try {
            spots.acquire();                 // take a permit (waits if 0 free)
            System.out.println("Car "+car+" parked. Free="+spots.availablePermits());
            Thread.sleep(500);
        } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        finally {
            System.out.println("Car "+car+" leaving");
            spots.release();                 // return the permit
        }
    }
    public static void main(String[] a) {
        for (int i=1;i<=6;i++){ int c=i; new Thread(()->park(c)).start(); }
    }
}
Expected output (3 park, then as spots free, rest follow)Car 1 parked. Free=2 Car 2 parked. Free=1 Car 3 parked. Free=0 Car 1 leaving Car 4 parked. Free=0 ... // never more than 3 parked at once

Why: the semaphore caps concurrency at 3. Cars 4–6 block on acquire() until a parked car calls release(). Replace 3 with 1 and it behaves like a lock.

7 · CountDownLatch & CyclicBarrier

LatchAndBarrier.javarun: java LatchAndBarrier.java
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.CyclicBarrier;

public class LatchAndBarrier {
    public static void main(String[] a) throws Exception {
        // --- CountDownLatch: 3 runners wait for one starting pistol ---
        CountDownLatch pistol = new CountDownLatch(1);
        for (int i=1;i<=3;i++){ int r=i;
            new Thread(()->{ try{ pistol.await(); // block until count==0
                System.out.println("Runner "+r+" GO");
            }catch(InterruptedException e){} }).start();
        }
        Thread.sleep(300);
        System.out.println("BANG!"); pistol.countDown(); // one-shot release

        // --- CyclicBarrier: 3 hikers must all arrive before moving ---
        CyclicBarrier checkpoint = new CyclicBarrier(3,
            () -> System.out.println("All arrived -> move on")); // barrier action
        for (int i=1;i<=3;i++){ int h=i;
            new Thread(()->{ try{ Thread.sleep(h*200);
                System.out.println("Hiker "+h+" at checkpoint");
                checkpoint.await(); // wait for the other two
            }catch(Exception e){} }).start();
        }
    }
}
Expected outputBANG! Runner 1 GO Runner 2 GO Runner 3 GO Hiker 1 at checkpoint Hiker 2 at checkpoint Hiker 3 at checkpoint All arrived -> move on

Why: the latch's countDown() fires once and can't be reset — after it hits 0 it stays open. The barrier's await() blocks each hiker until all 3 arrive, runs the barrier action, then resets so it could be reused for a next checkpoint.

8 · Producer-Consumer — wait/notify

ProducerConsumer.javarun: java ProducerConsumer.java
import java.util.LinkedList;
import java.util.Queue;

public class ProducerConsumer {
    private final Queue<Integer> buffer = new LinkedList<>();
    private final int CAP = 3;

    public void produce(int v) throws InterruptedException {
        synchronized (buffer) {                 // must hold monitor to wait/notify
            while (buffer.size() == CAP) buffer.wait(); // full -> wait (releases lock)
            buffer.add(v);
            System.out.println("produced "+v+" size="+buffer.size());
            buffer.notifyAll();                    // wake waiting consumer(s)
        }
    }
    public int consume() throws InterruptedException {
        synchronized (buffer) {
            while (buffer.isEmpty()) buffer.wait(); // empty -> wait
            int v = buffer.poll();
            System.out.println("   consumed "+v);
            buffer.notifyAll();                    // wake waiting producer(s)
            return v;
        }
    }
    public static void main(String[] a) {
        ProducerConsumer pc = new ProducerConsumer();
        new Thread(()->{ try{ for(int i=1;i<=5;i++) pc.produce(i); }catch(Exception e){} }).start();
        new Thread(()->{ try{ for(int i=1;i<=5;i++){ pc.consume(); Thread.sleep(100);} }catch(Exception e){} }).start();
    }
}
Expected output (interleaving varies; never exceeds size=3)produced 1 size=1 produced 2 size=2 produced 3 size=3 consumed 1 produced 4 size=3 consumed 2 ...

Why: wait() is called inside while (not if) to re-check the condition after waking — guarding against spurious wakeups. It releases the buffer's monitor while waiting so the other side can proceed. notifyAll() wakes the counterpart. Capacity 3 is never exceeded.

9 · Producer-Consumer — BlockingQueue (the clean way)

ProducerConsumerBQ.javarun: java ProducerConsumerBQ.java
import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.BlockingQueue;

public class ProducerConsumerBQ {
    public static void main(String[] a) {
        // capacity 3; put() blocks when full, take() blocks when empty
        BlockingQueue<Integer> q = new ArrayBlockingQueue<>(3);
        new Thread(()->{ try{ for(int i=1;i<=5;i++){ q.put(i); // auto-blocks if full
            System.out.println("put "+i); } }catch(InterruptedException e){} }).start();
        new Thread(()->{ try{ for(int i=1;i<=5;i++){ int v=q.take(); // auto-blocks if empty
            System.out.println("   took "+v); Thread.sleep(100);} }catch(InterruptedException e){} }).start();
    }
}
Expected output (no wait/notify needed)put 1 put 2 put 3 took 1 put 4 took 2 ...

Why: ArrayBlockingQueue handles all locking internally. put() blocks the producer when full; take() blocks the consumer when empty. Same behavior as #8 with ~half the code and no chance of a missed-notification deadlock — the reason to prefer it in production.

Part IV

Hands-On Lab practice

Predict the output before running each one — concurrency intuition is built by being wrong and seeing why.

Setup (one time)terminal
# 1. Check Java (need 11+, 17+ ideal). Single-file run needs 11+.
java -version

# 2. Make a lab folder
mkdir -p ~/concurrency-lab && cd ~/concurrency-lab

# 3. Each exercise = one .java file. Run it directly (no manual compile):
java Exercise1.java

# (Older Java? Compile then run:)
javac Exercise1.java && java Exercise1
Warm-up · 1

Reproduce a race condition

Goal: See a non-atomic counter produce a wrong total.

  1. Create a shared int count = 0 in a class.
  2. Start two threads, each doing count++ 1,000,000 times.
  3. join() both, then print count.
Predict → verify: Write down the number you expect. Run it 3 times. Is it 2,000,000? Why does it differ each run?
Solution
Exercise1.javarun: java Exercise1.java
public class Exercise1 {
    static int count = 0;
    public static void main(String[] a) throws InterruptedException {
        Runnable r = () -> { for(int i=0;i<1_000_000;i++) count++; };
        Thread a1=new Thread(r), b=new Thread(r);
        a1.start(); b.start(); a1.join(); b.join();
        System.out.println(count); // expect 2,000,000; almost never is
    }
}

Lost updates: both threads read the same value before either writes back, so increments vanish.

Warm-up · 2

Fix it with synchronized

Goal: Make Exercise 1 deterministic.

  1. Move count++ into a synchronized method inc().
  2. Have both threads call inc() in their loop.
  3. Run 3 times.
Predict → verify: Will it now always print 2,000,000? Will it run faster or slower than Ex1, and why?
Solution
Exercise2.javarun: java Exercise2.java
public class Exercise2 {
    static int count = 0;
    static synchronized void inc() { count++; } // critical section
    public static void main(String[] a) throws InterruptedException {
        Runnable r = () -> { for(int i=0;i<1_000_000;i++) inc(); };
        Thread a1=new Thread(r), b=new Thread(r);
        a1.start(); b.start(); a1.join(); b.join();
        System.out.println(count); // always 2,000,000
    }
}

Always correct now, but slower than Ex1 — lock acquire/release on every increment adds overhead. Correctness over raw speed.

Core · 3

Visibility: stop a thread with a flag

Goal: Reproduce a visibility bug, then fix it with volatile.

  1. A worker thread loops while(running){} on a non-volatile boolean running=true.
  2. After 1s, main sets running=false and prints "stop requested".
  3. If it hangs, add volatile to running and rerun.
Predict → verify: Does the worker stop without volatile? Does behavior change if you add a System.out.println() inside the loop — and why might that "accidentally fix" it?
Solution
Exercise3.javarun: java Exercise3.java
public class Exercise3 {
    static volatile boolean running = true; // remove 'volatile' to see the bug
    public static void main(String[] a) throws InterruptedException {
        Thread w = new Thread(() -> {
            long n=0; while (running) n++;
            System.out.println("worker stopped after "+n+" loops");
        });
        w.start(); Thread.sleep(1000);
        running = false; System.out.println("stop requested");
    }
}

Without volatile the worker may loop forever on a cached true. A println inside the loop can mask the bug because I/O includes internal synchronization that flushes memory — a misleading "fix," never rely on it.

Core · 4

Throttle with a Semaphore

Goal: Allow at most 2 of 5 workers to "download" at once.

  1. Create Semaphore(2).
  2. Each of 5 threads: acquire() → print "downloading" → sleep 500ms → release() in finally.
  3. Print availablePermits() as you go.
Predict → verify: How many "downloading" lines appear before any "done"? What happens if you forget release()?
Solution
Exercise4.javarun: java Exercise4.java
import java.util.concurrent.Semaphore;
public class Exercise4 {
    static final Semaphore s = new Semaphore(2);
    static void work(int id){
        try{ s.acquire();
            System.out.println("worker "+id+" downloading (free="+s.availablePermits()+")");
            Thread.sleep(500);
        }catch(InterruptedException e){Thread.currentThread().interrupt();}
        finally{ System.out.println("worker "+id+" done"); s.release(); }
    }
    public static void main(String[] a){
        for(int i=1;i<=5;i++){int id=i;new Thread(()->work(id)).start();}
    }
}

Only 2 download simultaneously. Forgetting release() permanently shrinks available permits — eventually everything blocks forever (a permit leak).

Challenge · 5

Producer-Consumer with wait/notify

Goal: Build a bounded buffer (capacity 5) with one producer and one consumer using only wait()/notify().

  1. Shared Queue<Integer> + capacity 5, guarded by synchronized.
  2. Producer waits when full; consumer waits when empty.
  3. Use while (not if) around wait(); call notifyAll() after each op.
Predict → verify: What breaks if you change while to if? What happens if you remove the synchronized block around wait()?
Solution
Exercise5.javarun: java Exercise5.java
import java.util.*;
public class Exercise5 {
    static final Queue<Integer> buf = new LinkedList<>();
    static final int CAP = 5;
    static void produce(int v) throws InterruptedException {
        synchronized(buf){
            while(buf.size()==CAP) buf.wait();   // while, not if!
            buf.add(v); System.out.println("P "+v+" (size "+buf.size()+")");
            buf.notifyAll();
        }
    }
    static void consume() throws InterruptedException {
        synchronized(buf){
            while(buf.isEmpty()) buf.wait();
            System.out.println("  C "+buf.poll());
            buf.notifyAll();
        }
    }
    public static void main(String[] a){
        new Thread(()->{try{for(int i=1;i<=10;i++)produce(i);}catch(Exception e){}}).start();
        new Thread(()->{try{for(int i=1;i<=10;i++){consume();Thread.sleep(80);}}catch(Exception e){}}).start();
    }
}

if instead of while → a spurious/early wakeup proceeds on a still-full/empty buffer, corrupting it. Removing synchronizedIllegalMonitorStateException, because wait/notify require holding the monitor.

Challenge · 6

Rewrite #5 with a BlockingQueue

Goal: Achieve the same behavior with zero manual synchronization, then compare line counts.

  1. Replace the buffer with ArrayBlockingQueue<>(5).
  2. Producer calls put(); consumer calls take().
  3. Delete all synchronized/wait/notify code.
Predict → verify: Roughly how many fewer lines? Which version would you ship, and what did the queue handle that you wrote by hand in #5?
Solution
Exercise6.javarun: java Exercise6.java
import java.util.concurrent.*;
public class Exercise6 {
    public static void main(String[] a){
        BlockingQueue<Integer> q = new ArrayBlockingQueue<>(5);
        new Thread(()->{try{for(int i=1;i<=10;i++){q.put(i);System.out.println("P "+i);}}catch(InterruptedException e){}}).start();
        new Thread(()->{try{for(int i=1;i<=10;i++){System.out.println("  C "+q.take());Thread.sleep(80);}}catch(InterruptedException e){}}).start();
    }
}

~25 lines shorter and no chance of a missed notification. The queue handled capacity blocking, monitor management, and signalling — everything you hand-coded in #5. Ship this one.

Self-check rubric — how to know you've got it

  • Atomicity: You can explain why Ex1 ≠ 2,000,000 by decomposing count++ into read-modify-write.
  • Mutual exclusion: You know why Ex2 is correct but slower, and that the lock is per-instance.
  • Visibility: You can state why Ex3 hangs without volatile and why println can mask it.
  • volatile vs synchronized: You can say which gives atomicity and which only visibility — without looking.
  • Semaphore: You can explain "N permits" vs a lock and what a permit leak causes.
  • Coordination: You can pick latch (one-shot) vs barrier (reusable) for a given scenario.
  • wait/notify: You can explain why while (not if) and why the monitor must be held.
  • Judgment: You'd reach for BlockingQueue over hand-rolled wait/notify in production and justify it.

Built from the original session deck · content marked [+] was added beyond the source · Java 17+, standard library only.