Collections Framework

The right data structure for every problem โ€” and how to choose

← Back to Index

What is the Collections Framework?

The Java Collections Framework is a unified set of interfaces and implementations for storing, retrieving, and manipulating groups of objects. It ships with the JDK โ€” no dependencies needed โ€” and covers virtually every data structure you'll encounter in production: dynamic lists, unique sets, key-value maps, queues, and deques.

The problem it solves: arrays are fixed-size and offer almost no built-in operations. Before the Collections Framework (pre-Java 2), every developer wrote their own resizable list or hash table โ€” incompatible with everyone else's. The framework provides a common API so that any code accepting a List works with ArrayList, LinkedList, or any other implementation, without changes.

// Arrays: fixed size, no built-in operations
String[] names = new String[3];
names[0] = "Alice";
// Need a 4th name? Allocate a new array, copy everything. No remove, no contains.

// Collections: dynamic, rich API, type-safe
List<String> names = new ArrayList<>();
names.add("Alice");
names.add("Bob");
names.remove("Alice");
names.contains("Bob");   // true
names.size();             // 1 โ€” grows and shrinks automatically
/*
 *  The Collections hierarchy:
 *
 *  Iterable
 *  โ””โ”€โ”€ Collection
 *      โ”œโ”€โ”€ List      โ€” ordered, allows duplicates
 *      โ”‚   โ”œโ”€โ”€ ArrayList    O(1) get, O(n) insert/delete middle
 *      โ”‚   โ””โ”€โ”€ LinkedList   O(1) insert/delete head/tail, O(n) get
 *      โ”‚
 *      โ”œโ”€โ”€ Set       โ€” no duplicates
 *      โ”‚   โ”œโ”€โ”€ HashSet      O(1) add/contains, no order
 *      โ”‚   โ”œโ”€โ”€ LinkedHashSet O(1) add/contains, insertion order
 *      โ”‚   โ””โ”€โ”€ TreeSet      O(log n) add/contains, sorted
 *      โ”‚
 *      โ””โ”€โ”€ Queue     โ€” ordered for processing
 *          โ”œโ”€โ”€ LinkedList   FIFO queue
 *          โ”œโ”€โ”€ ArrayDeque   fast deque, prefer over LinkedList
 *          โ””โ”€โ”€ PriorityQueue min-heap, poll() returns smallest
 *
 *  Map (NOT a Collection, but part of the framework)
 *      โ”œโ”€โ”€ HashMap      O(1) get/put, no order
 *      โ”œโ”€โ”€ LinkedHashMap O(1) get/put, insertion order
 *      โ””โ”€โ”€ TreeMap      O(log n) get/put, sorted by key
 */
Always declare as interface, not implementation
// โŒ Locks you to ArrayList โ€” hard to swap implementation later
ArrayList<String> names = new ArrayList<>();

// โœ… Depends on the contract โ€” swap implementation without touching callers
List<String> names = new ArrayList<>();
List<String> names = new LinkedList<>();       // one word change
List<String> names = new CopyOnWriteArrayList<>(); // thread-safe, one word change

List โ€” Ordered, Allows Duplicates

A List is the most-used collection type. It maintains insertion order, allows duplicates, and provides index-based access. Use it as your default unless you have a specific reason for another type.

ArrayList vs LinkedList

Operation ArrayList LinkedList
get(i) O(1) โœ… O(n) โŒ
add(e) at end O(1) amortised O(1)
add(i, e) middle O(n) โ€” shifts elements O(n) โ€” traversal to index
remove(i) middle O(n) โ€” shifts elements O(n) โ€” traversal to index
Memory Compact (contiguous array) Higher (node + 2 pointers per element)
Default choice โœ… Yes โ€” 90% of cases Only for queue/deque operations
List<String> users = new ArrayList<>();

// Add
users.add("Alice");
users.add("Bob");
users.add(0, "Zara");      // insert at index 0

// Read
users.get(0);              // "Zara"
users.size();              // 3
users.contains("Bob");    // true
users.indexOf("Alice");  // 1

// Update
users.set(1, "Anna");     // replaces "Alice" with "Anna"

// Remove
users.remove("Bob");      // by value
users.remove(0);          // by index โ€” careful: remove(0) vs remove(Integer.valueOf(0))

// Iterate
for (String u : users) { System.out.println(u); }
users.forEach(System.out::println);  // Java 8+

// Sort
users.sort(Comparator.naturalOrder());
users.sort(Comparator.comparing(String::length));

// Immutable list (Java 9+) โ€” no add/remove allowed
List<String> fixed = List.of("A", "B", "C");

Set โ€” No Duplicates

A Set guarantees uniqueness โ€” adding the same element twice has no effect. Use it when you need to eliminate duplicates or test membership efficiently.

Implementation Order Performance Use when
HashSet None O(1) add/contains Order irrelevant โ€” default choice
LinkedHashSet Insertion order O(1) add/contains Need unique + predictable iteration
TreeSet Sorted (natural/comparator) O(log n) add/contains Need sorted unique elements
// Deduplication: convert List โ†’ Set
List<String> withDupes = List.of("a", "b", "a", "c");
Set<String> unique = new HashSet<>(withDupes);  // {a, b, c}

// Fast membership test โ€” use Set, not List, for contains() at scale
Set<String> allowedRoles = Set.of("ADMIN", "EDITOR", "VIEWER");
if (allowedRoles.contains(user.getRole())) { ... }
// List.contains() is O(n). Set.contains() is O(1). Huge difference at scale.

// TreeSet: sorted, supports range operations
TreeSet<Integer> scores = new TreeSet<>(Set.of(50, 80, 30, 90, 70));
scores.first();              // 30
scores.last();               // 90
scores.headSet(70);         // {30, 50} โ€” elements < 70
scores.tailSet(70);         // {70, 80, 90}
scores.subSet(50, 85);      // {50, 70, 80}

// Immutable set (Java 9+)
Set<String> fixed = Set.of("A", "B", "C");
HashSet requires correct equals() and hashCode()

HashSet uses hashCode() to find the bucket and equals() to confirm identity. If you store custom objects without overriding both, two logically equal objects will appear as duplicates and contains() will return false even for an element you just added. With record (Java 16+) both are generated automatically.

Map โ€” Key-Value Pairs

Map is not a Collection but is central to the framework. It maps unique keys to values โ€” like a dictionary. Keys must be unique; values can repeat. null as a value is allowed in HashMap; as a key, only in HashMap (one null key).

Map<String, Integer> scores = new HashMap<>();

// Put / update
scores.put("Alice", 95);
scores.put("Bob", 82);
scores.put("Alice", 97);   // overwrites 95

// Get
scores.get("Alice");          // 97
scores.get("Unknown");        // null โ€” missing key
scores.getOrDefault("Unknown", 0);  // 0 โ€” safe fallback

// Check
scores.containsKey("Bob");   // true
scores.containsValue(82);    // true
scores.size();                 // 2

// Iterate
for (Map.Entry<String, Integer> e : scores.entrySet()) {
    System.out.println(e.getKey() + " โ†’ " + e.getValue());
}
scores.forEach((k, v) -> System.out.println(k + " โ†’ " + v)); // Java 8+

// Powerful Map operations (Java 8+)
scores.putIfAbsent("Carol", 88);                   // only if key absent
scores.merge("Alice", 5, Integer::sum);           // 97 + 5 = 102
scores.computeIfAbsent("Dan", k -> fetchScore(k));  // compute and store if absent
scores.replaceAll((k, v) -> v + 10);               // bonus for everyone

// Word frequency โ€” classic Map pattern
Map<String, Long> freq = new HashMap<>();
for (String word : words) {
    freq.merge(word, 1L, Long::sum);  // cleaner than getOrDefault pattern
}

// Immutable map (Java 9+)
Map<String, Integer> fixed = Map.of("A", 1, "B", 2);

Queue and Deque

A Queue processes elements in order โ€” typically FIFO. A Deque (double-ended queue) supports insertion and removal at both ends. Prefer ArrayDeque over LinkedList for both โ€” it's faster and uses less memory.

// Queue โ€” FIFO processing (task queues, BFS)
Queue<String> queue = new ArrayDeque<>();
queue.offer("first");    // add to tail (prefer offer over add โ€” no exception on full)
queue.offer("second");
queue.peek();             // "first" โ€” look without removing
queue.poll();             // "first" โ€” remove from head (returns null if empty, unlike remove())

// Deque โ€” stack (LIFO) or double-ended queue
Deque<String> deque = new ArrayDeque<>();
deque.push("a");         // push to head (stack behaviour)
deque.push("b");
deque.pop();              // "b" โ€” LIFO

// PriorityQueue โ€” min-heap: poll() always returns the smallest element
Queue<Integer> pq = new PriorityQueue<>();
pq.offer(5); pq.offer(1); pq.offer(3);
pq.poll();  // 1 โ€” always the minimum
pq.poll();  // 3

// Max-heap: reverse comparator
Queue<Integer> maxHeap = new PriorityQueue<>(Comparator.reverseOrder());

Choosing the Right Collection

Need Use Why
Ordered list with duplicates ArrayList Default list. O(1) random access.
Frequent head/tail add/remove ArrayDeque Faster than LinkedList for deque ops.
Unique elements, fast lookup HashSet O(1) contains(). Order irrelevant.
Unique elements, sorted TreeSet O(log n). Supports range queries.
Key โ†’ value lookup HashMap O(1) get/put. Default map.
Key โ†’ value, sorted keys TreeMap O(log n). floorKey/ceilingKey.
Key โ†’ value, insertion order LinkedHashMap LRU cache pattern. Predictable iteration.
Priority processing PriorityQueue poll() always returns min (or max).
Thread-safe reads, rare writes CopyOnWriteArrayList Concurrent reads without locking.
Thread-safe map ConcurrentHashMap Lock striping โ€” much faster than Hashtable.

Common Pitfalls

ConcurrentModificationException
// โŒ Modifying a collection while iterating it with for-each
List<String> list = new ArrayList<>(List.of("a", "b", "c"));
for (String s : list) {
    if (s.equals("b")) list.remove(s);  // ConcurrentModificationException!
}

// โœ… Option 1: Iterator.remove()
Iterator<String> it = list.iterator();
while (it.hasNext()) {
    if (it.next().equals("b")) it.remove();  // safe
}

// โœ… Option 2: removeIf (Java 8+) โ€” cleanest
list.removeIf(s -> s.equals("b"));
List.of() and Map.of() are truly immutable
List<String> fixed = List.of("a", "b");
fixed.add("c");  // UnsupportedOperationException โ€” not just unmodifiable, immutable

// โœ… If you need a mutable copy:
List<String> mutable = new ArrayList<>(List.of("a", "b"));
HashMap is not thread-safe
// โŒ Two threads writing to HashMap simultaneously โ†’ data corruption, infinite loops
Map<String, Integer> map = new HashMap<>();  // NOT thread-safe

// โœ… For concurrent access:
Map<String, Integer> map = new ConcurrentHashMap<>();

// โœ… For read-heavy, rare writes:
Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());

Senior Topics: Performance and Internals

HashMap internals โ€” why initial capacity matters

// HashMap internally uses an array of buckets.
// Default capacity: 16. Load factor: 0.75 โ†’ resizes at 12 entries.
// Resize = new array (2x size) + rehash all entries = O(n) operation.

// โŒ If you know you'll store ~1000 entries, default capacity causes ~6 resizes
Map<String, User> cache = new HashMap<>();

// โœ… Set initial capacity to avoid resizes: size / loadFactor + 1
Map<String, User> cache = new HashMap<>(1334);  // 1000 / 0.75 โ‰ˆ 1334

// Java 8+: buckets with 8+ entries convert from linked list to red-black tree
// โ†’ worst-case O(n) degrades to O(log n) even with hash collisions

LinkedHashMap as LRU cache

// LinkedHashMap with accessOrder=true moves accessed entries to tail.
// Override removeEldestEntry() to evict when size exceeds limit.
int MAX = 100;
Map<String, User> lruCache = new LinkedHashMap<>(MAX, 0.75f, true) {
    @Override
    protected boolean removeEldestEntry(Map.Entry<String, User> eldest) {
        return size() > MAX;  // evict oldest-accessed when over limit
    }
};
// Production: prefer Caffeine or Guava Cache for proper LRU with TTL support

Collections with Stream API

List<User> users = getUsers();

// Group by department โ†’ Map<String, List<User>>
Map<String, List<User>> byDept = users.stream()
    .collect(Collectors.groupingBy(User::getDepartment));

// Count per department โ†’ Map<String, Long>
Map<String, Long> countByDept = users.stream()
    .collect(Collectors.groupingBy(User::getDepartment, Collectors.counting()));

// Partition into active/inactive โ†’ Map<Boolean, List<User>>
Map<Boolean, List<User>> partitioned = users.stream()
    .collect(Collectors.partitioningBy(User::isActive));

// Collect to unmodifiable list (Java 16+)
List<String> names = users.stream().map(User::getName).toList();

Interview Questions

๐ŸŽ“ Junior level

Q: What is the difference between ArrayList and LinkedList?
ArrayList is backed by an array โ€” O(1) random access via index, O(n) insert/delete in the middle. LinkedList is a doubly-linked list โ€” O(1) insert/delete at head/tail, O(n) random access. In practice, ArrayList wins for most use cases because cache locality makes it faster even for operations where LinkedList is theoretically equal. Use LinkedList only when you truly need a deque.

Q: What is the difference between HashMap and HashSet?
HashSet is internally backed by a HashMap where each element is a key and the value is a dummy object. Both use hashing for O(1) average operations. HashMap stores key-value pairs; HashSet stores only keys (unique values).

Q: When would you use a Set over a List?
When you need uniqueness or fast membership testing. List.contains() is O(n) โ€” it scans every element. HashSet.contains() is O(1). For a list of allowed roles, blocked IPs, or processed event IDs, a Set is dramatically more efficient at scale.

๐Ÿ”ฅ Senior level

Q: How does HashMap handle hash collisions?
Entries with the same bucket index (same hashCode() % capacity) are stored in a linked list within that bucket. Since Java 8, when a bucket exceeds 8 entries, it converts from a linked list to a red-black tree โ€” degrading from O(n) worst-case to O(log n). If all keys hash to the same bucket (e.g. all return hashCode() = 0), performance collapses to O(n) for get/put. This is an actual attack vector โ€” HashDoS โ€” and why Java uses randomised hash seeds for String keys since Java 7.

Q: What is the contract between equals() and hashCode()?
If a.equals(b) is true, then a.hashCode() == b.hashCode() must also be true. The reverse is not required โ€” two objects can share a hash code without being equal (collision). Violating this contract breaks all hash-based collections: HashMap, HashSet, Hashtable. A common symptom: you put an object in a HashMap, mutate one of its fields (which changes its hash), and can no longer find it.

Q: Why is ConcurrentHashMap preferred over synchronizedMap?
Collections.synchronizedMap() wraps every method with a single lock on the whole map โ€” all operations are serialised. Under contention, this becomes a bottleneck. ConcurrentHashMap uses lock striping (one lock per segment/bucket group), allowing concurrent reads and concurrent writes to different segments. It also provides atomic compound operations: putIfAbsent(), computeIfAbsent(), merge() โ€” which synchronizedMap cannot guarantee atomically without external synchronization.