Lesson 63Phase 7: Concurrency
Safe sharing: atomics, locks and concurrent collections
AtomicInteger, ConcurrentHashMap, locks and latches, plus deadlock and how to avoid it.
By BasithUpdated 45 min
- AtomicInteger
- ConcurrentHashMap
- ReentrantLock
- CountDownLatch
- deadlock
- immutability
In thread synchronization you fixed a shared counter with synchronized: one lock, and every thread waits its turn. That works, but it is a blunt tool. The package java.util.concurrent has sharper ones, each shaped for one common job: a counter that never loses an update, a map that many threads can fill at once, a lock you can hold in your hand, and a way to wait until other threads are ready. This lesson takes them one at a time, then looks at the bug that locks bring with them, the deadlock.
- Count safely from many threads with
AtomicInteger, and say whatcompareAndSetdoes. - Share a map between threads with
ConcurrentHashMap, and update it withmergeorcomputerather than a check followed by a put. - Guard code with a
ReentrantLockin the lock, try, finally shape, and explain what reentrant means. - Make one thread wait for several others with a
CountDownLatch. - Explain how a deadlock happens, recognise one in
jstackoutput, and prevent it by taking locks in one order. - Use immutable objects to share data with no locking at all, and pick the right tool for a job.
count++ is three steps (read, add, write back), so two threads can both read the same old value and one update is lost; synchronized fixes it by letting one thread in at a time; and a check followed by an act (is a seat free? then sell it) races in the same way. From thread pools and virtual threads: the ways to start the threads that share things. From HashMap: merge and how a hash map stores its entries.One lock for everything is a blunt tool#
A synchronizedmethod takes the object's lock for the whole method. Every other thread that wants any synchronized method of that object waits in line, even when all it wants is to add one to a number. The program is correct, but the threads spend their time queuing. And a lock you cannot see is a lock you cannot test: you cannot ask “is it free?”, or give up after a second of waiting.
The concurrency package answers each of those needs with its own class. Here is the map of this lesson:
| The job | The tool |
|---|---|
| Count or total something from many threads | AtomicInteger, AtomicLong, LongAdder |
| Share a map that many threads read and update | ConcurrentHashMap |
| Guard several steps, with more control than synchronized | ReentrantLock |
| Wait until other threads have finished their part | CountDownLatch |
| Share data that never changes | An immutable object, such as a record |
Atomic variables: a counter with no lock#
Start with the bug from thread synchronization. Four threads each add one to a shared int, a hundred thousand times. The total should be 400,000.
public class Main { static int count = 0; public static void main(String[] args) throws InterruptedException { Thread[] workers = new Thread[4]; for (int i = 0; i < workers.length; i++) { workers[i] = new Thread(() -> { for (int j = 0; j < 100_000; j++) { count++; } }); workers[i].start(); } for (Thread w : workers) { w.join(); } System.out.println("count = " + count); }}count = 133357 (eight runs on one laptop gave 133357, 127496,
141571, 128977, 159924, 123362, 153370 and 201695)Eight runs, eight different wrong answers, all far below 400,000. Now change one thing: make the counter an AtomicInteger (from java.util.concurrent.atomic) and call count.incrementAndGet() in place of count++.
import java.util.concurrent.atomic.AtomicInteger; public class Main { static AtomicInteger count = new AtomicInteger(); public static void main(String[] args) throws InterruptedException { Thread[] workers = new Thread[4]; for (int i = 0; i < workers.length; i++) { workers[i] = new Thread(() -> { for (int j = 0; j < 100_000; j++) { count.incrementAndGet(); } }); workers[i].start(); } for (Thread w : workers) { w.join(); } System.out.println("count = " + count.get()); }}count = 400000 (every run)
Every run prints count = 400000. An atomic operation is one that happens as a single step that no other thread can split: the read, the add and the write of incrementAndGet happen together, so no update is lost. And there is no synchronizedanywhere. The processor itself has an instruction for “change this number, but only if nobody else changed it first”, and the atomic classes are built on it.
The methods you will use#
Each method name says what it does and in which order. incrementAndGet adds one and then gives you the new value; getAndIncrement gives you the old value and then adds one. Step through and watch the value in the memory view.
import java.util.concurrent.atomic.AtomicInteger; public class Main { public static void main(String[] args) { AtomicInteger visitors = new AtomicInteger(); System.out.println(visitors.incrementAndGet()); // add 1, then read System.out.println(visitors.getAndIncrement()); // read, then add 1 System.out.println(visitors.get()); System.out.println(visitors.addAndGet(10)); System.out.println(visitors.updateAndGet(n -> n * 2)); }}It prints 1, 1, 2, 12 and 24. new AtomicInteger() starts at 0. addAndGet(10) adds any amount, and updateAndGet applies a function to the current value, all as one step each. AtomicLong has the same methods for a long, AtomicBoolean holds a boolean, and AtomicReference holds a reference to any object.
import java.util.concurrent.atomic.AtomicInteger; public class Main { public static void main(String[] args) { AtomicInteger ticket = new AtomicInteger(100); int a = ticket.getAndIncrement(); int b = ticket.incrementAndGet(); System.out.println(a + " " + b + " " + ticket.get()); }}The ticket counter starts at 100. What does the program print?
getAndIncrement hands back the old value, 100, and the counter becomes 101. incrementAndGet makes it 102 and hands back 102. Read the name left to right: get, then increment; or increment, then get.
compareAndSet: change it only if nobody else has#
Every atomic method is built from one idea, compareAndSet(expected, newValue): “if the value is still expected, set it to newValue and return true; if someone changed it, change nothing and return false.” You can use it yourself for any update that is more than adding. Here is the ticket booth from thread synchronization, which must never sell a seat that is not there, written with no lock at all:
import java.util.concurrent.atomic.AtomicInteger; public class Main { public static void main(String[] args) { AtomicInteger seats = new AtomicInteger(2); System.out.println(book(seats) + " " + book(seats) + " " + book(seats)); System.out.println("seats left: " + seats.get()); } // Takes one seat if any are left. Safe even when many threads call it at once. static boolean book(AtomicInteger seats) { while (true) { int left = seats.get(); if (left == 0) { return false; } if (seats.compareAndSet(left, left - 1)) { return true; } // Another thread changed seats after our get(): read again and retry. } }}It prints true true false and seats left: 0. Read bookas a thread would. It reads how many seats are left. If there are none, it gives up. Otherwise it says “I saw left; if it is still left, make it one less.” If another thread took a seat in between, the value is no longer left, compareAndSetrefuses, and the loop reads the new number and tries again. A seat can never be sold twice, because only one thread's compareAndSet can succeed for each value.
Key ideaAn atomic class makes one variable safe on its own, with no lock. compareAndSet is the tool underneath: change the value only if it is still what you last saw, and retry if it is not.
ConcurrentHashMap: a map many threads can share#
A plain HashMap was never meant to be changed by two threads at once. Four voting booths below each count 100,000 votes into one shared map, so every drink should end with 100,000.
import java.util.*; public class Main { static Map<String, Integer> votes = new HashMap<>(); public static void main(String[] args) throws InterruptedException { String[] options = {"tea", "coffee", "juice", "water"}; Thread[] booths = new Thread[4]; for (int i = 0; i < booths.length; i++) { booths[i] = new Thread(() -> { for (int j = 0; j < 100_000; j++) { votes.merge(options[j % 4], 1, Integer::sum); } }); booths[i].start(); } for (Thread b : booths) { b.join(); } int total = 0; for (int n : votes.values()) { total += n; } System.out.println(new TreeMap<>(votes) + " total " + total); }}Exception in thread "Thread-0" java.util.ConcurrentModificationException
at java.base/java.util.HashMap.merge(HashMap.java:1394)
at Main.lambda$main$0(Main.java:12)
at java.base/java.lang.Thread.run(Thread.java:1516)
{coffee=53668, juice=53951, tea=53052, water=48965} total 209636
(ten runs gave totals from 144083 to 244666, and in seven of them
at least one booth's thread died with the exception above)Two kinds of damage. Votes vanish, the same lost update as before, now inside the map. And sometimes HashMap notices that it was changed while it was in the middle of a merge and throws ConcurrentModificationException, which kills that booth's thread and every vote it had left to count. Change only the line that creates the map:
static Map<String, Integer> votes = new ConcurrentHashMap<>(); // import java.util.concurrent.ConcurrentHashMap{coffee=100000, juice=100000, tea=100000, water=100000} total 400000 (every run)Every run counts all 400,000 votes. ConcurrentHashMap is a Map like any other, so nothing else in the program changes. Inside, it is built so that many threads can read at the same time and threads writing to different parts of the table do not wait for each other, and each of its methods, merge included, happens as one atomic step.
merge, compute and friends#
The methods that change a value based on the old one are the ones that matter when threads share a map, because each is a single step. Step through and watch the entries appear.
import java.util.*;import java.util.concurrent.ConcurrentHashMap; public class Main { public static void main(String[] args) { Map<String, Integer> votes = new ConcurrentHashMap<>(); String[] ballot = {"tea", "coffee", "tea", "juice", "tea"}; for (String drink : ballot) { votes.merge(drink, 1, Integer::sum); } System.out.println(votes.get("tea") + " " + votes.get("coffee") + " " + votes.get("water")); votes.putIfAbsent("water", 0); votes.compute("juice", (drink, n) -> n == null ? 1 : n + 10); System.out.println(new TreeMap<>(votes)); }}It prints 3 1 null and {coffee=1, juice=11, tea=3, water=0}. merge(key, 1, Integer::sum) puts 1 for a new key and adds 1 to an existing one. putIfAbsent adds a key only if it is missing. compute gives you the key and its current value (or null) and stores whatever you return. The TreeMap copy is only there to print the entries in a fixed order.
HashMap accepts null as a key or a value. A ConcurrentHashMap refuses both with a NullPointerException, even in get(null). The reason: when get returns null, it must mean “no such key”, because another thread could change the map between your get and a second call to containsKey.A safe map does not make two steps safe#
Each method of a ConcurrentHashMap is atomic on its own. Two calls in a row are not. Here the booths count with a check followed by an act, on the safe map:
String drink = options[j % 4];if (votes.containsKey(drink)) { votes.put(drink, votes.get(drink) + 1);} else { votes.put(drink, 1);}{coffee=70041, juice=69230, tea=78596, water=70408} total 288275
(six runs gave totals from 284123 to 298912, with no exception)No crash this time, and still more than a hundred thousand votes lost. Between one booth's get and its put, another booth can do its own get, put, and both write the same new number. That is the check-then-act race from thread synchronization again. The fix is to let the map do the whole update in one call: merge, compute, computeIfAbsent or putIfAbsent.
ConcurrentHashMap<String, Integer> called counts. Which line counts a word safely?ReentrantLock: a lock you hold in your hand#
Sometimes the thing to protect is several steps over several variables, which no single atomic variable can cover. synchronized does that job. ReentrantLock, from java.util.concurrent.locks, does the same job with the lock as an object you can see and ask questions of. It is always used in the same shape:
static int count = 0;static final ReentrantLock lock = new ReentrantLock(); // in each of the four threads, 100,000 times:lock.lock();try { count++;} finally { lock.unlock();}count = 400000 (every run)
lock() waits until the lock is free and takes it. The work goes in a try, and unlock() goes in its finally, so the lock is given back even if the work throws an exception. Forget the finally, and one exception leaves the lock taken forever: every other thread that wants it waits for good.
Sold one, 2 left and then Locked: false.Tap a line, then tap where it should go. Or use the arrows.
What reentrant means#
A thread that already holds the lock can take it again without waiting for itself. The lock counts how many times its owner has taken it, and it is free only when the count is back to 0. That is why a locked method can call another locked method of the same object. Step through and watch the hold count in the memory view.
import java.util.concurrent.locks.ReentrantLock; public class Main { static final ReentrantLock lock = new ReentrantLock(); static int balance = 100; public static void main(String[] args) { transfer(30); System.out.println(balance + ", locked: " + lock.isLocked()); } static void transfer(int amount) { lock.lock(); try { System.out.println("transfer holds: " + lock.getHoldCount()); withdraw(amount); } finally { lock.unlock(); } } static void withdraw(int amount) { lock.lock(); try { System.out.println("withdraw holds: " + lock.getHoldCount()); balance -= amount; } finally { lock.unlock(); } }}It prints transfer holds: 1, withdraw holds: 2 and 70, locked: false. Inside withdraw the same thread holds the lock twice; each unlock takes one hold away, and after the second the lock is free. A lock that was not reentrant would make withdraw wait for a lock its own thread holds, forever. synchronized is reentrant in the same way.
CountDownLatch: wait until the others are ready#
A shop app loads its menu, its prices and its stock on three threads at once, and must not open until all three are done. A CountDownLatch is a counter that threads count down and other threads wait on. new CountDownLatch(3) starts at 3; each loader calls countDown() when it finishes; await() blocks until the count reaches 0.
import java.util.concurrent.CountDownLatch; public class Main { public static void main(String[] args) throws InterruptedException { CountDownLatch ready = new CountDownLatch(3); startLoader("menu", 100, ready); startLoader("prices", 300, ready); startLoader("stock", 200, ready); System.out.println("Waiting for 3 loaders..."); ready.await(); System.out.println("All loaded. The shop opens."); } static void startLoader(String what, int millis, CountDownLatch ready) { new Thread(() -> { try { Thread.sleep(millis); } catch (InterruptedException e) { return; } System.out.println("Loaded " + what); ready.countDown(); }).start(); }}Waiting for 3 loaders... Loaded menu Loaded stock Loaded prices All loaded. The shop opens.
The loaders finish in order of how long they sleep (100, 200 and 300 milliseconds), so the menu comes first and the prices last, and only then does main get past await(). Here is the latch on its own, counted down by one thread so you can step through it:
import java.util.concurrent.CountDownLatch; public class Main { public static void main(String[] args) throws InterruptedException { CountDownLatch ready = new CountDownLatch(3); String[] parts = {"menu", "prices", "stock"}; for (String part : parts) { System.out.println("Loaded " + part); ready.countDown(); System.out.println("Still waiting for " + ready.getCount()); } ready.await(); System.out.println("All loaded. The shop opens."); }}A latch is used once. When it reaches 0 it stays open, further countDown() calls do nothing, and there is no way to wind it back up. Make a new one for the next round.
Deadlock: everyone waiting for everyone#
Locks bring a bug of their own. Ada and Ben are two threads, and each needs two locks, a fork and a knife, to do its work. Ada takes the fork first; Ben takes the knife first. Try it below: step each diner yourself, or play “Take turns”.
synchronized (fork) { synchronized (knife) { eat(); } }synchronized (knife) { synchronized (fork) { eat(); } }- take the fork
- take the knife
- eat
- put both down
- take the knife
- take the fork
- eat
- put both down
Ada takes the fork first, Ben takes the knife first. Each needs both to eat. Choose who moves.
If Ada takes the fork and Ben takes the knife before either reaches for the second tool, each now holds what the other needs, and each will wait until the other lets go. Neither ever will. That is a deadlock. Here is the same thing in Java, with real threads:
public class Main { public static void main(String[] args) { Object fork = new Object(); Object knife = new Object(); Thread ada = new Thread(() -> { synchronized (fork) { System.out.println("Ada has the fork"); pause(); synchronized (knife) { System.out.println("Ada eats"); } } }, "Ada"); Thread ben = new Thread(() -> { synchronized (knife) { System.out.println("Ben has the knife"); pause(); synchronized (fork) { System.out.println("Ben eats"); } } }, "Ben"); ada.start(); ben.start(); } static void pause() { try { Thread.sleep(100); } catch (InterruptedException e) { throw new RuntimeException(e); } }}Ada has the fork Ben has the knife (and then nothing: the program never ends, and nothing is printed to say why)
The pause() makes the bad timing happen every time, so you can see it; in a real program the same timing happens rarely, which makes deadlocks hard to find. The program does not crash and prints no error. It just stops making progress, and you end it with Ctrl+C.
Finding a deadlock with jstack#
The JDK comes with a tool that looks inside a running Java program. While the program hangs, run jps in another terminal to list running Java programs with their process ids, then jstack with the id. At the end of its report:
Found one Java-level deadlock:============================="Ada": waiting to lock monitor 0x000000750ac8e760 (object 0x0000007000f12760, a java.lang.Object), which is held by "Ben" "Ben": waiting to lock monitor 0x000000750ac8e840 (object 0x0000007000f12750, a java.lang.Object), which is held by "Ada"...Found 1 deadlock.It names both threads, which is why they were given names in the constructor, and says exactly who holds what and who waits for what. The lines it cut (shown as ...) give each thread's stack with the line numbers of the two synchronized blocks.
The fix: everyone takes the locks in the same order#
Switch the lab above to “Same order” and play “Take turns” again. Now both diners reach for the fork first. Whoever gets the fork can always get the knife, because the other diner cannot be holding the knife without the fork. The other one simply waits its turn. In the Java program, that means Ben's outer block becomes synchronized (fork) and his inner one synchronized (knife):
Thread ben = new Thread(() -> { synchronized (fork) { System.out.println("Ben has the fork"); pause(); synchronized (knife) { System.out.println("Ben eats"); } }}, "Ben");Ada has the fork Ada eats Ben has the fork Ben eats
All six runs finished, with Ada first because her thread starts first. (Ben could win the fork instead, and then he would eat first; either way, both eat.)
Key ideaA deadlock needs threads that hold one lock while waiting for another, in a circle. Take every set of locks in one agreed order, and the circle cannot form.
Three more habits keep deadlocks away:
- Hold a lock for as short a time as you can, and never wait for something slow (a file, the network, the user) while holding one.
- Do not call code you do not control, such as a method passed in by someone else, while holding a lock: it might take locks of its own.
- When the order cannot be fixed, use
tryLockwith a timeout, and if the second lock does not come, let go of the first and try again later.
The easiest safety: data that never changes#
Every problem on this page comes from threads changing shared data. Data that nobody can change needs no protection at all: any number of threads can read it at once. A record with only immutable parts is like that. The one trap is a list inside it, which the outside world could still change, so the record makes its own unchangeable copy.
import java.util.*; public class Main { public static void main(String[] args) { List<String> names = new ArrayList<>(List.of("Ada", "Ben")); Team team = new Team("Owls", names); names.add("Cy"); System.out.println(team.members() + " " + names); try { team.members().add("Dee"); } catch (UnsupportedOperationException e) { System.out.println("A Team cannot be changed"); } }} record Team(String name, List<String> members) { Team { members = List.copyOf(members); }}It prints [Ada, Ben] [Ada, Ben, Cy] and A Team cannot be changed. Adding Cy to the original list does not reach the team, because List.copyOf made a separate copy, and that copy refuses every change. A Team can be handed to a hundred threads with no lock. When something must change, make a new object with the new values, as records encourage, and share the new one.
Which tool for which job#
| You have | Reach for | Because |
|---|---|---|
| One number that many threads add to | AtomicInteger | Each update is one step, with no lock. |
| A busy statistic that is read now and then | LongAdder | Threads add to separate counts and do not fight. |
| A value that changes by a rule (if seats are left, take one) | compareAndSet in a loop | The change happens only if nobody changed the value first. |
| A map shared by many threads | ConcurrentHashMap | Safe reads and writes, and merge and compute update in one step. |
| Several steps over several variables | synchronized, or ReentrantLock for tryLock and timeouts | Only one thread at a time runs the guarded steps. |
| Work that must wait for other threads to finish | CountDownLatch | await() blocks until the count reaches 0. |
| Data that never needs to change | An immutable record | Nothing to protect, so nothing to lock. |
AtomicInteger: nextTicket() must add one and return the new number in a single atomic step, and peek() must return the number the next call will hand out, without taking it.Try it
Answer each one in your head or on paper first, then open the answer.
visits++ on a static int, and the server handles requests on many threads. The count is always too low. Explain why, and give two different fixes.book, why does the loop read seats.get() again after a failed compareAndSet, instead of trying compareAndSet(left, left - 1) again with the same left?HashMap for ConcurrentHashMap, but still counts with if (m.containsKey(k)) m.put(k, m.get(k) + 1); else m.put(k, 1);. Will the counts be right now? Why?lock.unlock() go in a finally block? What happens to the other threads if the code between lock() and unlock() throws and there is no finally?Practice
Work through these on your own. Each one builds on this page.
LostUpdates.java five times, then the AtomicInteger version five times, and record the totals. Then change the number of threads to 8 and the loop to 1,000,000, and time both versions with System.nanoTime(). Also try a version with synchronized and one with LongAdder. Which is fastest on your machine, and are all the correct ones correct every time?WordCounter class that splits a long text into four parts and counts the words of each part on its own thread into one shared ConcurrentHashMap with merge. Check the result against a simple one-thread count of the same text: the two maps must be equal.Deadlock.java and, while it hangs, use jps and jstack to find the deadlock yourself. Then fix it twice: once by taking the locks in the same order, and once by switching to two ReentrantLocks and using tryLock(100, TimeUnit.MILLISECONDS) for the second lock, letting go of the first and retrying when it fails.CountDownLatch. Then write it again with a thread pool and invokeAll, and say which version you find clearer and why.Further reading#
Spotted a mistake, or a part that did not make sense? Report it by email