- Understand race conditions and thread safety issues
- Master the synchronized keyword for methods and blocks
- Learn the volatile keyword and memory visibility
- Understand deadlock and how to prevent it
Synchronization Basics
Multiple threads accessing shared data? Chaos. Two people withdrawing from the same bank account simultaneously—without coordination, the balance corrupts. Synchronization ensures threads access shared resources safely.
The Problem: Race Conditions
A race condition occurs when the outcome depends on unpredictable thread timing:
public class Counter {
private int count = 0;
public void increment() {
count++; // Looks atomic, but it's NOT!
}
public int getCount() {
return count;
}
}
The count++ operation is actually three steps:
- Read the current value of count
- Add 1 to that value
- Write the new value back
If two threads execute simultaneously:
Thread 1: Read count (0)
Thread 2: Read count (0) <- Both read 0!
Thread 1: Add 1 (result: 1)
Thread 2: Add 1 (result: 1) <- Both compute 1!
Thread 1: Write 1
Thread 2: Write 1 <- Final count: 1 (should be 2!)
The synchronized Keyword
The synchronized keyword ensures that only one thread can execute a block of code at a time:
Synchronized Methods
public class Counter {
private int count = 0;
public synchronized void increment() {
count++; // Now only one thread at a time can execute this
}
public synchronized int getCount() {
return count;
}
}
When a thread enters a synchronized method:
- It acquires the lock on the object (
this) - Other threads must wait to enter any synchronized method on the same object
- The lock is released when the method exits
Synchronized Blocks
For finer control, synchronize only the critical section:
public class Counter {
private int count = 0;
private final Object lock = new Object();
public void increment() {
// Non-critical code can run in parallel
doSomeWork();
synchronized (lock) {
// Only this block is synchronized
count++;
}
// More non-critical code
doMoreWork();
}
}
Choosing the Lock Object
// Using 'this' as lock (same as synchronized method)
synchronized (this) {
// ...
}
// Using a dedicated lock object (recommended)
private final Object lock = new Object();
synchronized (lock) {
// ...
}
// Using the class object (for static methods)
synchronized (Counter.class) {
// ...
}
Best practice: Use a private dedicated lock object to prevent external code from interfering with your synchronization.
What Gets Locked?
Understanding what synchronized actually locks is crucial:
public class BankAccount {
private double balance;
// Locks on 'this' (the BankAccount instance)
public synchronized void deposit(double amount) {
balance += amount;
}
public synchronized void withdraw(double amount) {
balance -= amount;
}
}
Two threads calling methods on the same BankAccount instance are synchronized. But threads calling methods on different BankAccount instances run in parallel.
BankAccount account1 = new BankAccount();
BankAccount account2 = new BankAccount();
// These block each other (same object)
thread1.deposit(100); // on account1
thread2.withdraw(50); // on account1
// These run in parallel (different objects)
thread3.deposit(100); // on account1
thread4.deposit(100); // on account2
Static Synchronized Methods
Static methods synchronize on the Class object:
public class GlobalCounter {
private static int count = 0;
// Locks on GlobalCounter.class
public static synchronized void increment() {
count++;
}
// Equivalent to:
public static void incrementExplicit() {
synchronized (GlobalCounter.class) {
count++;
}
}
}
Memory Visibility
Synchronization isn't just about mutual exclusion - it also guarantees memory visibility:
public class SharedFlag {
private boolean running = true;
public void stop() {
running = false; // Written by Thread 1
}
public void run() {
while (running) { // Read by Thread 2
// Without synchronization, Thread 2 might never see the change!
doWork();
}
}
}
Without synchronization, a thread might cache variables and never see updates from other threads. The synchronized keyword establishes a "happens-before" relationship that ensures visibility.
The volatile Keyword
For simple flags, volatile is a lighter-weight alternative:
public class SharedFlag {
private volatile boolean running = true; // Guaranteed visibility
public void stop() {
running = false;
}
public void run() {
while (running) { // Always sees the latest value
doWork();
}
}
}
volatile guarantees:
- Every read sees the latest write
- No caching of the variable
But volatile doesn't help with compound operations:
private volatile int count = 0;
count++; // Still not thread-safe! (read-modify-write)
Avoiding Common Pitfalls
1. Synchronizing on the Wrong Object
// WRONG - synchronizing on a local variable
public void badMethod() {
Object lock = new Object(); // New object each call!
synchronized (lock) {
// Not actually protecting anything
}
}
// CORRECT - synchronizing on a shared object
private final Object lock = new Object();
public void goodMethod() {
synchronized (lock) {
// Properly protected
}
}
2. Synchronizing on Mutable References
// WRONG - the lock object can change
private String name = "initial";
public void setName(String n) {
synchronized (name) { // Different threads might lock on different objects!
name = n;
}
}
// CORRECT - use a dedicated final lock
private final Object nameLock = new Object();
public void setName(String n) {
synchronized (nameLock) {
name = n;
}
}
3. Over-Synchronization
// Unnecessarily broad synchronization
public synchronized void process(List<Item> items) {
for (Item item : items) {
validateItem(item); // Doesn't need synchronization
transformItem(item); // Doesn't need synchronization
synchronized (this) {
saveItem(item); // Only this needs synchronization
}
}
}
Reentrant Locks
Java's intrinsic locks are reentrant - a thread can acquire the same lock multiple times:
public class ReentrantExample {
public synchronized void outer() {
System.out.println("In outer");
inner(); // Same thread can enter another synchronized method
}
public synchronized void inner() {
System.out.println("In inner");
}
}
Without reentrancy, the second call would deadlock waiting for a lock the thread already holds.
Deadlock
Deadlock occurs when threads wait for each other's locks forever:
public class DeadlockExample {
private final Object lock1 = new Object();
private final Object lock2 = new Object();
public void method1() {
synchronized (lock1) {
sleep(100); // Give other thread time to grab lock2
synchronized (lock2) {
// Do work
}
}
}
public void method2() {
synchronized (lock2) { // Acquires lock2 first
sleep(100);
synchronized (lock1) { // Waits for lock1... forever!
// Do work
}
}
}
}
Preventing Deadlock
- Always acquire locks in the same order:
// Both methods acquire lock1 first, then lock2
synchronized (lock1) {
synchronized (lock2) { ... }
}
- Use tryLock with timeout (with Lock interface):
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
// Do work
} finally {
lock.unlock();
}
} else {
// Couldn't acquire lock, handle gracefully
}
Race conditions happen when threads access shared mutable data unsafely. synchronized provides mutual exclusion and memory visibility. Always synchronize on the same shared object. Use volatile for simple flags that don't need compound operations. Avoid deadlock by acquiring locks in consistent order. Prefer synchronized blocks over methods for better granularity. Use private final lock objects for encapsulation. Reentrant locks let the same thread acquire the lock multiple times.
