commit d4b3a63615a5b5d5b70b1b1351781f3680f113e4 Author: Ankur Mhatre Date: Thu Oct 1 04:27:27 2026 +0000 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 Claude-Session: https://claude.ai/code/session_01YXCrLgRKFgCh9RHKW8xaqJ diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b2e433c --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +target/ +*.class +.idea/ +*.iml diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..aa5473f --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7375d0c --- /dev/null +++ b/README.md @@ -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 ` (`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). diff --git a/collections/docs/output/01-reflection-without-add-opens.txt b/collections/docs/output/01-reflection-without-add-opens.txt new file mode 100644 index 0000000..865e5ee --- /dev/null +++ b/collections/docs/output/01-reflection-without-add-opens.txt @@ -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) diff --git a/collections/docs/output/02-reflection-with-add-opens.txt b/collections/docs/output/02-reflection-with-add-opens.txt new file mode 100644 index 0000000..54011b9 --- /dev/null +++ b/collections/docs/output/02-reflection-with-add-opens.txt @@ -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 + diff --git a/collections/docs/output/03-bucket-layout-small-map.txt b/collections/docs/output/03-bucket-layout-small-map.txt new file mode 100644 index 0000000..90b4209 --- /dev/null +++ b/collections/docs/output/03-bucket-layout-small-map.txt @@ -0,0 +1,17 @@ +Real bucket layout of a 6-entry HashMap (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 \ No newline at end of file diff --git a/collections/docs/output/04-treeify-trigger.txt b/collections/docs/output/04-treeify-trigger.txt new file mode 100644 index 0000000..cb2f672 --- /dev/null +++ b/collections/docs/output/04-treeify-trigger.txt @@ -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 \ No newline at end of file diff --git a/collections/docs/output/05-cme-classic-reproduction.txt b/collections/docs/output/05-cme-classic-reproduction.txt new file mode 100644 index 0000000..a36209c --- /dev/null +++ b/collections/docs/output/05-cme-classic-reproduction.txt @@ -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) \ No newline at end of file diff --git a/collections/docs/output/06-cme-silent-non-reproduction.txt b/collections/docs/output/06-cme-silent-non-reproduction.txt new file mode 100644 index 0000000..66ef249 --- /dev/null +++ b/collections/docs/output/06-cme-silent-non-reproduction.txt @@ -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. \ No newline at end of file diff --git a/collections/docs/output/07-cme-concurrenthashmap-no-throw.txt b/collections/docs/output/07-cme-concurrenthashmap-no-throw.txt new file mode 100644 index 0000000..c4cea1e --- /dev/null +++ b/collections/docs/output/07-cme-concurrenthashmap-no-throw.txt @@ -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) \ No newline at end of file diff --git a/collections/docs/output/08-iterator-remove-safe.txt b/collections/docs/output/08-iterator-remove-safe.txt new file mode 100644 index 0000000..20902f4 --- /dev/null +++ b/collections/docs/output/08-iterator-remove-safe.txt @@ -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] \ No newline at end of file diff --git a/collections/docs/output/09-equals-hashcode-broken-contract.txt b/collections/docs/output/09-equals-hashcode-broken-contract.txt new file mode 100644 index 0000000..1c55079 --- /dev/null +++ b/collections/docs/output/09-equals-hashcode-broken-contract.txt @@ -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 after adding two equal-but-differently-hashed points: size=2 +HashSet after adding two equal, correctly-hashed points: size=1 \ No newline at end of file diff --git a/collections/docs/output/10-mutable-key-lost-entry.txt b/collections/docs/output/10-mutable-key-lost-entry.txt new file mode 100644 index 0000000..8d19798 --- /dev/null +++ b/collections/docs/output/10-mutable-key-lost-entry.txt @@ -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 \ No newline at end of file diff --git a/collections/pom.xml b/collections/pom.xml new file mode 100644 index 0000000..deb9cb2 --- /dev/null +++ b/collections/pom.xml @@ -0,0 +1,57 @@ + + + 4.0.0 + + com.ankurm.interviewlab + collections + 1.0.0 + jar + + java-interview-lab :: collections + + 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. + + + + 25 + UTF-8 + 5.11.0 + + + + + org.junit.jupiter + junit-jupiter + ${junit.version} + test + + + + + collections + + + org.apache.maven.plugins + maven-compiler-plugin + 3.13.0 + + + org.apache.maven.plugins + maven-surefire-plugin + 3.3.1 + + + --add-opens java.base/java.util=ALL-UNNAMED + + + + + diff --git a/collections/scripts/run-all.sh b/collections/scripts/run-all.sh new file mode 100755 index 0000000..5bbd884 --- /dev/null +++ b/collections/scripts/run-all.sh @@ -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 diff --git a/collections/scripts/run.sh b/collections/scripts/run.sh new file mode 100755 index 0000000..a54f45b --- /dev/null +++ b/collections/scripts/run.sh @@ -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 }" + +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 diff --git a/collections/src/main/java/com/ankurm/interviewlab/collections/BucketInspector.java b/collections/src/main/java/com/ankurm/interviewlab/collections/BucketInspector.java new file mode 100644 index 0000000..e639b0f --- /dev/null +++ b/collections/src/main/java/com/ankurm/interviewlab/collections/BucketInspector.java @@ -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. + * + *

This needs {@code --add-opens java.base/java.util=ALL-UNNAMED}. 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 opened 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 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 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 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(); + } +} diff --git a/collections/src/main/java/com/ankurm/interviewlab/collections/CollidingKey.java b/collections/src/main/java/com/ankurm/interviewlab/collections/CollidingKey.java new file mode 100644 index 0000000..95bb085 --- /dev/null +++ b/collections/src/main/java/com/ankurm/interviewlab/collections/CollidingKey.java @@ -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. + * + *

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; + } +} diff --git a/collections/src/main/java/com/ankurm/interviewlab/collections/ReflectionAddOpensDemo.java b/collections/src/main/java/com/ankurm/interviewlab/collections/ReflectionAddOpensDemo.java new file mode 100644 index 0000000..910aa7f --- /dev/null +++ b/collections/src/main/java/com/ankurm/interviewlab/collections/ReflectionAddOpensDemo.java @@ -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 map = new HashMap<>(); + map.put("alpha", 1); + map.put("beta", 2); + + List rows = BucketInspector.dump(map); + System.out.println("reflection succeeded: " + rows.size() + " non-empty bucket(s)"); + System.out.println(BucketInspector.render(rows, BucketInspector.tableLength(map))); + } +} diff --git a/collections/src/main/java/com/ankurm/interviewlab/collections/eqhash/CorrectPoint.java b/collections/src/main/java/com/ankurm/interviewlab/collections/eqhash/CorrectPoint.java new file mode 100644 index 0000000..b6bf4fa --- /dev/null +++ b/collections/src/main/java/com/ankurm/interviewlab/collections/eqhash/CorrectPoint.java @@ -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 + ")"; + } +} diff --git a/collections/src/main/java/com/ankurm/interviewlab/collections/eqhash/EqualsOnlyPoint.java b/collections/src/main/java/com/ankurm/interviewlab/collections/eqhash/EqualsOnlyPoint.java new file mode 100644 index 0000000..b55f083 --- /dev/null +++ b/collections/src/main/java/com/ankurm/interviewlab/collections/eqhash/EqualsOnlyPoint.java @@ -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 + ")"; + } +} diff --git a/collections/src/main/java/com/ankurm/interviewlab/collections/eqhash/MutableKey.java b/collections/src/main/java/com/ankurm/interviewlab/collections/eqhash/MutableKey.java new file mode 100644 index 0000000..bb1e340 --- /dev/null +++ b/collections/src/main/java/com/ankurm/interviewlab/collections/eqhash/MutableKey.java @@ -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 + ")"; + } +} diff --git a/collections/src/test/java/com/ankurm/interviewlab/collections/BucketLayoutTest.java b/collections/src/test/java/com/ankurm/interviewlab/collections/BucketLayoutTest.java new file mode 100644 index 0000000..93348ab --- /dev/null +++ b/collections/src/test/java/com/ankurm/interviewlab/collections/BucketLayoutTest.java @@ -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 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 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 (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"); + } + } +} diff --git a/collections/src/test/java/com/ankurm/interviewlab/collections/ConcurrentModificationTest.java b/collections/src/test/java/com/ankurm/interviewlab/collections/ConcurrentModificationTest.java new file mode 100644 index 0000000..e22edaa --- /dev/null +++ b/collections/src/test/java/com/ankurm/interviewlab/collections/ConcurrentModificationTest.java @@ -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 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 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 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 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 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 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 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); + } +} diff --git a/collections/src/test/java/com/ankurm/interviewlab/collections/EqualsHashCodeContractTest.java b/collections/src/test/java/com/ankurm/interviewlab/collections/EqualsHashCodeContractTest.java new file mode 100644 index 0000000..13686aa --- /dev/null +++ b/collections/src/test/java/com/ankurm/interviewlab/collections/EqualsHashCodeContractTest.java @@ -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 broken = new HashSet<>(); + broken.add(a); + broken.add(b); + + HashSet 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 after adding two equal-but-differently-hashed points: size=" + broken.size()); + t.line("HashSet 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 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"); + } +} diff --git a/collections/src/test/java/com/ankurm/interviewlab/collections/TreeifyTriggerTest.java b/collections/src/test/java/com/ankurm/interviewlab/collections/TreeifyTriggerTest.java new file mode 100644 index 0000000..46e6bc2 --- /dev/null +++ b/collections/src/test/java/com/ankurm/interviewlab/collections/TreeifyTriggerTest.java @@ -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 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 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 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 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"); + } +} diff --git a/collections/src/test/java/com/ankurm/interviewlab/collections/support/Transcript.java b/collections/src/test/java/com/ankurm/interviewlab/collections/support/Transcript.java new file mode 100644 index 0000000..9066811 --- /dev/null +++ b/collections/src/test/java/com/ankurm/interviewlab/collections/support/Transcript.java @@ -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. + * + *

Usage from a test: + *

{@code
+ * Transcript t = Transcript.start("05-cme-classic-reproduction.txt",
+ *         "Classic ConcurrentModificationException: for-each + Collection.remove()");
+ * t.line("list before: " + list);
+ * ...
+ * t.flush();
+ * }
+ */ +public final class Transcript { + + private final Path path; + private final List 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 capturedLines() { + return lines; + } +}