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);
+ }
+ }
+}