hashmap-concurrenthashmap: companion code for the HashMap vs ConcurrentHashMap interview guide

Adds JMH put/get throughput benchmarks (synchronizedMap vs ConcurrentHashMap,
swept at 1/2/4/8 threads), a computeIfAbsent recursion trap demo (CHM tolerates
recursion into a different key but throws IllegalStateException on the same
key; plain HashMap throws ConcurrentModificationException even single-threaded),
and a null-handling demo (CHM rejects null keys/values on put() and null keys
on get() too). 7/7 JUnit tests pass; all output/ transcripts captured from
real runs.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01FhzLY5p6okFva3qsnsRyvM
This commit is contained in:
2026-09-30 09:29:06 +00:00
co-authored by Claude Sonnet 5
parent af615cc17c
commit 4a74153d87
16 changed files with 790 additions and 0 deletions
@@ -0,0 +1,46 @@
package com.ankurm.hashmapconcurrenthashmap;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* A recursive call back into the same map from inside a {@code computeIfAbsent} remapping
* function is a real, easy-to-write bug (a cache-of-caches pattern, a memoized recursive
* function backed by a map) with three genuinely different outcomes depending on the map type
* and the keys involved - none of which is "just works," and none of which is what most people
* guess without running it.
*/
public class ComputeIfAbsentRecursionDemo {
public static void main(String[] args) {
System.out.println("=== ConcurrentHashMap: computeIfAbsent recursing into a DIFFERENT key ===");
Map<Integer, Integer> chmDifferentKey = new ConcurrentHashMap<>();
try {
Integer result = chmDifferentKey.computeIfAbsent(1, k -> chmDifferentKey.computeIfAbsent(2, k2 -> 99));
System.out.println("no exception, result = " + result + ", map = " + chmDifferentKey);
} catch (Exception e) {
System.out.println("threw " + e.getClass().getName() + ": " + e.getMessage());
}
System.out.println();
System.out.println("=== HashMap: computeIfAbsent recursing into a DIFFERENT key (single-threaded!) ===");
Map<Integer, Integer> hmDifferentKey = new HashMap<>();
try {
Integer result = hmDifferentKey.computeIfAbsent(1, k -> hmDifferentKey.computeIfAbsent(2, k2 -> 99));
System.out.println("no exception, result = " + result + ", map = " + hmDifferentKey);
} catch (Exception e) {
System.out.println("threw " + e.getClass().getName() + ": " + e.getMessage());
}
System.out.println();
System.out.println("=== ConcurrentHashMap: computeIfAbsent recursing into the SAME key ===");
Map<Integer, Integer> chmSameKey = new ConcurrentHashMap<>();
try {
Integer result = chmSameKey.computeIfAbsent(1, k -> chmSameKey.computeIfAbsent(1, k2 -> 99));
System.out.println("no exception, result = " + result + ", map = " + chmSameKey);
} catch (Exception e) {
System.out.println("threw " + e.getClass().getName() + ": " + e.getMessage());
}
}
}
@@ -0,0 +1,52 @@
package com.ankurm.hashmapconcurrenthashmap;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
/**
* {@code HashMap} permits one null key and any number of null values. {@code ConcurrentHashMap}
* permits neither - and not just on {@code put()}: {@code get(null)} throws too, because it has
* to compute {@code null.hashCode()} to find the bucket, the same reason {@code put(null, v)}
* throws. The design reason (from the class javadoc): in a concurrent map, {@code get(key) == null}
* has to unambiguously mean "no mapping for this key" - if null were a legal value, that
* couldn't be distinguished from "the mapping is null" without extra synchronization the whole
* class exists to avoid.
*/
public class NullHandlingDemo {
public static void main(String[] args) {
System.out.println("=== HashMap: null key and null value are both fine ===");
Map<String, String> hm = new HashMap<>();
hm.put(null, "nullKeyValue");
hm.put("k1", null);
System.out.println("hm.get(null) = " + hm.get(null));
System.out.println("hm.get(\"k1\") = " + hm.get("k1"));
System.out.println();
System.out.println("=== ConcurrentHashMap: null key throws on put() ===");
Map<String, String> chm = new ConcurrentHashMap<>();
try {
chm.put(null, "x");
} catch (NullPointerException e) {
System.out.println("chm.put(null, \"x\") threw NullPointerException");
}
System.out.println();
System.out.println("=== ConcurrentHashMap: null value throws on put() ===");
try {
chm.put("k1", null);
} catch (NullPointerException e) {
System.out.println("chm.put(\"k1\", null) threw NullPointerException");
}
System.out.println();
System.out.println("=== ConcurrentHashMap: get(null) ALSO throws, not just put() ===");
chm.put("k1", "v1");
try {
chm.get(null);
} catch (NullPointerException e) {
System.out.println("chm.get(null) threw: " + e.getMessage());
}
}
}
@@ -0,0 +1,63 @@
package com.ankurm.hashmapconcurrenthashmap;
import org.openjdk.jmh.annotations.*;
import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
/**
* An 80% get / 20% put workload against a {@code Collections.synchronizedMap(new HashMap&lt;&gt;())}
* (the "make HashMap thread-safe the old way" answer) versus a {@link ConcurrentHashMap}, both
* pre-populated with 10,000 entries. Run at a fixed thread count per JVM invocation via
* {@code -t N}; {@code scripts/run-all.sh} sweeps 1, 2, 4, 8, 16, and 32 and captures each into
* {@code output/}. The honest expectation: at 1 thread they should be close (both are doing real
* work per operation, and {@code synchronizedMap} isn't paying for contention it doesn't have
* yet); the gap should open up as thread count climbs, because every operation on the
* synchronized wrapper - reads included - takes the same single monitor, while
* {@code ConcurrentHashMap}'s reads are lock-free and its writes only lock the bucket involved.
*/
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Warmup(iterations = 3, time = 1, timeUnit = TimeUnit.SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS)
@Fork(1)
@State(Scope.Benchmark)
public class PutGetBenchmark {
private static final int KEY_SPACE = 10_000;
private final Map<Integer, Integer> synchronizedHashMap = Collections.synchronizedMap(new HashMap<>());
private final Map<Integer, Integer> concurrentHashMap = new ConcurrentHashMap<>();
@Setup
public void fill() {
for (int i = 0; i < KEY_SPACE; i++) {
synchronizedHashMap.put(i, i);
concurrentHashMap.put(i, i);
}
}
@Benchmark
public Object synchronizedHashMapPutGet() {
ThreadLocalRandom r = ThreadLocalRandom.current();
int key = r.nextInt(KEY_SPACE);
if (r.nextInt(100) < 20) {
return synchronizedHashMap.put(key, key);
}
return synchronizedHashMap.get(key);
}
@Benchmark
public Object concurrentHashMapPutGet() {
ThreadLocalRandom r = ThreadLocalRandom.current();
int key = r.nextInt(KEY_SPACE);
if (r.nextInt(100) < 20) {
return concurrentHashMap.put(key, key);
}
return concurrentHashMap.get(key);
}
}
@@ -0,0 +1,64 @@
package com.ankurm.hashmapconcurrenthashmap;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
class MapClaimsTest {
@Test
void concurrentHashMapComputeIfAbsentToleratesRecursionIntoADifferentKey() {
Map<Integer, Integer> chm = new ConcurrentHashMap<>();
Integer result = chm.computeIfAbsent(1, k -> chm.computeIfAbsent(2, k2 -> 99));
assertEquals(99, result);
assertEquals(99, chm.get(1));
assertEquals(99, chm.get(2));
}
@Test
void hashMapComputeIfAbsentThrowsConcurrentModificationOnRecursionEvenSingleThreaded() {
Map<Integer, Integer> hm = new HashMap<>();
assertThrows(java.util.ConcurrentModificationException.class,
() -> hm.computeIfAbsent(1, k -> hm.computeIfAbsent(2, k2 -> 99)));
}
@Test
void concurrentHashMapComputeIfAbsentThrowsIllegalStateOnRecursionIntoTheSameKey() {
Map<Integer, Integer> chm = new ConcurrentHashMap<>();
assertThrows(IllegalStateException.class,
() -> chm.computeIfAbsent(1, k -> chm.computeIfAbsent(1, k2 -> 99)));
}
@Test
void hashMapAllowsOneNullKeyAndNullValues() {
Map<String, String> hm = new HashMap<>();
hm.put(null, "nullKeyValue");
hm.put("k1", null);
assertEquals("nullKeyValue", hm.get(null));
assertEquals(null, hm.get("k1"));
}
@Test
void concurrentHashMapRejectsNullKeyOnPut() {
Map<String, String> chm = new ConcurrentHashMap<>();
assertThrows(NullPointerException.class, () -> chm.put(null, "x"));
}
@Test
void concurrentHashMapRejectsNullValueOnPut() {
Map<String, String> chm = new ConcurrentHashMap<>();
assertThrows(NullPointerException.class, () -> chm.put("k1", null));
}
@Test
void concurrentHashMapRejectsNullKeyOnGetToo() {
Map<String, String> chm = new ConcurrentHashMap<>();
chm.put("k1", "v1");
assertThrows(NullPointerException.class, () -> chm.get(null));
}
}