synchronized is Java's built-in mechanism for mutual exclusion. It's simple and reliable, but sometimes its capabilities fall short. For those cases, the java.util.concurrent.locks package offers a more flexible tool — Lock.
What's wrong with synchronized
synchronized works on a "grab it or wait" principle: if one thread has captured the monitor, all the others block and wait patiently. You can't leave early, you can't interrupt the wait, and you can't try to acquire the lock just "to check."
Imagine a thread has already been waiting 10 seconds to acquire the lock. The user clicks "Cancel." There's no way to notify the waiting thread through synchronized — it will simply keep waiting.
Or another example: a system with many readers and one rare writer. synchronized doesn't distinguish reading from writing — readers will block each other, even though concurrent reading is perfectly safe.
Explicit locks exist precisely for these situations.
ReentrantLock: the basics
ReentrantLock is the most commonly used implementation of the Lock interface. "Reentrant" means that the same thread can acquire the lock several times in a row (without getting stuck on itself), and must release it exactly the same number of times.
The minimal usage pattern:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private final Lock lock = new ReentrantLock();
private int count = 0;
public void increment() {
lock.lock(); // acquire the lock
try {
count++; // critical section
} finally {
lock.unlock(); // ALWAYS in finally
}
}
}
The rule is ironclad: unlock() always goes in a finally block. If an exception is thrown before unlock(), the lock stays acquired forever — every other thread will hang. The finally block guarantees release under any outcome.
tryLock: try without hanging
tryLock() returns true if the lock could be acquired right now, and false if it's busy. The thread doesn't block — it decides for itself what to do next.
public boolean tryIncrement() {
if (lock.tryLock()) { // did we get the lock?
try {
count++;
return true;
} finally {
lock.unlock();
}
}
return false; // lock is busy, no big deal
}
There's also a variant with a timeout — wait for a given amount of time and leave if it doesn't come free:
if (lock.tryLock(200, TimeUnit.MILLISECONDS)) {
try {
// do the work
} finally {
lock.unlock();
}
} else {
// lock has been busy for more than 200 ms — do something else
}
This is especially useful when working with several locks: acquire the first one, try to acquire the second — and if that fails, release the first and retry later. Such a tactic helps avoid a deadlock.
lockInterruptibly: interruptible waiting
lockInterruptibly() waits to acquire the lock exactly like lock(), but with one important difference: if the thread receives an interrupt (Thread.interrupt()), it immediately exits the wait with an InterruptedException.
public void interruptibleWork() throws InterruptedException {
lock.lockInterruptibly(); // may throw InterruptedException
try {
// do the work
} finally {
lock.unlock();
}
}
A plain lock.lock() ignores interruption — the thread keeps waiting even after interrupt(). lockInterruptibly() is needed when it's important to be able to cancel the operation from outside — for example, during application shutdown or on a user request.
Fairness: a fair queue
By default, ReentrantLock gives no guarantees about which thread will get the lock next. In practice, the winner is often whoever happened to arrive "at the right moment" — this is called an unfair lock. It's faster because there's no need to maintain a queue.
If a thread can wait indefinitely while others constantly snatch the lock away — that's starvation. For such cases, ReentrantLock supports a fairness mode:
// true — enable a fair queue (FIFO by waiting time)
Lock fairLock = new ReentrantLock(true);
In fair mode, the lock is given to the thread that has been waiting the longest. This eliminates starvation but reduces overall speed because of the overhead of managing the queue.
Short formula: unfair is faster; fair is fairer. Use fair only when starvation is genuinely a problem.
ReadWriteLock: readers and writers
When data is read often and modified rarely, ReadWriteLock gives a significant performance boost. It has two locks:
- read lock — can be acquired by several threads simultaneously (as long as no one is writing);
- write lock — exclusive, waits until all readers have left.
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class Cache {
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
private final Lock readLock = rwLock.readLock();
private final Lock writeLock = rwLock.writeLock();
private String value;
public String get() {
readLock.lock(); // several threads can read simultaneously
try {
return value;
} finally {
readLock.unlock();
}
}
public void set(String newValue) {
writeLock.lock(); // only one writer, no readers
try {
value = newValue;
} finally {
writeLock.unlock();
}
}
}
If read operations vastly outnumber writes, ReadWriteLock substantially reduces contention between threads.
StampedLock: optimistic reading
StampedLock (Java 8+) is a more advanced alternative to ReadWriteLock. Its main feature is optimistic reading: a thread reads the data without acquiring the lock and only afterward checks whether it changed in the meantime.
import java.util.concurrent.locks.StampedLock;
public class Point {
private final StampedLock sl = new StampedLock();
private double x, y;
public double distanceFromOrigin() {
long stamp = sl.tryOptimisticRead(); // read without locking
double cx = x, cy = y;
if (!sl.validate(stamp)) { // did the data change during the read?
stamp = sl.readLock(); // then take a regular read lock
try {
cx = x; cy = y;
} finally {
sl.unlockRead(stamp);
}
}
return Math.sqrt(cx * cx + cy * cy);
}
}
StampedLock delivers maximum speed under a high read frequency, but it's harder to use: it isn't reentrant and works with "stamps" (long) instead of the familiar Lock interface.
When synchronized is enough
Explicit locks aren't always needed. synchronized is perfectly sufficient when:
- the critical section is simple and short;
- you don't need to try acquiring the lock without waiting (
tryLock); - you don't need to interrupt the wait;
- there's no split between readers and writers;
- the code is simpler — fewer chances to forget
unlock().
A short selection formula:
| Capability needed | Choice |
|---|---|
| Just protect a section | synchronized |
tryLock or a timeout | ReentrantLock |
| Interrupting the wait | ReentrantLock |
| Many readers, a rare writer | ReadWriteLock |
| Maximum read speed | StampedLock |
In short
Lockis an explicit lock fromjava.util.concurrent.locks;ReentrantLockis the primary implementation.unlock()always goes in afinallyblock — no exceptions.tryLock()returns control immediately if the lock is busy; with a timeout, it waits for the given time.lockInterruptibly()lets you interrupt the wait viaThread.interrupt().- Fair mode (
new ReentrantLock(true)) eliminates starvation but runs slower. ReadWriteLockspeeds up "many readers, a rare writer" scenarios.StampedLockprovides optimistic reading without acquiring the lock — faster, but more complex.synchronizedis still good for simple cases — don't overcomplicate without a reason.
What to read next
- Synchronized and the object monitor — how Java's built-in lock works and what happens inside the monitor.
- Atomic operations and CAS — when locks aren't needed at all:
AtomicInteger,AtomicReferenceand optimistic updates. - Common concurrency bugs — deadlock, livelock, starvation and data races: how they arise and how to avoid them.