Collections

1. What is the Collections Framework?

Imagine you are building an application that manages users. You need to store a list of users, look up users by their email, and maintain a set of unique roles. You could write your own data structures from scratch — arrays that resize, linked lists that traverse, hash tables that resolve collisions — but that would be reinventing the wheel for every project.

The Java Collections Framework (JCF) is a unified architecture for representing and manipulating groups of objects. Introduced in Java 2 (JDK 1.2), it provides:

  • Interfaces — Abstract data types that define operations (e.g., List, Set, Map)
  • Implementations — Concrete classes that implement those interfaces (e.g., ArrayList, HashSet, HashMap)
  • Algorithms — Static methods in the Collections utility class for sorting, searching, and transforming collections

Collection vs Collections

A common point of confusion:

Name Type Purpose
Collection Interface (java.util.Collection) Root interface for List, Set, and Queue. Defines core methods like add(), remove(), size(), iterator()
Collections Utility class (java.util.Collections) Contains static helper methods like sort(), unmodifiableList(), synchronizedMap(), emptyList()

Think of Collection as the blueprint and Collections as the toolbox.

2. Collections Hierarchy

The framework is organized into two main hierarchies: the Collection hierarchy (for single-element containers) and the Map hierarchy (for key-value pairs). Map does not extend Collection — this is an important distinction.

                     Iterable
                        |
                    Collection
                   /    |     \
                List   Set    Queue
                 |      |       |
          ArrayList  HashSet  PriorityQueue
          LinkedList TreeSet  ArrayDeque
                     LinkedHashSet

                      Map (separate hierarchy)
                   /    |       \
              HashMap TreeMap  LinkedHashMap
Interface Duplicates? Ordered? Key-Value? Key Implementations
List Yes Yes (insertion order) No ArrayList, LinkedList
Set No Depends on impl No HashSet, LinkedHashSet, TreeSet
Queue Yes FIFO or priority No PriorityQueue, ArrayDeque
Map No (keys) Depends on impl Yes HashMap, LinkedHashMap, TreeMap

Every collection in the Collection hierarchy is Iterable, meaning you can use a for-each loop on it. Map is not Iterable directly, but you can iterate over its keySet(), values(), or entrySet() views.

3. List Interface

A List is an ordered collection (also called a sequence). Lists allow:

  • Positional access — elements are accessed by their integer index (0-based)
  • Duplicate elements — the same value can appear multiple times
  • Insertion order — elements are maintained in the order they were added

The List interface defines operations like get(index), set(index, element), add(index, element), remove(index), indexOf(element), and subList(fromIndex, toIndex).

3.1 ArrayList

ArrayList is the most commonly used List implementation. Under the hood, it is backed by a resizable array. When the internal array fills up, ArrayList creates a new array (typically 1.5x the old size) and copies the elements over.

Performance characteristics:

Operation Time Complexity Why
get(index) O(1) Direct array index access
add(element) (at end) O(1) amortized Append to end; occasional resize
add(index, element) (in middle) O(n) Must shift elements to the right
remove(index) O(n) Must shift elements to the left
contains(element) O(n) Linear scan
size() O(1) Tracked internally

When to use ArrayList: When you need fast random access by index and most additions are at the end of the list. This covers the vast majority of real-world use cases.

import java.util.ArrayList;
import java.util.List;

public class ArrayListExample {
    public static void main(String[] args) {
        // Program to interface: declare as List, instantiate as ArrayList
        List languages = new ArrayList<>();

        // Adding elements
        languages.add("Java");
        languages.add("Python");
        languages.add("JavaScript");
        languages.add("Go");
        languages.add("Java"); // Duplicates are allowed
        System.out.println("Languages: " + languages);
        // Output: Languages: [Java, Python, JavaScript, Go, Java]

        // Access by index
        String first = languages.get(0);
        System.out.println("First: " + first); // Output: First: Java

        // Update an element
        languages.set(3, "Rust");
        System.out.println("After set(3): " + languages);
        // Output: After set(3): [Java, Python, JavaScript, Rust, Java]

        // Insert at specific position
        languages.add(1, "TypeScript");
        System.out.println("After add(1): " + languages);
        // Output: After add(1): [Java, TypeScript, Python, JavaScript, Rust, Java]

        // Remove by index
        languages.remove(4); // Removes "Rust"
        System.out.println("After remove(4): " + languages);
        // Output: After remove(4): [Java, TypeScript, Python, JavaScript, Java]

        // Remove by object (removes first occurrence)
        languages.remove("Java"); // Removes first "Java" at index 0
        System.out.println("After remove(\"Java\"): " + languages);
        // Output: After remove("Java"): [TypeScript, Python, JavaScript, Java]

        // Useful methods
        System.out.println("Size: " + languages.size());         // Output: Size: 4
        System.out.println("Contains Python? " + languages.contains("Python")); // Output: Contains Python? true
        System.out.println("Index of Java: " + languages.indexOf("Java"));      // Output: Index of Java: 3
        System.out.println("Is empty? " + languages.isEmpty());   // Output: Is empty? false

        // Sublist (fromIndex inclusive, toIndex exclusive)
        List subset = languages.subList(1, 3);
        System.out.println("Sublist(1,3): " + subset);
        // Output: Sublist(1,3): [Python, JavaScript]
    }
}

3.2 LinkedList

LinkedList is implemented as a doubly-linked list. Each element (node) holds a reference to both the previous and next nodes. It also implements the Deque interface, making it usable as both a queue and a stack.

Performance characteristics:

Operation Time Complexity Why
get(index) O(n) Must traverse from head or tail
add(element) (at end) O(1) Direct pointer update at tail
addFirst(element) O(1) Direct pointer update at head
remove() (first or last) O(1) Pointer update, no shifting
remove(index) (in middle) O(n) Must traverse to the node first
contains(element) O(n) Linear scan

When to use LinkedList: When you need frequent insertions/removals at the beginning or end of the list (queue or stack behavior). In practice, ArrayList outperforms LinkedList for most workloads because of CPU cache locality — the contiguous memory layout of an array is much more cache-friendly than scattered nodes on the heap.

import java.util.LinkedList;
import java.util.Deque;

public class LinkedListExample {
    public static void main(String[] args) {
        // LinkedList as a Deque (double-ended queue)
        Deque taskQueue = new LinkedList<>();

        // Add to front and back
        taskQueue.addFirst("Review PR");
        taskQueue.addLast("Write tests");
        taskQueue.addLast("Deploy to staging");
        taskQueue.addFirst("Fix critical bug"); // Urgent task goes first!
        System.out.println("Task Queue: " + taskQueue);
        // Output: Task Queue: [Fix critical bug, Review PR, Write tests, Deploy to staging]

        // Peek at first and last without removing
        System.out.println("Next task: " + taskQueue.peekFirst());
        // Output: Next task: Fix critical bug
        System.out.println("Last task: " + taskQueue.peekLast());
        // Output: Last task: Deploy to staging

        // Process (remove) from front
        String completed = taskQueue.pollFirst();
        System.out.println("Completed: " + completed);
        // Output: Completed: Fix critical bug
        System.out.println("Remaining: " + taskQueue);
        // Output: Remaining: [Review PR, Write tests, Deploy to staging]

        // Use as a stack (LIFO) -- push/pop operate on the head
        Deque undoStack = new LinkedList<>();
        undoStack.push("Type 'Hello'");
        undoStack.push("Bold text");
        undoStack.push("Change font");
        System.out.println("\nUndo Stack: " + undoStack);
        // Output: Undo Stack: [Change font, Bold text, Type 'Hello']

        String undone = undoStack.pop();
        System.out.println("Undone: " + undone);
        // Output: Undone: Change font
    }
}

3.3 ArrayList vs LinkedList

Criteria ArrayList LinkedList
Internal structure Resizable array Doubly-linked list
Random access (get) O(1) — fast O(n) — slow
Insert/remove at end O(1) amortized O(1)
Insert/remove at beginning O(n) — shifts everything O(1) — pointer update
Insert/remove in middle O(n) O(n) (traversal) + O(1) (insert)
Memory overhead Low (contiguous array) High (each node has 2 pointers + object overhead)
Cache performance Excellent (contiguous memory) Poor (nodes scattered on heap)
Implements Deque? No Yes
Best for Most general-purpose list needs Queue/stack patterns, frequent add/remove at ends

Rule of thumb: Use ArrayList by default. Only reach for LinkedList if you specifically need Deque operations and profiling shows it matters. In practice, even ArrayDeque is usually faster than LinkedList for queue/stack operations because of cache locality.

4. Set Interface

A Set is a collection that contains no duplicate elements. It models the mathematical set abstraction. If you try to add an element that is already in the set, the add() method returns false and the set remains unchanged.

Sets determine equality using the equals() method. For hash-based sets (HashSet, LinkedHashSet), the hashCode() method is also critical — objects that are equals() must have the same hashCode().

4.1 HashSet

HashSet is the most commonly used Set implementation. Internally, it is backed by a HashMap (the set elements are stored as keys with a dummy value). It provides:

  • O(1) average time for add(), remove(), contains()
  • No ordering guarantee — elements may come out in any order during iteration
  • Allows one null element
import java.util.HashSet;
import java.util.Set;

public class HashSetExample {
    public static void main(String[] args) {
        Set uniqueEmails = new HashSet<>();

        // Adding elements
        uniqueEmails.add("alice@example.com");
        uniqueEmails.add("bob@example.com");
        uniqueEmails.add("charlie@example.com");

        // Duplicate is rejected
        boolean added = uniqueEmails.add("alice@example.com");
        System.out.println("alice added again? " + added); // Output: alice added again? false
        System.out.println("Size: " + uniqueEmails.size()); // Output: Size: 3

        // Check membership
        System.out.println("Contains bob? " + uniqueEmails.contains("bob@example.com"));
        // Output: Contains bob? true

        // Remove an element
        uniqueEmails.remove("charlie@example.com");
        System.out.println("After remove: " + uniqueEmails);
        // Output: After remove: [bob@example.com, alice@example.com] (order may vary)

        // Iteration -- order is NOT guaranteed
        for (String email : uniqueEmails) {
            System.out.println("Email: " + email);
        }
    }
}

4.2 LinkedHashSet

LinkedHashSet extends HashSet and maintains a doubly-linked list across all entries, preserving insertion order. It has slightly higher memory overhead than HashSet because of the linked list, but iteration is predictable.

When to use: When you need the uniqueness guarantee of a Set but also need to iterate elements in the order they were added.

import java.util.LinkedHashSet;
import java.util.Set;

public class LinkedHashSetExample {
    public static void main(String[] args) {
        Set visitedPages = new LinkedHashSet<>();

        visitedPages.add("/home");
        visitedPages.add("/about");
        visitedPages.add("/products");
        visitedPages.add("/home");     // Duplicate -- ignored
        visitedPages.add("/contact");

        // Iteration preserves insertion order
        System.out.println("Browsing history (unique pages):");
        for (String page : visitedPages) {
            System.out.println("  " + page);
        }
        // Output:
        //   /home
        //   /about
        //   /products
        //   /contact
    }
}

4.3 TreeSet

TreeSet stores elements in sorted order using a Red-Black tree (a self-balancing binary search tree). It implements the NavigableSet interface, which provides methods for range queries and navigation.

  • O(log n) for add(), remove(), contains()
  • Elements must be Comparable (implement Comparable interface) OR you must provide a Comparator at construction time
  • Does not allow null (would throw NullPointerException — cannot compare null)
import java.util.TreeSet;
import java.util.NavigableSet;

public class TreeSetExample {
    public static void main(String[] args) {
        NavigableSet scores = new TreeSet<>();

        scores.add(85);
        scores.add(92);
        scores.add(78);
        scores.add(95);
        scores.add(88);
        scores.add(85); // Duplicate -- ignored

        // Automatically sorted in natural order (ascending)
        System.out.println("Scores: " + scores);
        // Output: Scores: [78, 85, 88, 92, 95]

        // NavigableSet methods
        System.out.println("First (lowest): " + scores.first());   // Output: First (lowest): 78
        System.out.println("Last (highest): " + scores.last());    // Output: Last (highest): 95
        System.out.println("Lower than 88: " + scores.lower(88));  // Output: Lower than 88: 85
        System.out.println("Higher than 88: " + scores.higher(88));// Output: Higher than 88: 92
        System.out.println("Floor of 90: " + scores.floor(90));    // Output: Floor of 90: 88
        System.out.println("Ceiling of 90: " + scores.ceiling(90));// Output: Ceiling of 90: 92

        // Subset (fromInclusive, toExclusive)
        System.out.println("Scores 80-90: " + scores.subSet(80, true, 90, true));
        // Output: Scores 80-90: [85, 88]

        // Descending order
        System.out.println("Descending: " + scores.descendingSet());
        // Output: Descending: [95, 92, 88, 85, 78]
    }
}

4.4 When to Use Each Set

Set Type Ordering Performance Best For
HashSet None O(1) average General-purpose uniqueness checks; fastest option
LinkedHashSet Insertion order O(1) average Unique elements with predictable iteration order
TreeSet Sorted (natural or custom) O(log n) Sorted unique elements, range queries, finding nearest values

5. Map Interface

A Map stores data as key-value pairs. Each key maps to exactly one value. Keys must be unique — if you put a new value with an existing key, the old value is replaced. Map does not extend Collection; it is a separate interface.

5.1 HashMap

HashMap is the workhorse of key-value storage in Java. It uses a hash table internally: keys are hashed to determine their bucket location, giving O(1) average-case performance for most operations.

  • O(1) average for put(), get(), containsKey(), remove()
  • No ordering guarantee for iteration
  • Allows one null key and multiple null values
  • Not thread-safe — use ConcurrentHashMap for concurrent access
import java.util.HashMap;
import java.util.Map;

public class HashMapExample {
    public static void main(String[] args) {
        Map wordCount = new HashMap<>();

        // put() -- add or update key-value pairs
        wordCount.put("java", 15);
        wordCount.put("python", 12);
        wordCount.put("javascript", 8);
        wordCount.put("go", 5);
        System.out.println("Word counts: " + wordCount);

        // get() -- retrieve value by key
        int javaCount = wordCount.get("java");
        System.out.println("Java count: " + javaCount); // Output: Java count: 15

        // getOrDefault() -- avoid NullPointerException for missing keys
        int rubyCount = wordCount.getOrDefault("ruby", 0);
        System.out.println("Ruby count: " + rubyCount); // Output: Ruby count: 0

        // put() with existing key -- replaces old value
        wordCount.put("java", 20);
        System.out.println("Java updated: " + wordCount.get("java")); // Output: Java updated: 20

        // putIfAbsent() -- only adds if key is not already present
        wordCount.putIfAbsent("java", 999);    // Ignored -- key exists
        wordCount.putIfAbsent("kotlin", 3);     // Added -- key is new
        System.out.println("Java: " + wordCount.get("java"));     // Output: Java: 20
        System.out.println("Kotlin: " + wordCount.get("kotlin")); // Output: Kotlin: 3

        // Checking keys and values
        System.out.println("Contains 'python' key? " + wordCount.containsKey("python"));   // Output: true
        System.out.println("Contains value 8? " + wordCount.containsValue(8));              // Output: true

        // Size
        System.out.println("Size: " + wordCount.size()); // Output: Size: 5

        // Remove
        wordCount.remove("go");
        System.out.println("After removing 'go': " + wordCount);

        // Iteration -- three ways to iterate a Map
        System.out.println("\n--- keySet() ---");
        for (String key : wordCount.keySet()) {
            System.out.println(key + " -> " + wordCount.get(key));
        }

        System.out.println("\n--- entrySet() (preferred) ---");
        for (Map.Entry entry : wordCount.entrySet()) {
            System.out.println(entry.getKey() + " -> " + entry.getValue());
        }

        System.out.println("\n--- values() ---");
        for (int count : wordCount.values()) {
            System.out.println("Count: " + count);
        }

        // Java 8+ forEach with lambda
        System.out.println("\n--- forEach lambda ---");
        wordCount.forEach((key, value) ->
            System.out.println(key + " = " + value)
        );
    }
}

5.2 LinkedHashMap

LinkedHashMap extends HashMap and maintains a doubly-linked list of entries, preserving insertion order (or optionally, access order). This makes iteration predictable.

Access-order mode is particularly useful for implementing LRU (Least Recently Used) caches. When access-order is enabled, every get() or put() moves the entry to the end of the linked list.

import java.util.LinkedHashMap;
import java.util.Map;

public class LinkedHashMapExample {
    public static void main(String[] args) {
        // Insertion-order LinkedHashMap
        Map config = new LinkedHashMap<>();
        config.put("host", "localhost");
        config.put("port", "8080");
        config.put("protocol", "https");

        // Iteration follows insertion order
        System.out.println("Config (insertion order):");
        config.forEach((key, value) ->
            System.out.println("  " + key + " = " + value)
        );
        // Output:
        //   host = localhost
        //   port = 8080
        //   protocol = https

        // LRU Cache using access-order LinkedHashMap
        // Parameters: initialCapacity, loadFactor, accessOrder
        int maxSize = 3;
        Map lruCache = new LinkedHashMap<>(16, 0.75f, true) {
            @Override
            protected boolean removeEldestEntry(Map.Entry eldest) {
                return size() > maxSize; // Remove oldest when cache exceeds max size
            }
        };

        lruCache.put("A", "Alpha");
        lruCache.put("B", "Bravo");
        lruCache.put("C", "Charlie");
        System.out.println("\nCache: " + lruCache); // {A=Alpha, B=Bravo, C=Charlie}

        lruCache.get("A"); // Access "A" -- moves it to end
        lruCache.put("D", "Delta"); // Exceeds max size -- evicts least recently used ("B")
        System.out.println("After access A, add D: " + lruCache);
        // Output: After access A, add D: {C=Charlie, A=Alpha, D=Delta}
        // "B" was evicted because it was the least recently used
    }
}

5.3 TreeMap

TreeMap stores entries sorted by their keys using a Red-Black tree. It implements the NavigableMap interface, providing methods for range queries, floor/ceiling lookups, and submap views.

  • O(log n) for put(), get(), remove()
  • Keys must be Comparable or a Comparator must be provided
  • Does not allow null keys (but allows null values)
import java.util.TreeMap;
import java.util.NavigableMap;
import java.util.Map;

public class TreeMapExample {
    public static void main(String[] args) {
        NavigableMap studentGrades = new TreeMap<>();

        studentGrades.put("Charlie", 3.5);
        studentGrades.put("Alice", 3.9);
        studentGrades.put("Eve", 3.2);
        studentGrades.put("Bob", 3.7);
        studentGrades.put("Diana", 3.8);

        // Automatically sorted by key (alphabetical for Strings)
        System.out.println("Grades (sorted by name): " + studentGrades);
        // Output: {Alice=3.9, Bob=3.7, Charlie=3.5, Diana=3.8, Eve=3.2}

        // NavigableMap methods
        System.out.println("First: " + studentGrades.firstEntry()); // Alice=3.9
        System.out.println("Last: " + studentGrades.lastEntry());   // Eve=3.2

        // Range query: students from "Bob" to "Diana" (inclusive)
        Map range = studentGrades.subMap("Bob", true, "Diana", true);
        System.out.println("Bob to Diana: " + range);
        // Output: Bob to Diana: {Bob=3.7, Charlie=3.5, Diana=3.8}

        // Floor and ceiling
        System.out.println("Floor of 'D': " + studentGrades.floorKey("D"));     // Charlie
        System.out.println("Ceiling of 'D': " + studentGrades.ceilingKey("D")); // Diana

        // Descending map
        System.out.println("Reverse: " + studentGrades.descendingMap());
        // Output: {Eve=3.2, Diana=3.8, Charlie=3.5, Bob=3.7, Alice=3.9}
    }
}

5.4 Hashtable (Legacy — Avoid)

Hashtable is a legacy class from Java 1.0 (before the Collections Framework existed). It is similar to HashMap but with two important differences:

  • Synchronized — every method is synchronized, which makes it thread-safe but slow
  • No nulls — does not allow null keys or null values

Do not use Hashtable in new code. If you need a thread-safe map, use ConcurrentHashMap, which uses lock striping for much better concurrent performance. If you do not need thread safety, use HashMap.

5.5 When to Use Each Map

Map Type Ordering Performance Null Keys? Thread-Safe? Best For
HashMap None O(1) average Yes (one) No General-purpose key-value storage
LinkedHashMap Insertion or access order O(1) average Yes (one) No Predictable iteration order, LRU caches
TreeMap Sorted by key O(log n) No No Sorted key-value pairs, range queries
Hashtable None O(1) average No Yes (but slow) Legacy code only — use ConcurrentHashMap instead
ConcurrentHashMap None O(1) average No Yes (efficient) Multi-threaded concurrent access

6. Queue and Deque

A Queue is a collection designed for holding elements prior to processing, typically in FIFO (First-In, First-Out) order. A Deque (Double-Ended Queue, pronounced “deck”) extends Queue and supports element insertion and removal at both ends.

Key Queue/Deque Methods

Operation Throws Exception Returns null/false
Insert add(e) offer(e)
Remove remove() poll()
Examine element() peek()

Prefer the “returns null/false” variants (offer, poll, peek) in most code — they let you handle empty queues gracefully without try/catch blocks.

6.1 PriorityQueue

PriorityQueue is a min-heap by default. Elements are dequeued in natural order (smallest first for numbers, alphabetical for strings) or by a custom Comparator. It is not a sorted data structure — only the head is guaranteed to be the minimum. The internal ordering of other elements is not guaranteed.

import java.util.PriorityQueue;
import java.util.Queue;
import java.util.Comparator;

public class PriorityQueueExample {
    public static void main(String[] args) {
        // Min-heap (default) -- lowest number = highest priority
        Queue minHeap = new PriorityQueue<>();
        minHeap.offer(30);
        minHeap.offer(10);
        minHeap.offer(20);
        minHeap.offer(5);

        System.out.println("Peek (min): " + minHeap.peek()); // Output: 5

        System.out.print("Polling (ascending): ");
        while (!minHeap.isEmpty()) {
            System.out.print(minHeap.poll() + " ");
        }
        // Output: Polling (ascending): 5 10 20 30
        System.out.println();

        // Max-heap using Comparator.reverseOrder()
        Queue maxHeap = new PriorityQueue<>(Comparator.reverseOrder());
        maxHeap.offer(30);
        maxHeap.offer(10);
        maxHeap.offer(20);
        maxHeap.offer(5);

        System.out.print("Polling (descending): ");
        while (!maxHeap.isEmpty()) {
            System.out.print(maxHeap.poll() + " ");
        }
        // Output: Polling (descending): 30 20 10 5
        System.out.println();

        // Priority queue with custom objects
        record Task(String name, int priority) {}

        Queue taskQueue = new PriorityQueue<>(
            Comparator.comparingInt(Task::priority) // Lower number = higher priority
        );

        taskQueue.offer(new Task("Send email", 3));
        taskQueue.offer(new Task("Fix production bug", 1));
        taskQueue.offer(new Task("Update docs", 5));
        taskQueue.offer(new Task("Code review", 2));

        System.out.println("\nProcessing tasks by priority:");
        while (!taskQueue.isEmpty()) {
            Task task = taskQueue.poll();
            System.out.println("  [P" + task.priority() + "] " + task.name());
        }
        // Output:
        //   [P1] Fix production bug
        //   [P2] Code review
        //   [P3] Send email
        //   [P5] Update docs
    }
}

6.2 ArrayDeque

ArrayDeque is the go-to implementation for both stacks and queues. It is backed by a resizable circular array and outperforms both LinkedList (when used as a queue) and Stack (a legacy class).

  • O(1) amortized for push, pop, offer, poll, peek
  • Not thread-safe
  • Does not allow null elements (null is used as a sentinel internally)
import java.util.ArrayDeque;
import java.util.Deque;

public class ArrayDequeExample {
    public static void main(String[] args) {
        // Use as a STACK (LIFO) -- push/pop at the head
        Deque callStack = new ArrayDeque<>();
        callStack.push("main()");
        callStack.push("processOrder()");
        callStack.push("validatePayment()");
        callStack.push("chargeCard()");

        System.out.println("Call stack: " + callStack);
        // Output: [chargeCard(), validatePayment(), processOrder(), main()]

        System.out.println("Current: " + callStack.peek()); // chargeCard()
        callStack.pop(); // chargeCard() returns
        System.out.println("After pop: " + callStack.peek()); // validatePayment()

        // Use as a QUEUE (FIFO) -- offer at tail, poll from head
        Deque printQueue = new ArrayDeque<>();
        printQueue.offer("Document1.pdf");
        printQueue.offer("Photo.jpg");
        printQueue.offer("Report.xlsx");

        System.out.println("\nPrint queue: " + printQueue);
        System.out.println("Printing: " + printQueue.poll()); // Document1.pdf
        System.out.println("Printing: " + printQueue.poll()); // Photo.jpg
        System.out.println("Remaining: " + printQueue);       // [Report.xlsx]
    }
}

7. Iterating Collections

Java provides several ways to loop through collections. Each approach has its strengths, and choosing the right one depends on whether you need the index, need to modify the collection during iteration, or prefer a functional style.

7.1 For-Each Loop (Enhanced For)

The simplest and most readable way to iterate any Iterable. Use this when you need to process every element and do not need the index or the ability to remove elements.

List names = List.of("Alice", "Bob", "Charlie");

// For-each loop -- clean and simple
for (String name : names) {
    System.out.println(name);
}
// Output:
// Alice
// Bob
// Charlie

7.2 Iterator

Use an Iterator when you need to remove elements safely during iteration. Calling collection.remove() inside a for-each loop throws a ConcurrentModificationException — the Iterator.remove() method is the safe way to do it.

import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;

public class IteratorExample {
    public static void main(String[] args) {
        List numbers = new ArrayList<>(List.of(1, 2, 3, 4, 5, 6, 7, 8));

        // Remove all even numbers using Iterator
        Iterator it = numbers.iterator();
        while (it.hasNext()) {
            int num = it.next();
            if (num % 2 == 0) {
                it.remove(); // Safe removal during iteration
            }
        }
        System.out.println("Odd numbers: " + numbers);
        // Output: Odd numbers: [1, 3, 5, 7]
    }
}

7.3 ListIterator

ListIterator extends Iterator and is available only for List implementations. It adds the ability to traverse backwards, get the current index, and replace or add elements during iteration.

import java.util.ArrayList;
import java.util.List;
import java.util.ListIterator;

public class ListIteratorExample {
    public static void main(String[] args) {
        List items = new ArrayList<>(List.of("apple", "banana", "cherry"));

        ListIterator lit = items.listIterator();
        while (lit.hasNext()) {
            int index = lit.nextIndex();
            String item = lit.next();
            lit.set(item.toUpperCase()); // Replace current element
            System.out.println("[" + index + "] " + item + " -> " + item.toUpperCase());
        }
        // Output:
        // [0] apple -> APPLE
        // [1] banana -> BANANA
        // [2] cherry -> CHERRY

        System.out.println("Updated list: " + items);
        // Output: Updated list: [APPLE, BANANA, CHERRY]

        // Traverse backwards
        System.out.print("Reverse: ");
        while (lit.hasPrevious()) {
            System.out.print(lit.previous() + " ");
        }
        // Output: Reverse: CHERRY BANANA APPLE
    }
}

7.4 forEach with Lambda (Java 8+)

The forEach() method accepts a Consumer lambda. It is concise for simple operations but less flexible than an explicit loop (no break, no continue, no checked exceptions).

import java.util.List;
import java.util.Map;

public class ForEachLambdaExample {
    public static void main(String[] args) {
        // Collection.forEach()
        List fruits = List.of("Apple", "Banana", "Cherry");
        fruits.forEach(fruit -> System.out.println("Fruit: " + fruit));

        // Map.forEach() -- provides both key and value
        Map inventory = Map.of("Apple", 50, "Banana", 30, "Cherry", 20);
        inventory.forEach((item, qty) ->
            System.out.println(item + ": " + qty + " in stock")
        );

        // Method reference -- even more concise
        fruits.forEach(System.out::println);

        // stream().forEach() -- use when chaining stream operations
        fruits.stream()
              .filter(f -> f.startsWith("B"))
              .forEach(f -> System.out.println("Starts with B: " + f));
        // Output: Starts with B: Banana
    }
}

7.5 ConcurrentModificationException

This is one of the most common errors Java developers encounter. It occurs when you modify a collection while iterating over it with a for-each loop.

import java.util.ArrayList;
import java.util.List;

public class ConcurrentModificationExample {
    public static void main(String[] args) {
        List names = new ArrayList<>(List.of("Alice", "Bob", "Charlie", "David"));

        // BAD -- throws ConcurrentModificationException
        // for (String name : names) {
        //     if (name.startsWith("C")) {
        //         names.remove(name); // Modifying collection during for-each!
        //     }
        // }

        // SOLUTION 1: Use Iterator.remove()
        var it = names.iterator();
        while (it.hasNext()) {
            if (it.next().startsWith("C")) {
                it.remove();
            }
        }
        System.out.println("After iterator remove: " + names);
        // Output: After iterator remove: [Alice, Bob, David]

        // SOLUTION 2: Use removeIf() (Java 8+) -- cleanest approach
        List names2 = new ArrayList<>(List.of("Alice", "Bob", "Charlie", "David"));
        names2.removeIf(name -> name.startsWith("C"));
        System.out.println("After removeIf: " + names2);
        // Output: After removeIf: [Alice, Bob, David]

        // SOLUTION 3: Collect items to remove, then removeAll
        List names3 = new ArrayList<>(List.of("Alice", "Bob", "Charlie", "David"));
        List toRemove = new ArrayList<>();
        for (String name : names3) {
            if (name.startsWith("C")) {
                toRemove.add(name);
            }
        }
        names3.removeAll(toRemove);
        System.out.println("After removeAll: " + names3);
        // Output: After removeAll: [Alice, Bob, David]
    }
}

7.6 When to Use Each Iteration Method

Method Best For Can Remove? Can Break?
For-each loop Simple read-only iteration No Yes (break)
Iterator Safe removal during iteration Yes (it.remove()) Yes (break)
ListIterator Bidirectional traversal, replace/add during iteration Yes Yes (break)
forEach + lambda Concise one-liner operations No No
removeIf() Conditional bulk removal Yes (built in) N/A
stream().forEach() Chaining filter/map/collect operations No No

8. Sorting

Sorting is one of the most common operations on collections. Java provides two mechanisms for defining sort order: the Comparable interface (natural ordering built into the class) and the Comparator interface (external, custom ordering).

8.1 Comparable — Natural Ordering

A class implements Comparable to define its natural ordering. The compareTo() method returns:

  • Negative if this is less than the other
  • Zero if equal
  • Positive if this is greater than the other

Built-in classes like String, Integer, Double, and LocalDate already implement Comparable. For your own classes, you implement it yourself.

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

// Employee implements Comparable -- natural ordering by salary
public class Employee implements Comparable {
    private String name;
    private double salary;

    public Employee(String name, double salary) {
        this.name = name;
        this.salary = salary;
    }

    public String getName() { return name; }
    public double getSalary() { return salary; }

    @Override
    public int compareTo(Employee other) {
        // Natural ordering: by salary ascending
        return Double.compare(this.salary, other.salary);
    }

    @Override
    public String toString() {
        return name + " ($" + String.format("%.0f", salary) + ")";
    }

    public static void main(String[] args) {
        List team = new ArrayList<>();
        team.add(new Employee("Alice", 85000));
        team.add(new Employee("Bob", 72000));
        team.add(new Employee("Charlie", 95000));
        team.add(new Employee("Diana", 78000));

        // Sort using natural ordering (compareTo)
        Collections.sort(team);
        System.out.println("By salary (natural): " + team);
        // Output: By salary (natural): [Bob ($72000), Diana ($78000), Alice ($85000), Charlie ($95000)]

        // Or use List.sort() -- same result
        team.sort(null); // null means use natural ordering
    }
}

8.2 Comparator — Custom Ordering

A Comparator is an external comparison strategy. Use it when:

  • You want to sort by a different field than the natural ordering
  • You do not own the class and cannot modify it to implement Comparable
  • You need multiple sort orders for the same class

Java 8 introduced Comparator.comparing() and method chaining, which replaced the old anonymous class approach.

import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

public class ComparatorExample {
    record Employee(String name, String department, double salary) {}

    public static void main(String[] args) {
        List team = new ArrayList<>();
        team.add(new Employee("Alice", "Engineering", 85000));
        team.add(new Employee("Bob", "Marketing", 72000));
        team.add(new Employee("Charlie", "Engineering", 95000));
        team.add(new Employee("Diana", "Marketing", 78000));
        team.add(new Employee("Eve", "Engineering", 85000));

        // Sort by name (alphabetical)
        team.sort(Comparator.comparing(Employee::name));
        System.out.println("By name:");
        team.forEach(e -> System.out.println("  " + e));
        // Alice, Bob, Charlie, Diana, Eve

        // Sort by salary descending
        team.sort(Comparator.comparingDouble(Employee::salary).reversed());
        System.out.println("\nBy salary (descending):");
        team.forEach(e -> System.out.println("  " + e));
        // Charlie (95000), Alice (85000), Eve (85000), Diana (78000), Bob (72000)

        // Sort by department, then by salary descending within department
        team.sort(Comparator.comparing(Employee::department)
                            .thenComparing(Comparator.comparingDouble(Employee::salary).reversed()));
        System.out.println("\nBy department, then salary desc:");
        team.forEach(e -> System.out.println("  " + e));
        // Output:
        //   Employee[name=Charlie, department=Engineering, salary=95000.0]
        //   Employee[name=Alice, department=Engineering, salary=85000.0]
        //   Employee[name=Eve, department=Engineering, salary=85000.0]
        //   Employee[name=Diana, department=Marketing, salary=78000.0]
        //   Employee[name=Bob, department=Marketing, salary=72000.0]

        // Handling nulls
        List items = new ArrayList<>(List.of("Banana", "Apple"));
        items.add(null);
        items.add("Cherry");

        items.sort(Comparator.nullsLast(Comparator.naturalOrder()));
        System.out.println("\nWith nulls last: " + items);
        // Output: With nulls last: [Apple, Banana, Cherry, null]
    }
}

8.3 Comparable vs Comparator

Aspect Comparable Comparator
Package java.lang java.util
Method compareTo(T o) compare(T o1, T o2)
Location Inside the class itself External (separate class or lambda)
Sort orders One (natural ordering) Many (create multiple Comparators)
Modifies class? Yes (class must implement it) No (works externally)
Use with Collections.sort(list), TreeSet list.sort(comparator), new TreeSet<>(comparator)

Best practice: Implement Comparable for the most natural, obvious ordering (e.g., employees by ID). Use Comparator for any alternative sort orders (by name, by department, by salary).

9. Collections Utility Class

The java.util.Collections class provides static methods that operate on or return collections. Think of it as the “Swiss Army knife” for collections. Here are the most useful methods:

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

public class CollectionsUtilityExample {
    public static void main(String[] args) {
        List numbers = new ArrayList<>(List.of(5, 3, 8, 1, 9, 2, 7));

        // --- Sorting ---
        Collections.sort(numbers);
        System.out.println("Sorted: " + numbers);
        // Output: Sorted: [1, 2, 3, 5, 7, 8, 9]

        // --- Reverse ---
        Collections.reverse(numbers);
        System.out.println("Reversed: " + numbers);
        // Output: Reversed: [9, 8, 7, 5, 3, 2, 1]

        // --- Shuffle (random order) ---
        Collections.shuffle(numbers);
        System.out.println("Shuffled: " + numbers);
        // Output: Shuffled: [3, 5, 1, 9, 7, 2, 8] (random each time)

        // --- Min and Max ---
        Collections.sort(numbers); // Re-sort for clarity
        System.out.println("Min: " + Collections.min(numbers)); // Output: Min: 1
        System.out.println("Max: " + Collections.max(numbers)); // Output: Max: 9

        // --- Frequency ---
        List words = List.of("java", "python", "java", "go", "java", "python");
        System.out.println("'java' appears: " + Collections.frequency(words, "java") + " times");
        // Output: 'java' appears: 3 times

        // --- Binary Search (list must be sorted!) ---
        List sorted = List.of(10, 20, 30, 40, 50);
        int index = Collections.binarySearch(sorted, 30);
        System.out.println("Index of 30: " + index); // Output: Index of 30: 2

        // --- Unmodifiable List (read-only wrapper) ---
        List mutable = new ArrayList<>(List.of("A", "B", "C"));
        List readOnly = Collections.unmodifiableList(mutable);
        // readOnly.add("D"); // Throws UnsupportedOperationException!
        System.out.println("Read-only: " + readOnly);

        // --- Empty collections (immutable) ---
        List empty = Collections.emptyList();
        System.out.println("Empty list: " + empty); // Output: Empty list: []
        // empty.add("X"); // Throws UnsupportedOperationException!

        // --- Singleton (immutable single-element collection) ---
        List single = Collections.singletonList("OnlyOne");
        System.out.println("Singleton: " + single); // Output: Singleton: [OnlyOne]

        // --- Fill ---
        List template = new ArrayList<>(List.of("A", "B", "C", "D"));
        Collections.fill(template, "X");
        System.out.println("Filled: " + template); // Output: Filled: [X, X, X, X]

        // --- Swap ---
        List letters = new ArrayList<>(List.of("A", "B", "C"));
        Collections.swap(letters, 0, 2);
        System.out.println("Swapped: " + letters); // Output: Swapped: [C, B, A]

        // --- nCopies (immutable) ---
        List copies = Collections.nCopies(5, "Hello");
        System.out.println("Copies: " + copies); // Output: Copies: [Hello, Hello, Hello, Hello, Hello]
    }
}

Java 9+ Immutable Factory Methods

Java 9 introduced convenient factory methods that are now the preferred way to create small, immutable collections:

import java.util.List;
import java.util.Set;
import java.util.Map;

public class ImmutableFactoryExample {
    public static void main(String[] args) {
        // List.of() -- immutable list
        List colors = List.of("Red", "Green", "Blue");
        System.out.println("Colors: " + colors);
        // colors.add("Yellow"); // UnsupportedOperationException!

        // Set.of() -- immutable set (no duplicates allowed)
        Set primes = Set.of(2, 3, 5, 7, 11);
        System.out.println("Primes: " + primes);

        // Map.of() -- immutable map (up to 10 entries)
        Map scores = Map.of(
            "Alice", 95,
            "Bob", 87,
            "Charlie", 92
        );
        System.out.println("Scores: " + scores);

        // Map.ofEntries() -- immutable map (any number of entries)
        Map config = Map.ofEntries(
            Map.entry("host", "localhost"),
            Map.entry("port", "8080"),
            Map.entry("env", "production"),
            Map.entry("debug", "false")
        );
        System.out.println("Config: " + config);

        // To create a mutable copy from an immutable collection:
        List mutableColors = new java.util.ArrayList<>(colors);
        mutableColors.add("Yellow"); // This works!
        System.out.println("Mutable colors: " + mutableColors);
    }
}

10. Choosing the Right Collection

Choosing the wrong collection is a common source of performance bugs and unnecessary complexity. Use this decision guide:

Decision Table

Need Recommended Collection Why
Ordered list, fast random access ArrayList O(1) get by index, contiguous memory
Frequent insert/remove at both ends ArrayDeque O(1) push/pop, better cache performance than LinkedList
Unique elements, fast lookup HashSet O(1) contains/add/remove
Unique elements, sorted TreeSet O(log n), red-black tree, supports range queries
Unique elements, insertion order LinkedHashSet O(1) with predictable iteration order
Key-value lookup HashMap O(1) average get/put
Key-value, sorted by key TreeMap O(log n), sorted keys, range queries
Key-value, insertion order LinkedHashMap O(1) with predictable iteration
Priority-based processing PriorityQueue Min-heap, always gives smallest element first
Thread-safe map ConcurrentHashMap Lock striping for high concurrent throughput
Thread-safe list (read-heavy) CopyOnWriteArrayList No locking for reads, copies on write
Producer-consumer queue LinkedBlockingQueue Blocking operations for thread coordination

Quick Decision Flowchart

Do you need key-value pairs?
├── YES -> Do keys need to be sorted?
│   ├── YES -> TreeMap
│   └── NO -> Need insertion order?
│       ├── YES -> LinkedHashMap
│       └── NO -> Need thread safety?
│           ├── YES -> ConcurrentHashMap
│           └── NO -> HashMap
└── NO -> Do you need uniqueness (no duplicates)?
    ├── YES -> Need sorted order?
    │   ├── YES -> TreeSet
    │   └── NO -> Need insertion order?
    │       ├── YES -> LinkedHashSet
    │       └── NO -> HashSet
    └── NO -> Do you need FIFO/LIFO/Priority?
        ├── YES -> Need priority ordering?
        │   ├── YES -> PriorityQueue
        │   └── NO -> ArrayDeque (for both stack and queue)
        └── NO -> ArrayList (default choice for lists)

11. Thread-Safe Collections

The standard collections (ArrayList, HashMap, HashSet) are not thread-safe. If multiple threads access them concurrently and at least one thread modifies the collection, you will get unpredictable behavior or ConcurrentModificationException.

Options for Thread Safety

Approach How Performance When to Use
Synchronized wrappers Collections.synchronizedList(), Collections.synchronizedMap() Poor (single lock for all operations) Quick fix; rarely the best choice
ConcurrentHashMap Lock striping + CAS operations Excellent for concurrent reads/writes Multi-threaded map access (most common need)
CopyOnWriteArrayList Creates a new array on every write Excellent reads, expensive writes Read-heavy, write-rare scenarios (e.g., listener lists)
BlockingQueue implementations LinkedBlockingQueue, ArrayBlockingQueue Good for producer-consumer Thread coordination, work queues
ConcurrentSkipListMap Skip list (concurrent sorted map) O(log n) with concurrency Need sorted map + thread safety
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.CopyOnWriteArrayList;
import java.util.List;
import java.util.Map;

public class ThreadSafeCollectionsExample {
    public static void main(String[] args) {
        // ConcurrentHashMap -- the go-to thread-safe map
        Map concurrentMap = new ConcurrentHashMap<>();
        concurrentMap.put("counter", 0);

        // Safe atomic operations
        concurrentMap.compute("counter", (key, value) -> value + 1);
        concurrentMap.merge("counter", 1, Integer::sum);
        System.out.println("Counter: " + concurrentMap.get("counter")); // Output: Counter: 2

        // putIfAbsent -- atomic check-and-put
        concurrentMap.putIfAbsent("newKey", 100);
        System.out.println("New key: " + concurrentMap.get("newKey")); // Output: New key: 100

        // CopyOnWriteArrayList -- great for listener/observer patterns
        List listeners = new CopyOnWriteArrayList<>();
        listeners.add("LoggingListener");
        listeners.add("MetricsListener");
        listeners.add("AlertListener");

        // Safe to iterate even if another thread modifies the list
        // (iterates over a snapshot)
        for (String listener : listeners) {
            System.out.println("Notifying: " + listener);
        }
    }
}

12. Common Mistakes

Even experienced developers fall into these traps. Understanding them will save you hours of debugging.

Mistake 1: Not Overriding hashCode() When You Override equals()

If two objects are equals(), they must have the same hashCode(). Failing to do this breaks HashSet, HashMap, and any other hash-based collection.

import java.util.HashSet;
import java.util.Objects;
import java.util.Set;

public class HashCodeMistake {

    // BAD -- overrides equals() but NOT hashCode()
    static class BadProduct {
        String name;
        BadProduct(String name) { this.name = name; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof BadProduct)) return false;
            return name.equals(((BadProduct) o).name);
        }
        // Missing hashCode()!
    }

    // GOOD -- overrides both equals() AND hashCode()
    static class GoodProduct {
        String name;
        GoodProduct(String name) { this.name = name; }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof GoodProduct)) return false;
            return name.equals(((GoodProduct) o).name);
        }

        @Override
        public int hashCode() {
            return Objects.hash(name);
        }
    }

    public static void main(String[] args) {
        // BAD: Two equal objects, different hash codes -> set treats them as different
        Set badSet = new HashSet<>();
        badSet.add(new BadProduct("Laptop"));
        badSet.add(new BadProduct("Laptop")); // Should be duplicate!
        System.out.println("Bad set size: " + badSet.size());
        // Output: Bad set size: 2 (BUG! Should be 1)

        // GOOD: Two equal objects, same hash code -> set correctly rejects duplicate
        Set goodSet = new HashSet<>();
        goodSet.add(new GoodProduct("Laptop"));
        goodSet.add(new GoodProduct("Laptop"));
        System.out.println("Good set size: " + goodSet.size());
        // Output: Good set size: 1 (Correct!)
    }
}

Mistake 2: Autoboxing Pitfalls with remove()

When you have a List<Integer>, the remove() method is overloaded: remove(int index) removes by index, while remove(Object o) removes by value. Autoboxing can cause confusion.

import java.util.ArrayList;
import java.util.List;

public class AutoboxingPitfall {
    public static void main(String[] args) {
        List numbers = new ArrayList<>(List.of(10, 20, 30, 40, 50));

        // This removes the element at INDEX 2 (which is 30), NOT the value 2!
        numbers.remove(2);
        System.out.println("After remove(2): " + numbers);
        // Output: After remove(2): [10, 20, 40, 50]

        // To remove the VALUE 20, you must box it explicitly:
        numbers.remove(Integer.valueOf(20));
        System.out.println("After remove(Integer.valueOf(20)): " + numbers);
        // Output: After remove(Integer.valueOf(20)): [10, 40, 50]
    }
}

Mistake 3: Using Raw Types Instead of Generics

import java.util.ArrayList;
import java.util.List;

public class RawTypeMistake {
    public static void main(String[] args) {
        // BAD -- raw type (no generics). Compiles but dangerous!
        List rawList = new ArrayList();
        rawList.add("Hello");
        rawList.add(42);       // No compile error -- anything goes
        rawList.add(true);     // No compile error
        // String s = (String) rawList.get(1); // ClassCastException at runtime!

        // GOOD -- parameterized type. Type safety at compile time.
        List typedList = new ArrayList<>();
        typedList.add("Hello");
        // typedList.add(42);  // Compile error! Cannot add Integer to List
        String s = typedList.get(0); // No cast needed -- compiler knows it's a String
        System.out.println(s);
    }
}

Mistake 4: Modifying a Map Key After Insertion

If you use a mutable object as a key in a HashMap and then modify the object, its hash code changes. The map can no longer find the entry — it is effectively lost.

import java.util.HashMap;
import java.util.Map;
import java.util.Objects;

public class MutableKeyMistake {
    static class MutableKey {
        String value;
        MutableKey(String value) { this.value = value; }

        @Override
        public int hashCode() { return Objects.hash(value); }

        @Override
        public boolean equals(Object o) {
            if (!(o instanceof MutableKey)) return false;
            return value.equals(((MutableKey) o).value);
        }
    }

    public static void main(String[] args) {
        Map map = new HashMap<>();
        MutableKey key = new MutableKey("original");
        map.put(key, "some data");

        System.out.println("Before mutation: " + map.get(key)); // Output: some data

        // Mutate the key -- hash code changes!
        key.value = "mutated";
        System.out.println("After mutation: " + map.get(key));  // Output: null (LOST!)
        System.out.println("Map size: " + map.size());          // Output: Map size: 1 (entry exists but unreachable)

        // Lesson: Always use immutable objects as Map keys (String, Integer, record, etc.)
    }
}

Mistake 5: Assuming List.of() / Map.of() Return Mutable Collections

import java.util.List;
import java.util.ArrayList;

public class ImmutableMistake {
    public static void main(String[] args) {
        // List.of() returns an IMMUTABLE list
        List immutable = List.of("A", "B", "C");

        try {
            immutable.add("D"); // UnsupportedOperationException!
        } catch (UnsupportedOperationException e) {
            System.out.println("Cannot modify List.of() -- it's immutable!");
        }

        // If you need to modify it, create a mutable copy first
        List mutable = new ArrayList<>(immutable);
        mutable.add("D"); // This works
        System.out.println("Mutable copy: " + mutable);
        // Output: Mutable copy: [A, B, C, D]
    }
}

13. Best Practices

Follow these guidelines to write clean, maintainable, and performant collection code:

1. Program to Interfaces, Not Implementations

Declare variables using the interface type, not the concrete class. This makes your code flexible — you can swap implementations without changing any other code.

// BAD -- coupled to the implementation
ArrayList names = new ArrayList<>();
HashMap scores = new HashMap<>();

// GOOD -- programmed to the interface
List names = new ArrayList<>();    // Can easily swap to LinkedList
Map scores = new HashMap<>(); // Can easily swap to TreeMap
Set tags = new HashSet<>();        // Can easily swap to LinkedHashSet

2. Use the Diamond Operator

Since Java 7, you do not need to repeat the type on the right side of the assignment. The compiler infers it.

// BAD -- redundant type specification (pre-Java 7 style)
Map> map = new HashMap>();

// GOOD -- diamond operator infers the type
Map> map = new HashMap<>();

3. Prefer isEmpty() Over size() == 0

List items = new ArrayList<>();

// BAD -- size() might be O(n) for some implementations
if (items.size() == 0) { /* ... */ }

// GOOD -- isEmpty() is always O(1) and more readable
if (items.isEmpty()) { /* ... */ }

4. Initialize with Expected Capacity

If you know approximately how many elements a collection will hold, set the initial capacity to avoid unnecessary resizing.

// BAD -- default capacity (10), will resize multiple times for 1000 elements
List names = new ArrayList<>();

// GOOD -- pre-sized to avoid resizing
List names = new ArrayList<>(1000);

// For HashMap, account for load factor (default 0.75)
// To hold 100 entries without resize: 100 / 0.75 = 134
Map cache = new HashMap<>(134);

5. Return Empty Collections, Not Null

Returning null forces every caller to check for null. Returning an empty collection eliminates NullPointerException risks.

import java.util.List;
import java.util.ArrayList;
import java.util.Collections;

public class ReturnEmptyExample {

    // BAD -- forces callers to null-check
    public static List getNamesBad(boolean hasData) {
        if (!hasData) {
            return null; // Caller: if (result != null) { ... }
        }
        return List.of("Alice", "Bob");
    }

    // GOOD -- callers can always iterate safely
    public static List getNamesGood(boolean hasData) {
        if (!hasData) {
            return Collections.emptyList(); // Or List.of()
        }
        return List.of("Alice", "Bob");
    }

    public static void main(String[] args) {
        // No null check needed -- this is always safe
        for (String name : getNamesGood(false)) {
            System.out.println(name); // Simply doesn't execute
        }
    }
}

6. Use Streams for Transformations, Loops for Side Effects

import java.util.List;
import java.util.stream.Collectors;

public class StreamVsLoopExample {
    public static void main(String[] args) {
        List names = List.of("alice", "bob", "charlie", "diana");

        // GOOD -- Stream for transformation (creating a new collection)
        List uppercased = names.stream()
            .filter(name -> name.length() > 3)
            .map(String::toUpperCase)
            .collect(Collectors.toList());
        System.out.println("Filtered + uppercased: " + uppercased);
        // Output: Filtered + uppercased: [ALICE, CHARLIE, DIANA]

        // GOOD -- Loop for side effects (printing, saving to DB, etc.)
        for (String name : uppercased) {
            System.out.println("Processing: " + name);
        }
    }
}

Summary of Best Practices

# Practice Example
1 Program to interfaces List not ArrayList
2 Use diamond operator new HashMap<>() not new HashMap()
3 Use isEmpty() list.isEmpty() not list.size() == 0
4 Pre-size when possible new ArrayList<>(expectedSize)
5 Return empty, not null Collections.emptyList() or List.of()
6 Use List.of() / Map.of() (Java 9+) For immutable small collections
7 Always override hashCode() with equals() Required for hash-based collections
8 Use immutable keys in Maps String, Integer, records
9 Prefer ConcurrentHashMap over synchronized wrappers For thread-safe maps
10 Use removeIf() instead of Iterator for conditional removal list.removeIf(x -> x < 0)

14. Complete Practical Example -- Contact Manager

Let us put everything together in a realistic example. This ContactManager application demonstrates how multiple collection types work together in a real-world scenario:

  • ArrayList -- stores contacts in insertion order
  • HashMap -- fast lookup by email
  • TreeSet -- maintains sorted unique tags
  • Comparator -- custom sorting
  • Iteration -- multiple iteration patterns
  • Stream operations -- filtering and transforming data
import java.util.*;
import java.util.stream.Collectors;

public class ContactManager {

    // --- Contact Record ---
    static class Contact implements Comparable {
        private final String name;
        private final String email;
        private final String phone;
        private final Set tags; // TreeSet for sorted tags

        public Contact(String name, String email, String phone, String... tags) {
            this.name = name;
            this.email = email;
            this.phone = phone;
            this.tags = new TreeSet<>(Arrays.asList(tags));
        }

        public String getName() { return name; }
        public String getEmail() { return email; }
        public String getPhone() { return phone; }
        public Set getTags() { return Collections.unmodifiableSet(tags); }

        public void addTag(String tag) { tags.add(tag); }
        public void removeTag(String tag) { tags.remove(tag); }

        @Override
        public int compareTo(Contact other) {
            return this.name.compareToIgnoreCase(other.name); // Natural ordering by name
        }

        @Override
        public boolean equals(Object o) {
            if (this == o) return true;
            if (!(o instanceof Contact)) return false;
            return email.equalsIgnoreCase(((Contact) o).email); // Email is unique identifier
        }

        @Override
        public int hashCode() {
            return Objects.hash(email.toLowerCase());
        }

        @Override
        public String toString() {
            return String.format("%-15s %-25s %-15s %s", name, email, phone, tags);
        }
    }

    // --- Contact Manager ---
    private final List contacts;          // Ordered list of all contacts
    private final Map emailIndex; // Fast lookup by email
    private final Set allTags;             // All unique tags across contacts

    public ContactManager() {
        this.contacts = new ArrayList<>();
        this.emailIndex = new HashMap<>();
        this.allTags = new TreeSet<>();
    }

    // Add a contact
    public boolean addContact(Contact contact) {
        if (emailIndex.containsKey(contact.getEmail().toLowerCase())) {
            System.out.println("  Contact with email " + contact.getEmail() + " already exists.");
            return false;
        }
        contacts.add(contact);
        emailIndex.put(contact.getEmail().toLowerCase(), contact);
        allTags.addAll(contact.getTags());
        return true;
    }

    // Find by email -- O(1) using HashMap
    public Contact findByEmail(String email) {
        return emailIndex.get(email.toLowerCase());
    }

    // Find by tag -- filter contacts that have a specific tag
    public List findByTag(String tag) {
        return contacts.stream()
            .filter(c -> c.getTags().contains(tag))
            .collect(Collectors.toList());
    }

    // Remove a contact
    public boolean removeContact(String email) {
        Contact contact = emailIndex.remove(email.toLowerCase());
        if (contact != null) {
            contacts.remove(contact);
            rebuildTagIndex();
            return true;
        }
        return false;
    }

    // Get all contacts sorted by name
    public List getContactsSortedByName() {
        List sorted = new ArrayList<>(contacts);
        Collections.sort(sorted); // Uses Comparable (by name)
        return sorted;
    }

    // Get all contacts sorted by custom criteria
    public List getContactsSorted(Comparator comparator) {
        List sorted = new ArrayList<>(contacts);
        sorted.sort(comparator);
        return sorted;
    }

    // Get all unique tags
    public Set getAllTags() {
        return Collections.unmodifiableSet(allTags);
    }

    // Get contact count per tag
    public Map getTagCounts() {
        return contacts.stream()
            .flatMap(c -> c.getTags().stream())
            .collect(Collectors.groupingBy(tag -> tag, TreeMap::new, Collectors.counting()));
    }

    // Rebuild tag index after removal
    private void rebuildTagIndex() {
        allTags.clear();
        contacts.forEach(c -> allTags.addAll(c.getTags()));
    }

    public int size() { return contacts.size(); }

    // --- Print all contacts ---
    public void printAll() {
        System.out.printf("%-15s %-25s %-15s %s%n", "NAME", "EMAIL", "PHONE", "TAGS");
        System.out.println("-".repeat(75));
        for (Contact c : contacts) {
            System.out.println(c);
        }
    }

    // --- Main: Demonstration ---
    public static void main(String[] args) {
        ContactManager manager = new ContactManager();

        // 1. ADD CONTACTS
        System.out.println("=== Adding Contacts ===");
        manager.addContact(new Contact("Alice Smith",   "alice@example.com",   "555-0101", "friend", "work"));
        manager.addContact(new Contact("Bob Johnson",   "bob@example.com",     "555-0102", "work", "engineering"));
        manager.addContact(new Contact("Charlie Brown", "charlie@example.com", "555-0103", "friend", "school"));
        manager.addContact(new Contact("Diana Prince",  "diana@example.com",   "555-0104", "work", "engineering", "lead"));
        manager.addContact(new Contact("Eve Davis",     "eve@example.com",     "555-0105", "friend"));
        manager.addContact(new Contact("Alice Smith",   "alice@example.com",   "555-9999", "duplicate"));
        // Output: Contact with email alice@example.com already exists.

        System.out.println("\nTotal contacts: " + manager.size());
        // Output: Total contacts: 5

        // 2. DISPLAY ALL CONTACTS
        System.out.println("\n=== All Contacts ===");
        manager.printAll();

        // 3. FAST LOOKUP BY EMAIL (HashMap -- O(1))
        System.out.println("\n=== Lookup by Email ===");
        Contact found = manager.findByEmail("diana@example.com");
        System.out.println("Found: " + found);

        Contact notFound = manager.findByEmail("nobody@example.com");
        System.out.println("Not found: " + notFound); // Output: Not found: null

        // 4. SEARCH BY TAG
        System.out.println("\n=== Contacts Tagged 'engineering' ===");
        List engineers = manager.findByTag("engineering");
        engineers.forEach(c -> System.out.println("  " + c.getName() + " - " + c.getEmail()));
        // Output:
        //   Bob Johnson - bob@example.com
        //   Diana Prince - diana@example.com

        // 5. ALL UNIQUE TAGS (TreeSet -- sorted)
        System.out.println("\n=== All Tags (sorted) ===");
        System.out.println(manager.getAllTags());
        // Output: [engineering, friend, lead, school, work]

        // 6. TAG FREQUENCY (TreeMap -- sorted by tag name)
        System.out.println("\n=== Tag Counts ===");
        manager.getTagCounts().forEach((tag, count) ->
            System.out.println("  " + tag + ": " + count)
        );
        // Output:
        //   engineering: 2
        //   friend: 3
        //   lead: 1
        //   school: 1
        //   work: 3

        // 7. SORTING -- by name (natural ordering via Comparable)
        System.out.println("\n=== Sorted by Name ===");
        manager.getContactsSortedByName().forEach(c ->
            System.out.println("  " + c.getName())
        );
        // Output: Alice Smith, Bob Johnson, Charlie Brown, Diana Prince, Eve Davis

        // 8. SORTING -- by email (custom Comparator)
        System.out.println("\n=== Sorted by Email ===");
        manager.getContactsSorted(Comparator.comparing(Contact::getEmail)).forEach(c ->
            System.out.println("  " + c.getEmail())
        );

        // 9. SORTING -- by number of tags (descending), then by name
        System.out.println("\n=== Sorted by Tag Count (desc), then Name ===");
        manager.getContactsSorted(
            Comparator.comparingInt((Contact c) -> c.getTags().size())
                      .reversed()
                      .thenComparing(Contact::getName, String.CASE_INSENSITIVE_ORDER)
        ).forEach(c ->
            System.out.println("  " + c.getName() + " (" + c.getTags().size() + " tags) " + c.getTags())
        );
        // Diana Prince (3 tags), Alice Smith (2 tags), Bob Johnson (2 tags), ...

        // 10. REMOVE A CONTACT
        System.out.println("\n=== Removing Charlie ===");
        boolean removed = manager.removeContact("charlie@example.com");
        System.out.println("Removed: " + removed); // Output: Removed: true
        System.out.println("Total after removal: " + manager.size()); // Output: 4
        System.out.println("Tags after removal: " + manager.getAllTags());
        // "school" tag is gone because Charlie was the only one with it

        // 11. ITERATING WITH INDEX (traditional for loop)
        System.out.println("\n=== Final Contact List with Index ===");
        List sorted = manager.getContactsSortedByName();
        for (int i = 0; i < sorted.size(); i++) {
            System.out.println("  " + (i + 1) + ". " + sorted.get(i).getName());
        }
        // Output:
        //   1. Alice Smith
        //   2. Bob Johnson
        //   3. Diana Prince
        //   4. Eve Davis
    }
}

Summary

The Java Collections Framework is one of the most important parts of the Java standard library. Here is a recap of what we covered:

Topic Key Takeaway
Collections Framework A unified architecture of interfaces, implementations, and algorithms for managing groups of objects
List (ArrayList, LinkedList) Ordered, allows duplicates, index-based access. ArrayList for 95% of cases.
Set (HashSet, LinkedHashSet, TreeSet) No duplicates. HashSet for speed, TreeSet for sorted order.
Map (HashMap, LinkedHashMap, TreeMap) Key-value pairs. HashMap for general use, TreeMap for sorted keys.
Queue/Deque (PriorityQueue, ArrayDeque) FIFO/LIFO processing. ArrayDeque for stacks and queues.
Iteration For-each for reading, Iterator for safe removal, removeIf() for conditional removal, forEach+lambda for concise operations.
Sorting Comparable for natural ordering, Comparator for custom. Use Comparator.comparing() chains (Java 8+).
Collections utility class sort, reverse, shuffle, unmodifiableList, emptyList, frequency, binarySearch, and more.
Thread safety Use ConcurrentHashMap, not Hashtable or synchronizedMap. CopyOnWriteArrayList for read-heavy lists.
Best practices Program to interfaces, use generics, return empty collections (not null), pre-size when possible, override hashCode with equals.

Master these collections and their trade-offs, and you will be well-equipped to choose the right data structure for any situation you encounter in real-world Java development.




Subscribe To Our Newsletter
You will receive our latest post and tutorial.
Thank you for subscribing!

required
required


Leave a Reply

Your email address will not be published. Required fields are marked *