Challenge

Make five compound operations atomic

25 minintermediate212 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 Starter

Hints

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

  2. Hint 2

    Which of these is 'the new value depends on the old' and which is 'only if absent'? They have different answers.

  3. Hint 3

    One of the five is not fixable with any ConcurrentHashMap method. Work out what makes it different from the other four.

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

← Back to How does ConcurrentHashMap achieve thread safety?