Layers of Logic

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.

Core21 min read4 exercises
01

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.

02

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.

03

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.
}
MethodWhat it gives you
lock()wait for it, like synchronizednot interruptible
tryLock()take it or return false at oncenever waits
tryLock(t, unit)wait up to a deadlinereturns false if the time runs out
lockInterruptibly()wait, and accept an interruptthrows InterruptedException
new ReentrantLock(true)fair modelongest waiter goes next. Slower

Verified against a held lock:

tryLock()            : false
tryLock(200ms)       : false
lockInterruptibly    : interrupted out of waiting

That 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 doesExclusive lock against read write lock
read 1 int200,000 rounds each40 ms against 470 ms
read 16 ints200,000 rounds each31 ms against 528 ms
read 256 ints20,000 rounds each36 ms against 58 ms
read 4096 ints20,000 rounds each564 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)        : false

Three 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.

04

Under the hood

Going deeper

These 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.

05

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.

06

Check yourself

Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.

  1. 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() throws InterruptedException while waiting. A thread waiting on synchronized ignores 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.

  2. Why must an explicit lock be unlocked in a `finally` block?

    Show the answer

    Because nothing else will do it. synchronized releases 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 and unlock() 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.

  3. 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.

07

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

The Layers of Logic VS Code extension runs the checks for exercises marked checked. For a manual exercise, run the program and compare its behaviour with the stated requirements and sample output.
A

Give Up on a Lock

Real work·25 min·25 points

ex-14-7-a

Four 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
GiveUp.java
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
Expect tryLock false, tryLock with a timeout false after waiting, and lockInterruptibly throwing InterruptedException when you interrupt the waiter.
Hint 2
None of the three are possible with synchronized. A thread waiting on a monitor cannot give up, cannot time out and cannot be interrupted.
Hint 3almost the answer
Count how many times each thread got through. An unfair lock will be lopsided and faster, because a running thread can barge in without a context switch. A fair lock shares evenly and pays for a wakeup every time.
What this is really testing

Whether the four extra methods on ReentrantLock are a list you have read or things you have used. Each one answers a specific thing synchronized cannot do.

B

Find Where ReadWriteLock Starts Winning

Hard·30 min·30 points

ex-14-7-b

Measure 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
Crossover.java
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
One set of numbers: at 1 int, 40 ms exclusive against 470 ms readWrite. At 4096 ints, 564 ms exclusive against 94 ms readWrite.
Hint 2
A read lock has to count how many readers are inside, and that counter is itself shared. Entering and leaving are contended atomic updates, and on a one element read that accounting is nearly all of the work.
Hint 3almost the answer
Adding a writer changes the picture again, because now the exclusive lock has real work to exclude. The read write lock's advantage grows, which is the case it was designed for.
What this is really testing

Whether you take the read write lock on trust. It has a reputation as an easy win and it was seventeen times slower until the reads got long enough.

C

Wake Only the Right Group

Hard·30 min·30 points

ex-14-7-c

Build 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
TwoGroups.java
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
Count a pointless wakeup as any time a thread returns from await or wait and finds its while condition still false.
Hint 3almost the answer
In the single monitor version, 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.
What this is really testing

Whether you can see what Condition adds. One monitor has one wait set, so notifyAll wakes producers and consumers alike, and Condition is how you stop doing that.

D

The Unlock That Never Ran

Real work·20 min·20 points

ex-14-7-d

One 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
NeverUnlocked.java
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
Putting lock() inside the try means the finally runs even when acquiring failed, so you would unlock a lock you never took, which throws IllegalMonitorStateException.
What this is really testing

Whether the finally is a convention or the thing holding your program up. synchronized releases for you and an explicit lock does not, and forgetting once is permanent.

08

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