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:
@@ -0,0 +1,4 @@
|
||||
target/
|
||||
*.class
|
||||
.idea/
|
||||
*.iml
|
||||
@@ -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.
|
||||
@@ -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 — 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` — 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** — 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
|
||||
— 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 — 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→32→64 purely from `treeifyBin()`'s own resize calls, then a real `Node`→`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 — 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 — 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
|
||||
@@ -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>
|
||||
Executable
+31
@@ -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
|
||||
Executable
+32
@@ -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 — {@code HashMap} deliberately exposes
|
||||
* none of it — 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 — {@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
|
||||
* — 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 — 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;
|
||||
}
|
||||
}
|
||||
+27
@@ -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)));
|
||||
}
|
||||
}
|
||||
+30
@@ -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 + ")";
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
package com.ankurm.interviewlab.collections.eqhash;
|
||||
|
||||
/**
|
||||
* Overrides {@code equals()} but deliberately NOT {@code hashCode()} — 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) — 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
+140
@@ -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);
|
||||
}
|
||||
}
|
||||
+80
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user