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
+4
View File
@@ -0,0 +1,4 @@
target/
*.class
.idea/
*.iml
+21
View File
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2026 Ankur Mhatre
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+114
View File
@@ -0,0 +1,114 @@
# java-interview-lab
Runnable companion code for ankurm.com's Java interview-prep article series. Every number,
stack trace, and console line quoted in those articles comes from a file committed somewhere
under this repository &mdash; nothing is retyped or paraphrased from memory.
One module per article. This repository currently holds:
| Module | Article | Status |
|---|---|---|
| [`collections`](collections/) | Top 40 Java Collections Interview Questions (HashMap Internals to Fail-Fast Iterators) | this commit |
## `collections` module
HashMap/HashSet/ArrayList internals, verified against the real JDK 25 `java.util.HashMap`
source (not against its javadoc prose) and reproduced with real, runnable code.
### Versions
| Component | Version | Verified against |
|---|---|---|
| JDK | **25.0.4.1+1** (Temurin, LTS) | `java -version`; `java.util.HashMap` source extracted from this JDK's own `lib/src.zip` |
| JUnit | **5.11.0** | `org.junit.jupiter:junit-jupiter` on Maven Central |
| Maven | 3.9.11 | `mvn -version` |
The three constants this article leans on hardest (`TREEIFY_THRESHOLD = 8`,
`UNTREEIFY_THRESHOLD = 6`, `MIN_TREEIFY_CAPACITY = 64`) were read directly out of
`java.util.HashMap`'s source for this exact JDK build, not copied from a blog post or an older
javadoc. See `collections/src/main/java/.../BucketInspector.java` for the reflective dump that
proves them at runtime.
### Quickstart
```bash
cd collections
export JAVA_HOME=/path/to/jdk-25 # must be JDK 25 or newer
export PATH="$JAVA_HOME/bin:$PATH"
mvn test # runs everything, including the HashMap-internals demos
```
The first `mvn` invocation in a fresh environment must run **online** (not `-o`) once, so the
compiler/surefire plugins populate the local repository cache; `-o` works for every run after
that. This is a Maven quirk, not specific to this module.
Run one scenario at a time with `./scripts/run.sh <name>` (`add-opens-fail`, `add-opens-ok`,
`treeify`, `cme`, `eqhash`, `bucket` &mdash; see the script for what each prints). Regenerate
every committed transcript in `docs/output/` with `./scripts/run-all.sh`.
**No HTTP endpoints in this module** &mdash; every demo is a plain Java `main()` method or a
JUnit test; there is nothing to start as a server. The diagnostic tool is
`BucketInspector.dump(HashMap)`, a reflective helper, not an endpoint.
### The one flag that matters: `--add-opens`
`BucketInspector` reflects into `java.util.HashMap`'s private `table` field to show the real
bucket layout. Since Java 9, that throws `InaccessibleObjectException` unless the JVM is
started with:
```
--add-opens java.base/java.util=ALL-UNNAMED
```
`pom.xml`'s surefire configuration sets this for `mvn test` automatically. Running
`ReflectionAddOpensDemo` directly with plain `java -cp ...` reproduces the crash on purpose
&mdash; see `docs/output/01-reflection-without-add-opens.txt`.
### Source layout
```
collections/
├── pom.xml
├── scripts/
│ ├── run.sh one named scenario at a time
│ └── run-all.sh regenerates every file in docs/output/
├── src/main/java/com/ankurm/interviewlab/collections/
│ ├── BucketInspector.java reflective bucket/bin dump (needs --add-opens)
│ ├── CollidingKey.java hashCode()==1 for every instance - forces one bucket
│ ├── ReflectionAddOpensDemo.java standalone entry point for the --add-opens demo
│ └── eqhash/
│ ├── EqualsOnlyPoint.java equals() overridden, hashCode() is NOT - the broken case
│ ├── CorrectPoint.java both overridden consistently - the fixed case
│ └── MutableKey.java a key whose hash-relevant field can change post-insertion
├── src/test/java/com/ankurm/interviewlab/collections/
│ ├── support/Transcript.java writes docs/output/NN-*.txt while the test asserts the same numbers
│ ├── BucketLayoutTest.java
│ ├── TreeifyTriggerTest.java
│ ├── ConcurrentModificationTest.java
│ └── EqualsHashCodeContractTest.java
└── docs/output/*.txt captured real output - see index below
```
### Index of captured output (`docs/output/`)
| File | What it proves |
|---|---|
| `01-reflection-without-add-opens.txt` | `InaccessibleObjectException`, uncaught, crashing the JVM &mdash; real stack trace |
| `02-reflection-with-add-opens.txt` | Same code, with the flag: a real bucket dump of a 2-entry `HashMap` |
| `03-bucket-layout-small-map.txt` | Real bucket indices for 6 string keys at default capacity 16 |
| `04-treeify-trigger.txt` | Capacity doubling 16&rarr;32&rarr;64 purely from `treeifyBin()`'s own resize calls, then a real `Node`&rarr;`TreeNode` conversion at insert #11 |
| `05-cme-classic-reproduction.txt` | A real `ConcurrentModificationException`, full stack trace, from `for`-each + `Collection.remove()` |
| `06-cme-silent-non-reproduction.txt` | The documented case where the SAME kind of mid-loop removal does **not** throw |
| `07-cme-concurrenthashmap-no-throw.txt` | `ConcurrentHashMap`'s weakly-consistent iterator mutated during iteration, never throws |
| `08-iterator-remove-safe.txt` | `Iterator.remove()` used correctly &mdash; no exception, correct result |
| `09-equals-hashcode-broken-contract.txt` | `equals()` without `hashCode()`: a real `HashSet` failing to deduplicate two equal objects |
| `10-mutable-key-lost-entry.txt` | A key mutated after insertion: the entry is unreachable by lookup but still physically present |
All ten are regenerated by one command (`./scripts/run-all.sh`); eight of the ten (03 through 10)
are generated by a JUnit test that also asserts the same numbers it prints, so a figure in the
article that stops being true fails the build, not just the next blog audit.
## License
MIT &mdash; see [LICENSE](LICENSE).
@@ -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;
}
}