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:
List, Set, Map)ArrayList, HashSet, HashMap)Collections utility class for sorting, searching, and transforming collectionsA 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.
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.
A List is an ordered collection (also called a sequence). Lists allow:
The List interface defines operations like get(index), set(index, element), add(index, element), remove(index), indexOf(element), and subList(fromIndex, toIndex).
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]
}
}
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
}
}
| 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.
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().
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:
add(), remove(), contains()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);
}
}
}
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
}
}
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.
add(), remove(), contains()Comparable interface) OR you must provide a Comparator at construction timeNullPointerException — 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]
}
}
| 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 |
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.
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.
put(), get(), containsKey(), remove()ConcurrentHashMap for concurrent accessimport 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)
);
}
}
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
}
}
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.
put(), get(), remove()Comparator must be providedimport 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}
}
}
Hashtable is a legacy class from Java 1.0 (before the Collections Framework existed). It is similar to HashMap but with two important differences:
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.
| 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 |
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.
| 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.
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
}
}
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).
push, pop, offer, poll, peekimport 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]
}
}
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.
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.
Listnames = List.of("Alice", "Bob", "Charlie"); // For-each loop -- clean and simple for (String name : names) { System.out.println(name); } // Output: // Alice // Bob // Charlie
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]
}
}
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
}
}
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
}
}
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]
}
}
| 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 |
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).
A class implements Comparable to define its natural ordering. The compareTo() method returns:
this is less than the otherthis is greater than the otherBuilt-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 } }
A Comparator is an external comparison strategy. Use it when:
ComparableJava 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]
}
}
| 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).
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 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);
}
}
Choosing the wrong collection is a common source of performance bugs and unnecessary complexity. Use this decision guide:
| 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 |
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)
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.
| 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);
}
}
}
Even experienced developers fall into these traps. Understanding them will save you hours of debugging.
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!)
}
}
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]
}
}
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);
}
}
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.)
}
}
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]
}
}
Follow these guidelines to write clean, maintainable, and performant collection code:
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 ArrayListnames = 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
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<>();
Listitems = 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()) { /* ... */ }
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 Listnames = 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);
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
}
}
}
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);
}
}
}
| # | 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) |
Let us put everything together in a realistic example. This ContactManager application demonstrates how multiple collection types work together in a real-world scenario:
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
}
}
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.