synchronized vs ReentrantLock — Why I Stopped Defaulting to synchronized

For the first few years of my Java career, every time I needed thread safety, I reached for <code>synchronized</code>. It was muscle memory. It worked. Nobody questioned it.

Then I hit a production issue that <code>synchronized</code> simply couldn't solve cleanly — and I realized I had been choosing tools by habit, not by judgment.


synchronized is not bad. Let's be clear.

public synchronized void increment() {
    count++;
}

Simple, readable, and safe. The JVM handles lock acquisition and release automatically — you cannot forget to unlock, you cannot leak a lock, and the intent is immediately obvious to anyone reading the code. For straightforward mutual exclusion, <code>synchronized</code> is still the right call. Don't let this article convince you otherwise.

The problem is reaching for it when the problem demands something more.


Where synchronized starts to hurt you

1. You need to try acquiring a lock without blocking forever

With <code>synchronized</code>, a thread that can't acquire the lock blocks indefinitely. There's no timeout, no way to say "try for 500ms and move on." In many real systems, that's not acceptable.

ReentrantLock lock = new ReentrantLock();

if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
    try {
        // acquired — do the work
    } finally {
        lock.unlock();
    }
} else {
    // didn&#039;t acquire — fallback, log, retry, or reject
}

Consider a payment service that needs to lock a user's account record before processing a transaction. Blocking a thread indefinitely while waiting is the kind of thing that causes cascading thread pool exhaustion under load. <code>tryLock</code> lets you express a real business constraint directly in code.


2. You need a waiting thread to respond to interruption

<code>synchronized</code> blocks do not respond to <code>Thread.interrupt()</code> while waiting for a lock. The thread stays parked until it gets the monitor, regardless of what the rest of the application is doing.

lock.lockInterruptibly();
try {
    // critical section
} finally {
    lock.unlock();
}

This matters for graceful shutdown. If your application is shutting down and a thread is blocked waiting on a <code>synchronized</code> method, it won't respond to an interrupt signal. <code>lockInterruptibly()</code> allows the thread to unblock, handle the interruption, and exit cleanly.


3. You have a read-heavy workload with infrequent writes

<code>synchronized</code> treats every access as exclusive — one thread in at a time, regardless of whether it's reading or writing. This is unnecessarily restrictive when most of your access is read-only.

ReentrantReadWriteLock rwLock = new ReentrantReadWriteLock();

// Multiple threads can hold the read lock simultaneously
public Data read() {
    rwLock.readLock().lock();
    try {
        return data;
    } finally {
        rwLock.readLock().unlock();
    }
}

// Write lock is exclusive — blocks all readers and writers
public void write(Data newData) {
    rwLock.writeLock().lock();
    try {
        data = newData;
    } finally {
        rwLock.writeLock().unlock();
    }
}

A configuration cache that's read thousands of times per second but updated once a minute is a perfect example. With <code>synchronized</code>, every read serializes behind every other read for no correctness reason. <code>ReentrantReadWriteLock</code> lets concurrent reads through while still protecting writes.


4. You need multiple wait conditions on the same lock

<code>synchronized</code> gives you one monitor per object — one <code>wait()</code> and one <code>notifyAll()</code>. When you have multiple distinct conditions, <code>notifyAll()</code> wakes every waiting thread, even the ones whose condition hasn't changed. They re-check, find nothing, and go back to sleep — wasted context switches at scale.

ReentrantLock lock = new ReentrantLock();
Condition notFull  = lock.newCondition();
Condition notEmpty = lock.newCondition();

// Producer
lock.lock();
try {
    while (queue.isFull()) notFull.await();
    queue.add(item);
    notEmpty.signal(); // wake only a consumer
} finally {
    lock.unlock();
}

// Consumer
lock.lock();
try {
    while (queue.isEmpty()) notEmpty.await();
    item = queue.poll();
    notFull.signal(); // wake only a producer
} finally {
    lock.unlock();
}

This is the classic bounded blocking queue. Producers wait when full, consumers wait when empty. With <code>notifyAll()</code>, every thread wakes when any state changes. With <code>Condition</code>, you signal exactly who needs to wake up.


The one place synchronized always wins

You cannot leak a lock.

// This leaks the lock if an exception is thrown before unlock()
lock.lock();
riskyOperation(); // throws an exception
lock.unlock();    // never reached

With <code>synchronized</code>, the JVM releases the monitor automatically when the block exits — exception or not. With <code>ReentrantLock</code>, if you forget the try-finally pattern, you will leak the lock and deadlock your application in a way that's extremely hard to diagnose.

// Always do this with ReentrantLock
lock.lock();
try {
    riskyOperation();
} finally {
    lock.unlock(); // always runs
}

In teams with mixed experience levels, this is a genuine maintenance risk. <code>synchronized</code> is harder to misuse and that simplicity has real value.


The judgment call

<code>synchronized</code> is the right default. It's simple, safe, and sufficient for most cases. Reach for <code>ReentrantLock</code> when you have a specific need it cannot meet — timed lock attempts, interruptible waiting, read/write separation, or multiple conditions.

The real skill isn't knowing the API differences. It's recognizing the moment when your default tool is costing you more than it's giving you — and having the judgment to switch deliberately, not reactively.

Share your love
Nanthakumar
Nanthakumar

I’m a curious engineer, interested in various aspects of software engineering.

Articles: 8