From f80e1079a890a0c072919763022cec5fa652377f Mon Sep 17 00:00:00 2001 From: asmhatre Date: Wed, 28 Oct 2026 18:07:37 +0000 Subject: [PATCH] Add hashmap-vs-concurrenthashmap module: JDK 25 sizeCtl/treeify internals, forced and measured lost updates Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_017ic4b7rnC7jppGGdHGESvy --- README.md | 1 + hashmap-vs-concurrenthashmap/README.md | 55 +++++++ .../output/01-jdk-constants.txt | 29 ++++ .../output/02-sizectl-lifecycle.txt | 16 ++ .../output/03-treeify-compare.txt | 16 ++ .../output/04-forced-lost-update.txt | 9 ++ .../output/05-measured-lost-updates.txt | 22 +++ .../output/06-recursive-update.txt | 7 + .../output/07-null-handling.txt | 9 ++ .../output/08-atomic-updates-exact.txt | 7 + hashmap-vs-concurrenthashmap/pom.xml | 63 ++++++++ .../scripts/run-all.sh | 15 ++ .../hashmapvschm/CollidingKey.java | 16 ++ .../interviewlab/hashmapvschm/Internals.java | 71 +++++++++ .../hashmapvschm/LostUpdateDemo.java | 141 ++++++++++++++++++ .../hashmapvschm/RecursiveUpdateDemo.java | 45 ++++++ .../hashmapvschm/AtomicUpdatesTest.java | 56 +++++++ .../hashmapvschm/ForcedInterleavingTest.java | 66 ++++++++ .../hashmapvschm/Jdk25ConstantsTest.java | 49 ++++++ .../hashmapvschm/NullHandlingTest.java | 49 ++++++ .../hashmapvschm/RecursiveUpdateTest.java | 43 ++++++ .../hashmapvschm/SizeCtlTest.java | 74 +++++++++ .../hashmapvschm/TreeifyCompareTest.java | 56 +++++++ .../hashmapvschm/support/Transcript.java | 43 ++++++ 24 files changed, 958 insertions(+) create mode 100644 hashmap-vs-concurrenthashmap/README.md create mode 100644 hashmap-vs-concurrenthashmap/output/01-jdk-constants.txt create mode 100644 hashmap-vs-concurrenthashmap/output/02-sizectl-lifecycle.txt create mode 100644 hashmap-vs-concurrenthashmap/output/03-treeify-compare.txt create mode 100644 hashmap-vs-concurrenthashmap/output/04-forced-lost-update.txt create mode 100644 hashmap-vs-concurrenthashmap/output/05-measured-lost-updates.txt create mode 100644 hashmap-vs-concurrenthashmap/output/06-recursive-update.txt create mode 100644 hashmap-vs-concurrenthashmap/output/07-null-handling.txt create mode 100644 hashmap-vs-concurrenthashmap/output/08-atomic-updates-exact.txt create mode 100644 hashmap-vs-concurrenthashmap/pom.xml create mode 100755 hashmap-vs-concurrenthashmap/scripts/run-all.sh create mode 100644 hashmap-vs-concurrenthashmap/src/main/java/com/ankurm/interviewlab/hashmapvschm/CollidingKey.java create mode 100644 hashmap-vs-concurrenthashmap/src/main/java/com/ankurm/interviewlab/hashmapvschm/Internals.java create mode 100644 hashmap-vs-concurrenthashmap/src/main/java/com/ankurm/interviewlab/hashmapvschm/LostUpdateDemo.java create mode 100644 hashmap-vs-concurrenthashmap/src/main/java/com/ankurm/interviewlab/hashmapvschm/RecursiveUpdateDemo.java create mode 100644 hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/AtomicUpdatesTest.java create mode 100644 hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/ForcedInterleavingTest.java create mode 100644 hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/Jdk25ConstantsTest.java create mode 100644 hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/NullHandlingTest.java create mode 100644 hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/RecursiveUpdateTest.java create mode 100644 hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/SizeCtlTest.java create mode 100644 hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/TreeifyCompareTest.java create mode 100644 hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/support/Transcript.java diff --git a/README.md b/README.md index 3ff8a58..261b82c 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,7 @@ One module per article. This repository currently holds: | [`lld-parking-lot`](lld-parking-lot/) | Low-Level Design Interview in Java: Parking Lot System, Step by Step | published | | [`hibernate-jpa`](hibernate-jpa/) | Top 40 Hibernate and JPA Interview Questions (2026) with Runnable Answers | this commit | | [`junit-mockito`](junit-mockito/) | Top 30 JUnit 6 and Mockito Interview Questions (2026) | this commit | +| [`hashmap-vs-concurrenthashmap`](hashmap-vs-concurrenthashmap/) | HashMap vs ConcurrentHashMap in Java: Internals, Thread Safety and Interview Answers | this commit | ## `collections` module diff --git a/hashmap-vs-concurrenthashmap/README.md b/hashmap-vs-concurrenthashmap/README.md new file mode 100644 index 0000000..9267125 --- /dev/null +++ b/hashmap-vs-concurrenthashmap/README.md @@ -0,0 +1,55 @@ +# hashmap-vs-concurrenthashmap + +Runnable companion for the ankurm.com article +[HashMap vs ConcurrentHashMap in Java: Internals, Thread Safety and Interview Answers](https://ankurm.com/java-hashmap-vs-concurrenthashmap-complete-interview-guide/). + +Everything the article quotes about JDK internals was read out of the running JDK (reflection) and +cross-checked against the `java.util.HashMap` and `java.util.concurrent.ConcurrentHashMap` source in +the JDK's own `lib/src.zip`. Nothing is quoted from memory. + +## Versions + +| Component | Version | Verified against | +|---|---|---| +| JDK | 25.0.5+8 (Temurin, LTS) | `java -version`; source from this JDK's `lib/src.zip` | +| JUnit | 6.1.3 | `maven-metadata.xml` on Maven Central (latest GA at time of writing) | +| Maven | 3.9.11 | `mvn -version` | + +## Quickstart + +```bash +export JAVA_HOME=/path/to/jdk-25 && export PATH="$JAVA_HOME/bin:$PATH" +cd hashmap-vs-concurrenthashmap +mvn test # 7 tests, deterministic; rewrites output/01-04 and 06-08 +./scripts/run-all.sh # also regenerates output/05 (the measured, run-to-run-varying one) +``` + +First Maven run must be online. The tests reflect into JDK-internal fields, so surefire is configured +with `--add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.util.concurrent=ALL-UNNAMED` +(see `pom.xml`). This is a learning aid; never reflect into JDK internals in production code. + +## Captured output (`output/`) + +| File | Written by | Deterministic? | +|---|---|---| +| `01-jdk-constants.txt` | `Jdk25ConstantsTest` | yes: TREEIFY_THRESHOLD, MIN_TREEIFY_CAPACITY, MOVED, TREEBIN ... | +| `02-sizectl-lifecycle.txt` | `SizeCtlTest` | yes: `sizeCtl` / `threshold` / `baseCount` through a map's life | +| `03-treeify-compare.txt` | `TreeifyCompareTest` | yes: 12 colliding keys, table length and bin type per insert | +| `04-forced-lost-update.txt` | `ForcedInterleavingTest` | yes: lost update with the bad timing forced by a latch | +| `05-measured-lost-updates.txt` | `LostUpdateDemo` (via `run-all.sh`) | **no**: counts differ on every run and machine; no test asserts them | +| `06-recursive-update.txt` | `RecursiveUpdateTest` | yes: `computeIfAbsent` recursion outcomes | +| `07-null-handling.txt` | `NullHandlingTest` | yes | +| `08-atomic-updates-exact.txt` | `AtomicUpdatesTest` | yes: `merge`/`compute`/locked total is exact every run | + +## Source + +| File | Purpose | +|---|---| +| `Internals.java` | reflective readers: constants, `sizeCtl`, `table`, `baseCount`, `threshold`, bin class | +| `CollidingKey.java` | key with a caller-chosen `hashCode()` to force one bin | +| `LostUpdateDemo.java` | 8 threads x 200,000 increments of one key, 5 rounds, six variants | +| `RecursiveUpdateDemo.java` | `computeIfAbsent` re-entering its own map | + +The older benchmark and null-handling demos the article also cites live in +[java-core-examples/hashmap-concurrenthashmap](https://ankurm.com/git.app/asmhatre/java-core-examples/src/branch/main/hashmap-concurrenthashmap). +For fail-fast iterators and bucket layout see the sibling [`collections`](../collections/) module. diff --git a/hashmap-vs-concurrenthashmap/output/01-jdk-constants.txt b/hashmap-vs-concurrenthashmap/output/01-jdk-constants.txt new file mode 100644 index 0000000..6fbb724 --- /dev/null +++ b/hashmap-vs-concurrenthashmap/output/01-jdk-constants.txt @@ -0,0 +1,29 @@ +Constants read by reflection from the running JDK's own HashMap and ConcurrentHashMap +===================================================================================== + +runtime: 25.0.5+8-LTS (Eclipse Adoptium) + +java.util.HashMap + DEFAULT_INITIAL_CAPACITY = 16 + MAXIMUM_CAPACITY = 1073741824 + DEFAULT_LOAD_FACTOR = 0.75 + TREEIFY_THRESHOLD = 8 + UNTREEIFY_THRESHOLD = 6 + MIN_TREEIFY_CAPACITY = 64 + +java.util.concurrent.ConcurrentHashMap + DEFAULT_CAPACITY = 16 + MAXIMUM_CAPACITY = 1073741824 + LOAD_FACTOR = 0.75 + DEFAULT_CONCURRENCY_LEVEL = 16 + TREEIFY_THRESHOLD = 8 + UNTREEIFY_THRESHOLD = 6 + MIN_TREEIFY_CAPACITY = 64 + MIN_TRANSFER_STRIDE = 16 + RESIZE_STAMP_BITS = 16 + RESIZE_STAMP_SHIFT = 16 + MAX_RESIZERS = 65535 + MOVED = -1 + TREEBIN = -2 + RESERVED = -3 + HASH_BITS = 2147483647 diff --git a/hashmap-vs-concurrenthashmap/output/02-sizectl-lifecycle.txt b/hashmap-vs-concurrenthashmap/output/02-sizectl-lifecycle.txt new file mode 100644 index 0000000..02cd14f --- /dev/null +++ b/hashmap-vs-concurrenthashmap/output/02-sizectl-lifecycle.txt @@ -0,0 +1,16 @@ +ConcurrentHashMap.sizeCtl vs HashMap.threshold, single-threaded, read by reflection +=================================================================================== + +new ConcurrentHashMap() sizeCtl=0 table=null +after 1st put sizeCtl=12 table.length=16 +after 12 puts (size=12) sizeCtl=24 table.length=32 +baseCount=12 counterCells=null size()=12 mappingCount()=12 (single thread: no CounterCell was ever needed) + +Sizing constructors: ask for 32 and see what each map really allocates +new ConcurrentHashMap(32) sizeCtl=64 (table not allocated yet) + after 1st put sizeCtl=48 table.length=64 +new HashMap(32) threshold=32 table=null (threshold holds the initial capacity until first put) + after 1st put threshold=24 table.length=32 + +What sizeCtl holds while a resize is running (computed, not caught live): +resizeStamp(16)=32795 (rs << 16) + 2 = -2145714174 -> negative, so every other thread sees 'resize in progress' diff --git a/hashmap-vs-concurrenthashmap/output/03-treeify-compare.txt b/hashmap-vs-concurrenthashmap/output/03-treeify-compare.txt new file mode 100644 index 0000000..8b9ea70 --- /dev/null +++ b/hashmap-vs-concurrenthashmap/output/03-treeify-compare.txt @@ -0,0 +1,16 @@ +12 colliding keys (all hashCode()==1, so bin index 1) inserted into HashMap and ConcurrentHashMap +================================================================================================= + +insert | HashMap table[1] (length) | ConcurrentHashMap table[1] (length, sizeCtl) +#1 | Node (length 16 ) | Node (length 16 , sizeCtl 12) +#2 | Node (length 16 ) | Node (length 16 , sizeCtl 12) +#3 | Node (length 16 ) | Node (length 16 , sizeCtl 12) +#4 | Node (length 16 ) | Node (length 16 , sizeCtl 12) +#5 | Node (length 16 ) | Node (length 16 , sizeCtl 12) +#6 | Node (length 16 ) | Node (length 16 , sizeCtl 12) +#7 | Node (length 16 ) | Node (length 16 , sizeCtl 12) +#8 | Node (length 16 ) | Node (length 16 , sizeCtl 12) +#9 | Node (length 32 ) | Node (length 128, sizeCtl 96) +#10 | Node (length 64 ) | TreeBin (length 128, sizeCtl 96) +#11 | TreeNode (length 64 ) | TreeBin (length 128, sizeCtl 96) +#12 | TreeNode (length 64 ) | TreeBin (length 128, sizeCtl 96) diff --git a/hashmap-vs-concurrenthashmap/output/04-forced-lost-update.txt b/hashmap-vs-concurrenthashmap/output/04-forced-lost-update.txt new file mode 100644 index 0000000..4c936b4 --- /dev/null +++ b/hashmap-vs-concurrenthashmap/output/04-forced-lost-update.txt @@ -0,0 +1,9 @@ +Two threads, two increments, forced interleaving: read, read, write, write +========================================================================== + +expected final value: 2 +HashMap get + put: 1 +synchronizedMap get + put: 1 +ConcurrentHashMap get + put: 1 +ConcurrentHashMap merge: 2 +(each map call is individually thread-safe; the get-then-put PAIR is not atomic) diff --git a/hashmap-vs-concurrenthashmap/output/05-measured-lost-updates.txt b/hashmap-vs-concurrenthashmap/output/05-measured-lost-updates.txt new file mode 100644 index 0000000..3675dbc --- /dev/null +++ b/hashmap-vs-concurrenthashmap/output/05-measured-lost-updates.txt @@ -0,0 +1,22 @@ +threads=8, increments per thread=200,000, rounds=5, cores=2 +Counts below VARY from run to run. Only the 'expected' column is fixed. + +HashMap get+put expected 1,600,000 actual per round: 228,496 400,000 287,091 1,200,000 451,357 + lost updates per round: min 400,000, max 1,371,504 +Collections.synchronizedMap get+put expected 1,600,000 actual per round: 282,588 398,788 918,560 433,831 691,994 + lost updates per round: min 681,440, max 1,317,412 +ConcurrentHashMap get+put expected 1,600,000 actual per round: 214,096 666,429 605,316 446,831 389,717 + lost updates per round: min 933,571, max 1,385,904 +HashMap + synchronized(map) around get+put expected 1,600,000 actual per round: 1,600,000 1,600,000 1,600,000 1,600,000 1,600,000 + lost updates per round: min 0, max 0 +ConcurrentHashMap merge(KEY, 1, Integer::sum) expected 1,600,000 actual per round: 1,600,000 1,600,000 1,600,000 1,600,000 1,600,000 + lost updates per round: min 0, max 0 +ConcurrentHashMap compute(KEY, v+1) expected 1,600,000 actual per round: 1,600,000 1,600,000 1,600,000 1,600,000 1,600,000 + lost updates per round: min 0, max 0 + +-- second kind of damage: threads insert DIFFERENT keys, nobody overwrites anybody -- +HashMap put of distinct keys (size()) expected 400,000 actual per round: 399,522 400,000 400,000 400,000 400,000 + lost updates per round: min 0, max 478 +HashMap exceptions thrown inside worker threads, per round: [null, null, null, null, null] +ConcurrentHashMap put of distinct keys (size()) expected 400,000 actual per round: 400,000 400,000 400,000 400,000 400,000 + lost updates per round: min 0, max 0 diff --git a/hashmap-vs-concurrenthashmap/output/06-recursive-update.txt b/hashmap-vs-concurrenthashmap/output/06-recursive-update.txt new file mode 100644 index 0000000..9fa6881 --- /dev/null +++ b/hashmap-vs-concurrenthashmap/output/06-recursive-update.txt @@ -0,0 +1,7 @@ +computeIfAbsent called from inside its own mapping function (single thread) +=========================================================================== + +HashMap, inner key in a different bin: threw java.util.ConcurrentModificationException +ConcurrentHashMap, inner key in a different bin: no exception, size=2 +ConcurrentHashMap, inner key, same bin (bin was empty): threw java.lang.IllegalStateException: Recursive update +ConcurrentHashMap, inner key, same bin (bin had a node): threw java.lang.IllegalStateException: Recursive update diff --git a/hashmap-vs-concurrenthashmap/output/07-null-handling.txt b/hashmap-vs-concurrenthashmap/output/07-null-handling.txt new file mode 100644 index 0000000..35cfb25 --- /dev/null +++ b/hashmap-vs-concurrenthashmap/output/07-null-handling.txt @@ -0,0 +1,9 @@ +null keys and values: HashMap allows, ConcurrentHashMap rejects +=============================================================== + +HashMap.put(null, ..) accepted; the null key lives in table[0]: true +HashMap.get(null) = for-null-key +HashMap.get("present-but-null") = null containsKey = true +HashMap.get("absent") = null containsKey = false <- get() alone cannot tell these two apart +ConcurrentHashMap put(null, v), put(k, null), get(null), containsKey(null), merge(k, null, f): all throw NullPointerException +compute(k, (k,v) -> null) removes the entry instead: containsKey(k) = false diff --git a/hashmap-vs-concurrenthashmap/output/08-atomic-updates-exact.txt b/hashmap-vs-concurrenthashmap/output/08-atomic-updates-exact.txt new file mode 100644 index 0000000..61a2261 --- /dev/null +++ b/hashmap-vs-concurrenthashmap/output/08-atomic-updates-exact.txt @@ -0,0 +1,7 @@ +8 threads x 20000 increments of one key: the atomic forms are exact on every run +================================================================================ + +expected: 160000 +ConcurrentHashMap.merge 160000 +ConcurrentHashMap.compute 160000 +HashMap + synchronized(map) around merge 160000 diff --git a/hashmap-vs-concurrenthashmap/pom.xml b/hashmap-vs-concurrenthashmap/pom.xml new file mode 100644 index 0000000..078016f --- /dev/null +++ b/hashmap-vs-concurrenthashmap/pom.xml @@ -0,0 +1,63 @@ + + + 4.0.0 + com.ankurm.interviewlab + hashmap-vs-concurrenthashmap + 1.0.0 + jar + + java-interview-lab :: hashmap-vs-concurrenthashmap + + Runnable companion code for ankurm.com's "HashMap vs ConcurrentHashMap in Java" article: + reflective dumps of JDK 25 internals (sizeCtl, treeification), a forced-interleaving lost + update, a measured multi-thread lost-update run, and the computeIfAbsent recursion traps. + + + + 25 + UTF-8 + 6.1.3 + + --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.util.concurrent=ALL-UNNAMED + + + + + + org.junit + junit-bom + ${junit.version} + pom + import + + + + + + + org.junit.jupiter + junit-jupiter + test + + + org.junit.platform + junit-platform-launcher + test + + + + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.6.0 + + ${opens} -Xmx512m -Duser.timezone=UTC -Dfile.encoding=UTF-8 + + + + + diff --git a/hashmap-vs-concurrenthashmap/scripts/run-all.sh b/hashmap-vs-concurrenthashmap/scripts/run-all.sh new file mode 100755 index 0000000..9d798fc --- /dev/null +++ b/hashmap-vs-concurrenthashmap/scripts/run-all.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +# Regenerates every file in output/. Needs JDK 25 and Maven. First run must be online. +# 01-04, 06-08 are written by JUnit tests that assert the same numbers (deterministic). +# 05 is a MEASUREMENT (lost updates differ on every run) and is not asserted by any test. +set -euo pipefail +cd "$(dirname "$0")/.." +mkdir -p output +mvn -B -q test +mvn -B -q compile +{ java -Xmx512m \ + --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.util.concurrent=ALL-UNNAMED \ + -cp target/classes com.ankurm.interviewlab.hashmapvschm.LostUpdateDemo 2>&1 \ + | grep -v '^Picked up JAVA_TOOL_OPTIONS' ; } > output/05-measured-lost-updates.txt +# The first line of 05 names the machine's core count; the rest varies run to run by design. +echo "done:"; ls -1 output diff --git a/hashmap-vs-concurrenthashmap/src/main/java/com/ankurm/interviewlab/hashmapvschm/CollidingKey.java b/hashmap-vs-concurrenthashmap/src/main/java/com/ankurm/interviewlab/hashmapvschm/CollidingKey.java new file mode 100644 index 0000000..ab06edb --- /dev/null +++ b/hashmap-vs-concurrenthashmap/src/main/java/com/ankurm/interviewlab/hashmapvschm/CollidingKey.java @@ -0,0 +1,16 @@ +package com.ankurm.interviewlab.hashmapvschm; + +/** A key whose hashCode() is chosen by the caller, so tests can force keys into one bin (or not). */ +public record CollidingKey(int id, int hash) implements Comparable { + public static CollidingKey sameBin(int id) { return new CollidingKey(id, 1); } + + @Override public int hashCode() { return hash; } + + @Override public boolean equals(Object o) { + return o instanceof CollidingKey k && k.id == id && k.hash == hash; + } + + @Override public int compareTo(CollidingKey o) { return Integer.compare(id, o.id); } + + @Override public String toString() { return "K" + id + "(h=" + hash + ")"; } +} diff --git a/hashmap-vs-concurrenthashmap/src/main/java/com/ankurm/interviewlab/hashmapvschm/Internals.java b/hashmap-vs-concurrenthashmap/src/main/java/com/ankurm/interviewlab/hashmapvschm/Internals.java new file mode 100644 index 0000000..a5d385e --- /dev/null +++ b/hashmap-vs-concurrenthashmap/src/main/java/com/ankurm/interviewlab/hashmapvschm/Internals.java @@ -0,0 +1,71 @@ +package com.ankurm.interviewlab.hashmapvschm; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.util.HashMap; +import java.util.concurrent.ConcurrentHashMap; + +/** + * Reflective peeks at the private state of the JDK's own HashMap and ConcurrentHashMap. + * Needs: --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.util.concurrent=ALL-UNNAMED + * (this is a diagnostic helper for learning, never something to ship). + */ +public final class Internals { + private Internals() {} + + public static Object get(Object target, Class declaring, String field) { + try { + Field f = declaring.getDeclaredField(field); + f.setAccessible(true); + return f.get(target); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException("cannot read " + declaring.getName() + "." + field, e); + } + } + + /** Value of a static final constant, read from the loaded class (not copied from a blog). */ + public static Object constant(Class declaring, String field) { + return get(null, declaring, field); + } + + public static Object[] chmTable(ConcurrentHashMap m) { + return (Object[]) get(m, ConcurrentHashMap.class, "table"); + } + + public static int chmSizeCtl(ConcurrentHashMap m) { + return (int) get(m, ConcurrentHashMap.class, "sizeCtl"); + } + + public static long chmBaseCount(ConcurrentHashMap m) { + return (long) get(m, ConcurrentHashMap.class, "baseCount"); + } + + public static Object[] hmTable(HashMap m) { + return (Object[]) get(m, HashMap.class, "table"); + } + + public static int hmThreshold(HashMap m) { + return (int) get(m, HashMap.class, "threshold"); + } + + public static int tableLength(Object[] table) { + return table == null ? 0 : table.length; + } + + /** Simple class name of the first node in the bin at {@code index}: Node, TreeNode, TreeBin, ... or "empty". */ + public static String binType(Object[] table, int index) { + if (table == null || table[index] == null) return "empty"; + return table[index].getClass().getSimpleName(); + } + + /** ConcurrentHashMap.resizeStamp(n), package-private static, called reflectively. */ + public static int chmResizeStamp(int n) { + try { + Method m = ConcurrentHashMap.class.getDeclaredMethod("resizeStamp", int.class); + m.setAccessible(true); + return (int) m.invoke(null, n); + } catch (ReflectiveOperationException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/hashmap-vs-concurrenthashmap/src/main/java/com/ankurm/interviewlab/hashmapvschm/LostUpdateDemo.java b/hashmap-vs-concurrenthashmap/src/main/java/com/ankurm/interviewlab/hashmapvschm/LostUpdateDemo.java new file mode 100644 index 0000000..7054c05 --- /dev/null +++ b/hashmap-vs-concurrenthashmap/src/main/java/com/ankurm/interviewlab/hashmapvschm/LostUpdateDemo.java @@ -0,0 +1,141 @@ +package com.ankurm.interviewlab.hashmapvschm; + +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.function.Consumer; + +/** + * MEASURED lost updates. Many threads increment one shared counter stored in a Map. + * The numbers differ on every run and on every machine - that is the point. Nothing here is + * asserted by a test; the deterministic version of the same bug is ForcedInterleavingTest. + * + * Run: java --add-opens ... -cp target/classes com.ankurm.interviewlab.hashmapvschm.LostUpdateDemo + */ +public class LostUpdateDemo { + + static final String KEY = "counter"; + static final int THREADS = 8; + static final int INCREMENTS_PER_THREAD = 200_000; + static final int ROUNDS = 5; + + /** Runs {@code body} on THREADS threads released together by a latch; returns wall millis. */ + static long race(Consumer body) throws InterruptedException { + CountDownLatch ready = new CountDownLatch(THREADS); + CountDownLatch go = new CountDownLatch(1); + Thread[] ts = new Thread[THREADS]; + for (int t = 0; t < THREADS; t++) { + final int id = t; + ts[t] = new Thread(() -> { + ready.countDown(); + try { go.await(); } catch (InterruptedException e) { return; } + body.accept(id); + }); + ts[t].start(); + } + ready.await(); + long t0 = System.nanoTime(); + go.countDown(); + for (Thread t : ts) t.join(); + return (System.nanoTime() - t0) / 1_000_000; + } + + interface Variant { int run(int round) throws Exception; String name(); } + + static void report(String name, long expected, long[] actualPerRound) { + StringBuilder sb = new StringBuilder(); + long min = Long.MAX_VALUE, max = Long.MIN_VALUE; + for (long a : actualPerRound) { + sb.append(String.format("%,d ", a)); + min = Math.min(min, a); max = Math.max(max, a); + } + System.out.printf("%-44s expected %,d actual per round: %s%n", name, expected, sb.toString().trim()); + System.out.printf("%-44s lost updates per round: min %,d, max %,d%n", "", expected - max, expected - min); + } + + static long[] times(int rounds, java.util.concurrent.Callable c) throws Exception { + long[] r = new long[rounds]; + for (int i = 0; i < rounds; i++) r[i] = c.call(); + return r; + } + + public static void main(String[] args) throws Exception { + long expected = (long) THREADS * INCREMENTS_PER_THREAD; + System.out.printf("threads=%d, increments per thread=%,d, rounds=%d, cores=%d%n", + THREADS, INCREMENTS_PER_THREAD, ROUNDS, Runtime.getRuntime().availableProcessors()); + System.out.println("Counts below VARY from run to run. Only the 'expected' column is fixed."); + System.out.println(); + + report("HashMap get+put", expected, times(ROUNDS, () -> { + Map m = new HashMap<>(); + m.put(KEY, 0); + race(id -> { for (int i = 0; i < INCREMENTS_PER_THREAD; i++) m.put(KEY, m.get(KEY) + 1); }); + return (long) m.get(KEY); + })); + + report("Collections.synchronizedMap get+put", expected, times(ROUNDS, () -> { + Map m = Collections.synchronizedMap(new HashMap<>()); + m.put(KEY, 0); + race(id -> { for (int i = 0; i < INCREMENTS_PER_THREAD; i++) m.put(KEY, m.get(KEY) + 1); }); + return (long) m.get(KEY); + })); + + report("ConcurrentHashMap get+put", expected, times(ROUNDS, () -> { + Map m = new ConcurrentHashMap<>(); + m.put(KEY, 0); + race(id -> { for (int i = 0; i < INCREMENTS_PER_THREAD; i++) m.put(KEY, m.get(KEY) + 1); }); + return (long) m.get(KEY); + })); + + report("HashMap + synchronized(map) around get+put", expected, times(ROUNDS, () -> { + Map m = new HashMap<>(); + m.put(KEY, 0); + race(id -> { for (int i = 0; i < INCREMENTS_PER_THREAD; i++) synchronized (m) { m.put(KEY, m.get(KEY) + 1); } }); + return (long) m.get(KEY); + })); + + report("ConcurrentHashMap merge(KEY, 1, Integer::sum)", expected, times(ROUNDS, () -> { + ConcurrentHashMap m = new ConcurrentHashMap<>(); + m.put(KEY, 0); + race(id -> { for (int i = 0; i < INCREMENTS_PER_THREAD; i++) m.merge(KEY, 1, Integer::sum); }); + return (long) m.get(KEY); + })); + + report("ConcurrentHashMap compute(KEY, v+1)", expected, times(ROUNDS, () -> { + ConcurrentHashMap m = new ConcurrentHashMap<>(); + m.put(KEY, 0); + race(id -> { for (int i = 0; i < INCREMENTS_PER_THREAD; i++) m.compute(KEY, (k, v) -> v == null ? 1 : v + 1); }); + return (long) m.get(KEY); + })); + + System.out.println(); + System.out.println("-- second kind of damage: threads insert DIFFERENT keys, nobody overwrites anybody --"); + final int perThread = 50_000; + long expectedSize = (long) THREADS * perThread; + long[] hmSizes = new long[ROUNDS]; + String[] hmErrors = new String[ROUNDS]; + for (int r = 0; r < ROUNDS; r++) { + HashMap m = new HashMap<>(); + final String[] err = {null}; + race(id -> { + try { + for (int i = 0; i < perThread; i++) m.put(id * perThread + i, i); + } catch (Throwable t) { err[0] = t.getClass().getName(); } + }); + hmSizes[r] = m.size(); + hmErrors[r] = err[0]; + } + report("HashMap put of distinct keys (size())", expectedSize, hmSizes); + System.out.println("HashMap exceptions thrown inside worker threads, per round: " + java.util.Arrays.toString(hmErrors)); + + long[] chmSizes = new long[ROUNDS]; + for (int r = 0; r < ROUNDS; r++) { + ConcurrentHashMap m = new ConcurrentHashMap<>(); + race(id -> { for (int i = 0; i < perThread; i++) m.put(id * perThread + i, i); }); + chmSizes[r] = m.size(); + } + report("ConcurrentHashMap put of distinct keys (size())", expectedSize, chmSizes); + } +} diff --git a/hashmap-vs-concurrenthashmap/src/main/java/com/ankurm/interviewlab/hashmapvschm/RecursiveUpdateDemo.java b/hashmap-vs-concurrenthashmap/src/main/java/com/ankurm/interviewlab/hashmapvschm/RecursiveUpdateDemo.java new file mode 100644 index 0000000..83ba7f6 --- /dev/null +++ b/hashmap-vs-concurrenthashmap/src/main/java/com/ankurm/interviewlab/hashmapvschm/RecursiveUpdateDemo.java @@ -0,0 +1,45 @@ +package com.ankurm.interviewlab.hashmapvschm; + +import java.util.HashMap; +import java.util.concurrent.ConcurrentHashMap; + +/** The three outcomes of calling computeIfAbsent from inside its own mapping function. */ +public final class RecursiveUpdateDemo { + private RecursiveUpdateDemo() {} + + /** HashMap: ConcurrentModificationException, single-threaded. */ + public static String hashMapDifferentKey() { + HashMap m = new HashMap<>(); + try { + m.computeIfAbsent(new CollidingKey(1, 1), k -> + m.computeIfAbsent(new CollidingKey(2, 2), k2 -> "inner")); + return "no exception, map=" + m.size() + " entries"; + } catch (RuntimeException e) { + return "threw " + e.getClass().getName(); + } + } + + /** ConcurrentHashMap, inner key lands in a DIFFERENT bin: works. */ + public static String chmDifferentBin() { + ConcurrentHashMap m = new ConcurrentHashMap<>(); + try { + m.computeIfAbsent(new CollidingKey(1, 1), k -> + m.computeIfAbsent(new CollidingKey(2, 2), k2 -> "inner")); + return "no exception, size=" + m.size(); + } catch (RuntimeException e) { + return "threw " + e.getClass().getName() + ": " + e.getMessage(); + } + } + + /** ConcurrentHashMap, inner key is a DIFFERENT key but in the SAME (empty) bin: ISE. */ + public static String chmSameBinDifferentKey() { + ConcurrentHashMap m = new ConcurrentHashMap<>(); + try { + m.computeIfAbsent(new CollidingKey(1, 1), k -> + m.computeIfAbsent(new CollidingKey(2, 1), k2 -> "inner")); + return "no exception, size=" + m.size(); + } catch (RuntimeException e) { + return "threw " + e.getClass().getName() + ": " + e.getMessage(); + } + } +} diff --git a/hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/AtomicUpdatesTest.java b/hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/AtomicUpdatesTest.java new file mode 100644 index 0000000..1fd1aad --- /dev/null +++ b/hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/AtomicUpdatesTest.java @@ -0,0 +1,56 @@ +package com.ankurm.interviewlab.hashmapvschm; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.ankurm.interviewlab.hashmapvschm.support.Transcript; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import org.junit.jupiter.api.Test; + +/** The fixes give an exact total on every run, however the threads interleave. */ +class AtomicUpdatesTest { + static final int THREADS = 8; + static final int PER_THREAD = 20_000; + + static void race(Runnable body) throws InterruptedException { + CountDownLatch go = new CountDownLatch(1); + Thread[] ts = new Thread[THREADS]; + for (int i = 0; i < THREADS; i++) { + ts[i] = new Thread(() -> { + try { go.await(); } catch (InterruptedException e) { return; } + body.run(); + }); + ts[i].start(); + } + go.countDown(); + for (Thread t : ts) t.join(); + } + + @Test + void mergeComputeAndExternalLockAreExact() throws Exception { + int expected = THREADS * PER_THREAD; + + ConcurrentHashMap merge = new ConcurrentHashMap<>(); + race(() -> { for (int i = 0; i < PER_THREAD; i++) merge.merge("n", 1, Integer::sum); }); + + ConcurrentHashMap compute = new ConcurrentHashMap<>(); + race(() -> { for (int i = 0; i < PER_THREAD; i++) compute.compute("n", (k, v) -> v == null ? 1 : v + 1); }); + + Map locked = new HashMap<>(); + race(() -> { for (int i = 0; i < PER_THREAD; i++) synchronized (locked) { locked.merge("n", 1, Integer::sum); } }); + + Transcript t = Transcript.start("08-atomic-updates-exact.txt", + THREADS + " threads x " + PER_THREAD + " increments of one key: the atomic forms are exact on every run"); + t.line("expected: " + expected); + t.line("ConcurrentHashMap.merge " + merge.get("n")); + t.line("ConcurrentHashMap.compute " + compute.get("n")); + t.line("HashMap + synchronized(map) around merge " + locked.get("n")); + t.flush(); + + assertEquals(expected, merge.get("n")); + assertEquals(expected, compute.get("n")); + assertEquals(expected, locked.get("n")); + } +} diff --git a/hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/ForcedInterleavingTest.java b/hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/ForcedInterleavingTest.java new file mode 100644 index 0000000..106973b --- /dev/null +++ b/hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/ForcedInterleavingTest.java @@ -0,0 +1,66 @@ +package com.ankurm.interviewlab.hashmapvschm; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.ankurm.interviewlab.hashmapvschm.support.Transcript; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.Test; + +/** + * A lost update with the unlucky timing built in on purpose, so it happens every time: + * both threads READ before either WRITES. + */ +class ForcedInterleavingTest { + + /** Two threads each do "read, then (after both have read) write read+1". Returns the final value. */ + static int forcedLostUpdate(Map map) throws Exception { + map.put("n", 0); + CountDownLatch bothRead = new CountDownLatch(2); + Runnable r = () -> { + int seen = map.get("n"); // step 1: read + bothRead.countDown(); + try { + if (!bothRead.await(10, TimeUnit.SECONDS)) throw new IllegalStateException("timeout"); + } catch (InterruptedException e) { + throw new IllegalStateException(e); + } + map.put("n", seen + 1); // step 2: write based on the stale read + }; + Thread a = new Thread(r), b = new Thread(r); + a.start(); b.start(); + a.join(); b.join(); + return map.get("n"); + } + + @Test + void everyMapLosesTheUpdateWhenTheCheckAndTheWriteAreSeparateCalls() throws Exception { + Transcript t = Transcript.start("04-forced-lost-update.txt", + "Two threads, two increments, forced interleaving: read, read, write, write"); + int hm = forcedLostUpdate(new HashMap<>()); + int sm = forcedLostUpdate(Collections.synchronizedMap(new HashMap<>())); + int cm = forcedLostUpdate(new ConcurrentHashMap<>()); + t.line("expected final value: 2"); + t.line("HashMap get + put: " + hm); + t.line("synchronizedMap get + put: " + sm); + t.line("ConcurrentHashMap get + put: " + cm); + + ConcurrentHashMap merged = new ConcurrentHashMap<>(); + merged.put("n", 0); + Thread a = new Thread(() -> merged.merge("n", 1, Integer::sum)); + Thread b = new Thread(() -> merged.merge("n", 1, Integer::sum)); + a.start(); b.start(); a.join(); b.join(); + t.line("ConcurrentHashMap merge: " + merged.get("n")); + t.line("(each map call is individually thread-safe; the get-then-put PAIR is not atomic)"); + t.flush(); + + assertEquals(1, hm); + assertEquals(1, sm); + assertEquals(1, cm); + assertEquals(2, merged.get("n")); + } +} diff --git a/hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/Jdk25ConstantsTest.java b/hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/Jdk25ConstantsTest.java new file mode 100644 index 0000000..1623e51 --- /dev/null +++ b/hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/Jdk25ConstantsTest.java @@ -0,0 +1,49 @@ +package com.ankurm.interviewlab.hashmapvschm; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.ankurm.interviewlab.hashmapvschm.support.Transcript; +import java.util.HashMap; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.jupiter.api.Test; + +/** Reads the constants out of the loaded JDK classes, instead of quoting them from a blog. */ +class Jdk25ConstantsTest { + + @Test + void constantsAreReadFromTheRunningJdk() { + Transcript t = Transcript.start("01-jdk-constants.txt", + "Constants read by reflection from the running JDK's own HashMap and ConcurrentHashMap"); + t.line("runtime: " + System.getProperty("java.runtime.version") + " (" + System.getProperty("java.vendor") + ")"); + t.blank().line("java.util.HashMap"); + String[] hm = {"DEFAULT_INITIAL_CAPACITY", "MAXIMUM_CAPACITY", "DEFAULT_LOAD_FACTOR", + "TREEIFY_THRESHOLD", "UNTREEIFY_THRESHOLD", "MIN_TREEIFY_CAPACITY"}; + for (String f : hm) t.line(String.format(" %-26s = %s", f, Internals.constant(HashMap.class, f))); + + t.blank().line("java.util.concurrent.ConcurrentHashMap"); + String[] chm = {"DEFAULT_CAPACITY", "MAXIMUM_CAPACITY", "LOAD_FACTOR", "DEFAULT_CONCURRENCY_LEVEL", + "TREEIFY_THRESHOLD", "UNTREEIFY_THRESHOLD", "MIN_TREEIFY_CAPACITY", + "MIN_TRANSFER_STRIDE", "RESIZE_STAMP_BITS", "RESIZE_STAMP_SHIFT", "MAX_RESIZERS", + "MOVED", "TREEBIN", "RESERVED", "HASH_BITS"}; + for (String f : chm) t.line(String.format(" %-26s = %s", f, Internals.constant(ConcurrentHashMap.class, f))); + t.flush(); + + assertEquals(16, Internals.constant(HashMap.class, "DEFAULT_INITIAL_CAPACITY")); + assertEquals(0.75f, Internals.constant(HashMap.class, "DEFAULT_LOAD_FACTOR")); + assertEquals(8, Internals.constant(HashMap.class, "TREEIFY_THRESHOLD")); + assertEquals(6, Internals.constant(HashMap.class, "UNTREEIFY_THRESHOLD")); + assertEquals(64, Internals.constant(HashMap.class, "MIN_TREEIFY_CAPACITY")); + + assertEquals(16, Internals.constant(ConcurrentHashMap.class, "DEFAULT_CAPACITY")); + assertEquals(0.75f, Internals.constant(ConcurrentHashMap.class, "LOAD_FACTOR")); + assertEquals(8, Internals.constant(ConcurrentHashMap.class, "TREEIFY_THRESHOLD")); + assertEquals(6, Internals.constant(ConcurrentHashMap.class, "UNTREEIFY_THRESHOLD")); + assertEquals(64, Internals.constant(ConcurrentHashMap.class, "MIN_TREEIFY_CAPACITY")); + assertEquals(16, Internals.constant(ConcurrentHashMap.class, "MIN_TRANSFER_STRIDE")); + assertEquals(-1, Internals.constant(ConcurrentHashMap.class, "MOVED")); + assertEquals(-2, Internals.constant(ConcurrentHashMap.class, "TREEBIN")); + assertEquals(-3, Internals.constant(ConcurrentHashMap.class, "RESERVED")); + assertEquals(0x7fffffff, Internals.constant(ConcurrentHashMap.class, "HASH_BITS")); + assertEquals(16, Internals.constant(ConcurrentHashMap.class, "DEFAULT_CONCURRENCY_LEVEL")); + } +} diff --git a/hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/NullHandlingTest.java b/hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/NullHandlingTest.java new file mode 100644 index 0000000..2e7875c --- /dev/null +++ b/hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/NullHandlingTest.java @@ -0,0 +1,49 @@ +package com.ankurm.interviewlab.hashmapvschm; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.ankurm.interviewlab.hashmapvschm.support.Transcript; +import java.util.HashMap; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.jupiter.api.Test; + +class NullHandlingTest { + + @Test + void nullRulesAndWhyTheyExist() { + Transcript t = Transcript.start("07-null-handling.txt", "null keys and values: HashMap allows, ConcurrentHashMap rejects"); + HashMap hm = new HashMap<>(); + hm.put(null, "for-null-key"); + hm.put("present-but-null", null); + t.line("HashMap.put(null, ..) accepted; the null key lives in table[0]: " + (Internals.hmTable(hm)[0] != null)); + assertNotNull(Internals.hmTable(hm)[0]); + t.line("HashMap.get(null) = " + hm.get(null)); + t.line("HashMap.get(\"present-but-null\") = " + hm.get("present-but-null") + + " containsKey = " + hm.containsKey("present-but-null")); + t.line("HashMap.get(\"absent\") = " + hm.get("absent") + + " containsKey = " + hm.containsKey("absent") + + " <- get() alone cannot tell these two apart"); + assertNull(hm.get("present-but-null")); + assertNull(hm.get("absent")); + assertTrue(hm.containsKey("present-but-null")); + + ConcurrentHashMap chm = new ConcurrentHashMap<>(); + assertThrows(NullPointerException.class, () -> chm.put(null, "x")); + assertThrows(NullPointerException.class, () -> chm.put("k", null)); + assertThrows(NullPointerException.class, () -> chm.get(null)); + assertThrows(NullPointerException.class, () -> chm.containsKey(null)); + assertThrows(NullPointerException.class, () -> chm.merge("k", null, (a, b) -> a)); + t.line("ConcurrentHashMap put(null, v), put(k, null), get(null), containsKey(null), merge(k, null, f):" + + " all throw NullPointerException"); + // compute() may RETURN null, which means 'remove the mapping' - that is not storing a null. + chm.put("k", "v"); + chm.compute("k", (k, v) -> null); + t.line("compute(k, (k,v) -> null) removes the entry instead: containsKey(k) = " + chm.containsKey("k")); + assertEquals(0, chm.size()); + t.flush(); + } +} diff --git a/hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/RecursiveUpdateTest.java b/hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/RecursiveUpdateTest.java new file mode 100644 index 0000000..b2beb40 --- /dev/null +++ b/hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/RecursiveUpdateTest.java @@ -0,0 +1,43 @@ +package com.ankurm.interviewlab.hashmapvschm; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertTrue; + +import com.ankurm.interviewlab.hashmapvschm.support.Transcript; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.jupiter.api.Test; + +class RecursiveUpdateTest { + + @Test + void recursionOutcomeDependsOnTheBinNotTheKey() { + Transcript t = Transcript.start("06-recursive-update.txt", + "computeIfAbsent called from inside its own mapping function (single thread)"); + String hm = RecursiveUpdateDemo.hashMapDifferentKey(); + String diffBin = RecursiveUpdateDemo.chmDifferentBin(); + String sameBinEmpty = RecursiveUpdateDemo.chmSameBinDifferentKey(); + + ConcurrentHashMap nonEmpty = new ConcurrentHashMap<>(); + nonEmpty.put(new CollidingKey(0, 1), "x"); + String sameBinNonEmpty; + try { + nonEmpty.computeIfAbsent(new CollidingKey(1, 1), + k -> nonEmpty.computeIfAbsent(new CollidingKey(2, 1), k2 -> "inner")); + sameBinNonEmpty = "no exception"; + } catch (RuntimeException e) { + sameBinNonEmpty = "threw " + e.getClass().getName() + ": " + e.getMessage(); + } + + t.line("HashMap, inner key in a different bin: " + hm); + t.line("ConcurrentHashMap, inner key in a different bin: " + diffBin); + t.line("ConcurrentHashMap, inner key, same bin (bin was empty): " + sameBinEmpty); + t.line("ConcurrentHashMap, inner key, same bin (bin had a node): " + sameBinNonEmpty); + t.flush(); + + assertEquals("threw java.util.ConcurrentModificationException", hm); + assertEquals("no exception, size=2", diffBin); + assertEquals("threw java.lang.IllegalStateException: Recursive update", sameBinEmpty); + assertEquals("threw java.lang.IllegalStateException: Recursive update", sameBinNonEmpty); + assertTrue(nonEmpty.size() >= 1); + } +} diff --git a/hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/SizeCtlTest.java b/hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/SizeCtlTest.java new file mode 100644 index 0000000..0a6f639 --- /dev/null +++ b/hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/SizeCtlTest.java @@ -0,0 +1,74 @@ +package com.ankurm.interviewlab.hashmapvschm; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNull; + +import com.ankurm.interviewlab.hashmapvschm.support.Transcript; +import java.util.HashMap; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.jupiter.api.Test; + +/** sizeCtl (ConcurrentHashMap) and threshold (HashMap) through a map's single-threaded life. */ +class SizeCtlTest { + + @Test + void sizeCtlLifecycleSingleThreaded() { + Transcript t = Transcript.start("02-sizectl-lifecycle.txt", + "ConcurrentHashMap.sizeCtl vs HashMap.threshold, single-threaded, read by reflection"); + + ConcurrentHashMap m = new ConcurrentHashMap<>(); + t.line("new ConcurrentHashMap() sizeCtl=" + Internals.chmSizeCtl(m) + + " table=" + Internals.chmTable(m)); + assertEquals(0, Internals.chmSizeCtl(m)); + assertNull(Internals.chmTable(m)); + + m.put("k0", 0); + t.line("after 1st put sizeCtl=" + Internals.chmSizeCtl(m) + + " table.length=" + Internals.tableLength(Internals.chmTable(m))); + assertEquals(12, Internals.chmSizeCtl(m)); + assertEquals(16, Internals.tableLength(Internals.chmTable(m))); + + for (int i = 1; i < 12; i++) m.put("k" + i, i); + t.line("after 12 puts (size=" + m.size() + ") sizeCtl=" + Internals.chmSizeCtl(m) + + " table.length=" + Internals.tableLength(Internals.chmTable(m))); + // addCount resizes when the count REACHES sizeCtl, so the 12th put already doubles the table. + assertEquals(32, Internals.tableLength(Internals.chmTable(m))); + assertEquals(24, Internals.chmSizeCtl(m)); + + t.line("baseCount=" + Internals.chmBaseCount(m) + " counterCells=" + + Internals.get(m, ConcurrentHashMap.class, "counterCells") + + " size()=" + m.size() + " mappingCount()=" + m.mappingCount() + + " (single thread: no CounterCell was ever needed)"); + assertEquals(12L, Internals.chmBaseCount(m)); + assertNull(Internals.get(m, ConcurrentHashMap.class, "counterCells")); + + t.blank().line("Sizing constructors: ask for 32 and see what each map really allocates"); + ConcurrentHashMap c32 = new ConcurrentHashMap<>(32); + t.line("new ConcurrentHashMap(32) sizeCtl=" + Internals.chmSizeCtl(c32) + " (table not allocated yet)"); + assertEquals(64, Internals.chmSizeCtl(c32)); + c32.put("a", 1); + t.line(" after 1st put sizeCtl=" + Internals.chmSizeCtl(c32) + + " table.length=" + Internals.tableLength(Internals.chmTable(c32))); + assertEquals(64, Internals.tableLength(Internals.chmTable(c32))); + assertEquals(48, Internals.chmSizeCtl(c32)); + + HashMap h32 = new HashMap<>(32); + t.line("new HashMap(32) threshold=" + Internals.hmThreshold(h32) + + " table=" + Internals.hmTable(h32) + " (threshold holds the initial capacity until first put)"); + assertEquals(32, Internals.hmThreshold(h32)); + h32.put("a", 1); + t.line(" after 1st put threshold=" + Internals.hmThreshold(h32) + + " table.length=" + Internals.tableLength(Internals.hmTable(h32))); + assertEquals(32, Internals.tableLength(Internals.hmTable(h32))); + assertEquals(24, Internals.hmThreshold(h32)); + + t.blank().line("What sizeCtl holds while a resize is running (computed, not caught live):"); + int rs = Internals.chmResizeStamp(16); + int whileResizing = (rs << 16) + 2; + t.line("resizeStamp(16)=" + rs + " (rs << 16) + 2 = " + whileResizing + + " -> negative, so every other thread sees 'resize in progress'"); + assertEquals(32795, rs); + assertEquals(-2145714174, whileResizing); + t.flush(); + } +} diff --git a/hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/TreeifyCompareTest.java b/hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/TreeifyCompareTest.java new file mode 100644 index 0000000..3d5e661 --- /dev/null +++ b/hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/TreeifyCompareTest.java @@ -0,0 +1,56 @@ +package com.ankurm.interviewlab.hashmapvschm; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import com.ankurm.interviewlab.hashmapvschm.support.Transcript; +import java.util.HashMap; +import java.util.concurrent.ConcurrentHashMap; +import org.junit.jupiter.api.Test; + +/** Twelve keys with identical hashCode() go into both maps; watch table length and the bin type. */ +class TreeifyCompareTest { + + @Test + void sameKeysSameBinDifferentTreeifyPath() { + Transcript t = Transcript.start("03-treeify-compare.txt", + "12 colliding keys (all hashCode()==1, so bin index 1) inserted into HashMap and ConcurrentHashMap"); + HashMap hm = new HashMap<>(); + ConcurrentHashMap cm = new ConcurrentHashMap<>(); + t.line(String.format("%-7s | %-28s | %-34s", "insert", "HashMap table[1] (length)", "ConcurrentHashMap table[1] (length, sizeCtl)")); + + String[] hmBin = new String[13]; + int[] hmLen = new int[13]; + String[] cmBin = new String[13]; + int[] cmLen = new int[13]; + for (int i = 1; i <= 12; i++) { + CollidingKey k = CollidingKey.sameBin(i); + hm.put(k, i); + cm.put(k, i); + Object[] ht = Internals.hmTable(hm); + Object[] ct = Internals.chmTable(cm); + hmBin[i] = Internals.binType(ht, 1); + hmLen[i] = ht.length; + cmBin[i] = Internals.binType(ct, 1); + cmLen[i] = ct.length; + t.line(String.format("#%-6d | %-9s (length %-3d) | %-9s (length %-3d, sizeCtl %d)", + i, hmBin[i], hmLen[i], cmBin[i], cmLen[i], Internals.chmSizeCtl(cm))); + } + t.flush(); + + // HashMap: the 9th insert makes the chain 9 long -> treeifyBin -> table < 64 -> resize 16->32, + // 10th -> 32->64, 11th -> now table is 64 -> real treeification (bin head becomes TreeNode). + assertEquals(16, hmLen[8]); assertEquals("Node", hmBin[8]); + assertEquals(32, hmLen[9]); assertEquals("Node", hmBin[9]); + assertEquals(64, hmLen[10]); assertEquals("Node", hmBin[10]); + assertEquals(64, hmLen[11]); assertEquals("TreeNode", hmBin[11]); + + // ConcurrentHashMap: the 9th insert calls treeifyBin -> tryPresize overshoots 16 -> 128; + // the 10th insert sees table >= 64 and builds a TreeBin. + assertEquals(16, cmLen[8]); assertEquals("Node", cmBin[8]); + assertEquals(128, cmLen[9]); assertEquals("Node", cmBin[9]); + assertEquals(128, cmLen[10]); assertEquals("TreeBin", cmBin[10]); + assertEquals("TreeBin", cmBin[12]); + assertEquals(12, cm.size()); + assertEquals(12, hm.size()); + } +} diff --git a/hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/support/Transcript.java b/hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/support/Transcript.java new file mode 100644 index 0000000..a10f4fa --- /dev/null +++ b/hashmap-vs-concurrenthashmap/src/test/java/com/ankurm/interviewlab/hashmapvschm/support/Transcript.java @@ -0,0 +1,43 @@ +package com.ankurm.interviewlab.hashmapvschm.support; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.List; + +/** Writes output/NN-*.txt while the calling test asserts the same numbers it prints. */ +public final class Transcript { + private final Path path; + private final List lines = new ArrayList<>(); + + private Transcript(Path path, String header) { + this.path = path; + lines.add(header); + lines.add("=".repeat(Math.min(header.length(), 100))); + lines.add(""); + } + + public static Transcript start(String fileName, String header) { + Path dir = Path.of("output"); + try { + Files.createDirectories(dir); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + return new Transcript(dir.resolve(fileName), header); + } + + public Transcript line(String text) { lines.add(text); return this; } + public Transcript blank() { lines.add(""); return this; } + + public void flush() { + try { + Files.write(path, (String.join("\n", lines) + "\n").getBytes(StandardCharsets.UTF_8)); + } catch (IOException e) { + throw new UncheckedIOException(e); + } + } +}