From 434054f2461a83423cdcdfa3996b296de1f65c67 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 1 Oct 2026 12:09:44 +0000 Subject: [PATCH] cow: CopyOnWriteArrayList - deterministic iterator-snapshot and synchronizedList CME demos, JMH read/write throughput, and the addLast/removeFirst fix for a real concurrent-writer race --- README.md | 1 + cow/README.md | 48 ++++++ cow/output/01-iterator-snapshot-demo.txt | 17 +++ cow/output/02-synchronizedlist-cme-demo.txt | 11 ++ cow/output/03-tests.txt | 4 + cow/output/04-read-throughput-jmh.txt | 5 + cow/output/05-write-throughput-jmh.txt | 9 ++ cow/output/06-write-throughput-race.txt | 31 ++++ cow/pom.xml | 83 +++++++++++ cow/scripts/run-all.sh | 21 +++ cow/scripts/run.sh | 6 + .../com/ankurm/cow/IteratorSnapshotDemo.java | 70 +++++++++ .../ankurm/cow/ReadThroughputBenchmark.java | 101 +++++++++++++ .../ankurm/cow/SynchronizedListCmeDemo.java | 89 ++++++++++++ .../ankurm/cow/WriteThroughputBenchmark.java | 75 ++++++++++ .../java/com/ankurm/cow/CowClaimsTest.java | 137 ++++++++++++++++++ pom.xml | 1 + 17 files changed, 709 insertions(+) create mode 100644 cow/README.md create mode 100644 cow/output/01-iterator-snapshot-demo.txt create mode 100644 cow/output/02-synchronizedlist-cme-demo.txt create mode 100644 cow/output/03-tests.txt create mode 100644 cow/output/04-read-throughput-jmh.txt create mode 100644 cow/output/05-write-throughput-jmh.txt create mode 100644 cow/output/06-write-throughput-race.txt create mode 100644 cow/pom.xml create mode 100644 cow/scripts/run-all.sh create mode 100644 cow/scripts/run.sh create mode 100644 cow/src/main/java/com/ankurm/cow/IteratorSnapshotDemo.java create mode 100644 cow/src/main/java/com/ankurm/cow/ReadThroughputBenchmark.java create mode 100644 cow/src/main/java/com/ankurm/cow/SynchronizedListCmeDemo.java create mode 100644 cow/src/main/java/com/ankurm/cow/WriteThroughputBenchmark.java create mode 100644 cow/src/test/java/com/ankurm/cow/CowClaimsTest.java diff --git a/README.md b/README.md index 13ece09..1c654ef 100644 --- a/README.md +++ b/README.md @@ -23,6 +23,7 @@ article; each module's own README has that article's version table, quickstart, | [`list-benchmarks`](list-benchmarks/) | ArrayList vs LinkedList in 2026: JMH Benchmarks and Why LinkedList Rarely Wins | | [`sequenced`](sequenced/) | Sequenced Collections in Java 21+: getFirst, getLast and reversed() | | [`immutable`](immutable/) | Immutable Collections in Java: List.of vs unmodifiableList vs copyOf | +| [`cow`](cow/) | CopyOnWriteArrayList in Java: When It Wins (JMH) and When It Hurts | ## License diff --git a/cow/README.md b/cow/README.md new file mode 100644 index 0000000..5ca2f32 --- /dev/null +++ b/cow/README.md @@ -0,0 +1,48 @@ +# cow + +Companion code for the ankurm.com post *"CopyOnWriteArrayList in Java: When It Wins (JMH) and +When It Hurts."* Module in `java-core-examples`, the Java-core series. + +## Versions this was built and tested against + +| Component | Version | Notes | +|---|---|---| +| JDK | 25.0.4.1+1 (Temurin, LTS) | `CopyOnWriteArrayList` shipped in Java 5 (2004) as part of the original `java.util.concurrent`; unchanged in contract since, though it now also implements `SequencedCollection` (JEP 431, Java 21). | +| JMH | 1.37 | | +| JUnit Jupiter | 5.11.0 | | +| Maven | 3.9.11 | | + +## Quickstart + +```bash +export JAVA_HOME=/path/to/jdk-17-or-newer +mvn compile +java -cp target/classes com.ankurm.cow.IteratorSnapshotDemo +``` + +`scripts/run-all.sh` regenerates `output/01-05` (not `06`, see below). `scripts/run.sh ` +runs one demo ad hoc. The JMH benchmarks also run as an executable jar: `mvn package -DskipTests` +then `java -jar target/benchmarks.jar `. + +## What's in here + +| File | What it shows | +|---|---| +| `IteratorSnapshotDemo.java` | Forces the exact ordering with `CountDownLatch` barriers instead of `Thread.sleep` guesses: an iterator opened before a write never sees that write; one opened after does. Deterministic every run. | +| `SynchronizedListCmeDemo.java` | Reproduces `ConcurrentModificationException` on `Collections.synchronizedList` on purpose, deterministically, when a caller iterates without wrapping the loop in its own `synchronized` block - then shows the fix. | +| `ReadThroughputBenchmark.java` | JMH: 4 threads reading by index, `CopyOnWriteArrayList` vs `synchronizedList`, with and without a concurrent background writer. | +| `WriteThroughputBenchmark.java` | JMH: cost of an `addLast`/`removeFirst` pair at sizes 10 and 1000, with 1 writer and with 4 concurrent writers. | +| `CowClaimsTest.java` | Pins all of the above as assertions, 7/7 passing. | +| `output/01-05` | Captured runs of the two demos, the test suite, and both JMH benchmarks. | +| `output/06-write-throughput-race.txt` | See below - not something `run-all.sh` regenerates. | + +## Notes worth knowing before reading the post + +- **The internal lock is a plain `Object` monitor, not a `ReentrantLock`.** `javap -p java.util.concurrent.CopyOnWriteArrayList` on JDK 25 shows `final transient java.lang.Object lock;` - a lot of writing (including earlier drafts of this very post) describes it as "a global reentrant lock." That description matches older JDKs; verify against the jar you're actually running before repeating it. +- **`output/06-write-throughput-race.txt` is a deliberately preserved bug, not a regenerable artifact.** The first version of `WriteThroughputBenchmark` used `list.add(-1); list.remove(list.size() - 1);` - two separate calls, each individually thread-safe, but not atomic as a pair. Under 4 concurrent writer threads this raced and threw a real `ArrayIndexOutOfBoundsException` on `CopyOnWriteArrayList` and a real `IndexOutOfBoundsException` on `Collections.synchronizedList` - both captured verbatim in that file. The fix, `addLast()`/`removeFirst()` (JEP 431 default-turned-overridden methods, each a single call with no externally-fetched index), is what the committed benchmark actually runs. Left in deliberately: it's a more convincing demonstration of "thread-safe per-call does not mean safe as a sequence of calls" than a paragraph describing the same thing would be. +- **Read throughput numbers are dramatic, not subtle.** See the post for the real ratio - this is the headline number and it's larger than most people expect even having read "CopyOnWriteArrayList is for read-heavy workloads" a dozen times. +- **This sandbox is shared and multi-tenant.** Several rows in `output/05` carry an error margin wider than the mean. Read the shape (which variant is bigger, by roughly how much) rather than quoting any single ops/ms figure to three significant digits. + +## License + +MIT - see the [repo-wide LICENSE](../LICENSE). diff --git a/cow/output/01-iterator-snapshot-demo.txt b/cow/output/01-iterator-snapshot-demo.txt new file mode 100644 index 0000000..1d53a1d --- /dev/null +++ b/cow/output/01-iterator-snapshot-demo.txt @@ -0,0 +1,17 @@ +list before any iterator is taken = [Apple, Banana, Cherry] +beforeWrite.next() = Apple +[writer] added Date and Elderberry - list is now [Apple, Banana, Cherry, Date, Elderberry] + +=== beforeWrite iterator was created BEFORE the write - draining the rest of it === +beforeWrite.next() = Banana +beforeWrite.next() = Cherry +beforeWrite never saw Date or Elderberry, and threw no exception. + +=== afterWrite iterator is created AFTER the write - it sees the current array === +afterWrite.next() = Apple +afterWrite.next() = Banana +afterWrite.next() = Cherry +afterWrite.next() = Date +afterWrite.next() = Elderberry + +Final list = [Apple, Banana, Cherry, Date, Elderberry] diff --git a/cow/output/02-synchronizedlist-cme-demo.txt b/cow/output/02-synchronizedlist-cme-demo.txt new file mode 100644 index 0000000..3b1c4ca --- /dev/null +++ b/cow/output/02-synchronizedlist-cme-demo.txt @@ -0,0 +1,11 @@ +[reader] it.next() = Apple +[writer] syncList.add("Date") completed - list is now [Apple, Banana, Cherry, Date] +[reader] it.next() threw ConcurrentModificationException, as expected + +Exception actually thrown: java.util.ConcurrentModificationException + +=== The fix: synchronize the WHOLE iteration yourself, as the Javadoc instructs === +holding the lock for the whole iteration - starting safeWriter now +iterated 3 elements with no exception, because safeWriter +cannot acquire the same monitor until this synchronized block exits +after the block: syncList2 = [Apple, Banana, Cherry, Date] diff --git a/cow/output/03-tests.txt b/cow/output/03-tests.txt new file mode 100644 index 0000000..dd9e128 --- /dev/null +++ b/cow/output/03-tests.txt @@ -0,0 +1,4 @@ +------------------------------------------------------------------------------- +Test set: com.ankurm.cow.CowClaimsTest +------------------------------------------------------------------------------- +Tests run: 7, Failures: 0, Errors: 0, Skipped: 0, Time elapsed: 0.088 s -- in com.ankurm.cow.CowClaimsTest diff --git a/cow/output/04-read-throughput-jmh.txt b/cow/output/04-read-throughput-jmh.txt new file mode 100644 index 0000000..790e39a --- /dev/null +++ b/cow/output/04-read-throughput-jmh.txt @@ -0,0 +1,5 @@ +Benchmark (container) (writer) Mode Cnt Score Error Units +ReadThroughputBenchmark.readSum COW NONE thrpt 3 1890.457 ± 486.709 ops/ms +ReadThroughputBenchmark.readSum COW BACKGROUND thrpt 3 911.480 ± 70.801 ops/ms +ReadThroughputBenchmark.readSum SYNC NONE thrpt 3 10.311 ± 23.312 ops/ms +ReadThroughputBenchmark.readSum SYNC BACKGROUND thrpt 3 9.428 ± 12.790 ops/ms diff --git a/cow/output/05-write-throughput-jmh.txt b/cow/output/05-write-throughput-jmh.txt new file mode 100644 index 0000000..ea044ed --- /dev/null +++ b/cow/output/05-write-throughput-jmh.txt @@ -0,0 +1,9 @@ +Benchmark (container) (size) Mode Cnt Score Error Units +WriteThroughputBenchmark.addRemove_fourConcurrentWriters COW 10 thrpt 3 2442.460 ± 10099.500 ops/ms +WriteThroughputBenchmark.addRemove_fourConcurrentWriters COW 1000 thrpt 3 404.113 ± 92.677 ops/ms +WriteThroughputBenchmark.addRemove_fourConcurrentWriters SYNC 10 thrpt 3 4256.370 ± 11472.184 ops/ms +WriteThroughputBenchmark.addRemove_fourConcurrentWriters SYNC 1000 thrpt 3 2431.519 ± 10671.130 ops/ms +WriteThroughputBenchmark.addRemove_oneWriter COW 10 thrpt 3 8590.710 ± 3673.510 ops/ms +WriteThroughputBenchmark.addRemove_oneWriter COW 1000 thrpt 3 692.991 ± 1173.810 ops/ms +WriteThroughputBenchmark.addRemove_oneWriter SYNC 10 thrpt 3 17149.052 ± 26734.246 ops/ms +WriteThroughputBenchmark.addRemove_oneWriter SYNC 1000 thrpt 3 11819.476 ± 794.382 ops/ms diff --git a/cow/output/06-write-throughput-race.txt b/cow/output/06-write-throughput-race.txt new file mode 100644 index 0000000..7f2f134 --- /dev/null +++ b/cow/output/06-write-throughput-race.txt @@ -0,0 +1,31 @@ +# Warmup Iteration 1: + +java.lang.ArrayIndexOutOfBoundsException: Index 12 out of bounds for length 12 + at java.base/java.util.concurrent.CopyOnWriteArrayList.elementAt(CopyOnWriteArrayList.java:397) + at java.base/java.util.concurrent.CopyOnWriteArrayList.remove(CopyOnWriteArrayList.java:538) + at com.ankurm.cow.WriteThroughputBenchmark.addRemove_fourConcurrentWriters(WriteThroughputBenchmark.java:68) + at com.ankurm.cow.jmh_generated.WriteThroughputBenchmark_addRemove_fourConcurrentWriters_jmhTest.addRemove_fourConcurrentWriters_thrpt_jmhStub(WriteThroughputBenchmark_addRemove_fourConcurrentWriters_jmhTest.java:147) + at com.ankurm.cow.jmh_generated.WriteThroughputBenchmark_addRemove_fourConcurrentWriters_jmhTest.addRemove_fourConcurrentWriters_Throughput(WriteThroughputBenchmark_addRemove_fourConcurrentWriters_jmhTest.java:86) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.openjdk.jmh.runner.BenchmarkHandler$BenchmarkTask.call(BenchmarkHandler.java:527) + at org.openjdk.jmh.runner.BenchmarkHandler$BenchmarkTask.call(BenchmarkHandler.java:504) + at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:328) + at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:545) + at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:328) + at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1090) +java.lang.IndexOutOfBoundsException: Index 1002 out of bounds for length 1002 + at java.base/jdk.internal.util.Preconditions.outOfBounds(Preconditions.java:100) + at java.base/jdk.internal.util.Preconditions.outOfBoundsCheckIndex(Preconditions.java:106) + at java.base/jdk.internal.util.Preconditions.checkIndex(Preconditions.java:302) + at java.base/java.util.Objects.checkIndex(Objects.java:365) + at java.base/java.util.ArrayList.remove(ArrayList.java:552) + at java.base/java.util.Collections$SynchronizedList.remove(Collections.java:2753) + at com.ankurm.cow.WriteThroughputBenchmark.addRemove_fourConcurrentWriters(WriteThroughputBenchmark.java:68) + at com.ankurm.cow.jmh_generated.WriteThroughputBenchmark_addRemove_fourConcurrentWriters_jmhTest.addRemove_fourConcurrentWriters_thrpt_jmhStub(WriteThroughputBenchmark_addRemove_fourConcurrentWriters_jmhTest.java:147) + at com.ankurm.cow.jmh_generated.WriteThroughputBenchmark_addRemove_fourConcurrentWriters_jmhTest.addRemove_fourConcurrentWriters_Throughput(WriteThroughputBenchmark_addRemove_fourConcurrentWriters_jmhTest.java:86) + at java.base/jdk.internal.reflect.DirectMethodHandleAccessor.invoke(DirectMethodHandleAccessor.java:104) + at java.base/java.lang.reflect.Method.invoke(Method.java:565) + at org.openjdk.jmh.runner.BenchmarkHandler$BenchmarkTask.call(BenchmarkHandler.java:527) + at org.openjdk.jmh.runner.BenchmarkHandler$BenchmarkTask.call(BenchmarkHandler.java:504) + at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:328) diff --git a/cow/pom.xml b/cow/pom.xml new file mode 100644 index 0000000..71d78b1 --- /dev/null +++ b/cow/pom.xml @@ -0,0 +1,83 @@ + + + 4.0.0 + + + com.ankurm + java-core-examples + 1.0 + + + cow + cow + CopyOnWriteArrayList: when it wins (JMH read/write-ratio benchmarks), a deterministic iterator-snapshot demo, and a real ConcurrentModificationException reproduction on Collections.synchronizedList. + + + 1.37 + + + + + org.openjdk.jmh + jmh-core + ${jmh.version} + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + + + org.junit.jupiter + junit-jupiter + 5.11.0 + test + + + + + benchmarks + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + 25 + + + org.openjdk.jmh + jmh-generator-annprocess + ${jmh.version} + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.2.5 + + + org.apache.maven.plugins + maven-shade-plugin + 3.5.1 + + + package + shade + + + + org.openjdk.jmh.Main + + + + + + + + + diff --git a/cow/scripts/run-all.sh b/cow/scripts/run-all.sh new file mode 100644 index 0000000..1f804f0 --- /dev/null +++ b/cow/scripts/run-all.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail +cd "$(dirname "$0")/.." + +mvn -q -B compile + +java -cp target/classes com.ankurm.cow.IteratorSnapshotDemo 2>&1 \ + | grep -vE 'JAVA_TOOL_OPTIONS|^WARNING' > output/01-iterator-snapshot-demo.txt +java -cp target/classes com.ankurm.cow.SynchronizedListCmeDemo 2>&1 \ + | grep -vE 'JAVA_TOOL_OPTIONS|^WARNING' > output/02-synchronizedlist-cme-demo.txt + +mvn -q -B test +cp target/surefire-reports/com.ankurm.cow.CowClaimsTest.txt output/03-tests.txt + +mvn -q -B package -DskipTests +java -jar target/benchmarks.jar ReadThroughputBenchmark -rf text -rff output/04-read-throughput-jmh.txt +java -jar target/benchmarks.jar WriteThroughputBenchmark -rf text -rff output/05-write-throughput-jmh.txt + +echo "Regenerated output/01-05. output/06-write-throughput-race.txt is a one-off captured failure" +echo "from an earlier, deliberately buggy version of the write benchmark - see the README - and" +echo "is NOT regenerated by this script." diff --git a/cow/scripts/run.sh b/cow/scripts/run.sh new file mode 100644 index 0000000..642cfd3 --- /dev/null +++ b/cow/scripts/run.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Run one of the demo classes ad hoc, e.g.: ./scripts/run.sh IteratorSnapshotDemo +set -euo pipefail +cd "$(dirname "$0")/.." +mvn -q -B compile +java -cp target/classes "com.ankurm.cow.$1" diff --git a/cow/src/main/java/com/ankurm/cow/IteratorSnapshotDemo.java b/cow/src/main/java/com/ankurm/cow/IteratorSnapshotDemo.java new file mode 100644 index 0000000..0e14b09 --- /dev/null +++ b/cow/src/main/java/com/ankurm/cow/IteratorSnapshotDemo.java @@ -0,0 +1,70 @@ +package com.ankurm.cow; + +import java.util.Iterator; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; + +/** + * The claim every CopyOnWriteArrayList article makes - "an iterator sees a snapshot of the list + * as it was when the iterator was created" - deserves a demo that doesn't depend on {@code + * Thread.sleep} timing guesses to actually prove it. This one forces the exact ordering with + * {@link CountDownLatch} barriers: an iterator is opened, a write happens from another thread + * while the first iterator is still mid-walk, and a second iterator is opened only after that + * write completes. The first iterator must still only see the pre-write elements; the second + * must see all of them; and the test suite ({@code IteratorSnapshotTest}) pins every number here + * as an assertion. + */ +public final class IteratorSnapshotDemo { + + private IteratorSnapshotDemo() {} + + public static void main(String[] args) throws InterruptedException { + CopyOnWriteArrayList list = new CopyOnWriteArrayList<>(); + list.add("Apple"); + list.add("Banana"); + list.add("Cherry"); + + System.out.println("list before any iterator is taken = " + list); + + Iterator beforeWrite = list.iterator(); // snapshot taken HERE, right now + System.out.println("beforeWrite.next() = " + beforeWrite.next()); // consumes "Apple" + + CountDownLatch readerReady = new CountDownLatch(1); + CountDownLatch writeDone = new CountDownLatch(1); + + Thread writer = new Thread(() -> { + try { + readerReady.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + list.add("Date"); + list.add("Elderberry"); + System.out.println("[writer] added Date and Elderberry - list is now " + list); + writeDone.countDown(); + }, "writer"); + writer.start(); + + readerReady.countDown(); + writeDone.await(); + writer.join(); + + System.out.println(); + System.out.println("=== beforeWrite iterator was created BEFORE the write - draining the rest of it ==="); + while (beforeWrite.hasNext()) { + System.out.println("beforeWrite.next() = " + beforeWrite.next()); + } + System.out.println("beforeWrite never saw Date or Elderberry, and threw no exception."); + + System.out.println(); + System.out.println("=== afterWrite iterator is created AFTER the write - it sees the current array ==="); + Iterator afterWrite = list.iterator(); + while (afterWrite.hasNext()) { + System.out.println("afterWrite.next() = " + afterWrite.next()); + } + + System.out.println(); + System.out.println("Final list = " + list); + } +} diff --git a/cow/src/main/java/com/ankurm/cow/ReadThroughputBenchmark.java b/cow/src/main/java/com/ankurm/cow/ReadThroughputBenchmark.java new file mode 100644 index 0000000..82869c6 --- /dev/null +++ b/cow/src/main/java/com/ankurm/cow/ReadThroughputBenchmark.java @@ -0,0 +1,101 @@ +package com.ankurm.cow; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.TearDown; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicBoolean; + +/** + * The read side of the "read/write ratio" question: does reading through {@code + * CopyOnWriteArrayList} or {@code Collections.synchronizedList} cost more when four threads are + * reading at once, and does a concurrent background writer change that answer? Four reader + * threads read the list by index and sum it; {@code writer=BACKGROUND} runs a fifth thread + * continuously mutating the same list with no delay between writes, for the whole measurement + * window. Reading is done with {@code get(index)}, not an {@code Iterator}, deliberately - a + * {@code synchronizedList} iterator races with a concurrent structural change and throws + * {@code ConcurrentModificationException} (reproduced deterministically in {@link + * SynchronizedListCmeDemo}), which would make the SYNC/BACKGROUND combination fail to complete + * rather than measure anything. {@code get(index)} never has that problem on either + * implementation, so it isolates the cost this benchmark is actually about. + */ +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +@Warmup(iterations = 2, time = 1) +@Measurement(iterations = 3, time = 1) +@Fork(1) +@Threads(4) +public class ReadThroughputBenchmark { + + @State(Scope.Benchmark) + public static class ListState { + @Param({"COW", "SYNC"}) + String container; + + @Param({"NONE", "BACKGROUND"}) + String writer; + + List list; + Thread writerThread; + AtomicBoolean stop; + + @Setup(Level.Trial) + public void setup() { + List seed = new ArrayList<>(); + for (int i = 0; i < 1000; i++) seed.add(i); + + list = "COW".equals(container) + ? new CopyOnWriteArrayList<>(seed) + : Collections.synchronizedList(new ArrayList<>(seed)); + + stop = new AtomicBoolean(false); + if ("BACKGROUND".equals(writer)) { + writerThread = new Thread(() -> { + int i = 0; + while (!stop.get()) { + // add-then-remove, in THIS order, so size only ever grows then shrinks + // back - it never dips below 1000, so concurrent get(i) for i < 1000 + // on readers below never races an out-of-bounds index. + list.add(0, i); + list.remove(list.size() - 1); + i++; + } + }, "bg-writer"); + writerThread.setDaemon(true); + writerThread.start(); + } + } + + @TearDown(Level.Trial) + public void tearDown() throws InterruptedException { + stop.set(true); + if (writerThread != null) writerThread.join(2000); + } + } + + @Benchmark + public long readSum(ListState s) { + long sum = 0; + int n = 1000; // the writer thread never lets size drop below this + for (int i = 0; i < n; i++) { + sum += s.list.get(i); + } + return sum; + } +} diff --git a/cow/src/main/java/com/ankurm/cow/SynchronizedListCmeDemo.java b/cow/src/main/java/com/ankurm/cow/SynchronizedListCmeDemo.java new file mode 100644 index 0000000..8d765cf --- /dev/null +++ b/cow/src/main/java/com/ankurm/cow/SynchronizedListCmeDemo.java @@ -0,0 +1,89 @@ +package com.ankurm.cow; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.atomic.AtomicReference; + +/** + * {@code Collections.synchronizedList} synchronizes each individual method call, but an + * iteration is a sequence of separate {@code hasNext()}/{@code next()} calls - nothing stops + * another thread's {@code add()} from landing in between two of them unless the caller wraps the + * whole iteration in its own {@code synchronized (list) { ... }} block, exactly as the class's + * own Javadoc warns. This demo forces that exact race with latches instead of hoping for it, so + * the {@code ConcurrentModificationException} shows up every run rather than most runs. + */ +public final class SynchronizedListCmeDemo { + + private SynchronizedListCmeDemo() {} + + public static void main(String[] args) throws InterruptedException { + List syncList = Collections.synchronizedList(new ArrayList<>(List.of("Apple", "Banana", "Cherry"))); + + CountDownLatch readerTookFirstElement = new CountDownLatch(1); + CountDownLatch writerAdded = new CountDownLatch(1); + AtomicReference caught = new AtomicReference<>(); + + Thread writer = new Thread(() -> { + try { + readerTookFirstElement.await(); + syncList.add("Date"); // structural modification while the reader is mid-iteration + System.out.println("[writer] syncList.add(\"Date\") completed - list is now " + syncList); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } finally { + writerAdded.countDown(); + } + }, "writer"); + + Thread reader = new Thread(() -> { + // Deliberately NOT wrapped in synchronized(syncList) { ... } - this is the mistake the demo reproduces. + Iterator it = syncList.iterator(); + System.out.println("[reader] it.next() = " + it.next()); // "Apple" - fine, no structural change yet + readerTookFirstElement.countDown(); + try { + writerAdded.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + return; + } + try { + it.next(); // the writer's add() already happened - this call must throw + System.out.println("[reader] it.next() returned without throwing (unexpected)"); + } catch (java.util.ConcurrentModificationException e) { + caught.set(e); + System.out.println("[reader] it.next() threw ConcurrentModificationException, as expected"); + } + }, "reader"); + + reader.start(); + writer.start(); + reader.join(); + writer.join(); + + System.out.println(); + System.out.println("Exception actually thrown: " + + (caught.get() != null ? caught.get().getClass().getName() : "none")); + + System.out.println(); + System.out.println("=== The fix: synchronize the WHOLE iteration yourself, as the Javadoc instructs ==="); + List syncList2 = Collections.synchronizedList(new ArrayList<>(List.of("Apple", "Banana", "Cherry"))); + Thread safeWriter = new Thread(() -> syncList2.add("Date"), "safe-writer"); + synchronized (syncList2) { + Iterator it = syncList2.iterator(); + System.out.println("holding the lock for the whole iteration - starting safeWriter now"); + safeWriter.start(); + int count = 0; + while (it.hasNext()) { + it.next(); + count++; + } + System.out.println("iterated " + count + " elements with no exception, because safeWriter"); + System.out.println("cannot acquire the same monitor until this synchronized block exits"); + } + safeWriter.join(); + System.out.println("after the block: syncList2 = " + syncList2); + } +} diff --git a/cow/src/main/java/com/ankurm/cow/WriteThroughputBenchmark.java b/cow/src/main/java/com/ankurm/cow/WriteThroughputBenchmark.java new file mode 100644 index 0000000..7c4e033 --- /dev/null +++ b/cow/src/main/java/com/ankurm/cow/WriteThroughputBenchmark.java @@ -0,0 +1,75 @@ +package com.ankurm.cow; + +import org.openjdk.jmh.annotations.Benchmark; +import org.openjdk.jmh.annotations.BenchmarkMode; +import org.openjdk.jmh.annotations.Fork; +import org.openjdk.jmh.annotations.Level; +import org.openjdk.jmh.annotations.Measurement; +import org.openjdk.jmh.annotations.Mode; +import org.openjdk.jmh.annotations.OutputTimeUnit; +import org.openjdk.jmh.annotations.Param; +import org.openjdk.jmh.annotations.Scope; +import org.openjdk.jmh.annotations.Setup; +import org.openjdk.jmh.annotations.State; +import org.openjdk.jmh.annotations.Threads; +import org.openjdk.jmh.annotations.Warmup; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; + +/** + * The write side: the cost of a single add-then-remove pair (chosen so the list's size, and + * therefore the cost of every subsequent write, stays constant across the whole measurement), + * at two sizes and two thread counts. {@code CopyOnWriteArrayList}'s write cost is expected to + * scale with list size, since every write copies the entire backing array; {@code + * synchronizedList}'s write cost should not. + */ +@BenchmarkMode(Mode.Throughput) +@OutputTimeUnit(TimeUnit.MILLISECONDS) +@Warmup(iterations = 2, time = 1) +@Measurement(iterations = 3, time = 1) +@Fork(1) +public class WriteThroughputBenchmark { + + @State(Scope.Benchmark) + public static class ListState { + @Param({"COW", "SYNC"}) + String container; + + @Param({"10", "1000"}) + int size; + + List list; + + @Setup(Level.Trial) + public void setup() { + List seed = new ArrayList<>(); + for (int i = 0; i < size; i++) seed.add(i); + list = "COW".equals(container) + ? new CopyOnWriteArrayList<>(seed) + : Collections.synchronizedList(new ArrayList<>(seed)); + } + } + + @Benchmark + @Threads(1) + public void addRemove_oneWriter(ListState s) { + s.list.addLast(-1); + s.list.removeFirst(); + } + + @Benchmark + @Threads(4) + public void addRemove_fourConcurrentWriters(ListState s) { + // addLast()/removeFirst() (JEP 431 default methods) instead of add(-1); remove(size()-1): + // the latter reads size() and removes by that index as two separate calls, which races + // under concurrent writers - a real IndexOutOfBoundsException from exactly that race is + // in this module's output/06-write-throughput-race.txt. addLast/removeFirst are each a + // single call with no externally-fetched index, so they stay correct under contention. + s.list.addLast(-1); + s.list.removeFirst(); + } +} diff --git a/cow/src/test/java/com/ankurm/cow/CowClaimsTest.java b/cow/src/test/java/com/ankurm/cow/CowClaimsTest.java new file mode 100644 index 0000000..8fd710a --- /dev/null +++ b/cow/src/test/java/com/ankurm/cow/CowClaimsTest.java @@ -0,0 +1,137 @@ +package com.ankurm.cow; + +import org.junit.jupiter.api.Test; + +import java.util.ArrayList; +import java.util.Collections; +import java.util.Iterator; +import java.util.List; +import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.CountDownLatch; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class CowClaimsTest { + + @Test + void iteratorTakenBeforeAWriteNeverSeesThatWrite() { + CopyOnWriteArrayList list = new CopyOnWriteArrayList<>(List.of("a", "b")); + Iterator it = list.iterator(); + list.add("c"); // write happens after the iterator snapshot was taken + List seen = new ArrayList<>(); + while (it.hasNext()) seen.add(it.next()); + assertEquals(List.of("a", "b"), seen, "the iterator must not see a write that happened after it was created"); + assertEquals(List.of("a", "b", "c"), list, "but the list itself must reflect the write"); + } + + @Test + void iteratorTakenAfterAWriteSeesIt() { + CopyOnWriteArrayList list = new CopyOnWriteArrayList<>(List.of("a", "b")); + list.add("c"); + Iterator it = list.iterator(); + List seen = new ArrayList<>(); + while (it.hasNext()) seen.add(it.next()); + assertEquals(List.of("a", "b", "c"), seen); + } + + @Test + void copyOnWriteIteratorNeverThrowsConcurrentModificationEvenUnderConcurrentWrites() throws InterruptedException { + CopyOnWriteArrayList list = new CopyOnWriteArrayList<>(List.of(1, 2, 3)); + CountDownLatch go = new CountDownLatch(1); + Thread writer = new Thread(() -> { + try { + go.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + for (int i = 0; i < 1000; i++) list.add(i); + }); + writer.start(); + go.countDown(); + assertFalse(throwsCme(() -> { + Iterator it = list.iterator(); + while (it.hasNext()) it.next(); + }), "CopyOnWriteArrayList's iterator must never throw ConcurrentModificationException"); + writer.join(); + } + + @Test + void synchronizedListIteratorThrowsCmeOnConcurrentStructuralChange() throws InterruptedException { + List list = Collections.synchronizedList(new ArrayList<>(List.of("a", "b", "c"))); + CountDownLatch readerTookOne = new CountDownLatch(1); + CountDownLatch writerDone = new CountDownLatch(1); + + Thread writer = new Thread(() -> { + try { + readerTookOne.await(); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + } + list.add("d"); + writerDone.countDown(); + }); + writer.start(); + + Iterator it = list.iterator(); + it.next(); // consumes "a" + readerTookOne.countDown(); + writerDone.await(); + + assertThrows(java.util.ConcurrentModificationException.class, it::next, + "iterating a synchronizedList without external synchronization must throw when another thread structurally modifies it mid-iteration"); + writer.join(); + } + + @Test + void synchronizingTheWholeIterationOnTheListPreventsTheSameCme() throws InterruptedException { + List list = Collections.synchronizedList(new ArrayList<>(List.of("a", "b", "c"))); + Thread writer = new Thread(() -> list.add("d")); + int count; + synchronized (list) { + writer.start(); + Iterator it = list.iterator(); + int c = 0; + while (it.hasNext()) { + it.next(); + c++; + } + count = c; + } + writer.join(); + assertEquals(3, count, "the writer could not run until the synchronized block released the lock"); + assertEquals(4, list.size()); + } + + @Test + void copyOnWriteArrayListAllowsDuplicatesAndPreservesInsertionOrder() { + CopyOnWriteArrayList list = new CopyOnWriteArrayList<>(); + list.add("x"); + list.add("y"); + list.add("x"); + assertEquals(List.of("x", "y", "x"), list); + } + + @Test + void copyOnWriteArrayListImplementsSequencedCollectionLikeAnyOtherList() { + CopyOnWriteArrayList list = new CopyOnWriteArrayList<>(List.of(1, 2, 3)); + assertTrue(list instanceof java.util.SequencedCollection); + assertEquals(3, list.getLast()); + assertEquals(List.of(3, 2, 1), list.reversed()); + } + + private interface ThrowingRunnable { + void run(); + } + + private static boolean throwsCme(ThrowingRunnable r) { + try { + r.run(); + return false; + } catch (java.util.ConcurrentModificationException e) { + return true; + } + } +} diff --git a/pom.xml b/pom.xml index 1bef3c1..e03aab7 100644 --- a/pom.xml +++ b/pom.xml @@ -31,6 +31,7 @@ list-benchmarks sequenced immutable + cow