☕ Java

Collections Overview

The Java Collections Framework (JCF) is a unified architecture for representing and manipulating groups of objects. It provides a set of interfaces that define the operations a collection must support, a set of abstract classes that provide partial implementations, and a set of concrete implementations optimised for different use cases. Every Java developer uses collections daily — lists for sequences, sets for uniqueness, maps for key-value pairs, and queues for ordering — and choosing the right implementation for the right use case is one of the most fundamental practical skills in Java.

What the Collections Framework Provides

Before the Collections Framework was introduced in Java 2 (1998), Java had only three container types: arrays (fixed-size, no built-in operations), Vector (synchronised, cumbersome dynamic array), and Hashtable (synchronised, cumbersome map). Every developer and every library invented their own container classes, and none of them were interoperable. Code that worked with one library's list could not be passed to another library's method that expected a different list type. The Collections Framework solved this with a set of common interfaces. Any code written against the List interface works with ArrayList, LinkedList, or any custom List implementation. Any code written against the Map interface works with HashMap, TreeMap, or any custom Map. This interoperability through shared interfaces is one of the most impactful API design decisions in Java's history. The framework provides five things. First, interfaces that define the operations a collection must support: Collection, List, Set, SortedSet, NavigableSet, Queue, Deque, and Map (plus sorted and navigable variants). Second, concrete implementations that are ready to use: ArrayList, LinkedList, HashSet, TreeSet, LinkedHashSet, HashMap, TreeMap, LinkedHashMap, ArrayDeque, PriorityQueue, and several more. Third, abstract base classes that simplify implementing your own collection: AbstractCollection, AbstractList, AbstractMap, and others. Fourth, algorithms as static methods on the Collections utility class: sort, binarySearch, shuffle, reverse, min, max, frequency, and more. Fifth, wrapper methods for common transformations: unmodifiable views, synchronised wrappers, and singleton collections. The framework is designed around the principle of programming to interfaces. You declare variables as List, not ArrayList; as Map, not HashMap. This separation between interface and implementation means you can change the implementation — for example, switching from ArrayList to LinkedList for better insertion performance — without changing any of the code that uses the collection.
Java
// ── Core collection interfaces and their meanings: ────────────────────
//
//  Iterable<E>                  — can be iterated with for-each loop
//    └── Collection<E>          — a group of elements
//          ├── List<E>          — ordered sequence, duplicates allowed
//          ├── Set<E>           — no duplicates
//          │     ├── SortedSet<E>    — sorted order
//          │     └── NavigableSet<E> — floor, ceiling, higher, lower
//          └── Queue<E>         — elements processed in order
//                ├── Deque<E>   — double-ended queue (stack + queue)
//                └── BlockingQueue<E> — concurrent, waits when empty/full
//
//  Map<K,V>                     — key-value pairs (NOT a Collection)
//    ├── SortedMap<K,V>         — keys in sorted order
//    └── NavigableMap<K,V>      — floor, ceiling, headMap, tailMap

// ── Programming to interfaces — not implementations: ──────────────────
// GOOD — declare as interface:
List<String>   names    = new ArrayList<>();
Set<Integer>   ids      = new HashSet<>();
Map<String,Integer> map = new HashMap<>();
Queue<Task>    tasks    = new ArrayDeque<>();

// Later, swap implementation without changing any usage code:
List<String>   names2   = new LinkedList<>();   // same List interface
Map<String,Integer> map2 = new TreeMap<>();     // same Map interface

// ── One line creates and populates a collection (Java 9+): ────────────
List<String>      immutableList = List.of("Alice", "Bob", "Charlie");
Set<Integer>      immutableSet  = Set.of(1, 2, 3, 4, 5);
Map<String,Integer> immutableMap = Map.of("a", 1, "b", 2, "c", 3);

// ── Mutable collections: ──────────────────────────────────────────────
List<String> mutableList = new ArrayList<>(List.of("Alice", "Bob"));
Set<Integer> mutableSet  = new HashSet<>(Set.of(1, 2, 3));

Concrete Implementation Guide

Choosing the right concrete implementation requires understanding what operation each collection is optimised for. Every implementation makes trade-offs between access time, insertion time, memory usage, ordering guarantees, and thread safety. Choosing ArrayList when you need a LinkedList wastes no noticeable performance for most use cases, but choosing HashMap when you need TreeMap loses the sorted-order guarantee entirely, and choosing ArrayList over HashSet loses duplicate prevention. ArrayList is the most commonly used collection in Java. It is a dynamic array — random access by index is O(1), append to end is amortised O(1), and insertion or deletion in the middle is O(n) because elements must shift. It is cache-friendly because elements are stored contiguously. Use ArrayList as the default List unless you have a specific reason to choose otherwise. LinkedList is a doubly linked list. Each element stores references to the previous and next element. Random access is O(n) because you must traverse from the head or tail. Insertion and deletion at the beginning or end are O(1). Insertion and deletion at a known position (via iterator) are O(1). Use LinkedList only when you frequently insert or remove elements at the start of a list, or when you need a Deque and want to avoid the O(n) shifting of ArrayList. HashMap provides O(1) average get, put, and remove. Keys are not sorted. Iteration order is not guaranteed to be stable across runs (it is randomised since Java 8 for security). LinkedHashMap maintains insertion order with a small constant overhead. TreeMap maintains sorted order (O(log n) get, put, remove). Use HashMap as the default, LinkedHashMap when iteration order must be consistent with insertion, TreeMap when sorted keys are required. HashSet is backed by HashMap and provides O(1) average add, contains, and remove. TreeSet is backed by TreeMap and provides O(log n) with sorted order. LinkedHashSet maintains insertion order.
Java
// ── ArrayListdefault list, O(1) access: ───────────────────────────
List<String> list = new ArrayList<>();
list.add("Alice");
list.add("Bob");
list.add(0, "Zara");    // insert at index — O(n) shift
String first = list.get(0);    // O(1) random access
list.remove(1);                // O(n) shift
System.out.println(list);      // [Zara, Alice]

// ── LinkedList — O(1) head/tail insert: ───────────────────────────────
Deque<String> deque = new LinkedList<>();
deque.addFirst("first");
deque.addLast("last");
deque.addFirst("new first");
System.out.println(deque.peekFirst());  // new first
System.out.println(deque.peekLast());   // last

// ── HashMap — O(1) average: ───────────────────────────────────────────
Map<String, Integer> scores = new HashMap<>();
scores.put("Alice", 90);
scores.put("Bob",   85);
scores.put("Carol", 92);
System.out.println(scores.get("Alice"));       // 90
scores.putIfAbsent("Dave", 78);
scores.computeIfPresent("Bob", (k, v) -> v + 5); // Bob → 90
System.out.println(scores.getOrDefault("Eve", 0)); // 0

// ── TreeMap — sorted keys, O(log n): ─────────────────────────────────
Map<String, Integer> sorted = new TreeMap<>(scores);
System.out.println(sorted.firstKey());          // Alice (alphabetically first)
System.out.println(sorted.lastKey());           // Dave
System.out.println(sorted.headMap("Carol"));    // {Alice=90, Bob=90}

// ── HashSet vs TreeSet: ───────────────────────────────────────────────
Set<Integer> hashSet = new HashSet<>(List.of(5, 3, 1, 4, 2));
Set<Integer> treeSet = new TreeSet<>(List.of(5, 3, 1, 4, 2));
System.out.println(hashSet);  // [1, 2, 3, 4, 5] — may vary
System.out.println(treeSet);  // [1, 2, 3, 4, 5] — always sorted

// ── Implementation selection guide: ──────────────────────────────────
//
// Need a list?
//   Default                → ArrayList
//   Frequent head inserts  → LinkedList / ArrayDeque
//   Thread-safe            → CopyOnWriteArrayList
//
// Need a set?
//   Default                → HashSet
//   Insertion order        → LinkedHashSet
//   Sorted order           → TreeSet
//
// Need a map?
//   Default                → HashMap
//   Insertion order        → LinkedHashMap
//   Sorted keys            → TreeMap
//   Thread-safe            → ConcurrentHashMap
//
// Need a queue?
//   FIFO queue             → ArrayDeque
//   Priority order         → PriorityQueue
//   Thread-safe blocking   → LinkedBlockingQueue

Collections Utility Class

java.util.Collections (with an 's' — different from the Collection interface) provides static utility methods that operate on collections. These algorithms represent common operations that would otherwise require writing loops manually: sorting, searching, shuffling, reversing, rotating, filling, copying, and finding extremes. Collections.sort() sorts a List in natural order (using the elements' Comparable implementation) or using a provided Comparator. It uses a stable merge sort (TimSort), which preserves the original order of equal elements. Collections.binarySearch() searches a sorted List in O(log n) — it must only be called on an already-sorted List, otherwise the result is undefined. Collections.unmodifiableXxx() methods return a read-only view of an existing collection. Attempts to modify the returned collection throw UnsupportedOperationException. These views are backed by the original collection — changes to the original are visible through the view. They are used to expose a collection to callers while preventing them from modifying it. Collections.synchronizedXxx() methods wrap a collection in a thread-safe wrapper. All method calls are synchronised on the wrapper object. However, compound operations (check-then-act patterns like if(!list.contains(x)) list.add(x)) still require external synchronisation, and iteration over a synchronised collection still requires external synchronisation to prevent ConcurrentModificationException. For modern concurrent applications, the classes in java.util.concurrent are almost always preferable to synchronised wrappers. Collections.singleton(), singletonList(), and singletonMap() return immutable collections containing exactly one element. nCopies() returns an immutable List of n copies of a specified element. emptyList(), emptySet(), and emptyMap() return shared empty immutable collections — they are preferred over new ArrayList<>() when you need to return an empty collection because they avoid allocation and are guaranteed immutable.
Java
import java.util.*;

List<Integer> numbers = new ArrayList<>(Arrays.asList(5, 2, 8, 1, 9, 3));

// ── Sorting: ──────────────────────────────────────────────────────────
Collections.sort(numbers);
System.out.println(numbers);            // [1, 2, 3, 5, 8, 9]

// Custom comparator — sort descending:
numbers.sort(Collections.reverseOrder());
System.out.println(numbers);            // [9, 8, 5, 3, 2, 1]

// ── Binary search (requires sorted list): ─────────────────────────────
Collections.sort(numbers);              // [1, 2, 3, 5, 8, 9]
int idx = Collections.binarySearch(numbers, 5);
System.out.println("5 found at index: " + idx);  // 3

// ── shuffle, reverse, rotate: ─────────────────────────────────────────
Collections.shuffle(numbers, new Random(42));
System.out.println("Shuffled: " + numbers);

Collections.reverse(numbers);
System.out.println("Reversed: " + numbers);

Collections.rotate(numbers, 2);  // rotate right by 2 positions
System.out.println("Rotated: " + numbers);

// ── min and max: ──────────────────────────────────────────────────────
List<String> names = Arrays.asList("Charlie", "Alice", "Bob");
System.out.println(Collections.min(names));  // Alice
System.out.println(Collections.max(names));  // Charlie

// ── Immutable views: ──────────────────────────────────────────────────
List<String> mutable   = new ArrayList<>(Arrays.asList("a", "b", "c"));
List<String> immutable = Collections.unmodifiableList(mutable);
System.out.println(immutable.get(0));     // a — reads work
// immutable.add("d");                    // throws UnsupportedOperationException

mutable.add("d");                         // changing original IS reflected:
System.out.println(immutable.size());     // 4 — view shows the change

// ── Factory collections: ──────────────────────────────────────────────
List<String>  singleton    = Collections.singletonList("only");
Set<Integer>  emptySet     = Collections.emptySet();
List<String>  fiveHello    = Collections.nCopies(5, "hello");
System.out.println(fiveHello);           // [hello, hello, hello, hello, hello]

// ── frequency and disjoint: ──────────────────────────────────────────
List<String> words = Arrays.asList("cat", "dog", "cat", "bird", "cat");
System.out.println(Collections.frequency(words, "cat"));  // 3

Set<String> setA = Set.of("a", "b", "c");
Set<String> setB = Set.of("d", "e", "f");
System.out.println(Collections.disjoint(setA, setB));  // true — no overlap

Thread Safety and Concurrent Collections

None of the standard collection implementations (ArrayList, HashMap, HashSet, etc.) are thread-safe. Using them from multiple threads without external synchronisation produces data corruption and undefined behaviour. There are three approaches to thread-safe collections in Java, each with different trade-offs. The synchronised wrapper approach uses Collections.synchronizedXxx() to wrap any collection. Every method call is synchronised but compound operations still need external synchronisation. This approach is simple but scales poorly under contention because all threads compete for the same lock. The copy-on-write approach uses CopyOnWriteArrayList and CopyOnWriteArraySet. Every mutating operation creates a brand-new copy of the underlying array. Reads are completely unsynchronised and therefore very fast. Writes are expensive because of the copy. This is ideal for collections that are read far more often than they are written — typical for listener lists and configuration lists. The concurrent data structure approach uses ConcurrentHashMap, ConcurrentSkipListMap, ConcurrentSkipListSet, LinkedBlockingQueue, ArrayBlockingQueue, ConcurrentLinkedQueue, and similar classes from java.util.concurrent. These use fine-grained locking, lock striping, or non-blocking algorithms (compare-and-swap) to allow multiple threads to operate concurrently with much better throughput than synchronised wrappers. ConcurrentHashMap in particular is the standard choice for any concurrent map use case. The choice between these approaches depends on the read-write ratio and the required atomicity. For a map with many readers and occasional writers, ConcurrentHashMap is ideal. For a rarely modified list of listeners, CopyOnWriteArrayList is ideal. For a producer-consumer pattern with blocking behaviour, a BlockingQueue is the right abstraction.
Java
import java.util.concurrent.*;
import java.util.*;

// ── Thread-unsafe — never share between threads without sync: ─────────
List<String> unsafeList = new ArrayList<>();
Map<String, Integer> unsafeMap = new HashMap<>();
// If two threads call unsafeList.add() concurrently → data corruption!

// ── Approach 1: Synchronised wrappers — simple but low concurrency: ───
List<String> syncList = Collections.synchronizedList(new ArrayList<>());
Map<String, Integer> syncMap = Collections.synchronizedMap(new HashMap<>());
// Individual operations are thread-safe, but iteration still needs sync:
synchronized(syncList) {
    for (String s : syncList) {  // must synchronise the iteration
        System.out.println(s);
    }
}

// ── Approach 2: Copy-on-write — best for read-heavy: ─────────────────
CopyOnWriteArrayList<String> cowList = new CopyOnWriteArrayList<>();
cowList.add("listener1");
cowList.add("listener2");
// Iteration is always safe — reads a snapshot:
for (String listener : cowList) {  // no synchronisation needed
    System.out.println(listener);
}

// ── Approach 3: Concurrent collections — best performance: ────────────
ConcurrentHashMap<String, Integer> concMap = new ConcurrentHashMap<>();
concMap.put("key", 1);
concMap.computeIfAbsent("key2", k -> expensiveComputation(k));
// Atomic compound operations — no external synchronisation needed:
concMap.merge("counter", 1, Integer::sum);  // thread-safe increment

// BlockingQueue — producer-consumer pattern:
BlockingQueue<String> queue = new LinkedBlockingQueue<>(100);

// Producer thread:
Thread producer = new Thread(() -> {
    try {
        queue.put("task1");    // blocks if queue is full
        queue.put("task2");
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
});

// Consumer thread:
Thread consumer = new Thread(() -> {
    try {
        String task = queue.take();  // blocks if queue is empty
        System.out.println("Processing: " + task);
    } catch (InterruptedException e) {
        Thread.currentThread().interrupt();
    }
});

producer.start(); consumer.start();