3. Java Collections Framework — Architect-Level Deep Dive
3.1 List, Set, Map Hierarchies and Implementations
Core Interface Hierarchy
Iterable
└── Collection
├── List (ordered, duplicates allowed, index-based)
│ ├── ArrayList
│ ├── LinkedList
│ ├── Vector (legacy, synchronized)
│ └── CopyOnWriteArrayList
├── Set (no duplicates)
│ ├── HashSet (backed by HashMap)
│ ├── LinkedHashSet (backed by LinkedHashMap)
│ ├── TreeSet (backed by TreeMap, NavigableSet)
│ └── EnumSet
└── Queue / Deque
├── PriorityQueue
├── ArrayDeque
└── LinkedList (also implements Deque)
Map (not a Collection — separate hierarchy)
├── HashMap
├── LinkedHashMap
├── TreeMap (NavigableMap, SortedMap)
├── Hashtable (legacy, synchronized)
├── ConcurrentHashMap
└── WeakHashMap / IdentityHashMap / EnumMap
Key architectural distinctions:
| Aspect | List | Set | Map |
|---|---|---|---|
| Duplicates | Allowed | Not allowed | Keys unique, values can duplicate |
| Ordering | Insertion order (index) | Implementation-dependent | Implementation-dependent |
| Null elements | Multiple nulls allowed | One null allowed (except TreeSet) | One null key (except TreeMap, Hashtable) |
| Access pattern | Index-based O(1)/O(n) | Hash/tree-based lookup | Hash/tree-based lookup |
Why Map isn’t a Collection: A Map<K,V> doesn’t fit the Collection<E> contract of single-element operations (add(E), contains(E)). It exposes its collection views instead — keySet(), values(), entrySet() — which are Collection/Set instances backed by the map, so mutations on the view propagate to the map (except via entrySet() for adding new entries, which is unsupported).
Architect-level decision points:
- Need insertion-order iteration with O(1) lookup →
LinkedHashMap/LinkedHashSet - Need sorted iteration →
TreeMap/TreeSet(O(log n) operations, red-black tree) - Need thread-safety without external locking →
ConcurrentHashMap,CopyOnWriteArrayList - LRU cache implementation →
LinkedHashMapwithaccessOrder=true+ overriddenremoveEldestEntry()
3.2 ArrayList vs LinkedList — Internal Structure
ArrayList — backed by Object[] array
transient Object[] elementData;
private int size;
- Default capacity 10; grows via
grow()→ new capacity =oldCapacity + (oldCapacity >> 1)(1.5x growth, not 2x — a common interview trap). Arrays.copyOf()triggers a full array copy on resize — O(n) amortized to O(1) peradd().- Random access
get(index)is O(1) — direct array indexing. - Insertion/deletion at arbitrary index is O(n) — requires
System.arraycopy()shift of subsequent elements. - Cache-friendly: contiguous memory layout means excellent CPU cache locality, which in practice makes ArrayList faster than LinkedList even for some O(n) operations due to cache misses dominating LinkedList traversal cost.
LinkedList — doubly linked list of Node<E>
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
}
transient Node<E> first;
transient Node<E> last;
- No capacity/resizing concerns — pure pointer manipulation.
get(index)is O(n) — must traverse fromfirstorlast, whichever is closer (it bisects based on index vs size/2).addFirst()/addLast()/removeFirst()/removeLast()are O(1) — this is LinkedList’s actual strength, which is why it implementsDeque.- Insertion/deletion at a known node (via
ListIterator) is O(1) since no shifting is needed — but locating that node is still O(n). - Higher memory overhead per element: each
Nodecarries two pointers + object header, vs ArrayList’s flat slot.
Practical guidance for architects:
| Use case | Choice | Reason |
|---|---|---|
| Frequent random access, infrequent mutation | ArrayList | O(1) get, cache locality |
| Frequent head/tail insertion (queue/stack semantics) | LinkedList or ArrayDeque | O(1) at ends |
| General-purpose default | ArrayList | Better cache behavior, lower memory overhead per element |
| Implementing Deque/Queue | ArrayDeque (preferred over LinkedList) | Array-backed circular buffer, better cache locality, no per-node allocation |
Note: Modern guidance (post-Java 6) generally avoids LinkedList even for deque use cases — ArrayDeque outperforms it in nearly all benchmarks due to cache locality, despite LinkedList’s “theoretical” O(1) advantage.
3.3 HashMap Internals (Buckets, Hashing, Resize, Java 8 Tree-ification)
Structure:
transient Node<K,V>[] table; // array of buckets
static final float DEFAULT_LOAD_FACTOR = 0.75f;
static final int DEFAULT_INITIAL_CAPACITY = 16;
static final int TREEIFY_THRESHOLD = 8;
static final int UNTREEIFY_THRESHOLD = 6;
static final int MIN_TREEIFY_CAPACITY = 64;
Hashing process:
hash(key)— applies a supplemental hash spreading function:static final int hash(Object key) { int h; return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16); }This XORs the high 16 bits into the low 16 bits — guards against poor hashCode() implementations where lower bits lack entropy, especially important since
table.lengthis always a power of 2.- Bucket index =
(n - 1) & hash— equivalent tohash % nbut faster sincenis power-of-2 (bitmask AND vs modulo division). - Collision handling: Each bucket holds a linked list of
Node<K,V>by default. On collision, new nodes append to the list; lookup degrades to O(n) within that bucket.
Resize (rehashing):
- Triggered when
size > capacity * loadFactor(i.e., 12 entries at default 16 capacity/0.75 LF). - New capacity =
oldCapacity << 1(doubling). - Java 8 optimization: during resize, entries either stay at the same index or move to
index + oldCapacity— determined by checking one additional bit (hash & oldCapacity), avoiding full rehash recomputation. This split is done in-place without recalculatinghash().
Java 8 Tree-ification:
- When a single bucket’s linked-list length reaches
TREEIFY_THRESHOLD(8), and total table capacity ≥MIN_TREEIFY_CAPACITY(64), that bucket converts to a red-black tree (TreeNodeextendsNode), improving worst-case lookup from O(n) to O(log n). - If capacity < 64 but a bucket hits 8 collisions, the table resizes instead of tree-ifying (resizing first is preferred since small tables shouldn’t pay tree overhead).
- Reverse: if a tree bucket shrinks to ≤
UNTREEIFY_THRESHOLD(6) during removal, it converts back to a linked list. - This defends against hash-flooding/algorithmic-complexity attacks where adversarial keys are crafted to collide into the same bucket (especially relevant for
HashMap<String,V>exposed to untrusted input, e.g., HTTP parameter maps).
Why HashMap requires equals()/hashCode() contract:
- Equal objects must produce equal hash codes (otherwise lookups fail to find existing keys).
- Mutable keys (especially mutating fields used in
hashCode()) after insertion corrupt the structure — the entry becomes unreachable viaget()since it’s still physically in its old bucket but a fresh hash computation points elsewhere.
Load factor trade-off: Lower load factor (e.g., 0.5) → fewer collisions, faster lookups, more memory/more frequent resizing. Higher load factor (e.g., 0.9) → more collisions, less memory, less frequent resizing. 0.75 is empirically the time/space sweet spot.
3.4 ConcurrentHashMap vs Synchronized Map vs Hashtable
| Hashtable | Collections.synchronizedMap() |
ConcurrentHashMap | |
|---|---|---|---|
| Locking granularity | Entire map (every method synchronized) |
Entire map (single monitor wraps delegate) | Per-bucket (Java 8+: CAS + synchronized on bin head node) |
| Null keys/values | Not allowed | Depends on backing map | Not allowed (deliberate — avoids ambiguity between “absent” and “mapped to null” in concurrent reads) |
| Iterator | Fail-fast | Fail-fast (requires manual sync during iteration) | Weakly consistent — doesn’t throw ConcurrentModificationException, reflects state at some point during/after iterator creation |
| Read concurrency | Blocking | Blocking | Non-blocking for reads (volatile reads on Node fields) |
| Performance under contention | Poor | Poor | Good — lock striping minimizes contended sections |
| Legacy status | Legacy (pre-Collections framework) | Wrapper utility | Modern, JSR-166 (Doug Lea) |
ConcurrentHashMap internals (Java 8+ redesign):
- Abandoned Java 7’s
Segment[]lock-striping array in favor of synchronizing on individual bucket head nodes directly — finer granularity, lower memory overhead. - Writes use
Unsafe.compareAndSwapObject(CAS) for inserting into an empty bucket — completely lock-free in the common case. - Only when a bucket already has a colliding chain does it synchronize on that bucket’s first node — meaning concurrent writes to different buckets never block each other.
- Size tracking uses a striped counter (
LongAdder-styleCounterCell[]) rather than a singleAtomicLong, reducing CAS contention from concurrentput()calls across threads. size()is therefore an approximation under concurrent modification — by design, since an exact count would require a global lock, defeating the purpose.- Bucket tree-ification (red-black trees for long chains) applies here too, same thresholds as HashMap.
Compound atomic operations: ConcurrentHashMap provides computeIfAbsent(), merge(), compute() as atomic, lock-protected-per-bucket operations — critical for things like counters/caches where check-then-act on a plain HashMap + external lock would be both slower and easier to get wrong.
Architect guidance: Never recommend Hashtable or synchronizedMap() for new code unless interfacing with legacy APIs that require them. ConcurrentHashMap is the default for any shared mutable map in multi-threaded service code (caches, counters, session registries).
3.5 TreeMap/TreeSet and Comparable vs Comparator
Backing structure: Both are backed by a red-black tree (self-balancing BST) — guarantees O(log n) for get, put, remove, containsKey, and ordered traversal.
TreeMap implements NavigableMap giving methods beyond SortedMap:
floorKey(),ceilingKey(),lowerKey(),higherKey()— nearest-match lookups.firstEntry(),lastEntry(),pollFirstEntry(),pollLastEntry().headMap(),tailMap(),subMap()— range views (live, backed by the original map).
Comparable (intrinsic ordering):
class Employee implements Comparable<Employee> {
public int compareTo(Employee other) {
return Integer.compare(this.id, other.id);
}
}
- Defines the class’s “natural ordering” — single, fixed sort order baked into the class itself.
- Used automatically by
TreeMap/TreeSet/Collections.sort()when noComparatoris supplied.
Comparator (extrinsic ordering):
Comparator<Employee> bySalaryDesc = Comparator
.comparing(Employee::getSalary).reversed()
.thenComparing(Employee::getName);
- Defines ordering external to the class — lets you sort the same objects multiple ways without modifying the class.
- Required when the class doesn’t implement
Comparable, or when you need an alternate ordering. - Java 8 added the fluent
comparing()/thenComparing()/reversed()chain, replacing verbose anonymous-class comparators.
Critical contract — consistency with equals():
TreeMap/TreeSetusecompareTo()/compare()exclusively for both ordering and equality determination —compareTo() == 0is treated as “duplicate,” notequals(). If yourcompareTo()isn’t consistent withequals(),TreeSetcan silently “lose” elements thatequals()would consider distinct, which is a frequent production bug source. Document this deviation explicitly if intentional (e.g., a case-insensitiveStringcomparator in aTreeSet<String>).
When TreeMap/TreeSet vs HashMap/HashSet:
- Need sorted iteration or range queries (
floor/ceiling,subMap) → TreeMap/TreeSet, accept O(log n) over O(1). - Need pure fastest lookup with no ordering requirement → HashMap/HashSet.
- Need insertion-order iteration without sort overhead → LinkedHashMap/LinkedHashSet.
3.6 Fail-Fast vs Fail-Safe Iterators
Fail-Fast (ArrayList, HashMap, HashSet, most java.util collections):
- Maintains a
modCountfield, incremented on every structural modification (add/remove, not set). - Iterator captures
expectedModCountat creation; everynext()call checksmodCount != expectedModCountand throwsConcurrentModificationException(CME) if they diverge. - This is a best-effort detection mechanism, not a guarantee — the Javadoc explicitly states CME should not be relied upon for correctness, only to catch bugs. It’s possible for concurrent modification to go undetected (e.g., removing the second-to-last element of an ArrayList during iteration can sometimes avoid throwing CME due to how the bounds check interacts with size).
- The correct way to remove during iteration is
Iterator.remove()itself — which updatesexpectedModCountin lockstep, avoiding the exception.
Fail-Safe (ConcurrentHashMap, CopyOnWriteArrayList, ConcurrentSkipListMap):
CopyOnWriteArrayList: every mutation (add/remove/set) creates a new copy of the entire underlying array; iterators hold a reference to the array snapshot at iterator-creation time, so they never see subsequent modifications and never throw CME.- Trade-off: write operations are O(n) (full array copy) — appropriate only for read-heavy, write-rare scenarios (e.g., listener lists, configuration caches).
ConcurrentHashMap: iterators are weakly consistent — they reflect some state of the map as of iterator creation or later, may or may not show concurrent updates, but never throw CME and never return the same element twice.- This isn’t a full snapshot like CopyOnWrite — it’s a traversal that tolerates concurrent structural changes without locking, by virtue of how the underlying bucket/node structure is updated (new nodes inserted via CAS at bucket heads, traversal follows volatile
nextpointers).
- This isn’t a full snapshot like CopyOnWrite — it’s a traversal that tolerates concurrent structural changes without locking, by virtue of how the underlying bucket/node structure is updated (new nodes inserted via CAS at bucket heads, traversal follows volatile
Architect-relevant distinction: “Fail-safe” doesn’t mean “sees a consistent snapshot” except for CopyOnWriteArrayList. For ConcurrentHashMap, fail-safe means “won’t crash, but don’t assume the iteration reflects the map as of any single instant” — important when reasoning about correctness in concurrent batch-processing code.
3.7 Java 8+ Collection Enhancements
Immutable Collections (Java 9+ factory methods):
List<String> list = List.of("a", "b", "c");
Set<String> set = Set.of("a", "b");
Map<String, Integer> map = Map.of("a", 1, "b", 2);
Map<String, Integer> map2 = Map.ofEntries(
Map.entry("a", 1), Map.entry("b", 2)
);
- Returned instances are genuinely immutable —
add()/put()/remove()throwUnsupportedOperationException, unlikeCollections.unmodifiableList()which only prevents mutation through that wrapper while the backing list remains mutable elsewhere. List.of()/Set.of()rejectnullelements (throwsNullPointerException) — a deliberate defensive design choice, unlike legacy collections.Set.of()andMap.of()have randomized iteration order per JVM run (salted hashing) — intentionally prevents code from accidentally depending on iteration order, exposing such bugs early.- Backed by specialized internal implementations (
ImmutableCollections.ListN, etc.) optimized for small fixed sizes rather than general-purposeArrayList/HashMapoverhead.
Stream Collectors (java.util.stream.Collectors):
Key collectors an architect should reach for fluently:
// Grouping
Map<Department, List<Employee>> byDept =
employees.stream().collect(Collectors.groupingBy(Employee::getDept));
// Grouping + downstream aggregation
Map<Department, Double> avgSalaryByDept =
employees.stream().collect(
Collectors.groupingBy(Employee::getDept,
Collectors.averagingDouble(Employee::getSalary)));
// Partitioning (boolean split)
Map<Boolean, List<Employee>> partitioned =
employees.stream().collect(Collectors.partitioningBy(e -> e.getSalary() > 100000));
// Joining
String csv = names.stream().collect(Collectors.joining(", ", "[", "]"));
// To unmodifiable collection (Java 10+)
List<String> immutable = stream.collect(Collectors.toUnmodifiableList());
// Teeing (Java 12+) — combine two collectors' results
var result = employees.stream().collect(Collectors.teeing(
Collectors.counting(),
Collectors.summingDouble(Employee::getSalary),
(count, total) -> total / count // manual average, demonstrating composition
));
Other notable enhancements:
Map.computeIfAbsent(),computeIfPresent(),merge(),getOrDefault()— reduce common null-check/check-then-act boilerplate to single atomic-on-CHM, concise-on-HashMap calls.removeIf(Predicate)onCollection— in-place filtered removal without manual iterator handling.forEach(Consumer)default method onIterable.Collection.toArray(IntFunction)(Java 11+) —list.toArray(String[]::new), avoiding the old pre-sized-array idiom.Stream.toList()(Java 16+) — shorthand forcollect(Collectors.toList()), returns an unmodifiable list (subtle behavior difference architects should flag in code review — it’s not equivalent to the mutable listCollectors.toList()historically returned, and code relying on mutability after.toList()will break).