Skip to main content

Ch 13.2 Thread Synchronization (Synchronization)

In a multi-threaded environment, multiple threads share the same process memory. Without controls, concurrent access to shared data leads to unpredictable results. Synchronization prevents more than one thread from entering a critical section at the same time.

1. Race Condition

A race condition occurs when two or more threads access shared data concurrently, and the outcome depends on the timing of their execution.

public class RaceConditionExample {
static int count = 0; // shared resource

public static void main(String[] args) throws InterruptedException {
Runnable incrementTask = () -> {
for (int i = 0; i < 10000; i++) {
count++; // NOT atomic! (read → increment → write: 3 steps)
}
};

Thread t1 = new Thread(incrementTask);
Thread t2 = new Thread(incrementTask);

t1.start();
t2.start();

t1.join();
t2.join();

System.out.println("Expected: 20000");
System.out.println("Actual: " + count); // may be less than 20000!
}
}

count++ is actually three operations: read → increment → write. If two threads read the same value simultaneously, one increment is lost.

warning

Race conditions don't always reproduce, making them extremely difficult to debug. Prevention at the design stage is the most effective strategy.

2. synchronized Keyword

Method Synchronization

Mark an entire method as a critical section:

class BankAccount {
private int balance;

public BankAccount(int initialBalance) {
this.balance = initialBalance;
}

// Instance lock: only one thread can execute a synchronized method on the same object at a time
public synchronized void deposit(int amount) {
System.out.printf("[Deposit start] balance: %d, amount: %d%n", balance, amount);
balance += amount;
System.out.printf("[Deposit done] new balance: %d%n", balance);
}

public synchronized void withdraw(int amount) {
if (balance >= amount) {
System.out.printf("[Withdraw start] balance: %d, amount: %d%n", balance, amount);
try { Thread.sleep(100); } catch (InterruptedException e) {}
balance -= amount;
System.out.printf("[Withdraw done] new balance: %d%n", balance);
} else {
System.out.println("[Withdraw failed] Insufficient balance: " + balance);
}
}

public synchronized int getBalance() {
return balance;
}
}

Block Synchronization

Synchronize only the part that needs it, using a specific object as the lock. More performant than method synchronization:

class InventoryManager {
private int stock = 100;
private final Object stockLock = new Object(); // dedicated lock object

public void sellItem(int quantity) {
// Non-critical code can run concurrently
System.out.println("Preparing to process sale request...");

synchronized (stockLock) { // only the stock-related section is synchronized
if (stock >= quantity) {
stock -= quantity;
System.out.println("Sale complete. Remaining stock: " + stock);
} else {
System.out.println("Out of stock. Current stock: " + stock);
}
}

// Non-critical post-processing
System.out.println("Sale processed");
}
}

Instance Lock vs Class Lock

class LockExample {
// Instance lock: based on the object (this) — separate for each instance
public synchronized void instanceMethod() {
System.out.println("Instance lock acquired: " + Thread.currentThread().getName());
try { Thread.sleep(1000); } catch (InterruptedException e) {}
}

// Class lock: based on LockExample.class — shared across ALL instances
public static synchronized void classMethod() {
System.out.println("Class lock acquired: " + Thread.currentThread().getName());
try { Thread.sleep(1000); } catch (InterruptedException e) {}
}

public static void main(String[] args) {
LockExample obj1 = new LockExample();
LockExample obj2 = new LockExample();

// obj1 and obj2 have separate instance locks — can run concurrently
new Thread(obj1::instanceMethod).start(); // obj1's lock
new Thread(obj2::instanceMethod).start(); // obj2's lock (can run simultaneously)

// Static methods share the class lock — must run sequentially
new Thread(LockExample::classMethod).start();
new Thread(LockExample::classMethod).start(); // waits for the first to finish
}
}

3. wait() and notify() — Thread Communication

Even after preventing race conditions, a thread holding a lock too long can starve others. wait() and notify() enable threads to cooperate.

  • wait(): releases the lock and enters waiting state
  • notify(): wakes up one waiting thread
  • notifyAll(): wakes up all waiting threads
note

wait(), notify(), and notifyAll() must be called from inside a synchronized block.

4. Producer-Consumer Pattern

The classic use case for wait()/notify():

import java.util.LinkedList;
import java.util.Queue;

class SharedBuffer {
private final Queue<Integer> buffer = new LinkedList<>();
private final int MAX_SIZE = 5;

// Producer: add data
public synchronized void produce(int item) throws InterruptedException {
while (buffer.size() == MAX_SIZE) {
System.out.println("[Producer] Buffer full. Waiting...");
wait(); // release lock and wait
}
buffer.offer(item);
System.out.println("[Producer] Produced " + item + ". Buffer size: " + buffer.size());
notifyAll(); // wake up waiting consumers
}

// Consumer: consume data
public synchronized int consume() throws InterruptedException {
while (buffer.isEmpty()) {
System.out.println("[Consumer] Buffer empty. Waiting...");
wait(); // release lock and wait
}
int item = buffer.poll();
System.out.println("[Consumer] Consumed " + item + ". Buffer size: " + buffer.size());
notifyAll(); // wake up waiting producers
return item;
}
}

public class ProducerConsumerExample {
public static void main(String[] args) {
SharedBuffer buffer = new SharedBuffer();

// Producer thread
Thread producer = new Thread(() -> {
for (int i = 1; i <= 10; i++) {
try {
buffer.produce(i);
Thread.sleep(200);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});

// Consumer thread (consumes slower than producer)
Thread consumer = new Thread(() -> {
for (int i = 0; i < 10; i++) {
try {
buffer.consume();
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});

producer.start();
consumer.start();
}
}

5. Deadlock

A deadlock is a situation where two or more threads are each waiting for resources held by the others — none can proceed.

Four conditions required for deadlock(all must be true):

  1. Mutual Exclusion: resource can be held by only one thread at a time
  2. Hold and Wait: a thread holds a resource while waiting for another
  3. No Preemption: resources cannot be forcibly taken from a thread
  4. Circular Wait: threads form a cycle, each waiting on the next
public class DeadlockExample {
private static final Object lockA = new Object();
private static final Object lockB = new Object();

public static void main(String[] args) {
Thread t1 = new Thread(() -> {
synchronized (lockA) {
System.out.println("T1: acquired lockA");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("T1: waiting for lockB...");
synchronized (lockB) { // t2 holds lockB — will wait forever
System.out.println("T1: acquired lockB"); // never reached
}
}
});

Thread t2 = new Thread(() -> {
synchronized (lockB) {
System.out.println("T2: acquired lockB");
try { Thread.sleep(100); } catch (InterruptedException e) {}
System.out.println("T2: waiting for lockA...");
synchronized (lockA) { // t1 holds lockA — will wait forever
System.out.println("T2: acquired lockA"); // never reached
}
}
});

t1.start();
t2.start();
// This program never terminates!
}
}

Prevention: always acquire locks in the same order:

public class DeadlockSolution {
private static final Object lockA = new Object();
private static final Object lockB = new Object();

// Both methods acquire lockA first, then lockB — eliminates circular wait
static void safeMethod1() {
synchronized (lockA) {
synchronized (lockB) {
System.out.println("Safely acquired both locks: " + Thread.currentThread().getName());
}
}
}

static void safeMethod2() {
synchronized (lockA) { // same order!
synchronized (lockB) {
System.out.println("Safely acquired both locks: " + Thread.currentThread().getName());
}
}
}
}

6. volatile — Memory Visibility

In a multi-threaded environment, each thread may cache variables in CPU registers for performance. One thread's update may not be visible to another thread's cache. volatile forces all reads and writes to go directly to main memory.

public class VolatileExample {
// Without volatile → in some JVM/JIT environments the loop may never terminate
// private static boolean running = true;

// With volatile → always reads from main memory (visibility guaranteed)
private static volatile boolean running = true;

public static void main(String[] args) throws InterruptedException {
Thread worker = new Thread(() -> {
System.out.println("Worker thread: started");
while (running) { // always reads the latest value thanks to volatile
// working...
}
System.out.println("Worker thread: stopped");
});

worker.start();
Thread.sleep(1000);
running = false; // changed by main thread
System.out.println("Main thread: set running = false");
}
}

synchronized vs volatile

synchronizedvolatile
AtomicityGuaranteed (entire block)Read/write individually only
VisibilityGuaranteedGuaranteed
PerformanceRelatively slow (locking)Fast (no lock)
Use caseCompound operations, state changesSimple flag variables, single read/write

7. Practical Example: Thread-Safe Bank Account

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.atomic.AtomicInteger;

class SynchronizedBankAccount {
private final String accountNumber;
private int balance;
private final List<String> transactionHistory = new ArrayList<>();
private final AtomicInteger transactionCount = new AtomicInteger(0);

public SynchronizedBankAccount(String accountNumber, int initialBalance) {
this.accountNumber = accountNumber;
this.balance = initialBalance;
}

public synchronized boolean deposit(int amount, String depositor) {
if (amount <= 0) return false;
balance += amount;
String record = String.format("[%d] Deposit: +%,d (from: %s) | Balance: %,d",
transactionCount.incrementAndGet(), amount, depositor, balance);
transactionHistory.add(record);
System.out.println(record);
notifyAll(); // wake any threads waiting due to insufficient balance
return true;
}

public synchronized boolean withdraw(int amount, String requester) throws InterruptedException {
if (amount <= 0) return false;

// Wait up to 3 seconds if balance is insufficient
long deadline = System.currentTimeMillis() + 3000;
while (balance < amount) {
long remaining = deadline - System.currentTimeMillis();
if (remaining <= 0) {
System.out.printf("[Failed] Withdrawal timed out (requester: %s, amount: %,d)%n",
requester, amount);
return false;
}
System.out.printf("[Waiting] Insufficient balance (current: %,d, requested: %,d, by: %s)%n",
balance, amount, requester);
wait(remaining);
}

balance -= amount;
String record = String.format("[%d] Withdrawal: -%,d (by: %s) | Balance: %,d",
transactionCount.incrementAndGet(), amount, requester, balance);
transactionHistory.add(record);
System.out.println(record);
return true;
}

public synchronized int getBalance() { return balance; }

public void printHistory() {
System.out.println("\n=== Transaction History (" + accountNumber + ") ===");
transactionHistory.forEach(System.out::println);
System.out.printf("Final balance: %,d%n", balance);
}
}

public class BankSimulation {
public static void main(String[] args) throws InterruptedException {
SynchronizedBankAccount account = new SynchronizedBankAccount("KB-001", 100_000);

List<Thread> threads = new ArrayList<>();

// Deposit thread
threads.add(new Thread(() -> {
try {
Thread.sleep(500);
account.deposit(50_000, "Salary");
Thread.sleep(1000);
account.deposit(30_000, "Interest");
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Deposit-Thread"));

// Withdrawal threads (concurrent)
for (int i = 1; i <= 3; i++) {
final int amount = i * 30_000;
final String name = "User-" + i;
threads.add(new Thread(() -> {
try {
account.withdraw(amount, name);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "Withdraw-" + name));
}

for (Thread t : threads) t.start();
for (Thread t : threads) t.join();

account.printHistory();
}
}
Pro Tip

Always use while (not if) when checking the condition before calling wait(). Spurious wakeups (threads waking up without being notified) can occur in some JVM implementations — the while loop re-checks the condition after waking, preventing incorrect behavior.