Same structure as the messaging guide:
- Part 1 — question index only (self-test: read the question, answer aloud, then check).
- Part 2 — answers at two levels.
L4= correct + mechanism-aware.L5= adds tradeoff, failure mode, ownership. - Part 3 — runnable reference code (Java 17).
- Part 4 — Generics quick view.
Baseline: Java 17, with Java 8 vs 7 differences called out where interviewers still probe them.
CH-1Sketch the Collection/Map hierarchy. Why isMapnot aCollection?CH-2State theequals/hashCodecontract. What breaks when it's violated?CH-3What makes a good hash key? Why must it be effectively immutable?CH-4ComparablevsComparator. What does "consistent with equals" mean and where does it bite?CH-5Fail-fast vs weakly-consistent vs snapshot iterators — which collections give which?CH-6What causesConcurrentModificationExceptionin a single thread?CH-7Which collections acceptnullkeys/values, which don't, and why?CH-8Views vs copies —subList,keySet,values,entrySet,Arrays.asList.CH-9Unmodifiable vs immutable vsList.of— what's actually different?CH-10Why returnCollections.emptyList()instead ofnull?
LI-1ArrayListinternals: growth policy, copy mechanics, default capacity.LI-2ArrayListvsLinkedList— isLinkedListever the right answer?LI-3What does theRandomAccessmarker interface change?LI-4remove(int)vsremove(Object)onList<Integer>— the classic trap.LI-5Arrays.asListpitfalls.LI-6subListmechanics and its CME risk.LI-7CopyOnWriteArrayList— cost model and correct use cases.LI-8Vector/Stack— why they're effectively dead.LI-9Safe ways to remove while iterating.LI-10Presizing andtrimToSize— when does it actually matter?
MP-1WalkHashMap.putend to end: hash → index → bucket → resize.MP-2Whyh ^ (h >>> 16)and why(n-1) & hashinstead of%?MP-3Resize mechanics. What changed in Java 8's lo/hi split?MP-4Treeification: why 8, why 64, why untreeify at 6?MP-5Load factor 0.75 — what's the tradeoff being priced?MP-6What happens if a key'shashCodechanges after insertion?MP-7HashMapunder concurrent writes — Java 7 infinite loop vs Java 8 behavior.MP-8LinkedHashMap— insertion vs access order. How do you build an LRU?MP-9TreeMap/NavigableMap— API surface and when to reach for it.MP-10ConcurrentHashMap(Java 8) design: CAS, per-bin lock, lock-free reads.MP-11Why isCHM.size()approximate? What ismappingCount()?MP-12How does CHM resize concurrently (ForwardingNode,helpTransfer)?MP-13computeIfAbsent— what's atomic, and what's the recursive-update trap?MP-14Collections.synchronizedMapvsConcurrentHashMap.MP-15ConcurrentSkipListMap— when over CHM?MP-16EnumMap/EnumSetinternals.MP-17WeakHashMapsemantics. Which leak does it not fix?MP-18IdentityHashMap— when is==semantics correct?MP-19HashtablevsHashMap.MP-20Hash-collision DoS — the attack and the mitigation.MP-21Build an LRU cache. What would you actually ship?MP-22Presizing aHashMapfor n entries — what number do you pass?
ST-1HashSet/LinkedHashSet/TreeSet— what backs each?ST-2Why doesHashSetstore a dummyPRESENTobject?ST-3TreeSetordering vsequals— duplicates that aren't duplicates.ST-4Concurrent set options and how to get one from any map.ST-5Set.of— duplicate rejection and randomized iteration order.
QD-1Queue vs Deque vs Stack. What replacesjava.util.Stack?QD-2ArrayDequeinternals — why it beatsLinkedListfor both stack and queue.QD-3PriorityQueueinternals, iteration order,remove(Object)cost.QD-4Top-K with a heap — which direction of comparator, and why?QD-5TheBlockingQueuefamily and a selection framework.QD-6ArrayBlockingQueuevsLinkedBlockingQueuelock design.QD-7SynchronousQueueandnewCachedThreadPool.QD-8DelayQueueand scheduled/retry work.QD-9Unbounded queue + fixed thread pool = the classic OOM. Explain.QD-10ConcurrentLinkedQueuevsLinkedBlockingQueue.QD-11add/offer,remove/poll,element/peek— three families, three failure behaviors.
CC-1How do you safely publish a collection to other threads?CC-2Which compound operations are still unsafe on aConcurrentHashMap?CC-3Iterator guarantees across the concurrent collections.CC-4Defensive copies at API boundaries — when are they worth it?CC-5Why are immutable collections thread-safe without synchronization?
SB-1Collectors.toMap— duplicate key and null value traps.SB-2groupingByvstoMapvspartitioningBy; downstream collectors.SB-3Collectors.toList()vsStream.toList()— mutability contract.SB-4When is a parallel stream over a collection actually faster?SB-5What is aSpliteratorand why do its characteristics matter?
PF-1Big-O cheat sheet — and where constants dominate the asymptotics.PF-2Boxing and object-header overhead; when to reach for primitive collections.PF-3Cache locality:ArrayListvsLinkedListin the real world.PF-4Decision framework: how do you pick a collection in a design round?
FM-1What are the collection-related incidents you'd actually expect in production?FM-2Mutable-key defects — how do they present?FM-3Unbounded growth via collections — the leak patterns.FM-4ThreadLocal+ map leaks in application containers.
L4
Iterable→Collection→List,Set,Queue.Deque extends Queue.SortedSet→NavigableSet.Mapis a separate root:Map→SortedMap→NavigableMap.ConcurrentMapis a sibling interface.Mapis not aCollectionbecause aCollectionis a collection of single elements; a map is a collection of pairs.add(E)has no sensible meaning.- A map exposes three collection views:
keySet(),values(),entrySet().
L5
- The split is a deliberate API design decision, not an oversight — Josh Bloch has said forcing
Map extends Collection<Map.Entry>would have made everyCollectionoperation ambiguous on maps. - The interesting consequence is the view contract: the views are live and backed by the map, so
map.keySet().remove(k)mutates the map. That's the thing juniors get wrong. Setis essentiallyMapwith the values discarded —HashSetliterally wraps aHashMap. Recognizing that means everyHashMaptuning answer transfers toHashSetfor free.
L4
equalsmust be: reflexive, symmetric, transitive, consistent, andx.equals(null) == false.hashCode: equal objects must have equal hash codes. Unequal objects may collide.- Violation → an object put into a
HashMapcannot be found by an equal key, or duplicates appear in aHashSet.
L5
- The asymmetric direction matters: breaking
equals ⇒ same hashCodesilently loses data. Breaking the reverse only costs performance. - Symmetry breaks in practice with
instanceof-basedequalsacross a subclass —super.equals(sub)true,sub.equals(super)false. UsegetClass()comparison or composition instead of inheritance (Effective Java Item 10). - Records and Lombok
@EqualsAndHashCodegenerate both together, which removes the most common class of bug. In JPA entities, prefer a business key or a UUID assigned before persist — never the generated ID, because it's null before flush and the hash changes after. - Ownership framing: I treat
equals/hashCodeon any type that enters aSetor a map key position as a review-blocking concern.
L4
- Immutable (or at least: the fields used by
hashCodenever change while the object is in a collection). - Well-distributed
hashCode, cheap to compute,equalscheap to compute. String, boxed primitives, enums, records over immutable components, and UUIDs are all good defaults.
L5
Stringcaches its hash (hashfield, computed lazily), so repeated lookups are cheap — that's part of why string keys are fine in hot paths.- Enums are the best key type available:
EnumMapskips hashing entirely and indexes an array byordinal(). - Failure mode when the key is mutable: the entry stays in the old bucket, so
getcomputes a new index, misses, and the entry becomes unreachable but still retained — a lookup failure and a memory leak at the same time. - Watch mutable collections used as keys (
List,Sethash over contents). Legal, but a landmine.
L4
Comparable<T>.compareTo= natural ordering, defined on the type itself.Comparator<T>.compare= external ordering, pluggable, and composable viacomparing(...).thenComparing(...).reversed().- "Consistent with equals" means
a.compareTo(b) == 0iffa.equals(b). - Sorted collections (
TreeMap,TreeSet) use comparison, notequals. If they disagree, the set will treat comparison-equal but non-equalsitems as duplicates.
L5
- Concrete bite:
TreeSet<Person>with a comparator onlastNameonly will silently drop every person after the first with a given surname.containsalso uses comparison, so a hash-equal object may report absent. BigDecimalis the canonical example in the JDK:new BigDecimal("1.0").equals(new BigDecimal("1.00"))is false butcompareTois 0 — soHashSetandTreeSetdisagree on the same data. In money-handling code this is a real defect class.- Comparators must be transitive and total, or
Arrays.sort's TimSort will throwIllegalArgumentException: Comparison method violates its general contract!at some data-dependent size. Classic cause: subtracting ints (a.id - b.id) and overflowing. UseInteger.compare.
L4
- Fail-fast —
ArrayList,HashMap,TreeMap,ArrayDeque,PriorityQueue. TrackmodCount; structural modification during iteration throwsConcurrentModificationException. - Weakly consistent —
ConcurrentHashMap,ConcurrentSkipListMap,ConcurrentLinkedQueue,LinkedBlockingQueue. Never throw CME; reflect some state at or after construction; may or may not see concurrent updates. - Snapshot —
CopyOnWriteArrayList/CopyOnWriteArraySet. Iterate a frozen array; never see later writes;iterator.remove()throwsUnsupportedOperationException.
L5
- CME is best-effort by contract —
modCountis not volatile, so you cannot rely on it as a correctness mechanism across threads. It's a debugging aid, not a guard. - Weakly consistent means you cannot compute a consistent aggregate by iterating a live CHM. If you need a point-in-time total, you need an external snapshot or an accumulator maintained under the same update path.
- This is the single most useful classification to have memorized — it answers "is this safe?" for almost every concurrent-collection question.
L4
- Cause: structurally modifying the collection (
add/remove) through the collection reference while a for-each loop's iterator is live. The iterator'sexpectedModCountno longer matchesmodCount. - Safe options:
Iterator.remove(),Collection.removeIf(pred), iterate a copy, or collect-then-remove. set(i, v)on aListis not a structural modification and is fine.
L5
- There's a notorious near-miss: removing the second-to-last element does not throw, because
hasNext()iscursor != sizeand the size change makes it return false early. So the bug is data-dependent and escapes tests. This is why "it worked in dev" is not evidence. removeIfis not just cleaner —ArrayListoverrides it to do a single bitset pass plus one compaction, so it's O(n) instead of O(n²) for repeatedremove(int).- On a
Map, remove viamap.entrySet().removeIf(...)ormap.values().removeIf(...)— the views are live, which isCH-1paying off.
L4
| Collection | null key | null value |
|---|---|---|
HashMap / LinkedHashMap |
one allowed (bucket 0) | allowed |
TreeMap |
no (NPE, unless comparator allows) | allowed |
Hashtable |
no | no |
ConcurrentHashMap |
no | no |
ArrayList / LinkedList |
— | allowed |
ArrayDeque / PriorityQueue |
— | no |
List.of / Map.of / Set.of |
no | no |
L5
- CHM rejects nulls because
get(k) == nullwould be ambiguous between "absent" and "mapped to null", and in a concurrent map you can't disambiguate with a follow-upcontainsKey— the state may have changed between calls. Doug Lea's stated reasoning. ArrayDeque/PriorityQueuereject nulls becausenullis the sentinel for "empty" inpoll/peek.List.of(...)is null-hostile, not merely null-rejecting:List.of(1,2).contains(null)throws NPE, which surprises code that migrated fromArrays.asList. That's a real migration hazard.- Practical rule I use: don't put nulls in collections at all. Use
Optionalat the boundary or a sentinel; a null in a collection is almost always a design smell that shows up later as an NPE three layers away.
L4
- Views (live, write-through):
map.keySet(),map.values(),map.entrySet(),list.subList(a,b),Arrays.asList(arr),Collections.unmodifiableList(l),Map.headMap/tailMap/subMap. - Copies:
new ArrayList<>(c),List.copyOf(c),stream().toList(),Collections.unmodifiableList(new ArrayList<>(l)). - Mutating a view mutates the backing structure; mutating the backing structure invalidates the view (or throws CME).
L5
Arrays.asListis a two-way view over the array:set()writes through to the array,add/removethrowUnsupportedOperationException. If you then hand that list to a library that wants to sort in place, it works; if it wants to add, it blows up at runtime.Collections.unmodifiableList(l)is an unmodifiable view, not an immutable list — the caller can't mutate through it, but anyone holdinglstill can, and the "immutable" copy changes underneath. If you're returning it from an API, copy first.subListretains a reference to the whole backing array — a 10-element sublist of a million-element list keeps the million alive. Same shape as the oldString.substringleak. Copy if the sublist outlives the parent.
L4
Collections.unmodifiableX(c)— view; blocks mutation through this reference only; backing collection can still change.List.of/Set.of/Map.of(Java 9) — genuinely immutable, null-hostile, no defensive-copy overhead, more compact representation for small sizes.List.copyOf(c)— immutable copy, and a no-op ifcis already one of these immutable types.- Elements are still mutable in all cases — these are shallow guarantees.
L5
Set.of/Map.ofiteration order is deliberately randomized per JVM run (aSALTcomputed at class init) to stop code from depending on unspecified order. That means a test that passes locally can fail in CI. Good design, occasionally infuriating.Map.ofthrowsIllegalArgumentExceptionon duplicate keys;Map.ofEntriesis the >10-entry form.- For API boundaries: return
List.copyOf(internal)— immutable, and free when the input is already immutable. That's my default for getters returning collections.
L4
- Returning
nullforces every caller to null-check; forgetting is an NPE at the call site, far from the cause. Collections.emptyList()/List.of()are singletons — zero allocation.- For-each over an empty collection is a no-op, so callers just work.
L5
Optional<Collection>is an anti-pattern — two ways to say "nothing." Return the empty collection.- The exception: if "no data" and "empty result" are semantically different to the caller (e.g. cache miss vs cached empty result), you need a distinguishing type, not a null.
L4
- Backed by
Object[] elementDataplus anint size. - Default-constructed with a shared empty array; first
addinflates to capacity 10. - Growth:
newCapacity = oldCapacity + (oldCapacity >> 1)— 1.5×. ThenArrays.copyOf(an intrinsifiedSystem.arraycopy). get/setO(1).addat end amortized O(1).add/removeat index i → O(n) shift.
L5
- Amortized O(1) hides a latency spike: a growth at 1M elements copies 1M references in one go. In a latency-sensitive path with a known bound, presize.
- 1.5× rather than 2× is a memory/copy tradeoff, and it has a nice property: the sum of freed blocks eventually exceeds the next request, so the allocator can reuse space. 2× never can.
removenulls the trailing slot to avoid a leak — but the array itself never shrinks. A list that peaked at 1M and now holds 10 still holds a 1M array.trimToSize()or reallocate.- Max capacity is bounded near
Integer.MAX_VALUE - 8; past that you need a different structure entirely.
L4
ArrayList: contiguous array, O(1) index access, cache-friendly, ~4–8 bytes overhead per element (reference only).LinkedList: doubly-linked nodes, O(1) insert/remove given a node reference, O(n) to reach an index, ~40 bytes overhead per node.- Default to
ArrayList. Always.
L5
- The classic "LinkedList is better for inserts" claim is almost always wrong, because you must traverse to the insertion point — O(n) pointer chases with cache misses — and then the O(1) splice is free.
ArrayList's O(n)arraycopyis a single sequential memmove and typically wins even at n in the tens of thousands. - The honest use case is
LinkedListas aDeque— andArrayDequebeats it there too. - Where a linked structure genuinely wins: when you hold node references and splice repeatedly, which the JDK
LinkedListAPI doesn't expose. So you'd write your own. - Interview-safe answer: "I use
ArrayListunless I have a measured reason not to; for queue/stack semantics I useArrayDeque. I've never shipped aLinkedList."
L4
- Empty marker interface on
ArrayList,Vector,CopyOnWriteArrayList,Arrays.asListresults. - Signals that index access is roughly constant-time.
- Algorithms branch on it:
Collections.binarySearch,shuffle,reverseuse an index loop forRandomAccesslists and an iterator/ListIteratorloop otherwise.
L5
- The reason it exists is that the alternative — an index loop over a
LinkedList— is O(n²). The marker lets generic algorithms avoid a pathological case without instanceof-checking concrete types. - If you write a library method taking
List<T>, mirror the JDK:if (list instanceof RandomAccess) { index loop } else { iterator loop }.
L4
List<Integer> l = new ArrayList<>(List.of(10,20,30));l.remove(1)→ removes index 1 (the value 20), becauseremove(int)is an exact match and no boxing is needed.l.remove(Integer.valueOf(1))→ removes the value 1.- Overload resolution prefers the primitive form; boxing is only considered in a later phase.
L5
- Same trap in
Collection.removeonSet<Integer>? No —Sethas noremove(int), so it's unambiguous. The bug isList-specific. - This is a genuine production bug generator when a
List<Integer>holds IDs. Mitigation: don't useList<Integer>for IDs; wrap in a domain type, or useremoveIf(x -> x == id).
L4
- Fixed size —
add/removethrowUnsupportedOperationException;setworks. - Write-through to the source array in both directions.
Arrays.asList(intArray)returnsList<int[]>of size 1, notList<Integer>— varargs sees one object. UseArrays.stream(intArray).boxed().toList().- Allows nulls (unlike
List.of).
L5
new ArrayList<>(Arrays.asList(...))is the mutable-copy idiom;List.of(...)is the immutable one. Post-Java-9 there's no reason to useArrays.asListunless you specifically want the array view or need nulls.- The int[] trap survives because it compiles cleanly and fails at runtime with a confusing
ClassCastExceptionsomewhere downstream — worth a static-analysis rule.
L4
- Returns a view over
[from, to). Mutations propagate both ways. - Structurally modifying the backing list after taking the sublist makes the sublist throw CME on next use.
list.subList(a,b).clear()is the idiomatic range-removal.
L5
- The retention issue from
CH-8: the view holds the parent, so a small sublist pins a large array.new ArrayList<>(list.subList(a,b))if it escapes the local scope. - The view's
modCountcheck is against the parent, so the failure is non-local — the code that breaks is not the code that changed anything. Painful to debug.
L4
- Every mutation copies the entire backing array under a
ReentrantLock; readers see a stable volatile array reference with no locking at all. - Reads O(1) and contention-free. Writes O(n) and allocate O(n).
- Iterators are snapshots — no CME, but
iterator.remove()throws. - Fit: read-mostly, small, mutation-rare. Listener lists, config snapshots, feature-flag sets.
L5
- The cost model is the whole answer: n writes to an n-element list is O(n²) allocation. Put one on a hot write path and you'll see GC pressure before you see the bug.
- Real fit at JPMC-shaped systems: a set of registered event handlers or a cached permission ruleset refreshed every few minutes and read on every request. That's exactly its niche.
- Alternative for the same shape at larger sizes: hold an immutable
Map/Listin avolatilefield and replace the whole reference on refresh. Same semantics, explicit, and you control the copy.
L4
Vectorsynchronizes every method — coarse-grained, and useless because compound operations still need external locking.Stack extends Vectorand its iteration order is bottom-to-top, i.e. the opposite of pop order. Genuine bug source.- Replacements:
ArrayList(+Collections.synchronizedListor a concurrent type if needed),ArrayDequefor stack semantics.
L5
- The deeper point: per-method synchronization is the wrong granularity for any real usage.
if (!v.contains(x)) v.add(x)is still racy. Once you need external locking anyway, the internal locking is pure overhead. ArrayDequeas a stack is also faster — no synchronization, andpush/popat the head of a circular array.
L4 — removeIf (best), Iterator.remove(), iterate a copy, or use a concurrent collection. For maps: entrySet().removeIf(...).
L5 — removeIf is overridden in ArrayList for a single-pass bitset implementation → O(n) vs O(n²). Under concurrency, none of these are sufficient; you need CHM/COW or external locking. And note Iterator.remove() is optional in the contract — it throws on immutable and COW collections.
L4
new ArrayList<>(expectedSize)avoids repeated grow-and-copy. Worth it when the size is known and large.trimToSize()shrinks capacity to size — for long-lived lists that peaked.
L5
- Presizing matters most where the allocation is in a loop: a per-request list sized 10 that grows to 500 does ~15 array copies per request. At 1000 rps that's measurable GC.
- Don't over-apply. For lists under ~100 elements the JIT and TLAB allocation make this noise. Measure before you clutter code with capacity hints.
L4
hash = h ^ (h >>> 16)whereh = key.hashCode().index = (n - 1) & hash, n = table length (always a power of two).- Empty bin → place a new
Node. - Occupied → compare
hashfirst, then==, thenequals. Match → replace value. No match → append to list, or insert into the red-black tree if already treeified. - If the bin list length reaches 8 →
treeifyBin(which resizes instead if table length < 64). if (++size > threshold) resize()wherethreshold = capacity * loadFactor.
L5
- The
hash == hash && (k == key || key.equals(k))ordering is a deliberate cheap-check-first: int comparison, then reference identity, then the potentially expensiveequals. Worth naming — it shows you've read the source. - Insertion is tail insertion since Java 8 (head insertion in 7), which is what removed the resize cycle in
MP-7. putIfAbsent,merge,compute*all go through the sameputVal/computeIfAbsentmachinery — they're not layered helpers, they're single-traversal operations. That's whymergebeatsget-then-put.
L4
(n-1) & hashonly uses the low bits of the hash. With small tables, two keys differing only in high bits would collide.h ^ (h >>> 16)XORs the high 16 bits down into the low 16, so high-bit entropy participates in bucket selection.&instead of%because the table size is a power of two, making the mask exactly equivalent and far cheaper than division.
L5
- It's a deliberately cheap spread — one shift, one XOR — not a full avalanche. The JDK authors traded quality for speed because treeification now bounds the worst case anyway. Before Java 8,
HashMapused four shifts and XORs. - Power-of-two sizing also makes resize cheap: an element's new index is either
iori + oldCap, decided by one bit (hash & oldCap). That's the whole trick behindMP-3. - If a user supplies a non-power-of-two initial capacity,
tableSizeForrounds up to the next power of two.
L4
- Triggered when
size > capacity * loadFactor. Capacity doubles. - Java 8 split: for each bin, elements go to either the
lolist (indexi) or thehilist (indexi + oldCap), decided by(e.hash & oldCap) == 0. - No rehashing needed — the hash is stored in the node.
- Relative order within a bin is preserved.
L5
- Java 7 rehashed and reversed order via head insertion, which under concurrency could form a cycle in the linked list → 100% CPU in
get(). Java 8's order-preserving split eliminated that specific failure. - Resize is O(n) and single-threaded; a map that grows to millions pays repeated full rehashes. Presize (
MP-22). - Treeified bins are split too, and untreeify back to a list if the resulting half is ≤
UNTREEIFY_THRESHOLD(6).
L4
TREEIFY_THRESHOLD = 8— bin converts list → red-black tree.MIN_TREEIFY_CAPACITY = 64— if the table is smaller, resize instead of treeifying (a short table is the more likely cause of long bins).UNTREEIFY_THRESHOLD = 6— during resize, a tree bin with ≤6 nodes reverts to a list.- Effect: worst-case bin lookup goes from O(n) to O(log n).
L5
- 8 comes from a Poisson argument in the JDK source comments: with a good hash and load factor 0.75, the probability of a bin reaching 8 is roughly 1 in 10⁷. So treeification is effectively an adversarial/bad-hash safety net, not a normal-path optimization.
- The 8/6 gap is hysteresis — a single threshold would thrash convert/revert around the boundary.
- Tree bins require an ordering: they use hash, then
Comparableif the key implements it, then a tie-break on identity hash. So treeified performance is better forComparablekeys. - The real significance: this is the hash-collision DoS mitigation (
MP-20).
L4
- Threshold = capacity × load factor. Lower → fewer collisions, more memory, more frequent resize. Higher → denser table, longer bins.
- 0.75 is the empirical balance point; the JDK docs state it offers a good tradeoff between time and space costs.
L5
- With load factor 0.75 and a good hash, bin occupancy follows a Poisson(0.5) distribution — most bins hold 0 or 1 entries. That's the number the treeify probability is computed from.
- Raising it to 1.0 to "save memory" is usually a false economy: you save one array of references but lengthen every lookup, and you've disabled the resize that would break up long bins.
- Almost never worth tuning. Presizing (
MP-22) is the lever that actually matters.
L4
- The entry stays physically in its original bucket.
- A subsequent
get(key)computes the new hash → wrong bucket → miss. - The entry is unreachable via
get/remove/containsKeybut still occupies memory and still appears during iteration.
L5
- It's a leak and a correctness bug simultaneously, and it's invisible in tests unless you mutate between put and get.
- Presents in production as "the cache entry exists in the heap dump but the service says cache miss" — an incident I'd expect to take hours to diagnose without knowing this mechanism.
- Prevention: immutable key types, records, or defensive copy on insert. Static analysis can flag non-final fields in classes used as map keys.
L4
- Not thread-safe. Lost updates, corrupted size, and CME during iteration.
- Java 7: concurrent
resizewith head-insertion could produce a circular linked list →get()spins forever, pinning a CPU core at 100%. - Java 8: the lo/hi split preserves order and doesn't create cycles, so the infinite loop is gone — but you still get lost entries, wrong
size, and possible lost nodes. - Fix:
ConcurrentHashMap.
L5
- The Java 7 infinite loop was one of the most-reported production incidents in the Java world — worth naming explicitly because it's a favorite interview follow-up and a good "I know why this changed" signal.
- Java 8 not throwing is arguably worse operationally: silent data loss instead of an obvious pegged CPU. Absence of a symptom isn't safety.
- The correct posture: a
HashMapreachable from more than one thread is a review-blocking defect, full stop. Not "probably fine because it's read-mostly" — unsafe publication means readers can see a partially constructed table.
L4
LinkedHashMap extends HashMapand adds a doubly-linked list across all entries → predictable iteration order.- Two modes: insertion-order (default) and access-order (
new LinkedHashMap<>(cap, 0.75f, true)), wheregetmoves the entry to the tail. - Override
removeEldestEntry(eldest)to returntruepast a size cap → LRU eviction, in ~10 lines.
L5
- Cost: two extra references per entry vs
HashMap. Iteration is O(size) rather than O(capacity), so it's actually faster to iterate a sparse map. - In access-order mode
get()is a structural modification — so a "read-only" thread mutates the list and CME becomes possible from a getter. Surprising and a real bug source. - Not thread-safe; wrapping in
synchronizedMapgives you a global lock on every read. For a real cache use Caffeine — W-TinyLFU admission, per-entry TTL/TTI, async loading, and eviction stats.LinkedHashMapLRU is the right answer for "implement it on a whiteboard," not for "what would you ship."
L4
- Red-black tree (self-balancing BST).
get/put/removeO(log n). Sorted iteration for free. NavigableMapAPI is the reason to use it:floorKey,ceilingKey,higherKey,lowerKey,firstEntry,lastEntry,headMap,tailMap,subMap,descendingMap.- Uses
compareTo/Comparator, neverequals— seeCH-4.
L5
- The real-world justification is range and nearest-key queries, not sorted iteration. Examples: time-bucketed metrics lookup ("value at or before timestamp t"), tiered pricing/fee schedules ("rate for the bracket containing this amount"), IP-range or version-range lookup. Those are one
floorEntrycall and O(n) with aHashMap. - The pricing-bracket example lands well in a fintech interview and maps to instrument/tier logic.
- Cost vs
HashMap: ~2–5× slower point lookups and more per-node memory. Only pay for it if you use the ordering.
L4
- Java 7: 16 (default) independent
Segments, each aReentrantLock-guarded mini-map → concurrency capped at the segment count. - Java 8: segments gone. Same table as
HashMap, plus:- Empty bin →
casTabAtto install the first node — lock-free. - Non-empty bin →
synchronizedon the bin's head node. Lock granularity = one bucket. - Reads → fully lock-free;
Node.valandNode.nextarevolatile.
- Empty bin →
- Bins treeify at 8 just like
HashMap. No null keys or values.
L5
- Effective concurrency scales with table size rather than a fixed segment count, so a large CHM has thousands of independent locks. That's the whole point of the rewrite.
- Using the bin head as the monitor is elegant — the lock object is exactly the data it guards, no extra allocation.
- Biased/thin locking means the uncontended
synchronizedpath is nearly free, which is why they chose it overReentrantLock. - Reads being lock-free is what makes CHM the right default even for read-heavy workloads — you don't need
COWsemantics to get contention-free reads.
L4
- CHM doesn't maintain a single counter — that would be a global contention point defeating the per-bin locking.
- It uses a striped counter: a
baseCountplus aCounterCell[]array (same idea asLongAdder).size()sums them. - The sum is therefore a snapshot that may be stale the moment it returns.
mappingCount()returnslong— use it, since a CHM can exceedInteger.MAX_VALUEentries.
L5
- Consequence for design: never write
if (map.size() < limit) map.put(...)and expect a hard cap. That's a check-then-act race. If you need a bounded map you need an explicit semaphore or an atomic counter with CAS on the limit. - Same reasoning applies to
isEmpty(). - The
LongAdderpattern generalizes: any hot counter under contention should be aLongAdder, not anAtomicLong. Good thing to volunteer.
L4
- Resize is cooperative. A thread that triggers it claims a range of bins to transfer.
- Transferred bins are replaced with a
ForwardingNodepointing at the new table. - A thread that encounters a
ForwardingNodeduringputcallshelpTransferand joins the resize instead of blocking. - Readers hitting a
ForwardingNodeare redirected to the new table.
L5
- This is why CHM doesn't have a stop-the-world resize pause the way
HashMapdoes — the cost is spread across the threads causing it, which is nicely self-regulating under load. - It's also why
size()can't be exact mid-resize. - Detail worth knowing:
sizeCtlis the coordination field — negative means resizing, and its low bits encode the number of helping threads.
L4
- On CHM,
computeIfAbsentis atomic: the check and the insert happen under the bin lock, so the mapping function runs at most once per absent key. - That makes it the correct idiom for lazy initialization —
get-then-putIfAbsentmay construct the value more than once. - Trap: the mapping function must be short and must not modify the same map. Recursive update throws
IllegalStateExceptionon CHM, and CME onHashMap(Java 9+).
L5
- The function runs while holding the bin lock. A slow function (I/O, a remote call, a DB fetch) blocks every other thread hashing to that bin. I've seen this exact pattern used as a "cache loader" and become a throughput cliff. If loading is expensive, store a
CompletableFutureor a memoizing supplier as the value and complete it outside the lock. - On plain
HashMapbefore Java 9, recursivecomputeIfAbsentcould silently corrupt the table rather than throw — one of those "upgrade fixed a bug we didn't know we had" cases. mergeis the better idiom for counters:map.merge(k, 1L, Long::sum)— one traversal, atomic on CHM, and no boxing dance.
L4
Collections.synchronizedMap |
ConcurrentHashMap |
|
|---|---|---|
| Locking | one global mutex | per-bin, reads lock-free |
| Reads | blocked by writes | never blocked |
| Iteration | must be manually synchronized by the caller, or CME | weakly consistent, no CME |
| Nulls | allowed (delegates to HashMap) |
rejected |
| Atomic compounds | none | putIfAbsent, compute*, merge |
L5
- The killer detail on
synchronizedMapis that iteration is not covered by the wrapper — you mustsynchronized (map) { for (...) }yourself, which the Javadoc says and everyone ignores. That's a latent CME. synchronizedMapstill has one use: when you need null keys/values and thread safety. Rare, and usually a sign the model is wrong.- Ownership: I'd treat any
synchronizedMapin new code as a review comment with "why not CHM?"
L4
- Lock-free (CAS-based) concurrent skip list. Implements
ConcurrentNavigableMap— sorted, with the full navigable API. - O(log n) for get/put/remove, weakly consistent iterators.
- Use it when you need both concurrency and ordering/range queries. CHM has no ordering;
TreeMaphas no concurrency. size()is O(n) — it traverses. Don't call it in a loop.
L5
- The lock-free property means no thread can block another, which matters for latency tails more than throughput. Under low contention CHM is faster; skip list wins on predictability plus ordering.
- Skip lists are chosen over concurrent balanced trees because rebalancing is hard to do lock-free — probabilistic balancing is CAS-friendly. Good "why this data structure" answer.
- Realistic use: an in-memory time-ordered index (event timestamps → payload) that's written by ingestion threads and range-queried by readers. Maps directly onto a pipeline story.
L4
EnumMapis backed by a plainObject[]indexed byordinal(). No hashing, no collisions, natural (declaration) ordering, extremely compact.EnumSetis a bit vector:RegularEnumSetuses a singlelongfor ≤64 constants;JumboEnumSetuses along[].- Both are dramatically faster and smaller than
HashMap/HashSetwith enum keys. Neither is thread-safe.
L5
EnumSetset operations (union, intersection, complement) are single bitwise instructions.EnumSet.complementOf,range,noneOfare the idioms.- This is my go-to for permission/flag sets and state-machine transition tables — small, fast, self-documenting, and it makes illegal states unrepresentable compared to a
Set<String>. - Concrete framing for the RBAC work: a
Map<DocumentType, EnumSet<Permission>>is both faster and clearer than nested string maps.
L4
- Keys are held by
WeakReference. When a key has no strong reference elsewhere, GC can reclaim it and the entry is eventually removed. - Cleanup is lazy — stale entries are expunged during subsequent
get/put/size, drained from aReferenceQueue. - Uses
equals, not identity, unless you also wantIdentityHashMapsemantics.
L5
- The trap: values are strongly referenced. If a value references its own key (directly or transitively), the key is never weakly reachable and nothing is ever collected. This is why
WeakHashMapdoesn't fix the classic cases people reach for it for. - It's also not a cache — you have no control over when entries vanish, so hit rate is at the GC's mercy. Use a real cache with a size bound.
- Legitimate use: metadata keyed by an object you don't own the lifecycle of, e.g. per-
Classor per-ClassLoaderstate. That's what the JDK uses it for internally.
L4
- Compares keys with
==and usesSystem.identityHashCode, ignoring overriddenequals/hashCode. - Linear-probing open-addressed table, not chaining.
- Deliberately violates the general
Mapcontract — the Javadoc says so.
L5
- Correct uses: object-graph traversal (serializers, deep-copy, cycle detection), where two
equalsobjects are genuinely distinct nodes. Jackson and JAXB both use this internally. - Using it as a general-purpose "fast map" is a bug factory —
map.get(new String("a"))misses. - Good signal question: it shows whether the candidate understands that
equalssemantics are a choice, not a law.
L4
Hashtableis a legacy synchronized-on-every-method class;HashMapis unsynchronized.Hashtablerejects nulls;HashMapallows one null key and null values.Hashtableuses%on a non-power-of-two capacity, grows2n+1;HashMapmasks a power of two.Hashtableiteration uses the legacyEnumeration(not fail-fast) as well as an iterator.
L5
- The real answer to "which do I use" is neither —
HashMaporConcurrentHashMap.Hashtablehas the worst of both: global locking and no atomic compound operations. - Still relevant because
Properties extends Hashtable, so it shows up in legacy config code.
L4
- Attack: an attacker sends JSON/form data with thousands of keys engineered to collide in the same
HashMapbucket. Every insert becomes an O(n) list scan → O(n²) total → CPU exhaustion from a single small request. - Practical because
String.hashCodeis a published, trivially invertible function. - Mitigation in Java 8+: treeification bounds a degenerate bin to O(log n).
L5
- Treeification is a mitigation, not a fix — O(n log n) is still an amplification, just a survivable one. Real defenses are upstream: cap request body size, cap parameter count (Tomcat's
maxParameterCount, Spring's multipart limits), and never build an unbounded map from untrusted input. - Java 7 shipped a randomized
altHashingfor String keys as an emergency mitigation (jdk.map.althashing.threshold); it was removed in 8 once treeify landed. - This is a good answer to volunteer in a security-flavored round — it connects data structures to an actual CVE class (CVE-2012-2739 and relatives).
L4
- Whiteboard:
LinkedHashMapin access-order mode withremoveEldestEntry, or aHashMap<K, Node>+ manual doubly-linked list (that's LeetCode 146 — O(1) get and put). - Production: Caffeine. Size/weight bounds, TTL and TTI, refresh-after-write, async loading, stats, and W-TinyLFU which beats plain LRU on hit rate for skewed workloads.
- Distributed: Redis/ElastiCache with
maxmemory-policy allkeys-lru.
L5
- Name the failure modes you'd guard: unbounded growth (always set a bound), stampede on a cold key (use
AsyncLoadingCacheor a per-key lock so one loader wins), and stale-data blast radius (TTL sized against the downstream's change rate). - Local vs distributed is the real tradeoff question: local is faster and has no network failure mode, but each instance has an independent view — unacceptable if the cached data drives authorization decisions. That's the version of this question a bank asks.
- LRU vs LFU vs W-TinyLFU: LRU is vulnerable to a scan wiping the working set; TinyLFU adds a frequency sketch as an admission filter to prevent exactly that.
L4
new HashMap<>(n)sets capacity, not expected entry count. With load factor 0.75, a map created with capacitynresizes after0.75nentries.- To hold
nentries without a resize:new HashMap<>((int) Math.ceil(n / 0.75)). - Java 19+:
HashMap.newHashMap(n)does this for you. Guava hasMaps.newHashMapWithExpectedSize(n).
L5
new HashMap<>(expectedSize)— passing the raw count — is one of the most common "optimization" bugs in Java code. It guarantees exactly one resize, which is the thing you were trying to avoid.- Note the constructor rounds up to a power of two, so the effective threshold is often higher than the naive calculation suggests — the correction still matters at the boundary.
- Worth doing when building a map from a known-size collection in a hot path (e.g. converting a 30k-record batch), noise otherwise.
L4
HashSet→HashMapinternally. O(1) average, no order.LinkedHashSet→LinkedHashMap. Insertion order, slight memory cost.TreeSet→TreeMap. Sorted, O(log n),NavigableSetAPI.- Everything about
HashMaptuning applies directly.
L5
LinkedHashSetis underused: it gives deterministic iteration for free, which makes tests reproducible and log output stable. I default to it wherever a set is iterated and the output is observed.HashSetiteration order is unspecified, not random — it's stable for the same insertion sequence on the same JVM, which is exactly enough to lull you into depending on it before it changes on upgrade.
L4
HashSetstores each element as aHashMapkey with a shared staticObject PRESENTas the value.- One shared singleton, so the cost is one reference per entry, not one object.
addreturnsmap.put(e, PRESENT) == null.
L5
- The cost of the reuse is a full
HashMap.Nodeper element (hash, key, value, next) where a dedicated set would need three fields. Roughly 32 bytes/element vs the ~16 a specialized implementation would use. - For large primitive sets, that's a real argument for Eclipse Collections/fastutil (
IntOpenHashSet) — order-of-magnitude memory difference.
L4
TreeSetdecides membership bycompareTo/comparereturning 0 —equalsis never consulted.- A comparator over a partial key silently drops elements that are distinct by
equals. contains/removealso use comparison, so lookups can miss objects that are in the set byequals.
L5
- Fix: make the comparator total by chaining a tie-break to a unique field —
comparing(Person::lastName).thenComparing(Person::id). - The
BigDecimalcase fromCH-4:HashSetkeeps1.0and1.00as two elements,TreeSetkeeps one. Same data, two answers, in monetary code. - Interview framing: "sorted collections use a different equality relation than hashed collections; if they disagree the same data has two different set semantics."
L4
ConcurrentHashMap.newKeySet()— the standard concurrent hash set. Backed by CHM, all its properties.Collections.newSetFromMap(anyMap)— makes a set from any map implementation.CopyOnWriteArraySet— snapshot semantics,containsis O(n). Small read-mostly sets only.ConcurrentSkipListSet— sorted concurrent set.
L5
CopyOnWriteArraySetis backed byCopyOnWriteArrayList, socontainsis a linear scan and every add scans for duplicates → O(n) per add, O(n²) to build. Fine at 10 elements, terrible at 10,000.newSetFromMapis the trick for getting a weak set (newSetFromMap(new WeakHashMap<>())) or an identity set — no dedicated JDK class exists for either.
L4
- Duplicate elements throw
IllegalArgumentExceptionat construction (unlikenew HashSet<>(List.of(...)), which silently dedupes). - Null-hostile:
contains(null)throws NPE. - Iteration order is randomized per JVM run.
L5
- The randomization is intentional (
ImmutableCollections.SALT, seeded fromSystem.nanoTimeat class init) to break code that depends on unspecified order. It will surface as a flaky test in CI, which is the point. - The duplicate-rejection difference bites during refactors from
Arrays.asList→List.ofwhen the source data legitimately contains duplicates.
L4
Queue— FIFO,offer/poll/peek.Deque— both ends:addFirst/addLast,pollFirst/pollLast, pluspush/popfor stack semantics.java.util.Stackis legacy (synchronized, and iterates bottom-to-top). Replace withArrayDequeused as a stack.PriorityQueueimplementsQueuebut is ordered by priority, not FIFO.
L5
Dequesubsumes both, which is whyArrayDequeis the single default for both stack and queue in single-threaded code.- The
Stackiteration-order bug is worth naming —for (x : stack)gives you the reverse of pop order, and it's silently wrong rather than an exception.
L4
- Circular array with
headandtailindices; capacity is always a power of two so wraparound is(i + 1) & (n - 1). - Doubles when full. Amortized O(1) at both ends.
- No nulls (null is the empty sentinel). Not thread-safe.
- Faster than
LinkedListfor both stack and queue: contiguous memory, no per-node allocation.
L5
- Per-element cost is one reference vs
LinkedList's ~40-byte node — plus sequential access patterns the prefetcher likes. The JDK Javadoc explicitly says it's faster thanStackas a stack andLinkedListas a queue. - No capacity bound and no blocking, so it's not a backpressure mechanism. For producer/consumer across threads you want a
BlockingQueue(QD-5). removeFirstOccurrence/remove(Object)are O(n) — fine, but don't build an algorithm on it.
L4
- Binary min-heap in an array.
offer/pollO(log n),peekO(1),remove(Object)/containsO(n). - Ordered by natural ordering or a supplied
Comparator. - Iteration order is not sorted —
toString,forEach, and streams give heap-array order. Only repeatedpoll()gives sorted output. - Unbounded, grows automatically. Not thread-safe (use
PriorityBlockingQueue).
L5
- The iteration-order surprise is a common production bug — someone logs the queue or streams it into a list and gets near-sorted-looking output that's wrong in the middle. Only the head is guaranteed.
- No stability guarantee for equal priorities. If FIFO-within-priority matters (a job scheduler), add a monotonically increasing sequence number as the comparator tie-break. This is a good detail to volunteer in a design round.
- Heapify from an existing collection (
new PriorityQueue<>(collection)) is O(n), not O(n log n) — worth knowing when building from a batch.
L4
- Top-K largest → maintain a min-heap of size K. Push each element; if size > K,
poll()(removes the smallest). What survives is the K largest. - Top-K smallest → max-heap of size K, i.e.
Comparator.reverseOrder(). - Complexity O(n log K), memory O(K) — the point is that it works when n doesn't fit in memory.
- Alternative: quickselect O(n) average, but destroys input and has O(n²) worst case.
L5
- The direction confuses people because it's counterintuitive; the anchor is "the heap's root is the element you're willing to evict."
- At scale (streaming, distributed), you'd do a per-partition top-K and merge — that's the map-reduce shape and it's what an interviewer wants after the single-machine version.
- For approximate top-K over a high-cardinality stream, Count-Min Sketch + a small heap is the standard answer. Worth naming if the question drifts toward "trending items."
L4
| Queue | Bounded | Notes |
|---|---|---|
ArrayBlockingQueue |
yes, fixed at construction | single lock, optional fairness |
LinkedBlockingQueue |
optional (default Integer.MAX_VALUE) |
two locks (put/take) → higher throughput |
SynchronousQueue |
capacity 0 | direct handoff, every put waits for a take |
PriorityBlockingQueue |
unbounded | priority-ordered, no blocking put |
DelayQueue |
unbounded | elements only available after their delay expires |
LinkedTransferQueue |
unbounded | transfer() waits for a consumer |
- Four method families: throws (
add/remove), returns special value (offer/poll), blocks (put/take), times out (offer(t,u)/poll(t,u)).
L5
- Selection rule: bounded by default. The bound is the backpressure mechanism — an unbounded queue converts a downstream slowdown into an OOM (
QD-9). SynchronousQueue+newCachedThreadPoolmeans "never queue, always spawn" — unbounded thread growth under load. Fine for short-lived I/O tasks, dangerous for anything else.LinkedTransferQueueis the best general-purpose unbounded choice performance-wise, but I'd still reach for a bounded queue in a service.- This maps directly onto the SQS visibility-timeout/DLQ story — same backpressure reasoning, different layer.
L4
ABQ: oneReentrantLockguarding both ends, twoConditions (notEmpty,notFull). Producers and consumers contend on the same lock. Pre-allocated array, no per-element allocation.LBQ: separateputLockandtakeLock, so a producer and a consumer can proceed simultaneously. Allocates a node per element, and anAtomicIntegercount shared across both locks.LBQgenerally higher throughput under contention;ABQlower and more predictable memory, optional fairness.
L5
ABQsupports a fair mode (FIFO among waiting threads) at a throughput cost;LBQdoesn't. Fairness matters if starvation is a real risk, which it usually isn't.ABQ's pre-allocated array is the better fit for latency-sensitive work — no allocation, no GC contribution per element.- Default recommendation:
LinkedBlockingQueuewith an explicit capacity. You get the two-lock throughput and the bound.
L4
- Capacity zero — it's a handoff point, not storage.
putblocks until atakearrives and vice versa. Executors.newCachedThreadPool()uses it: if no idle thread takes the task immediately, the pool creates a new thread. Max pool size isInteger.MAX_VALUE.- Fair mode uses a FIFO queue of waiting threads; unfair (default) uses a stack, which has better throughput but can starve.
L5
- The consequence of
newCachedThreadPoolis unbounded thread creation →OutOfMemoryError: unable to create new native threadunder a traffic spike. It's one of the two "never use theExecutorsfactory methods" cases (the other isQD-9). - Correct posture: always construct
ThreadPoolExecutordirectly with an explicit core/max/queue/rejection policy. Same argument as theExecutorscaveat in Effective Java. - Legitimate use of
SynchronousQueue: when you genuinely want zero buffering because the producer must feel the consumer's latency immediately.
L4
- Unbounded queue of
Delayedelements;take()returns an element only once itsgetDelay()≤ 0. - Internally a
PriorityQueueordered by expiry plus a leader-follower waiting scheme to avoid a thundering herd. - Use: scheduled retries, TTL expiry, delayed task execution.
L5
ScheduledThreadPoolExecutoruses the same idea internally (DelayedWorkQueue) and is usually the better API — you rarely want to manage the polling loop yourself.- The real-system caveat: in-memory delayed work is lost on restart and doesn't coordinate across instances. For a distributed retry, the answer is SQS delay seconds / message timers, or a persisted schedule table with a claim-based poller — which is exactly the pattern I'd argue for in a service.
L4
Executors.newFixedThreadPool(n)uses aLinkedBlockingQueuewithInteger.MAX_VALUEcapacity.- If arrival rate exceeds service rate, the queue grows without bound. Heap fills, GC thrashes, then OOM.
- The pool never grows past
n—maximumPoolSizeis only consulted when the queue is full, and an unbounded queue is never full. - Fix: construct
ThreadPoolExecutorwith a bounded queue and an explicitRejectedExecutionHandler.
L5
- The second-order effect is worse than the OOM: latency grows unboundedly before the crash, so every queued request is already timed out by the time it's serviced. You're burning CPU on work nobody is waiting for. A bounded queue plus fast rejection is strictly better — fail fast, shed load, keep the successful requests fast.
- Rejection policy choice is the design decision:
AbortPolicy(throw — usually right for a service, surfaces as a 503),CallerRunsPolicy(natural backpressure onto the caller thread — good for a batch pipeline, bad for a request thread since it blocks your HTTP worker),DiscardOldestPolicy(only if data is genuinely droppable). - Direct parallel to the queue-depth alarms on the SQS side — same failure shape, and the same answer: bound it and alarm on depth and age.
L4
CLQ: unbounded, non-blocking, lock-free (Michael–Scott algorithm, CAS-based).poll()returnsnullwhen empty — the consumer must spin or back off.LBQ: optionally bounded, blockingput/take, lock-based.CLQ.size()is O(n) and not atomic — never use it in a condition.
L5
- Use
CLQonly when the consumer already has its own event loop and you never want to block. UseLBQwhen you want a consumer thread to park while idle and you want backpressure. - In practice, blocking is a feature — a parked thread costs nothing, and spinning burns CPU.
LBQwith a bound is the default. CLQ's lock-free property means it makes progress even if a producer thread is descheduled mid-operation, which matters in a real-time-ish context and almost never in a web service.
L4
| Operation | Throws | Returns special | Blocks | Times out |
|---|---|---|---|---|
| Insert | add |
offer |
put |
offer(e,t,u) |
| Remove | remove |
poll |
take |
poll(t,u) |
| Examine | element |
peek |
— | — |
addthrowsIllegalStateExceptionon a full bounded queue;offerreturnsfalse.remove/elementthrowNoSuchElementExceptionon empty;poll/peekreturnnull.
L5
- Almost always use
offer/polland check the result, orput/takewhen you want blocking. Usingaddon a bounded queue is how you get an unhandledIllegalStateExceptionon a traffic spike. - Ignoring
offer's return value is a silent-data-loss bug — worth a lint rule.
L4
- Building a
HashMapin one thread and reading it in another without synchronization is unsafe even if nobody writes afterward — the reader may see a partially constructed table. - Safe publication mechanisms: initialize in a static initializer, store into a
finalfield, store into avolatilefield, publish via a concurrent collection, or guard with a lock. finalfields get freeze semantics at the end of the constructor, so a fully-populated immutable map in afinalfield is safe.
L5
- This is the part people miss when they argue "it's read-only after startup so it's fine." Without a happens-before edge, there's no guarantee the reader sees the writes at all.
- Practical idiom for a refreshed lookup table: build a new immutable
Map, assign it to avolatilefield, and let readers read the field. Copy-on-write at the reference level — no locking, no CHM, trivially correct. List.of/Map.ofresults are safe to publish through a data race because their fields are final. Not something to rely on casually, but it's the underlying reason immutability buys thread safety.
L4
- Individual operations are atomic; sequences are not.
- Unsafe:
if (!map.containsKey(k)) map.put(k, v),map.put(k, map.get(k) + 1),if (map.size() < N) map.put(...). - Safe replacements:
putIfAbsent,merge,compute,computeIfAbsent,replace(k, old, new). - Bulk operations (
forEach,search,reduce) are not atomic snapshots.
L5
- Counters:
map.merge(k, 1L, Long::sum)is atomic and single-traversal. Better still for hot counters:map.computeIfAbsent(k, x -> new LongAdder()).increment()— the map operation happens once per key, then increments are uncontended. - There's no atomic way to enforce a size bound on a CHM (
MP-11) — if you need one, use aSemaphorealongside, or Caffeine, which handles it. - The general principle to state: "concurrent collections give you atomic operations, not atomic transactions. If the invariant spans two operations, you need external coordination."
L4
| Collection | Iterator | iterator.remove() |
|---|---|---|
ArrayList, HashMap, TreeMap, ArrayDeque |
fail-fast (CME) | supported |
ConcurrentHashMap, ConcurrentSkipListMap, ConcurrentLinkedQueue, LinkedBlockingQueue |
weakly consistent | supported |
CopyOnWriteArrayList/Set |
snapshot | throws UnsupportedOperationException |
List.of, Map.of, Collections.unmodifiable* |
— | throws UnsupportedOperationException |
L5
- Weakly consistent means: no CME, each element traversed at most once, and updates may be visible. You cannot derive a consistent aggregate.
- If you need a consistent snapshot of a CHM, you need to either serialize writes behind a lock during the read, or maintain an immutable snapshot reference you swap atomically (
CC-1).
L4
- Copy on the way in (constructor/setter) so the caller can't mutate your internal state afterwards.
- Copy or wrap on the way out (getter) so the caller can't mutate your internals.
List.copyOf(x)on both sides is the modern one-liner; it's free when the input is already immutable.
L5
- Cost matters: copying a 100k-element list on every getter call is a real regression. For large internal state, prefer an immutable type internally so the getter is a free reference return.
- Copying is shallow — copying a
List<MutableThing>protects the list structure, not the elements. Say this explicitly; it's the follow-up. - My default at a service boundary: internal state is an immutable collection of immutable records, so getters copy nothing and there's no aliasing question at all.
L4
- No mutation → no data races on the object's own state.
- Needs: all fields final, no leaked references to mutable internals, safe construction (no
thisescaping the constructor). - Records give you most of this, but a record holding a
Listfield must still copy the list in a compact constructor.
L5
- Final-field freeze semantics mean a properly constructed immutable object is safe to publish even via a data race. That's the JMM guarantee that makes immutability actually free rather than "safe if you also synchronize."
- The engineering tradeoff is allocation: immutable updates copy. That's why persistent data structures (structural sharing, e.g. Vavr, or Clojure's) exist. Rarely worth introducing to a Java service.
L4
- Duplicate key →
IllegalStateException: Duplicate key. Supply a merge function:toMap(k, v, (a, b) -> b). - A null value →
NullPointerException, becausetoMapusesmap.mergeinternally, which is null-hostile. Even the 3-arg form. - Fourth arg is a map supplier:
toMap(k, v, merge, TreeMap::new)orLinkedHashMap::newfor ordered output.
L5
- The null-value NPE is nastier than it looks: the exception says nothing about which key, and it only fires when your data happens to contain a null. Workaround is
Collectors.toMapwith a wrapper, or a plainforEachloop withput. - The duplicate-key exception is arguably a feature — it surfaces a data assumption you didn't know you were making. When I hit it I check whether the key is genuinely unique before reaching for a merge function.
groupingByhas neither problem, which is why it's the safer default when uniqueness isn't guaranteed.
L4
toMap→ key must be unique, value is the mapped element.groupingBy(classifier)→Map<K, List<T>>; defaultHashMap+toList()downstream.partitioningBy(predicate)→Map<Boolean, List<T>>, always both keys present.- Downstream collectors compose:
groupingBy(X::type, counting()),groupingBy(X::type, mapping(X::id, toSet())),groupingBy(X::type, TreeMap::new, summingLong(X::amount)).
L5
partitioningByis faster thangroupingByon a boolean because it uses a fixed two-slot map — trivial, but it also documents intent better.groupingByreturns mutableHashMap/ArrayListby default; usecollectingAndThen(toList(), List::copyOf)if the result escapes.- Composed downstreams (
mapping,flatMapping,filtering,teeing) replace most nested-loop aggregation code.teeing(Java 12) computes two collectors in one pass — good for "count and sum in one traversal."
L4
Collectors.toList()— mutability, serializability, and thread-safety are unspecified; in practice anArrayList. Allows nulls.Collectors.toUnmodifiableList()— guaranteed unmodifiable, null-hostile.Stream.toList()(Java 16) — returns an unmodifiable list, but does allow nulls, unliketoUnmodifiableList().
L5
- That null difference is the one people trip on when mass-migrating
.collect(toList())→.toList(): the mutability change breaks code that sorted the result in place, but the null behavior is more permissive, so it hides rather than reveals. - Default going forward:
.toList()when you don't need to mutate,.collect(toCollection(ArrayList::new))when you explicitly do.
L4
- Needs: large N, per-element work that isn't trivial, a splittable source, and no shared mutable state.
- Splits well:
ArrayList, arrays,IntStream.range,HashMap(sized). Splits badly:LinkedList,Iterator-based sources,BufferedReader.lines(). - Uses the common
ForkJoinPool— shared process-wide.
L5
- The common-pool sharing is the operational landmine: one parallel stream doing blocking I/O starves every other parallel stream in the JVM, including framework internals. In a Spring Boot service under load I'd treat
parallelStream()in request-handling code as a defect. Submit to a dedicatedForkJoinPoolif you must. - Rough heuristic (from Brian Goetz):
N × Qshould exceed ~10⁴ elementary operations before parallelism pays for the fork/join overhead. - Ordered operations (
findFirst,limit,forEachOrdered) reintroduce sequencing costs and can make parallel slower than sequential. - Honest interview answer: "I've almost never had a workload where parallel streams were the right tool — a service is already parallel at the request level, so the cores are busy."
L4
- The parallel-capable iterator:
tryAdvance(one element),trySplit(hand off a chunk),estimateSize,characteristics. - Characteristics:
ORDERED,DISTINCT,SORTED,SIZED,NONNULL,IMMUTABLE,CONCURRENT,SUBSIZED. - The stream pipeline uses them to skip work — e.g.
distinct()is a no-op on aDISTINCTsource;SIZEDletstoArraypresize.
L5
- This is why
ArrayListparallelizes well andLinkedListdoesn't:ArrayList's spliterator isSIZED | SUBSIZED | ORDEREDand splits by index in O(1);LinkedList's must walk. - Writing a custom
Spliteratoris the right move when you're wrapping a paged API into a stream — implementtrySplitreturningnullif you can't split, and you still get a correct sequential stream. - Declaring characteristics you don't actually satisfy causes silently wrong results, not exceptions. It's a contract, not a hint.
L4
| Structure | get/contains | add | remove | Notes |
|---|---|---|---|---|
ArrayList |
O(1) index / O(n) contains | O(1)* end | O(n) | *amortized |
LinkedList |
O(n) | O(1) ends | O(1) w/ node, O(n) by value | |
ArrayDeque |
O(n) contains | O(1)* both ends | O(1)* ends | |
HashMap/HashSet |
O(1) avg, O(log n) worst | O(1) avg | O(1) avg | worst since treeify |
LinkedHashMap |
O(1) avg | O(1) avg | O(1) avg | + order |
TreeMap/TreeSet |
O(log n) | O(log n) | O(log n) | sorted |
PriorityQueue |
O(1) peek, O(n) contains | O(log n) | O(log n) poll, O(n) by value | |
CopyOnWriteArrayList |
O(1) get, O(n) contains | O(n) | O(n) | snapshot reads |
ConcurrentSkipListMap |
O(log n) | O(log n) | O(log n) | size O(n) |
L5
- The constants dominate below ~10k elements. A
LinkedListinsert is asymptotically better and empirically worse because of pointer chasing and allocation. - Watch the O(n) operations hiding in "fast" structures:
PriorityQueue.remove(Object),CLQ.size(),ConcurrentSkipListMap.size(),CopyOnWriteArraySet.add. Those are where an innocent-looking loop becomes O(n²). - The number I'd actually quote in a design round is memory, not time — see
PF-2.
L4
- Object header ~12–16 bytes; references 4 bytes with compressed oops (heap < 32 GB), 8 without.
Integer≈ 16 bytes + 4 for the reference vs 4 for anint.HashMap.Node≈ 32 bytes on top of key and value.Map<Integer, Integer>with 1M entries ≈ 60–80 MB vs ~8 MB for twoint[].Integer.valueOfcaches −128..127, which is why==on small boxed ints "works" and then doesn't.
L5
- The autoboxing cache is a genuine bug source:
Integer a = 127, b = 127; a == bis true; at 128 it's false. Always.equalsor unbox. - When memory actually matters (large in-memory indexes, caches), primitive collections — Eclipse Collections, fastutil, HPPC — are a 5–10× win in both footprint and speed. That's a real build-vs-buy call worth naming.
- The counter-argument I'd give: adding a collections library for a 100-entry map is over-engineering. The threshold is roughly "does this structure show up in a heap dump's top 10."
L4
ArrayListelements are contiguous references; the objects themselves may be scattered, but the reference scan is sequential and prefetch-friendly.LinkedListnodes are allocated independently — each traversal step is a potential cache miss.- A cache miss is ~100ns vs ~1ns for L1. That's the 100× constant the Big-O table doesn't show.
L5
- This is why
ArrayListbeatsLinkedListon insertion-in-the-middle benchmarks at surprising sizes —System.arraycopyis a vectorized sequential memmove and the traversal to find the position dominates for the linked list. - Escape-analysis and allocation locality mean freshly built object graphs are often contiguous anyway, so
ArrayListof recently-allocated objects behaves better than the theory suggests. - The honest engineering statement: "I choose
ArrayListby default and only change on a profile, because memory layout matters more than asymptotics at the sizes I actually see."
L4 — Ask in order:
- Key-value or elements? →
Mapfamily vsCollectionfamily. - Duplicates allowed? →
ListvsSet. - Ordering needed? none → hash; insertion →
Linked*; sorted/range →Tree*/skip list; priority → heap. - Access pattern? index →
ArrayList; ends only →ArrayDeque; lookup by key → hash map. - Concurrency? none → plain; concurrent →
ConcurrentHashMap/newKeySet/BlockingQueue; read-mostly & tiny →CopyOnWrite*. - Bounded? any cross-thread queue → bounded, always.
- Key type is an enum? →
EnumMap/EnumSet.
L5
- Add: who owns the lifetime? Any long-lived collection needs an eviction or bound story, or it's a leak waiting for a traffic pattern (
FM-3). - Add: does it cross an API boundary? Then it should be immutable, and the interface type should be
List/Map, not the implementation. - Say the tradeoff out loud in interviews: "
HashMapunless I need ordering;ArrayListunless I need queue semantics;ConcurrentHashMapthe moment two threads touch it; boundedLinkedBlockingQueuefor handoff. Everything else needs a specific justification."
L4
- Unbounded collection → heap exhaustion → OOM or GC death spiral.
HashMapshared across threads → lost updates / corrupted state.CMEfrom removal during iteration, often only on specific data.- Unbounded executor queue → latency collapse then OOM (
QD-9). - Mutable key → entries become unreachable (
MP-6). Collectors.toMapduplicate-key exception on production data that dev data didn't have.- Code depending on
HashMapiteration order, breaking on a JDK upgrade.
L5
- The pattern across all of these: they're load- or data-dependent, so they pass tests and fail in production. That's why the mitigations are structural (bounds, immutability, concurrent types) rather than test-based.
- Detection: heap dump + a histogram (
jmap -histo, or Eclipse MAT's dominator tree) finds #1 and #5 in minutes if you know what you're looking at. Datadog JVM metrics on old-gen occupancy after GC is the leading indicator. - The one I'd flag in a code review before it ever ships: any
MaporListfield on a singleton bean with no eviction. That's the JPMC-shaped version — a per-request cache on a@Servicebean is a leak by construction.
L4
- Symptom:
map.containsKey(k)false immediately aftermap.put(k, v)with the same reference. - Also:
map.size()grows butgetalways misses; duplicates appear in aSet. - Cause: a field used by
hashCodechanged while the object was in the collection.
L5
- Especially common with JPA entities as map keys, because Hibernate populates the ID after flush — the hash changes mid-transaction.
- Also common with DTOs that a mapper mutates after collection insertion.
- Prevention I'd enforce: records or explicitly immutable value types for anything that can be a key, and an ArchUnit/Checkstyle rule if the codebase has been burned.
L4
- Static
Mapused as a cache with no eviction. - Per-request data accumulated on a singleton-scoped bean.
- Listener/callback registries where
unregisteris never called on the failure path. - Retry/dedup sets keyed by request ID with no TTL.
- Unbounded queues in producer/consumer pipelines.
L5
- The dedup-set case is the one that connects to the messaging work: an idempotency key set held in memory grows with traffic forever. The right answer is a TTL'd store — DynamoDB with TTL, Redis with EXPIRE, or a Postgres table with a cleanup job — not a
HashSet. - Rule I'd state as a principle: every long-lived collection needs one of a hard size bound, a TTL, or a documented natural bound (e.g. "one entry per configured instrument type, ~200"). "It shouldn't get big" is not a bound.
- Detection is easier than prevention: alert on old-gen occupancy after full GC trending up over days, not on heap usage, which is noisy.
L4
ThreadLocalvalues live in aThreadLocalMapon theThreadobject. In a pooled-thread environment (Tomcat,ThreadPoolExecutor), threads are reused indefinitely, so a value set and never removed lives forever.- The map's keys are weak references to the
ThreadLocal, but the values are strong — same shape asMP-17. - Fix: always
remove()in afinally, or use a filter/interceptor that clears the context after each request.
L5
- The classic severe version is a classloader leak: a
ThreadLocalholding an application-classloader object on a container thread prevents the entire webapp classloader from being collected on redeploy →MetaspaceOOM after a few redeploys. - MDC (logging context), security context, and tenant/trace context are the usual culprits — all of them are
ThreadLocal-backed and all of them need explicit cleanup. Spring'sRequestContextHolderand Sleuth/Micrometer do this for you; hand-rolled context does not. - With virtual threads (Java 21+), threads aren't pooled, so the leak shape changes — but
ScopedValueis the intended replacement and it's structurally scoped, which removes the class of bug.
All Java 17 unless noted.
// Prefer a record — equals/hashCode/toString generated, fields final.
public record InstrumentKey(String isin, String venue) implements Comparable<InstrumentKey> {
public InstrumentKey {
Objects.requireNonNull(isin);
Objects.requireNonNull(venue);
}
private static final Comparator<InstrumentKey> ORDER =
Comparator.comparing(InstrumentKey::isin)
.thenComparing(InstrumentKey::venue); // total order: safe for TreeSet
@Override public int compareTo(InstrumentKey o) { return ORDER.compare(this, o); }
}
// Hand-written class version (when you can't use a record).
public final class Offering {
private final String id; // final: safe as a map key
private final BigDecimal amount;
Offering(String id, BigDecimal amount) {
this.id = Objects.requireNonNull(id);
this.amount = Objects.requireNonNull(amount);
}
@Override public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false; // getClass, not instanceof: preserves symmetry
Offering that = (Offering) o;
return id.equals(that.id) && amount.compareTo(that.amount) == 0; // compareTo: 1.0 == 1.00
}
// NOTE: because equals uses compareTo on BigDecimal, hashCode must NOT use amount.hashCode().
@Override public int hashCode() { return id.hashCode(); }
}
// Comparator composition, including null handling and reverse.
Comparator<Offering> byAmountDescThenId =
Comparator.comparing(Offering::amount, Comparator.nullsLast(Comparator.reverseOrder()))
.thenComparing(Offering::id);
// NEVER: (a, b) -> a.getCount() - b.getCount() // int overflow -> TimSort contract violation
Comparator<Offering> safe = Comparator.comparingInt(o -> o.id().length());// (a) LinkedHashMap access-order LRU — the interview answer.
public final class LruCache<K, V> extends LinkedHashMap<K, V> {
private final int maxEntries;
public LruCache(int maxEntries) {
super(HashMap.newHashMap(maxEntries), 0.75f, true); // true = ACCESS order (Java 19+ sizing helper)
this.maxEntries = maxEntries;
}
@Override protected boolean removeEldestEntry(Map.Entry<K, V> eldest) {
return size() > maxEntries;
}
}
// Not thread-safe. get() structurally modifies (access order) -> CME from a "reader".
// Wrap: Collections.synchronizedMap(new LruCache<>(1000)) — global lock, acceptable only at low QPS.
// (b) Manual HashMap + doubly-linked list — LeetCode 146 shape, O(1) get/put.
final class Node<K, V> { K k; V v; Node<K,V> prev, next; Node(K k, V v){this.k=k;this.v=v;} }
public final class ManualLru<K, V> {
private final int cap;
private final Map<K, Node<K,V>> index;
private final Node<K,V> head = new Node<>(null, null); // MRU sentinel
private final Node<K,V> tail = new Node<>(null, null); // LRU sentinel
public ManualLru(int cap) {
this.cap = cap;
this.index = HashMap.newHashMap(cap);
head.next = tail; tail.prev = head;
}
public V get(K k) {
Node<K,V> n = index.get(k);
if (n == null) return null;
unlink(n); linkFirst(n);
return n.v;
}
public void put(K k, V v) {
Node<K,V> n = index.get(k);
if (n != null) { n.v = v; unlink(n); linkFirst(n); return; }
if (index.size() == cap) { Node<K,V> lru = tail.prev; unlink(lru); index.remove(lru.k); }
n = new Node<>(k, v);
index.put(k, n); linkFirst(n);
}
private void unlink(Node<K,V> n) { n.prev.next = n.next; n.next.prev = n.prev; }
private void linkFirst(Node<K,V> n) { n.next = head.next; n.prev = head;
head.next.prev = n; head.next = n; }
}// (c) What I'd actually ship: Caffeine.
// build.gradle: implementation 'com.github.ben-manes.caffeine:caffeine:3.1.8'
LoadingCache<String, Instrument> cache = Caffeine.newBuilder()
.maximumSize(10_000)
.expireAfterWrite(Duration.ofMinutes(10))
.refreshAfterWrite(Duration.ofMinutes(2)) // serve stale, refresh in background
.recordStats() // export hitRate to Datadog
.build(key -> instrumentClient.fetch(key)); // loader; stampede-protected per key
Instrument i = cache.get("US0378331005");ConcurrentMap<String, LongAdder> counters = new ConcurrentHashMap<>();
// WRONG — check-then-act, lost updates.
if (!counters.containsKey(k)) counters.put(k, new LongAdder());
counters.get(k).increment();
// RIGHT — atomic, and the adder makes the increment itself contention-free.
counters.computeIfAbsent(k, x -> new LongAdder()).increment();
// Simple counting without an adder:
ConcurrentMap<String, Long> counts = new ConcurrentHashMap<>();
counts.merge(k, 1L, Long::sum); // atomic, one traversal
// Conditional replace (CAS on value):
boolean applied = map.replace(key, expectedOld, newValue);
// TRAP: expensive loader holds the BIN LOCK for every thread hashing to that bin.
cache.computeIfAbsent(key, k -> httpClient.fetch(k)); // <-- do not do this
// Fix: store a future; the fetch happens outside the map's lock.
ConcurrentMap<String, CompletableFuture<Instrument>> futures = new ConcurrentHashMap<>();
CompletableFuture<Instrument> f = futures.computeIfAbsent(
key, k -> CompletableFuture.supplyAsync(() -> httpClient.fetch(k), ioPool));
Instrument value = f.join();
// (Caffeine's AsyncLoadingCache does exactly this, with eviction. Prefer it.)
// TRAP: recursive update -> IllegalStateException on CHM, CME on HashMap (Java 9+).
map.computeIfAbsent(a, k -> { map.put(b, v); return compute(k); }); // <-- throws
// Concurrent set:
Set<String> seen = ConcurrentHashMap.newKeySet();
if (seen.add(messageId)) { process(msg); } // atomic dedup — but see FM-3: needs a TTLList<Order> orders = new ArrayList<>(source);
// BEST: single pass, O(n) in ArrayList's override.
orders.removeIf(o -> o.status() == CANCELLED);
// When you need the element during removal:
for (Iterator<Order> it = orders.iterator(); it.hasNext(); ) {
Order o = it.next();
if (o.isExpired()) { audit(o); it.remove(); }
}
// Map removal via the live views (CH-1):
map.entrySet().removeIf(e -> e.getValue().isStale());
map.values().removeIf(Objects::isNull);
map.keySet().retainAll(activeKeys);
// synchronizedMap: the WRAPPER DOES NOT COVER ITERATION.
Map<String, V> sync = Collections.synchronizedMap(new HashMap<>());
synchronized (sync) { // required, or CME
for (var e : sync.entrySet()) { ... }
}
// ...which is exactly why you use ConcurrentHashMap instead./** Top K largest by amount. Min-heap of size K: the root is what we're willing to evict. */
static List<Trade> topK(Iterable<Trade> stream, int k) {
PriorityQueue<Trade> heap = new PriorityQueue<>(k, Comparator.comparing(Trade::amount));
for (Trade t : stream) {
heap.offer(t);
if (heap.size() > k) heap.poll(); // drop the current smallest
}
List<Trade> out = new ArrayList<>(heap); // NOTE: heap order, NOT sorted
out.sort(Comparator.comparing(Trade::amount).reversed());
return out;
}
/** FIFO tie-break within equal priority — PriorityQueue is NOT stable. */
record Job(int priority, long seq, Runnable task) {}
AtomicLong seq = new AtomicLong();
PriorityQueue<Job> scheduler = new PriorityQueue<>(
Comparator.comparingInt(Job::priority).thenComparingLong(Job::seq));
scheduler.offer(new Job(1, seq.getAndIncrement(), task));BlockingQueue<Record> queue = new LinkedBlockingQueue<>(10_000); // ALWAYS bounded
ExecutorService consumers = Executors.newFixedThreadPool(8);
final Record POISON = Record.poison();
// Producer: put() blocks when full -> upstream feels the pressure. That is the point.
void ingest(Record r) throws InterruptedException {
if (!queue.offer(r, 500, TimeUnit.MILLISECONDS)) {
droppedCounter.increment(); // or: throw and let the caller retry/DLQ
throw new BackpressureException("ingest queue full");
}
}
// Consumer with drain-to-batch (far fewer lock acquisitions than take()-per-element).
Runnable consumer = () -> {
List<Record> batch = new ArrayList<>(500);
try {
while (!Thread.currentThread().isInterrupted()) {
Record first = queue.take(); // blocks; parked thread costs nothing
if (first == POISON) { queue.put(POISON); break; } // repost for siblings
batch.add(first);
queue.drainTo(batch, 499);
persist(batch);
batch.clear();
}
} catch (InterruptedException e) { Thread.currentThread().interrupt(); }
};ThreadPoolExecutor pool = new ThreadPoolExecutor(
8, 32, // core, max
60L, TimeUnit.SECONDS, // keep-alive for non-core
new LinkedBlockingQueue<>(1_000), // BOUNDED — or max is never reached (QD-9)
new ThreadFactoryBuilder().setNameFormat("ingest-%d").build(), // named threads = usable dumps
new ThreadPoolExecutor.AbortPolicy()); // fail fast -> 503, not an unbounded latency queue
// Rejection policy is the design decision:
// AbortPolicy -> throws RejectedExecutionException. Right for a request-serving service.
// CallerRunsPolicy -> backpressure onto the caller. Right for a batch pipeline; BAD on an HTTP thread.
// DiscardOldestPolicy-> only when data is genuinely droppable (e.g. metrics samples).
// Instrument it — queue depth is the leading indicator, before latency moves.
Gauge.builder("pool.queue.depth", pool, p -> p.getQueue().size()).register(meterRegistry);
Gauge.builder("pool.active", pool, ThreadPoolExecutor::getActiveCount).register(meterRegistry);// toMap with an explicit merge + an ordered result map.
Map<String, Offering> byIsin = offerings.stream()
.collect(Collectors.toMap(
Offering::isin,
Function.identity(),
(a, b) -> a.version() >= b.version() ? a : b, // deterministic conflict rule
LinkedHashMap::new));
// groupingBy with a downstream, into a sorted map.
Map<InstrumentType, BigDecimal> notionalByType = trades.stream()
.collect(Collectors.groupingBy(
Trade::type,
() -> new EnumMap<>(InstrumentType.class), // enum key -> EnumMap (MP-16)
Collectors.reducing(BigDecimal.ZERO, Trade::notional, BigDecimal::add)));
// Two aggregations in ONE pass (Java 12+).
record Summary(long count, BigDecimal total) {}
Summary s = trades.stream().collect(Collectors.teeing(
Collectors.counting(),
Collectors.reducing(BigDecimal.ZERO, Trade::notional, BigDecimal::add),
Summary::new));
// Immutable result at the API boundary.
List<String> ids = trades.stream()
.map(Trade::id)
.collect(Collectors.collectingAndThen(Collectors.toList(), List::copyOf));
// TRAP: toMap NPEs on a null value even with a merge function (it uses Map.merge).
// Use a loop, or map nulls to a sentinel first.enum Permission { READ, WRITE, DELETE, SHARE, AUDIT }
enum DocumentType { CONTRACT, PROSPECTUS, TERM_SHEET }
// Bit-vector sets; union/intersection are single bitwise ops.
static final Map<Role, EnumSet<Permission>> ROLE_GRANTS = new EnumMap<>(Map.of(
Role.VIEWER, EnumSet.of(Permission.READ),
Role.EDITOR, EnumSet.of(Permission.READ, Permission.WRITE),
Role.ADMIN, EnumSet.allOf(Permission.class)));
static final Map<DocumentType, EnumSet<Permission>> TYPE_LIMITS = new EnumMap<>(Map.of(
DocumentType.CONTRACT, EnumSet.complementOf(EnumSet.of(Permission.DELETE)),
DocumentType.PROSPECTUS, EnumSet.allOf(Permission.class),
DocumentType.TERM_SHEET, EnumSet.of(Permission.READ, Permission.SHARE)));
static boolean allowed(Role role, DocumentType type, Permission p) {
EnumSet<Permission> effective = EnumSet.copyOf(ROLE_GRANTS.get(role)); // copy: EnumSet is mutable
effective.retainAll(TYPE_LIMITS.get(type)); // one AND over a long
return effective.contains(p);
}
// EnumMap: Object[] indexed by ordinal(). No hashing, no collisions, iteration in declaration order.// Fee schedule: "which bracket does this notional fall into?" — one O(log n) call.
NavigableMap<BigDecimal, BigDecimal> feeSchedule = new TreeMap<>(Map.of(
new BigDecimal("0"), new BigDecimal("0.0030"),
new BigDecimal("1000000"), new BigDecimal("0.0020"),
new BigDecimal("10000000"), new BigDecimal("0.0010")));
BigDecimal rate = feeSchedule.floorEntry(notional).getValue(); // greatest key <= notional
// Time-bucketed lookup: "value as of timestamp t".
NavigableMap<Instant, Quote> quotes = new TreeMap<>();
Quote asOf = quotes.floorEntry(t).getValue();
// Range scan (view — no copy):
SortedMap<Instant, Quote> window = quotes.subMap(from, true, to, false);
// Concurrent version, same API:
ConcurrentNavigableMap<Instant, Quote> live = new ConcurrentSkipListMap<>();
// NOTE: live.size() is O(n). Track a separate LongAdder if you need a count.public final class RuleSet {
private final Map<DocumentType, List<Rule>> rules; // immutable internals
public RuleSet(Map<DocumentType, List<Rule>> input) {
Map<DocumentType, List<Rule>> copy = new EnumMap<>(DocumentType.class);
input.forEach((k, v) -> copy.put(k, List.copyOf(v))); // deep-ish copy on the way IN
this.rules = Collections.unmodifiableMap(copy);
}
public List<Rule> rulesFor(DocumentType t) {
return rules.getOrDefault(t, List.of()); // free: already immutable, no copy on get
}
}
// Hot-swap pattern for a periodically refreshed lookup table.
// No locks, no ConcurrentHashMap — publication safety comes from the volatile write.
public final class RuleCache {
private volatile RuleSet current = new RuleSet(Map.of());
public RuleSet get() { return current; } // lock-free read
@Scheduled(fixedDelay = 300_000)
void refresh() { current = new RuleSet(repository.loadAll()); } // atomic reference swap
}// Java 19+:
Map<String, Row> m = HashMap.newHashMap(expectedSize); // handles the /0.75 for you
Set<String> s = HashSet.newHashSet(expectedSize);
// Java 17 and below:
static int mapCapacity(int expectedEntries) {
return (int) Math.ceil(expectedEntries / 0.75d);
}
Map<String, Row> m17 = new HashMap<>(mapCapacity(30_000));
// WRONG — resizes at 22,500 entries, which is exactly what you were avoiding.
Map<String, Row> bad = new HashMap<>(30_000);- Generics are compile-time only.
javacchecks types, erases the type parameters, and inserts casts. List<String>andList<Integer>are the same class at runtime:list1.getClass() == list2.getClass()istrue.- Unbounded
Terases toObject; bounded<T extends Number>erases toNumber. - Chosen for migration compatibility — pre-generics code had to keep working. That decision is the source of every limitation below.
List<String> a = new ArrayList<>();
List<Integer> b = new ArrayList<>();
a.getClass() == b.getClass(); // true — same runtime class| Illegal | Why | Workaround |
|---|---|---|
new T() |
no runtime type | pass a Supplier<T> or Class<T> |
new T[10] |
can't allocate an erased type | (T[]) new Object[10] + @SuppressWarnings, or List<T> |
x instanceof List<String> |
erased | x instanceof List<?> |
List<String>.class |
one class object for all | List.class |
static T field; |
T is per-instance | make the method generic, or a static factory |
class MyEx<T> extends Exception |
catch matching needs reification | non-generic exception + a typed payload |
List<int> |
primitives aren't objects | List<Integer>, or a primitive-collection library |
overloads differing only in List<String> vs List<Integer> |
same erasure | rename the methods |
- Arrays are covariant and reified.
Object[] o = new String[1];compiles, theno[0] = 1;throwsArrayStoreExceptionat runtime. - Generics are invariant and erased.
List<Object> l = new ArrayList<String>();doesn't compile — the error moves to compile time, which is the point. - Because they mix badly, you can't create
new List<String>[10]. PreferList<List<String>>over an array of generics.
Producer Extends, Consumer Super.
// Producer: you READ T out of it.
void printAll(List<? extends Number> src) {
for (Number n : src) { ... } // read OK
// src.add(1); // COMPILE ERROR — could be List<Double>
}
// Consumer: you WRITE T into it.
void fill(List<? super Integer> dst) {
dst.add(1); // write OK
Object o = dst.get(0); // reads come back as Object only
}
// The JDK's own signature is the canonical example:
public static <T> void copy(List<? super T> dest, List<? extends T> src)
// Both read and write -> exact type, no wildcard:
void swap(List<T> list, int i, int j)List<?>(unbounded) — you can readObjectandadd(null), nothing else. Use for "I only care about size/clear/iteration."- Rule of thumb: wildcards on parameters, never on return types. A wildcard return type forces every caller to deal with wildcards.
// Type parameter goes before the return type.
static <T extends Comparable<? super T>> T max(Collection<? extends T> c) { ... }
// ^ bound ^ super: allows T whose *superclass* implements Comparable
// Multiple bounds — class first, then interfaces.
static <T extends Number & Comparable<T>> T clamp(T v, T lo, T hi) { ... }
// Recursive (self-referential) bound — the Enum idiom.
static <E extends Enum<E>> EnumSet<E> allOf(Class<E> type) { ... }
// Type token — recovers the type erasure removed.
static <T> T parse(String json, Class<T> type) { return mapper.readValue(json, type); }
// For generic targets you need a super type token (Jackson's TypeReference):
List<Trade> trades = mapper.readValue(json, new TypeReference<List<Trade>>() {});<? super T> in Comparable<? super T> matters: it lets max() accept a Dog whose ordering is defined on Animal. Without it, the signature is needlessly restrictive — this is what "flexible API design" means in practice.
@SafeVarargs // static, final, or private methods only (private since Java 9)
static <T> List<T> listOf(T... items) { return List.of(items); }
// The danger — a generic varargs param is an array of an erased type:
static <T> T[] toArray(T... args) { return args; }
static <T> T[] pick(T a, T b) { return toArray(a, b); } // creates Object[] at runtime
String[] s = pick("x", "y"); // ClassCastException at runtimeRule: only add @SafeVarargs if the method never stores into the varargs array and never lets it escape.
- A raw
Listdisables all generic checking in that expression, including for unrelated type parameters. Never use raw types in new code. @SuppressWarnings("unchecked")goes on the narrowest possible declaration — ideally a local variable, never a class.- Bridge methods: the compiler synthesizes them so covariant overrides work after erasure (
Comparable.compareTo(Object)delegating tocompareTo(Foo)). You'll see them in stack traces; that's all you need to know.
- Why is
List<String>not a subtype ofList<Object>? → Invariance; otherwise you couldadd(1)through the supertype reference and break type safety. - What does
List<?>let you add? → Onlynull. - Why can't you create
new T[n]? → Erasure: no runtime type to allocate. Use(T[]) new Object[n]internally, orList<T>. - PECS on
Collections.copy? →destis? super T(consumer),srcis? extends T(producer). - Why can't a generic class extend
Throwable? →catchmatching requires reified types. - Two methods
f(List<String>)andf(List<Integer>)? → Same erasuref(List), won't compile. List<Object>vsList<?>vs rawList? → Holds anything (invariant, unusable asList<String>) / unknown type, read-only / no checking at all, never use.- Does
getClass()distinguishArrayList<String>fromArrayList<Integer>? → No. - When would you take
Class<T>as a parameter? → Whenever you need the runtime type erasure removed: deserialization, reflection,EnumSet.allOf. - Why does
Collections.maxuse<T extends Comparable<? super T>>? → So a subtype ordered by its supertype'scompareTostill qualifies.
MP-1→MP-7(HashMap internals). Highest question density in both bank screens and big-tech phone screens.CH-2,CH-4,CH-5,CH-6(contracts + iterator semantics). These unlock most follow-ups.MP-10→MP-14,CC-1→CC-3(CHM + concurrency). This is the L4/L5 separator.QD-5,QD-9(bounded queues + pool sizing). Bridges directly into your SQS/backpressure story — reuse the same framing.- Part 4 generics — one pass, then the self-test. Bank screens ask 4.1/4.2/4.4 verbatim.
MP-6, MP-13, FM-3, FM-4 are the ones to volunteer unprompted when an interviewer asks "tell me about a bug you'd worry about" — they show operational thinking rather than recall.
- OpenJDK
HashMap.java— read the class-level comment block; the Poisson distribution justification forTREEIFY_THRESHOLD = 8is right there. ThenConcurrentHashMap.javain the same directory for the CAS/bin-lock design. - Java 17 Collections Framework Overview and the
java.util.concurrentpackage summary — the concurrent package summary is where the weakly-consistent iterator contract is actually defined. - The Java Tutorials — Generics — the "Restrictions on Generics" and "Wildcards" pages cover Part 4 completely.
- Caffeine — read the wiki's "Efficiency" page for the W-TinyLFU vs LRU hit-rate comparison. This is the production answer to
MP-21. - Effective Java 3rd ed. — Items 10–14 (equals/hashCode/Comparable), 26–33 (generics), 78–84 (concurrency). Item 28 (lists over arrays) is the cleanest explanation of covariance vs erasure in print.
- Java Concurrency in Practice — Ch. 5 (building blocks) and Ch. 8 (thread pool sizing / rejection policies) are the source material for
QD-5throughQD-9.