In module 5 you built the heart of BiblioTech with ArrayList, HashMap, HashSet and ArrayDeque. They were the right tools —for a single thread—. In 08-04 you protected the catalogue with locks and it worked, but every read pays for a lock() even though reading gets in nobody's way, and in 08-05 you already used AtomicInteger without it ever having been explained.

This lesson closes both cracks. It starts where it hurts most: a demonstration that a HashMap shared between two threads does not merely give odd results, it can be structurally corrupted and leave a core at 100% in an infinite loop it never exits. From there it walks through the three generations of solution —synchronized collections, concurrent collections and atomic variables— with the question that unites them: how do you make a compound operation atomic without blocking everybody?

Along the way it settles the module 5 debt: the BlockingQueue mentioned there and deferred to here, and the complete implementation of the producer-consumer pattern that was described conceptually and left unwritten.

By the end, BiblioTech's catalogue will use ConcurrentHashMap, its statistics will be atomic counters with not a single lock, and its reservation queue will be a real producer-consumer with a clean poison-pill shutdown.

Contents

  1. A HashMap corrupted by two threads
  2. ConcurrentModificationException revisited
  3. First generation: synchronized collections
  4. The double trap of synchronized collections
  5. ConcurrentHashMap: how it works inside
  6. The atomic compound operations
  7. Weakly consistent iterators and the approximate size()
  8. CopyOnWriteArrayList and CopyOnWriteArraySet
  9. ConcurrentLinkedQueue and the concurrent queues
  10. BlockingQueue: the complete family
  11. The producer-consumer pattern
  12. The poison pill
  13. ConcurrentSkipListMap in a note
  14. Atomic variables and compare-and-swap
  15. The operations of the atomic classes
  16. AtomicReference and the ABA problem
  17. LongAdder under contention
  18. BiblioTech: a concurrent catalogue and atomic statistics
  19. Final decision table
  20. Common Mistakes and Tips
  21. Exercises

  1. A HashMap corrupted by two threads

HashMap is not thread-safe. The sentence is repeated everywhere; what almost never gets explained is what it means exactly, and the meaning is worse than it sounds.

Remember the internal structure from 05-05: an array of buckets, and in each bucket a linked list (or a tree, if it grows a lot) with the colliding entries. When the map exceeds the load factor, a rehash happens: a bigger array is created and all the entries are redistributed.

If two threads put during a rehash, the linked lists can end up forming a cycle. And a list with a cycle makes a subsequent get() never terminate.

import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;

public class CorruptedHashMap {

    public static void main(String[] args) throws InterruptedException {

        for (int attempt = 1; attempt <= 5; attempt++) {

            Map<Integer, String> map = new HashMap<>();
            final int PER_THREAD = 100_000;

            Thread t1 = new Thread(() -> {
                for (int i = 0; i < PER_THREAD; i++) map.put(i, "A" + i);
            }, "writer-1");

            Thread t2 = new Thread(() -> {
                for (int i = PER_THREAD; i < PER_THREAD * 2; i++) map.put(i, "B" + i);
            }, "writer-2");

            t1.start();
            t2.start();

            // Deadline: if the HashMap gets corrupted, a thread can end up
            // in an infinite loop inside put() or get().
            t1.join(5000);
            t2.join(5000);

            int expected = PER_THREAD * 2;
            if (t1.isAlive() || t2.isAlive()) {
                System.out.printf("Attempt %d: INFINITE LOOP. "
                        + "t1=%s t2=%s  <-- corrupted structure%n",
                        attempt, t1.getState(), t2.getState());
                System.out.println("  (look at the CPU usage: one core at 100%)");
                System.exit(1);
            }
            System.out.printf("Attempt %d: expected %d, actual %d, lost %d%n",
                    attempt, expected, map.size(), expected - map.size());
        }
    }
}

Typical output:

Attempt 1: expected 200000, actual 187341, lost 12659
Attempt 2: expected 200000, actual 193028, lost 6972
Attempt 3: INFINITE LOOP. t1=RUNNABLE t2=RUNNABLE  <-- corrupted structure
  (look at the CPU usage: one core at 100%)

The two failure modes, from less to more severe:

  1. Lost entries. Two simultaneous puts on the same bucket trample each other's work. The map ends up with fewer entries than were inserted. It is the race condition of 08-04 applied to a data structure.
  2. Infinite loop. During the rehash, two threads can leave a bucket's linked list pointing at itself. A get() landing in that bucket walks the cycle forever, burning a core at 100% and throwing no exception.

The second case is a famous industry anecdote: for years it was a recurring cause of servers sitting at 100% CPU for no apparent reason, and the diagnosis —a thread dump, section 12 of 08-03, with several threads RUNNABLE inside HashMap.get— is a story anybody who has been in production a while can tell.

Technical note. In Java 8+ the rehash implementation changed and the cycle is far harder to provoke than in Java 7, but the class is still not thread-safe and lost entries reproduce without difficulty. It is not a solved problem: it is a less visible one.

The conclusion: sharing a HashMap between threads without protection does not give "approximate results". It gives broken structures.

  1. ConcurrentModificationException revisited

In 05-02 you saw the fail-fast behaviour: the iterators of the classic collections keep a modCount and check on every next() that nobody has modified the collection behind their back.

List<String> list = new ArrayList<>(List.of("a", "b", "c"));
for (String s : list) {
    list.remove(s);       // ConcurrentModificationException
}

There it was a single thread. Now the concurrent version appears, and it is more treacherous:

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.TimeUnit;

public class ConcurrentFailFast {

    public static void main(String[] args) throws InterruptedException {

        List<String> catalog = new ArrayList<>();
        for (int i = 0; i < 10_000; i++) catalog.add("978-" + i);

        Thread reader = new Thread(() -> {
            try {
                while (!Thread.currentThread().isInterrupted()) {
                    int n = 0;
                    for (String isbn : catalog) {     // iteration
                        n += isbn.length();
                    }
                }
            } catch (java.util.ConcurrentModificationException e) {
                System.out.println("[reader] ConcurrentModificationException: "
                        + "another thread modified the catalogue while iterating");
            }
        }, "bibliotech-reader");

        Thread writer = new Thread(() -> {
            try {
                while (!Thread.currentThread().isInterrupted()) {
                    catalog.add("978-new");
                    TimeUnit.MILLISECONDS.sleep(1);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "bibliotech-writer");

        reader.start();
        writer.start();

        TimeUnit.SECONDS.sleep(2);
        reader.interrupt();
        writer.interrupt();
    }
}

Output (almost immediate):

[reader] ConcurrentModificationException: another thread modified the catalogue while iterating

What matters about this example is how that exception should be read. It is not "a concurrency error you have to catch". It is a bug detector the collection offers as a courtesy: it warns you that you are using the collection unsafely. Catching the exception and retrying is papering over the symptom; the solution is to change the collection or protect the access.

And there is a nuance that gets forgotten: fail-fast is not guaranteed. The documentation is explicit: modCount is not volatile, so an iterator may not see the modification and may return inconsistent data instead of throwing the exception. You can have a silent race.

  1. First generation: synchronized collections

Java 1.2 introduced the synchronized wrappers in Collections:

import java.util.*;

Map<String, Material> map = Collections.synchronizedMap(new HashMap<>());
List<Material> list       = Collections.synchronizedList(new ArrayList<>());
Set<String> set           = Collections.synchronizedSet(new HashSet<>());

Every method of the wrapper is a synchronized on the wrapper itself:

// Roughly, what Collections.synchronizedMap does:
public V get(Object key) {
    synchronized (mutex) { return m.get(key); }
}
public V put(K key, V value) {
    synchronized (mutex) { return m.put(key, value); }
}

It solves the corruption of section 1 —the puts no longer trample each other— but it has one obvious problem and two traps.

The obvious problem: a single global lock. Every operation, reads included, is serialised. With sixteen threads reading a map, fifteen are waiting. It is a precise bottleneck.

Hashtable and Vector are the old version of the same thing, from Java 1.0, with all their methods synchronized. They also have the 08-04 defect: they synchronise on this, so their lock is exposed. Do not use them in new code.

  1. The double trap of synchronized collections

Trap 1: iterating still requires manual synchronisation.

Each individual method is synchronized, but an iteration is many calls. Between hasNext() and next() there is no lock, so another thread can modify the collection and provoke the ConcurrentModificationException of section 2.

List<Material> list = Collections.synchronizedList(new ArrayList<>());

// WRONG: every iterator call is synchronized, but the
// WHOLE ITERATION is not. ConcurrentModificationException guaranteed.
for (Material m : list) {
    process(m);
}

// RIGHT: synchronise the entire iteration on the wrapper itself.
// It is what the javadoc of Collections.synchronizedList explicitly
// requires, and hardly anybody reads that part.
synchronized (list) {
    for (Material m : list) {
        process(m);       // CAREFUL: 'process' runs WITH THE LOCK HELD
    }                     // (rule 3 of 08-04: no foreign code here)
}

Note the cost of the correct version: for the whole iteration, nobody else can touch the list. With ten thousand elements and a millisecond of processing each, that is ten seconds of total blockage.

Trap 2: compound operations are still not atomic.

This is the worst one, because the code looks safe.

Map<String, Integer> loansByIsbn = Collections.synchronizedMap(new HashMap<>());

// WRONG: check-then-act. Each call is synchronized,
// but the GAP between them is not.
if (!loansByIsbn.containsKey(isbn)) {           // (1) thread A: not there
    loansByIsbn.put(isbn, 1);                   // (3) thread A writes 1
}                                               // (2) thread B also saw "not there"
                                                // (4) thread B writes 1 -> one is lost

// WRONG: read-modify-write, the same problem as 'counter++' (08-04)
Integer n = loansByIsbn.get(isbn);              // (1) reads 5
loansByIsbn.put(isbn, n + 1);                   // (3) writes 6
                                                // another thread also read 5 and
                                                // also writes 6: one loan lost

They are the two canonical forms of race condition from 08-04, and the synchronized collection does nothing to prevent them. The only way to fix it with this generation is an external lock:

// Correct but clumsy: an external lock over the collection.
synchronized (loansByIsbn) {
    Integer n = loansByIsbn.get(isbn);
    loansByIsbn.put(isbn, n == null ? 1 : n + 1);
}

It works, but you have had to leave the abstraction: the "safe" collection was not safe for what you needed, and now safety depends on every piece of code in the application remembering to take that lock. With that, we reach the second generation.

  1. ConcurrentHashMap: how it works inside

ConcurrentHashMap (Java 5, rewritten in Java 8) solves all three things at once: it does not get corrupted, it does not serialise reads, and it offers atomic compound operations.

Its two central ideas:

Idea 1: reads never block. The internal nodes have their value and next fields declared volatile. Thanks to the visibility guarantees of 08-04, a get() can read without acquiring any lock and still see a consistent, recent value. Zero contention between readers, and between readers and writers.

Idea 2: writes lock only the affected bucket. Instead of a global lock, synchronisation happens on the first node of the bucket. Two writes on different buckets —which is the normal case, because the hash spreads them out— do not get in each other's way at all.

flowchart TB
    subgraph SM["Collections.synchronizedMap"]
        direction TB
        C1["ONE global lock"] --> T1["bucket 0"]
        C1 --> T2["bucket 1"]
        C1 --> T3["bucket 2"]
        C1 --> T4["bucket 3"]
        N1["Every operation,<br/>reads included,<br/>is serialised"]
    end
    subgraph CHM["ConcurrentHashMap"]
        direction TB
        L["Reads: NO lock<br/>volatile fields"]
        B0["bucket 0<br/>own lock"]
        B1["bucket 1<br/>own lock"]
        B2["bucket 2<br/>own lock"]
        B3["bucket 3<br/>own lock"]
        N2["Writes on different<br/>buckets: in parallel"]
    end

A demonstration of the performance difference:

import java.util.*;
import java.util.concurrent.*;

public class MapComparison {

    static long measure(Map<Integer, String> map, int threads, int opsPerThread,
                        double readRatio) throws InterruptedException {

        // Preload so the reads hit.
        for (int i = 0; i < 10_000; i++) map.put(i, "value-" + i);

        CountDownLatch startGate = new CountDownLatch(1);
        CountDownLatch finishLine = new CountDownLatch(threads);

        for (int h = 0; h < threads; h++) {
            new Thread(() -> {
                try {
                    startGate.await();
                    ThreadLocalRandom random = ThreadLocalRandom.current();
                    for (int i = 0; i < opsPerThread; i++) {
                        int key = random.nextInt(10_000);
                        if (random.nextDouble() < readRatio) {
                            map.get(key);
                        } else {
                            map.put(key, "new-" + i);
                        }
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    finishLine.countDown();
                }
            }, "access-" + h).start();
        }

        long start = System.nanoTime();
        startGate.countDown();       // they all start at once (08-05)
        finishLine.await();
        return (System.nanoTime() - start) / 1_000_000;
    }

    public static void main(String[] args) throws InterruptedException {

        final int THREADS = 16;
        final int OPS = 200_000;

        System.out.printf("%d threads x %d operations%n%n", THREADS, OPS);
        System.out.printf("%-28s | %10s | %10s%n", "Implementation", "90% reads", "50% reads");
        System.out.println("-----------------------------|------------|------------");

        long s90 = measure(Collections.synchronizedMap(new HashMap<>()), THREADS, OPS, 0.9);
        long s50 = measure(Collections.synchronizedMap(new HashMap<>()), THREADS, OPS, 0.5);
        System.out.printf("%-28s | %8d ms | %8d ms%n", "synchronizedMap", s90, s50);

        long c90 = measure(new ConcurrentHashMap<>(), THREADS, OPS, 0.9);
        long c50 = measure(new ConcurrentHashMap<>(), THREADS, OPS, 0.5);
        System.out.printf("%-28s | %8d ms | %8d ms%n", "ConcurrentHashMap", c90, c50);

        System.out.printf("%nImprovement: %.1fx (90%% reads), %.1fx (50%% reads)%n",
                (double) s90 / c90, (double) s50 / c50);
    }
}

Indicative output:

16 threads x 200000 operations

Implementation               |  90% reads |  50% reads
-----------------------------|------------|------------
synchronizedMap              |     3187 ms |     3402 ms
ConcurrentHashMap            |      184 ms |      271 ms

Improvement: 17.3x (90% reads), 12.6x (50% reads)

An order of magnitude of difference, and it grows with the number of threads. With a single thread, on the other hand, the two are nearly identical: the gain is in scalability, not raw speed.

  1. The atomic compound operations

This is ConcurrentHashMap's most valuable contribution, and the one that solves trap 2 of section 4: compound operations that are genuinely atomic.

Method What it does atomically
putIfAbsent(k, v) Inserts if the key is absent; returns the previous value or null
computeIfAbsent(k, f) If absent, computes the value with f and inserts it
computeIfPresent(k, f) If present, recomputes the value with f
compute(k, f) Always recomputes; a null result removes the entry
merge(k, v, f) If absent it stores v; if present, combines the current one with v using f
remove(k, v) Removes only if the current value is v
replace(k, old, new) Replaces only if the current value is old
getOrDefault(k, d) Returns the value or d if absent (does not modify)

The same cases from section 4, now correct:

import java.util.concurrent.ConcurrentHashMap;
import java.util.Map;

public class AtomicMapOperations {

    private final Map<String, Integer> loansByIsbn = new ConcurrentHashMap<>();
    private final Map<String, Card> cardCache = new ConcurrentHashMap<>();

    /** Check-then-act, solved: a single atomic operation. */
    public boolean registerFirstLoan(String isbn) {
        // Returns null if it did NOT exist (and inserts it), or the previous value.
        return loansByIsbn.putIfAbsent(isbn, 1) == null;
    }

    /** Read-modify-write, solved with merge. */
    public void countLoan(String isbn) {
        // If absent -> stores 1. If present -> applies Integer::sum
        // between the current value and the 1 we pass. All atomic.
        loansByIsbn.merge(isbn, 1, Integer::sum);
    }

    /** The same with compute, more explicit. */
    public void countLoanAlternative(String isbn) {
        loansByIsbn.compute(isbn, (key, current) ->
                current == null ? 1 : current + 1);
    }

    /**
     * A lazy cache, solved with computeIfAbsent.
     * It replaces the three phases of exercise 3 of 08-04:
     * the function runs AT MOST ONCE per key, even if
     * ten threads ask for it at once. The other nine wait and
     * receive the same object: there is no duplicated computation.
     */
    public Card getCard(String isbn) {
        return cardCache.computeIfAbsent(isbn, this::buildCard);
    }

    /** Remove only if the value is the expected one: compare-and-remove. */
    public boolean returnIfLast(String isbn) {
        return loansByIsbn.remove(isbn, 1);
    }

    /** Download counter with no NullPointerException. */
    public int downloadsOf(String isbn) {
        return loansByIsbn.getOrDefault(isbn, 0);
    }

    private Card buildCard(String isbn) { /* expensive query */ return null; }
}

The critical warning about computeIfAbsent and compute: the function you pass runs with the bucket's lock held. From that follow three absolute prohibitions:

// FORBIDDEN 1: modifying the SAME map inside the function.
// It can cause a deadlock or corrupt the structure.
map.computeIfAbsent(k, key -> {
    map.put("another", "thing");     // NEVER
    return compute(key);
});

// FORBIDDEN 2: long or blocking operations.
// It locks the whole bucket, and the whole application suffers
// (rule 2 of 08-04: no I/O inside the lock).
map.computeIfAbsent(k, key -> readFromDisk(key));       // WRONG if it is slow

// FORBIDDEN 3: calling foreign code (listeners, callbacks).
// Rule 3 of 08-04, applied here.

// CORRECT when the computation is expensive: compute outside and publish with putIfAbsent.
Card computed = readFromDisk(isbn);          // with no lock at all
Card existing = map.putIfAbsent(isbn, computed);
Card result = (existing != null) ? existing : computed;
// There may be duplicated computation if two threads coincide, but
// the result is correct and the bucket is not locked.

  1. Weakly consistent iterators and the approximate size()

The concurrent collections change two contracts compared to the classic ones, and you have to know them.

Weakly consistent iterators. ConcurrentHashMap's iterators do not throw ConcurrentModificationException. They walk the state of the map at the moment the iterator was created, and they may or may not reflect later modifications. They do not guarantee seeing the changes, but they do guarantee not breaking.

Fail-fast (HashMap) Weakly consistent (ConcurrentHashMap)
Modification during iteration ConcurrentModificationException No exception
Sees later changes Maybe, maybe not
Walks every element Yes, or fails Yes, every element present at the start
Blocks the writers Only if you synchronise by hand Never
Thread-safe No Yes
Map<String, Material> catalog = new ConcurrentHashMap<>();

// SAFE: it throws no exception, blocks nobody, and no writer
// sits waiting for you to finish walking 100,000 entries.
for (Map.Entry<String, Material> e : catalog.entrySet()) {
    process(e.getValue());
}
// If another thread adds an entry while you iterate, you may see it
// and you may not. What will NOT happen is the loop failing.

size() is approximate. In ConcurrentHashMap, size(), isEmpty() and containsValue() return a value that was correct at some recent instant, but that may have changed before you use it.

// WRONG: check-then-act on an approximate size.
if (catalog.size() < LIMIT) {
    catalog.put(isbn, material);     // the size may have changed in between
}

// The size of a concurrent structure is a STATISTICAL figure,
// useful for logs and metrics, not for control decisions.
LOG.log(Level.INFO, "Catalogue with ~{0} materials", catalog.size());

It is an unavoidable consequence of the design: keeping an exact count would require a global synchronisation point, and that is precisely what was removed to gain scalability.

  1. CopyOnWriteArrayList and CopyOnWriteArraySet

A radically different strategy: every modification copies the whole array.

  • Reads: with no lock at all, over an immutable array. Extremely fast.
  • Writes: under a lock, copying the entire array. O(n) cost per write.
package com.nexussoftware.bibliotech.service;

import java.util.List;
import java.util.concurrent.CopyOnWriteArrayList;

/**
 * Registry of catalogue event listeners.
 *
 * A PERFECT use case for CopyOnWriteArrayList:
 *  - ~10 listeners register at start-up and almost never change.
 *  - They are walked on EVERY catalogue operation: thousands of times a minute.
 *  - Read/write ratio: 100,000 to 1.
 */
public class ListenerRegistry {

    private final List<CatalogListener> listeners = new CopyOnWriteArrayList<>();

    public void register(CatalogListener l)   { listeners.add(l); }
    public void unregister(CatalogListener l) { listeners.remove(l); }

    /**
     * Notifies every listener.
     *
     * TWO decisive advantages over a synchronized list:
     *  1. There is NO lock during the walk: listeners can
     *     register or unregister listeners without deadlocking.
     *  2. It is rule 3 of 08-04 —never call foreign code holding a
     *     lock— honoured automatically by the collection.
     */
    public void notifyAdded(Material m) {
        for (CatalogListener l : listeners) {
            try {
                l.materialAdded(m);
            } catch (RuntimeException e) {
                // A faulty listener must not stop the others
                // being told (degradation policy of 06-07).
                LOG.log(Level.WARNING, "Listener failed: " + l, e);
            }
        }
    }
}

The elegant detail: CopyOnWriteArrayList's iterator works over a snapshot of the array taken when it was created. It is completely immune to modifications, throws no exceptions and blocks nobody. In exchange, it does not support remove() (it throws UnsupportedOperationException) because modifying a snapshot would make no sense.

When it pays off and when it does not:

Situation Use copy-on-write?
Event listeners Yes, the canonical case
A configuration list read constantly Yes
A small set of business rules Yes
A list with thousands of elements and frequent writes No: every write copies thousands of references
A work queue No: use a BlockingQueue
An accumulator growing in a loop No: O(n²) in total
// DISASTER: 10,000 writes on a growing list.
// Every add() copies the whole array: 1 + 2 + ... + 10,000 ≈ 50 million
// reference copies. Seconds where it should be milliseconds.
List<Material> list = new CopyOnWriteArrayList<>();
for (int i = 0; i < 10_000; i++) {
    list.add(materials.get(i));        // O(n) each -> O(n²) in total
}

  1. ConcurrentLinkedQueue and the concurrent queues

ConcurrentLinkedQueue is a non-blocking, unbounded FIFO queue implemented with the Michael-Scott algorithm based on compare-and-swap (section 14): with no locks at all.

import java.util.Queue;
import java.util.concurrent.ConcurrentLinkedQueue;

Queue<Reservation> pending = new ConcurrentLinkedQueue<>();

pending.offer(reservation);        // add: never blocks, never fails
Reservation r = pending.poll();    // take: returns NULL if it is empty

The key difference from a BlockingQueue: poll() returns null if the queue is empty, instead of waiting. That forces the consumer to poll:

// ANTIPATTERN with ConcurrentLinkedQueue: polling (08-03, section 6).
while (!Thread.currentThread().isInterrupted()) {
    Reservation r = pending.poll();
    if (r == null) {
        Thread.sleep(100);       // wakes 10 times a second for nothing
        continue;
    }
    serve(r);
}

That loop burns CPU when there is no work and adds up to 100 ms of latency when there is. Almost always what you want is a BlockingQueue.

Use ConcurrentLinkedQueue when you do not need to wait: accumulating events another thread will drain periodically, or a work bag consulted opportunistically.

  1. BlockingQueue: the complete family

Here the module 5 debt is settled. A BlockingQueue is a queue whose operations wait when they cannot be completed: take() waits if it is empty, put() waits if it is full.

The four groups of operations, which you need to know because the choice matters:

Throws an exception Returns a special value Blocks With a deadline
Insert add(e) offer(e)false put(e) offer(e, t, u)
Remove remove() poll()null take() poll(t, u)
Examine element() peek()null

The implementations:

Implementation Capacity Structure When to use it
ArrayBlockingQueue Bounded (fixed) Circular array The default choice: the bound gives backpressure
LinkedBlockingQueue Optionally bounded Linked list More throughput with many producers and consumers
SynchronousQueue 0 No storage Direct hand-off: every put waits for a take
PriorityBlockingQueue Unbounded Heap When priority, not arrival, sets the order
DelayQueue Unbounded Heap by time Elements that cannot be taken until a certain instant
LinkedTransferQueue Unbounded Linked list transfer(): wait for the consumer to receive it

Each one in context:

import java.util.concurrent.*;

// 1. ARRAYBLOCKINGQUEUE: bounded. If it fills up, the producer WAITS.
//    That is BACKPRESSURE: the producer slows to the consumer's pace.
//    It is what avoids the OutOfMemoryError of unbounded queues (08-05).
BlockingQueue<Reservation> reservations = new ArrayBlockingQueue<>(100);

// 2. Bounded LINKEDBLOCKINGQUEUE: two internal locks (one for the
//    head and one for the tail), so a producer and a consumer
//    can work at the same time. Better with high concurrency.
BlockingQueue<Notice> notices = new LinkedBlockingQueue<>(500);

// 3. SYNCHRONOUSQUEUE: capacity ZERO. Every put() waits for a take().
//    It is a hand-to-hand transfer, with no store. It is the queue
//    newCachedThreadPool uses (08-05), hence its unlimited threads.
BlockingQueue<Task> handoff = new SynchronousQueue<>();

// 4. PRIORITYBLOCKINGQUEUE: the "smallest" according to the
//    Comparator (05-09) comes out first. The most overdue notices first.
BlockingQueue<Loan> byUrgency = new PriorityBlockingQueue<>(
        100, Comparator.comparingLong(Loan::daysLate).reversed());

// 5. DELAYQUEUE: the elements implement Delayed and cannot be
//    taken until their delay expires. Retries with a wait.
DelayQueue<NoticeRetry> retries = new DelayQueue<>();

The bounded ArrayBlockingQueue deserves a paragraph, because it solves a real problem from 08-05. With an unbounded queue, a fast producer piles up tasks until memory runs out. With a bounded one, when the queue fills the producer blocks in put() and stops producing until there is room. The system regulates itself without discarding anything and without growing without limit. It is the same effect CallerRunsPolicy achieved in a pool.

  1. The producer-consumer pattern

Promised in module 5, described conceptually and deferred to here. Now, complete.

The idea: some threads (producers) generate work and put it in a queue; others (consumers) take it out and process it. The queue decouples both: they need not know each other, nor go at the same pace, nor coordinate.

sequenceDiagram
    participant P1 as producer-1
    participant P2 as producer-2
    participant Q as BlockingQueue(10)
    participant C1 as consumer-1
    participant C2 as consumer-2

    P1->>Q: put(reservation A)
    P2->>Q: put(reservation B)
    C1->>Q: take() -> A
    Note over C1: processes A
    C2->>Q: take() -> B
    Note over C2: processes B
    C1->>Q: take()
    Note over C1,Q: empty queue: C1 BLOCKED<br/>consuming no CPU
    P1->>Q: put(reservation C)
    Q-->>C1: wakes up with C
    Note over P1,Q: if the queue fills up,<br/>the producers block<br/>in put(): BACKPRESSURE

A complete implementation for BiblioTech:

package com.nexussoftware.bibliotech.service;

import com.nexussoftware.bibliotech.domain.Reservation;

import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Reservation processor with the producer-consumer pattern.
 *
 * The employees produce reservations from the menu (several threads);
 * a group of consumers serves them (check availability,
 * notify, register).
 *
 * The BOUNDED queue gives backpressure: if reservations arrive faster
 * than they are served, the menu slows down instead of piling
 * reservations up in memory until it runs out.
 */
public class ReservationProcessor implements AutoCloseable {

    private static final Logger LOG = Logger.getLogger(ReservationProcessor.class.getName());

    private static final int CAPACITY = 50;
    private static final int CONSUMERS = 4;

    private final BlockingQueue<Reservation> queue = new ArrayBlockingQueue<>(CAPACITY);
    private final ExecutorService consumers;

    private final AtomicInteger served = new AtomicInteger();
    private final AtomicInteger rejected = new AtomicInteger();
    private volatile boolean acceptingNew = true;

    public ReservationProcessor() {
        AtomicInteger n = new AtomicInteger(1);
        this.consumers = Executors.newFixedThreadPool(CONSUMERS,
                r -> new Thread(r, "bibliotech-reservations-" + n.getAndIncrement()));

        for (int i = 0; i < CONSUMERS; i++) {
            consumers.execute(this::consumerLoop);
        }
    }

    // ---------- PRODUCER ----------

    /**
     * Enqueues a reservation. BLOCKS if the queue is full: that is
     * backpressure, and it is a feature, not a defect.
     */
    public void enqueue(Reservation r) throws InterruptedException {
        if (!acceptingNew) {
            throw new IllegalStateException("The processor is shutting down");
        }
        queue.put(r);     // waits if it is full
    }

    /**
     * A variant that does not wait indefinitely: if there is no room
     * within 2 seconds, it rejects. That is right for a user interface:
     * better to say "the system is saturated" than to leave the menu hanging.
     */
    public boolean enqueueWithDeadline(Reservation r) throws InterruptedException {
        boolean accepted = queue.offer(r, 2, TimeUnit.SECONDS);
        if (!accepted) {
            rejected.incrementAndGet();
            LOG.log(Level.WARNING, "Reservation rejected through saturation: {0}", r.id());
        }
        return accepted;
    }

    // ---------- CONSUMER ----------

    private void consumerLoop() {
        String me = Thread.currentThread().getName();
        LOG.log(Level.INFO, "[{0}] consumer ready", me);
        try {
            while (true) {
                // take() BLOCKS without consuming CPU if the queue is empty.
                // It is the correct alternative to polling with sleep (08-03).
                Reservation r = queue.take();

                // POISON PILL: the shutdown signal (section 12).
                if (r == Reservation.END) {
                    LOG.log(Level.INFO, "[{0}] pill received, finishing", me);
                    return;
                }

                try {
                    serve(r);
                    served.incrementAndGet();
                } catch (Exception e) {
                    // An individual failure must NOT kill the consumer:
                    // if it dies, the pool silently loses capacity.
                    LOG.log(Level.WARNING, "[" + me + "] reservation failed: " + r.id(), e);
                }
            }
        } catch (InterruptedException e) {
            LOG.log(Level.INFO, "[{0}] consumer interrupted", me);
            Thread.currentThread().interrupt();      // restore (08-02)
        }
    }

    private void serve(Reservation r) throws InterruptedException {
        TimeUnit.MILLISECONDS.sleep(120);            // simulated I/O
        if (r.id().hashCode() % 23 == 0) {
            throw new IllegalStateException("material already on loan");
        }
    }

    // ---------- SHUTDOWN ----------

    /**
     * ORDERLY shutdown: stop accepting, insert one pill per
     * consumer and wait. Everything already queued gets processed.
     */
    @Override
    public void close() {
        acceptingNew = false;
        try {
            for (int i = 0; i < CONSUMERS; i++) {
                queue.put(Reservation.END);  // one per consumer
            }
            consumers.shutdown();
            if (!consumers.awaitTermination(30, TimeUnit.SECONDS)) {
                consumers.shutdownNow();
            }
        } catch (InterruptedException e) {
            consumers.shutdownNow();
            Thread.currentThread().interrupt();
        }
        LOG.log(Level.INFO, "Processor closed: {0} served, {1} rejected",
                new Object[] { served.get(), rejected.get() });
    }

    public int served()   { return served.get(); }
    public int rejected() { return rejected.get(); }
    public int pending()  { return queue.size(); }
}

Usage:

public class ReservationDemo {

    public static void main(String[] args) throws InterruptedException {

        try (ReservationProcessor processor = new ReservationProcessor()) {

            // Three producers: three employees using the menu at once.
            Thread[] producers = new Thread[3];
            for (int p = 0; p < 3; p++) {
                final String employee = switch (p) {
                    case 0 -> "Marta Ruiz";
                    case 1 -> "Diego Alonso";
                    default -> "Nuria Vidal";
                };
                producers[p] = new Thread(() -> {
                    try {
                        for (int i = 1; i <= 40; i++) {
                            processor.enqueue(new Reservation(
                                    employee + "-R" + i, "978-000000000" + (i % 3 + 1)));
                            TimeUnit.MILLISECONDS.sleep(20);
                        }
                    } catch (InterruptedException e) {
                        Thread.currentThread().interrupt();
                    }
                }, "menu-" + employee.split(" ")[0]);
                producers[p].start();
            }

            // Progress while they work.
            for (int t = 0; t < 10; t++) {
                System.out.printf("  served=%d  pending=%d%n",
                        processor.served(), processor.pending());
                TimeUnit.MILLISECONDS.sleep(400);
            }

            for (Thread p : producers) p.join();
            System.out.println("All the producers have finished");

        }   // close(): poison pills and wait for the drain
    }
}

Output:

  served=0  pending=3
  served=12  pending=14
  served=25  pending=26
  served=38  pending=39
  served=51  pending=50   <-- queue FULL: the producers slow down
  served=64  pending=50
  served=77  pending=43
  served=90  pending=30
  served=103  pending=17
  served=113  pending=7
All the producers have finished
INFO: Processor closed: 120 served, 0 rejected

The key line is where pending sticks at 50. The queue reached its capacity and the producers started blocking in put(): they stopped producing at the rate they wanted and switched to producing at the rate the system could absorb. That is backpressure, and it is the property that stops a system collapsing under load. With an unbounded queue, pending would have kept growing until memory ran out.

  1. The poison pill

How do you tell a consumer blocked in take() that there will be no more work? There are two ways, and one is better.

Option A: interrupt. It works —take() throws InterruptedException— but it is blunt: if the consumer was halfway through processing an element, that work is lost.

Option B: the poison pill. A sentinel element meaning "that's it" is inserted into the queue. The consumer recognises it and finishes in an orderly way, after processing everything ahead of it.

package com.nexussoftware.bibliotech.domain;

public record Reservation(String id, String isbn) {

    /**
     * POISON PILL: a sentinel instance meaning
     * "there will be no more work, finish".
     *
     * It is compared with == (identity), not with equals: it is a
     * unique, unrepeatable object, and no real data can match it.
     */
    public static final Reservation END = new Reservation("__END__", "__END__");
}

The three rules of the poison pill:

1. One pill per consumer. Each consumer takes one and finishes; if you insert a single one with four consumers, three wait forever.

for (int i = 0; i < CONSUMERS; i++) {
    queue.put(Reservation.END);
}

2. The pill goes at the end. Since the queue is FIFO, everything inserted before is processed first. The shutdown is orderly by construction: not one element is lost.

3. Compare with ==, not with equals. The pill is a unique instance; comparing by identity is faster and cannot be confused with real data that happens to be equal.

Beware of the "re-inject the pill" variant, which you sometimes see:

// Alternative: a single pill each consumer re-injects.
if (r == Reservation.END) {
    queue.put(Reservation.END);   // pass it to the next one
    return;
}

It is ingenious and dangerous with a bounded queue: if the queue is full, that put() blocks and the consumer never finishes. With offer() instead you could lose the pill. One pill per consumer is simpler and always correct.

Comparison:

Poison pill Interruption
Work pending in the queue Gets processed Lost
Work in progress Finishes Interrupted
Requires a sentinel value Yes No
Works if a consumer is stuck No Yes
When to use it Orderly shutdown Urgent shutdown

In practice both are used: pill first, and shutdownNow() as plan B if they do not finish in time. It is the two-phase pattern of 08-05, applied here.

  1. ConcurrentSkipListMap in a note

ConcurrentSkipListMap and ConcurrentSkipListSet are the concurrent, sorted versions of TreeMap and TreeSet. They implement NavigableMap, so they offer firstKey, headMap, tailMap, ceilingKey and company, and they are thread-safe with no locks.

import java.util.concurrent.ConcurrentSkipListMap;
import java.util.NavigableMap;

// Loans sorted by due date (in milliseconds).
NavigableMap<Long, Loan> byDueDate = new ConcurrentSkipListMap<>();

byDueDate.put(dueMs, loan);

// All those overdue before now, in order, blocking nobody.
NavigableMap<Long, Loan> overdue =
        byDueDate.headMap(System.currentTimeMillis(), true);

They are implemented with skip lists, a probabilistic structure giving O(log n) with no need to rebalance like a tree —which would be very expensive to do concurrently—. Use them when you need ordering and concurrency; if you only need concurrency, ConcurrentHashMap is faster.

  1. Atomic variables and compare-and-swap

The second half of the lesson. The classes in java.util.concurrent.atomic solve the counter problem of 08-01 without locks.

import java.util.concurrent.atomic.AtomicInteger;

public class AtomicCounter {

    private final AtomicInteger value = new AtomicInteger(0);

    public void increment() {
        value.incrementAndGet();     // ATOMIC, lock-free
    }

    public int value() { return value.get(); }

    public static void main(String[] args) throws InterruptedException {
        final int ROUNDS = 1_000_000;
        AtomicCounter c = new AtomicCounter();

        Thread t1 = new Thread(() -> { for (int i = 0; i < ROUNDS; i++) c.increment(); });
        Thread t2 = new Thread(() -> { for (int i = 0; i < ROUNDS; i++) c.increment(); });
        t1.start(); t2.start(); t1.join(); t2.join();

        System.out.println("Expected: " + ROUNDS * 2 + ", actual: " + c.value());
    }
}
Expected: 2000000, actual: 2000000

Exact, every time. How, with no lock?

The compare-and-swap instruction

Modern processors offer an atomic instruction called CAS (compare-and-swap, or CMPXCHG on x86). Its semantics, executed indivisibly by the hardware:

"Look at this memory location. If it contains the expected value, replace it with new and tell me yes. If not, touch nothing and tell me no."

It is the operation that underpins all lock-free concurrency.

AtomicInteger a = new AtomicInteger(10);

// "If it is 10, set it to 11". Returns true if it did.
boolean success = a.compareAndSet(10, 11);

The retry loop underneath

incrementAndGet() is not magic: it is a CAS loop. Its implementation, conceptually:

/**
 * What incrementAndGet() does inside, simplified.
 * It is the CAS LOOP pattern, the basis of all lock-free programming.
 */
public int incrementAndGet() {
    while (true) {
        int current = get();                     // 1. read
        int next = current + 1;                  // 2. compute
        if (compareAndSet(current, next)) {      // 3. try to write
            return next;                         //    success: leave
        }
        // Failure: another thread changed the value between 1 and 3.
        // Nothing has been lost: read again and retry.
    }
}

The essential difference from a lock:

  • A lock says: "nobody else touch this while I work". The others wait blocked.
  • CAS says: "I try; if somebody got in first, I try again". Nobody waits blocked; somebody is always making progress.

That last property is called lock freedom (lock-free): at any moment, at least one thread is advancing. There can be no deadlock, because there is nothing to hold.

sequenceDiagram
    participant A as thread-A
    participant M as AtomicInteger
    participant B as thread-B

    Note over M: value = 10
    A->>M: get() -> 10
    B->>M: get() -> 10
    A->>M: compareAndSet(10, 11)
    Note over M: matches: value = 11, returns true
    B->>M: compareAndSet(10, 11)
    Note over M: does NOT match (it is 11): returns false
    Note over B: retries
    B->>M: get() -> 11
    B->>M: compareAndSet(11, 12)
    Note over M: matches: value = 12
    Note over A,B: Two increments, value = 12.<br/>NONE lost.

Compare this diagram with the one in 08-04: there both threads wrote 11 and an increment was lost. Here the second thread detects that somebody got ahead of it and retries. That detection is what CAS does.

Cost: under extreme contention, a CAS loop can retry many times and waste CPU. With moderate contention it is faster than a lock because there are no context switches and no thread suspension.

  1. The operations of the atomic classes

The main classes: AtomicInteger, AtomicLong, AtomicBoolean, AtomicReference<V>, plus the arrays AtomicIntegerArray, AtomicLongArray and AtomicReferenceArray.

Method What it does Returns
get() / set(v) Read / write (like volatile) value / void
incrementAndGet() ++v The new value
getAndIncrement() v++ The previous value
decrementAndGet() / getAndDecrement() --v / v-- new / previous
addAndGet(d) / getAndAdd(d) Add d new / previous
getAndSet(v) Write and return what was there previous
compareAndSet(exp, new) CAS boolean
updateAndGet(f) Apply the function f atomically new
getAndUpdate(f) Ditto previous
accumulateAndGet(x, f) Combine the current value with x through f new
package com.nexussoftware.bibliotech.service;

import java.util.concurrent.atomic.*;

/**
 * BiblioTech statistics with atomic counters.
 * Without a single lock, and with reads that block nothing.
 */
public class BiblioTechStatistics {

    private final AtomicLong totalLoans   = new AtomicLong();
    private final AtomicLong totalReturns = new AtomicLong();
    private final AtomicLong finesCollectedCents = new AtomicLong();
    private final AtomicInteger activeLoans = new AtomicInteger();
    private final AtomicBoolean maintenanceMode = new AtomicBoolean(false);
    private final AtomicLong maxConcurrent = new AtomicLong();

    public void registerLoan() {
        totalLoans.incrementAndGet();
        int active = activeLoans.incrementAndGet();

        // ALL-TIME MAXIMUM with accumulateAndGet: it combines the current
        // value with 'active' using Math::max, atomically.
        // Writing it with get()+set() would be a classic race.
        maxConcurrent.accumulateAndGet(active, Math::max);
    }

    public void registerReturn(long fineCents) {
        totalReturns.incrementAndGet();
        activeLoans.decrementAndGet();
        if (fineCents > 0) {
            finesCollectedCents.addAndGet(fineCents);
        }
    }

    /**
     * Applies a 10% discount to the total collected.
     * updateAndGet applies the function ATOMICALLY, with a CAS loop
     * underneath: if another thread modifies the value between the read and the
     * write, the function is RE-EXECUTED with the new value.
     *
     * IMPORTANT: the function must be PURE and fast, because it can
     * run several times. No side effects.
     */
    public long applyDiscount() {
        return finesCollectedCents.updateAndGet(v -> (long) (v * 0.9));
    }

    /**
     * Enter maintenance ONLY IF we were not already in it.
     * compareAndSet guarantees that, even if ten threads ask at once,
     * exactly ONE receives true and runs the preparation.
     * It is the "only once" idiom without locks.
     */
    public boolean enterMaintenance() {
        if (maintenanceMode.compareAndSet(false, true)) {
            prepareMaintenance();          // only one thread gets here
            return true;
        }
        return false;                      // somebody beat us to it
    }

    public String summary() {
        return String.format(
                "loans=%d returns=%d active=%d max=%d fines=%.2f EUR",
                totalLoans.get(), totalReturns.get(),
                activeLoans.get(), maxConcurrent.get(),
                finesCollectedCents.get() / 100.0);
    }

    private void prepareMaintenance() { /* ... */ }
}

A note on summary(): the six values are read at different instants, so the set is not a consistent snapshot. It can show loans=100 and active=3 from two different moments. For metrics that is perfectly acceptable; if you needed consistency across all the counters, a lock or the immutable-state pattern with AtomicReference of the next section would be required.

  1. AtomicReference and the ABA problem

AtomicReference<V> applies CAS to an object reference. Combined with the immutability of 08-04, it gives a very powerful pattern: updating a complete state atomically and without locks.

package com.nexussoftware.bibliotech.service;

import java.util.concurrent.atomic.AtomicReference;

public class BiblioTechState {

    /** The complete state, immutable (a record from 04-07). */
    public record State(long loans, long returns,
                        long fineCents, boolean maintenance) {

        State withLoan() {
            return new State(loans + 1, returns, fineCents, maintenance);
        }
        State withReturn(long fine) {
            return new State(loans, returns + 1,
                    fineCents + fine, maintenance);
        }
    }

    private final AtomicReference<State> state =
            new AtomicReference<>(new State(0, 0, 0, false));

    /**
     * A completely lock-free read that is ALWAYS CONSISTENT: the four
     * fields come from the same instant, because they are a single
     * immutable object. That is the advantage over four separate counters.
     */
    public State snapshot() {
        return state.get();
    }

    /** Atomic update of all four fields at once. */
    public void registerLoan() {
        state.updateAndGet(State::withLoan);
    }

    public void registerReturn(long fineCents) {
        state.updateAndGet(s -> s.withReturn(fineCents));
    }
}

This combination —immutable state + AtomicReference + updateAndGet— is one of the most elegant techniques in Java concurrency: free, always consistent reads, atomic lock-free writes, and no possibility of deadlock.

The ABA problem

There is a pathological case of CAS worth knowing about.

CAS checks that the value is the expected one, not that it has not changed. If a value goes from A to B and back to A, a CAS expecting A will succeed, even though something important happened in between.

Thread 1: reads A ............................. CAS(A -> C): SUCCESS
Thread 2:      reads A, CAS(A->B), CAS(B->A)
                                                 ^
        Thread 1 has no idea two changes happened.
        If its decision depended on NOTHING having happened, it is a bug.

With integer counters this is harmless: if the counter is back to 10, it is 10 and that is that. The problem appears with references in linked structures: a node can be removed, recycled and reinserted, and a CAS on its reference would succeed on a node that is no longer logically the same.

The solution: add a stamp or mark that always changes.

import java.util.concurrent.atomic.AtomicStampedReference;

// Every modification increments the STAMP, even if the value goes back to
// being the same. The CAS checks value AND stamp, so A-B-A is detected.
AtomicStampedReference<Node> head =
        new AtomicStampedReference<>(initialNode, 0);

int[] currentStamp = new int[1];
Node current = head.get(currentStamp);

head.compareAndSet(current, newNode,
                   currentStamp[0], currentStamp[0] + 1);   // stamp + 1

// A simpler variant with a single boolean bit:
// AtomicMarkableReference<Node>

In practice, ABA only shows up if you implement lock-free data structures by hand. If you use ConcurrentHashMap and AtomicInteger, the library has already taken care of it. Know about it so you know it exists and understand why AtomicStampedReference is there.

  1. LongAdder under contention

AtomicLong is excellent under moderate contention. Under extreme contention —sixteen threads incrementing the same counter non-stop— its CAS loop starts failing a lot: the threads retry over and over and, on top of that, they all write to the same cache line, which causes constant invalidations between cores (cache line bouncing).

LongAdder (Java 8) solves this with a simple idea: it keeps several internal cells and each thread increments its own. Only when sum() is called are they all added up.

import java.util.concurrent.atomic.LongAdder;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.*;

public class CounterComparison {

    static long measureAtomicLong(int threads, int ops) throws InterruptedException {
        AtomicLong c = new AtomicLong();
        return measure(threads, ops, c::incrementAndGet, c::get);
    }

    static long measureLongAdder(int threads, int ops) throws InterruptedException {
        LongAdder c = new LongAdder();
        return measure(threads, ops, c::increment, c::sum);
    }

    static long measure(int threads, int ops, Runnable increment,
                        java.util.function.LongSupplier read) throws InterruptedException {
        CountDownLatch startGate = new CountDownLatch(1);
        CountDownLatch finishLine = new CountDownLatch(threads);
        for (int h = 0; h < threads; h++) {
            new Thread(() -> {
                try {
                    startGate.await();
                    for (int i = 0; i < ops; i++) increment.run();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally { finishLine.countDown(); }
            }, "counter-" + h).start();
        }
        long start = System.nanoTime();
        startGate.countDown();
        finishLine.await();
        long ns = System.nanoTime() - start;
        // Verification: both must give the exact result.
        if (read.getAsLong() != (long) threads * ops) {
            throw new AssertionError("incorrect result");
        }
        return ns / 1_000_000;
    }

    public static void main(String[] args) throws InterruptedException {
        final int OPS = 2_000_000;
        System.out.printf("%-8s | %14s | %14s | %8s%n",
                "Threads", "AtomicLong ms", "LongAdder ms", "Gain");
        System.out.println("---------|----------------|----------------|---------");
        for (int threads : new int[] { 1, 2, 4, 8, 16 }) {
            long a = measureAtomicLong(threads, OPS / threads);
            long l = measureLongAdder(threads, OPS / threads);
            System.out.printf("%-8d | %14d | %14d | %7.1fx%n",
                    threads, a, l, (double) a / Math.max(l, 1));
        }
    }
}

Indicative output:

Threads  |  AtomicLong ms |   LongAdder ms |     Gain
---------|----------------|----------------|---------
1        |             12 |             18 |     0.7x
2        |             41 |             21 |     2.0x
4        |             96 |             19 |     5.1x
8        |            213 |             22 |     9.7x
16       |            487 |             26 |    18.7x

What this table teaches:

  • With one thread, AtomicLong wins. LongAdder has more internal machinery and does not amortise it without contention.
  • The advantage grows with the threads. At 16 threads, nearly 19x.
  • AtomicLong scales badly: going from 1 to 16 threads multiplies the time by 40, even though the total work is the same. That is pure contention.
AtomicLong LongAdder
Writing under contention Degrades Excellent
Reading (get/sum) O(1), exact O(number of cells), approximate if there are concurrent writes
Memory 1 value Several cells (grows with contention)
Supports compareAndSet Yes No
Use for Counters with little contention; when you need CAS High-frequency metrics and statistics

The rule: if you only count and read the total now and then —metrics, request counters, statistics—, LongAdder. If you need the exact value on every operation or to use compareAndSet, AtomicLong. DoubleAdder, LongAccumulator and DoubleAccumulator complete the family; the Accumulators allow an arbitrary combining function.

  1. BiblioTech: a concurrent catalogue and atomic statistics

The final version of the catalogue, without a single explicit lock():

package com.nexussoftware.bibliotech.service;

import com.nexussoftware.bibliotech.domain.Material;

import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.concurrent.atomic.LongAdder;

/**
 * BiblioTech's concurrent catalogue.
 *
 * Compared with the ReadWriteLock version of 08-04:
 *  - There is no explicit lock to release in a finally.
 *  - Reads block NOTHING, not even other reads.
 *  - Writes on different buckets happen in parallel.
 *  - Compound operations are solved with the map's own atomic
 *    methods, not by hand-encapsulating critical sections.
 *
 * HONEST LIMIT: an invariant spanning SEVERAL structures
 * (like LoanRegistry's between byId and byEmployee) still
 * needs a lock. Concurrent collections guarantee the atomicity
 * of ONE operation on ONE collection, not of two.
 */
public class ConcurrentCatalog {

    /** Main index by ISBN. Lock-free reads. */
    private final ConcurrentMap<String, Material> byIsbn = new ConcurrentHashMap<>();

    /** Materials grouped by type. The value is a concurrent list. */
    private final ConcurrentMap<MaterialType, List<Material>> byType =
            new ConcurrentHashMap<>();

    /** Event listeners: many reads, almost no writes. */
    private final List<CatalogListener> listeners = new CopyOnWriteArrayList<>();

    // --- Statistics: LongAdder because of their high write frequency ---
    private final LongAdder lookups = new LongAdder();
    private final LongAdder hits    = new LongAdder();
    private final LongAdder insertions = new LongAdder();
    private final AtomicLong lastModifiedMs = new AtomicLong();

    // ---------- WRITING ----------

    /**
     * Adds a material if its ISBN was absent.
     * putIfAbsent is ATOMIC: even if ten threads add the same ISBN
     * at once, exactly one receives true. It is check-then-act
     * solved without locks.
     */
    public boolean add(Material m) {
        if (byIsbn.putIfAbsent(m.isbn(), m) != null) {
            return false;                       // it already existed
        }
        // computeIfAbsent creates the list only if absent, atomically.
        // CopyOnWriteArrayList because these lists are walked far
        // more often than they are modified.
        byType.computeIfAbsent(m.type(), t -> new CopyOnWriteArrayList<>()).add(m);

        insertions.increment();
        lastModifiedMs.set(System.currentTimeMillis());
        notifyAdded(m);
        return true;
    }

    public boolean remove(String isbn) {
        Material m = byIsbn.remove(isbn);
        if (m == null) return false;

        List<Material> list = byType.get(m.type());
        if (list != null) list.remove(m);

        lastModifiedMs.set(System.currentTimeMillis());
        return true;
    }

    // ---------- READING ----------

    /** Lock-free. With 100 threads querying, none waits for another. */
    public Material findByIsbn(String isbn) {
        lookups.increment();
        Material m = byIsbn.get(isbn);
        if (m != null) hits.increment();
        return m;
    }

    /**
     * Returns the list for a type. Since it is a CopyOnWriteArrayList,
     * the caller can iterate it in complete safety even if another thread
     * modifies it: its iterator works over an immutable snapshot.
     * There is no need to copy defensively, unlike in 08-04.
     */
    public List<Material> byType(MaterialType type) {
        return byType.getOrDefault(type, List.of());
    }

    /**
     * A complete walk of the catalogue.
     * The iterator is WEAKLY CONSISTENT: it throws no
     * ConcurrentModificationException and does not block the writers.
     */
    public void forEach(java.util.function.Consumer<Material> action) {
        for (Material m : byIsbn.values()) {
            action.accept(m);
        }
    }

    /** CAREFUL: approximate (section 7). Fine for metrics, not for control. */
    public int approximateSize() {
        return byIsbn.size();
    }

    // ---------- LISTENERS ----------

    public void registerListener(CatalogListener l) { listeners.add(l); }

    private void notifyAdded(Material m) {
        // No lock during the notification: rule 3 of 08-04
        // ("never call foreign code holding a lock") is honoured
        // automatically thanks to CopyOnWriteArrayList.
        for (CatalogListener l : listeners) {
            try {
                l.materialAdded(m);
            } catch (RuntimeException e) {
                LOG.log(Level.WARNING, "Listener failed", e);
            }
        }
    }

    // ---------- METRICS ----------

    public String metrics() {
        long c = lookups.sum();
        long h = hits.sum();
        return String.format("lookups=%d hits=%d rate=%.1f%% insertions=%d materials~%d",
                c, h, c == 0 ? 0.0 : 100.0 * h / c, insertions.sum(), byIsbn.size());
    }
}

A stress test:

public class ConcurrentCatalogTest {

    public static void main(String[] args) throws InterruptedException {

        final int THREADS = 16;
        final int OPS = 100_000;

        ConcurrentCatalog catalog = new ConcurrentCatalog();
        CountDownLatch startGate = new CountDownLatch(1);
        CountDownLatch finishLine = new CountDownLatch(THREADS);

        for (int h = 0; h < THREADS; h++) {
            final int id = h;
            new Thread(() -> {
                try {
                    startGate.await();
                    ThreadLocalRandom random = ThreadLocalRandom.current();
                    for (int i = 0; i < OPS; i++) {
                        if (random.nextInt(100) < 90) {
                            catalog.findByIsbn("978-" + random.nextInt(1000));
                        } else {
                            catalog.add(new Book(
                                    "978-" + random.nextInt(1000), "Title", "Author"));
                        }
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally { finishLine.countDown(); }
            }, "catalog-" + id).start();
        }

        long start = System.nanoTime();
        startGate.countDown();
        finishLine.await();
        long ms = (System.nanoTime() - start) / 1_000_000;

        System.out.println("Operations  : " + (THREADS * OPS));
        System.out.println("Time        : " + ms + " ms");
        System.out.println("Throughput  : " + (THREADS * OPS / Math.max(ms, 1)) + " ops/ms");
        System.out.println(catalog.metrics());
    }
}

Indicative output:

Operations  : 1600000
Time        : 312 ms
Throughput  : 5128 ops/ms
Metrics     : lookups=1439871 hits=1438204 rate=99.9% insertions=1000 materials~1000

One million six hundred thousand operations with sixteen threads in 312 ms, without a single lock(). And the detail that validates the design: insertions=1000 with 1000 possible ISBNs. Even though sixteen threads repeatedly tried to add the same ISBNs, exactly one thousand insertions succeeded: putIfAbsent honoured its contract under maximum contention.

  1. Final decision table

You need… Use Why
A high-frequency counter LongAdder It scales under contention
A counter with an exact value on every operation AtomicInteger/AtomicLong CAS, exact, lock-free
An "only once" flag AtomicBoolean.compareAndSet Exactly one winner
Multi-field state, consistent on reading AtomicReference + record An immutable, atomic snapshot
A simple stop flag volatile boolean The cheapest thing that guarantees visibility
A shared map ConcurrentHashMap Lock-free reads, atomic compounds
A shared sorted map ConcurrentSkipListMap Navigable and concurrent
A list of listeners or configuration CopyOnWriteArrayList Immune, lock-free iteration
A large list with frequent writes synchronizedList or a lock Copy-on-write would be O(n²)
To hand work between threads BlockingQueue take/put with no polling, with backpressure
A queue with no waiting ConcurrentLinkedQueue Non-blocking, but it forces polling
An invariant across several structures synchronized or Lock Concurrent collections do not cover it
Many reads and few writes over your own state ReadWriteLock Readers in parallel
Data that does not change An immutable object (record) Zero synchronisation, impossible to corrupt
Data used by a single thread A local variable / ThreadLocal The best option: do not share

The order in which the options should be considered, from best to worst:

  1. Do not share (local, confined).
  2. Share immutable (record).
  3. Atomic (AtomicX, LongAdder).
  4. Concurrent collection (ConcurrentHashMap, BlockingQueue).
  5. Lock (synchronized, Lock, ReadWriteLock).

Common Mistakes and Tips

Mistake 1: sharing a HashMap between threads. It does not give "approximate" results: it loses entries and can be corrupted to the point of causing an infinite loop with a core at 100%.

Mistake 2: believing Collections.synchronizedMap makes your code safe. Each method is; iterating and compound operations are not. It is the most frequent mistake of the first generation.

Mistake 3: iterating a synchronized collection without synchronising the iteration. ConcurrentModificationException. And if you do synchronise, you block everybody for the whole walk.

Mistake 4: doing get + put on a ConcurrentHashMap. The map is safe; your sequence of two calls is not. Use merge, compute, computeIfAbsent or putIfAbsent.

Mistake 5: using a concurrent collection's size() to make decisions. It is approximate by design. Fine for metrics, not for control.

Mistake 6: doing heavy or blocking work inside computeIfAbsent. It runs with the bucket locked. Compute outside and publish with putIfAbsent.

Mistake 7: modifying the same map inside a compute function. It can deadlock or corrupt the structure. Forbidden.

Mistake 8: using CopyOnWriteArrayList to accumulate in a loop. Every add copies the array: O(n²) in total. It is for many reads and almost no writes.

Mistake 9: polling a ConcurrentLinkedQueue with poll() + sleep(). It burns CPU and adds latency. Use a BlockingQueue and take().

Mistake 10: using an unbounded BlockingQueue as a work queue. With no bound there is no backpressure, and a fast producer ends up exhausting memory.

Mistake 11: inserting a single poison pill with several consumers. The rest wait forever. One per consumer.

Mistake 12: passing a function with side effects to updateAndGet. It can run several times because of the CAS loop. It must be pure and fast.

Mistake 13: believing concurrent collections remove the need for locks. They guarantee the atomicity of one operation on one collection. An invariant across two structures still needs a lock.

Tip 1: ConcurrentHashMap by default for any shared map. It is faster, safer and has a better API than the alternatives. There is no reason not to use it.

Tip 2: learn merge and computeIfAbsent by heart. They solve 90% of check-then-act cases in one readable, atomic line.

Tip 3: bound your queues. The bound is the difference between a system that degrades gracefully and one that falls over.

Tip 4: LongAdder for metrics, AtomicLong for logic. If you only count, LongAdder. If you need the exact value at every step or compareAndSet, AtomicLong.

Tip 5: AtomicReference + record is your best tool for shared multi-field state. Free, consistent reads, atomic writes, impossible to deadlock.

Tip 6: the poison pill gives an orderly shutdown; interruption, an urgent one. Use the first and keep the second as plan B with a deadline.

Exercises

Exercise 1: The three generations, measured

Write ThreeGenerations comparing an unprotected HashMap, Collections.synchronizedMap and ConcurrentHashMap under the same load: 12 threads, 100,000 operations each, 80% reads and 20% writes over a space of 5,000 keys. For each implementation measure the time, check whether the final number of entries is the expected one, and catch any exception. Use a starting-gate CountDownLatch and a deadline in case the HashMap goes into an infinite loop. Explain the three results.

Exercise 2: A complete producer-consumer with backpressure

Implement ImportPipeline, a two-stage processing for BiblioTech:

  • Stage 1 (2 producer threads): read "lines" from a simulated catalogue and put them in an ArrayBlockingQueue<String> of capacity 20.
  • Stage 2 (4 consumer threads): take lines, turn them into Material (with a 30 ms sleep) and add them to a ConcurrentHashMap.

Requirements: LongAdder counters for lines read, materials created and lines discarded; a monitor thread printing the queue size and the counters every 300 ms, demonstrating that the queue fills up and slows the producers down; shutdown with a poison pill (one per consumer); and a final check that not a single line was lost.

Exercise 3: A statistics counter, four implementations

Write StatisticsComparison implementing the same loan counter in four ways: (a) a long with synchronized, (b) AtomicLong, (c) LongAdder, and (d) AtomicReference<State> with an immutable three-field record updated with updateAndGet. Subject each to 16 threads × 500,000 increments, verify that they all give the exact result, and measure the time. Add a second measurement with a single thread to show the reversal of results. Comment on which implementation you would pick for high-frequency metrics and which for business state that must be read consistently.

Solutions

Solution to Exercise 1

import java.util.*;
import java.util.concurrent.*;

public class ThreeGenerations {

    static final int THREADS = 12;
    static final int OPS = 100_000;
    static final int KEYS = 5_000;

    record Result(String name, long ms, int entries, String issue) { }

    static Result measure(String name, Map<Integer, String> map)
            throws InterruptedException {

        CountDownLatch startGate = new CountDownLatch(1);
        CountDownLatch finishLine = new CountDownLatch(THREADS);
        // volatile is not enough to accumulate text from several threads:
        // we use a concurrent queue to collect the issues.
        Queue<String> issues = new ConcurrentLinkedQueue<>();

        for (int h = 0; h < THREADS; h++) {
            Thread t = new Thread(() -> {
                try {
                    startGate.await();
                    ThreadLocalRandom random = ThreadLocalRandom.current();
                    for (int i = 0; i < OPS; i++) {
                        int key = random.nextInt(KEYS);
                        if (random.nextInt(100) < 80) {
                            map.get(key);
                        } else {
                            map.put(key, "v" + i);
                        }
                    }
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } catch (RuntimeException e) {
                    issues.add(e.getClass().getSimpleName());
                } finally {
                    finishLine.countDown();
                }
            }, name + "-" + h);
            t.setDaemon(true);      // daemon: if it goes into an infinite loop,
            t.start();              // it will not stop the JVM terminating
        }

        long start = System.nanoTime();
        startGate.countDown();

        // Deadline: the unprotected HashMap may NEVER finish.
        boolean completed = finishLine.await(20, TimeUnit.SECONDS);
        long ms = (System.nanoTime() - start) / 1_000_000;

        String issue = completed
                ? (issues.isEmpty() ? "-" : issues.peek())
                : "DID NOT FINISH (infinite loop or corruption)";

        int entries;
        try {
            entries = map.size();
        } catch (RuntimeException e) {
            entries = -1;
        }
        return new Result(name, ms, entries, issue);
    }

    public static void main(String[] args) throws InterruptedException {

        System.out.printf("%d threads x %d ops (80%% reads) over %d keys%n%n",
                THREADS, OPS, KEYS);

        List<Result> results = new ArrayList<>();
        results.add(measure("HashMap", new HashMap<>()));
        results.add(measure("synchronizedMap", Collections.synchronizedMap(new HashMap<>())));
        results.add(measure("ConcurrentHashMap", new ConcurrentHashMap<>()));

        System.out.printf("%-20s | %8s | %10s | %-40s%n",
                "Implementation", "ms", "entries", "issue");
        System.out.println("---------------------|----------|------------|"
                + "------------------------------------------");
        for (Result r : results) {
            System.out.printf("%-20s | %8d | %10d | %-40s%n",
                    r.name(), r.ms(), r.entries(), r.issue());
        }

        System.out.println();
        System.out.println("Expected: " + KEYS + " entries (every key touched)");
    }
}

Indicative output:

12 threads x 100000 ops (80% reads) over 5000 keys

Implementation       |       ms |    entries | issue
---------------------|----------|------------|------------------------------------------
HashMap              |    20003 |       4211 | DID NOT FINISH (infinite loop or corruption)
synchronizedMap      |     1876 |       5000 | -
ConcurrentHashMap    |      147 |       5000 | -

Expected: 5000 entries (every key touched)

The three results:

  1. HashMap did not finish in 20 seconds and its size() says 4211 instead of 5000: entries have been lost and at least one thread ended up in a loop. Marking them as daemons was essential for the program to be able to finish.
  2. synchronizedMap is correct but slow: 1876 ms, with twelve threads serialised by a single lock, including the 80% of reads that would not get in each other's way.
  3. ConcurrentHashMap is correct and 12x faster than the synchronized one: reads block nothing and writes only compete when they land in the same bucket.

Solution to Exercise 2

package com.nexussoftware.bibliotech.persistence;

import java.util.concurrent.*;
import java.util.concurrent.atomic.LongAdder;

public class ImportPipeline {

    private static final String POISON = "__END__";
    private static final int CAPACITY = 20;
    private static final int PRODUCERS = 2;
    private static final int CONSUMERS = 4;
    private static final int LINES_PER_PRODUCER = 150;

    // BOUNDED queue: if the consumers cannot keep up, the producers
    // block in put(). Backpressure.
    private final BlockingQueue<String> queue = new ArrayBlockingQueue<>(CAPACITY);

    private final ConcurrentMap<String, String> materials = new ConcurrentHashMap<>();

    // LongAdder: very frequent writes, occasional reads.
    private final LongAdder linesRead = new LongAdder();
    private final LongAdder created   = new LongAdder();
    private final LongAdder discarded = new LongAdder();

    private volatile boolean running = true;

    // ---------- STAGE 1: PRODUCERS ----------

    private void produce(int producerId) {
        try {
            for (int i = 0; i < LINES_PER_PRODUCER; i++) {
                String line = "978-" + producerId + String.format("%05d", i)
                        + ";Title " + i + ";Author";
                queue.put(line);      // BLOCKS if the queue is full
                linesRead.increment();
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    // ---------- STAGE 2: CONSUMERS ----------

    private void consume() {
        String me = Thread.currentThread().getName();
        try {
            while (true) {
                String line = queue.take();      // BLOCKS if it is empty

                // Poison pill: comparison by IDENTITY.
                if (line == POISON) {
                    System.out.println("  [" + me + "] pill received, finishing");
                    return;
                }
                try {
                    TimeUnit.MILLISECONDS.sleep(30);      // "expensive" conversion
                    String[] fields = line.split(";");
                    if (fields.length < 3) {
                        throw new IllegalArgumentException("incomplete line");
                    }
                    materials.put(fields[0], fields[1]);
                    created.increment();
                } catch (IllegalArgumentException e) {
                    discarded.increment();                 // degrade (06-07)
                }
            }
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
        }
    }

    // ---------- ORCHESTRATION ----------

    public void run() throws InterruptedException {

        ExecutorService producers = Executors.newFixedThreadPool(PRODUCERS,
                new ThreadNamer("pipeline-producer"));
        ExecutorService consumers = Executors.newFixedThreadPool(CONSUMERS,
                new ThreadNamer("pipeline-consumer"));

        for (int c = 0; c < CONSUMERS; c++) consumers.execute(this::consume);

        CountDownLatch productionFinished = new CountDownLatch(PRODUCERS);
        for (int p = 0; p < PRODUCERS; p++) {
            final int id = p;
            producers.execute(() -> {
                try { produce(id); }
                finally { productionFinished.countDown(); }    // ALWAYS
            });
        }

        // Monitor: shows that the queue fills up and slows the producers.
        Thread monitor = new Thread(() -> {
            try {
                while (running) {
                    System.out.printf("      [monitor] queue=%2d/%d  read=%d  created=%d%n",
                            queue.size(), CAPACITY, linesRead.sum(), created.sum());
                    TimeUnit.MILLISECONDS.sleep(300);
                }
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
            }
        }, "pipeline-monitor");
        monitor.setDaemon(true);
        monitor.start();

        // 1. Wait for the producers to finish.
        productionFinished.await();
        System.out.println("  Production finished; injecting pills");

        // 2. ONE pill PER CONSUMER, at the end of the queue:
        //    everything before it gets processed first.
        for (int c = 0; c < CONSUMERS; c++) queue.put(POISON);

        // 3. Orderly two-phase shutdown.
        producers.shutdown();
        consumers.shutdown();
        boolean ok = consumers.awaitTermination(30, TimeUnit.SECONDS);
        if (!ok) consumers.shutdownNow();

        running = false;
        monitor.interrupt();

        // 4. Verification.
        long total = LINES_PER_PRODUCER * PRODUCERS;
        System.out.println();
        System.out.println("=== RESULT ===");
        System.out.println("Lines produced    : " + linesRead.sum() + " (expected " + total + ")");
        System.out.println("Materials created : " + created.sum());
        System.out.println("Discarded         : " + discarded.sum());
        System.out.println("In the map        : " + materials.size());
        System.out.println("Queue at the end  : " + queue.size() + " (must be 0)");
        System.out.println("No losses         : "
                + (created.sum() + discarded.sum() == total));
    }

    static class ThreadNamer implements ThreadFactory {
        private final String prefix;
        private int n = 1;
        ThreadNamer(String prefix) { this.prefix = prefix; }
        @Override public synchronized Thread newThread(Runnable r) {
            return new Thread(r, prefix + "-" + n++);
        }
    }

    public static void main(String[] args) throws InterruptedException {
        new ImportPipeline().run();
    }
}

Output (fragment):

      [monitor] queue=20/20  read=42  created=22
      [monitor] queue=20/20  read=82  created=62
      [monitor] queue=20/20  read=122  created=102
      [monitor] queue=20/20  read=162  created=142
      ...
  Production finished; injecting pills
  [pipeline-consumer-2] pill received, finishing
  [pipeline-consumer-1] pill received, finishing
  [pipeline-consumer-4] pill received, finishing
  [pipeline-consumer-3] pill received, finishing

=== RESULT ===
Lines produced    : 300 (expected 300)
Materials created : 300
Discarded         : 0
In the map        : 300
Queue at the end  : 0 (must be 0)
No losses         : true

Three things the output demonstrates:

  1. queue=20/20 sustained: the queue is permanently full, so the producers spend most of their time blocked in put(). They produce at the consumers' pace, not their own. With an unbounded queue, queue would have grown to 300 and all that intermediate memory would have been claimed at once.
  2. Queue at the end: 0 and No losses: true: the poison pill, by going to the end of a FIFO queue, guarantees that all the earlier work is processed before shutdown. Not one line lost.
  3. All four consumers finish, one per pill. With a single pill, three would have stayed blocked in take() forever and awaitTermination would have expired.

Solution to Exercise 3

import java.util.concurrent.*;
import java.util.concurrent.atomic.*;

public class StatisticsComparison {

    interface Counter {
        void registerLoan();
        long total();
        String name();
    }

    /** (a) a long guarded with synchronized. */
    static class WithSynchronized implements Counter {
        private long loans = 0;
        public synchronized void registerLoan() { loans++; }
        public synchronized long total() { return loans; }
        public String name() { return "synchronized"; }
    }

    /** (b) AtomicLong: a CAS loop underneath. */
    static class WithAtomicLong implements Counter {
        private final AtomicLong loans = new AtomicLong();
        public void registerLoan() { loans.incrementAndGet(); }
        public long total() { return loans.get(); }
        public String name() { return "AtomicLong"; }
    }

    /** (c) LongAdder: separate cells, summed on reading. */
    static class WithLongAdder implements Counter {
        private final LongAdder loans = new LongAdder();
        public void registerLoan() { loans.increment(); }
        public long total() { return loans.sum(); }
        public String name() { return "LongAdder"; }
    }

    /** (d) AtomicReference over an immutable THREE-field record. */
    static class WithAtomicReference implements Counter {

        record State(long loans, long returns, long fines) {
            State withLoan() {
                return new State(loans + 1, returns, fines);
            }
        }

        private final AtomicReference<State> state =
                new AtomicReference<>(new State(0, 0, 0));

        public void registerLoan() {
            // updateAndGet retries if another thread got in first.
            // The function must be PURE: it can run several times.
            state.updateAndGet(State::withLoan);
        }

        public long total() { return state.get().loans(); }

        /** UNIQUE ADVANTAGE: a CONSISTENT snapshot of the three fields. */
        public State snapshot() { return state.get(); }

        public String name() { return "AtomicReference+record"; }
    }

    static long measure(Counter c, int threads, int perThread) throws InterruptedException {
        CountDownLatch startGate = new CountDownLatch(1);
        CountDownLatch finishLine = new CountDownLatch(threads);

        for (int h = 0; h < threads; h++) {
            new Thread(() -> {
                try {
                    startGate.await();
                    for (int i = 0; i < perThread; i++) c.registerLoan();
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally { finishLine.countDown(); }
            }, "stat-" + h).start();
        }

        long start = System.nanoTime();
        startGate.countDown();
        finishLine.await();
        long ms = (System.nanoTime() - start) / 1_000_000;

        long expected = (long) threads * perThread;
        if (c.total() != expected) {
            throw new AssertionError(c.name() + " INCORRECT: "
                    + c.total() + " != " + expected);
        }
        return ms;
    }

    static Counter newCounter(int type) {
        return switch (type) {
            case 0 -> new WithSynchronized();
            case 1 -> new WithAtomicLong();
            case 2 -> new WithLongAdder();
            default -> new WithAtomicReference();
        };
    }

    public static void main(String[] args) throws InterruptedException {

        final int TOTAL = 8_000_000;

        // Warm-up (JIT).
        for (int t = 0; t < 4; t++) measure(newCounter(t), 4, 50_000);

        for (int threads : new int[] { 1, 16 }) {
            System.out.printf("%n=== %d thread(s), %d increments in total ===%n",
                    threads, TOTAL);
            System.out.printf("%-26s | %8s | %14s%n", "Implementation", "ms", "inc/ms");
            System.out.println("---------------------------|----------|---------------");

            for (int t = 0; t < 4; t++) {
                Counter c = newCounter(t);
                long ms = measure(c, threads, TOTAL / threads);
                System.out.printf("%-26s | %8d | %14d%n",
                        c.name(), ms, TOTAL / Math.max(ms, 1));
            }
        }

        // Demonstration of AtomicReference's unique advantage.
        WithAtomicReference ar = new WithAtomicReference();
        measure(ar, 8, 100_000);
        WithAtomicReference.State snap = ar.snapshot();
        System.out.printf("%nCONSISTENT snapshot: loans=%d returns=%d fines=%d%n",
                snap.loans(), snap.returns(), snap.fines());
        System.out.println("The three fields come from the SAME instant, something");
        System.out.println("three independent counters cannot guarantee.");
    }
}

Indicative output:

=== 1 thread(s), 8000000 increments in total ===
Implementation             |       ms |         inc/ms
---------------------------|----------|---------------
synchronized               |       61 |         131147
AtomicLong                 |       48 |         166666
LongAdder                  |       72 |         111111
AtomicReference+record     |      284 |          28169

=== 16 thread(s), 8000000 increments in total ===
Implementation             |       ms |         inc/ms
---------------------------|----------|---------------
synchronized               |     1842 |           4343
AtomicLong                 |      918 |           8714
LongAdder                  |       94 |          85106
AtomicReference+record     |     1531 |           5225

CONSISTENT snapshot: loans=800000 returns=0 fines=0
The three fields come from the SAME instant, something
three independent counters cannot guarantee.

A complete analysis:

  • With one thread, AtomicLong wins and LongAdder loses. With no contention, LongAdder's multiple cells are machinery that does not pay for itself. It confirms that the best counter depends on the contention; there is no absolute one.
  • With 16 threads, LongAdder is 20x faster than AtomicLong and nearly 20x faster than synchronized. It is exactly the case it was designed for.
  • AtomicReference+record is the slowest in both cases, and that makes sense: every increment creates a new object and its CAS loop retries under contention. But look at the last line of the output: it is the only one that can give a consistent snapshot of the three fields. The other three, with three separate counters, would give values from different instants.
  • synchronized scales worst of all: it goes from 61 ms to 1842 ms, a factor of 30, with the same total work. That is pure lock contention, with context switches and thread suspension.

What to pick: for high-frequency metrics —catalogue lookups, loans per second—, LongAdder without hesitation. For business state that must be read consistently —the summary shown to the user or persisted—, AtomicReference with an immutable record, accepting its higher write cost in exchange for free and always consistent reads.

Conclusion

You have closed the two cracks that remained open and settled the module 5 debt.

You know exactly what it means that HashMap is not thread-safe. It is not that it gives approximate results: it loses entries and can corrupt its structure to the point where a get() enters an infinite loop and pins a core at 100% without throwing any exception. And you know the concurrent version of the 05-02 fail-fast, with the nuance that is hardly ever mentioned: ConcurrentModificationException is not guaranteed, because modCount is not volatile, so the alternative to the exception is not that all is well, but that the race goes unnoticed.

You know the three generations of solution. The synchronized collections of Collections, which fix the corruption with a global lock —serialising even the reads— and bring with them the double trap: iterating still demands manual synchronisation of the whole walk, and compound operations —check-then-act and read-modify-write— are still not atomic, exactly as in 08-04. And the concurrent collections, with ConcurrentHashMap at the head: reads with no lock at all thanks to its nodes' volatile fields, writes locking only the affected bucket, and an order of magnitude of difference in a sixteen-thread scenario.

You have ConcurrentHashMap's most valuable contribution: the atomic compound operationsputIfAbsent, computeIfAbsent, compute, merge, getOrDefault, remove(k,v), replace(k,v1,v2)— which are the correct way of solving check-then-act, with the critical warning that the function runs with the bucket locked: no long work, no touching the same map, no foreign code. And you know the two contracts that change: the weakly consistent iterators, which throw no exception and block nobody in exchange for not guaranteeing that they see later changes, and the approximate size(), which is a statistical figure and never a basis for deciding.

You know when to use CopyOnWriteArrayList —event listeners, configuration, rules: enormous numbers of reads and almost no writes— and why using it to accumulate in a loop is O(n²). You know that ConcurrentLinkedQueue does not block but forces polling, and that this is almost always the wrong answer.

And you have the complete BlockingQueues, promised since module 5: their four groups of operations —exception, special value, blocking, with a deadline— and their six implementations, with the bounded ArrayBlockingQueue as the default choice because the bound is what gives backpressure: when the queue fills, the producer blocks in put() and switches to producing at the rate the system can absorb, instead of piling up until memory runs out. And with them, the complete producer-consumer pattern that module 5 described and left unwritten, with take() that waits without burning CPU, with individual failures that do not kill the consumer, and with the poison pill and its three rules: one per consumer, at the end of the queue —so the shutdown is orderly by construction and not one element is lost—, and compared by identity.

And you know lock-free programming. The processor's compare-and-swap instruction —"if it holds what is expected, replace it and tell me yes; if not, touch nothing"— and the retry loop underneath incrementAndGet. With the essential difference that defines it: a lock says "nobody else touch this" and the others wait blocked; a CAS says "I try and, if somebody got in first, I do it again", and somebody is always making progress, which makes deadlock impossible. You have mastered the operations of AtomicInteger, AtomicLong, AtomicBoolean and AtomicReference —including updateAndGet and accumulateAndGet, whose function must be pure because it can run several times—, the compareAndSet(false, true) idiom that guarantees exactly one winner, the ABA problem and its solution with AtomicStampedReference, and LongAdder, which at sixteen threads beats AtomicLong by a factor of twenty and at one thread loses: the proof that the best counter depends on the contention.

Above all, you have the pattern that combines the best of two lessons: immutable state with a record + AtomicReference + updateAndGet, which gives free reads, always consistent across all the fields, and atomic writes with no lock at all. It is the most elegant technique in all of Java concurrency.

BiblioTech no longer has a single lock() in its catalogue. ConcurrentCatalog uses ConcurrentHashMap for its indexes, CopyOnWriteArrayList for its listeners —which automatically honours the 08-04 rule about never calling foreign code holding a lock—, and LongAdder for its metrics. One million six hundred thousand operations with sixteen threads in three hundred milliseconds, with exactly insertions=1000 over a thousand possible ISBNs: putIfAbsent honouring its contract under maximum contention. And ReservationProcessor implements the real producer-consumer, with backpressure visible in the output —the queue stuck at its capacity, slowing the producers— and an orderly shutdown without losing a single reservation.

With the limit declared honestly: concurrent collections guarantee the atomicity of one operation on one collection, not of two. LoanRegistry's invariant between byId and byEmployee still needs the lock from 08-04, and that is not a defect of the library: it is the real boundary of what can be solved without mutual exclusion.

And you have the decision table that organises the whole module, with its hierarchy: do not share, share immutable, atomic, concurrent collection, lock — in that order, moving down only when the previous level is not enough.

But notice what is still missing. BiblioTech now does things in parallel, but every time it needs the result of something, somebody sits waiting: future.get() blocks. If you wanted to chain three steps —query the catalogue, calculate the fines and export the report—, you would have to get() between each one, and the orchestrating thread would spend nearly all its time idle. Future tells you "a result will arrive here", but the only way to use it is ask and wait. There is no way to say "when it is ready, do this next", nor to combine two independent results, nor to define what to do if something fails midway through the chain.

In the next lesson, Asynchronous Tasks with CompletableFuture, the module closes with the answer to that. You will see the four limitations of Future that motivated its successor; the asynchronous composition model, in which you declare the whole chain up front and no thread waits for anybody; creation with supplyAsync and runAsync and why it is worth passing your own Executor instead of using the common pool; the difference between thenApply and thenCompose —with the CompletableFuture<CompletableFuture<T>> that appears when you get it wrong—; combining with thenCombine, allOf and anyOf; error handling with exceptionally, handle and whenComplete, with the CompletionException that wraps the cause; the deadlines of orTimeout and completeOnTimeout; and the traps that make a badly written asynchronous chain worse than the blocking code it replaces. By the end, BiblioTech will have a chain that queries, calculates and exports without blocking the menu at any point, and the module will be closed.

Java Programming Course

Module 1: Introduction to Java

Module 2: Control Flow

Module 3: Object-Oriented Programming

Module 4: Advanced Object-Oriented Programming

Module 5: Data Structures and Collections

Module 6: Exception Handling

Module 7: File Input/Output

Module 8: Multithreading and Concurrency

Module 9: Networking

Module 10: Advanced Topics

Module 11: Java Frameworks and Libraries

Module 12: Building Real-World Applications

© Copyright 2026. All rights reserved