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