synchronized vs ReentrantLock
synchronized is the language-level mutex, concise, JVM-optimized, automatic release on exception. ReentrantLock is the API-level mutex, adds tryLock with timeout, fairness, multiple Conditions, lock interruptibility. Pick based on which features the call site needs.
When to use what
Decision tree
- Need just mutual exclusion? →
synchronized. Default choice. - Need timeout / interruptibility / fairness? →
ReentrantLock. - Need multiple wait conditions (e.g., notFull + notEmpty)? →
ReentrantLock+Condition. - Read-heavy, rare writes? →
ReentrantReadWriteLock. - Read-mostly with very rare writes, hot path? →
StampedLockoptimistic.
The synchronized advantages
- Concise, no try/finally boilerplate.
- Auto-release on exception, exception in critical section never leaks the lock.
- JVM-optimized, lock coarsening, escape analysis, thin/inflated locks. Uncontended fast path is fast.
- Reentrant, same thread can re-enter.
- Familiar, every Java engineer knows it.
The ReentrantLock advantages
tryLock(timeout), back off if can't acquire in time. Critical for deadlock avoidance.- Fairness, FIFO ordering. Optional, costs throughput.
- Multiple Conditions, separate signals for "buffer not full" vs "buffer not empty."
- Interruptible acquire,
lockInterruptibly()letsinterrupt()cancel the wait. - Programmatic introspection, query
getQueueLength(),isHeldByCurrentThread().
The Java 21 wrinkle: virtual threads pin on synchronized
Virtual threads + I/O
A virtual thread inside a synchronized block CANNOT unmount from its carrier thread, it's "pinned." If the synchronized code does I/O, the carrier blocks too, defeating the point of virtual threads.
Fix: use ReentrantLock instead of synchronized in code paths that virtual threads will execute AND that include blocking I/O. The JVM unmounts virtual threads on lock.lock() correctly.
JEP 491 (Java 24) removes monitor pinning for synchronized methods/blocks; on JDK 24+ this caveat largely disappears. On earlier LTS releases, prefer ReentrantLock for virtual-thread-heavy services.
What about Atomic*?
For single-variable updates (counters, flags, references), AtomicInteger/AtomicReference/LongAdder are faster than locks. Locks are for multi-variable invariants, when the critical section spans multiple fields. Don't reach for a lock when an atomic suffices.
Primitives by language
- synchronized (block, method)
- ReentrantLock + Condition.await/signal
- ReentrantReadWriteLock
- StampedLock (optimistic reads)
Implementation
synchronized (lock) enters the monitor; auto-releases on exit (including exceptions). The JVM applies lock coarsening, escape analysis, and (pre-JDK 15) biased locking. For ~95% of code, this is the right choice.
1 class Counter {
2 private int value = 0;
3 private final Object lock = new Object();
4
5 public void inc() {
6 synchronized (lock) {
7 value++;
8 }
9 }
10 // Or simpler, synchronized method = synchronized(this):
11 public synchronized int get() { return value; }
12 }Use ReentrantLock for: tryLock with timeout (avoid deadlock), fairness (FIFO), multiple wait conditions, or lock acquisition that can be interrupted. The cost: explicit unlock + try/finally.
1 import java.util.concurrent.locks.*;
2
3 class TimeoutCounter {
4 private int value = 0;
5 private final ReentrantLock lock = new ReentrantLock();
6
7 public boolean incWithTimeout(long ms) throws InterruptedException {
8 if (!lock.tryLock(ms, TimeUnit.MILLISECONDS)) {
9 return false; // couldn't acquire in time
10 }
11 try {
12 value++;
13 return true;
14 } finally {
15 lock.unlock(); // ALWAYS in finally
16 }
17 }
18 }synchronized has only wait/notify/notifyAll, one condition per monitor. ReentrantLock supports multiple Condition objects. Crucial for bounded buffers (notFull / notEmpty), wakes only the right kind of waiter.
1 import java.util.concurrent.locks.*;
2 import java.util.LinkedList;
3
4 class BoundedBuffer<T> {
5 private final LinkedList<T> queue = new LinkedList<>();
6 private final int capacity;
7 private final ReentrantLock lock = new ReentrantLock();
8 private final Condition notFull = lock.newCondition();
9 private final Condition notEmpty = lock.newCondition();
10
11 public BoundedBuffer(int capacity) { this.capacity = capacity; }
12
13 public void put(T item) throws InterruptedException {
14 lock.lock();
15 try {
16 while (queue.size() == capacity) notFull.await();
17 queue.add(item);
18 notEmpty.signal(); // wakes ONE consumer
19 } finally { lock.unlock(); }
20 }
21
22 public T take() throws InterruptedException {
23 lock.lock();
24 try {
25 while (queue.isEmpty()) notEmpty.await();
26 T item = queue.removeFirst();
27 notFull.signal();
28 return item;
29 } finally { lock.unlock(); }
30 }
31 }Many concurrent readers OR one writer. Reader contention is eliminated. Use when reads dominate writes by 5x+. Writer-preferring by default; pass true for fair (FIFO) ordering.
1 import java.util.concurrent.locks.*;
2
3 class CachedConfig {
4 private final ReentrantReadWriteLock rw = new ReentrantReadWriteLock();
5 private Map<String, String> data = new HashMap<>();
6
7 public String get(String key) {
8 rw.readLock().lock();
9 try { return data.get(key); }
10 finally { rw.readLock().unlock(); }
11 }
12
13 public void reload(Map<String, String> newData) {
14 rw.writeLock().lock();
15 try { data = newData; }
16 finally { rw.writeLock().unlock(); }
17 }
18 }Java 8+. Optimistic read: assume no writer; verify the stamp at end. If a writer ran during the read, retry pessimistically. Wins when writes are RARE, reads pay no synchronization cost on the fast path.
1 import java.util.concurrent.locks.StampedLock;
2
3 class OptimisticCache {
4 private final StampedLock sl = new StampedLock();
5 private double x, y;
6
7 public double distanceFromOrigin() {
8 long stamp = sl.tryOptimisticRead();
9 double curX = x, curY = y;
10 if (!sl.validate(stamp)) {
11 // Writer ran; fall back to pessimistic read
12 stamp = sl.readLock();
13 try { curX = x; curY = y; }
14 finally { sl.unlockRead(stamp); }
15 }
16 return Math.sqrt(curX * curX + curY * curY);
17 }
18 }Key points
- •synchronized: cheap, JVM-optimized, auto-release. Default choice.
- •ReentrantLock: tryLock(timeout), fairness, multiple Conditions, interruptible
- •Both reentrant, same thread can re-acquire
- •synchronized.notify() / notifyAll() vs ReentrantLock.newCondition().signal()
- •Java 21+ virtual threads + synchronized = pinning (use ReentrantLock instead for I/O-heavy code)
Follow-up questions
▸When is synchronized faster than ReentrantLock?
▸Why does virtual thread + synchronized 'pin' the carrier?
▸When to choose StampedLock?
▸synchronized on this, bad practice?
Gotchas
- !ReentrantLock without try/finally → exception leaks the lock
- !synchronized on String literals or autoboxed Integers → shared monitors, deadlocks across unrelated code
- !Fairness in locks adds overhead, use only when measurement justifies it
- !Reading a long/double without synchronization on 32-bit JVMs is NOT atomic
- !Holding a lock during an external call (network, callback) → deadlock if callee tries to lock something held by caller