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:
+16
@@ -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 + ")"; }
|
||||
}
|
||||
+71
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
+141
@@ -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);
|
||||
}
|
||||
}
|
||||
+45
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+56
@@ -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"));
|
||||
}
|
||||
}
|
||||
+66
@@ -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"));
|
||||
}
|
||||
}
|
||||
+49
@@ -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"));
|
||||
}
|
||||
}
|
||||
+49
@@ -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();
|
||||
}
|
||||
}
|
||||
+43
@@ -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);
|
||||
}
|
||||
}
|
||||
+74
@@ -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();
|
||||
}
|
||||
}
|
||||
+56
@@ -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());
|
||||
}
|
||||
}
|
||||
+43
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user