Java synchronized vs ReentrantLock: 7 Critical Differences Explained
While both synchronized and ReentrantLock provide mutual exclusion for Java threads, synchronized is a JVM-level implicit lock using monitorenter and monitorexit bytecode instructions, whereas ReentrantLock is an explicit Java class in java.util.concurrent.locks that offers interruptible acquisition, fairness policies, and multiple Condition objects.
When building thread-safe concurrent applications in Java, developers must choose between the built-in synchronized keyword and the ReentrantLock class. According to the comprehensive concurrency notes in the CyC2018/CS-Notes repository—specifically detailed in notes/Java 并发.md in the "比较" (comparison) section around line 491—these two synchronization mechanisms differ fundamentally in implementation, flexibility, and control granularity. Understanding these distinctions is essential for writing high-performance, maintainable multithreaded code.
Implementation Mechanism: JVM Bytecode vs. Java Class
The most fundamental difference lies in how each mechanism is implemented at the runtime level.
synchronized is a native JVM feature managed through object monitors. When you use the synchronized keyword, the Java compiler generates monitorenter and monitorexit bytecode instructions. The JVM handles the monitor lock directly through object headers and native synchronization primitives, as documented in notes/Java 并发.md around line 337.
ReentrantLock is a pure Java class (java.util.concurrent.locks.ReentrantLock) that implements the Lock interface. It maintains lock state internally using atomic variables and manages waiting threads through an Abstract Queued Synchronizer (AQS). While it delegates to native code for low-level atomic operations, the locking logic and queue management are implemented in Java, providing greater flexibility at the cost of explicit API usage.
Lock Acquisition: Implicit vs. Explicit
The syntax and responsibility for lock management differ significantly between the two approaches.
With synchronized, lock acquisition is implicit. You declare synchronization through:
- Synchronized blocks:
synchronized (obj) { ... } - Synchronized methods:
synchronized void method()
The JVM automatically acquires the monitor when entering the block or method and guarantees release when exiting, even if an exception is thrown.
With ReentrantLock, acquisition is explicit. You must manually call lock.lock() to acquire the lock and lock.unlock() to release it. This requires careful exception handling, typically using a try…finally block to ensure the lock is released regardless of exceptions:
lock.lock();
try {
// critical section
} finally {
lock.unlock(); // guaranteed release
}
Failure to call unlock() in a finally block can lead to permanent deadlocks if an exception occurs in the critical section.
Interruptibility: Responding to Thread Cancellation
One of the most significant functional differences is how each mechanism handles thread interruption during lock acquisition.
synchronized is not interruptible. Once a thread is blocked waiting to enter a synchronized block or method, it cannot be woken up by calling Thread.interrupt(). The thread will remain blocked until the lock becomes available, completely ignoring interruption requests during the wait.
ReentrantLock supports interruptible lock acquisition through the lockInterruptibly() method. As detailed in notes/Java 并发.md around line 505, this method allows a waiting thread to respond to interruption by throwing InterruptedException, enabling responsive cancellation policies:
try {
lock.lockInterruptibly();
try {
// critical section
} finally {
lock.unlock();
}
} catch (InterruptedException e) {
// handle cancellation gracefully
Thread.currentThread().interrupt();
}
This capability is essential for implementing responsive systems where long-running operations must be cancellable.
Fairness Policies: Preventing Thread Starvation
Fairness determines whether the lock favors the thread that has been waiting the longest.
synchronized uses a non-fair policy by default. The JVM makes no guarantees about which waiting thread acquires the monitor next when the lock becomes available. While the JVM implementation may favor the longest-waiting thread, there is no strict fairness contract, and thread starvation—where some threads never acquire the lock—is possible under high contention.
ReentrantLock provides configurable fairness. You can construct a fair lock using new ReentrantLock(true), which guarantees that the longest-waiting thread acquires the lock next. This prevents starvation but may reduce overall throughput due to the overhead of maintaining the ordered queue and waking threads in sequence.
Condition Support: Single vs. Multiple Wait Sets
The mechanisms differ in how threads wait for specific conditions within the lock.
synchronized provides a single implicit condition per monitor through the Object class methods: wait(), notify(), and notifyAll(). All threads waiting for different conditions must share the same wait set, which can lead to inefficiency because notifyAll() wakes all waiting threads even if only some can proceed.
ReentrantLock supports multiple distinct conditions through the Condition interface. By calling lock.newCondition(), you can create separate condition objects, each maintaining its own wait set. This allows fine-grained control where you can signal threads waiting for specific conditions without waking threads waiting for different conditions, improving efficiency in complex coordination scenarios.
Performance Characteristics
Modern JVMs have optimized both mechanisms, but trade-offs remain.
synchronized benefits from extensive JVM optimizations including biased locking, lock elision, and lightweight locks. In uncontended or low-contention scenarios, synchronized often outperforms ReentrantLock because it avoids the overhead of Java object allocation and method dispatch, operating directly on the object monitor in native code.
ReentrantLock incurs slightly higher baseline overhead due to additional method calls and the allocation of Node objects for the internal AQS queue. However, this overhead is negligible in high-contention scenarios or when utilizing advanced features like fairness or interruptibility that synchronized cannot provide.
Practical Code Examples
The following examples demonstrate the syntactic and semantic differences between these locking mechanisms.
Basic synchronized Usage
This example shows implicit lock acquisition through synchronized methods, where the JVM handles monitorenter and monitorexit automatically:
public class SyncCounter {
private int count = 0;
public synchronized void increment() {
count++; // monitorenter / monitorexit handled by JVM
}
public synchronized int get() {
return count;
}
}
Explicit Locking with ReentrantLock
This example demonstrates explicit lock management using lock() and unlock() with proper try…finally exception handling:
import java.util.concurrent.locks.*;
public class LockCounter {
private final Lock lock = new ReentrantLock(); // non-fair lock
private int count = 0;
public void increment() {
lock.lock(); // explicit acquisition
try {
count++;
} finally {
lock.unlock(); // guaranteed release
}
}
public int get() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
Advanced ReentrantLock Features
This bounded buffer example showcases interruptible lock acquisition (lockInterruptibly()) and multiple Condition objects for fine-grained producer-consumer coordination:
import java.util.concurrent.locks.*;
public class BoundedBuffer<T> {
private final Lock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private final Object[] items = new Object[10];
private int putPtr, takePtr, count;
public void put(T x) throws InterruptedException {
lock.lockInterruptibly(); // can be interrupted while waiting
try {
while (count == items.length)
notFull.await(); // await on specific condition
items[putPtr] = x;
if (++putPtr == items.length) putPtr = 0;
++count;
notEmpty.signal(); // wake a consumer
} finally {
lock.unlock();
}
}
@SuppressWarnings("unchecked")
public T take() throws InterruptedException {
lock.lockInterruptibly();
try {
while (count == 0)
notEmpty.await();
Object x = items[takePtr];
if (++takePtr == items.length) takePtr = 0;
--count;
notFull.signal(); // wake a producer
return (T) x;
} finally {
lock.unlock();
}
}
}
When to Use synchronized vs. ReentrantLock
Choosing the right synchronization mechanism depends on your specific concurrency requirements and complexity needs.
Prefer synchronized when:
- You need simple mutual exclusion without advanced features like timeout or interruptibility
- The locking logic is straightforward and contained within a single method or block
- You want automatic lock release to prevent deadlocks from forgotten
unlock()calls - You are working in low-contention scenarios where JVM optimizations (biased locking, lock elision) provide excellent performance
Choose ReentrantLock when:
- You need to interrupt a thread waiting for a lock using
lockInterruptibly() - You require timed lock attempts via
tryLock(long timeout, TimeUnit unit) - You need fairness guarantees to prevent thread starvation (
new ReentrantLock(true)) - You require multiple wait-sets/conditions for complex thread coordination patterns
- You need to query lock statistics or implement sophisticated locking policies
Summary
synchronizedis a JVM-native implicit lock usingmonitorenterandmonitorexitbytecode, offering automatic scope-based release but limited flexibility.ReentrantLockis an explicit Java class providing interruptible acquisition (lockInterruptibly()), optional fairness, timed lock attempts (tryLock()), and multipleConditionobjects.- Interruptibility is a key differentiator:
synchronizedblocks cannot respond toThread.interrupt(), whileReentrantLocksupports interruption during lock acquisition. - Fairness is only configurable with
ReentrantLockvia the constructor parameter;synchronizedalways uses a non-fair policy. - Performance is comparable in modern JVMs, but
synchronizedbenefits from biased locking and lock elision optimizations, whileReentrantLockincurs slight overhead for its additional features.
Frequently Asked Questions
Can I use ReentrantLock as a drop-in replacement for synchronized?
While ReentrantLock can replace synchronized for mutual exclusion, it is not a direct drop-in replacement due to its explicit API. You must manually call unlock() in a finally block to prevent deadlocks, whereas synchronized handles release automatically. Additionally, ReentrantLock cannot be used with the synchronized keyword syntax, requiring refactoring of lock acquisition logic throughout your codebase.
Is ReentrantLock faster than synchronized?
In modern JVMs, synchronized is often faster for uncontended or low-contention scenarios due to optimizations like biased locking, lightweight locks, and lock elision. ReentrantLock incurs slightly higher overhead from additional method calls and object allocations for its internal queue nodes. However, in high-contention scenarios or when using advanced features like fairness, ReentrantLock provides better control and potentially better throughput through its sophisticated queuing mechanisms.
How does lockInterruptibly() work differently from lock()?
The lockInterruptibly() method allows a thread waiting to acquire a lock to respond to interruption by throwing InterruptedException, whereas lock() ignores interrupts during the waiting period. When using lock(), if a thread is interrupted while waiting for the lock, it continues waiting until the lock is acquired, then sets the interrupt status flag upon return. In contrast, lockInterruptibly() immediately throws InterruptedException if the thread is interrupted while waiting, allowing for responsive cancellation without acquiring the lock.
Can synchronized be made fair like ReentrantLock?
No, the synchronized keyword does not support configurable fairness policies. The JVM implementation typically uses a non-fair ordering where the next thread to acquire the monitor is not guaranteed to be the one that has been waiting the longest. While specific JVM implementations may favor the longest-waiting thread, there is no strict fairness contract, and thread starvation is possible under high contention. If strict fairness is required, you must use ReentrantLock constructed with new ReentrantLock(true).
Have a question about this repo?
These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:
curl -s "https://instagit.com/install.md" Maintain an open-source project? Get it listed too →