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:
@@ -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 |
|
| [`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() |
|
| [`sequenced`](sequenced/) | Sequenced Collections in Java 21+: getFirst, getLast and reversed() |
|
||||||
| [`immutable`](immutable/) | Immutable Collections in Java: List.of vs unmodifiableList vs copyOf |
|
| [`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
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -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).
|
||||||
@@ -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]
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
@@ -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>
|
||||||
@@ -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."
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user