Learn Java

Lesson 63Phase 7: Concurrency

Safe sharing: atomics, locks and concurrent collections

AtomicInteger, ConcurrentHashMap, locks and latches, plus deadlock and how to avoid it.

By Updated 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.

By the end of this lesson
  • Count safely from many threads with AtomicInteger, and say what compareAndSet does.
  • Share a map between threads with ConcurrentHashMap, and update it with merge or compute rather than a check followed by a put.
  • Guard code with a ReentrantLock in 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 jstack output, 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.
From before
From thread synchronization: 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.
Which programs run here
The visualizer runs one thread, so every program on this page that starts threads is shown with the output of real runs on a JDK, several runs each, because the results change from run to run. The tools themselves run here as one thread sees them, so you can step through every method.

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 jobThe tool
Count or total something from many threadsAtomicInteger, AtomicLong, LongAdder
Share a map that many threads read and updateConcurrentHashMap
Guard several steps, with more control than synchronizedReentrantLock
Wait until other threads have finished their partCountDownLatch
Share data that never changesAn 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.

LostUpdates.java (four threads, real runs)
read along
1public class Main {
2 static int count = 0;
3
4 public static void main(String[] args) throws InterruptedException {
5 Thread[] workers = new Thread[4];
6 for (int i = 0; i < workers.length; i++) {
7 workers[i] = new Thread(() -> {
8 for (int j = 0; j < 100_000; j++) {
9 count++;
10 }
11 });
12 workers[i].start();
13 }
14 for (Thread w : workers) {
15 w.join();
16 }
17 System.out.println("count = " + count);
18 }
19}
Output
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++.

AtomicCount.java (four threads, real runs)
read along
1import java.util.concurrent.atomic.AtomicInteger;
2
3public class Main {
4 static AtomicInteger count = new AtomicInteger();
5
6 public static void main(String[] args) throws InterruptedException {
7 Thread[] workers = new Thread[4];
8 for (int i = 0; i < workers.length; i++) {
9 workers[i] = new Thread(() -> {
10 for (int j = 0; j < 100_000; j++) {
11 count.incrementAndGet();
12 }
13 });
14 workers[i].start();
15 }
16 for (Thread w : workers) {
17 w.join();
18 }
19 System.out.println("count = " + count.get());
20 }
21}
Output
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.

Visitors.java
1import java.util.concurrent.atomic.AtomicInteger;
2
3public class Main {
4 public static void main(String[] args) {
5 AtomicInteger visitors = new AtomicInteger();
6
7 System.out.println(visitors.incrementAndGet()); // add 1, then read
8 System.out.println(visitors.getAndIncrement()); // read, then add 1
9 System.out.println(visitors.get());
10 System.out.println(visitors.addAndGet(10));
11 System.out.println(visitors.updateAndGet(n -> n * 2));
12 }
13}
Prints
›1
›1
›2
›12
›24

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.

Predict the outputTickets.java
1import java.util.concurrent.atomic.AtomicInteger;
2
3public class Main {
4 public static void main(String[] args) {
5 AtomicInteger ticket = new AtomicInteger(100);
6 int a = ticket.getAndIncrement();
7 int b = ticket.incrementAndGet();
8 System.out.println(a + " " + b + " " + ticket.get());
9 }
10}

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:

Seats.java
1import java.util.concurrent.atomic.AtomicInteger;
2
3public class Main {
4 public static void main(String[] args) {
5 AtomicInteger seats = new AtomicInteger(2);
6 System.out.println(book(seats) + " " + book(seats) + " " + book(seats));
7 System.out.println("seats left: " + seats.get());
8 }
9
10 // Takes one seat if any are left. Safe even when many threads call it at once.
11 static boolean book(AtomicInteger seats) {
12 while (true) {
13 int left = seats.get();
14 if (left == 0) {
15 return false;
16 }
17 if (seats.compareAndSet(left, left - 1)) {
18 return true;
19 }
20 // Another thread changed seats after our get(): read again and retry.
21 }
22 }
23}
Prints
›true true false
›seats left: 0

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 idea

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

Votes.java with a HashMap (four threads, real runs)
read along
1import java.util.*;
2
3public class Main {
4 static Map<String, Integer> votes = new HashMap<>();
5
6 public static void main(String[] args) throws InterruptedException {
7 String[] options = {"tea", "coffee", "juice", "water"};
8 Thread[] booths = new Thread[4];
9 for (int i = 0; i < booths.length; i++) {
10 booths[i] = new Thread(() -> {
11 for (int j = 0; j < 100_000; j++) {
12 votes.merge(options[j % 4], 1, Integer::sum);
13 }
14 });
15 booths[i].start();
16 }
17 for (Thread b : booths) {
18 b.join();
19 }
20 int total = 0;
21 for (int n : votes.values()) {
22 total += n;
23 }
24 System.out.println(new TreeMap<>(votes) + " total " + total);
25 }
26}
Output
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:

Votes.java with a ConcurrentHashMap (four threads, real runs)
read along
1static Map<String, Integer> votes = new ConcurrentHashMap<>(); // import java.util.concurrent.ConcurrentHashMap
Output
{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.

Ballot.java
1import java.util.*;
2import java.util.concurrent.ConcurrentHashMap;
3
4public class Main {
5 public static void main(String[] args) {
6 Map<String, Integer> votes = new ConcurrentHashMap<>();
7 String[] ballot = {"tea", "coffee", "tea", "juice", "tea"};
8
9 for (String drink : ballot) {
10 votes.merge(drink, 1, Integer::sum);
11 }
12 System.out.println(votes.get("tea") + " " + votes.get("coffee") + " " + votes.get("water"));
13
14 votes.putIfAbsent("water", 0);
15 votes.compute("juice", (drink, n) -> n == null ? 1 : n + 10);
16 System.out.println(new TreeMap<>(votes));
17 }
18}
Prints
›3 1 null
›{coffee=1, juice=11, tea=3, water=0}

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.

No null keys or values
A 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:

Votes.java, check then act (four threads, real runs)
read along
1String drink = options[j % 4];
2if (votes.containsKey(drink)) {
3 votes.put(drink, votes.get(drink) + 1);
4} else {
5 votes.put(drink, 1);
6}
Output
{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.

Quick check
Several threads add words to a shared 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:

LockedCount.java (four threads, real runs)
read along
1static int count = 0;
2static final ReentrantLock lock = new ReentrantLock();
3
4// in each of the four threads, 100,000 times:
5lock.lock();
6try {
7 count++;
8} finally {
9 lock.unlock();
10}
Output
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.

Put the lines in orderShop.java
Put the lines in order so the program sells one item under the lock and prints 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.

Transfer.java
1import java.util.concurrent.locks.ReentrantLock;
2
3public class Main {
4 static final ReentrantLock lock = new ReentrantLock();
5 static int balance = 100;
6
7 public static void main(String[] args) {
8 transfer(30);
9 System.out.println(balance + ", locked: " + lock.isLocked());
10 }
11
12 static void transfer(int amount) {
13 lock.lock();
14 try {
15 System.out.println("transfer holds: " + lock.getHoldCount());
16 withdraw(amount);
17 } finally {
18 lock.unlock();
19 }
20 }
21
22 static void withdraw(int amount) {
23 lock.lock();
24 try {
25 System.out.println("withdraw holds: " + lock.getHoldCount());
26 balance -= amount;
27 } finally {
28 lock.unlock();
29 }
30 }
31}
Prints
›transfer holds: 1
›withdraw holds: 2
›70, locked: false

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.

Opening.java (three loader threads, real runs)
read along
1import java.util.concurrent.CountDownLatch;
2
3public class Main {
4 public static void main(String[] args) throws InterruptedException {
5 CountDownLatch ready = new CountDownLatch(3);
6
7 startLoader("menu", 100, ready);
8 startLoader("prices", 300, ready);
9 startLoader("stock", 200, ready);
10
11 System.out.println("Waiting for 3 loaders...");
12 ready.await();
13 System.out.println("All loaded. The shop opens.");
14 }
15
16 static void startLoader(String what, int millis, CountDownLatch ready) {
17 new Thread(() -> {
18 try {
19 Thread.sleep(millis);
20 } catch (InterruptedException e) {
21 return;
22 }
23 System.out.println("Loaded " + what);
24 ready.countDown();
25 }).start();
26 }
27}
Output
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:

Latch.java
1import java.util.concurrent.CountDownLatch;
2
3public class Main {
4 public static void main(String[] args) throws InterruptedException {
5 CountDownLatch ready = new CountDownLatch(3);
6 String[] parts = {"menu", "prices", "stock"};
7
8 for (String part : parts) {
9 System.out.println("Loaded " + part);
10 ready.countDown();
11 System.out.println("Still waiting for " + ready.getCount());
12 }
13 ready.await();
14 System.out.println("All loaded. The shop opens.");
15 }
16}
Prints
›Loaded menu
›Still waiting for 2
›Loaded prices
›Still waiting for 1
›Loaded stock
›Still waiting for 0
›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”.

Two threads, two locks
Ada
synchronized (fork) { synchronized (knife) { eat(); } }
Ben
synchronized (knife) { synchronized (fork) { eat(); } }
the forkfree
the knifefree
AdaRUNNABLE
  1. take the fork
  2. take the knife
  3. eat
  4. put both down
BenRUNNABLE
  1. take the knife
  2. take the fork
  3. eat
  4. put both down

Ada takes the fork first, Ben takes the knife first. Each needs both to eat. Choose who moves.

Play an order:

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:

Deadlock.java (real run)
read along
1public class Main {
2 public static void main(String[] args) {
3 Object fork = new Object();
4 Object knife = new Object();
5
6 Thread ada = new Thread(() -> {
7 synchronized (fork) {
8 System.out.println("Ada has the fork");
9 pause();
10 synchronized (knife) {
11 System.out.println("Ada eats");
12 }
13 }
14 }, "Ada");
15 Thread ben = new Thread(() -> {
16 synchronized (knife) {
17 System.out.println("Ben has the knife");
18 pause();
19 synchronized (fork) {
20 System.out.println("Ben eats");
21 }
22 }
23 }, "Ben");
24 ada.start();
25 ben.start();
26 }
27
28 static void pause() {
29 try {
30 Thread.sleep(100);
31 } catch (InterruptedException e) {
32 throw new RuntimeException(e);
33 }
34 }
35}
Output
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:

Terminal (the end of jstack's report)
read along
1Found one Java-level deadlock:
2=============================
3"Ada":
4 waiting to lock monitor 0x000000750ac8e760 (object 0x0000007000f12760, a java.lang.Object),
5 which is held by "Ben"
6
7"Ben":
8 waiting to lock monitor 0x000000750ac8e840 (object 0x0000007000f12750, a java.lang.Object),
9 which is held by "Ada"
10...
11Found 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):

Deadlock.java, same order (six real runs)
read along
1Thread ben = new Thread(() -> {
2 synchronized (fork) {
3 System.out.println("Ben has the fork");
4 pause();
5 synchronized (knife) {
6 System.out.println("Ben eats");
7 }
8 }
9}, "Ben");
Output
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 idea

A 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 tryLock with 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.

Team.java
1import java.util.*;
2
3public class Main {
4 public static void main(String[] args) {
5 List<String> names = new ArrayList<>(List.of("Ada", "Ben"));
6 Team team = new Team("Owls", names);
7 names.add("Cy");
8 System.out.println(team.members() + " " + names);
9 try {
10 team.members().add("Dee");
11 } catch (UnsupportedOperationException e) {
12 System.out.println("A Team cannot be changed");
13 }
14 }
15}
16
17record Team(String name, List<String> members) {
18 Team {
19 members = List.copyOf(members);
20 }
21}
Prints
›[Ada, Ben] [Ada, Ben, Cy]
›A Team cannot be changed

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 haveReach forBecause
One number that many threads add toAtomicIntegerEach update is one step, with no lock.
A busy statistic that is read now and thenLongAdderThreads add to separate counts and do not fight.
A value that changes by a rule (if seats are left, take one)compareAndSet in a loopThe change happens only if nobody changed the value first.
A map shared by many threadsConcurrentHashMapSafe reads and writes, and merge and compute update in one step.
Several steps over several variablessynchronized, or ReentrantLock for tryLock and timeoutsOnly one thread at a time runs the guarded steps.
Work that must wait for other threads to finishCountDownLatchawait() blocks until the count reaches 0.
Data that never needs to changeAn immutable recordNothing to protect, so nothing to lock.
ExerciseTicketMachine.java
A ticket machine at a deli is shared by several counters, each on its own thread. Finish it with its 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.

1
A web page counts its visitors with 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.
2
In 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?
3
A program swaps 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?
4
Why must 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?
5
Two threads transfer money between accounts. Thread 1 locks account A, then B. Thread 2 locks account B, then A. Describe how they can deadlock, and give a rule that prevents it whatever accounts are involved.

Practice

Work through these on your own. Each one builds on this page.

1
On your own computer, run 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?
2
Write a 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.
3
Run 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.
4
A program starts five worker threads that each prepare one part of a report, and the main thread must print the report only after all five are done. Write it with a 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