AlgoInsight

Free lesson · Concurrency Interview Mastery

synchronized vs ReentrantLock

Medium6 min readconcurrencymultithreadingjava

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 without try/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 of ReentrantLock's flexibility, and it's exactly the footgun that synchronized removes for you. If you don't need the extra features, this is a reason to prefer synchronized.

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 ReentrantLock over synchronized?" The strong answer names concrete capabilities, not vague preference: "I default to synchronized because it's simpler and releases automatically. I'd switch to ReentrantLock when I need a feature it can't provide - a timed tryLock to avoid hanging forever, lockInterruptibly for 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: "With ReentrantLock I always unlock in a finally, since unlike synchronized it 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 synchronized and ReentrantLock are 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.

Back to Concurrency Interview Mastery