14.8
Counting Without a Lock
One processor instruction reads, compares and writes as a single unbreakable step. Every lock in the last section is built on it, and using it directly fixes the counter with no waiting at all. It is also slower than a lock once eight threads want it.
Previously on
Section 14.7 said one thing without explaining it:
Acquiring is a compare-and-swap from 0 to 1.
Every lock in that section is built on it. Section 14.5 showed why it has to exist: a lock cannot be built from ordinary reads and writes, because the lock itself would have a race in it.
The problem
Every fix so far has been the same shape. Stop other threads from being here.
synchronized (lock) { counter++; }It works and it costs. A thread that arrives during your turn stops running, and getting it going again is a context switch: registers saved, scheduler consulted, caches now full of the wrong data. Section 14.1 put a price on that.
Now imagine what would happen if a thread was paused while holding the lock. The operating system deschedules it halfway through the increment. Every other thread waiting for that lock waits until it is scheduled again, and none of them can do anything about it.
That is the cost of blocking. Not the waiting itself, but that one thread’s bad luck stops everybody.
And the increment is three instructions, which was the whole problem. Read, add, write, with two gaps.
What is missing is a way to do all three as one step. Something no other thread can be in the middle of.
It cannot be written in Java. Here is the attempt:
if (value == expected) {
value = next;
return true;
}Read, compare, write. Two threads can both find value == expected before either one writes, which is exactly the failure from Section 14.4 wearing a different shape.
You cannot build atomicity out of things that are not atomic. It has to come from below.
The idea
Processors have an instruction for it. Compare-and-swap.
It takes three things: a memory location, the value you expect to find there, and the value you want to write. It reads, compares, and writes only if they match, and it reports whether it wrote. No other core can touch that memory in the middle.
AtomicInteger v = new AtomicInteger(5);
v.compareAndSet(5, 6); // true. Value was 5, now it is 6
v.compareAndSet(5, 7); // false. Value is 6 now, so nothing happenedVerified exactly.
Which gives you a counter with no lock:
AtomicInteger counter = new AtomicInteger();
counter.incrementAndGet();atomic counter : 800000 (wanted 800000)Correct, and no thread ever blocked.
Inside, it is a retry loop:
What incrementAndGet does
- Read the current valueSay it is 7.
- Work out the new one8. Nothing is shared yet, so this cannot go wrong.
- Compare-and-swap 7 to 8One instruction. It succeeds only if the value is still 7.
- If it failed, go back to step oneSomebody changed it. Read the new value, add one to that, try again.
A thread that loses is never parked. It reads again and retries, still running the whole time.
That is what lock-free means: no thread is ever stopped by another. One thread being descheduled cannot hold anyone else up, because nobody was waiting for it.
The family of atomic classes:
| Class | What it holds | |
|---|---|---|
| AtomicInteger, AtomicLong | a number | incrementAndGet, addAndGet, compareAndSet |
| AtomicBoolean | a flag | compareAndSet, useful for do-this-once |
| AtomicReference<T> | any object | swap a whole object atomically |
| LongAdder | a number, spread out | built for many threads counting at once |
Now the measurement, and it is not what the word lock-free suggests. Eight million increments in total, split across the threads:
| Threads | synchronized / ReentrantLock / AtomicInteger / LongAdder | |
|---|---|---|
| 1 | no contention at all | 77 / 102 / 57 / 61 ms |
| 2 | some | 333 / 503 / 148 / 32 ms |
| 8 | heavy | 420 / 179 / 326 / 24 ms |
At one thread the atomic is fastest. At two it beats both locks comfortably. At eight it loses to ReentrantLock, 326 ms against 179 ms.
The retry loop is why. With eight threads hammering one value, most compare-and-swaps fail, and every failure is real work thrown away on a core that could have been idle. A lock parks the losers, which sounds worse and means they stop competing for the memory.
Under the hood
Going deeperOn x86 the instruction is lock cmpxchg. The lock prefix tells the processor to hold the cache line for the duration, so no other core can read or write that memory in the middle. ARM does it differently, with load-linked and store-conditional, and the guarantee is the same.
The JVM reaches it through Unsafe.compareAndSwapInt, which is compiled to that instruction directly rather than being called as a method. There is no Java code underneath.
Atomics are volatile as well. The value inside an AtomicInteger is a volatile int, so everything from Section 14.4 applies: every read is fresh, every write is visible, and no reordering across it. An atomic fixes all three problems, like a lock and unlike volatile alone.
Why heavy contention hurts so much. The counter lives in one cache line. When a core writes to it, every other core’s copy is invalidated and has to be fetched again.
Eight cores writing the same line means that line is being passed around constantly, and each pass is far slower than reading local cache. This is cache line ping-pong, and it is the real cost in the eight thread row above. The failed retries are the visible symptom.
LongAdder avoids it by giving threads different cells, which sit on different cache lines on purpose.
AtomicReference swaps whole objects, which is how you make a lock-free stack:
void push(T item) {
Node<T> head;
Node<T> next = new Node<>(item);
do {
head = top.get();
next.next = head;
} while (!top.compareAndSet(head, next));
}Build the new node pointing at whatever the top is, then swap the top from that to the new node. If somebody else pushed in between, the compare fails, and the loop reads the new top and tries again.
That loop is the shape of nearly all lock-free code, and it has a problem the next section is about.
What it costs
Lock-free is not a synonym for faster, which the eight thread row makes plain. Under heavy contention on one value, an atomic can lose to the lock it replaced. Nothing about the code tells you which side of that you are on.
The wasted work is invisible in the wrong way. Failed retries look like the processor being busy. A profiler shows high usage, everything appears to be working hard, and much of it is the same increment being computed and thrown away.
It is also single variable only. Real invariants usually span two fields, and there is no way to compose two atomic operations into one. That limitation sends most real code back to a lock.
Writing your own lock-free structure is genuinely hard, and the retry loop above is the easy half. The next section is a bug that lives inside exactly that pattern and cannot be seen by reading it.
And LongAdder’s sum() is approximate under concurrent updates. For a metric that is correct and worth knowing. For anything where the count has to be exact at the moment you read it, it is not the right class.
Check yourself
Answer from memory before opening the explanation. Then compare your rule with the answer and revisit the example if they differ.
What does compare-and-swap actually do, and why can it not be written in Java?
Show the answer
Three things as one unbreakable step: read the current value, compare it with what you expected, and write the new value only if they match. It reports whether it wrote.
Written out in Java it is
if (value == expected) { value = next; return true; }, which is exactly the read-change-write from Section 14.4 and has the same race in it. Two threads can both pass the comparison before either writes.It cannot be built out of ordinary reads and writes at all. It is a processor instruction,
lock cmpxchgon x86, and the processor guarantees no other core can touch that memory in the middle.That single instruction is what every lock in Section 14.7 is built on.
How does an atomic counter work when nothing ever waits?
Show the answer
It tries, and if it lost the race it tries again.
incrementAndGetis a loop: read the value, work out value plus one, compare-and-swap. If the swap fails, some other thread changed it, so read the new value and go round again.Nothing blocks. A thread that loses is not parked or queued, it just repeats, and it is still running the whole time.
That is what lock-free means. It does not mean no waiting ever happened. It means no thread is ever stopped by another, so one thread being descheduled cannot hold everyone else up.
Eight threads, one counter. Why did `AtomicInteger` lose to `ReentrantLock`?
Show the answer
Because at eight threads most of the compare-and-swaps fail, and every failure is wasted work on a core that could have been idle.
Measured, 8 million increments in total: at one thread Atomic was fastest at 57 ms. At two it was 148 ms against 333 for synchronized. At eight it was 326 ms while ReentrantLock was 179 ms.
All eight threads are hammering the same memory, so that cache line bounces between cores constantly and most attempts have to be redone. A lock parks the losers, which sounds worse and means they stop competing.
LongAddertook 24 ms at eight threads by not sharing one value at all.
Exercises
Write each solution in VS Code, predict its result, and then run the checks. Reading an example does not replace implementing it.
4 exercises115 pointsabout 110 minutes
Fix the Counter Four Ways
ex-14-8-aFour correct answers, and the ranking changes as you add threads.
Keep the total work fixed so the numbers mean something, and warm everything before recording. Then look at the shape rather than the individual figures: one of them improves with threads, two get worse, and one gets dramatically better.
The atomic losing to a lock at eight threads is the result worth explaining out loud. Lock-free does not mean nobody waits. It means nobody blocks, and a thread that retries forever is still burning a core.
What your program must do
- Time all four at one, two and eight threads with the same total work
- Confirm all four are exactly correct every time
- Say which wins at each thread count
- Explain why the atomic loses ground as threads are added
import java.util.concurrent.atomic.*;
import java.util.concurrent.locks.*;
public class FourWays {
public static void main(String[] args) throws Exception {
// TODO: count to 8 million four ways: synchronized, ReentrantLock,
// AtomicInteger, LongAdder
// TODO: do it at 1 thread, 2 threads and 8 threads, keeping the TOTAL
// work the same so the numbers compare
// TODO: warm everything up first
// TODO: confirm all four give exactly 8000000
// TODO: write down which wins at each thread count, and explain the shape
}
}
Hint 1
Hint 2
Hint 3almost the answer
Write incrementAndGet Yourself
ex-14-8-bSix lines, and after writing them AtomicInteger stops being a mystery.
The failure counter is the part that teaches. The answer comes out exactly right and the number of discarded attempts is larger than the number of successful ones, which is a shape you would never guess from reading the API.
Compare that count at two threads and eight. It does not grow gently.
What your program must do
- Write the retry loop yourself and confirm the total is exact
- Count failed compare-and-swap attempts and report the number
- Compare the failure count at two threads and at eight
- Try onSpinWait after repeated failures and describe the effect
import java.util.concurrent.atomic.*;
public class MyIncrement {
static final AtomicInteger value = new AtomicInteger();
static final AtomicLong failures = new AtomicLong();
static int myIncrementAndGet() {
// TODO: read, add one, compareAndSet. Retry on failure. Count failures.
return 0;
}
public static void main(String[] args) throws Exception {
// TODO: eight threads x 200_000 through your version. Check the total.
// TODO: print how many compareAndSet calls FAILED
// TODO: compare that count at 2 threads and at 8
// TODO: add Thread.onSpinWait() after a few failures. Does anything change?
}
}
Hint 1
int cur; do { cur = value.get(); } while (!value.compareAndSet(cur, cur + 1)); with a counter in the failure path.Hint 2
Hint 3almost the answer
Thread.onSpinWait() compiles to a pause instruction that tells the processor this is a spin loop. It does not sleep or release anything, and on a heavily contended loop it can help a little.Atomic on One Field Is Not Enough
ex-14-8-cEvery line here is atomic and the method is not.
Show it failing in two different ways: an observer catching the gap between the two lines, and a check-then-act letting the balance go negative. Both are the same underlying issue, which is that atomicity does not compose.
The AtomicReference fix is worth writing even if you would ship the lock. Swapping one immutable object holding both values is how lock-free code handles multiple fields, and it is a direct use of Phase VIII.
What your program must do
- Show the balance and the history disagreeing even though both lines are atomic
- Show the balance going below zero despite an atomic check
- Fix it with a single AtomicReference to an immutable object
- Fix it with a lock, and say which you would choose
import java.util.*;
import java.util.concurrent.atomic.*;
public class NotEnough {
static final AtomicInteger balance = new AtomicInteger(1000);
static final List<String> history = Collections.synchronizedList(new ArrayList<>());
static void withdraw(int amount) {
balance.addAndGet(-amount); // atomic
history.add("withdrew " + amount); // also atomic
}
public static void main(String[] args) throws Exception {
// TODO: run withdraw from several threads and have another thread
// repeatedly check that balance and history AGREE.
// Show that they disagree sometimes.
// TODO: also show the balance going negative even though each step is atomic
// TODO: fix it with one AtomicReference holding an immutable record of both
// TODO: fix it again with a lock, and say which you would ship
}
}
Hint 1
Hint 2
if (balance.get() >= amount) balance.addAndGet(-amount) has the same problem: two threads can both pass the check.Hint 3almost the answer
Build a Lock-Free Stack
ex-14-8-dThe same retry loop as the counter, on a structure rather than a number.
Get it working with eight threads pushing and eight popping, and check both directions: nothing lost, and nothing handed out twice. The second is easier to get wrong and harder to notice.
The last question is the important one, and you should answer it before reading Section 14.9. When your pop finds the top unchanged, what exactly have you concluded from that? Write the sentence down. The next section is about why it is not true.
What your program must do
- Implement push and pop with compare-and-swap retry loops
- Run eight threads pushing and eight popping, and confirm nothing is lost
- Confirm nothing is returned twice
- Say what your pop assumes when its compare succeeds
import java.util.concurrent.atomic.*;
public class Stack<T> {
static class Node<T> {
final T value; Node<T> next;
Node(T value) { this.value = value; }
}
private final AtomicReference<Node<T>> top = new AtomicReference<>();
public void push(T item) {
// TODO: build a node, point it at the current top,
// compareAndSet the top from that to your node, retry on failure
}
public T pop() {
// TODO: read the top, work out the new top, compareAndSet, retry
// Return null when it is empty.
return null;
}
public static void main(String[] args) throws Exception {
// TODO: eight threads pushing 100_000 each, then eight popping.
// Confirm nothing is lost and nothing is duplicated.
}
}
Hint 1
push is: make the node, point it at top.get(), then compareAndSet(thatTop, yourNode). If it fails, read the top again and repeat.Hint 2
pop reads the top, remembers top.next, and compare-and-swaps the top to that. Return null when the top is null, and check for that before reading next.Hint 3almost the answer
After the credits
Look at the retry loop again.
do {
head = top.get();
next.next = head;
} while (!top.compareAndSet(head, next));The compare asks: is the top still the same object I read? If yes, nothing changed, so it is safe to swap.
That reasoning has a hole in it. Between your read and your compare, another thread can pop your node, push two others, and push your node back. The top is the same object. Nothing about the structure underneath is the same at all, and your compare succeeds.
Section 14.9 is that bug. It is called the ABA problem, it is unfixable by looking harder at the code, and the answer is to stop comparing values and start comparing versions. AtomicStampedReference exists for it.
The same section covers what happens to a retry loop when it never wins, which is the failure mode lock-free code has instead of deadlock.
Compare-and-swap will return in 14.9 - The Value Came Back