Collections and HashMap: the must-knows
A dense revision sheet for the Collections round: choosing collections by Big-O, ArrayList growth, HashMap internals from hash spreading to tree bins and resize splits, LRU with LinkedHashMap, ConcurrentHashMap, fail-fast iterators, generics and immutable collections.
On this page
- Picture it first: the collections map
- Choosing the right collection
- ArrayList vs LinkedList
- HashMap internals in depth
- The mutable key bug
- LinkedHashMap and the LRU cache
- TreeMap, TreeSet and ordering
- ConcurrentHashMap vs synchronizedMap vs Hashtable
- Fail-fast vs fail-safe iterators
- Generics essentials
- Immutable vs unmodifiable
- Pitfalls that cost offers
- Senior-round follow-ups
- How to say it in the interview
🗂️ What is this sheet?
It's the Collections round compressed into one sitting: which collection to pick and what it costs, how HashMap works down to the bit operations, and the concurrency, iterator and generics questions that follow it. Every Java interview I've run or sat in touches at least two of these items. The order is deliberate: HashMap internals come first because they are asked most, and everything else hangs off them.
For this sheet, we'll revise the collections hierarchy, a Big-O table for choosing the right type, ArrayList against LinkedList, HashMap from hashCode() to treeified bins, then LinkedHashMap, TreeMap, ConcurrentHashMap, iterators, ordering, generics and immutable collections. Each item gives you the why in a few sentences and the exact numbers an interviewer expects. When you want the long version, follow the link.
Below the line (out of scope)
- The
equalsandhashCodecontracts in full depth: The equals() and hashCode() contracts - Writing
compareToand comparator chains properly: Comparing objects in Java - Every collection class, one lesson each: Module 03: Collections
- Thread pools, locks and the memory model: Multithreading and the Java Memory Model: the must-knows
The 30-second answer
Collections split into List, Set and Queue under Collection, with Map as a separate hierarchy. ArrayList is the default list: O(1) get, amortised O(1) append, growth by 1.5x. HashMap is an array of buckets: it spreads the hash with h ^ (h >>> 16), picks the bucket with (n - 1) & hash, compares stored hash then equals inside the bucket, and doubles the table when size passes capacity × 0.75 (12 for the default 16). Since Java 8 a bucket that passes 8 nodes becomes a red-black tree once the table has 64 buckets, so the worst case drops from O(n) to O(log n). For threads I use ConcurrentHashMap, which CASes into empty bins and locks only the head node of a busy one, and I never mutate a key after inserting it.
What you’ll learn
- Pick the right collection from a Big-O table and explain ArrayList's 1.5x amortised growth against LinkedList
- Walk through HashMap put and get:
h ^ (h >>> 16),(n - 1) & hash, load factor 0.75, resize with the lo/hi split - State the treeify rules (8, 64, 6), why Java 8 added them, and why keys must be immutable
- Build an LRU cache with
LinkedHashMapand compare TreeMap, Hashtable, synchronizedMap and ConcurrentHashMap - Explain fail-fast iterators and
modCount, and solve the remove-in-for-each puzzle - Answer the generics and immutability probes: type erasure, PECS, invariance,
List.ofvsunmodifiableList
Interview radar
Asked very often: How does HashMap work internally? ArrayList vs LinkedList? HashMap vs Hashtable vs ConcurrentHashMap? Why do I get ConcurrentModificationException?
Senior rounds probe: Why the table size is a power of two, what happens on resize, why treeify waits for 64 buckets, why ConcurrentHashMap rejects null, how compute stays atomic, PECS and type erasure.
Production angle: A map that grows forever because keys were mutated, a HashMap shared between threads, a newFixedThreadPool-style unbounded structure, or an LRU cache built by hand when LinkedHashMap already does it.
01Picture it first: the collections map
Think of the Collections Framework as a set of contracts with several engines behind each one. List promises order and index access, Set promises uniqueness, Queue promises an order of removal, and Map promises key lookup. The engine you choose (array, linked nodes, hash table, red-black tree) decides the cost of every operation.
Here is the whole family on one page, including the detail people forget: Map does not extend Collection.
The Java collections hierarchy: Iterable and Collection at the top, List, Set and Queue below with their main implementations, and Map as a separate tree with HashMap, LinkedHashMap, TreeMap and ConcurrentHashMap
- Map is not a Collection:
Maphas noadd(E)oriterator(); you iteratekeySet(),values()orentrySet(). Interviewers ask this to see whether you've looked at the hierarchy or memorised class names. Module 03: Collections - Sets are maps in disguise:
HashSetstores elements as keys of an internalHashMapwith a shared dummy value,LinkedHashSetuses aLinkedHashMap, andTreeSetuses aTreeMap. Everything you learn about the map applies to the set. - Java 21 sequenced collections (JEP 431):
SequencedCollectionaddsgetFirst(),getLast(),addFirst(),reversed()toList,DequeandLinkedHashSet;SequencedMapaddsfirstEntry(),pollLastEntry()andputFirst()toLinkedHashMapandTreeMap. Mentioning it shows you're current. - Legacy classes:
Vector,StackandHashtableare synchronized on every method and date from Java 1.0. UseArrayList,ArrayDequeandHashMap(orConcurrentHashMap) instead.
02Choosing the right collection
When an interviewer says "which collection would you use?", they want the access pattern first, then the type, then its cost. This is the table I'd keep in my head.
| Need | Pick | get / contains | add | remove | Notes |
|---|---|---|---|---|---|
| Ordered list, random access | ArrayList |
O(1) by index, O(n) contains | amortised O(1) at end, O(n) in middle | O(n) | default list |
| Insert/remove at both ends | ArrayDeque |
O(1) peek | amortised O(1) | O(1) at ends | faster stack and queue than Stack/LinkedList |
| Key lookup | HashMap |
O(1) average | O(1) average | O(1) average | worst case O(log n) with tree bins |
| Key lookup, keep insertion or access order | LinkedHashMap |
O(1) | O(1) | O(1) | LRU caches |
| Sorted keys, range queries | TreeMap |
O(log n) | O(log n) | O(log n) | floorKey, ceilingKey, subMap |
| Unique elements | HashSet / TreeSet |
O(1) / O(log n) | O(1) / O(log n) | O(1) / O(log n) | same engines as the maps |
| Always take the smallest | PriorityQueue |
O(1) peek | O(log n) | O(log n) poll, O(n) remove(Object) |
binary heap in an array, not sorted |
| Many readers, rare writers | CopyOnWriteArrayList |
O(1) | O(n) copy | O(n) copy | listener lists |
| Shared map across threads | ConcurrentHashMap |
O(1), lock-free | O(1) | O(1) | no null keys or values |
Notice the pattern. Hash structures give O(1) by giving up order, tree structures give order for O(log n), and the linked variants add order back to a hash structure for a few extra pointers per entry.
If you know the size, say so and presize. new ArrayList<>(10_000) and HashMap.newHashMap(10_000) (Java 19+) skip every intermediate copy. That one sentence often earns more credit than reciting the table.
03ArrayList vs LinkedList
ArrayList is a resizable Object[]. A no-argument new ArrayList<>() starts with a shared empty array and allocates 10 slots on the first add. When it's full, it grows to old + (old >> 1), which is 1.5x, and copies everything with Arrays.copyOf.
- Amortised O(1) append: a copy of n elements happens only after roughly n/2 cheap appends since the last copy, so the total copying work over n appends is a constant multiple of n. Any single
addcan still cost O(n); that's the copy. - Middle inserts are O(n) in both:
ArrayListshifts elements withSystem.arraycopy,LinkedListwalks to the position first (from the nearer end), then relinks. The shift is a memory copy and wins in practice. - Memory: with compressed oops, an
ArrayListslot is 4 bytes; aLinkedList.Nodeis an object with a 12-byte header plus three references, about 24 bytes per element, and scattered on the heap so every step is a likely cache miss. - When LinkedList is fine: removing via a
ListIteratoryou already hold, or as aDequeif you neednullelements. OtherwiseArrayDequebeats it as a queue andArrayListbeats it as a list. Module 03: Collections
Iterating 1 million Integers is usually several times faster from an ArrayList than from a LinkedList on the same machine, because the array of references is walked sequentially and the CPU prefetcher helps; the linked version chases 1 million pointers. Measure with JMH before you quote a number in production, but in the interview, "cache locality beats Big-O constants" is the point.
04HashMap internals in depth
This is the question. Let's get the picture right first, then walk put and get step by step.
A HashMap is a Node<K,V>[] table whose length is always a power of two. Each Node holds four things: the cached hash, the key, the value and next. A bucket (bin) is empty, a linked list of nodes, or a red-black tree of TreeNodes.
A HashMap table of 16 buckets: bucket 0 holds the null key, bucket 5 holds a linked list bin of three nodes, and bucket 11 holds a red-black tree bin, with the treeify, resize and untreeify rules underneath
The numbers to say out loud
| Constant | Value | Why |
|---|---|---|
| Default capacity | 16 | power of two, so (n - 1) & hash replaces % |
| Load factor | 0.75 | balance between memory and chain length |
| Threshold | capacity × 0.75 (12 for 16) | resize when size exceeds it, so the 13th entry triggers it |
TREEIFY_THRESHOLD |
8 | list becomes a tree when a bin passes 8 nodes |
MIN_TREEIFY_CAPACITY |
64 | below 64 buckets, resize instead of treeifying |
UNTREEIFY_THRESHOLD |
6 | a tree half with 6 or fewer nodes after a split becomes a list |
| Maximum capacity | 2^30 | the largest power of two that fits an int table length |
The table itself is created lazily: new HashMap<>() allocates nothing until the first put.
Step 1: spread the hash
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
With 16 buckets, only the low 4 bits of the hash pick the bucket. Many real hashCodes differ only in their high bits (think Float keys or small multiples of large numbers), so without help they'd all collide. XOR-ing the top 16 bits into the bottom 16 lets the high bits influence the index for the cost of one shift and one XOR. A null key gets hash 0, so it always lives in bucket 0, and HashMap allows exactly one.
Step 2: pick the bucket
index = (n - 1) & hash. For n = 16, n - 1 is 0b1111, so the mask keeps the low 4 bits. This is the same as hash % n for a power of two, but it's a single AND, and it is never negative. That's why the capacity you pass to the constructor is rounded up to the next power of two: new HashMap<>(100) gets 128 buckets.
Step 3: put, step by step
- If the table is
null,resize()allocates it (16 buckets by default). - Compute
i = (n - 1) & hash. Iftable[i]is empty, place a newNodethere. Done. - Otherwise compare the first node: same stored
hash, then==on the key, thenequals. If it matches, this is an update. - If the bin is a tree, call
putTreeVal(O(log k) for k nodes in the bin). - Otherwise walk the list. If a node matches, update it. If you reach the end, append at the tail (Java 8+), and if the bin has now passed 8 nodes, call
treeifyBin. - For an update, replace the value and return the old one;
modCountdoes not change. - For a new entry, increment
modCountandsize. If++size > threshold,resize().
Step 4: get, step by step
get is the read-only version: spread the hash, mask to a bucket, check the first node, then either search the tree or walk the list comparing stored hash, then ==, then equals. The stored-hash check skips almost every equals call, which matters when equals is expensive, like on long strings.
Try it yourself first
"Aa" and "BB" have the same hashCode(), 2112. What does this print, and which bucket are they in with the default 16 buckets?
Map<String, Integer> m = new HashMap<>();
m.put("Aa", 1);
m.put("BB", 2);
System.out.println(m.size() + " " + m.get("Aa") + " " + m.get("BB"));
// 2 1 2
// 2112 is 0x840; (2112 >>> 16) is 0, so the spread hash is 2112.
// 2112 & 15 = 0, so both nodes share bucket 0, as a 2-node list.
A collision is not a bug. Both entries survive because equals tells them apart inside the bucket. The hash only decides where to look; equals decides what matches.
Step 5: resize and the lo/hi split
When size passes the threshold, the table doubles: 16 to 32, threshold 12 to 24. Because the new mask has exactly one more bit, every node either stays at index j or moves to j + oldCap. The JDK tests one bit, hash & oldCap, and splits each bin into a "lo" list and a "hi" list without calling hashCode() again.
A resize from 16 to 32 buckets: bucket 5 with hashes 5, 21, 37 and 53 splits on the bit worth 16, so 5 and 37 stay at index 5 and 21 and 53 move to index 21
Each resize is still O(n) for the whole map, which is why presizing matters. And because Java 8 keeps the order inside each half, two threads racing through a resize can no longer build the cyclic list that made Java 7's HashMap spin at 100% CPU. It's still not thread-safe: you can lose entries instead.
Step 6: treeify, and why it waits for 64
When you add a node to a bin that already has 8, treeifyBin runs. If the table has fewer than 64 buckets, it resizes instead: with a small table, a long chain more likely means "too many entries for 16 buckets" than "pathological hashes", and doubling spreads the chain out. Only with 64 or more buckets does the bin become a red-black tree of TreeNodes, ordered by hash, then by compareTo if the keys are mutually Comparable, then by a tie-break on identity hash.
- Why Java 8 changed collision handling: before Java 8 a bucket was always a list, so an attacker who sends thousands of keys with the same hash (the 2011 hash-flooding attacks on web frameworks used colliding form-parameter names) turns each lookup into an O(n) scan and a request into O(n²) work. Tree bins cap the damage at O(log n) per lookup. The javadoc's Poisson estimate for a well-distributed hash at load factor 0.75 puts the chance of a bin reaching 8 nodes at about 0.00000006, so treeification is a defence, not a normal path.
- Comparable keys help: with
StringorIntegerkeys a tree bin is searched bycompareTo. With keys that are notComparable, equal hashes force the tree search down both branches, and the worst case drifts back toward O(n). - Untreeify at 6: when a resize splits a tree bin and a half ends up with 6 or fewer nodes, it becomes a plain list again. The gap between 8 and 6 stops a bin from flipping back and forth on every insert and delete. The equals() and hashCode() contracts
A HashMap.Node is about 32 bytes with compressed oops (12-byte header, int hash, three references, padded), plus 4 bytes for its table slot; a TreeNode is roughly twice that. To hold 1,000,000 entries without resizing you need a capacity of at least 1,000,000 / 0.75 = 1,333,334, rounded to 2,097,152 buckets. Starting from 16, the map gets there through 17 doublings, each one rehashing everything inserted so far. HashMap.newHashMap(1_000_000) (Java 19+) does the arithmetic for you.
05The mutable key bug
The stored hash is computed once at insertion. If you change a field that hashCode() reads, the node stays in the old bucket while lookups go to the new one.
record Point(int x, int y) {} // immutable: safe as a key
final class MutablePoint { // mutable: dangerous as a key
int x, y;
MutablePoint(int x, int y) { this.x = x; this.y = y; }
@Override public boolean equals(Object o) {
return o instanceof MutablePoint p && p.x == x && p.y == y;
}
@Override public int hashCode() { return 31 * x + y; }
}
Set<MutablePoint> seen = new HashSet<>();
MutablePoint p = new MutablePoint(1, 2);
seen.add(p);
p.x = 10; // hash changes, node does not move
System.out.println(seen.contains(p)); // false
System.out.println(seen.size()); // 1, and remove(p) also fails: a leak
The fix is design, not a clever map: make keys immutable (records, String, Integer) or at least never mutate the fields used by equals and hashCode while the object sits in a hash structure. If you must change a key, remove it, change it, and put it back. The equals() and hashCode() contracts
06LinkedHashMap and the LRU cache
LinkedHashMap is a HashMap whose entries are also threaded onto a doubly linked list. By default the list is in insertion order. Pass accessOrder = true and every get or put of an existing key moves that entry to the tail, so the head is always the least recently used entry. After each insertion, the map calls removeEldestEntry(eldest); return true and the head is evicted.
Try it yourself first
With capacity 2, you put A and B, read A, then put C. Which key gets evicted?
Premium lesson
Keep reading with the Java Pass
The rest of this lesson is part of Java One-Stop Interview Prep: in-depth explanations, diagrams, model answers and how to say them in the interview.
- TreeMap, TreeSet and ordering
- ConcurrentHashMap vs synchronizedMap vs Hashtable
- Fail-fast vs fail-safe iterators
- Generics essentials
- Immutable vs unmodifiable
- Pitfalls that cost offers
- Senior-round follow-ups
- How to say it in the interview
From ₹199 · one-time payment · No auto-renewal
Interview questions for this topicComing soon
Further reading (8)
- HashMap (Java SE 21 API)
- ConcurrentHashMap (Java SE 21 API)
- LinkedHashMap (Java SE 21 API)
- List: unmodifiable lists (Java SE 21 API)
- JEP 180: Handle Frequent HashMap Collisions with Balanced Trees
- JEP 431: Sequenced Collections
- JLS 21, Chapter 4.6: Type Erasure
- Effective Java, 3rd ed., Items 26, 28 and 31: raw types, lists over arrays, bounded wildcards (PECS)