Skip to main content

Top 40 Java Collections Interview Questions (HashMap Internals to Fail-Fast Iterators)

Forty Java Collections interview questions grouped from fundamentals to failure modes, verified against real JDK 25 source and real runs: HashMap bucket layout and treeification reproduced via reflection, a real ConcurrentModificationException alongside the well-known case where it silently does not fire, and the equals/hashCode contract broken and fixed in code.

A HashMap interview question almost never stops at “what is a HashMap.” It stops one layer down — at why a bucket sometimes holds a tree instead of a list, why a for-each loop that calls list.remove() sometimes throws and sometimes doesn’t, or why two objects that are equals() can end up invisible to each other in a HashSet. Most “Top N Collections questions” lists answer that layer from memory. This one answers it from a real JDK 25 build: the exact source file that defines TREEIFY_THRESHOLD, a program that forces a real bucket to become a real red-black tree and proves it by inspecting the live object, and a ConcurrentModificationException that is thrown for real — alongside the equally real, equally common case where the exact same mistake does not throw one. Forty questions are covered below, grouped the way they actually come up in an interview — fundamentals first, then the mechanism underneath, then the contract that holds it together, then the failure modes, then the corners everyone forgets. Read start to finish and each section leans on the one before it; skip to a heading if you already know the basics.
Versions. Verified on JDK 25.0.4.1+1 (Temurin, LTS) against java.util.HashMap’s own source, extracted from that JDK’s lib/src.zip — not quoted from javadoc prose, which lags the source on more than one of the claims below. Tests run with JUnit 5.11.0 and Maven 3.9.11 on a 2-vCPU x86-64 sandbox. Every constant, stack trace, and console line quoted below comes from a committed file in the companion repository, regenerated by one script.

The problem interviewers are actually testing

Anyone can recite “HashMap is O(1) average case.” What separates a surface answer from a strong one is whether you can say why it’s O(1), what specifically makes it stop being O(1), and what you’d actually see on screen when that happens. That’s the real subject of this article: not the definitions, but the mechanism, reproduced, with the console output to prove it. Three claims carry most of the weight in this space, and all three get mangled by memory in casual answers:
  • A bucket becomes a tree at 8 colliding entries — but only if the table is already at least 64 slots. Below that, HashMap resizes instead. Most answers skip the second half.
  • A fail-fast iterator is best-effort, not guaranteed. There is a well-known, completely reproducible case where removing an element mid-loop does not throw ConcurrentModificationException, and most engineers have shipped it at least once.
  • Overriding equals() without hashCode() doesn’t just “violate a contract” in the abstract — it produces a HashSet that silently keeps duplicates, measurable in one line of test code.
All three are demonstrated below with real code, not just stated.

The shape of the whole framework, in one picture

Before the internals, the vocabulary. The Java Collections Framework is four interface families (List, Set, Queue, Map) and a handful of concrete implementations of each, chosen for different trade-offs. Map is drawn separately on purpose — it is not a Collection at all.
Collection<E> List<E> Set<E> Queue<E> ArrayList LinkedList HashSet LinkedHashSet TreeSet ArrayDeque PriorityQueue Map<K,V> (no Collection) HashMap LinkedHashMap TreeMap HashSet<E> is, internally, a HashMap<E,Object> with every value set to one shared sentinel Map is drawn apart deliberately — it was never a Collection, even though it is central to the framework
That dashed line matters more than it looks: HashSet does not have its own hashing logic at all. Its add(e) is literally map.put(e, PRESENT) against an internal HashMap<E,Object>, confirmed directly from java.util.HashSet’s source (PRESENT is a single static sentinel object shared by every entry). Every HashMap internals question below is therefore also a HashSet internals question.

Q1. What is the Java Collections Framework, and what are its core interfaces?

A unified architecture for storing and manipulating groups of objects: Collection (with List, Set, Queue underneath it) plus Map as a parallel, separate hierarchy. Interfaces define behavior and guarantees; concrete classes like ArrayList or HashMap implement them with a specific data structure and specific trade-offs.

Q2. Collection vs Collections — what’s the difference?

Collection<E> is an interface — the root type for List, Set, and Queue. Collections is a utility class — static helper methods (Collections.sort(), Collections.unmodifiableList(), Collections.synchronizedMap()) that operate on collections. The naming collision trips up beginners constantly; they are unrelated except by name.

Q3. List vs Set vs Map vs Queue — when do you reach for which?

InterfaceDuplicates?Ordered?Reach for it when…
Listyesinsertion order, indexedyou need position-based access or care about sequence
Setnoimplementation-dependentuniqueness is the point
Mapno duplicate keysimplementation-dependentyou need a key→value lookup
Queue/Dequeyesprocessing order (FIFO/LIFO/priority)you need ordered processing, not lookup

Q4. Why doesn’t Map extend Collection?

A Collection<E> holds one type of element and its contract centers on add(E), iterator(), and membership. A Map<K,V> holds pairs and its contract centers on lookup by key — add doesn’t even make sense for it (what would you add, a key with no value?). Retrofitting Map under Collection was considered and rejected early in the framework’s design precisely because the method contracts don’t line up. You can still iterate a Map — through its view collections, keySet(), values(), and entrySet(), each of which genuinely is a Collection.

Q5. ArrayList vs LinkedList — what actually differs?

Confirmed directly from both classes’ declarations: ArrayList extends AbstractList and implements the RandomAccess marker interface over a backing Object[]; LinkedList extends AbstractSequentialList and implements both List and Deque as a doubly linked list, with no RandomAccess at all. That one marker interface is the whole story: get(i) on an ArrayList is a pointer-arithmetic array read; on a LinkedList it is a walk from whichever end is closer. Insertion at the front or back is O(1) on a LinkedList and (amortized) O(1) at the end only for ArrayList — inserting at the front of an ArrayList shifts every element. In practice ArrayList wins almost every benchmark that isn’t pure head-insertion, because its cache-friendly contiguous memory beats pointer-chasing even where linked-list Big-O looks better on paper.

Q6. Array vs ArrayList?

A plain array is fixed-size and can hold primitives directly (int[]). ArrayList<Integer> auto-boxes every element, grows dynamically (allocate-copy on overflow, not shown to the caller), and comes with the full List API. The boxing is not free — a tight numeric loop over int[] will outperform the boxed equivalent — which is the real reason primitive arrays still show up in performance-sensitive code.

Going deeper on this section

  • The RandomAccess marker interface exists purely so generic algorithms like Collections.binarySearch can pick an index-based or iterator-based strategy — see its one-paragraph javadoc, which is unusually explicit about why a marker interface with zero methods is useful.
  • SequencedCollection, SequencedSet and SequencedMap (JEP 431, final since JDK 21) added getFirst()/getLast()/reversed() across the framework — LinkedHashMap in this JDK 25 build implements SequencedMap directly, confirmed via javap in the ordering section below.

The smallest thing that works: put(), get(), and a real bucket dump

Every HashMap key goes through the same three steps before it touches a bucket: compute hashCode(), spread it, then mask it down to an index. The tool below does nothing more than read that index back out of a live map by reflecting into the private table field — there is no public API for any of this, so reflection is the only way to see it directly instead of trusting a description of it.
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"
            // ...walk the chain, counting...
        }
        return rows;
    } catch (NoSuchFieldException | IllegalAccessException e) {
        throw new IllegalStateException("reflection into HashMap internals failed", e);
    }
}
Source: BucketInspector.java (trimmed — the real file also records chain length and derives bucket index the same way HashMap itself does, using the same formula shown below).
This reflection needs a JVM flag, and the uncaught crash without it is itself interview-relevant. Since Java 9, setAccessible(true) on a private field of a JDK class throws InaccessibleObjectException unless the owning module opens that package for deep reflection. java.util is exported (its public classes work normally) but not opened by java.base by default. Running the exact same class with plain java -cp ... crashes; adding --add-opens java.base/java.util=ALL-UNNAMED fixes it. Both runs were captured for real, not described:
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.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)
Output: 01-reflection-without-add-opens.txt. With the flag, the exact same class prints a real bucket layout instead of crashing — see 02-reflection-with-add-opens.txt. Run it against six ordinary string keys at the default capacity of 16, and the bucket assignment is exactly (tableLength - 1) & spreadHash, nothing fancier:
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
Output: 03-bucket-layout-small-map.txt, generated by BucketLayoutTest.java. apple and cherry land in the same bucket despite completely different hash codes — that’s not a bug, it’s arithmetic: 16 buckets can only ever hold 16 distinct indices, so collisions are guaranteed the moment you have more than 16 wildly different hash codes, and are common well before that by pigeonhole alone.

Q7. How does HashMap.put() work, step by step?

  1. Compute key.hashCode().
  2. Spread it: h ^ (h >>> 16) (see Q8).
  3. Mask to a bucket index: (table.length - 1) & spreadHash.
  4. If the bucket is empty, place a new node there.
  5. If not, walk the chain (or tree) comparing hash first, then equals(), looking for an existing key to overwrite.
  6. If no match, append a new node; if the chain just crossed the treeify threshold and the table is large enough, convert it to a tree (next section).
  7. If size now exceeds threshold, resize.

Q8. Why does HashMap spread the hash with h ^ (h >>> 16) instead of using hashCode() directly?

Because the final index only looks at the low bits of the hash ((n-1) & hash, and n is never larger than needed). If two keys’ hash codes only differ in high bits — a real risk for hash functions that mix poorly in their low bits — they’d collide every time at small table sizes. XOR-folding the top 16 bits into the bottom 16 lets high-bit variance influence the index too, at the cost of one shift and one XOR per lookup. It’s a cheap insurance policy against a specific, real class of bad hash functions, not a hash function in its own right.

Q9. Why is HashMap capacity always a power of two?

Because (n - 1) & hash is only equivalent to hash % n when n is a power of two — and a bitwise AND is markedly cheaper than a modulo on most hardware. If you pass a non-power-of-two initial capacity to the constructor, it’s silently rounded up to the next one.

Q10. What is load factor, and why is the default 0.75?

Load factor is the fraction-full threshold that triggers a resize: threshold = capacity * loadFactor. It’s a space/time trade-off — a lower load factor resizes sooner (fewer collisions, more wasted array slots), a higher one resizes later (denser table, longer chains). 0.75 is the JDK’s own empirically-chosen compromise; it isn’t derived from a formula, it’s a tuning constant, and you can pass a different one to the constructor if your workload benefits.

Q11. What actually happens during resize()?

The table doubles in size, and every existing entry is rehashed into the new, larger table. Because capacity is always a power of two, the JDK can skip recomputing the full hash on resize: each old bucket splits into exactly two new buckets (same index, or old index + old capacity), decided by a single extra bit of the already-computed hash. This “split, don’t rehash from scratch” trick is why resize, while not free, is cheaper than it looks — and it’s also the exact mechanism the next section leans on to force a real tree.
Going deeper: what tableSizeFor() actually computes, and the fencepost it has to handle

Passing new HashMap<>(100) doesn’t give you a table of 100 — it rounds up to the next power of two, 128, via a private static method with the deliberately odd-looking body of decrementing the input by one, OR-ing it with every right-shifted version of itself (by 1, 2, 4, 8, 16), then incrementing. That sequence of shifts is a classic bit-twiddling idiom for “round up to the next power of two” — the decrement at the start exists purely so that an input that is already an exact power of two (like 128) doesn’t get rounded up to the next one (256) by mistake. Confirmed directly from java.util.HashMap.tableSizeFor() in this JDK’s source: return (n < 0) ? 1 : (n >= MAXIMUM_CAPACITY) ? MAXIMUM_CAPACITY : n + 1; after the shift cascade, where MAXIMUM_CAPACITY = 1 << 30 — a hard ceiling that exists because capacity has to fit comfortably in a positive int after doubling.

Going deeper on this section

  • The exact resize split logic (which old-bucket bit decides lo vs. hi) lives in HashMap.resize()’s transfer loop — read alongside the treeify trigger in the next section, since the same splitting logic has a second job for tree bins.
  • Official HashMap javadoc for the full method contract.

How it really works underneath: collisions, bins, and a real treeification

Here is the part almost every answer gets half right: a bucket does not become a tree the instant it has 9 entries. It becomes a tree only if the table itself is already at least MIN_TREEIFY_CAPACITY (64) slots — otherwise treeifyBin() resizes the whole table instead and leaves the bucket as a plain linked list. All three numbers below are read directly out of this JDK’s own java.util.HashMap source, not copied from a blog:
ConstantValueWhat it governs
TREEIFY_THRESHOLD8a bin converts to a tree once it would exceed 8 nodes…
MIN_TREEIFY_CAPACITY64…but only if table.length >= 64; otherwise the table resizes instead
UNTREEIFY_THRESHOLD6a tree bin converts back to a list if a resize split leaves it with ≤6 nodes
To force this for real, the demo below uses a key whose hashCode() always returns the same constant — which, because capacity is always a power of two, means a resize can never spread these keys into different buckets:
@Override
public int hashCode() {
    return 1; // deliberately constant
}

@Override
public boolean equals(Object o) {
    return o instanceof CollidingKey other && other.id == id;
}
Source: CollidingKey.java. Twenty of these, inserted into a map built with a deliberately huge load factor (so the ordinary size-based resize never fires and the only resizes that happen are the ones treeifyBin() triggers itself), produce exactly the sequence the source predicts:
insert #1 table=16 bucket[1] 1 Node insert #9 table 16→32 bucket[1] 9 Nodes too small to treeify → resize insert #10 table 32→64 bucket[1] 10 Nodes still too small → resize again insert #11 table stays 64 bucket[1] 11 TreeNodes 64 ≥ MIN_TREEIFY_CAPACITY → real tree Two resizes happen BEFORE the first real treeification — both triggered by treeifyBin() itself, not by load factor
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
Output: 04-treeify-trigger.txt, generated by TreeifyTriggerTest.java, which also asserts a second, easy-to-miss case: stopped at exactly 9 colliding keys, the bucket has 9 nodes but the table is only 32 slots — still plain Node objects, confirmed by the same reflective dump, because treeifyBin() chose to resize instead of treeify.
Untreeification is not the mirror image of treeification you might assume. A tree bin does not convert back to a list just because you removed entries down to 6 or fewer. Reading TreeNode.split() directly: the UNTREEIFY_THRESHOLD check only runs during a resize split — when a tree bin is being divided into two new bins and one half ends up with 6 or fewer nodes, that half converts back to a plain list. Calling remove() on a tree bin with no resize happening leaves it a tree, however small it gets.

Q12. What is treeification, and when does it happen?

When a single bucket’s chain would exceed TREEIFY_THRESHOLD (8) entries and the table is at least MIN_TREEIFY_CAPACITY (64) slots, HashMap converts that bucket’s linked list of Node objects into a red-black tree of TreeNode objects, ordered first by hash and then (for non-mutually-comparable keys) by a tie-breaking rule. This turns worst-case lookup in that one bucket from O(n) into O(log n).

Q13. Why does a small table resize instead of treeifying?

Because treeification is only worth its overhead (extra memory per node, tree-balancing cost) when the table itself is already reasonably sized — a small table with one long chain usually means the table is simply too small overall, and growing it is the cheaper, more general fix. The source’s own comment on MIN_TREEIFY_CAPACITY says it should be “at least 4 * TREEIFY_THRESHOLD” specifically to avoid resize/treeify churning back and forth.

Q14. Does a tree bin ever convert back to a list?

Yes, but only as a side effect of a resize split leaving one half with UNTREEIFY_THRESHOLD (6) or fewer nodes — see the callout above. Plain removal, with no resize involved, never untreeifies a bin by itself.

Q15. Why would two completely unrelated keys end up in the same bucket?

Pigeonhole: a table of n buckets can only have n distinct indices, so once you have more distinct hash codes than buckets, collisions are mathematically forced — and they show up well before that from ordinary clustering in real hash distributions. apple and cherry colliding at capacity 16 in the Q7 output above is exactly this, with real hash codes.

Q16. Could you really have hundreds of entries in one bucket? What happens to complexity?

Yes — a broken or adversarial hashCode() (like CollidingKey’s constant 1, deliberately) puts everything in one bucket regardless of table size. Without treeification, that degrades the whole map to O(n) per operation — the historical behavior, and the reason treeification was added in JDK 8 partly as a defense against hash-flooding denial-of-service attacks on, for example, web frameworks that build maps from untrusted request parameters.
Going deeper: how a tree orders keys that aren’t mutually Comparable

A red-black tree needs a total order, but HashMap keys are never required to implement Comparable. TreeNode handles this with a fallback: it first orders by hash (cheap, already computed), and when two different keys tie on hash it calls a private method tieBreakOrder(Object a, Object b), confirmed present in this JDK’s HashMap source. That method compares the keys’ runtime class names as a last resort, and if even that ties, falls back to System.identityHashCode — an order with no real-world meaning, but a consistent, total one, which is all a red-black tree actually requires to stay balanced. The practical implication: tree-bin ordering is an implementation detail with zero guarantees about iteration order, and relying on it would be relying on something the JDK explicitly reserves the right to change.

Going deeper on this section

The contract the compiler never checks: equals() and hashCode()

Nothing stops you from overriding equals() and leaving hashCode() alone — it compiles, it runs, and it quietly breaks every hash-based collection you put the object into. The contract has three parts, and only the first one is enforced by anything other than discipline:
  • If a.equals(b) is true, a.hashCode() == b.hashCode() must be true.
  • If a.hashCode() == b.hashCode(), a.equals(b) is not required to be true (that’s just a collision, handled by the chain/tree).
  • hashCode() must be stable for the lifetime of an object, for any field it is computed from — which is exactly what the mutable-key demo below violates on purpose.
public final class EqualsOnlyPoint {
    private final int x;
    private final int 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.
}
Source: EqualsOnlyPoint.java. Put two different instances representing the same point into a HashSet, and the class that is “equal to itself” by its own equals() still ends up duplicated:
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
Output: 09-equals-hashcode-broken-contract.txt, generated by EqualsHashCodeContractTest.java. Two objects that answer “yes, I’m equal to that one” still land in different buckets, because HashSet never gets far enough to call equals() — it only compares objects that already hashed to the same bucket.
HashSet<EqualsOnlyPoint> internal table (8 of 16 buckets shown) bucket 2 bucket 6 a=(3,4) b=(3,4) a.equals(b) is true — but their different identity hashCode()s send them to different buckets, so HashSet never asks

Q17. State the equals()/hashCode() contract in one sentence.

Equal objects must have equal hash codes; unequal hash codes therefore imply unequal objects, but equal hash codes do not imply equal objects.

Q18. What breaks if you override equals() but not hashCode()?

Exactly what the output above shows: hash-based collections (HashSet, HashMap keys, HashMap-backed caches) silently fail to deduplicate or look up objects your own equals() considers identical, because the lookup never reaches equals() — it’s gated behind a bucket match first.

Q19. Why must HashMap/HashSet keys be effectively immutable?

Because the bucket a key lives in is decided once, at insertion, from its hash code at that moment. The structure never re-files an entry when a field changes later — there is no hook for that. If the hash-relevant state changes, every future lookup computes a different hash and looks in the wrong bucket.

Q20. What actually happens if you mutate a key after inserting it?

MutableKey key = new MutableKey(42);
map.put(key, "original-value");
key.setTag(99); // mutate the field hashCode() depends on, AFTER insertion
Source: MutableKey.java. The entry is not removed, not corrupted, and not lost in any way you’d notice from size() — it is simply unreachable by key lookup, under either the old or the new value:
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
Output: 10-mutable-key-lost-entry.txt. That last line is the diagnostic that actually confirms the entry is a ghost rather than genuinely gone: iterating the live buckets finds it; looking it up by key, under either hash value, does not.
This is why “just use a mutable class as a key” is a real production trap, not a theoretical one. The compiler allows it. equals() and hashCode() can both be perfectly correct in isolation. The bug only appears later, after the key has already been inserted somewhere and something — a setter, a Lombok @Data field, deserialization into an existing object — changes a field the hash depends on. The fingerprint is exactly the output above: size() looks right, iteration finds the entry, direct lookup does not.

Q21. Is it legal for two unequal objects to share a hashCode()? Does that break anything?

Completely legal, and unavoidable in general (more possible object states than possible int values guarantees collisions exist somewhere). It costs performance, not correctness — equals() still disambiguates within the shared bucket. CollidingKey above is this taken to the extreme on purpose: every instance shares one hash code and correctness is unaffected, only the bucket’s internal shape changes (Q12–16).

Q22. What do Java records generate for equals() and hashCode()?

A record generates both automatically, derived consistently from every declared component — so record Point(int x, int y) {} gets a correct, contract-respecting pair for free, which is exactly the thing EqualsOnlyPoint above gets wrong by hand. It’s one of the strongest practical reasons to reach for a record over a hand-written immutable class when the type really is just a tuple of values.
Going deeper: why String.hashCode() is cached, and why Objects.hash(...) isn’t free

String keys are so common in HashMaps that the JDK caches the computed hash in a private hash field, confirmed directly in this JDK’s java.lang.String source — computed lazily on first call and reused forever after, since strings are immutable and the value can never change. A companion field, hashIsZero, exists specifically to distinguish “never computed yet” from “computed, and the real hash happens to be zero” — without it, a string whose genuine hash is 0 would recompute its hash on every single call forever. The comment on the field even calls out that the read/write pattern is a deliberately “benign data race”: multiple threads might compute the same value concurrently and redundantly, which is wasted work but never wrong work, so no synchronization is needed. By contrast, Objects.hash(a, b, c) — the usual one-liner for a hand-written hashCode() — allocates a varargs Object[] on every single call with no caching at all; fine for occasional use, a real allocation hotspot if called in a tight loop over large collections.

Going deeper on this section

What breaks: fail-fast iterators and a ConcurrentModificationException you can reproduce

This is where most real incidents live, so it gets the most space. The textbook description — “modifying a collection while iterating it throws ConcurrentModificationException” — is true often enough to be dangerous, because it is not true always, and the gap between “usually throws” and “always throws” is exactly where production bugs hide.
List<Integer> list = new ArrayList<>(List.of(1, 2, 3, 4, 5));
for (Integer i : list) {
    if (i == 3) {
        list.remove(i); // structural modification NOT through the iterator
    }
}
Source: ConcurrentModificationTest.java. This throws, reliably, with a real stack trace that points straight at the iterator’s own bookkeeping:
ConcurrentModificationException has nothing to do with threads, despite the name. It fires just as reliably in a single-threaded program, as every example in this section is. “Concurrent” here means “a second, independent modification happened during iteration” — and the second modification can come from the very same thread, on the very same call stack, which is exactly what list.remove() inside a for-each loop is. Do not read a caught ConcurrentModificationException as evidence of a thread-safety bug; it is at least as often a single-threaded iteration bug.
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 java.base/java.util.ArrayList.forEach(ArrayList.java:1604)
Output: 05-cme-classic-reproduction.txt. The mechanism: every structural change increments an internal modCount on the list itself; the iterator captured an expectedModCount when it was created, and next() compares the two on every call — confirmed directly in ArrayList$Itr.checkForComodification()’s source, which is a two-line method that does exactly that comparison and nothing else. Now the part most engineers find out the hard way, in production rather than in an interview: that comparison only happens inside next(). hasNext() is, verbatim from ArrayList.Itr’s source, just return cursor != size; — no modCount check at all. That gap is real and reproducible:
List<Integer> list = new ArrayList<>(List.of(10, 20));
for (Integer i : list) {
    seen.add(i);
    if (i == 10) {
        list.remove(i); // removes the LAST remaining element after this one
    }
}
Source: ConcurrentModificationTest.java, method removingTheSecondToLastElementDoesNotThrow(). No exception. The loop just ends, silently, having only ever seen the first element:
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.
Output: 06-cme-silent-non-reproduction.txt.
Classic case: list [1,2,3,4,5], remove at i==3 after remove: cursor=3, size=4 hasNext(): 3!=4 → true next(): modCount check FAILS → throws CME Silent case: list [10,20], remove the only one left after remove: cursor=1, size=1 hasNext(): 1!=1 → false loop ends next() never called again Same mistake, same iterator implementation — the only difference is whether cursor and size happen to collide first
The fix is unglamorous and total: use the iterator’s own remove(), which updates expectedModCount at the same time it structurally changes the list, so the two never drift apart:
Iterator<Integer> it = list.iterator();
while (it.hasNext()) {
    if (it.next() % 2 == 0) {
        it.remove(); // keeps expectedModCount in sync
    }
}
Source: ConcurrentModificationTest.java, method iteratorRemoveIsTheSafeWay(); output in 08-iterator-remove-safe.txt. In modern code, list.removeIf(predicate) does the same thing more concisely and is the version worth reaching for first.

Q23. What is a fail-fast iterator?

An iterator that detects concurrent structural modification of its backing collection (by anything other than itself) and throws ConcurrentModificationException on a best-effort basis, rather than risking undefined behavior from iterating over a structure that changed shape underneath it.

Q24. What actually causes ConcurrentModificationException?

A mismatch between the collection’s live modCount and the value the iterator captured when it was created, detected the next time the iterator checks — which, critically, is inside next(), not inside every method.

Q25. Why does Iterator.remove() not trigger the same exception?

Because it updates the iterator’s own expectedModCount to match the new modCount immediately after performing the removal — it’s structurally identical to Collection.remove() underneath, the difference is purely that the iterator keeps its own bookkeeping in sync with itself.

Q26. Is fail-fast behavior actually guaranteed?

No — and the javadoc says so explicitly: fail-fast is “best-effort” and should never be relied on for correctness, only for bug detection. The silent non-throw reproduced above (06) is the proof: the exact same programming mistake, with a different collection size, produces no exception and no warning, just quietly wrong behavior.

Q27. How do you safely remove elements while iterating?

Iterator.remove() (or ListIterator.remove()/add() for lists), Collection.removeIf(Predicate) for the common case, or iterate over a copy (new ArrayList<>(original)) and mutate the original if you need the original collection’s iteration order preserved exactly. For concurrent collections, see Q28.

Q28. Why doesn’t ConcurrentHashMap throw ConcurrentModificationException?

Its iterator is weakly consistent by design, not fail-fast: it’s guaranteed never to throw ConcurrentModificationException, and guaranteed to reflect the map’s state at some point during the iteration — but makes no promise about whether it sees entries added or removed after the iterator was created.
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)
Output: 07-cme-concurrenthashmap-no-throw.txt, from the same test class. The “19” is not a typo and not a fixed number — it will vary run to run, because seeing newly-added entries mid-iteration is explicitly allowed, not guaranteed; treat that count as indicative of the behavior, not as a number to assert on exactly.

Q29. ListIterator vs Iterator — what extra can you safely do?

ListIterator adds backward traversal (hasPrevious()/previous()), index inspection (nextIndex()/previousIndex()), and — the genuinely new capability — set() and add() during iteration, both of which keep expectedModCount in sync the same way remove() does, so they are exception-safe where the equivalent list-level calls would not be.

Q30. Fail-fast vs fail-safe — name an example of each and the real cost of fail-safe.

Fail-fast: ArrayList, HashMap, HashSet — detect (best-effort) concurrent modification and throw. Fail-safe (or more precisely, weakly consistent): ConcurrentHashMap, CopyOnWriteArrayList, ConcurrentSkipListMap. The cost isn’t free: CopyOnWriteArrayList literally copies the entire backing array on every single mutation so existing iterators keep seeing a stable snapshot — excellent for read-heavy, write-rare workloads (listener lists are the canonical case), ruinous for write-heavy ones.
Going deeper: fail-fast and fail-safe guarantees across the rest of the JDK

Vector and Hashtable predate the modern Collections Framework and are internally synchronized on every method — their iterators are still fail-fast in the same modCount sense as ArrayList, synchronization only protects individual method calls from data races, it does nothing for the check-then-act gap between hasNext() and next() across threads. Collections.synchronizedList(list) has the identical limitation and famously requires manual synchronization on the list itself during iteration, documented explicitly in its own javadoc, for exactly this reason. On the weakly-consistent side, ConcurrentSkipListMap and ConcurrentSkipListSet give the same never-throws guarantee as ConcurrentHashMap while also maintaining sorted order concurrently — worth knowing as the concurrent answer to “I need a thread-safe TreeMap,” a combination ConcurrentHashMap cannot provide since it has no ordering guarantee at all.

Going deeper on this section

What the defaults don’t do: ordering, nulls, thread-safety, and complexity

A plain HashMap makes exactly one promise about iteration order: none. Everything beyond that — insertion order, sorted order, thread safety — is a different, specific class making a different, specific promise, each with a real cost.
LinkedHashMap: the bucket array PLUS a hidden doubly-linked list through every entry bucket 2 bucket 9 bucket 4 “first” “second” “third” Insertion-order iteration walks this green chain directly — it never touches the bucket array at all which is also why insertion order survives a resize: the chain doesn’t care which bucket an entry physically lives in

Q31. HashMap vs LinkedHashMap vs TreeMap — what ordering does each actually guarantee?

ClassIteration orderBacking structure
HashMapnone guaranteed (bucket order, changes across resizes)array of buckets, each a list or tree
LinkedHashMapinsertion order (or access order, if configured)HashMap plus a doubly-linked list through all entries
TreeMapsorted by key (Comparable or a supplied Comparator)red-black tree, confirmed via its class declaration implementing NavigableMap
LinkedHashMap in this JDK build implements SequencedMap directly — confirmed via javap — which is what gives it getFirst(), getLast(), putFirst()/putLast(), and reversed() without any extra code: it was a pure interface addition on an existing, unchanged implementation.

Q32. Comparable vs Comparator?

Comparable<T> is implemented by the type itself — one natural ordering, defined once (compareTo). Comparator<T> is external and pluggable — as many orderings as you want, without touching the class, which matters most when you don’t own the class or need more than one ordering (name ascending vs. age descending on the same object, say).

Q33. Can HashMap have a null key? Null values? What about the others?

HashMap allows exactly one null key and any number of null values — the null key is simply treated as hashing to bucket 0. TreeMap rejects a null key outright with NullPointerException (there is nothing to compare null against). ConcurrentHashMap rejects both null keys and null values — deliberately, because in a concurrent map, get(key) == null is ambiguous between “no mapping” and “mapping to null,” and that ambiguity is dangerous specifically when another thread could be concurrently changing the answer.
Null-handling is inconsistent across the Map implementations on purpose, and it’s a common “gotcha” question precisely because of that. HashMap: one null key allowed, any number of null values. TreeMap: null key throws immediately, because ordering needs something to compare against. ConcurrentHashMap: both are rejected, for the get()-ambiguity reason above. Code written and tested against HashMap and then swapped to ConcurrentHashMap under load — a very ordinary refactor — can start throwing NullPointerException on a code path that worked fine the day before.

Q34. Is HashMap thread-safe? What really goes wrong if you share one across threads?

No. Unsynchronized concurrent writes can corrupt the internal bucket structure, not just lose an update — the infamous JDK 7-era infinite-loop-on-resize bug (concurrent resizes could turn a bucket’s linked list into a cycle) was exactly this, fixed in JDK 8 partly by changing resize to preserve relative order instead of reversing it. Don’t synchronize a plain HashMap by hand for new code — reach for ConcurrentHashMap, which was designed for concurrent access from the ground up rather than retrofitted.

Q35. HashMap vs Hashtable vs ConcurrentHashMap — the one-line decision?

Hashtable is legacy (predates the Collections Framework, synchronizes every method, rejects null keys/values) — there is no reason to choose it for new code. HashMap for single-threaded or externally-synchronized use. ConcurrentHashMap for genuine concurrent access; see the dedicated article linked at the end of the previous section for its internals and real measured throughput against synchronized alternatives.

Q36. Rough time complexity across the common implementations?

OperationArrayListLinkedListHashMapTreeMap
get by index / keyO(1)O(n)O(1) avg, O(log n) worst (treeified)O(log n)
insert at endO(1) amortizedO(1)O(1) avgO(log n)
insert at frontO(n)O(1)n/an/a
contains / searchO(n)O(n)O(1) avgO(log n)
“O(1) worst (treeified)” is doing real work in that table — without the treeification mechanism from the earlier section, a pathologically bad hashCode() would degrade HashMap all the way to LinkedList-grade O(n) lookups, silently.
Going deeper: the JDK 7 resize bug that made this a production incident, not just a theoretical one

Before JDK 8, HashMap.resize() rehashed each bucket by inserting entries at the head of the new bucket’s list, which reverses their order. Two threads resizing concurrently could interleave their head-insertions in a way that created a genuine cycle in the linked list — not just a lost update, a literal circular reference. The next unlucky get() on that bucket would loop forever, pinning a CPU core at 100% with no exception, no log line, and no obvious cause; this happened for real in production systems that used a shared HashMap under load and was a well-documented class of incident before JDK 8 changed resize to append at the tail instead, preserving order and — as a side effect, not the primary goal — making this specific cycle impossible to construct the same way. It is not, however, a reason to consider HashMap safe for concurrent use in JDK 8+: unsynchronized concurrent writes can still silently lose entries, it just can no longer spin a core forever doing it.

Going deeper on this section

Edge cases interviewers actually ask

The long tail — individually small, collectively the difference between “knows HashMap” and “has actually used Java.”

Q37. What is Integer caching, and why does it matter next to equals()/hashCode()?

Integer.valueOf(int) caches and reuses instances for -128 to 127 inclusive — confirmed directly in java.lang.Integer’s source, where the cache range is a named constant. Autoboxing uses valueOf(), so Integer a = 100, b = 100; has a == b true (same cached object), while Integer a = 200, b = 200; has a == b false (two distinct objects outside the cache) — a classic trap for anyone using == on boxed types instead of equals(). It’s unrelated to hash-bucket placement (both objects hash identically either way) but shows up in the same interviews because it’s the same category of “identity vs. equality” mistake.

Q38. What’s the Arrays.asList() gotcha?

It returns a fixed-size list backed directly by the array — set() works and writes through to the array, but add() or remove() throw UnsupportedOperationException, because the returned type is a private inner class that only implements the size-preserving subset of List. Wrap it in new ArrayList<>(Arrays.asList(...)) if you need a genuinely mutable, independent copy.

Q39. What do List.of() and Map.of() guarantee that Arrays.asList() doesn’t?

True immutability: every mutator throws UnsupportedOperationException, including set(). They also reject null elements outright (NullPointerException at construction), unlike Arrays.asList(), which happily holds nulls. Useful default for any collection that’s conceptually a constant.

Q40. Should you ever write your own hash table instead of using HashMap?

Almost never for general-purpose use — the engineering in this article (adaptive treeification, cache-aware bit tricks, decades of tuning) is exactly the kind of thing that’s expensive to redo correctly and easy to get subtly wrong, as the JDK 7 resize bug above demonstrates even the people who maintain it got wrong once. The legitimate reasons to reach for something custom are narrow and specific: a primitive-keyed map avoiding boxing entirely (libraries like Eclipse Collections or fastutil exist precisely for this), an open-addressing table where cache locality matters more than HashMap’s chaining can offer, or a domain-specific structure (a trie for prefix search, a bitset for dense small-integer sets) that solves a narrower problem better than a general hash table ever could.
Should you memorize all forty of these? No — memorize the mental model (bucket index from a spread hash, treeify only above the capacity floor, fail-fast is best-effort, equals/hashCode must travel together) and you can re-derive most of the specific answers on the spot, which also happens to be what a good interviewer is actually listening for. The two things worth memorizing as bare numbers are 8 and 64 (treeify threshold and the capacity floor it needs), because deriving those from first principles isn’t really possible — they’re tuning constants, not consequences of anything deeper.

Going deeper on this section

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.