Add collections module: HashMap bucket layout, treeification, and a real ConcurrentModificationException reproduction

Companion code for the ankurm.com post "Top 40 Java Collections Interview Questions
(HashMap Internals to Fail-Fast Iterators)". Verified against JDK 25's own HashMap
source (TREEIFY_THRESHOLD=8, UNTREEIFY_THRESHOLD=6, MIN_TREEIFY_CAPACITY=64), with
every claim backed by a committed docs/output/*.txt transcript from a real run.

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YXCrLgRKFgCh9RHKW8xaqJ
This commit is contained in:
2026-10-01 04:27:27 +00:00
co-authored by Claude Sonnet 5
commit d4b3a63615
27 changed files with 1085 additions and 0 deletions
@@ -0,0 +1,8 @@
Exception in thread "main" java.lang.reflect.InaccessibleObjectException: Unable to make field transient java.util.HashMap$Node[] java.util.HashMap.table accessible: module java.base does not "opens java.util" to unnamed module @1dbd16a6
at java.base/java.lang.reflect.AccessibleObject.throwInaccessibleObjectException(AccessibleObject.java:353)
at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:329)
at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:277)
at java.base/java.lang.reflect.Field.checkCanSetAccessible(Field.java:179)
at java.base/java.lang.reflect.Field.setAccessible(Field.java:173)
at com.ankurm.interviewlab.collections.BucketInspector.dump(BucketInspector.java:41)
at com.ankurm.interviewlab.collections.ReflectionAddOpensDemo.main(ReflectionAddOpensDemo.java:23)
@@ -0,0 +1,5 @@
reflection succeeded: 2 non-empty bucket(s)
table.length = 16 | non-empty buckets = 2
bucket[ 7] chain=1 kind=Node
bucket[ 14] chain=1 kind=Node
@@ -0,0 +1,17 @@
Real bucket layout of a 6-entry HashMap<String,Integer> (default capacity)
==========================================================================
map.size() = 6
table.length = 16 | non-empty buckets = 4
bucket[ 0] chain=2 kind=Node
bucket[ 1] chain=2 kind=Node
bucket[ 4] chain=1 kind=Node
bucket[ 5] chain=1 kind=Node
Index for each key, computed the same way HashMap.hash() does it:
apple hashCode=93029210 spread=93030097 index=(16-1)&spread=1
banana hashCode=-1396355227 spread=-1396317280 index=(16-1)&spread=0
cherry hashCode=-1361513063 spread=-1361552575 index=(16-1)&spread=1
date hashCode=3076014 spread=3075968 index=(16-1)&spread=0
egg hashCode=100357 spread=100356 index=(16-1)&spread=4
fig hashCode=101380 spread=101381 index=(16-1)&spread=5
@@ -0,0 +1,11 @@
Forcing real treeification: 20 keys with hashCode()==1, loadFactor=1000f (size-resize disabled)
===============================================================================================
All keys hash to the same bucket (see CollidingKey javadoc for why a resize can't separate them).
after inserting key #1 : table.length=16 bucket[1] chain=1 kind=Node
after inserting key #9 : table.length=32 bucket[1] chain=9 kind=Node
after inserting key #10 : table.length=64 bucket[1] chain=10 kind=Node
after inserting key #11 : table.length=64 bucket[1] chain=11 kind=TreeNode
Final state: table.length=64, bucket[1] kind=TreeNode, first became a tree at insert #11
@@ -0,0 +1,14 @@
Classic ConcurrentModificationException: for-each + Collection.remove() mid-loop
================================================================================
list before: [1, 2, 3, 4, 5]
threw: java.util.ConcurrentModificationException
stack trace (trimmed to the ArrayList$Itr frames that matter):
java.util.ConcurrentModificationException
at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1096)
at java.base/java.util.ArrayList$Itr.next(ArrayList.java:1050)
at com.ankurm.interviewlab.collections.ConcurrentModificationTest.lambda$classicForEachPlusCollectionRemoveThrows$0(ConcurrentModificationTest.java:28)
at com.ankurm.interviewlab.collections.ConcurrentModificationTest.classicForEachPlusCollectionRemoveThrows(ConcurrentModificationTest.java:27)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1604)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1604)
@@ -0,0 +1,13 @@
The fail-fast contract is 'best-effort', not guaranteed: a case that does NOT throw
===================================================================================
list before: [10, 20] (size=2)
elements the loop actually saw before ending: [10]
list after the loop: [20]
ConcurrentModificationException thrown: false
Why: after removing 10, size becomes 1 and the iterator's cursor is already 1
(it advanced to 1 when next() returned 10). ArrayList.Itr.hasNext() is just
'return cursor != size;' - no modCount check - so hasNext() sees 1 != 1, returns
false, and the loop ends normally. next() is the only method that checks
modCount, and it is never called again. The element 20 is silently never visited.
@@ -0,0 +1,8 @@
ConcurrentHashMap's iterator is weakly consistent: it never throws CME
======================================================================
map before: {0=v0, 1=v1, 2=v2, 3=v3, 4=v4}
entries visited during iteration (original 5, plus possibly some newly-added ones): 19
map after: 24 entries total
ConcurrentModificationException thrown: false
(weakly consistent: may or may not reflect the mutation, but is guaranteed never to throw)
@@ -0,0 +1,5 @@
The fix: Iterator.remove() instead of Collection.remove() during iteration
==========================================================================
list before: [1, 2, 3, 4, 5]
list after removing all even numbers via Iterator.remove(): [1, 3, 5]
@@ -0,0 +1,10 @@
equals() overridden without hashCode(): a == b is true, but hashCode() differs
==============================================================================
a.equals(b) = true
a.hashCode() = 1390869998
b.hashCode() = 1820383114
(these should be IDENTICAL per the contract - they are not, because hashCode() was never overridden)
HashSet<EqualsOnlyPoint> after adding two equal-but-differently-hashed points: size=2
HashSet<CorrectPoint> after adding two equal, correctly-hashed points: size=1
@@ -0,0 +1,9 @@
Mutating a key's hash-relevant field after insertion: the entry is still THERE, just unfindable
===============================================================================================
map.put(key(tag=42), ...) then key.setTag(99)
containsKey(new MutableKey(42)) before mutation : true
containsKey(new MutableKey(42)) after mutation : false (looks for old hash's bucket - key no longer hashes there)
containsKey(new MutableKey(99)) after mutation : false (looks in the NEW hash's bucket - key was never filed there either)
map.size() is still : 1 (the entry was never removed!)
the mutated key object IS still found by direct iteration over keySet(): true
+57
View File
@@ -0,0 +1,57 @@
<?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>collections</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<name>java-interview-lab :: collections</name>
<description>
Runnable companion code for ankurm.com's "Top 40 Java Collections Interview Questions"
article: HashMap bucket-layout dumps via reflection, a real treeification trigger, a real
ConcurrentModificationException reproduction (and a real case where fail-fast silently does
NOT fire), and equals/hashCode contract-violation demos.
</description>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<junit.version>5.11.0</junit.version>
</properties>
<dependencies>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>${junit.version}</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<finalName>collections</finalName>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-surefire-plugin</artifactId>
<version>3.3.1</version>
<configuration>
<!-- Transcript tests reflect into java.util.HashMap's internal table field;
the JPMS module system refuses setAccessible(true) on a JDK-internal
field across module boundaries without this. Forgetting it is the
single most common failure mode for this module (see README). -->
<argLine>--add-opens java.base/java.util=ALL-UNNAMED</argLine>
</configuration>
</plugin>
</plugins>
</build>
</project>
+31
View File
@@ -0,0 +1,31 @@
#!/usr/bin/env bash
# Regenerates every file in docs/output/ from scratch. Run from the module root
# (java-interview-lab/collections).
#
# Needs JDK 25 (LTS) on PATH as `java`/`javac`, and a `mvn` that can reach Maven Central the
# first time (surefire/compiler plugins must be cached locally before -o works).
set -eu
cd "$(dirname "$0")/.."
echo "== compiling =="
mvn -q -B compile
echo "== 01/02: reflection into HashMap internals, with and without --add-opens =="
mkdir -p docs/output
java -cp target/classes com.ankurm.interviewlab.collections.ReflectionAddOpensDemo \
> docs/output/01-reflection-without-add-opens.txt 2>&1 || true
java --add-opens java.base/java.util=ALL-UNNAMED -cp target/classes \
com.ankurm.interviewlab.collections.ReflectionAddOpensDemo \
> docs/output/02-reflection-with-add-opens.txt 2>&1
# Strip the sandbox's own proxy/trust-store banner (JAVA_TOOL_OPTIONS) - it is environment
# noise, not part of either demonstration, and should not be in a committed transcript.
for f in docs/output/01-reflection-without-add-opens.txt docs/output/02-reflection-with-add-opens.txt; do
grep -v '^Picked up JAVA_TOOL_OPTIONS' "$f" > "$f.tmp" && mv "$f.tmp" "$f"
done
echo "== 03-10: JUnit-driven transcripts (BucketLayoutTest, TreeifyTriggerTest, ConcurrentModificationTest, EqualsHashCodeContractTest) =="
mvn -q -B test
echo "== done - docs/output now contains: =="
ls -1 docs/output
+32
View File
@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Runs one named scenario and prints it to stdout (does not touch docs/output - use
# run-all.sh to regenerate the committed transcripts). Examples:
#
# ./scripts/run.sh add-opens-fail # reflection WITHOUT --add-opens (crashes on purpose)
# ./scripts/run.sh add-opens-ok # reflection WITH --add-opens
# ./scripts/run.sh treeify # run just the treeification test, verbose
# ./scripts/run.sh cme # run just the ConcurrentModificationException tests
set -eu
cd "$(dirname "$0")/.."
scenario="${1:?usage: run.sh <add-opens-fail|add-opens-ok|treeify|cme|eqhash|bucket>}"
mvn -q -B compile
case "$scenario" in
add-opens-fail)
java -cp target/classes com.ankurm.interviewlab.collections.ReflectionAddOpensDemo ;;
add-opens-ok)
java --add-opens java.base/java.util=ALL-UNNAMED -cp target/classes \
com.ankurm.interviewlab.collections.ReflectionAddOpensDemo ;;
treeify)
mvn -B test -Dtest=TreeifyTriggerTest ;;
cme)
mvn -B test -Dtest=ConcurrentModificationTest ;;
eqhash)
mvn -B test -Dtest=EqualsHashCodeContractTest ;;
bucket)
mvn -B test -Dtest=BucketLayoutTest ;;
*)
echo "unknown scenario: $scenario" >&2; exit 1 ;;
esac
@@ -0,0 +1,106 @@
package com.ankurm.interviewlab.collections;
import java.lang.reflect.Array;
import java.lang.reflect.Field;
import java.lang.reflect.InaccessibleObjectException;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
/**
* Reflects into {@link java.util.HashMap}'s private {@code table} field to print the real
* bucket layout: how many buckets are non-empty, how long each chain is, and whether a bucket
* holds plain {@code Node} objects or has been converted to {@code TreeNode}s (a red-black
* tree). There is no public API for any of this &mdash; {@code HashMap} deliberately exposes
* none of it &mdash; so the only way to see it is to look at the private fields directly.
*
* <p><strong>This needs {@code --add-opens java.base/java.util=ALL-UNNAMED}.</strong> Since
* Java 9, {@code setAccessible(true)} on a private field of a JDK class throws
* {@link InaccessibleObjectException} unless the owning module opens that package for deep
* reflection to the caller's module. {@code java.util} is exported (its public classes are
* usable normally) but not <em>opened</em> by {@code java.base} by default. Run this class, or
* the test suite, without the flag and see for yourself &mdash; {@code run.sh} demonstrates both.
*/
public final class BucketInspector {
private BucketInspector() {}
public record BucketRow(int index, int chainLength, String nodeKind) {}
/**
* Dumps every non-empty bucket of {@code map}'s current internal table.
*
* @throws InaccessibleObjectException (unchecked, thrown directly by
* {@code Field.setAccessible}, not wrapped) if the JVM was not started with
* {@code --add-opens java.base/java.util=ALL-UNNAMED} - see
* {@code docs/output/01-reflection-without-add-opens.txt} for the real stack trace
*/
public static List<BucketRow> dump(HashMap<?, ?> map) {
try {
Field tableField = HashMap.class.getDeclaredField("table");
tableField.setAccessible(true); // <-- throws InaccessibleObjectException without --add-opens
Object table = tableField.get(map);
if (table == null) {
return List.of();
}
int length = Array.getLength(table);
List<BucketRow> rows = new ArrayList<>();
for (int i = 0; i < length; i++) {
Object node = Array.get(table, i);
if (node == null) {
continue;
}
int chain = 0;
String kind = node.getClass().getSimpleName(); // "Node" or "TreeNode"
Object cursor = node;
Field nextField = firstNodeClassOf(node).getDeclaredField("next");
nextField.setAccessible(true);
while (cursor != null) {
chain++;
cursor = nextField.get(cursor);
}
rows.add(new BucketRow(i, chain, kind));
}
return rows;
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new IllegalStateException("reflection into HashMap internals failed", e);
}
}
/** Capacity of the current internal table (the array length, not {@code size()}). */
public static int tableLength(HashMap<?, ?> map) {
try {
Field tableField = HashMap.class.getDeclaredField("table");
tableField.setAccessible(true);
Object table = tableField.get(map);
return table == null ? 0 : Array.getLength(table);
} catch (NoSuchFieldException | IllegalAccessException e) {
throw new IllegalStateException("reflection into HashMap internals failed", e);
}
}
private static Class<?> firstNodeClassOf(Object node) {
// Node.next is declared on java.util.HashMap$Node itself; TreeNode extends
// LinkedHashMap$Entry extends HashMap$Node, so walking up to HashMap$Node always
// finds a declared `next` field regardless of which concrete subtype we were handed.
Class<?> c = node.getClass();
while (c != null && !c.getName().equals("java.util.HashMap$Node")) {
c = c.getSuperclass();
}
if (c == null) {
throw new IllegalStateException("not a HashMap node: " + node.getClass());
}
return c;
}
public static String render(List<BucketRow> rows, int tableLength) {
StringBuilder sb = new StringBuilder();
sb.append("table.length = ").append(tableLength)
.append(" | non-empty buckets = ").append(rows.size()).append("\n");
for (BucketRow row : rows) {
sb.append(String.format(" bucket[%3d] chain=%-3d kind=%s%n",
row.index(), row.chainLength(), row.nodeKind()));
}
return sb.toString();
}
}
@@ -0,0 +1,44 @@
package com.ankurm.interviewlab.collections;
import java.util.Objects;
/**
* A key whose {@code hashCode()} always returns the same constant, regardless of how many of
* these keys exist or how the owning {@code HashMap} resizes. {@code equals()} is still a real,
* per-instance comparison (by {@code id}), so each instance is a genuinely distinct map entry
* &mdash; they just all collide into the same bucket.
*
* <p>Why a constant works regardless of table capacity: {@code HashMap} computes a bucket index
* as {@code (tableLength - 1) & spreadHash}, and {@code tableLength} is always a power of two.
* {@code spreadHash} for a key with {@code hashCode() == 1} is {@code 1 ^ (1 >>> 16) == 1}, and
* {@code (n - 1) & 1 == 1} for every power-of-two {@code n >= 2}. So these keys land in bucket 1
* at capacity 16, bucket 1 at capacity 64, bucket 1 at capacity 1024 &mdash; a resize never
* spreads them apart, which is exactly what is needed to force a real treeification.
*/
public final class CollidingKey {
private final int id;
public CollidingKey(int id) {
this.id = id;
}
@Override
public int hashCode() {
return 1; // deliberately constant
}
@Override
public boolean equals(Object o) {
return o instanceof CollidingKey other && other.id == id;
}
@Override
public String toString() {
return "CollidingKey#" + id;
}
public int id() {
return id;
}
}
@@ -0,0 +1,27 @@
package com.ankurm.interviewlab.collections;
import java.util.HashMap;
import java.util.List;
/**
* Standalone entry point run twice by {@code scripts/run.sh}: once as plain {@code java
* -cp ...}, once with {@code --add-opens java.base/java.util=ALL-UNNAMED}. The first run is
* expected to crash with an uncaught {@link java.lang.reflect.InaccessibleObjectException} -
* it is unchecked and is thrown directly out of {@code Field.setAccessible}, so there is
* nothing to usefully catch here. That crash, printed by the JVM's own default uncaught
* exception handler, is captured verbatim as {@code docs/output/01-reflection-without-add-opens.txt}.
*/
public final class ReflectionAddOpensDemo {
private ReflectionAddOpensDemo() {}
public static void main(String[] args) {
HashMap<String, Integer> map = new HashMap<>();
map.put("alpha", 1);
map.put("beta", 2);
List<BucketInspector.BucketRow> rows = BucketInspector.dump(map);
System.out.println("reflection succeeded: " + rows.size() + " non-empty bucket(s)");
System.out.println(BucketInspector.render(rows, BucketInspector.tableLength(map)));
}
}
@@ -0,0 +1,30 @@
package com.ankurm.interviewlab.collections.eqhash;
import java.util.Objects;
/** The fixed version of {@link EqualsOnlyPoint}: both methods overridden, both consistent. */
public final class CorrectPoint {
private final int x;
private final int y;
public CorrectPoint(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public boolean equals(Object o) {
return o instanceof CorrectPoint p && p.x == x && p.y == y;
}
@Override
public int hashCode() {
return Objects.hash(x, y);
}
@Override
public String toString() {
return "(" + x + "," + y + ")";
}
}
@@ -0,0 +1,31 @@
package com.ankurm.interviewlab.collections.eqhash;
/**
* Overrides {@code equals()} but deliberately NOT {@code hashCode()} &mdash; the single most
* common way to break the equals/hashCode contract. Two instances that are equal by {@code
* equals()} end up with different {@code hashCode()} values (the inherited identity hash),
* which is exactly what the contract forbids. See {@code EqualsHashCodeContractTest} for what
* this actually does to a {@code HashSet}.
*/
public final class EqualsOnlyPoint {
private final int x;
private final int y;
public EqualsOnlyPoint(int x, int y) {
this.x = x;
this.y = y;
}
@Override
public boolean equals(Object o) {
return o instanceof EqualsOnlyPoint p && p.x == x && p.y == y;
}
// hashCode() intentionally NOT overridden - uses Object's identity hash.
@Override
public String toString() {
return "(" + x + "," + y + ")";
}
}
@@ -0,0 +1,42 @@
package com.ankurm.interviewlab.collections.eqhash;
import java.util.Objects;
/**
* A key whose {@code hashCode()} depends on a field that can be changed after the key has
* already been inserted into a {@code HashMap} or {@code HashSet}. The contract technically
* allows this (nothing stops you overriding {@code equals}/{@code hashCode} on a mutable
* field) &mdash; it is just a trap, because the collection has already filed the entry under
* the bucket computed from the OLD hash code and never gets a chance to refile it.
*/
public final class MutableKey {
private int tag;
public MutableKey(int tag) {
this.tag = tag;
}
public void setTag(int tag) {
this.tag = tag;
}
public int getTag() {
return tag;
}
@Override
public boolean equals(Object o) {
return o instanceof MutableKey m && m.tag == tag;
}
@Override
public int hashCode() {
return Objects.hash(tag);
}
@Override
public String toString() {
return "MutableKey(tag=" + tag + ")";
}
}
@@ -0,0 +1,59 @@
package com.ankurm.interviewlab.collections;
import com.ankurm.interviewlab.collections.support.Transcript;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Dumps the real bucket layout of a small HashMap. Requires the test JVM to run with
* {@code --add-opens java.base/java.util=ALL-UNNAMED} (set in pom.xml's surefire config) -
* see {@link ReflectionAddOpensDemo} and {@code scripts/run.sh} for what happens without it.
*/
class BucketLayoutTest {
@Test
void capacitySixteenHasEmptyAndFullBuckets() {
// Default capacity 16, default load factor 0.75f -> threshold 12.
HashMap<String, Integer> map = new HashMap<>();
String[] keys = {"apple", "banana", "cherry", "date", "egg", "fig"};
for (int i = 0; i < keys.length; i++) {
map.put(keys[i], i);
}
List<BucketInspector.BucketRow> rows = BucketInspector.dump(map);
int tableLength = BucketInspector.tableLength(map);
Transcript t = Transcript.start("03-bucket-layout-small-map.txt",
"Real bucket layout of a 6-entry HashMap<String,Integer> (default capacity)");
t.line("map.size() = " + map.size());
t.line(BucketInspector.render(rows, tableLength));
t.line("Index for each key, computed the same way HashMap.hash() does it:");
for (String k : keys) {
int h = k.hashCode();
int spread = h ^ (h >>> 16);
int index = (tableLength - 1) & spread;
t.line(String.format(" %-8s hashCode=%-12d spread=%-12d index=(%d-1)&spread=%d",
k, h, spread, tableLength, index));
}
t.flush();
assertEquals(16, tableLength, "default capacity before any resize");
assertTrue(rows.size() <= 6, "cannot have more non-empty buckets than entries");
int totalChain = rows.stream().mapToInt(BucketInspector.BucketRow::chainLength).sum();
assertEquals(6, totalChain, "every entry must be accounted for across all chains");
}
@Test
void sameBucketIndexSurvivesEveryPowerOfTwoCapacity() {
// This is the mechanical fact CollidingKey relies on, pinned directly.
for (int n : new int[]{16, 32, 64, 128, 1024}) {
int spread = 1 ^ (1 >>> 16); // hashCode()==1 for CollidingKey
int index = (n - 1) & spread;
assertEquals(1, index, "capacity " + n + " must still route hashCode()==1 to bucket 1");
}
}
}
@@ -0,0 +1,140 @@
package com.ankurm.interviewlab.collections;
import com.ankurm.interviewlab.collections.support.Transcript;
import org.junit.jupiter.api.Test;
import java.io.PrintWriter;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.ConcurrentModificationException;
import java.util.HashMap;
import java.util.Iterator;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import static org.junit.jupiter.api.Assertions.*;
class ConcurrentModificationTest {
@Test
void classicForEachPlusCollectionRemoveThrows() {
List<Integer> list = new ArrayList<>(List.of(1, 2, 3, 4, 5));
Transcript t = Transcript.start("05-cme-classic-reproduction.txt",
"Classic ConcurrentModificationException: for-each + Collection.remove() mid-loop");
t.line("list before: " + list);
ConcurrentModificationException thrown = assertThrows(ConcurrentModificationException.class, () -> {
for (Integer i : list) {
if (i == 3) {
list.remove(i); // structural modification NOT through the iterator
}
}
});
StringWriter sw = new StringWriter();
thrown.printStackTrace(new PrintWriter(sw));
t.line("threw: " + thrown);
t.blank();
t.line("stack trace (trimmed to the ArrayList$Itr frames that matter):");
for (String line : sw.toString().split("\n")) {
if (line.contains("ArrayList") || line.contains("ConcurrentModificationTest") || line.startsWith("java.util.ConcurrentModificationException")) {
t.line(line.strip());
}
}
t.flush();
}
@Test
void removingTheSecondToLastElementDoesNotThrow() {
// The well-known "fail-fast is best-effort" trap, reproduced for real: removing the
// only remaining element such that cursor ends up equal to the new size means
// hasNext() (which only compares cursor != size, see ArrayList.Itr.hasNext() source)
// returns false BEFORE next() is ever called again - and next() is the only method
// that checks modCount. No exception is thrown. The loop just silently ends early.
List<Integer> list = new ArrayList<>(List.of(10, 20));
Transcript t = Transcript.start("06-cme-silent-non-reproduction.txt",
"The fail-fast contract is 'best-effort', not guaranteed: a case that does NOT throw");
t.line("list before: " + list + " (size=" + list.size() + ")");
List<Integer> seen = new ArrayList<>();
boolean threw = false;
try {
for (Integer i : list) {
seen.add(i);
if (i == 10) {
list.remove(i); // removes the LAST remaining element after this one
}
}
} catch (ConcurrentModificationException e) {
threw = true;
}
t.line("elements the loop actually saw before ending: " + seen);
t.line("list after the loop: " + list);
t.line("ConcurrentModificationException thrown: " + threw);
t.blank();
t.line("Why: after removing 10, size becomes 1 and the iterator's cursor is already 1");
t.line("(it advanced to 1 when next() returned 10). ArrayList.Itr.hasNext() is just");
t.line("'return cursor != size;' - no modCount check - so hasNext() sees 1 != 1, returns");
t.line("false, and the loop ends normally. next() is the only method that checks");
t.line("modCount, and it is never called again. The element 20 is silently never visited.");
t.flush();
assertFalse(threw, "this specific removal pattern is documented to NOT throw - that's the trap");
assertEquals(List.of(10), seen, "the loop must have silently stopped after seeing only the first element");
assertEquals(List.of(20), list, "20 is still in the list - it was just never visited by the broken loop");
}
@Test
void iteratorRemoveIsTheSafeWay() {
List<Integer> list = new ArrayList<>(List.of(1, 2, 3, 4, 5));
Transcript t = Transcript.start("08-iterator-remove-safe.txt",
"The fix: Iterator.remove() instead of Collection.remove() during iteration");
t.line("list before: " + list);
Iterator<Integer> it = list.iterator();
while (it.hasNext()) {
if (it.next() % 2 == 0) {
it.remove(); // keeps expectedModCount in sync - see ArrayList.Itr.remove() source
}
}
t.line("list after removing all even numbers via Iterator.remove(): " + list);
t.flush();
assertEquals(List.of(1, 3, 5), list);
}
@Test
void concurrentHashMapIteratorNeverThrowsCme() {
Map<Integer, String> map = new ConcurrentHashMap<>();
for (int i = 0; i < 5; i++) {
map.put(i, "v" + i);
}
Transcript t = Transcript.start("07-cme-concurrenthashmap-no-throw.txt",
"ConcurrentHashMap's iterator is weakly consistent: it never throws CME");
t.line("map before: " + map);
boolean threw = false;
int seen = 0;
try {
for (Map.Entry<Integer, String> e : map.entrySet()) {
seen++;
map.put(100 + e.getKey(), "added-during-iteration-" + e.getKey());
}
} catch (ConcurrentModificationException e) {
threw = true;
}
t.line("entries visited during iteration (original 5, plus possibly some newly-added ones): " + seen);
t.line("map after: " + map.size() + " entries total");
t.line("ConcurrentModificationException thrown: " + threw);
t.line("(weakly consistent: may or may not reflect the mutation, but is guaranteed never to throw)");
t.flush();
assertFalse(threw);
}
}
@@ -0,0 +1,80 @@
package com.ankurm.interviewlab.collections;
import com.ankurm.interviewlab.collections.eqhash.CorrectPoint;
import com.ankurm.interviewlab.collections.eqhash.EqualsOnlyPoint;
import com.ankurm.interviewlab.collections.eqhash.MutableKey;
import com.ankurm.interviewlab.collections.support.Transcript;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.HashSet;
import static org.junit.jupiter.api.Assertions.*;
class EqualsHashCodeContractTest {
@Test
void overridingEqualsWithoutHashCodeBreaksHashSetDeduplication() {
EqualsOnlyPoint a = new EqualsOnlyPoint(3, 4);
EqualsOnlyPoint b = new EqualsOnlyPoint(3, 4);
HashSet<EqualsOnlyPoint> broken = new HashSet<>();
broken.add(a);
broken.add(b);
HashSet<CorrectPoint> fixed = new HashSet<>();
fixed.add(new CorrectPoint(3, 4));
fixed.add(new CorrectPoint(3, 4));
Transcript t = Transcript.start("09-equals-hashcode-broken-contract.txt",
"equals() overridden without hashCode(): a == b is " + a.equals(b) + ", but hashCode() differs");
t.line("a.equals(b) = " + a.equals(b));
t.line("a.hashCode() = " + a.hashCode());
t.line("b.hashCode() = " + b.hashCode());
t.line("(these should be IDENTICAL per the contract - they are not, because hashCode() was never overridden)");
t.blank();
t.line("HashSet<EqualsOnlyPoint> after adding two equal-but-differently-hashed points: size=" + broken.size());
t.line("HashSet<CorrectPoint> after adding two equal, correctly-hashed points: size=" + fixed.size());
t.flush();
assertTrue(a.equals(b), "the two points ARE equal by the overridden equals()");
assertNotEquals(a.hashCode(), b.hashCode(),
"identity hashCode() differs across instances - this IS the contract violation");
assertEquals(2, broken.size(), "HashSet treats them as different buckets/entries - dedup silently fails");
assertEquals(1, fixed.size(), "with hashCode() overridden consistently, dedup works as expected");
}
@Test
void mutatingAKeyAfterInsertionLosesTheEntry() {
MutableKey key = new MutableKey(42);
HashMap<MutableKey, String> map = new HashMap<>();
map.put(key, "original-value");
boolean foundBeforeMutation = map.containsKey(new MutableKey(42));
key.setTag(99); // mutate the field hashCode() depends on, AFTER insertion
boolean foundAfterMutationBySameTag = map.containsKey(new MutableKey(42));
boolean foundByNewTag = map.containsKey(new MutableKey(99));
boolean stillInSize = map.size() == 1;
boolean findableByIteration = map.keySet().stream().anyMatch(k -> k == key);
Transcript t = Transcript.start("10-mutable-key-lost-entry.txt",
"Mutating a key's hash-relevant field after insertion: the entry is still THERE, just unfindable");
t.line("map.put(key(tag=42), ...) then key.setTag(99)");
t.line("containsKey(new MutableKey(42)) before mutation : " + foundBeforeMutation);
t.line("containsKey(new MutableKey(42)) after mutation : " + foundAfterMutationBySameTag
+ " (looks for old hash's bucket - key no longer hashes there)");
t.line("containsKey(new MutableKey(99)) after mutation : " + foundByNewTag
+ " (looks in the NEW hash's bucket - key was never filed there either)");
t.line("map.size() is still : " + map.size() + " (the entry was never removed!)");
t.line("the mutated key object IS still found by direct iteration over keySet(): " + findableByIteration);
t.flush();
assertTrue(foundBeforeMutation, "sanity check: lookup works before mutation");
assertFalse(foundAfterMutationBySameTag, "looking under the OLD hash's bucket for the key-as-it-now-is fails");
assertFalse(foundByNewTag, "looking under the NEW hash's bucket also fails - it was filed under the old one");
assertTrue(stillInSize, "the entry is not gone, just unreachable by key lookup");
assertTrue(findableByIteration, "proves the entry object is still physically present in the table");
}
}
@@ -0,0 +1,91 @@
package com.ankurm.interviewlab.collections;
import com.ankurm.interviewlab.collections.support.Transcript;
import org.junit.jupiter.api.Test;
import java.util.HashMap;
import java.util.List;
import static org.junit.jupiter.api.Assertions.*;
/**
* Reproduces real treeification against the actual JDK 25 {@code java.util.HashMap}, verified
* against its source (TREEIFY_THRESHOLD=8, UNTREEIFY_THRESHOLD=6, MIN_TREEIFY_CAPACITY=64 - see
* README). A high load factor keeps the ordinary size/threshold resize out of the way, so every
* capacity change observed here comes from exactly one place: {@code HashMap.treeifyBin()}
* calling {@code resize()} because the table was still smaller than {@code MIN_TREEIFY_CAPACITY}.
*/
class TreeifyTriggerTest {
@Test
void treeifyBinResizesTwiceBeforeItActuallyTreeifies() {
// loadFactor=1000f means threshold = 16 * 1000 = 16000 after lazy init -
// size-based resizing is effectively disabled for this test's ~20 entries.
HashMap<CollidingKey, Integer> map = new HashMap<>(16, 1000f);
Transcript t = Transcript.start("04-treeify-trigger.txt",
"Forcing real treeification: 20 keys with hashCode()==1, loadFactor=1000f (size-resize disabled)");
t.line("All keys hash to the same bucket (see CollidingKey javadoc for why a resize can't separate them).");
t.blank();
int lastTableLength = -1;
String lastKind = "";
int firstTreeNodeAtInsert = -1;
for (int i = 1; i <= 20; i++) {
map.put(new CollidingKey(i), i);
int tableLength = BucketInspector.tableLength(map);
List<BucketInspector.BucketRow> rows = BucketInspector.dump(map);
BucketInspector.BucketRow bucket1 = rows.stream()
.filter(r -> r.index() == 1)
.findFirst()
.orElseThrow();
boolean changed = tableLength != lastTableLength || !bucket1.nodeKind().equals(lastKind);
if (changed) {
t.line(String.format("after inserting key #%-2d : table.length=%-4d bucket[1] chain=%-3d kind=%s",
i, tableLength, bucket1.chainLength(), bucket1.nodeKind()));
}
if (firstTreeNodeAtInsert < 0 && bucket1.nodeKind().equals("TreeNode")) {
firstTreeNodeAtInsert = i;
}
lastTableLength = tableLength;
lastKind = bucket1.nodeKind();
}
t.blank();
t.line("Final state: table.length=" + lastTableLength + ", bucket[1] kind=" + lastKind
+ ", first became a tree at insert #" + firstTreeNodeAtInsert);
t.flush();
assertEquals(64, lastTableLength,
"treeifyBin() should have doubled capacity 16->32->64 before being able to treeify");
assertEquals("TreeNode", lastKind, "bucket should have converted to a red-black tree");
assertTrue(firstTreeNodeAtInsert >= 9 && firstTreeNodeAtInsert <= 13,
"treeification should kick in once the bin crosses TREEIFY_THRESHOLD at capacity>=64, " +
"actual insert index was " + firstTreeNodeAtInsert);
}
@Test
void smallTableNeverTreeifiesEvenPastThreshold() {
// Same colliding keys, but capped at capacity 16 by using a tiny maximum via a
// deliberately small initial capacity AND disabling growth is not directly possible on
// a stock HashMap - instead this test demonstrates the real guard by reading
// MIN_TREEIFY_CAPACITY behaviour indirectly: at the moment treeifyBin would run on a
// table smaller than 64, HashMap.resize() runs INSTEAD of treeifying. We already see
// that in the test above at inserts #9 and #10 (capacity goes 16->32->64 instead of
// converting to a tree at 16 or 32). This test just pins the two intermediate states.
HashMap<CollidingKey, Integer> map = new HashMap<>(16, 1000f);
for (int i = 1; i <= 9; i++) {
map.put(new CollidingKey(i), i);
}
// treeifyBin fired once already (at insert #9) but table was 16 < 64, so it resized to 32
// instead of treeifying - bucket 1 must still be plain Node objects, not a tree.
List<BucketInspector.BucketRow> rows = BucketInspector.dump(map);
BucketInspector.BucketRow bucket1 = rows.stream().filter(r -> r.index() == 1).findFirst().orElseThrow();
assertEquals(32, BucketInspector.tableLength(map));
assertEquals("Node", bucket1.nodeKind(),
"below MIN_TREEIFY_CAPACITY, HashMap must resize instead of treeifying, per treeifyBin() source");
}
}
@@ -0,0 +1,76 @@
package com.ankurm.interviewlab.collections.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 a numbered transcript file under {@code docs/output/} while a test asserts the same
* numbers it is printing. Every figure quoted in the blog post traces back to one of these
* files, produced by a real JUnit run, not retyped by hand.
*
* <p>Usage from a test:
* <pre>{@code
* Transcript t = Transcript.start("05-cme-classic-reproduction.txt",
* "Classic ConcurrentModificationException: for-each + Collection.remove()");
* t.line("list before: " + list);
* ...
* t.flush();
* }</pre>
*/
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("docs", "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;
}
/** Appends the full text (e.g. a multi-line stack trace), verbatim, one line per newline. */
public Transcript block(String text) {
for (String l : text.split("\n", -1)) {
lines.add(l);
}
return this;
}
public void flush() {
try {
Files.write(path, String.join("\n", lines).getBytes(StandardCharsets.UTF_8));
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}
public List<String> capturedLines() {
return lines;
}
}