Prevent race conditions with synchronized blocks, ReentrantLock, and ReadWriteLock.
Published September 21, 2026
When multiple threads access shared mutable state, you need mutual exclusion โ only one thread should modify the state at a time. Java offers two mechanisms: the synchronized keyword and the java.util.concurrent.locks package.
public class Counter {
private int count = 0;
// Method-level lock (locks on 'this')
public synchronized void increment() {
count++;
}
// Block-level lock (preferred โ smaller critical section)
public void incrementBlock() {
synchronized (this) {
count++;
}
}
// Static synchronized โ locks on the Class object
public static synchronized void staticMethod() { ... }
}
Every Java object has an intrinsic lock (monitor). synchronized acquires this lock on entry and releases it on exit โ even if an exception is thrown.
// Two synchronized methods on the same object share the same lock
public class BankAccount {
private double balance;
public synchronized void deposit(double amount) { balance += amount; }
public synchronized void withdraw(double amount) { balance -= amount; }
// deposit and withdraw cannot run concurrently on the same BankAccount
}
import java.util.concurrent.locks.*;
public class Counter {
private final ReentrantLock lock = new ReentrantLock();
private int count = 0;
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock(); // ALWAYS unlock in finally!
}
}
// Try to acquire lock without blocking
public boolean tryIncrement() {
if (lock.tryLock()) {
try { count++; return true; }
finally { lock.unlock(); }
}
return false;
}
// Try with timeout
public boolean tryIncrementTimeout() throws InterruptedException {
if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
try { count++; return true; }
finally { lock.unlock(); }
}
return false;
}
}
public class Cache<K, V> {
private final Map<K, V> map = new HashMap<>();
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
public V get(K key) {
readLock.lock(); // multiple threads can read simultaneously
try { return map.get(key); }
finally { readLock.unlock(); }
}
public void put(K key, V value) {
writeLock.lock(); // exclusive write access
try { map.put(key, value); }
finally { writeLock.unlock(); }
}
}
public class BoundedQueue<T> {
private final Queue<T> queue = new LinkedList<>();
private final int capacity;
private final Lock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
public void put(T item) throws InterruptedException {
lock.lock();
try {
while (queue.size() == capacity) notFull.await();
queue.add(item);
notEmpty.signal();
} finally { lock.unlock(); }
}
public T take() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) notEmpty.await();
T item = queue.poll();
notFull.signal();
return item;
} finally { lock.unlock(); }
}
}
| Feature | synchronized | ReentrantLock |
|---|---|---|
| Auto-unlock on exception | โ | โ (need finally) |
| Fairness policy | No | Yes (new ReentrantLock(true)) |
| tryLock() | No | โ |
| Multiple conditions | No | โ |
| Code readability | Better | More verbose |
ReentrantReadWriteLock lets multiple readers proceed concurrently, but every reader still has to acquire an actual read lock โ cheap, but not free, especially under very high read contention. StampedLock (Java 8+) adds a third mode, optimistic reading, that avoids acquiring a lock at all for the common case:
public class Point {
private final StampedLock lock = new StampedLock();
private double x, y;
double distanceFromOrigin() {
long stamp = lock.tryOptimisticRead(); // no actual lock taken
double currentX = x, currentY = y; // read without blocking anyone
if (!lock.validate(stamp)) { // did a writer sneak in while we read?
stamp = lock.readLock(); // fall back to a real read lock
try { currentX = x; currentY = y; }
finally { lock.unlockRead(stamp); }
}
return Math.sqrt(currentX * currentX + currentY * currentY);
}
void move(double deltaX, double deltaY) {
long stamp = lock.writeLock();
try { x += deltaX; y += deltaY; }
finally { lock.unlockWrite(stamp); }
}
}
tryOptimisticRead() doesn't block writers at all โ it just hands back a stamp. The read proceeds speculatively; validate(stamp) afterward checks whether a write happened in the meantime. If not, the optimistic read was safe and free of any locking cost; if a writer did intervene, the code falls back to a real (blocking) read lock. This trades a small amount of complexity for significantly better throughput under read-heavy, write-rare contention โ the exact profile ReentrantReadWriteLock already targets, just faster in the common case.
synchronized is reentrant โ a thread holding the lock can re-enter synchronized methods on the same object without blocking.volatile is NOT a replacement for synchronized โ it only guarantees visibility, not atomicity of compound operations.The code above shows how each locking tool works. Choosing between them comes down to a few questions:
AtomicInteger, AtomicReference, LongAdder) instead of a lock.synchronized is concise, can't be left locked by a forgotten unlock(), and the JVM optimizes it well. It's the right default.ReentrantLock with tryLock(timeout), lockInterruptibly(), or multiple Conditions.ReentrantReadWriteLock, or StampedLock for optimistic reads. Measure first: read-write locks have more overhead, and only pay off when reads dominate and critical sections are non-trivial.ConcurrentHashMap, CopyOnWriteArrayList or a BlockingQueue over wrapping ordinary collections in locks.A deadlock needs four conditions at once: mutual exclusion, holding one lock while waiting for another, no forced release, and a circular wait. The classic example is two transfers in opposite directions:
void transfer(Account from, Account to, long amount) {
synchronized (from) {
synchronized (to) { // T1 locks A then waits for B; T2 locks B then waits for A
from.debit(amount);
to.credit(amount);
}
}
}
The standard fix breaks the circular wait with a global lock order:
void transfer(Account from, Account to, long amount) {
Account first = from.id() < to.id() ? from : to; // always lock the lower id first
Account second = first == from ? to : from;
synchronized (first) {
synchronized (second) {
from.debit(amount);
to.credit(amount);
}
}
}
Other defences:
tryLock with a timeout, and back off and retry if the second lock isn't available.To diagnose a deadlock in production, take a thread dump (jstack <pid>, or jcmd <pid> Thread.print): the JVM reports "Found one Java-level deadlock", and shows which threads hold and wait for which monitors.
Q: Is synchronized slow?
A: Not in the common case. An uncontended lock is cheap, and modern JVMs optimize synchronized blocks heavily. Performance problems come from contention: many threads competing for the same lock. Reduce contention (smaller critical sections, lock striping, concurrent collections), rather than just swapping synchronized for ReentrantLock.
Q: What does "reentrant" mean?
A: A thread that already holds a lock can acquire it again without blocking. For example, a synchronized method can call another synchronized method on the same object. The lock keeps a hold count, and is released only when the count returns to zero. Both synchronized and ReentrantLock are reentrant.
Q: Why must unlock() always be in a finally block?
A: If the code between lock() and unlock() throws, and unlock() isn't in finally, the lock is never released, and every other thread that needs it blocks forever. synchronized releases automatically when the block exits, even by exception, which is one reason it's the safer default.
Q: What's the difference between wait()/notify() and Condition.await()/signal()?
A: Both let threads wait for a state change while releasing a lock. wait/notify work with an object's intrinsic monitor, which gives one wait set per object. Conditions belong to a Lock, and there can be several per lock, such as "notFull" and "notEmpty" in a bounded buffer, so you wake only the threads waiting for the relevant change. In both cases, always wait in a while loop that rechecks the condition, because of spurious wake-ups.
Q: What is lock striping?
A: Splitting one lock into several, each guarding part of the data. For example, 16 locks, each protecting the keys that hash to it. Threads working on different parts don't contend. Older versions of ConcurrentHashMap used segments this way, and the current version locks individual bins.