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
Collectionsutility 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<String> 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<String> 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<String> 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<String> 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<String> 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<String> 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
Comparableinterface) OR you must provide aComparatorat 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<Integer> 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
ConcurrentHashMapfor concurrent access
import java.util.HashMap;
import java.util.Map;
public class HashMapExample {
public static void main(String[] args) {
Map<String, Integer> 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<String, Integer> 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<String, String> 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<String, String> lruCache = new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, String> 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
Comparatormust 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<String, Double> 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<String, Double> 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<Integer> 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<Integer> 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<Task> 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<String> 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<String> 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<String> names = List.of("Alice", "Bob", "Charlie");
// For-each loop -- clean and simple
for (String name : names) {
System.out.println(name);
}
// Output:
// Alice
// Bob
// Charlie7.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<Integer> numbers = new ArrayList<>(List.of(1, 2, 3, 4, 5, 6, 7, 8));
// Remove all even numbers using Iterator
Iterator<Integer> 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<String> items = new ArrayList<>(List.of("apple", "banana", "cherry"));
ListIterator<String> 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<String> fruits = List.of("Apple", "Banana", "Cherry");
fruits.forEach(fruit -> System.out.println("Fruit: " + fruit));
// Map.forEach() -- provides both key and value
Map<String, Integer> 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<String> 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<String> 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<String> names3 = new ArrayList<>(List.of("Alice", "Bob", "Charlie", "David"));
List<String> 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
thisis less than the other - Zero if equal
- Positive if
thisis 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<Employee> {
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<Employee> 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<Employee> 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<String> 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<Integer> 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<String> 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<Integer> 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<String> mutable = new ArrayList<>(List.of("A", "B", "C"));
List<String> readOnly = Collections.unmodifiableList(mutable);
// readOnly.add("D"); // Throws UnsupportedOperationException!
System.out.println("Read-only: " + readOnly);
// --- Empty collections (immutable) ---
List<String> empty = Collections.emptyList();
System.out.println("Empty list: " + empty); // Output: Empty list: []
// empty.add("X"); // Throws UnsupportedOperationException!
// --- Singleton (immutable single-element collection) ---
List<String> single = Collections.singletonList("OnlyOne");
System.out.println("Singleton: " + single); // Output: Singleton: [OnlyOne]
// --- Fill ---
List<String> 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<String> 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<String> 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<String> colors = List.of("Red", "Green", "Blue");
System.out.println("Colors: " + colors);
// colors.add("Yellow"); // UnsupportedOperationException!
// Set.of() -- immutable set (no duplicates allowed)
Set<Integer> primes = Set.of(2, 3, 5, 7, 11);
System.out.println("Primes: " + primes);
// Map.of() -- immutable map (up to 10 entries)
Map<String, Integer> scores = Map.of(
"Alice", 95,
"Bob", 87,
"Charlie", 92
);
System.out.println("Scores: " + scores);
// Map.ofEntries() -- immutable map (any number of entries)
Map<String, String> 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<String> 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<String, Integer> 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<String> 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<BadProduct> 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<GoodProduct> 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<Integer> 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<String> typedList = new ArrayList<>();
typedList.add("Hello");
// typedList.add(42); // Compile error! Cannot add Integer to List<String>
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<MutableKey, String> 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<String> 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<String> 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<String> names = new ArrayList<>();
HashMap<String, Integer> scores = new HashMap<>();
// GOOD -- programmed to the interface
List<String> names = new ArrayList<>(); // Can easily swap to LinkedList
Map<String, Integer> scores = new HashMap<>(); // Can easily swap to TreeMap
Set<String> tags = new HashSet<>(); // Can easily swap to LinkedHashSet2. 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<String, List<Integer>> map = new HashMap<String, List<Integer>>();
// GOOD -- diamond operator infers the type
Map<String, List<Integer>> map = new HashMap<>();3. Prefer isEmpty() Over size() == 0
List<String> 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<String> names = new ArrayList<>();
// GOOD -- pre-sized to avoid resizing
List<String> names = new ArrayList<>(1000);
// For HashMap, account for load factor (default 0.75)
// To hold 100 entries without resize: 100 / 0.75 = 134
Map<String, String> 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<String> 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<String> 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<String> names = List.of("alice", "bob", "charlie", "diana");
// GOOD -- Stream for transformation (creating a new collection)
List<String> 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<Contact> {
private final String name;
private final String email;
private final String phone;
private final Set<String> 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<String> 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<Contact> contacts; // Ordered list of all contacts
private final Map<String, Contact> emailIndex; // Fast lookup by email
private final Set<String> 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<Contact> 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<Contact> getContactsSortedByName() {
List<Contact> sorted = new ArrayList<>(contacts);
Collections.sort(sorted); // Uses Comparable (by name)
return sorted;
}
// Get all contacts sorted by custom criteria
public List<Contact> getContactsSorted(Comparator<Contact> comparator) {
List<Contact> sorted = new ArrayList<>(contacts);
sorted.sort(comparator);
return sorted;
}
// Get all unique tags
public Set<String> getAllTags() {
return Collections.unmodifiableSet(allTags);
}
// Get contact count per tag
public Map<String, Long> 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<Contact> 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<Contact> 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.