cow: CopyOnWriteArrayList - deterministic iterator-snapshot and synchronizedList CME demos, JMH read/write throughput, and the addLast/removeFirst fix for a real concurrent-writer race

This commit is contained in:
Claude
2026-10-01 12:09:44 +00:00
parent 2eaa464bad
commit 434054f246
17 changed files with 709 additions and 0 deletions
+48
View File
@@ -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 <ClassName>`
runs one demo ad hoc. The JMH benchmarks also run as an executable jar: `mvn package -DskipTests`
then `java -jar target/benchmarks.jar <BenchmarkName>`.
## 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).
+17
View File
@@ -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]
@@ -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]
+4
View File
@@ -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
+5
View File
@@ -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
+9
View File
@@ -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
+31
View File
@@ -0,0 +1,31 @@
# Warmup Iteration 1: <failure>
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)
+83
View File
@@ -0,0 +1,83 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>com.ankurm</groupId>
<artifactId>java-core-examples</artifactId>
<version>1.0</version>
</parent>
<artifactId>cow</artifactId>
<name>cow</name>
<description>CopyOnWriteArrayList: when it wins (JMH read/write-ratio benchmarks), a deterministic iterator-snapshot demo, and a real ConcurrentModificationException reproduction on Collections.synchronizedList.</description>
<properties>
<jmh.version>1.37</jmh.version>
</properties>
<dependencies>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>${jmh.version}</version>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.11.0</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>benchmarks</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<release>25</release>
<annotationProcessorPaths>
<path>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>${jmh.version}</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.2.5</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.5.1</version>
<executions>
<execution>
<phase>package</phase>
<goals><goal>shade</goal></goals>
<configuration>
<transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>org.openjdk.jmh.Main</mainClass>
</transformer>
</transformers>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
+21
View File
@@ -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."
+6
View File
@@ -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"
@@ -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<String> list = new CopyOnWriteArrayList<>();
list.add("Apple");
list.add("Banana");
list.add("Cherry");
System.out.println("list before any iterator is taken = " + list);
Iterator<String> 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<String> afterWrite = list.iterator();
while (afterWrite.hasNext()) {
System.out.println("afterWrite.next() = " + afterWrite.next());
}
System.out.println();
System.out.println("Final list = " + list);
}
}
@@ -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<Integer> list;
Thread writerThread;
AtomicBoolean stop;
@Setup(Level.Trial)
public void setup() {
List<Integer> 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;
}
}
@@ -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<String> syncList = Collections.synchronizedList(new ArrayList<>(List.of("Apple", "Banana", "Cherry")));
CountDownLatch readerTookFirstElement = new CountDownLatch(1);
CountDownLatch writerAdded = new CountDownLatch(1);
AtomicReference<Exception> 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<String> 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<String> syncList2 = Collections.synchronizedList(new ArrayList<>(List.of("Apple", "Banana", "Cherry")));
Thread safeWriter = new Thread(() -> syncList2.add("Date"), "safe-writer");
synchronized (syncList2) {
Iterator<String> 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);
}
}
@@ -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<Integer> list;
@Setup(Level.Trial)
public void setup() {
List<Integer> 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();
}
}
@@ -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<String> list = new CopyOnWriteArrayList<>(List.of("a", "b"));
Iterator<String> it = list.iterator();
list.add("c"); // write happens after the iterator snapshot was taken
List<String> 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<String> list = new CopyOnWriteArrayList<>(List.of("a", "b"));
list.add("c");
Iterator<String> it = list.iterator();
List<String> seen = new ArrayList<>();
while (it.hasNext()) seen.add(it.next());
assertEquals(List.of("a", "b", "c"), seen);
}
@Test
void copyOnWriteIteratorNeverThrowsConcurrentModificationEvenUnderConcurrentWrites() throws InterruptedException {
CopyOnWriteArrayList<Integer> 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<Integer> it = list.iterator();
while (it.hasNext()) it.next();
}), "CopyOnWriteArrayList's iterator must never throw ConcurrentModificationException");
writer.join();
}
@Test
void synchronizedListIteratorThrowsCmeOnConcurrentStructuralChange() throws InterruptedException {
List<String> 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<String> 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<String> list = Collections.synchronizedList(new ArrayList<>(List.of("a", "b", "c")));
Thread writer = new Thread(() -> list.add("d"));
int count;
synchronized (list) {
writer.start();
Iterator<String> 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<String> list = new CopyOnWriteArrayList<>();
list.add("x");
list.add("y");
list.add("x");
assertEquals(List.of("x", "y", "x"), list);
}
@Test
void copyOnWriteArrayListImplementsSequencedCollectionLikeAnyOtherList() {
CopyOnWriteArrayList<Integer> 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;
}
}
}