Core Java · Lesson 11
Synchronization
Deep Dive: Synchronization and Thread Safety
Synchronization ensures that when multiple threads access shared data, they do so in an orderly, predictable way without corrupting that data. Without synchronization, threads can interfere with each other, causing data corruption, missed updates, and non-deterministic bugs. This lesson explores advanced synchronization patterns beyond basic synchronized keywords — including locks, atomic operations, and concurrent collections.
The Problem: Race Conditions and Data Corruption
A race condition occurs when the outcome depends on the timing of thread execution. Consider a simple counter: if two threads increment simultaneously, both might read the same value, increment it, and write back the same result — a lost update. This is why shared data access must be controlled.
public class RaceConditionExample {
private int counter = 0; // Shared data
// UNSAFE: no synchronization
public void unsafeIncrement() {
counter++; // This is NOT atomic: read, modify, write
}
// SAFE: synchronized
public synchronized void safeIncrement() {
counter++;
}
public int getCounter() {
return counter;
}
public static void main(String[] args) throws InterruptedException {
RaceConditionExample example = new RaceConditionExample();
// 100 threads, each incrementing 1000 times
Thread[] threads = new Thread[100];
for (int i = 0; i < 100; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
example.unsafeIncrement(); // Without sync: often < 100,000
// example.safeIncrement(); // With sync: always 100,000
}
});
threads[i].start();
}
for (Thread t : threads) {
t.join();
}
System.out.println("Counter: " + example.getCounter());
}
}
Synchronized Methods and Blocks
The synchronized keyword prevents concurrent access. A synchronized method locks the entire object; only one thread can execute it at a time. A synchronized block locks just a portion of code, offering finer-grained control and better performance. Always synchronize at the right granularity.
public class BankAccount {
private double balance;
private int transactionCount = 0;
// Synchronized method: locks entire method
public synchronized void withdraw(double amount) {
if (balance >= amount) {
balance -= amount;
transactionCount++;
}
}
// Synchronized block: locks just this critical section
public void transfer(BankAccount target, double amount) {
synchronized (this) { // Lock source account
if (this.balance >= amount) {
this.balance -= amount;
synchronized (target) { // Lock target account
target.balance += amount;
}
}
}
}
public synchronized double getBalance() {
return balance;
}
}
Locks: Beyond Synchronized
The Lock interface (java.util.concurrent.locks) provides more control than synchronized. ReentrantLock allows a thread to acquire the same lock multiple times, supports timeout-based locking, and can be interrupted. Use locks for complex locking scenarios.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class LockExample {
private int count = 0;
private Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock(); // Always unlock in finally
}
}
// Try lock with timeout
public boolean tryIncrementWithTimeout() {
try {
if (lock.tryLock(1, java.util.concurrent.TimeUnit.SECONDS)) {
try {
count++;
return true;
} finally {
lock.unlock();
}
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return false;
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
Atomic Operations: Lightweight Thread Safety
Atomic classes (AtomicInteger, AtomicLong, AtomicReference) provide thread-safe operations without explicit locking. They use hardware-level atomic instructions, making them faster than synchronization for simple operations. Use atomic classes for counters, flags, and other simple shared values.
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicBoolean;
public class AtomicExample {
private AtomicInteger counter = new AtomicInteger(0);
private AtomicBoolean running = new AtomicBoolean(true);
public void increment() {
counter.incrementAndGet(); // Atomic operation
}
public int getCount() {
return counter.get();
}
public void stop() {
running.set(false);
}
public boolean isRunning() {
return running.get();
}
public void compareAndSwap(int expectedValue, int newValue) {
// Atomic: only sets if current value equals expected
counter.compareAndSet(expectedValue, newValue);
}
public static void main(String[] args) throws InterruptedException {
AtomicExample example = new AtomicExample();
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 10000; j++) {
example.increment();
}
});
threads[i].start();
}
for (Thread t : threads) {
t.join();
}
System.out.println("Final count: " + example.getCount()); // 100,000
}
}
Concurrent Collections: Thread-Safe Data Structures
Concurrent collections (CopyOnWriteArrayList, ConcurrentHashMap, BlockingQueue) are designed for multithreading. They handle synchronization internally, avoiding the need for explicit locks. Use them instead of synchronizing standard collections.
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.concurrent.BlockingQueue;
import java.util.concurrent.LinkedBlockingQueue;
public class ConcurrentCollectionsExample {
public static void main(String[] args) throws InterruptedException {
// ConcurrentHashMap: thread-safe map without full locking
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("count", 0);
// CopyOnWriteArrayList: thread-safe list (good for read-heavy)
CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("Item 1");
// BlockingQueue: thread-safe queue with blocking operations
BlockingQueue<String> queue = new LinkedBlockingQueue<>();
// Producer thread
Thread producer = new Thread(() -> {
for (int i = 1; i <= 5; i++) {
try {
queue.put("Message-" + i);
System.out.println("Produced: Message-" + i);
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
// Consumer thread
Thread consumer = new Thread(() -> {
for (int i = 0; i < 5; i++) {
try {
String msg = queue.take(); // Blocks if empty
System.out.println("Consumed: " + msg);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
});
producer.start();
consumer.start();
producer.join();
consumer.join();
}
}
| Synchronization Mechanism | Use Case | Performance | Complexity |
|---|---|---|---|
| synchronized | Simple shared state | Good | Low |
| ReentrantLock | Complex locking needs | Good | Medium |
| AtomicInteger | Simple counters, flags | Excellent | Low |
| ConcurrentHashMap | Shared maps | Excellent | Low |
| BlockingQueue | Producer-consumer | Good | Medium |
Common Mistake: Synchronizing everything out of paranoia. Over-synchronization causes performance bottlenecks, deadlocks, and makes code harder to understand. Synchronize only the critical sections where data is actually shared. Also, be careful with lock ordering: if thread A locks X then Y, and thread B locks Y then X, deadlock occurs. Always acquire locks in the same order.
Best Practices for Synchronization
- Minimize synchronized regions: lock only what you need, as briefly as possible
- Use
AtomicIntegerfor simple counters instead of synchronized methods - Use concurrent collections instead of manually synchronizing standard ones
- Acquire locks in consistent order to prevent deadlocks
- Always release locks (use try-finally or try-with-resources)
- Prefer immutability: immutable objects are inherently thread-safe
- Document which fields are protected by which locks: helps others understand
- Use
volatilefor flags that don't need full synchronization
Real-World Example: Scalable Web Cache
A web caching layer stores frequently accessed data. Instead of synchronizing the entire cache (which would be slow), it uses ConcurrentHashMap for thread-safe lookups. Cache entries are immutable records, requiring no synchronization. When entries expire, background threads use ReentrantLock to safely update the eviction list. Atomic counters track cache hits and misses. This design scales well: hundreds of threads can read from the cache concurrently, and updates happen without blocking readers. By choosing the right synchronization tool for each part, the system achieves both safety and high performance.
Quiz Check