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

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

Co-Authored-By: Claude Sonnet 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01YXCrLgRKFgCh9RHKW8xaqJ
This commit is contained in:
2026-10-01 04:27:27 +00:00
co-authored by Claude Sonnet 5
commit d4b3a63615
27 changed files with 1085 additions and 0 deletions
@@ -0,0 +1,8 @@
Exception in thread "main" java.lang.reflect.InaccessibleObjectException: Unable to make field transient java.util.HashMap$Node[] java.util.HashMap.table accessible: module java.base does not "opens java.util" to unnamed module @1dbd16a6
at java.base/java.lang.reflect.AccessibleObject.throwInaccessibleObjectException(AccessibleObject.java:353)
at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:329)
at java.base/java.lang.reflect.AccessibleObject.checkCanSetAccessible(AccessibleObject.java:277)
at java.base/java.lang.reflect.Field.checkCanSetAccessible(Field.java:179)
at java.base/java.lang.reflect.Field.setAccessible(Field.java:173)
at com.ankurm.interviewlab.collections.BucketInspector.dump(BucketInspector.java:41)
at com.ankurm.interviewlab.collections.ReflectionAddOpensDemo.main(ReflectionAddOpensDemo.java:23)
@@ -0,0 +1,5 @@
reflection succeeded: 2 non-empty bucket(s)
table.length = 16 | non-empty buckets = 2
bucket[ 7] chain=1 kind=Node
bucket[ 14] chain=1 kind=Node
@@ -0,0 +1,17 @@
Real bucket layout of a 6-entry HashMap<String,Integer> (default capacity)
==========================================================================
map.size() = 6
table.length = 16 | non-empty buckets = 4
bucket[ 0] chain=2 kind=Node
bucket[ 1] chain=2 kind=Node
bucket[ 4] chain=1 kind=Node
bucket[ 5] chain=1 kind=Node
Index for each key, computed the same way HashMap.hash() does it:
apple hashCode=93029210 spread=93030097 index=(16-1)&spread=1
banana hashCode=-1396355227 spread=-1396317280 index=(16-1)&spread=0
cherry hashCode=-1361513063 spread=-1361552575 index=(16-1)&spread=1
date hashCode=3076014 spread=3075968 index=(16-1)&spread=0
egg hashCode=100357 spread=100356 index=(16-1)&spread=4
fig hashCode=101380 spread=101381 index=(16-1)&spread=5
@@ -0,0 +1,11 @@
Forcing real treeification: 20 keys with hashCode()==1, loadFactor=1000f (size-resize disabled)
===============================================================================================
All keys hash to the same bucket (see CollidingKey javadoc for why a resize can't separate them).
after inserting key #1 : table.length=16 bucket[1] chain=1 kind=Node
after inserting key #9 : table.length=32 bucket[1] chain=9 kind=Node
after inserting key #10 : table.length=64 bucket[1] chain=10 kind=Node
after inserting key #11 : table.length=64 bucket[1] chain=11 kind=TreeNode
Final state: table.length=64, bucket[1] kind=TreeNode, first became a tree at insert #11
@@ -0,0 +1,14 @@
Classic ConcurrentModificationException: for-each + Collection.remove() mid-loop
================================================================================
list before: [1, 2, 3, 4, 5]
threw: java.util.ConcurrentModificationException
stack trace (trimmed to the ArrayList$Itr frames that matter):
java.util.ConcurrentModificationException
at java.base/java.util.ArrayList$Itr.checkForComodification(ArrayList.java:1096)
at java.base/java.util.ArrayList$Itr.next(ArrayList.java:1050)
at com.ankurm.interviewlab.collections.ConcurrentModificationTest.lambda$classicForEachPlusCollectionRemoveThrows$0(ConcurrentModificationTest.java:28)
at com.ankurm.interviewlab.collections.ConcurrentModificationTest.classicForEachPlusCollectionRemoveThrows(ConcurrentModificationTest.java:27)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1604)
at java.base/java.util.ArrayList.forEach(ArrayList.java:1604)
@@ -0,0 +1,13 @@
The fail-fast contract is 'best-effort', not guaranteed: a case that does NOT throw
===================================================================================
list before: [10, 20] (size=2)
elements the loop actually saw before ending: [10]
list after the loop: [20]
ConcurrentModificationException thrown: false
Why: after removing 10, size becomes 1 and the iterator's cursor is already 1
(it advanced to 1 when next() returned 10). ArrayList.Itr.hasNext() is just
'return cursor != size;' - no modCount check - so hasNext() sees 1 != 1, returns
false, and the loop ends normally. next() is the only method that checks
modCount, and it is never called again. The element 20 is silently never visited.
@@ -0,0 +1,8 @@
ConcurrentHashMap's iterator is weakly consistent: it never throws CME
======================================================================
map before: {0=v0, 1=v1, 2=v2, 3=v3, 4=v4}
entries visited during iteration (original 5, plus possibly some newly-added ones): 19
map after: 24 entries total
ConcurrentModificationException thrown: false
(weakly consistent: may or may not reflect the mutation, but is guaranteed never to throw)
@@ -0,0 +1,5 @@
The fix: Iterator.remove() instead of Collection.remove() during iteration
==========================================================================
list before: [1, 2, 3, 4, 5]
list after removing all even numbers via Iterator.remove(): [1, 3, 5]
@@ -0,0 +1,10 @@
equals() overridden without hashCode(): a == b is true, but hashCode() differs
==============================================================================
a.equals(b) = true
a.hashCode() = 1390869998
b.hashCode() = 1820383114
(these should be IDENTICAL per the contract - they are not, because hashCode() was never overridden)
HashSet<EqualsOnlyPoint> after adding two equal-but-differently-hashed points: size=2
HashSet<CorrectPoint> after adding two equal, correctly-hashed points: size=1
@@ -0,0 +1,9 @@
Mutating a key's hash-relevant field after insertion: the entry is still THERE, just unfindable
===============================================================================================
map.put(key(tag=42), ...) then key.setTag(99)
containsKey(new MutableKey(42)) before mutation : true
containsKey(new MutableKey(42)) after mutation : false (looks for old hash's bucket - key no longer hashes there)
containsKey(new MutableKey(99)) after mutation : false (looks in the NEW hash's bucket - key was never filed there either)
map.size() is still : 1 (the entry was never removed!)
the mutated key object IS still found by direct iteration over keySet(): true