14.7
Locks You Can Give Up On
Everything synchronized cannot do, in one package. Try and give up, wait with a deadline, be interrupted, let many readers in at once. The last one is the famous optimisation and it made things seventeen times slower until the reads got long enough.
Previously on
By the end of Section 14.6 the limits of synchronized are a list:
No way to try for a lock and give up. No timeout. No interrupting a thread that is waiting for one. No letting many readers in at once. One wait set, so notifyAll wakes everybody for everything.
java.util.concurrent.locks answers all five.
The problem
Your service updates an account. If another thread is already updating it, you would rather return “busy, try again” than hold the request open.
synchronized (account) {
account.update();
}There is no version of that which gives up. The thread goes BLOCKED and stays there. It cannot time out, and it cannot be interrupted, because a thread waiting on synchronized ignores the interrupt flag entirely.
Second problem. A configuration object read by fifty threads and written once a minute.
synchronized (config) {
return config.get(key);
}Fifty threads that only want to read are queueing one at a time, and none of them changes anything. Readers do not conflict with each other. Only a writer conflicts with anyone.
Third problem. You want at most five threads calling an external service at once. Not one, five. A lock cannot express that at all.
Fourth problem, from last section. Producers waiting for space and consumers waiting for items share one wait set, so notifyAll wakes both groups for every event.
The idea
ReentrantLock is synchronized with the missing methods.
private final ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
counter++;
} finally {
lock.unlock(); // you must do this. Nothing else will.
}| Method | What it gives you | |
|---|---|---|
| lock() | wait for it, like synchronized | not interruptible |
| tryLock() | take it or return false at once | never waits |
| tryLock(t, unit) | wait up to a deadline | returns false if the time runs out |
| lockInterruptibly() | wait, and accept an interrupt | throws InterruptedException |
| new ReentrantLock(true) | fair mode | longest waiter goes next. Slower |
Verified against a held lock:
tryLock() : false
tryLock(200ms) : false
lockInterruptibly : interrupted out of waitingThat third line is the one synchronized cannot do at all.
ReadWriteLock lets readers share.
private final ReadWriteLock rw = new ReentrantReadWriteLock();
// many at once
rw.readLock().lock();
try { return config.get(key); } finally { rw.readLock().unlock(); }
// one, and no readers
rw.writeLock().lock();
try { config.put(key, value); } finally { rw.writeLock().unlock(); }Any number of readers, or one writer, never both.
And now the measurement, which does not say what you expect. Eight reader threads, nothing writing:
| What each read does | Exclusive lock against read write lock | |
|---|---|---|
| read 1 int | 200,000 rounds each | 40 ms against 470 ms |
| read 16 ints | 200,000 rounds each | 31 ms against 528 ms |
| read 256 ints | 20,000 rounds each | 36 ms against 58 ms |
| read 4096 ints | 20,000 rounds each | 564 ms against 94 ms |
On short reads the read write lock is seventeen times slower. On long ones it is six times faster.
A read lock has to count how many readers are inside, and that count is shared, so entering and leaving are atomic updates that eight threads contend for. When the read itself is one array access, the accounting is nearly all the work.
The crossover here is somewhere between 256 and 4096 integers, and it is a property of this machine and this workload. The only honest rule is that a read write lock is worth it when reads are long, and a pessimisation when they are short.
Semaphore counts permits.
private final Semaphore slots = new Semaphore(3);
slots.acquire();
try { callExternalService(); } finally { slots.release(); }semaphore permits : 3
after two acquires : 1
tryAcquire(4) : falseThree threads through at a time. A Semaphore(1) is a lock, with one difference worth knowing: any thread can release a permit, including one that never acquired it. It is a counter, not an owner.
Condition splits the wait set.
private final Lock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
// producer
lock.lock();
try {
while (queue.size() == capacity) notFull.await();
queue.add(item);
notEmpty.signal(); // wake a CONSUMER, not everyone
} finally { lock.unlock(); }await and signal are wait and notify with different names, and the difference is that one lock can have several conditions. Producers wait on one, consumers on the other, and a signal reaches only the group that cares.
That is the answer to notifyAll waking everybody, and the same while loop rule from Section 14.6 still applies.
Under the hood
Going deeperThese locks are built on AbstractQueuedSynchronizer. One volatile int holding the state, and a queue of waiting threads.
For ReentrantLock the state is the hold count: 0 free, 1 held once, 2 held twice. Acquiring is a compare-and-swap from 0 to 1, which is Section 14.8.
For ReadWriteLock the same int is split in half: the top sixteen bits count readers and the bottom sixteen count writer holds. That is why the reader count is contended, and why the measurement came out the way it did.
For Semaphore the state is the number of permits.
Three different tools, one mechanism, and it is why they behave consistently.
Fairness is expensive. An unfair lock lets a thread that arrives at the right moment take it immediately, even if others have been queued for a while. That is called barging, and it is fast because the thread is already running and needs no context switch.
A fair lock always hands over to the longest waiter, which means waking a parked thread every time. It removes starvation and it can be several times slower. Unfair is the default for good reasons.
StampedLock adds optimistic reading.
long stamp = sl.tryOptimisticRead(); // takes no lock at all
int value = data;
if (!sl.validate(stamp)) { // did a writer arrive?
stamp = sl.readLock(); // fall back to a real read lock
try { value = data; } finally { sl.unlockRead(stamp); }
}The optimistic read acquires nothing. It reads a version number, does the work, and checks whether the number changed. If no writer came, the whole read cost one volatile read and no contention at all, which is exactly what the read write lock measurement was suffering from.
It is not reentrant, and it is easy to use wrongly. It is the right answer for a very read heavy structure where you have measured the read lock as the bottleneck.
What it costs
You have taken over the release, and every path out of the block is now yours to get right. One method that returns early without unlocking leaves a lock held forever, and the symptom is a program that stops with no exception anywhere.
More choices also means more ways to pick wrong. synchronized had one behaviour. These have fairness, timeouts, reentrancy and several kinds of read, and the wrong combination compiles perfectly.
The read write lock is the clearest trap in the section. It has a reputation as a reliable optimisation, and on short reads it was seventeen times slower than the thing it replaced. Nothing about the code shows which side of the crossover you are on.
These locks are also invisible to a thread dump in a way synchronized is not. A thread blocked on a monitor shows which object it is waiting for. A thread parked on an explicit lock shows a stack ending in LockSupport.park, and working out which lock that is takes real effort.
And StampedLock deserves its own warning. Not reentrant, easy to misuse, and a thread that takes a read lock twice deadlocks against itself.
Check yourself
Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.
What can a `ReentrantLock` do that `synchronized` cannot?
Show the answer
Four things, and each one answers a real gap.
Give up.
tryLock()returns false at once if the lock is held, so a thread can do something else instead of waiting.Wait with a deadline.
tryLock(200, MILLISECONDS)waits and then gives up.Be interrupted.
lockInterruptibly()throwsInterruptedExceptionwhile waiting. A thread waiting onsynchronizedignores interruption completely, which is the gap from Section 14.3.Be fair.
new ReentrantLock(true)hands the lock to whoever has waited longest, instead of whoever the scheduler happens to pick.The price is that you must release it yourself, in a
finally, every time.Why must an explicit lock be unlocked in a `finally` block?
Show the answer
Because nothing else will do it.
synchronizedreleases its monitor automatically when the block ends, including when an exception is thrown.lock.lock()has no block and no end, so if the code between it andunlock()throws, the lock is never released. Every other thread that wants it waits forever, and the program stops with no error to explain it.So the shape is fixed and there is no variation on it:
lock.lock(); try { ... } finally { lock.unlock(); }The
lock()call goes before the try. Putting it inside means an unlock that runs when the lock was never acquired.A `ReadWriteLock` lets many readers in at once. Why was it seventeen times slower than an exclusive lock in the measurement?
Show the answer
Because letting readers in costs bookkeeping, and the reads were too short to be worth it.
A read lock has to track how many readers are inside, which means an atomic update on the way in and another on the way out. When the read itself is one array access, that accounting is nearly all of the work, and eight threads fighting over the counter is worse than eight threads queueing politely.
Measured, eight readers: reading 1 int took 40 ms exclusive and 470 ms with a read write lock. Reading 4096 ints took 564 ms exclusive and 94 ms with the read write lock.
So it wins when reads are long enough to overlap usefully. On short reads it is a pessimisation, and nothing in the code tells you which case you are in.
Exercises
Write each solution in VS Code, predict its result, and then run the checks. Reading an example does not replace implementing it.
4 exercises105 pointsabout 105 minutes
Give Up on a Lock
ex-14-7-aFour methods, and each one exists because somebody hit a wall with synchronized.
Try all of them against a held lock and record what happens. Then try the same three things with synchronized and notice that you cannot even write them.
The fairness comparison is worth doing carefully. Count turns per thread as well as timing, because the fair version is worse on one number and much better on the other, and knowing which you need is the actual decision.
What your program must do
- Try all three acquisition methods against a held lock and record what each did
- Say which of the three are possible with synchronized
- Compare a fair lock against an unfair one for speed and for fairness
- Say what fairness costs and when you would pay it
import java.util.concurrent.*;
import java.util.concurrent.locks.*;
public class GiveUp {
static final ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) throws Exception {
// TODO: hold the lock on main. From another thread try:
// tryLock(), tryLock(200ms), lockInterruptibly() then interrupt it
// TODO: print what each one did
// TODO: try the same three things with synchronized. Which are possible?
// TODO: build a fair lock and an unfair one, run eight threads through each,
// and compare both the timing and how evenly the turns were shared
}
}
Hint 1
Hint 2
synchronized. A thread waiting on a monitor cannot give up, cannot time out and cannot be interrupted.Hint 3almost the answer
Find Where ReadWriteLock Starts Winning
ex-14-7-bMeasure before believing, especially when the advice is this widespread.
At short read lengths the read write lock is dramatically slower than the plain lock it was supposed to improve on. At long ones it wins comfortably. Somewhere in between is a crossover that belongs to your machine and your workload.
Warm the code first. Both versions get much faster once the JIT has seen them, and an unwarmed run can put the crossover in completely the wrong place.
What your program must do
- Compare both locks at four different read lengths
- Warm the code up before recording, and say why that matters here
- Find roughly where the crossover sits on your machine
- Add a writer and describe what happens to both versions
import java.util.concurrent.locks.*;
public class Crossover {
static int[] data = new int[4096];
static long readWork(int len) {
long s = 0;
for (int i = 0; i < len; i++) s += data[i];
return s;
}
public static void main(String[] args) throws Exception {
// TODO: eight reader threads, nothing writing. Compare an exclusive
// ReentrantLock against a ReadWriteLock's read lock.
// TODO: do it at read lengths of 1, 16, 256 and 4096 ints
// TODO: warm both up before timing
// TODO: find roughly where the crossover is on YOUR machine
// TODO: now add one writer and see what changes
}
}
Hint 1
Hint 2
Hint 3almost the answer
Wake Only the Right Group
ex-14-7-cBuild it both ways and count the wasted wakeups.
The notifyAll version is correct and noisy: every event wakes every waiter, and most of them go straight back to sleep. With four of each that is a lot of thread scheduling for nothing.
Two conditions turn that into a targeted signal. The while loops stay, because the other reasons for a false wakeup have not gone away, and the count of pointless wakeups drops sharply. That number is the whole argument for the class.
What your program must do
- Build a bounded buffer with a lock and two conditions
- Count pointless wakeups in that version and in a notifyAll version
- Run both with four producers and four consumers
- Say why notify is unsafe in the single monitor version
import java.util.*;
import java.util.concurrent.locks.*;
public class TwoGroups {
// TODO: a bounded buffer using ReentrantLock and TWO Conditions:
// notFull for producers
// notEmpty for consumers
public static void main(String[] args) throws Exception {
// TODO: count how many times a thread wakes up with nothing to do,
// in your Condition version
// TODO: write the same buffer with synchronized and notifyAll,
// and count the same thing
// TODO: run both with four producers and four consumers
// TODO: say what you would have to give up to use notify() instead
}
}
Hint 1
lock.newCondition() as many times as you like. Producers await on notFull and signal notEmpty. Consumers do the opposite.Hint 2
Hint 3almost the answer
notify can wake a producer when an item arrived. That producer re-checks, finds no space, and waits again, and the consumer was never told. Two conditions remove the possibility.The Unlock That Never Ran
ex-14-7-dOne exception, one missing finally, and the program is finished.
Nothing crashes at the moment of the mistake. The exception is caught, the program carries on, and the next thread that wants that lock waits forever with no error anywhere.
The last part is the one people get wrong when they first learn the rule. Moving lock() inside the try looks tidier and is a different bug, and being able to say exactly what it does is worth more than memorising the shape.
What your program must do
- Show a thrown exception leaving the lock held forever
- Print the lock state to confirm it is still held and by whom
- Fix it with try and finally
- Show that synchronized does not have this problem, and say why
import java.util.concurrent.locks.*;
public class NeverUnlocked {
static final ReentrantLock lock = new ReentrantLock();
static void broken(int i) {
lock.lock();
if (i == 3) throw new IllegalStateException("boom");
lock.unlock();
}
public static void main(String[] args) throws Exception {
// TODO: call broken() a few times, catching the exception, then have
// another thread try to take the lock. What happens?
// TODO: print lock.isLocked() and lock.getHoldCount()
// TODO: fix it with try/finally
// TODO: write the same broken shape with synchronized. Does it break?
// TODO: put lock() INSIDE the try and say what goes wrong with that
}
}
Hint 1
lock.isLocked() and lock.getHoldCount() will tell you the lock is still held after the exception escaped.Hint 2
synchronized releases its monitor on every exit including an exception, because the compiler wraps it in an implicit finally. That is the convenience you gave up.Hint 3almost the answer
lock() inside the try means the finally runs even when acquiring failed, so you would unlock a lock you never took, which throws IllegalMonitorStateException.After the credits
One line in this section was doing something you have not been shown.
Acquiring is a compare-and-swap from 0 to 1.
Every lock here is built on an instruction that reads a value, compares it with what you expected, and writes a new one, as a single unbreakable step. That is the thing you could not build in Section 14.5 out of ordinary reads and writes.
Section 14.8 is that instruction and what you can do with it directly. AtomicInteger fixes the counter from Section 14.4 with no lock at all, no waiting and no blocking, and it is faster than synchronized under contention.
The idea is called lock-free, and the trade is that a thread never waits and may have to try again. The retry loop, and the strange bug it can hide, are Section 14.9.
Explicit locks will return in 14.8 - Counting Without a Lock