Synchronization — Cheat Sheet
Operating Systems · 6 topics. Download the PDF or the Instagram carousel and share it.
Race Condition & Critical Section
A race condition occurs when the outcome of concurrent operations depends on the unpredictable interleaving of thread execution, typically when multiple threads access shared mutable state without synchronization.
- ✓Race condition: outcome depends on thread interleaving — occurs when shared mutable state is accessed without synchronization.
- ✓Critical section requirements: mutual exclusion, progress, bounded waiting.
- ✓Java count++ is not atomic — three bytecode instructions (read, add, write) can be interleaved.
- ✓synchronized provides mutual exclusion using a monitor lock — simple but may cause contention.
- ✓AtomicInteger uses hardware CAS instruction — lock-free, faster for single-variable updates.
- ✓LongAdder uses striped counters for better throughput than AtomicLong under high contention.
// RACE CONDITION — shared counter with two threads
class RaceCounter {
private int count = 0;
// NOT thread-safe — count++ is 3 bytecode ops:
// 1. GETFIELD (read count into register)
// 2. ICONST_1 + IADD (add 1)
// 3. PUTFIELD (write back)
// Thread A can be preempted between steps 1 and 3!
public void increment() { count++; }
public int get() { return count; }
}
RaceCounter counter = new RaceCounter();
int THREADS = 100, INCREMENTS_EACH = 1000;
List<Thread> threads = new ArrayList<>();
for (int i = 0; i < THREADS; i++) {
threads.add(new Thread(() -> {
for (int j = 0; j < INCREMENTS_EACH; j++) counter.increment();
}));
}
threads.forEach(Thread::start);
for (Thread t : threads) t.join();
System.out.println("Expected: " + (THREADS * INCREMENTS_EACH)); // 100,000
System.out.println("Actual: " + counter.get()); // < 100,000 due to race!
// Fix 1: synchronized method — mutex, most readable
synchronized void incrementSafe() { count++; }
// Fix 2: AtomicInteger — lock-free CAS, fastest for single variable
AtomicInteger atomicCount = new AtomicInteger(0);
atomicCount.incrementAndGet(); // atomic compare-and-swap — no raceMutex vs Semaphore
A mutex is a binary lock with ownership used for mutual exclusion; a semaphore is an integer counter without ownership used for signalling and resource counting.
- ✓Mutex: binary lock with ownership — only the locking thread can unlock; used for mutual exclusion.
- ✓Semaphore: integer counter without ownership — any thread can signal; used for resource counting and signalling.
- ✓ReentrantLock is Java's mutex — supports tryLock, timed lock, and fair ordering.
- ✓Java Semaphore(n) controls access to n identical resources — blocks when permits reach 0.
- ✓Binary semaphore (init=1) resembles a mutex but lacks ownership — a different thread can signal.
- ✓Always release semaphores and unlock mutexes in a finally block to prevent deadlock on exception.
// ReentrantLock as mutex — only the locker can unlock
ReentrantLock mutex = new ReentrantLock();
// Thread-safe balance transfer
class BankAccount {
private final ReentrantLock lock = new ReentrantLock();
private long balance;
BankAccount(long initial) { this.balance = initial; }
public void transfer(BankAccount to, long amount) throws InterruptedException {
// tryLock with timeout — avoids indefinite blocking
if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
if (balance >= amount) {
balance -= amount;
to.deposit(amount);
System.out.printf("Transferred %d — balance: %d%n", amount, balance);
}
} finally {
lock.unlock(); // ALWAYS unlock in finally
}
} else {
System.out.println("Could not acquire lock — skipping transfer");
}
}
public synchronized void deposit(long amount) { balance += amount; }
public long getBalance() { return balance; }
}
BankAccount alice = new BankAccount(1000);
BankAccount bob = new BankAccount(500);
alice.transfer(bob, 200);
System.out.println("Alice: " + alice.getBalance() + " Bob: " + bob.getBalance());Monitors & Condition Variables
A monitor combines a mutex and condition variables into a single high-level construct; threads call wait() to release the lock and sleep, and notify() to wake sleeping threads.
- ✓Monitor = mutex + condition variable + shared data combined in one construct.
- ✓Java object intrinsic lock: synchronized acquires it; wait() releases it and parks; notify()/notifyAll() wakes.
- ✓Spurious wakeups are permitted by the Java spec — always use while loop, never if, around wait().
- ✓notifyAll() wakes all threads from the wait set; notify() wakes one (nondeterministically).
- ✓ReentrantLock + Condition allows multiple named wait sets — avoids waking unrelated threads.
- ✓Always acquire lock in try and release in finally to prevent deadlock on exception.
// Correct monitor pattern: while loop around wait()
class BoundedBuffer<T> {
private final Queue<T> buffer = new LinkedList<>();
private final int capacity;
BoundedBuffer(int capacity) { this.capacity = capacity; }
// Producer: put item, wait if buffer is full
public synchronized void put(T item) throws InterruptedException {
while (buffer.size() == capacity) { // MUST be while, not if!
wait(); // releases lock, parks thread — avoids CPU spinning
}
buffer.add(item);
notifyAll(); // wake all waiting consumers (and other producers)
System.out.println("Produced: " + item + " buffer=" + buffer.size());
}
// Consumer: take item, wait if buffer is empty
public synchronized T take() throws InterruptedException {
while (buffer.isEmpty()) { // MUST be while — spurious wakeup protection
wait();
}
T item = buffer.poll();
notifyAll(); // wake all waiting producers
System.out.println("Consumed: " + item + " buffer=" + buffer.size());
return item;
}
}
BoundedBuffer<Integer> buf = new BoundedBuffer<>(3);
Thread producer = new Thread(() -> {
for (int i = 0; i < 10; i++) {
try { buf.put(i); } catch (InterruptedException e) { break; }
}
});
Thread consumer = new Thread(() -> {
for (int i = 0; i < 10; i++) {
try { buf.take(); } catch (InterruptedException e) { break; }
}
});
producer.start(); consumer.start();
producer.join(); consumer.join();Producer-Consumer Problem
The producer-consumer problem models threads that produce and consume items through a shared bounded buffer, requiring synchronization so producers wait when the buffer is full and consumers wait when it is empty.
- ✓Producer-consumer requires: mutual exclusion on buffer state, blocking when full (producers), blocking when empty (consumers).
- ✓synchronized + wait/notifyAll: correct but notifyAll() causes thundering herd on every state change.
- ✓ReentrantLock + two Conditions: targeted signal() — notFull wakes producers, notEmpty wakes consumers.
- ✓BlockingQueue (ArrayBlockingQueue): idiomatic Java solution — handles all synchronization internally.
- ✓Poison pill: sentinel value placed by producer to signal consumers to shut down gracefully.
- ✓SynchronousQueue: zero-capacity queue for direct handoff — each put() blocks until a take() is ready.
// Producer-Consumer: synchronized + wait/notifyAll
class SharedBuffer {
private final int[] buffer;
private int count = 0, in = 0, out = 0;
SharedBuffer(int size) { buffer = new int[size]; }
public synchronized void produce(int item) throws InterruptedException {
while (count == buffer.length) wait(); // full → block
buffer[in] = item;
in = (in + 1) % buffer.length;
count++;
notifyAll(); // wake sleeping consumers
}
public synchronized int consume() throws InterruptedException {
while (count == 0) wait(); // empty → block
int item = buffer[out];
out = (out + 1) % buffer.length;
count--;
notifyAll(); // wake sleeping producers
return item;
}
}
SharedBuffer buf = new SharedBuffer(5);
Thread producer = new Thread(() -> {
for (int i = 1; i <= 20; i++) {
try { buf.produce(i); System.out.println("Produced: " + i); }
catch (InterruptedException e) { break; }
}
});
Thread consumer = new Thread(() -> {
for (int i = 1; i <= 20; i++) {
try { System.out.println("Consumed: " + buf.consume()); }
catch (InterruptedException e) { break; }
}
});
producer.start(); consumer.start();
producer.join(); consumer.join();Readers-Writers Problem
The readers-writers problem allows multiple readers to read shared data concurrently but requires exclusive access for writers, balancing throughput and fairness.
- ✓Multiple readers can hold the read lock concurrently — no blocking between readers.
- ✓A writer requires exclusive access — blocks all readers and other writers.
- ✓First readers-writers (readers preferred): writers may starve; second (writers preferred): readers may starve.
- ✓Java ReentrantReadWriteLock(fair=true) prevents starvation at the cost of some throughput.
- ✓Lock downgrade (write → read) is supported; lock upgrade (read → write) is NOT and causes deadlock.
- ✓ReadWriteLock provides significant throughput gains for read-heavy, write-rare workloads (caches, config).
// Read-write lock: concurrent reads, exclusive writes
ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock(true); // fair=true
Lock readLock = rwLock.readLock();
Lock writeLock = rwLock.writeLock();
Map<String, String> sharedConfig = new HashMap<>();
// Multiple readers can proceed simultaneously
Runnable reader = () -> {
readLock.lock();
try {
// All reader threads proceed in parallel — no blocking between readers
String value = sharedConfig.get("key");
System.out.printf("[%s] Read: %s%n", Thread.currentThread().getName(), value);
Thread.sleep(50); // simulate read processing
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
readLock.unlock(); // ALWAYS unlock in finally
}
};
// Writer gets exclusive access — blocks all readers and writers
Runnable writer = () -> {
writeLock.lock();
try {
sharedConfig.put("key", "value-" + System.currentTimeMillis());
System.out.printf("[%s] Write completed%n", Thread.currentThread().getName());
Thread.sleep(20); // simulate write processing
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
writeLock.unlock();
}
};
// Throughput comparison: 10 readers, 1 writer
ExecutorService exec = Executors.newFixedThreadPool(12);
for (int i = 0; i < 10; i++) exec.submit(reader);
exec.submit(writer);
exec.submit(reader); // this reader doesn't block other readers
exec.shutdown();
exec.awaitTermination(5, TimeUnit.SECONDS);Dining Philosophers Problem
Five philosophers alternating between thinking and eating share five forks, demonstrating the deadlock problem and its solutions: ordered lock acquisition, arbitrator, and asymmetric strategies.
- ✓Deadlock requires all four conditions: mutual exclusion, hold and wait, no preemption, circular wait.
- ✓Dining philosophers deadlock: all pick left fork, wait for right → circular wait.
- ✓Breaking circular wait by enforcing global lock ordering prevents deadlock.
- ✓Asymmetric solution: always acquire locks in lower-index-first order across all threads.
- ✓Java thread dump (jstack) identifies deadlock cycles: "Found one Java-level deadlock."
- ✓General rule: when acquiring multiple locks, always acquire them in the same global order across all threads.
// Deadlock-prone dining philosophers
ReentrantLock[] forks = new ReentrantLock[5];
for (int i = 0; i < 5; i++) forks[i] = new ReentrantLock();
Runnable deadlockPhilosopher(int id) {
return () -> {
int left = id;
int right = (id + 1) % 5;
while (true) {
// DEADLOCK: all pick up left fork, then wait for right
forks[left].lock(); // pick up left fork
System.out.println("P" + id + " has left fork " + left);
try { Thread.sleep(10); } catch (InterruptedException e) { return; }
// All 5 philosophers reach here simultaneously:
// P0 waits for fork1 (held by P1)
// P1 waits for fork2 (held by P2) → CIRCULAR WAIT = DEADLOCK
forks[right].lock();
try {
System.out.println("P" + id + " eating");
Thread.sleep(50);
} catch (InterruptedException e) {
forks[right].unlock(); return;
} finally {
forks[right].unlock();
}
forks[left].unlock();
}
};
}
// Detect: jstack <pid> → "Found one Java-level deadlock"
// Shows cycle: Thread-0 waiting for lock held by Thread-1 waiting for ... Thread-4 waiting for Thread-0