Appearance
Locks and Condition in Java Multithreading
When you first learn about multithreading in Java, the synchronized keyword feels like a complete solution. You mark a method or a block as synchronized, Java puts a monitor lock on the object, and only one thread gets in at a time. Clean and simple. But as you start building real systems with real requirements, you run into situations where synchronized simply cannot do what you need. That is exactly why Java introduced the java.util.concurrent.locks package, which gives you a whole toolkit of purpose built locking mechanisms. This article walks you through all of them: ReentrantLock, ReadWriteLock, StampedLock, Semaphore, and Condition, covering not just how they work but why they exist and when to reach for each one.
The Problem with Synchronized
Let us start by understanding the specific limitation of synchronized that motivates everything else in this article.
You know that when you mark a method as synchronized, Java places a monitor lock on the object that owns the method. Consider this example:
java
public class SharedResource {
// Synchronized puts a monitor lock on the SharedResource OBJECT
public synchronized void produce() {
System.out.println("Lock acquired by: " + Thread.currentThread().getName());
try {
Thread.sleep(4000); // Hold the lock for 4 seconds so we can observe behavior
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
System.out.println("Lock released by: " + Thread.currentThread().getName());
}
}java
public class Main {
public static void main(String[] args) {
SharedResource resource1 = new SharedResource();
SharedResource resource2 = new SharedResource(); // Different object!
Thread t1 = new Thread(() -> resource1.produce());
Thread t2 = new Thread(() -> resource2.produce()); // Called on resource2
t1.start();
t2.start();
}
}If you run this, you will see both threads print "Lock acquired" almost simultaneously. Both get in at the same time. This is not a bug, it is exactly how synchronized is supposed to work. Thread one puts a monitor lock on resource1 and thread two puts a monitor lock on resource2. They are two completely different objects, so two completely separate locks. Neither thread blocks the other.
Now imagine your real requirement is this: no matter how many different objects your threads are working through, only one thread should ever be inside that critical section at any moment. You want the lock to be independent of any specific object. Synchronized cannot give you that.
On top of this object dependency problem, synchronized has three other significant limitations.
First, if a thread tries to enter a synchronized block and the lock is held by another thread, it waits forever. You cannot say "try to get the lock, but give up after 500 milliseconds and do something else instead." With synchronized, it is either you wait indefinitely or you do not try at all.
Second, a thread blocked waiting for a synchronized lock cannot be interrupted. If thread two is sitting outside a synchronized block waiting for thread one to finish, and someone calls t2.interrupt(), thread two will not respond. It is stuck.
Third, synchronized gives every object exactly one wait queue. When you call notifyAll() on an object, every thread waiting on that object wakes up, whether it is a producer thread or a consumer thread. You cannot selectively wake only the producers or only the consumers. This causes unnecessary context switching and wasted work.
The custom locks in java.util.concurrent.locks solve all of these problems.
ReentrantLock: Locking That Goes Where You Carry It
The first and most fundamental custom lock is ReentrantLock. The name "reentrant" means that if the thread already holding the lock tries to acquire it again, it succeeds instead of deadlocking itself. The lock maintains an internal hold count, and the lock is only truly released when the hold count drops back to zero.
Here is the most important thing about ReentrantLock: it does not depend on any object. You create a ReentrantLock instance and pass it around. Any thread that wants to protect a critical section uses lock.lock() to enter and lock.unlock() to leave. It does not matter which object instance they are operating on, the lock is independent.
java
import java.util.concurrent.locks.ReentrantLock;
public class SharedResource {
private ReentrantLock lock; // Lock is passed in from outside
public SharedResource(ReentrantLock lock) {
this.lock = lock;
}
public void produce() {
lock.lock(); // Acquire the custom lock, not a monitor lock
try {
System.out.println("Lock acquired by: " + Thread.currentThread().getName());
Thread.sleep(4000);
System.out.println("Lock released by: " + Thread.currentThread().getName());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock(); // ALWAYS unlock in finally, no matter what
}
}
}java
public class Main {
public static void main(String[] args) {
ReentrantLock sharedLock = new ReentrantLock(); // One lock for everyone
SharedResource resource1 = new SharedResource(sharedLock);
SharedResource resource2 = new SharedResource(sharedLock); // Different object
Thread t1 = new Thread(() -> resource1.produce());
Thread t2 = new Thread(() -> resource2.produce()); // Still uses sharedLock
t1.start();
t2.start();
}
}Now when you run this, thread one acquires the lock and thread two waits, even though they are calling through different object instances. The lock is the shared state, not the object.
Notice the finally block around lock.unlock(). This is not optional, it is mandatory. If your critical section throws an exception and you have not put the unlock in a finally block, the lock never gets released. Every thread that comes along afterwards blocks forever. Your application is deadlocked and the only fix is a restart. Always put unlock() in finally.
tryLock: Ask Politely and Accept No for an Answer
One of the most powerful features ReentrantLock gives you is the ability to attempt a lock acquisition without blocking forever. The tryLock() method tries to acquire the lock and returns a boolean telling you whether it succeeded.
java
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class SmartResource {
private final ReentrantLock lock = new ReentrantLock();
private int counter = 0;
// Attempt to acquire the lock, give up after 500 milliseconds
public void tryIncrement() {
try {
boolean acquired = lock.tryLock(500, TimeUnit.MILLISECONDS);
if (acquired) {
try {
counter++;
System.out.println("Incremented by: " + Thread.currentThread().getName());
} finally {
lock.unlock();
}
} else {
// Lock was busy for 500ms, do something else instead
System.out.println(Thread.currentThread().getName() + " could not acquire lock, doing fallback work");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}This is enormously useful in systems where you cannot afford to block indefinitely. A web request handler that waits forever for a lock will time out the entire HTTP request. With tryLock, you can detect contention and respond gracefully, maybe by returning a cached result or queuing the work for retry.
The version without a timeout, lock.tryLock(), returns immediately with true or false. The version with a timeout, lock.tryLock(500, TimeUnit.MILLISECONDS), waits up to the specified duration before giving up.
Interview Questions on ReentrantLock
What is the difference between lock() and tryLock()?lock() blocks the calling thread indefinitely until the lock is available. tryLock() attempts to acquire the lock and returns immediately or after a timeout, returning false if it could not acquire the lock. tryLock() allows you to handle the "lock is busy" case gracefully rather than blocking forever.
Why must unlock() always be in a finally block? If an exception occurs inside the critical section and unlock() is not in a finally block, the exception will propagate upward and the unlock call will never execute. The lock stays held forever, causing every subsequent thread that tries to acquire it to block permanently, which is a deadlock.
What does "reentrant" mean in ReentrantLock? Reentrant means that the thread that already holds the lock can acquire it again without blocking itself. An internal hold count tracks how many times the owning thread has acquired the lock. Each lock() call increments the count and each unlock() call decrements it. The lock is only released to other threads when the count reaches zero.
Shared Locks and Exclusive Locks
Before explaining ReadWriteLock, you need to understand the two fundamental categories of locks that exist in concurrent programming, because everything from here builds on this distinction.
A shared lock, also called a read lock, allows multiple threads to hold it simultaneously. If thread one acquires a shared lock and thread two wants to also acquire a shared lock on the same resource, thread two is allowed in immediately. Both threads hold the shared lock at the same time. The rule is that threads holding a shared lock may only read the data, not modify it. You can have as many concurrent readers as you want.
An exclusive lock, also called a write lock, can only be held by one thread at a time. And crucially, an exclusive lock can only be acquired when no other lock of any kind is held on that resource. Not a shared lock, not another exclusive lock, nothing. The moment an exclusive lock is taken, no other thread can take any kind of lock until that exclusive lock is released.
To summarize the rules:
When one thread holds a shared lock, other threads can also take shared locks on the same resource. But no thread can take an exclusive lock until all shared locks are released.
When one thread holds an exclusive lock, no other thread can take any lock at all, shared or exclusive, until that exclusive lock is released.
This distinction directly maps to reading versus writing. Reading is safe to do in parallel because readers do not interfere with each other. Writing requires complete exclusivity because a writer changes data that readers might be looking at.
ReadWriteLock: Let Readers Run Free
With the shared and exclusive lock distinction in mind, ReentrantReadWriteLock makes perfect sense. It gives you two separate locks that share a single underlying state: a read lock (shared) and a write lock (exclusive).
Multiple threads can hold the read lock at the same time, allowing them to read concurrently. But the write lock is exclusive: only one thread can hold it, and only when no read locks are active.
java
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class SharedResource {
private ReadWriteLock lock;
private Map<String, String> data = new HashMap<>();
public SharedResource(ReadWriteLock lock) {
this.lock = lock;
}
// Multiple threads can call this simultaneously (shared lock)
public void read(String key) {
lock.readLock().lock(); // Acquire shared lock
try {
System.out.println("Read lock acquired by: " + Thread.currentThread().getName());
System.out.println("Value: " + data.get(key));
Thread.sleep(8000); // Hold read lock for 8 seconds for demo
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.readLock().unlock(); // Release shared lock
}
}
// Only one thread can call this, and only when no reads are active (exclusive lock)
public void write(String key, String value) {
lock.writeLock().lock(); // Acquire exclusive lock
try {
System.out.println("Write lock acquired by: " + Thread.currentThread().getName());
data.put(key, value);
} finally {
lock.writeLock().unlock(); // Release exclusive lock
}
}
}java
public class Main {
public static void main(String[] args) {
ReadWriteLock rwLock = new ReentrantReadWriteLock(); // Interface with ReentrantReadWriteLock implementation
SharedResource resource = new SharedResource(rwLock);
Thread t1 = new Thread(() -> resource.read("name")); // Shared lock
Thread t2 = new Thread(() -> resource.read("name")); // Also shared lock, runs concurrently with t1
Thread t3 = new Thread(() -> resource.write("name", "Java")); // Exclusive lock, waits for t1 and t2
t1.start();
t2.start();
Thread.sleep(100); // Let both readers start first
t3.start();
}
}When you run this, threads one and two both acquire the read lock and run concurrently. Thread three tries to acquire the write lock but has to wait because threads one and two hold shared locks. Only after both read locks are released does thread three get its exclusive write lock.
When to Use ReadWriteLock
ReadWriteLock shines in applications where reads vastly outnumber writes. Think of a product catalog, a user profile cache, or a configuration store. If you have a thousand read operations for every one write operation, using a plain synchronized or ReentrantLock is massively wasteful because it forces all those reads to queue up one by one even though they could all run in parallel safely.
With ReadWriteLock, all thousand readers can proceed simultaneously. Only the occasional writer has to wait and block everyone else.
Interview question: What is the difference between ReentrantLock and ReentrantReadWriteLock? ReentrantLock is a mutual exclusion lock. Only one thread can hold it at a time, regardless of whether that thread is reading or writing. ReentrantReadWriteLock maintains two locks internally: a read lock that multiple threads can hold simultaneously for concurrent reading, and a write lock that only one thread can hold for exclusive writing. ReadWriteLock is the better choice when reads are far more frequent than writes.
StampedLock: The Optimistic Alternative
Even ReadWriteLock has overhead. When a thread acquires the read lock, it has to update internal atomic counters, which involves CPU synchronization operations even if no writer is competing. For extremely performance sensitive code, this overhead matters.
Java 8 introduced StampedLock, which adds a third reading mode on top of the shared and exclusive lock capabilities: optimistic reading.
Optimistic vs Pessimistic Locking
To understand optimistic locking, you need to understand that there are two fundamentally different philosophies about how to handle concurrent access.
Pessimistic locking says: assume the worst. Before you touch any shared data, grab a lock and hold it until you are done. Other threads must wait. This is what synchronized, ReentrantLock, and ReadWriteLock all do.
Optimistic locking says: assume the best. Do not acquire any lock at all. Just read the data, do your work, and before you commit any changes, check whether anyone else modified the data while you were working. If no one did, your result is valid and you can proceed. If someone did modify it, your work is based on stale data, so you roll back and try again.
Databases use optimistic locking via a version number column. When you read a row, you note its version. When you write back, your update query includes a WHERE version = <what I read> condition. If no one else modified the row, the version matches and your update succeeds, incrementing the version. If someone else wrote first, the version no longer matches and your update affects zero rows, telling you to retry.
StampedLock's Optimistic Read in Java
StampedLock implements this same idea at the lock level. Every lock operation returns a stamp, which is a long value that encodes the state of the lock at that moment. Optimistic reading does not acquire any lock. It just reads the current stamp as a version number.
java
import java.util.concurrent.locks.StampedLock;
public class SharedResource {
private int value = 10;
private final StampedLock lock = new StampedLock();
// Optimistic reading: no lock acquired
public void optimisticRead() {
long stamp = lock.tryOptimisticRead(); // Save the current "version" of the lock state
// DO NOT acquire any lock here
int localCopy = value; // Read the shared value into a local variable
// Simulate some computation time
try {
Thread.sleep(6000);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
// Before committing the work, check: did any writer modify the data while we worked?
if (lock.validate(stamp)) {
// No writer intervened. Our local copy is still valid.
System.out.println("Optimistic read successful, value: " + localCopy);
} else {
// A writer changed the data while we were working. Roll back.
System.out.println("Optimistic read failed! Someone wrote while we were reading. Rolling back.");
value = 10; // Restore original value in this simplified demo
}
}
// Write lock: exclusive access, changes the lock's internal stamp
public void write() {
long stamp = lock.writeLock(); // Returns a stamp (useful for unlock)
try {
System.out.println("Write lock acquired by: " + Thread.currentThread().getName());
value = 20; // Modify shared data
} finally {
lock.unlockWrite(stamp); // Pass the stamp back to release the write lock
}
}
// Pessimistic read lock: same as ReadWriteLock, but stamp based
public void pessimisticRead() {
long stamp = lock.readLock(); // Acquire shared lock, returns stamp
try {
System.out.println("Read lock acquired, value: " + value);
} finally {
lock.unlockRead(stamp); // Must pass stamp to release
}
}
}The reason StampedLock operations return a stamp is precisely because of optimistic locking. The stamp is the "row version" at the moment you read. When you call lock.validate(stamp), it checks whether any write occurred between when you captured the stamp and now. If the stamp is still valid, no write happened and your data is good. If the stamp is invalid, a write changed the lock's internal version and your data may be stale.
Notice that optimistic reads have no unlock call. You never acquired a lock, so there is nothing to release. You are just working with a snapshot of the version number.
The Key Insight About Stamps
The write lock operation changes the internal stamp of the StampedLock. When a writer acquires and then releases the write lock, the stamp version increments. This is exactly like the database version column. Any optimistic reader who captured the old stamp will now find that validate() returns false, telling them a write occurred.
java
public class Main {
public static void main(String[] args) throws InterruptedException {
SharedResource resource = new SharedResource();
Thread reader = new Thread(() -> resource.optimisticRead());
Thread writer = new Thread(() -> resource.write());
reader.start();
Thread.sleep(100); // Let reader start first
writer.start(); // Writer acquires write lock, changes stamp, releases lock
// Reader wakes up after 6 seconds, validate() returns false because writer changed the stamp
// Output: Optimistic read failed!
}
}Run it without starting the writer thread and you will see the optimistic read succeed. Run it with the writer thread and you will see it fail and roll back. This is optimistic locking in action.
Interview Questions on StampedLock
What is the difference between StampedLock and ReadWriteLock? StampedLock provides three modes: pessimistic read (same as ReadWriteLock's read lock), write (same as ReadWriteLock's write lock), and optimistic read (no lock at all). The optimistic read mode is the key addition. It allows threads to read without acquiring any lock and validate afterward whether the data is still consistent.
When would you choose optimistic locking over pessimistic locking? When reads are very frequent and write conflicts are rare. In low contention scenarios, optimistic locking avoids the overhead of actually acquiring locks, which improves throughput. In high contention scenarios where writes are frequent, optimistic reads fail often and have to retry, making pessimistic locking more efficient.
Semaphore: Controlling How Many Threads Enter at Once
The locks we have seen so far all enforce a single rule: either one thread at a time (exclusive), or any number of readers but one writer. A Semaphore gives you direct control over a fixed number of concurrent threads.
A Semaphore maintains a pool of permits. You specify how many permits it has when you create it. A thread calls acquire() to take a permit. If a permit is available, the thread proceeds immediately. If no permits are available, the thread blocks until another thread calls release() to return one.
java
import java.util.concurrent.Semaphore;
public class SharedResource {
// Exactly 2 threads can be inside this section at the same time
private final Semaphore semaphore = new Semaphore(2);
public void produce() {
try {
semaphore.acquire(); // Take one permit (blocks if none available)
try {
System.out.println("Lock acquired by: " + Thread.currentThread().getName());
Thread.sleep(4000); // Simulate work
System.out.println("Lock released by: " + Thread.currentThread().getName());
} finally {
semaphore.release(); // Return the permit
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
}java
public class Main {
public static void main(String[] args) {
SharedResource resource = new SharedResource();
// Four threads compete for two permits
Thread t1 = new Thread(() -> resource.produce());
Thread t2 = new Thread(() -> resource.produce());
Thread t3 = new Thread(() -> resource.produce());
Thread t4 = new Thread(() -> resource.produce());
t1.start();
t2.start();
t3.start();
t4.start();
}
}Running this produces output like:
Lock acquired by: Thread-0
Lock acquired by: Thread-1
Lock released by: Thread-1
Lock acquired by: Thread-2
Lock released by: Thread-0
Lock acquired by: Thread-3
Lock released by: Thread-2
Lock released by: Thread-3Threads zero and one get in immediately because two permits are available. Threads two and three block. As soon as one of the first two releases its permit, the next waiting thread gets in. At most two threads are ever inside the critical section simultaneously.
When to Use Semaphore
Semaphore is perfect for resource pool scenarios. Classic examples include:
A database connection pool with five connections: you create a Semaphore(5). Any thread wanting a connection acquires a permit. When only five threads are inside the pool at once, additional threads queue up. As connections are returned to the pool, the permits are released and waiting threads get their turn.
A printer pool with three printers: Semaphore(3) ensures at most three print jobs run concurrently. Additional jobs wait until a printer is free.
A rate limiter: if you want at most ten threads processing API requests concurrently to avoid overwhelming a downstream service, Semaphore(10) enforces this ceiling.
The key difference between Semaphore and ReentrantLock is that Semaphore is not tied to any specific thread. One thread can acquire and a different thread can release. ReentrantLock must be unlocked by the same thread that locked it.
Interview question: What is the difference between a Semaphore and a ReentrantLock? ReentrantLock enforces mutual exclusion: exactly one thread can hold it at a time, and the same thread must lock and unlock it. A Semaphore manages a fixed number of permits: up to N threads can hold permits simultaneously, and there is no requirement that the releasing thread is the same one that acquired. Semaphore is for controlling concurrency levels; ReentrantLock is for mutual exclusion.
Condition: Inter Thread Communication with Custom Locks
You know how wait() and notify() allow threads to communicate through a synchronized object's monitor. One thread waits inside the monitor when some condition is not met. Another thread signals the waiting thread when the condition becomes true.
When you switch from synchronized to custom locks like ReentrantLock, you lose access to wait() and notify(). Those methods only work on the monitor lock that synchronized uses. For custom locks, Java provides the Condition interface.
The mapping is direct:
object.wait()becomescondition.await()object.notify()becomescondition.signal()object.notifyAll()becomescondition.signalAll()
You create a Condition by calling lock.newCondition() on a ReentrantLock. The condition is permanently tied to that lock.
java
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class SharedResource {
private boolean available = false;
private final ReentrantLock lock = new ReentrantLock();
private final Condition condition = lock.newCondition(); // Condition tied to this lock
// Producer: adds data when nothing is available
public void produce() {
lock.lock();
try {
if (available) {
// Already something available, wait for consumer to take it
System.out.println(Thread.currentThread().getName() + " waiting to produce");
condition.await(); // Equivalent to object.wait()
}
// Produce the item
available = true;
System.out.println(Thread.currentThread().getName() + " produced item");
condition.signal(); // Wake up a waiting consumer. Equivalent to object.notify()
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
}
// Consumer: takes data when something is available
public void consume() {
lock.lock();
try {
if (!available) {
// Nothing to consume yet, wait for producer
System.out.println(Thread.currentThread().getName() + " waiting to consume");
condition.await(); // Equivalent to object.wait()
}
// Consume the item
available = false;
System.out.println(Thread.currentThread().getName() + " consumed item");
condition.signal(); // Wake up a waiting producer. Equivalent to object.notify()
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
lock.unlock();
}
}
}java
public class Main {
public static void main(String[] args) {
SharedResource resource = new SharedResource();
Thread producer = new Thread(() -> {
for (int i = 0; i < 5; i++) {
resource.produce();
}
});
Thread consumer = new Thread(() -> {
for (int i = 0; i < 5; i++) {
resource.consume();
}
});
producer.start();
consumer.start();
}
}The mechanics are identical to wait/notify: calling condition.await() releases the lock and suspends the thread. Calling condition.signal() picks one waiting thread and moves it back to the ready state. That thread must reacquire the lock before it continues executing.
The Real Power: Multiple Conditions Per Lock
Here is where Condition becomes genuinely superior to wait/notify. You can create multiple Condition objects on the same lock. Each Condition has its own separate queue of waiting threads. This lets you signal producers without waking consumers, and signal consumers without waking producers.
With plain synchronized and notifyAll(), you wake up every thread waiting on the object. If you have five producers and five consumers all waiting, notifyAll() wakes all ten. Nine of them recheck their condition, find it false, and go back to waiting. That is nine wasted context switches for every useful wakeup.
java
import java.util.LinkedList;
import java.util.Queue;
import java.util.concurrent.locks.Condition;
import java.util.concurrent.locks.ReentrantLock;
public class BoundedBuffer<T> {
private final Queue<T> queue = new LinkedList<>();
private final int capacity;
private final ReentrantLock lock = new ReentrantLock();
// Two separate waiting queues on the SAME lock
private final Condition notFull = lock.newCondition(); // Producers wait here
private final Condition notEmpty = lock.newCondition(); // Consumers wait here
public BoundedBuffer(int capacity) {
this.capacity = capacity;
}
public void put(T item) throws InterruptedException {
lock.lock();
try {
while (queue.size() == capacity) {
notFull.await(); // Producer waits on notFull condition only
}
queue.offer(item);
notEmpty.signal(); // Wakes ONLY a thread waiting on notEmpty (a consumer)
} finally {
lock.unlock();
}
}
public T take() throws InterruptedException {
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await(); // Consumer waits on notEmpty condition only
}
T item = queue.poll();
notFull.signal(); // Wakes ONLY a thread waiting on notFull (a producer)
return item;
} finally {
lock.unlock();
}
}
}When a producer calls notEmpty.signal(), it wakes exactly one consumer, not any producer. When a consumer calls notFull.signal(), it wakes exactly one producer, not any consumer. No wasted wakeups, no spurious context switching. This is cleaner and more efficient than anything wait/notify can express with a single monitor.
Notice also the while loops instead of if checks. This is required even with Condition. A thread can sometimes be woken up spuriously without actually being signaled. The while loop rechecks the condition and goes back to waiting if it was a spurious wakeup.
Interview Questions on Condition
Why can you not use wait() and notify() with ReentrantLock?wait() and notify() are methods on Object and work specifically with the implicit monitor lock that synchronized uses. When you use ReentrantLock, there is no monitor lock involved, so the Object monitor methods have nothing to operate on. Condition is the replacement that provides the same interthread communication semantics for explicit locks.
What advantage does Condition have over notifyAll()? A single object monitor has one wait queue. notifyAll() wakes every thread in that queue regardless of why they are waiting. With Condition, you can create multiple distinct wait queues on the same lock. You can have one Condition for producers and another for consumers. Signaling one condition only wakes threads waiting on that specific condition, eliminating unnecessary wakeups and context switches.
What is signal() equivalent to, and when would you use signalAll()?signal() is equivalent to notify(): it wakes one arbitrary waiting thread. Use it when you know that only one thread needs to be woken and any one of them will do. signalAll() is equivalent to notifyAll(): it wakes all waiting threads. Use it when multiple threads might be able to proceed, or when you cannot guarantee which thread needs to run next.
Putting It All Together: Which Lock to Use
After seeing all four lock types plus Condition, the natural question is which one to reach for in a given situation.
Use ReentrantLock when you need what synchronized provides but need more control: a timeout on lock acquisition with tryLock, the ability to pass the lock as an object independent of any class instance, or the ability to create Conditions on it.
Use ReentrantReadWriteLock when your workload is read heavy and write light. If threads mostly read shared data and only occasionally write, ReadWriteLock allows concurrent reads to proceed in parallel while still enforcing exclusive access for writes. The win is proportional to how much more frequently reads occur than writes.
Use StampedLock when you are in extremely performance sensitive code with low write contention. Optimistic reads avoid any lock acquisition at all. If writes rarely interfere with reads, optimistic reads almost always succeed on the first attempt with zero lock overhead. If writes are frequent, optimistic reads fail often and the retry overhead makes this worse than pessimistic locking.
Use Semaphore when you need to control the number of concurrent threads accessing a resource, not just enforce single thread access. Database connection pools, thread pool size limiters, and rate limiters are natural fits.
Use Condition whenever you are using a custom lock and need threads to wait for a specific state change. It is the direct replacement for wait/notify in the custom lock world, and its ability to maintain multiple separate wait queues per lock makes it strictly more expressive.
Common Pitfalls to Avoid
Forgetting finally around unlock() is the most dangerous mistake. It causes permanent deadlock that only a restart fixes.
Forgetting to call lock() before await() on a Condition causes an IllegalMonitorStateException, the same as calling wait() outside a synchronized block.
Using if instead of while when checking conditions in a Condition.await() loop leaves your code vulnerable to spurious wakeups.
Trying to use wait() or notify() with a ReentrantLock will not cause a compile error but will produce incorrect behavior because the monitor lock the wait() operates on is different from the ReentrantLock the thread actually holds.
With ReadWriteLock, do not forget that the read lock still needs to be released. Forgetting to unlock the read lock will eventually block all writers permanently, though readers continue to function until a writer needs in.
Summary
Java's java.util.concurrent.locks package gives you a set of precisely targeted tools that go far beyond what synchronized can express.
ReentrantLock gives you mutual exclusion that is independent of object identity, with timeout based lock attempts through tryLock. ReentrantReadWriteLock maximizes throughput for read heavy workloads by allowing concurrent readers while still enforcing exclusive writes. StampedLock adds optimistic reading for zero overhead reads in low contention scenarios, using a version stamp validation pattern identical to database optimistic locking. Semaphore controls the number of threads allowed into a section simultaneously, making it the natural choice for resource pools. Condition replaces wait/notify for custom lock based interthread communication, with the added power of multiple independent wait queues per lock.
Understanding these tools makes you a better concurrent programmer. You start seeing not just how to serialize access, but how to shape the concurrency to match the actual contention pattern of your data. High read workloads get ReadWriteLock. Rare conflict scenarios get optimistic reading. Resource pools get Semaphore. Producers and consumers communicating through custom locks get Condition. Each tool exists because a real class of problems demanded it, and knowing which problem each tool solves is what separates careful multithreading from guesswork.