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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
|
||||
+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