Thread Synchronization in Java
In a multithreaded environment, multiple threads often need to share access to common resources (variables, objects, files). Without coordination, this concurrent access leads to Race Conditions, resulting in unpredictable behavior and data corruption.
Thread Synchronization is the capability to control the access of multiple threads to any shared resource, ensuring that only one thread can access the resource at a specific time.
1. Why Synchronization is Needed
Consider a simple counter incremented by two threads simultaneously. The operation count++ is not atomic; it consists of three steps:
- Read the current value of
count. - Increment the value by 1.
- Write the new value back to memory.
If Thread A reads count (e.g., 5), and before it writes 6, Thread B also reads count (still 5), both threads will write 6. One increment is lost.
2. Intrinsic Locks (Monitors)
Every object in Java has an intrinsic lock associated with it, often called a monitor. When a thread wants exclusive access to an object's fields, it must acquire the object's intrinsic lock before accessing them, and then release the intrinsic lock when it's done.
The synchronized Keyword
Java provides the synchronized keyword to utilize these intrinsic locks. It can be applied in two ways:
A. Synchronized Methods
When a method is declared synchronized, the thread automatically acquires the intrinsic lock for that method's object. Other threads calling any synchronized method on the same object will block until the lock is released.
public class Counter {
private int count = 0;
// The lock is 'this' (the current object instance)
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
Note: For static synchronized methods, the lock is acquired on the Class object (Counter.class), not the instance.
B. Synchronized Blocks
Synchronizing entire methods can be inefficient if only a small part of the method accesses shared data. Synchronized blocks allow you to narrow the scope of the lock and specify exactly which object's lock to acquire.
public class BankAccount {
private double balance;
// Best practice: use a dedicated, private, final lock object
private final Object lock = new Object();
public void deposit(double amount) {
// Non-critical operations here (e.g., input validation)
synchronized(lock) { // Acquire lock
balance += amount; // Critical section
} // Release lock
}
}
3. Explicit Locks: java.util.concurrent.locks
While synchronized is built-in and easy to use, it has limitations (e.g., a thread waiting for a lock cannot be interrupted, and it must block indefinitely). Java 5 introduced the Lock interface for more advanced locking mechanisms.
ReentrantLock
ReentrantLock behaves like synchronized but offers more features: fairness policies, interruptible lock acquisition, and timeout support.
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class SafeResource {
private final Lock lock = new ReentrantLock();
private int resourceState;
public void updateResource() {
lock.lock(); // Explicitly acquire lock
try {
resourceState++;
} finally {
lock.unlock(); // MUST be released in a finally block
}
}
// Example of tryLock
public void tryUpdate() {
if (lock.tryLock()) {
try {
resourceState++;
} finally {
lock.unlock();
}
} else {
System.out.println("Could not acquire lock, doing something else.");
}
}
}
4. Inter-Thread Communication (wait, notify, notifyAll)
Sometimes threads need to coordinate, not just mutually exclude each other. For example, in a Producer-Consumer scenario, a Consumer must wait if a queue is empty, and a Producer should notify the Consumer when an item is added.
These methods are defined in the Object class and must be called from within a synchronized context.
wait(): Causes the current thread to release the lock and wait until another thread wakes it up.notify(): Wakes up a single thread waiting on this object's lock.notifyAll(): Wakes up all threads waiting on this object's lock.
public class MessageQueue {
private String message;
private boolean hasMessage = false;
public synchronized void put(String msg) throws InterruptedException {
while (hasMessage) { // Always use a loop to check conditions (spurious wakeups)
wait(); // Release lock and wait for consumer
}
message = msg;
hasMessage = true;
notifyAll(); // Notify waiting consumers
}
public synchronized String take() throws InterruptedException {
while (!hasMessage) {
wait(); // Release lock and wait for producer
}
hasMessage = false;
notifyAll(); // Notify waiting producers
return message;
}
}
(Modern Java prefers BlockingQueue for this pattern, but understanding wait/notify is fundamental).
5. Potential Pitfalls: Deadlocks
A Deadlock occurs when two or more threads are blocked forever, waiting for each other to release locks.
Example Scenario:
- Thread 1 acquires Lock A and waits for Lock B.
- Thread 2 acquires Lock B and waits for Lock A.
Prevention Strategies:
- Lock Ordering: Always acquire multiple locks in a strict, consistent global order across all threads.
- Lock Timeouts: Use
tryLock(time, unit)fromReentrantLockto back off if all locks cannot be acquired. - Minimize Lock Scope: Hold locks for the shortest possible time.