The ArrayCatalog you closed the previous lesson with works, but three lines out of four are infrastructure: a counter n running parallel to length, an Arrays.copyOf to double the capacity, a System.arraycopy to close gaps, a manual null to avoid leaking memory. None of those lines talks about libraries, loans or employees. And even with all that effort, the catalogue still cannot guarantee there are no repeated ISBNs without walking it end to end, nor find a material by its reference in less than O(n).
The Java Collections Framework is the JDK's answer to that problem, and it is one of the finest pieces of design on the platform. It is not "a few useful classes": it is an architecture of interfaces, implementations and algorithms that has been the common vocabulary of every Java programmer for twenty-five years. Learning it properly means two things. First, that you will stop writing infrastructure. Second, and more important, that you will learn to choose the right data structure, which is one of the decisions with the greatest impact on the performance and the clarity of a real program.
This lesson is the map. It does not go deep into any implementation —that is the next seven lessons— but gives you the aerial view: which interfaces exist and how they relate, which implementation to choose for each problem, how traversal works inside and which operations all collections share. The master table in section 4 is the heart of the whole module; you will come back to it again and again.
Contents
- What the Collections Framework is
- The interface hierarchy
- Declare by the interface, instantiate the implementation
- The master table of implementations
- Generics in collections
Iterable,Iteratorand thefor-eachfrom the insideConcurrentModificationException: fail-fast explained- The common methods of
Collection - Immutable and convenience collections
- How to choose the right collection
- BiblioTech's first refactoring
- Common Mistakes and Tips
- Exercises
- What the Collections Framework is
A collection is an object that groups several elements and knows how to manage them: add, remove, search, count, traverse. Compared with the array, a collection has three decisive virtues:
- It grows and shrinks on its own. There is no capacity to administer.
- It has semantics. A
Setguarantees there are no duplicates; aQueueguarantees FIFO order. The array guarantees nothing: it only stores. - It is interchangeable. Since they all implement the same interfaces, swapping
ArrayListforLinkedListis a one-word change.
The Framework is organised into three layers that are worth distinguishing from the start:
| Layer | What it is | Examples |
|---|---|---|
| Interfaces | The contract: which operations a kind of collection offers | Collection, List, Set, Queue, Deque, Map |
| Implementations | The how: the concrete data structure | ArrayList, LinkedList, HashSet, TreeMap, ArrayDeque |
| Algorithms | Generic operations over any implementation | Collections.sort, Collections.max, Collections.shuffle |
That separation is the direct application of everything you learned in module 4. List is an interface in the exact sense of 04-01: a contract that says what can be done without saying how. ArrayList and LinkedList are two different implementations of the same contract, and the polymorphism of 03-06 lets you swap them without touching the code that uses them. The Collections Framework is probably the best example of interface-oriented design you are going to find.
Everything lives in java.util, so almost any class you write will start with one of these imports:
- The interface hierarchy
This is the structure you need burned into memory:
classDiagram
Iterable <|-- Collection
Collection <|-- List
Collection <|-- Set
Collection <|-- Queue
Set <|-- SortedSet
SortedSet <|-- NavigableSet
Queue <|-- Deque
class Iterable {
<<interface>>
+iterator() Iterator
+forEach(Consumer)
}
class Collection {
<<interface>>
+add(e) boolean
+remove(o) boolean
+contains(o) boolean
+size() int
+isEmpty() boolean
}
class List {
<<interface>>
ORDER by position
DUPLICATES allowed
+get(int)
+set(int, e)
+indexOf(o)
}
class Set {
<<interface>>
NO duplicates
+add() returns false if already there
}
class Queue {
<<interface>>
PROCESSING order (FIFO)
+offer(e)
+poll()
+peek()
}
class Deque {
<<interface>>
Double ended: queue AND stack
+addFirst(e)
+addLast(e)
}
And apart from that, with no inheritance relationship to the above:
classDiagram
Map <|-- SortedMap
SortedMap <|-- NavigableMap
class Map {
<<interface>>
Key to value pairs
UNIQUE keys
+put(k, v)
+get(k)
+containsKey(k)
+keySet()
+values()
+entrySet()
}
Read the two hierarchies like this, top to bottom:
Iterableis the most general interface of all and promises only one thing: "I can give you an iterator to walk me one by one". Anything that isIterableworks withfor-each. Notice that an array is notIterable—it is not a class, it implements nothing—; thefor-eachover arrays works because the compiler treats it as a special case (you will see it in section 6).Collectionadds the basic "group of elements" vocabulary:add,remove,contains,size,isEmpty,clear. It is the minimum common to lists, sets and queues.List: elements ordered by position, with an index and duplicates allowed. It is the natural equivalent of the array.Set: no duplicates, no access by index. It models the mathematical concept of a set.Queue: elements in a processing order, normally FIFO. You add at one end and consume at the other.Deque: a double-ended queue; it serves as both a queue and a stack.
Why Map is not a Collection
It is the question everybody asks the first time, and it has a precise answer: a Collection stores loose elements; a Map stores key→value pairs. The methods of Collection would make no sense on a map:
- What would
map.add(x)do? Isxa key, a value, a pair? - What would
map.iterator()return? Keys, values or pairs? - What would
map.contains(x)check? The map needs two different questions:containsKeyandcontainsValue.
Forcing Map extends Collection would have produced an interface full of ambiguous or unsupported methods. The design solution is cleaner: Map is an independent hierarchy, and it offers three views that are collections:
Map<String, Material> index = new HashMap<>();
Set<String> keys = index.keySet(); // the keys, no duplicates: a Set
Collection<Material> values = index.values(); // the values, may repeat
Set<Map.Entry<String, Material>> pairs = index.entrySet(); // the pairsAnd those three views are Iterable, so they can indeed be walked with for-each. It is the best of both worlds: Map keeps its own coherent API and stays connected to the rest of the Framework through its views. You will see it in detail in 05-05.
- Declare by the interface, instantiate the implementation
This is the golden rule of the Framework, and probably the most profitable professional habit in this lesson:
List<Book> catalog = new ArrayList<>(); // CORRECT
ArrayList<Book> bad = new ArrayList<>(); // works, but it is worse designTo the left of the =, the most general type that covers what you need (the interface). To the right, the concrete implementation. The reasons:
1. You can change implementation by touching one word. If tomorrow you find you need queue behaviour, you change new ArrayList<>() for new LinkedList<>() and nothing else in the program notices. If you had declared ArrayList<Book>, you have to revise every declaration, every parameter and every return type.
2. Your method signatures become flexible. Compare:
public void process(ArrayList<Book> books) { ... } // only accepts ArrayList
public void process(List<Book> books) { ... } // accepts ArrayList, LinkedList, List.of(...)
public void process(Collection<Book> books) { ... } // also accepts Set and Queue
public void process(Iterable<Book> books) { ... } // accepts ANYTHING traversableThe practical rule for signatures: ask for the most general type that serves you. If you only traverse, Iterable or Collection. If you need indices, List. When returning, on the other hand, it pays to be a little more specific so as not to hide useful guarantees (if you return something without duplicates, declare it as a Set).
3. You communicate the intent, not the mechanism. List<Book> says "an ordered sequence with possible repetition". ArrayList<Book> also says "implemented on top of an array", an internal detail the caller does not care about.
The exceptions are few and explicit: when you need a method that exists only in the implementation. ArrayList has trimToSize(), LinkedList has addFirst() inherited from Deque. In those cases, declare by the type that offers the method you actually use:
Deque<Reservation> reservations = new ArrayDeque<>(); // Deque, because you will use addLast/pollFirstAnd a note on the diamond operator <>: since Java 7, the type on the right-hand side is inferred from the left, so new ArrayList<Book>() is written new ArrayList<>(). Always write it that way.
- The master table of implementations
This table is the heart of the module. Each row is a lesson or a section of the next seven, but having them together gives you something no isolated lesson does: a criterion for choosing.
| Implementation | Interface | Order | Duplicates | null |
Access by index/key | Add | Remove | Search | When to choose it |
|---|---|---|---|---|---|---|---|---|---|
ArrayList |
List |
Insertion | Yes | Yes | O(1) | O(1)* at the end | O(n) | O(n) | The default list. 90% of cases |
LinkedList |
List, Deque |
Insertion | Yes | Yes | O(n) | O(1) at the ends | O(1) with an iterator | O(n) | Queue/stack; massive insertion at the ends |
HashSet |
Set |
None | No | One null |
— | O(1) | O(1) | O(1) | The default set: uniqueness and membership |
LinkedHashSet |
Set |
Insertion | No | One null |
— | O(1) | O(1) | O(1) | Like HashSet but with reproducible order |
TreeSet |
NavigableSet |
Sorted | No | No | — | O(log n) | O(log n) | O(log n) | You need the elements always sorted, or ranges |
HashMap |
Map |
None | Keys no | 1 key, N values | O(1) | O(1) | O(1) | O(1) | The default map: key→value index |
LinkedHashMap |
Map |
Insertion or access | Keys no | Yes | O(1) | O(1) | O(1) | O(1) | Reproducible order; LRU cache |
TreeMap |
NavigableMap |
Sorted keys | Keys no | Key no | O(log n) | O(log n) | O(log n) | O(log n) | Sorted keys, ranges, firstKey/floorKey |
ArrayDeque |
Deque, Queue |
Insertion | Yes | No | — | O(1) at the ends | O(1) at the ends | O(n) | The default queue and stack |
PriorityQueue |
Queue |
By priority | Yes | No | — | O(log n) | O(log n) for the head | O(n) | Always process "the most urgent" first |
* Amortised: almost always O(1), but when the internal array fills up there is an O(n) copy. You will see why the average is still O(1) in 05-03.
Read the table by columns and the patterns that really matter will come out:
- The "Order" column has three values. None means the traversal order is unpredictable and can change between runs or when elements are added: never depend on it. Insertion means it is walked in the order you put the elements in. Sorted means by
ComparableorComparator(05-09). - The "O(1) versus O(log n)" column sums up the difference between hash structures (
HashXxx) and tree ones (TreeXxx): the hash is faster, the tree keeps the order. Choosing means deciding whether you need that order. - The "
null" column is a huge source of unexpectedNullPointerExceptions. The "modern" structures (ArrayDeque,PriorityQueue) reject them on purpose, because they usenullas an internal "empty" signal. The tree ones reject them becausenullcannot be compared withcompareTo. - The bold rows in "when to choose it" are the four default answers:
ArrayList,HashSet,HashMap,ArrayDeque. Always start with one of them and change only if you have a concrete reason.
- Generics in collections
All collections are generic: they carry between angle brackets the type of element they hold.
List<Book> catalog = new ArrayList<>();
Set<String> seenIsbns = new HashSet<>();
Map<String, Material> index = new HashMap<>();For now, read it like this: <...> is the type of what is inside. List<Book> is "a list of books". Map<String, Material> is "a map whose keys are strings and whose values are materials". Nothing more.
What you gain is compile-time safety:
List<Book> catalog = new ArrayList<>();
catalog.add(new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018));
// catalog.add("Effective Java"); // COMPILATION ERROR, and that is a good thing
Book first = catalog.get(0); // no cast: the compiler already knows it is a BookBefore Java 5 collections stored Object, and every read required a cast that could fail at run time:
List oldList = new ArrayList(); // "raw type": NEVER write it
oldList.add(new Book(...));
oldList.add("just any old string"); // compiles without complaint
Book b = (Book) oldList.get(1); // ClassCastException at RUN TIMEGenerics move that error from run time to compile time, which is exactly where you want your errors. Never use raw types (without <>); the compiler will warn you with an unchecked warning and it will be right.
Autoboxing and its cost
Generics only accept reference types, not primitives. List<int> does not compile; you have to write List<Integer>. The autoboxing of 01-04 does the conversion automatically and the code looks natural:
List<Integer> daysLate = new ArrayList<>();
daysLate.add(5); // autoboxing: 5 -> Integer.valueOf(5)
int first = daysLate.get(0); // unboxing: Integer -> intBut the cost is real and worth knowing:
| Aspect | int[] data |
List<Integer> data |
|---|---|---|
| Memory per element | 4 bytes | ~16 bytes of object + 4-8 of reference |
| Cache locality | Excellent (contiguous) | Poor (scattered objects) |
| Cost of reading | Direct | Follow a reference + unboxing |
For a million integers, the difference is about 4 MB versus more than 20 MB, and several times slower to walk. In a business application it makes no difference; in intensive numerical computation, it does. The rule: use collections unless you work with lots of primitives and performance is critical, in which case the array wins.
There is also an Integer comparison trap you already saw in 01-05 and that reappears here:
Integer a = 127, b = 127;
Integer c = 128, d = 128;
System.out.println(a == b); // true (cache from -128 to 127)
System.out.println(c == d); // false (different objects)
System.out.println(c.equals(d)); // true <- ALWAYS equalsThe full theory of generics —your own type parameters (class Box<T>), wildcards (? extends, ? super), bounds (<T extends Comparable<T>>) and the type erasure that explains why List<int> is impossible— is lesson 10-01. In this module it is enough for you to use the existing generics.
Iterable, Iterator and the for-each from the inside
Iterable, Iterator and the for-each from the insideThe for-each you learned in 05-01 is not magic: it is syntactic sugar. The compiler translates it into something else, and knowing what will explain several behaviours at once that otherwise look arbitrary.
Over an array, the compiler generates a classic for:
for (Material m : catalog) { System.out.println(m.getTitle()); }
// the compiler generates something equivalent to:
for (int i = 0; i < catalog.length; i++) {
Material m = catalog[i];
System.out.println(m.getTitle());
}Over an Iterable (any collection), it generates a loop with an iterator:
for (Material m : catalogList) { System.out.println(m.getTitle()); }
// the compiler generates something equivalent to:
Iterator<Material> it = catalogList.iterator();
while (it.hasNext()) {
Material m = it.next();
System.out.println(m.getTitle());
}An Iterator is an object that represents a position within a traversal and offers three methods:
| Method | What it does |
|---|---|
boolean hasNext() |
Is there any element left to visit? |
E next() |
Returns the next one and advances. If there are none left, NoSuchElementException |
void remove() |
Removes the last element returned by next(), safely |
The design is a beautiful application of the principle from 04-01: Iterator is a contract that decouples the traversal from the structure. An ArrayList implements it by moving an index; a LinkedList, by jumping from node to node; a HashSet, by walking buckets. Your loop is unaware of any of those differences.
flowchart LR
A["The for-each starts"] --> B["collection.iterator()"]
B --> C{"it.hasNext()"}
C -- yes --> D["e = it.next()"]
D --> E["loop body"]
E --> C
C -- no --> F["End of the loop"]
You will not normally write iterators by hand. There is one important exception, and it is the reason they exist: removing elements while you traverse.
Iterator<Material> it = catalog.iterator();
while (it.hasNext()) {
Material m = it.next();
if (!m.isAvailable()) {
it.remove(); // safe: the collection knows it was the iterator
}
}And, since Iterable is the most general interface of all, a method that only traverses can accept it and work with any data source:
public static double sumRates(Iterable<Material> materials) {
double total = 0;
for (Material m : materials) { total += m.getDailyRate(); }
return total;
}
// valid for List, Set, Queue, TreeSet, keySet()... for anything traversableIterable also offers forEach, which takes one of the Consumers you mastered in 04-06:
catalog.forEach(m -> System.out.println(m.describe()));
catalog.forEach(System.out::println); // method referenceIt is equivalent to a for-each and sometimes more readable, especially when you already have the method reference written. And in module 10 you will see that stream() opens a third, far more expressive way to filter, transform and group in a single expression; in this module we will always traverse with for-each, Iterator and the collections' own methods.
ConcurrentModificationException: fail-fast explained
ConcurrentModificationException: fail-fast explainedNow, the error that generates more internet searches than anything else in the Framework:
List<Material> catalog = new ArrayList<>(...);
for (Material m : catalog) {
if (!m.isAvailable()) {
catalog.remove(m); // ConcurrentModificationException
}
}The name is misleading: no concurrent thread is needed. "Concurrent" here means "during the traversal". What happens is this.
Every collection carries an internal counter called modCount (modification count) that is incremented every time its structure changes: every add, every remove, every clear. When you ask for an iterator, it saves a copy of the current modCount. And on every next(), the iterator compares:
// inside ArrayList's iterator, simplified:
final void checkForComodification() {
if (modCount != expectedModCount) {
throw new ConcurrentModificationException();
}
}If the collection changed behind the iterator's back, the two counters differ and the iterator refuses to continue.
flowchart TD
A["catalog.iterator()<br/>expectedModCount = modCount = 3"] --> B["it.next() → returns m0"]
B --> C["catalog.remove(m0)<br/>modCount becomes 4"]
C --> D["it.next()"]
D --> E{"modCount 4<br/>differs from<br/>expectedModCount 3"}
E -- yes --> F["ConcurrentModificationException"]
This policy is called fail-fast: fail early and loudly. And it is a virtue, not a whim. If the iterator carried on, the traversal would fall out of sync and the result would be worse than an exception: elements silently skipped. Check it with this example, which does not throw an exception and is still wrong:
List<String> names = new ArrayList<>(List.of("Marta Ruiz", "Diego Alonso", "Nuria Vidal"));
for (String n : names) {
if (n.startsWith("Marta")) { names.remove(n); }
}
System.out.println(names); // [Diego Alonso, Nuria Vidal] ... and no exceptionWhen you remove element 0 from a list of 3, the size drops to 2 and the iterator's internal index moves to 1, so hasNext() (which compares 1 != 2) gives true, "Nuria Vidal" is returned, the index moves to 2 and hasNext() gives false: the loop ends early and "Diego Alonso" is never visited. If the element to remove is the second to last, the traversal ends without an exception and without having looked at the last one. That is the scenario fail-fast tries to prevent; the fact that it sometimes does not fire is an accident of the counting, not a guarantee.
The four solutions
1. Iterator.remove() — the classic solution, valid in any version of Java:
Iterator<Material> it = catalog.iterator();
while (it.hasNext()) {
if (!it.next().isAvailable()) {
it.remove(); // the iterator UPDATES its expectedModCount
}
}2. removeIf(Predicate) — since Java 8, the solution preferred for readability:
One line, no visible iterator, and with the Predicate from 04-06. It is what you should write by default.
3. Traverse a copy — when you need to modify the original while reading it whole:
for (Material m : new ArrayList<>(catalog)) { // you traverse the COPY
if (!m.isAvailable()) { catalog.remove(m); } // you modify the ORIGINAL
}It costs memory (it duplicates the list) but it is indispensable when the operation inside the loop can add and remove elements.
4. Traverse backwards with indices — useful if you need the position:
for (int i = catalog.size() - 1; i >= 0; i--) {
if (!catalog.get(i).isAvailable()) { catalog.remove(i); }
}Backwards, because when you remove element i the following ones shift; going in reverse, the ones already visited do not move.
| Situation | Recommended solution |
|---|---|
| Removing according to a simple criterion | removeIf |
| Removing with complex logic or side effects | Iterator.remove() |
| Adding and removing during the traversal | Traverse a copy |
| You need the index of the removed element | classic for backwards |
| Modifying the state of the objects (not the collection) | a normal for-each: no problem |
That last row matters: for (Material m : catalog) { m.lend(); } is perfectly legal. modCount counts structural changes in the collection, not changes in the objects it holds.
- The common methods of
Collection
CollectionAnything that is a Collection —any List, Set or Queue— understands this vocabulary:
| Method | What it does | Returns |
|---|---|---|
add(E e) |
Adds the element | boolean: false if the collection rejects it (a duplicate in a Set) |
remove(Object o) |
Removes the first occurrence, using equals |
boolean: true if there was something to remove |
contains(Object o) |
Is it there?, using equals |
boolean |
size() |
How many elements there are | int |
isEmpty() |
Is it empty? | boolean |
clear() |
Empties the collection | void |
addAll(Collection c) |
Adds all of another's | boolean: whether anything changed |
removeAll(Collection c) |
Removes all those that are in the other | boolean |
retainAll(Collection c) |
Keeps only those that are in the other | boolean |
containsAll(Collection c) |
Are all of the other's there? | boolean |
forEach(Consumer) |
Applies an action to each element | void |
removeIf(Predicate) |
Removes those matching the criterion | boolean |
toArray(T[] a) |
Dumps into an array | T[] |
Two observations about the returned value that solve real problems:
add returns a boolean, and in a Set that boolean is worth gold. false means "it was already there", so detecting duplicates is one line:
Set<String> seenIsbns = new HashSet<>();
if (!seenIsbns.add(book.getIsbn())) {
System.out.println("WARNING: duplicate ISBN, registration rejected -> " + book.getIsbn());
}remove and contains use equals, not ==. That is why 03-09 insisted so much on implementing it properly: if your class does not override equals, contains will compare references and an "equal" but distinct object will never be found.
List<Card> cards = new ArrayList<>();
cards.add(new Card("Effective Java", "Joshua Bloch", 2018));
// it works because Card is a record: generated and correct equals
System.out.println(cards.contains(new Card("Effective Java", "Joshua Bloch", 2018))); // trueAnd toArray closes the circle with the previous lesson:
List<Material> list = new ArrayList<>(...);
Material[] array = list.toArray(new Material[0]); // the standard idiomThe new Material[0] is not a waste: it tells the method what type of array to create. Passing new Material[list.size()] is also valid and tends to be slightly slower on modern JVMs, odd as that sounds. Always use new Material[0].
- Immutable and convenience collections
Since Java 9 there are factory methods that create immutable collections in a single expression:
List<String> employees = List.of("Marta Ruiz", "Diego Alonso", "Nuria Vidal");
Set<String> types = Set.of("Book", "Magazine", "DVD");
Map<String, Integer> terms = Map.of("Book", 15, "Magazine", 7, "DVD", 3);Their properties, which have to be known in detail because they are surprising:
- Completely immutable: any
add,remove,setorclearthrowsUnsupportedOperationException. - They do not accept
null, neither as an element, nor as a key, nor as a value: they throwNullPointerExceptionon creation. Set.ofandMap.ofreject duplicates on construction, withIllegalArgumentException. It is deliberate: if you writeSet.of("a", "a")it is almost certainly a mistake of yours.Map.ofaccepts up to 10 pairs. For more,Map.ofEntries(Map.entry(k, v), ...).- Their traversal order is not guaranteed and can change between runs (in
Set.ofandMap.ofit is deliberately randomised so that nobody depends on it).List.ofdoes preserve the order, because a list is ordered by definition.
They are perfect for constants, fixed tables and configuration values:
public static final Set<String> LENDABLE_TYPES = Set.of("Book", "Magazine", "DVD");
public static final Map<String, Integer> DAYS_BY_TYPE = Map.of("Book", 15, "Magazine", 7, "DVD", 3);unmodifiableList and the difference that matters
Collections.unmodifiableList does something similar but not the same: it creates a read-only view over an existing list.
List<String> original = new ArrayList<>(List.of("Marta Ruiz", "Diego Alonso"));
List<String> view = Collections.unmodifiableList(original);
view.add("Nuria Vidal"); // UnsupportedOperationException, as you expected
original.add("Nuria Vidal"); // allowed... and the VIEW changes too
System.out.println(view); // [Marta Ruiz, Diego Alonso, Nuria Vidal]List.of(...) |
Collections.unmodifiableList(list) |
List.copyOf(list) |
|
|---|---|---|---|
| Can it be modified through its own API? | No | No | No |
| Does it change if somebody modifies the original? | There is no original | Yes | No: it is a copy |
Accepts null |
No | Yes (whichever were there) | No |
| Cost of creating it | O(n) | O(1): it copies nothing | O(n) |
For a genuine defensive copy in a getter (the pattern from 03-07), unmodifiableList is not enough if the caller can reach the original list. The correct thing is List.copyOf(list) or Collections.unmodifiableList(new ArrayList<>(list)).
And there are three more utilities for frequent cases:
List<Material> emptyList = Collections.emptyList(); // empty, immutable, free
List<String> one = Collections.singletonList("Marta Ruiz"); // exactly one element
Map<String, Integer> emptyMap = Collections.emptyMap();Their value lies in a design tip worth adopting right now: never return null where a collection is expected. Return an empty collection. The caller will be able to write for (Material m : catalog.find(...)) without checking anything, and a whole family of NullPointerExceptions disappears.
- How to choose the right collection
This tree resolves the vast majority of real decisions:
flowchart TD
A["I need to store several elements"] --> B{"Does each element have<br/>a KEY to look it up?"}
B -- yes --> C{"Do I need the keys<br/>in order?"}
C -- "no" --> C1["HashMap"]
C -- "insertion order" --> C2["LinkedHashMap"]
C -- "natural order or Comparator" --> C3["TreeMap"]
B -- no --> D{"Can there be<br/>DUPLICATES?"}
D -- no --> E{"Do I need order?"}
E -- "no" --> E1["HashSet"]
E -- "insertion order" --> E2["LinkedHashSet"]
E -- "sorted" --> E3["TreeSet"]
D -- yes --> F{"How do I reach<br/>the elements?"}
F -- "by position, I walk them all" --> F1["ArrayList"]
F -- "only at the ends" --> G{"The output order is...?"}
G -- "FIFO or LIFO" --> G1["ArrayDeque"]
G -- "by priority" --> G2["PriorityQueue"]
And in five questions, in case you prefer the list format:
- Do I search by key? →
Map. (HashMapunless you need order.) - Are duplicates an error? →
Set. (HashSetunless you need order.) - Do I need positions and indices? →
List. (ArrayListalmost always.) - Do I only add at one end and take from the other? →
Deque/Queue. (ArrayDeque.) - Do I always want to deal with the most urgent one first? →
PriorityQueue.
A warning about premature optimisation: for collections of tens or hundreds of elements, the performance difference between implementations is irrelevant. Choose by semantics: Set when duplicates are a conceptual error, Map when a key genuinely exists. A Set<String> of ISBNs documents that ISBNs are unique better than any comment. Performance matters from tens of thousands of elements upwards, and there the table in section 4 will give you the right answer.
- BiblioTech's first refactoring
Let us apply what we have learned to the ArrayCatalog of the previous lesson. Remember what it looked like:
public class ArrayCatalog {
private Material[] materials;
private int n;
public void add(Material m) {
if (m == null) { return; }
if (n == materials.length) {
materials = Arrays.copyOf(materials, materials.length * 2);
}
materials[n] = m;
n++;
}
public boolean remove(String reference) {
for (int i = 0; i < n; i++) {
if (materials[i].getReference().equals(reference)) {
System.arraycopy(materials, i + 1, materials, i, n - i - 1);
materials[n - 1] = null;
n--;
return true;
}
}
return false;
}
// ...
}And this is how it looks with collections:
package com.nexussoftware.bibliotech.service;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Comparator;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.function.Predicate;
import com.nexussoftware.bibliotech.domain.Material;
/** BiblioTech's catalogue, first version on top of the Collections Framework. */
public class Catalog {
// Declared by the INTERFACE, instantiated with the default implementation
private final List<Material> materials = new ArrayList<>();
private final Set<String> references = new HashSet<>(); // O(1) duplicate control
/** Adds if the reference was not there already. Returns false if it was a duplicate. */
public boolean add(Material m) {
if (m == null) { return false; }
if (!references.add(m.getReference())) { // add returns false if already there
return false;
}
materials.add(m); // no capacity, no counter, no copyOf
return true;
}
/** Removes by reference. No arraycopy and no nulls to clean up. */
public boolean remove(String reference) {
if (!references.remove(reference)) { return false; }
return materials.removeIf(m -> m.getReference().equals(reference));
}
/** Searches with any criterion, using the Predicate from 04-06. */
public List<Material> find(Predicate<Material> criteria) {
List<Material> result = new ArrayList<>();
for (Material m : materials) {
if (criteria.test(m)) { result.add(m); }
}
return result; // no copyOf(result, n): it is already the exact size
}
public int count(Predicate<Material> criteria) {
int n = 0;
for (Material m : materials) {
if (criteria.test(m)) { n++; }
}
return n;
}
/** Sorts the catalogue in place with any Comparator. */
public void sort(Comparator<Material> criteria) {
materials.sort(criteria); // a method of List's own since Java 8
}
/** Read-only view: nobody outside can alter the catalogue. */
public Collection<Material> list() {
return List.copyOf(materials);
}
public int size() { return materials.size(); }
public boolean isEmpty() { return materials.isEmpty(); }
}Usage:
Catalog catalog = new Catalog();
catalog.add(new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018));
catalog.add(new Book("Design Patterns", "Erich Gamma", "978-0000000002", 1994));
catalog.add(new Book("Refactoring", "Martin Fowler", "978-0000000003", 1999));
catalog.add(new Magazine("Java Magazine", "REV-2024-03", 42, "Monthly"));
if (!catalog.add(new Book("Effective Java (2nd ed.)", "Joshua Bloch", "978-0000000001", 2018))) {
System.out.println("WARNING: ISBN 978-0000000001 already catalogued. Registration rejected.");
}
catalog.sort(Comparator.comparing(Material::getTitle));
catalog.list().forEach(m -> System.out.println(" " + m.describe()));
System.out.println("Materials on loan: " + catalog.count(m -> !m.isAvailable()));WARNING: ISBN 978-0000000001 already catalogued. Registration rejected. [Book] Design Patterns - Erich Gamma (978-0000000002) [Book] Effective Java - Joshua Bloch (978-0000000001) [Magazine] Java Magazine no. 42 (REV-2024-03) [Book] Refactoring - Martin Fowler (978-0000000003) Materials on loan: 0
Count what has disappeared: the field n, the capacity check, the Arrays.copyOf to grow, the System.arraycopy to remove, the manual null, the final trimming of the result array. And count what has appeared: a Set<String> that guarantees in O(1) that there are no repeated ISBNs, something the array version did not have and that would have cost an O(n) loop on every registration.
There is still a weakness, and it is deliberate: remove and any search by reference walk the whole list, O(n). With a Map<String, Material> it would be O(1). That is 05-05.
Common Mistakes and Tips
Declaring by the implementation. ArrayList<Book> list = new ArrayList<>() works, but it ties you down. Write List<Book> list = new ArrayList<>(). In your method parameters, ask for the most general type that serves you: Collection or Iterable if you only traverse.
Using raw types. List list = new ArrayList(); compiles with a warning and returns Object, forcing casts that fail at run time. Always put the <Type> in.
Modifying the collection inside a for-each. It is the number one cause of ConcurrentModificationException. Use removeIf for simple criteria, Iterator.remove() for complex logic, or traverse a copy if you also add.
Trusting the order of a HashSet or HashMap. There is no guaranteed order and it can change when elements are added or between Java versions. If you need reproducible order, use the Linked or Tree variants. A test that depends on the order of a HashSet is a test that will fail one day.
Putting objects without correct equals/hashCode into a Set or using them as Map keys. The object will be stored but never found. In 05-05 you will see the exact demonstration and the reason why; for now, if an object is going into a Set or a Map key, revisit 03-09.
Expecting List.of to be modifiable. It returns an immutable list. If you need to modify it: new ArrayList<>(List.of(...)).
Confusing unmodifiableList with a copy. It is a view: if somebody modifies the original list, the view changes. For a real defensive copy, List.copyOf(list).
Returning null instead of an empty collection. It forces the caller to check for null on every use and guarantees a NullPointerException the day somebody forgets. Return List.of() or Collections.emptyList().
Comparing Integers with == inside collections. It works up to 127 because of the integer cache and fails from 128 upwards. Always use equals, or compare with intValue().
Performance tip: do not optimise without measuring. For 200 elements, ArrayList and LinkedList are indistinguishable. Choose by semantics; performance shows up from tens of thousands upwards, and by then you will have the table from section 4.
Exercises
Exercise 1: choosing the right collection
For each BiblioTech requirement, state which interface and which implementation you would use and why. Write the variable declaration too.
- The full catalogue, walked often and displayed in the order in which items were registered.
- The ISBNs already catalogued, to reject duplicate registrations instantly.
- Finding a material by its reference without walking the catalogue.
- The loans pending processing, dealt with in order of arrival.
- The due-date notices, always dealing first with the one with the most days late.
- The material types that exist, always listed alphabetically.
- The last 10 operations, so that the most recent one can be undone.
- The loan days by type, as a program constant.
Exercise 2: purging the catalogue without ConcurrentModificationException
Write a class CatalogPurge with three static methods that take a List<Material> and remove the materials whose title is empty or whose reference is null:
purgeWithIterator(List<Material>), using an explicitIterator.purgeWithRemoveIf(List<Material>), in one line.purgeOverCopy(List<Material>), traversing a copy.
Add a method demonstrateError(List<Material>) that deliberately triggers the ConcurrentModificationException, with a comment explaining at exactly what moment it will fire.
Exercise 3: traversing anything
Write a utility class Traversals with methods that accept the most general type possible:
int count(Iterable<?> source): counts the elements of anything traversable.String join(Iterable<Material> materials, String separator): concatenates the titles.double sumRates(Collection<Material> materials).List<Material> available(Collection<Material> materials): those that are available, always returning a list (empty if there are none, nevernull).
Show that they all work the same with an ArrayList, a HashSet, a List.of(...) and a map's keySet() where appropriate.
Solutions
Solution 1
| # | Requirement | Declaration | Why |
|---|---|---|---|
| 1 | Full catalogue | List<Material> catalog = new ArrayList<>(); |
Insertion order, duplicates possible (two copies), frequent traversal: O(1) access and the best cache locality |
| 2 | Catalogued ISBNs | Set<String> seenIsbns = new HashSet<>(); |
Uniqueness is the requirement. add returns false if it was already there: O(1) detection |
| 3 | Search by reference | Map<String, Material> index = new HashMap<>(); |
There is a natural key (the reference). O(1) lookup instead of O(n) |
| 4 | Loans in order of arrival | Deque<Loan> pending = new ArrayDeque<>(); |
Pure FIFO: addLast to enqueue, pollFirst to serve. ArrayDeque is the default queue implementation |
| 5 | Notices by lateness | Queue<Loan> notices = new PriorityQueue<>(Comparator.comparingInt(Loan::daysLate).reversed()); |
"Always the most urgent first" is exactly the semantics of PriorityQueue |
| 6 | Alphabetical types | SortedSet<String> types = new TreeSet<>(); |
No duplicates and always sorted, with no manual sorting after every addition |
| 7 | Last 10 operations | Deque<CatalogOperation> history = new ArrayDeque<>(); |
LIFO: push on recording, pop on undoing. To cap it at 10, pollLast() when size() > 10 |
| 8 | Days by type (constant) | static final Map<String, Integer> DAYS = Map.of("Book", 15, "Magazine", 7, "DVD", 3); |
A fixed table: immutable, expressed in one line and proof against accidental modification |
The cross-cutting lesson of the exercise: the right collection follows from the wording. "No repeats" says Set. "Search by" says Map. "In order of arrival" says Queue. "Undo" says stack. If you have to think about it a lot, the requirement is probably not well formulated.
Solution 2
package com.nexussoftware.bibliotech.service;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
import com.nexussoftware.bibliotech.domain.Material;
public final class CatalogPurge {
private CatalogPurge() { }
/** A material is invalid if it has no usable title or no reference. */
private static boolean isInvalid(Material m) {
return m == null
|| m.getTitle() == null || m.getTitle().isBlank()
|| m.getReference() == null;
}
/** Version 1: explicit Iterator. Works in any version of Java. */
public static void purgeWithIterator(List<Material> catalog) {
Iterator<Material> it = catalog.iterator();
while (it.hasNext()) {
Material m = it.next(); // you have to keep what next() returns
if (isInvalid(m)) {
it.remove(); // removes the LAST one returned by next()
// it.remove() updates expectedModCount: that is why it is safe
}
}
}
/** Version 2: removeIf. Java 8+. This is the one you should write by default. */
public static void purgeWithRemoveIf(List<Material> catalog) {
catalog.removeIf(CatalogPurge::isInvalid); // static method reference (04-06)
}
/**
* Version 3: traverse a copy. Necessary when the loop body also
* ADDS elements to the original collection.
*/
public static void purgeOverCopy(List<Material> catalog) {
for (Material m : new ArrayList<>(catalog)) { // the COPY is traversed
if (isInvalid(m)) {
catalog.remove(m); // the ORIGINAL is modified
}
}
}
/** Deliberately incorrect version, to see the failure. */
public static void demonstrateError(List<Material> catalog) {
for (Material m : catalog) {
if (isInvalid(m)) {
catalog.remove(m);
// The remove increments modCount. On the NEXT call to next()
// the iterator compares modCount with its expectedModCount, sees
// that they differ and throws ConcurrentModificationException.
//
// Special case: if the removed element is the SECOND TO LAST, after
// the removal hasNext() returns false, the loop ends without calling
// next() and the exception does NOT fire... but the last element has
// not been visited. That is worse than the exception: a silent failure.
}
}
}
}The three valid versions do the same thing at different costs: removeIf and Iterator.remove operate on the original list with no extra memory; purgeOverCopy duplicates the list, and only pays off when the loop also adds elements. The removeIf version is also the only one that reads at a glance.
Solution 3
package com.nexussoftware.bibliotech.service;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import com.nexussoftware.bibliotech.domain.Material;
public final class Traversals {
private Traversals() { }
/**
* Iterable<?> is the MOST GENERAL type possible: it serves List, Set, Queue,
* keySet(), values()... The '?' means "of any element type": we do not care
* WHAT is inside because we are only counting. The theory of wildcards is
* covered in 10-01.
*/
public static int count(Iterable<?> source) {
if (source == null) { return 0; }
int n = 0;
for (Object ignored : source) { n++; }
return n;
}
/** Iterable because we only traverse: we need no size(), get() or add(). */
public static String join(Iterable<Material> materials, String separator) {
if (materials == null) { return ""; }
StringBuilder sb = new StringBuilder();
for (Material m : materials) {
if (m == null) { continue; }
if (sb.length() > 0) { sb.append(separator); }
sb.append(m.getTitle());
}
return sb.toString();
}
/** Collection because, besides traversing, we are interested in isEmpty(). */
public static double sumRates(Collection<Material> materials) {
if (materials == null || materials.isEmpty()) { return 0.0; }
double total = 0.0;
for (Material m : materials) {
if (m != null) { total += m.getDailyRate(); }
}
return total;
}
/** ALWAYS returns a list, never null: the caller can traverse without checking. */
public static List<Material> available(Collection<Material> materials) {
if (materials == null) { return List.of(); } // empty and immutable, zero cost
List<Material> result = new ArrayList<>();
for (Material m : materials) {
if (m != null && m.isAvailable()) { result.add(m); }
}
return result;
}
}A demonstration that the general type is what gives you the flexibility:
Material book = new Book("Effective Java", "Joshua Bloch", "978-0000000001", 2018);
Material magazine = new Magazine("Java Magazine", "REV-2024-03", 42, "Monthly");
List<Material> list = new ArrayList<>(List.of(book, magazine));
Set<Material> set = new HashSet<>(list);
List<Material> fixed = List.of(book, magazine);
Map<String, Material> index = new HashMap<>();
index.put(book.getReference(), book);
index.put(magazine.getReference(), magazine);
System.out.println(Traversals.count(list)); // 2
System.out.println(Traversals.count(set)); // 2
System.out.println(Traversals.count(fixed)); // 2
System.out.println(Traversals.count(index.keySet())); // 2 <- a Set<String>
System.out.println(Traversals.count(index.values())); // 2 <- a Collection<Material>
System.out.println(Traversals.join(index.values(), " | "));
System.out.printf("Sum of rates: %.2f EUR%n", Traversals.sumRates(set));
System.out.println("Available: " + Traversals.available(fixed).size());A single method, count, works over five different sources —including a map's views— because it asks for Iterable, the minimum contract they all fulfil. It is the golden rule of section 3 taken to method signatures, and it is what separates a rigid API from a reusable one. When you meet Streams in module 10, this same flexibility will be expressed even more compactly, but the principle will be the same.
Conclusion
You now have the full map. You know that the Collections Framework is organised into three layers —interfaces that define contracts, implementations that pick the data structure and algorithms that operate over any of them— and that this separation is the JDK's best example of interface-oriented design. You know the hierarchy: Iterable → Collection → List/Set/Queue, with Deque extending Queue and the Sorted/Navigable variants; and you know why Map stands apart: it stores pairs, not loose elements, and add, contains and iterator would be ambiguous on it. You also know that it reconnects to the rest of the Framework through its three views: keySet, values and entrySet.
You have adopted the golden rule: List<Book> catalog = new ArrayList<>(). Declare by the interface, instantiate the implementation, and ask in your signatures for the most general type that serves you. With that, changing implementation costs one word and your methods accept sources you had not even foreseen.
You have the master table of the ten implementations you will use for the rest of your career, with their order, their duplicates, their nulls and their complexities, and the four default answers memorised: ArrayList, HashSet, HashMap, ArrayDeque. And you have the decision tree for reaching the right choice in five questions.
You understand generics as a user: <...> is the type of what is inside, it gives compile-time safety, removes casts and forces Integer instead of int at a memory cost worth knowing —the theory arrives in 10-01—. You know that the for-each is syntactic sugar: over arrays it translates into a for with an index, and over collections into an Iterator with hasNext/next. And you understand ConcurrentModificationException thoroughly: the modCount, the fail-fast policy, why it is a virtue, why sometimes it does not fire and fails worse, and the four solutions with the criterion for choosing between them, starting with removeIf.
You have mastered Collection's common vocabulary —add with its revealing boolean, remove and contains leaning on equals, addAll, retainAll, forEach, removeIf, toArray(new T[0])— and the immutable collections, with the precise difference between List.of, Collections.unmodifiableList (a view) and List.copyOf (a copy), plus the tip that eliminates a whole family of failures: never return null where a collection is expected.
And BiblioTech can already feel it. The Catalog has lost the counter, the capacity, the copyOf, the arraycopy and the manual nulls, and it has gained something it did not have before: a Set<String> that guarantees in constant time that there are no repeated ISBNs. One obvious weakness remains: searching by reference still walks the whole list.
In the next lesson, ArrayList, you will go into the implementation you will use more than any other. You will see how it works inside —the internal array, the difference between capacity and size, resizing and why adding is still "amortised" O(1) even though it sometimes copies a million elements—, its complete API with the classic trap of remove(int) versus remove(Object) on a List<Integer>, the conversions between array and list with the traps of each, and the definitive refactoring of BiblioTech's catalogue, with the before and after of a real method.
Java Programming Course
Module 1: Introduction to Java
- Introduction to Java
- Setting Up the Development Environment
- Basic Syntax and Structure
- Variables and Data Types
- Operators
- Console Input and Output
- Your First Complete Program: BiblioTech
Module 2: Control Flow
- Conditional Statements
- Loops
- Switch Statements
- Break and Continue
- Debugging and Execution Traces
- Project: The BiblioTech Interactive Menu
Module 3: Object-Oriented Programming
- Introduction to OOP
- Classes and Objects
- Methods
- Constructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- The Object Class: equals, hashCode and toString
Module 4: Advanced Object-Oriented Programming
- Interfaces
- Abstract Classes
- Inner Classes
- Anonymous Classes
- Lambda Expressions
- Functional Interfaces and Method References
- Enums and Records
Module 5: Data Structures and Collections
- Arrays
- The Collections Framework
- ArrayList
- LinkedList
- HashMap
- HashSet
- Queue and Deque
- Stack
- Sorting and Searching Collections
Module 6: Exception Handling
- Introduction to Exceptions
- The Try-Catch Block
- Throw and Throws
- Custom Exceptions
- The Finally Block
- Try-with-resources and AutoCloseable
- Error Handling Strategies and Logging
Module 7: File Input/Output
- Reading Files
- Writing Files
- File Streams
- BufferedReader and BufferedWriter
- Serialization
- The NIO.2 API: Path and Files
- Interchange Formats: CSV and Properties
Module 8: Multithreading and Concurrency
- Introduction to Multithreading
- Creating Threads
- Thread Lifecycle
- Synchronization
- Concurrency Utilities
- Concurrent Collections and Atomic Variables
- Asynchronous Tasks with CompletableFuture
Module 9: Networking
- Introduction to Networking
- Sockets
- ServerSocket
- DatagramSocket and DatagramPacket
- URL and HttpURLConnection
- The Modern HTTP Client
Module 10: Advanced Topics
- Generics
- Annotations
- Reflection
- Java 8 Features: Streams and Optional
- Dates and Times with java.time
- Java 9 and Beyond
- Memory, Garbage Collection and Performance
Module 11: Java Frameworks and Libraries
- Introduction to Java Frameworks
- Spring Framework
- Hibernate
- JUnit
- Maven
- Advanced Testing with Mockito
- Essential Ecosystem Libraries
