ExerciseChallenge
Challenge
Make five compound operations atomic
25 minintermediate2–12 yrs
Edge cases. You have to reason, and two valid fixes differ.
What this teaches
- Every method is atomic; a sequence of methods is not
- merge, compute, computeIfAbsent and replace each fit a different shape
- The mapping function runs under the bin lock, so it must be short and must not touch the map
- A thread-safe map says nothing about the objects stored in it
- A whole-map invariant cannot be expressed with per-key atomics at all
Starter
Starter.java
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
/**
* Challenge: five methods, all using a ConcurrentHashMap, all wrong.
*
* Every one was written by someone who chose the right class. The map is
* genuinely thread-safe; each of these methods calls it more than once, and
* the gap between the calls is where the bug lives.
*
* Four have a one-line fix. The fifth does not, and knowing why is the point
* of the exercise.
*/
public class Starter {
static final ConcurrentHashMap<String, Integer> counts = new ConcurrentHashMap<>();
static final ConcurrentHashMap<String, String> sessions = new ConcurrentHashMap<>();
static final ConcurrentHashMap<String, List<String>> groups = new ConcurrentHashMap<>();
static final ConcurrentHashMap<String, Long> lastSeen = new ConcurrentHashMap<>();
static final ConcurrentHashMap<String, String> cache = new ConcurrentHashMap<>();
static final int MAX_CACHE = 1_000;
/* ─────────── 1: counting ─────────── */
static void record(String key) {
counts.put(key, counts.getOrDefault(key, 0) + 1);
}
/* ─────────── 2: claim a session id, once ─────────── */
static String claim(String user, String sessionId) {
if (!sessions.containsKey(user)) {
sessions.put(user, sessionId);
}
return sessions.get(user);
}
/* ─────────── 3: grouping ─────────── */
static void addMember(String group, String member) {
groups.computeIfAbsent(group, g -> new ArrayList<>()).add(member);
}
/* ─────────── 4: keep the latest timestamp only ─────────── */
static void touch(String key, long timestamp) {
Long current = lastSeen.get(key);
if (current == null || current < timestamp) {
lastSeen.put(key, timestamp);
}
}
/* ─────────── 5: a bounded cache ─────────── */
static void store(String key, String value) {
if (cache.size() < MAX_CACHE) {
cache.put(key, value);
}
}
/* ─────────── your work ─────────── */
// TODO 1: for each of the five, list the map calls it makes in order, and
// mark where another thread can interleave. Do this before any edits.
// TODO 2: fix 1 with a single atomic call. There are two reasonable
// answers — one keeps an Integer, one stores an AtomicInteger. Say when
// you would want the second.
// TODO 3: fix 2 with a single atomic call, and say what its return value
// tells you that your current code throws away.
// TODO 4: 3 is subtler. computeIfAbsent already creates exactly one list.
// Find what is still unguarded, then fix it. Two correct answers.
// TODO 5: fix 4. The new value depends on the old one, which rules out
// most of the atomic methods. Two fit — pick one and justify it.
// TODO 6: 5 cannot be fixed with any ConcurrentHashMap method. Say why in
// terms of what kind of invariant it is, then write the version that does
// work and name what it costs.
// TODO 7: one rule about the function you pass to computeIfAbsent, merge
// or compute. State it, and say what happens if you break it.
public static void main(String[] args) throws Exception {
// TODO 8: write a hammer test for methods 1 and 2 that fails before
// your fix and passes after. Eight threads is enough.
System.out.println("write the tests, then delete this line");
}
}Run it locally:
cd exercises/java/concurrency/concurrenthashmap-internals/02-challenge
javac Starter.java -d /tmp/out && java -cp /tmp/out StarterHints
Hint 1
For each method, write the sequence of map calls it makes. More than one call is a race unless something else guards it.
Hint 2
Which of these is 'the new value depends on the old' and which is 'only if absent'? They have different answers.
Hint 3
One of the five is not fixable with any ConcurrentHashMap method. Work out what makes it different from the other four.
Hint 4
In the grouping case, ask what happens after computeIfAbsent returns.
Done when
- Four of the five are fixed with a single atomic call each
- The fifth is identified as a whole-map invariant, with the reason
- The grouping bug is fixed at the value, not just at the map
- A comment notes what must never happen inside a mapping function