Free lesson · Concurrency Interview Mastery
synchronized vs ReentrantLock
Java gives you two ways to get mutual exclusion: the built-in synchronized keyword and the explicit ReentrantLock class from java.util.concurrent.locks. They solve the same core problem - only one thread in the critical section at a time - but ReentrantLock trades simplicity for a set of capabilities synchronized can't offer. This lesson is the side-by-side so you know which to reach for and can defend the choice. Short version up front: default to synchronized; reach for ReentrantLock when you specifically need one of its extra powers.
synchronized: the built-in lock
synchronized uses an object's intrinsic lock (its "monitor"). You can synchronize a method or a block.
// Synchronized method - locks on 'this'
public synchronized void increment() {
count++;
}
// Synchronized block - locks on a specific object, finer-grained
private final Object lock = new Object();
public void increment() {
synchronized (lock) {
count++;
}
}
The defining virtue is that lock release is automatic. When the block or method exits - whether normally or by throwing an exception - the lock is released. You cannot forget to unlock, and an exception cannot strand the lock. That safety is why synchronized should be your default.
It also provides the memory guarantees from the JMM lesson (happens-before via the monitor), and it's reentrant - a thread already holding the lock can re-enter another synchronized block on the same monitor without deadlocking itself.
The limits of synchronized: you can't try to acquire it without committing to block forever; you can't time out; you can't interrupt a thread that's blocked waiting for it; and you can't control fairness. If you need any of those, you need ReentrantLock.
ReentrantLock: the explicit lock
ReentrantLock does the same mutual exclusion but you control acquisition and release explicitly - which means you're responsible for releasing it, always in a finally block.
private final ReentrantLock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock(); // MUST be in finally - or an exception strands the lock forever
}
}
Warning: Always unlock in a
finally. If an exception is thrown inside the critical section and you unlock afterward withouttry/finally, the lock is never released and every other thread blocks on it forever - a self-inflicted deadlock. This manual-release burden is the price ofReentrantLock's flexibility, and it's exactly the footgun thatsynchronizedremoves for you. If you don't need the extra features, this is a reason to prefersynchronized.
What ReentrantLock buys you
The reason to accept the manual-release burden is these capabilities, none of which synchronized can do:
Try-lock (non-blocking). Attempt to acquire; if it's held, don't block - do something else.
if (lock.tryLock()) {
try { /* got it */ } finally { lock.unlock(); }
} else {
// couldn't acquire - back off, retry, or take another path
}
Timed lock. Wait for the lock but give up after a timeout - essential for avoiding indefinite hangs.
if (lock.tryLock(2, TimeUnit.SECONDS)) {
try { /* ... */ } finally { lock.unlock(); }
}
Interruptible lock. lock.lockInterruptibly() lets a waiting thread be interrupted (e.g., for shutdown) instead of blocking uninterruptibly. A thread blocked on synchronized can't be interrupted.
Fairness. new ReentrantLock(true) grants the lock in roughly first-come-first-served order, preventing thread starvation. synchronized makes no fairness guarantee. (Fairness has a throughput cost - only use it when starvation is a real concern.)
Multiple condition variables. lock.newCondition() gives you separate wait-sets on one lock - for example, a bounded buffer can have a notFull condition and a notEmpty condition and signal them independently. With synchronized you get exactly one wait-set (wait/notify) per monitor. This is ReentrantLock's most powerful exclusive feature, and we use it heavily in the patterns module.
flowchart TD
Q{Need tryLock, timeout,<br/>interruptible, fairness,<br/>or multiple conditions?} -->|No| S[Use synchronized<br/>simpler, auto-release]
Q -->|Yes| R[Use ReentrantLock<br/>more power, manual unlock in finally]
This decision flowchart helps choose between the two locks. The question is whether you need any of these features: try-lock, a timeout, interruptible acquisition, fairness, or multiple condition variables. If no, use synchronized, which is simpler and releases automatically. If yes, use ReentrantLock, which gives you that extra power at the cost of having to unlock manually in a finally block.
Side by side
| Feature | synchronized |
ReentrantLock |
|---|---|---|
| Release | Automatic (on block exit / exception) | Manual (unlock() in finally) |
| Try-acquire without blocking | No | Yes (tryLock()) |
| Timed acquire | No | Yes (tryLock(time, unit)) |
| Interruptible while waiting | No | Yes (lockInterruptibly()) |
| Fairness option | No | Yes (new ReentrantLock(true)) |
| Condition variables | One per monitor (wait/notify) |
Multiple (newCondition()) |
| Reentrant | Yes | Yes |
| Memory (happens-before) guarantees | Yes | Yes |
| Simplicity / safety | Higher - harder to misuse | Lower - manual release is a footgun |
Condition variables: the killer feature in action
The single most compelling reason to choose ReentrantLock is multiple Conditions. A bounded buffer needs to wait for two different things - space to put, and items to take - and signal them independently. With synchronized you have one wait-set; with a lock you create as many conditions as you need.
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private final Queue<T> buffer = new LinkedList<>();
private final int capacity = 10;
public void put(T item) throws InterruptedException {
lock.lock();
try {
while (buffer.size() == capacity) notFull.await(); // wait for space
buffer.add(item);
notEmpty.signal(); // wake a taker
} finally { lock.unlock(); }
}
public T take() throws InterruptedException {
lock.lock();
try {
while (buffer.isEmpty()) notEmpty.await(); // wait for an item
T item = buffer.poll();
notFull.signal(); // wake a putter
return item;
} finally { lock.unlock(); }
}
Two things to notice. First, await() is always inside a while loop checking the condition, never an if - this guards against spurious wakeups and lost-signal races (covered in the challenges module). Second, having separate notFull and notEmpty conditions means a put only wakes takers and a take only wakes putters, avoiding the wasteful "wake everyone and let them re-check" behavior you'd get with a single synchronized wait-set. This precision is ReentrantLock's standout advantage, and producer-consumer is the canonical case for it.
How to choose
Default to synchronized. It's simpler, it can't leak a lock, it's readable, and the JVM optimizes it well. Reach for ReentrantLock only when you need a specific feature it provides: a timeout to avoid indefinite blocking, tryLock to avoid waiting, interruptibility for clean shutdown, fairness to prevent starvation, or multiple conditions for producer-consumer-style signaling. "Use the simplest tool that does the job, and synchronized is simpler" is the right instinct.
Interview: The classic question is "when would you use
ReentrantLockoversynchronized?" The strong answer names concrete capabilities, not vague preference: "I default tosynchronizedbecause it's simpler and releases automatically. I'd switch toReentrantLockwhen I need a feature it can't provide - a timedtryLockto avoid hanging forever,lockInterruptiblyfor responsive shutdown, fairness to prevent starvation, or multiple condition variables, like a bounded buffer needing separate not-full and not-empty waits." Then the safety note: "WithReentrantLockI always unlock in afinally, since unlikesynchronizedit won't auto-release on exception." That answer shows you know both the why and the risk.Common follow-up: "What does reentrant mean?" - A thread already holding the lock can acquire it again without deadlocking; the lock counts acquisitions and only fully releases when the count returns to zero. Both
synchronizedandReentrantLockare reentrant, which is what lets a synchronized method call another synchronized method on the same object safely.
A note on ReadWriteLock and StampedLock
Two specialized cousins worth knowing exist: ReentrantReadWriteLock allows many concurrent readers but only one writer - a big win for read-heavy data where readers don't need to exclude each other. StampedLock adds an optimistic-read mode that's even cheaper when writes are rare. Both have their own lesson in the locking-strategies module; for now, just register that when your workload is overwhelmingly reads, a plain exclusive lock is often the wrong default and a read-write lock can dramatically improve throughput.
Quick reference
// synchronized - default, auto-release
synchronized (lock) { /* critical section */ }
// ReentrantLock - when you need its features
lock.lock();
try { /* critical section */ } finally { lock.unlock(); }
// tryLock with timeout
if (lock.tryLock(2, TimeUnit.SECONDS)) {
try { /* ... */ } finally { lock.unlock(); }
}
Next we step back and survey the whole toolbox these locks live in - a guided tour of java.util.concurrent, the package that gives you concurrent collections, atomics, synchronizers, and executors so you rarely have to build concurrency primitives by hand.