Add hashmap-vs-concurrenthashmap module: JDK 25 sizeCtl/treeify internals, forced and measured lost updates

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_017ic4b7rnC7jppGGdHGESvy
This commit is contained in:
2026-10-28 18:07:37 +00:00
co-authored by Claude Sonnet 5.5
parent 715036b640
commit f80e1079a8
24 changed files with 958 additions and 0 deletions
+1
View File
@@ -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
+55
View File
@@ -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.
@@ -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
@@ -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'
@@ -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)
@@ -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)
@@ -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
@@ -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
@@ -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
@@ -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
+63
View File
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.ankurm.interviewlab</groupId>
<artifactId>hashmap-vs-concurrenthashmap</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>java-interview-lab :: hashmap-vs-concurrenthashmap</name>
<description>
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.
</description>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>6.1.3</junit.version>
<!-- Tests reflect into java.util.HashMap and java.util.concurrent.ConcurrentHashMap private
fields; JPMS refuses setAccessible(true) on them without these two flags. -->
<opens>--add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.util.concurrent=ALL-UNNAMED</opens>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit</groupId>
<artifactId>junit-bom</artifactId>
<version>${junit.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.junit.platform</groupId>
<artifactId>junit-platform-launcher</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.6.0</version>
<configuration>
<argLine>${opens} -Xmx512m -Duser.timezone=UTC -Dfile.encoding=UTF-8</argLine>
</configuration>
</plugin>
</plugins>
</build>
</project>
+15
View File
@@ -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
@@ -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<CollidingKey> {
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 + ")"; }
}
@@ -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);
}
}
}
@@ -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<Integer> 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<Long> 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<String, Integer> 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<String, Integer> 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<String, Integer> 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<String, Integer> 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<String, Integer> 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<String, Integer> 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<Integer, Integer> 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<Integer, Integer> 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);
}
}
@@ -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<CollidingKey, String> 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<CollidingKey, String> 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<CollidingKey, String> 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();
}
}
}
@@ -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<String, Integer> merge = new ConcurrentHashMap<>();
race(() -> { for (int i = 0; i < PER_THREAD; i++) merge.merge("n", 1, Integer::sum); });
ConcurrentHashMap<String, Integer> compute = new ConcurrentHashMap<>();
race(() -> { for (int i = 0; i < PER_THREAD; i++) compute.compute("n", (k, v) -> v == null ? 1 : v + 1); });
Map<String, Integer> 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"));
}
}
@@ -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<String, Integer> 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<String, Integer> 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"));
}
}
@@ -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"));
}
}
@@ -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<String, String> 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<String, String> 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();
}
}
@@ -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<CollidingKey, String> 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);
}
}
@@ -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<String, Integer> 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<String, Integer> 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<String, Integer> 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();
}
}
@@ -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<CollidingKey, Integer> hm = new HashMap<>();
ConcurrentHashMap<CollidingKey, Integer> 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());
}
}
@@ -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<String> 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);
}
}
}