Lecture #7: Object Collections in Java (java.util)
Introduction
Arrays are the collection type built directly into the Java language: they use C-like syntax (int[] a = new int[10]), they are objects allocated on the heap, but they have a serious limitation — once created, their size is fixed forever. If you need a structure that grows and shrinks at run time, arrays alone are not enough.
This is exactly the gap that the java.util package fills. It provides a family of dynamic, general-purpose data structures — lists, sets, and key-value maps — plus a handful of algorithmic helpers (sorting, searching) and some unrelated utility classes (Date, Calendar, Scanner, and so on). The library is large (more than fifty classes), but almost all of it is organized around a small number of abstract interfaces — Collection, List, Set, Map — that describe what a structure can do without saying how it does it. Once you understand that handful of interfaces and the idea of generic type parameters, you can navigate the rest of the library on your own, the same way you would navigate an unfamiliar API in any language: this is often called a “design language” — learning the vocabulary once, then combining it freely.
This chapter covers seven things: (1) why abstract interfaces exist and how implements differs from extends; (2) the Collection and List hierarchy; (3) List implementations and their tradeoffs; (4) Set and why equals/hashCode matter for it; (5) iteration, including the for-each loop; (6) sorting with Comparable/Comparator; and (7) the Map interface for key-value associations.
1. Abstract Interfaces: A Second Kind of Inheritance
The problem they solve
Imagine you are building a small drawing framework. You have several unrelated class hierarchies — geometric shapes (Rectangle, Circle), logic gates (And, Or), maybe even files — and you want to collect a subset of objects from all of these hierarchies that share one thing in common: they know how to draw themselves on screen via a display() method.
There is no single common superclass you can use for this, other than Object itself, and not every Object is drawable. You could invent an abstract superclass Displayable and have every drawable class extend it — but Java only allows single inheritance of implementation (extends takes exactly one class), and your shapes and gates already belong to their own natural hierarchies (a Rectangle might already extend Shape, an And gate might already extend Gate). You cannot bolt on a second superclass.
Picture several independent class trees side by side — shapes, gates, whatever else — with an interface cutting across all of them, picking out just the classes that happen to be drawable, without merging their separate inheritance trees.
Interfaces as pure protocol
An interface is what you get when you push the idea of an abstract class to its logical extreme: no instance fields at all (only static constants are allowed), and every method is abstract — a pure protocol, a list of operation signatures with no implementation. A class signs up to that protocol using implements rather than extends, and — critically — a class can implements as many interfaces as it likes. This is what makes interfaces suitable for cross-cutting capabilities: Rectangle can extend Shape and implement Displayable and implement Comparable<Rectangle>, all at once.
public interface Displayable {
void display();
}
public class Rectangle implements Displayable, Comparable<Rectangle> {
private double width, height;
public Rectangle(double width, double height) {
this.width = width;
this.height = height;
}
public double surface() {
return width * height;
}
@Override
public void display() {
System.out.println("Rectangle " + width + "x" + height);
}
@Override
public int compareTo(Rectangle other) {
return Double.compare(this.surface(), other.surface());
}
}
An interface can itself extend one or more other interfaces (this is one of the few places Java allows “multiple extends,” precisely because interfaces carry no implementation to conflict):
public interface List<E> extends Collection<E>, Iterable<E> { /* ... */ }
// illustrative — the real java.util.List only writes "extends Collection<E>",
// since Iterable<E> is already inherited transitively through Collection<E>;
// listing both here is legal, just redundant, to show the multi-extends syntax
Abstract classes, by contrast, are still useful when several classes need to share code, not just share a protocol. For example, an abstract class can factor out a method that stays unimplemented while giving siblings a common partial implementation:
public abstract class LogicGate implements Displayable {
protected String label;
// display() is intentionally left abstract here
}
public class AndGate extends LogicGate {
@Override
public void display() { System.out.println(label + ": AND"); }
}
public class OrGate extends LogicGate {
@Override
public void display() { System.out.println(label + ": OR"); }
}
Once a type is expressed as an interface, it can be used anywhere a type is expected — as a variable type, a method parameter, or the element type of a collection — and ordinary subtyping rules apply:
import java.util.*;
public class Canvas {
protected List<Displayable> items = new ArrayList<>();
public void add(Displayable item) { items.add(item); }
public void renderAll() {
for (Displayable item : items) item.display();
}
}
java.util’s own classes are full of this pattern. ArrayList<E> is a good example: it extends one abstract class (for shared implementation) and implements several interfaces (for protocol):
public class ArrayList<E>
extends AbstractList<E>
implements List<E>, Cloneable, Serializable
AbstractList<E> supplies common bookkeeping and generic algorithms; List<E> is the pure abstract contract, independent of any specific implementation — a bit like a header file describing an API without its source. Interfaces of this kind are everywhere in mainstream object-oriented platforms (Java, C#, and others followed the same idea), and the same principle scales up to language-independent interface description languages such as WSDL for distributed systems — a topic for a later software architecture course.
2. The Collection Hierarchy
Generics: why they matter
Before Java 5, every collection stored plain Object references. That meant no compile-time type checking on what you put in or took out — you inserted anything, and getting an element back required an explicit downcast, which the compiler could not verify and which could fail at run time with a ClassCastException. This is essentially what happens by default in dynamically typed languages.
// Pre-5.0 style — do not write new code like this
List legacyList = new ArrayList(); // raw type, elements are Object
legacyList.add(new Rectangle(2, 3));
Rectangle r = (Rectangle) legacyList.get(0); // manual, unchecked cast
Since Java 5, collections are generic: they are parameterized by the element type, written List<E>, Set<E>, Map<K,V>, and so on. The compiler then enforces that only compatible elements go in, and no cast is needed coming out:
List<Rectangle> rectangles = new ArrayList<>();
rectangles.add(new Rectangle(2, 3));
Rectangle r = rectangles.get(0); // no cast needed, checked at compile time
Because collections only ever hold objects, primitive values (int, double, boolean, …) cannot be stored directly — you must use their wrapper classes (Integer, Double, Boolean, …). Since Java 5, autoboxing/unboxing performs this conversion automatically:
List<Double> readings = new ArrayList<>();
double x = 3.14;
readings.add(x); // autoboxing: double -> Double, automatic
double sum = 0.0;
for (double value : readings) // auto-unboxing: Double -> double
sum += value;
Autoboxing is convenient, but it is not free: each boxed value is a full heap object, with the memory and time overhead that implies compared to a raw primitive array. It is fine for everyday code but worth keeping in mind for tight numerical loops.
The Collection<E> interface
At the top of the hierarchy sits Collection<E>, which specifies the operations common to essentially every collection type — add, remove, test membership, measure size:
public interface Collection<E> {
boolean add(E element);
boolean remove(Object o);
boolean contains(Object o); // uses equals()
int size();
boolean isEmpty();
void clear();
Object[] toArray();
// equals()/hashCode() are NOT redeclared here — every interface already
// inherits them from Object; Collection's Javadoc just documents the
// contract they're expected to follow, without re-listing the methods
// ... plus the "All" family below
}
Several operations have a bulk (“All”) counterpart that applies to every element of another collection at once:
Collection<String> a = new ArrayList<>(List.of("x", "y", "z"));
Collection<String> b = new ArrayList<>(List.of("y", "z"));
a.addAll(b); // add every element of b into a
boolean subset = a.containsAll(b); // true if b's elements are all in a
List.of(...) returns an immutable list — trying to add/remove on it directly throws UnsupportedOperationException — which is why both lines above wrap it in new ArrayList<>(...) to get a mutable copy. The same wrapping trick works starting from a plain array, via Arrays.asList(...): new ArrayList<>(Arrays.asList(someArray)) converts an existing T[] into a mutable List<T> in one line, without a hand-written loop — useful any time you’re handed an array (e.g. a Component[] built elsewhere) and want to work with it as a List from that point on.
Two everyday specializations of Collection are:
- Lists — ordered by position (index), duplicates allowed.
- Sets — no duplicates allowed, generally unordered (or ordered by a different rule, as we will see with
TreeSet).
3. Lists: java.util.List
A List<E> is a Collection<E> whose elements have an externally visible order given by an integer index, and where the same value may appear more than once. On top of the generic Collection operations, List adds index-based access:
public interface List<E> extends Collection<E> {
void add(int index, E element); // add(element) alone appends at the end
E get(int index); // throws IndexOutOfBoundsException
E set(int index, E element); // replaces, returns the old value
E remove(int index);
int indexOf(Object o); // index of first occurrence, or -1
List<E> subList(int from, int to);
}
Two standard implementations cover the common trade-offs:
ArrayList<E>— backed by a contiguous, resizable array.get(i)/set(i, x)are O(1);add(i, x)/remove(i)are O(n), since every following element has to shift. This is the default choice for most code. (It coexists with the olderVectorclass, which predates the Collections Framework and is now rarely used in new code.)LinkedList<E>— backed by a doubly linked chain of nodes.addFirst/addLast/removeFirst/removeLastare O(1) — this is the case where “insertion is fast” genuinely holds. Butget(i)/add(i, x)are O(n): theListinterface only gives you an index, not a node reference, so reaching positionistill means walking the chain from one end. If you’re coming from a C linked-list background where you’re used to holding a node pointer directly, don’t extend that O(1) intuition to indexed access on Java’sListinterface — it isn’t there.
A small worked example, modeling a circuit made of components:
import java.util.*;
public class Circuit {
protected List<Component> components = new ArrayList<>();
public void plugIn(Component c) {
components.add(c);
}
public void replace(int index, Component c) {
components.set(index, c);
}
public void run() {
for (int i = 0; i < components.size(); i++) {
components.get(i).run();
}
}
}
Note the declared field type: List<Component>, not ArrayList<Component>. Coding against the interface, and only choosing the concrete implementation (ArrayList, LinkedList) at the point of construction, keeps the rest of the class free to swap implementations later without changing any other line.
4. Sets: java.util.Set
A Set<E> is a Collection<E> with one extra rule bolted on: no duplicates. Calling add with a value that’s already present is a no-op — the set’s size doesn’t change, and no exception is thrown. Two standard implementations mirror the List split:
HashSet<E>— backed by a hash table (in fact, internally backed by aHashMap<E, Object>).add/contains/removeare near-constant time on average, but iteration order is unspecified and can even change between runs.TreeSet<E>— backed by a balanced binary search tree, kept in sorted order. Operations areO(log n)rather than average-constant, but iterating aTreeSetalways visits elements in ascending order — which requires the element type to implementComparable(or aComparatorto be supplied, as covered in §6 below).
Set<String> seen = new HashSet<>();
seen.add("alice");
seen.add("bob");
seen.add("alice"); // no-op — "alice" is already present
System.out.println(seen.size()); // 2, not 3
This is exactly where the equals/hashCode contract from the previous chapter stops being an abstract rule and starts being something that silently breaks your code. HashSet (and HashMap’s key set, which is built the same way) decides whether two elements are “the same” using hashCode() first, then equals() — both inherited from Object, both identity-based by default. If your element is a custom class that never overrode them, duplicates you’d consider “obviously the same” sail straight through:
class Point {
int x, y;
Point(int x, int y) { this.x = x; this.y = y; }
// no equals()/hashCode() override
}
Set<Point> visited = new HashSet<>();
visited.add(new Point(1, 1));
visited.add(new Point(1, 1));
System.out.println(visited.size()); // 2 — two "different" objects, as far as HashSet can tell,
// even though every field matches
Override equals()/hashCode() on Point (exactly as shown in the previous chapter) and size() correctly reports 1. The rule to internalize: any time you put a custom class into a HashSet or use it as a HashMap key, ask yourself whether you’ve overridden equals/hashCode — if not, assume deduplication silently isn’t working.
5. Iterating Over a Collection
Every Collection<E> extends Iterable<E>, which means it can hand out an iterator: an object that walks the elements one at a time.
public interface Iterable<E> {
Iterator<E> iterator();
}
public interface Iterator<E> {
boolean hasNext();
E next(); // throws NoSuchElementException if hasNext() was false
}
Using it explicitly:
public void runAll() {
Iterator<Component> it = components.iterator();
while (it.hasNext()) {
it.next().run();
}
}
Since anything that implements Iterable<E> (every Collection, and arrays too) can be walked this way, Java 5 introduced the for-each loop as syntactic sugar over exactly this pattern — the compiler rewrites it into the Iterator calls above automatically:
public void runAll() {
for (Component c : components) {
c.run();
}
}
The two styles are not fully interchangeable, though — pick based on what the traversal needs:
- Use
for-eachfor a plain, exhaustive traversal (a “static” loop that always visits every element). - Use an explicit
Iteratorwhen you need to stop early on some condition, or otherwise control the traversal manually (a “dynamic” loop) — typically written as awhile.
6. Ordering Elements: Comparable, Comparator, and Collections
The utility class java.util.Collections (not to be confused with the Collection interface — note the singular/plural distinction) provides static helper methods that operate on lists, most notably sorting and searching:
public class Collections {
public static <E extends Comparable<? super E>> void sort(List<E> list) { /* ... */ }
public static <E> void sort(List<E> list, Comparator<? super E> c) { /* ... */ } // overload: explicit ordering
public static <E extends Comparable<? super E>> int binarySearch(List<E> list, E key) { /* ... */ }
// an equivalent set of static helpers exists for arrays, in java.util.Arrays
}
The <E extends Comparable<? super E>> bound on the single-argument sort is not just documentation — it’s enforced by the compiler. If E doesn’t implement Comparable, Collections.sort(list) simply won’t compile; you either make the element type Comparable (shown just below) or use the second overload and supply a Comparator explicitly, which needs no such bound.
For Collections.sort to know how to order arbitrary objects, the element type must supply an ordering by implementing java.lang.Comparable<T>:
public interface Comparable<T> {
int compareTo(T other);
// negative -> this comes before other
// zero -> this is equivalent to other
// positive -> this comes after other
}
This generalizes the classic C strcmp idea to any object type — String itself implements Comparable<String> this way. Here is a small domain example, a Book ordered by its author’s name:
public class Book implements Comparable<Book> {
private String title, author;
public Book(String title, String author) {
this.title = title;
this.author = author;
}
public String getTitle() { return title; }
public String getAuthor() { return author; }
@Override
public int compareTo(Book other) {
return this.author.compareTo(other.getAuthor());
}
@Override
public String toString() { return title + " — " + author; }
}
List<Book> books = new ArrayList<>();
books.add(new Book("Germinal", "Zola"));
books.add(new Book("The C Programming Language", "Kernighan"));
books.add(new Book("Thinking in Java", "Eckel"));
Collections.sort(books); // relies on Book.compareTo
books.forEach(System.out::println);
// Thinking in Java — Eckel
// The C Programming Language — Kernighan
// Germinal — Zola
Sometimes you need an ordering that is not the class’s natural one (or the class does not implement Comparable at all). For that, java.util offers Comparator<T>, a separate strategy object passed in at sort time instead of being baked into the class:
List<Book> books2 = new ArrayList<>(books);
books2.sort(Comparator.comparing(Book::getTitle)); // natural order is by author; sort by title instead
books2.forEach(System.out::println);
// Germinal — Zola
// The C Programming Language — Kernighan
// Thinking in Java — Eckel
The general pattern is the same one you’ll reach for repeatedly: Comparable bakes in one “natural” ordering that belongs to the class itself (here, by author), while a Comparator lets any caller impose a different, situational ordering (here, by title) without touching the class at all.
7. Key-Value Associations: java.util.Map
A Map<K,V> maintains associations between unique keys and values — think of it as a two-column table: key -> value. Keys form an implicit Set<K> (no duplicate keys); values may repeat.
public interface Map<K, V> {
V put(K key, V value);
V get(Object key); // returns null if the key is absent
boolean containsKey(Object key);
boolean containsValue(Object value);
V remove(Object key);
Set<K> keySet(); // the set of all keys
Collection<V> values(); // the collection of all values
}
Two standard implementations dominate:
HashMap<K,V>— backed by a hash table; the key set behaves like aHashSet, using each key’shashCode(). Very fast on average (near constant time forget/put), but iteration order is unspecified. If your key type is a custom class, it must overrideequals()andhashCode()consistently (see References, Aliasing, and Object Identity) — without that,get()will fail to find a value stored under a “logically equal” key, because the defaulthashCode()/equals()only recognize the exact same object instance.TreeMap<K,V>— backed by a balanced binary search tree; the key set behaves like aTreeSet, kept sorted. Access isO(log n)rather than average-constant, but iteration visits keys in sorted order — which requires the key type to implementComparable(or aComparatorto be supplied).
Multiple maps can index the same pool of objects by different criteria at essentially no extra memory cost, because a map only stores references to the shared objects — but be aware of aliasing: if you mutate an object after using it as a key (in a way that changes its hash code or its ordering), the structure that indexed it can become inconsistent, since there is no automatic “re-indexing” the way a database would provide.
A small library example, storing Books in a TreeMap keyed by an alphanumeric catalog code:
// file: UnknownBookException.java
public class UnknownBookException extends Exception {}
// file: Library.java
import java.util.*;
public class Library {
protected Map<String, Book> catalog = new TreeMap<>();
public void add(String code, Book book) {
catalog.put(code, book);
}
// iterate over values — a real use would compute something per book;
// here we just count them (in practice you'd call catalog.size() directly)
public int countByAuthor(String author) {
int count = 0;
for (Book b : catalog.values()) {
if (b.getAuthor().equals(author)) count++;
}
return count;
}
// iterate over keys, in sorted order (TreeMap guarantee)
public void listing() {
for (String code : catalog.keySet()) {
System.out.println(code + ": " + catalog.get(code));
}
}
public void borrow(String code) throws UnknownBookException {
Book b = catalog.get(code);
if (b == null) throw new UnknownBookException();
// ... mark as borrowed
}
}
Library library = new Library();
library.add("I101", new Book("The C Programming Language", "Kernighan"));
library.add("L202", new Book("Germinal", "Zola"));
library.add("I345", new Book("Thinking in Java", "Eckel"));
library.listing();
// I101: The C Programming Language — Kernighan
// I345: Thinking in Java — Eckel
// L202: Germinal — Zola
Because keySet() returns a Set<K> and values() returns a Collection<V>, everything learned about List iteration in Section 4 (both explicit Iterator and for-each) applies to maps as well — you simply iterate over one of these two derived views instead of over the map itself (a Map is not itself Iterable).
Recap
- Interfaces are “pure protocol” types: no fields, only abstract method signatures. A class can
implementsseveral interfaces at once, which is how Java gives objects cross-cutting capabilities (likeDisplayableorComparable<T>) without needing multiple inheritance of implementation. Interfaces may also extend other interfaces. Collection<E>is the root abstraction for a dynamic group of elements, withadd,remove,contains,size, and bulk “All” variants.List<E>extends it with index-based access;ArrayListfavors fast indexed access,LinkedListfavors fast insertion/removal at the ends only — not fast indexed access.- Since Java 5, collections are generic (
List<E>, not rawList), giving compile-time type safety instead of manual casts; autoboxing/unboxing lets primitives flow in and out of collections via their wrapper classes. Set<E>is aCollectionwith no duplicates;HashSettrades ordering for speed,TreeSetkeeps elements sorted. Both rely onequals/hashCode(HashSet) orComparable/Comparator(TreeSet) to decide what counts as a duplicate — a custom element class without a correctequals/hashCodeoverride will silently fail to deduplicate.- Every
CollectionisIterable: use an explicitIterator(hasNext/next) when you need controlled or conditional traversal, andfor-eachfor a plain full traversal — the compiler rewritesfor-eachinto iterator calls automatically. Collections.sortorders aListusing either the element type’s natural order, viaComparable<T>.compareTo, or an externalComparator<T>supplied at call time.Map<K,V>associates unique keys with values;HashMaptrades ordering for speed,TreeMapkeeps keys sorted (requiringComparablekeys) at the cost ofO(log n)operations. Its key set and value collection are themselves iterable, reusing everything learned aboutList/Setiteration — and aHashMapwith a custom key type needs the sameequals/hashCodediscipline asHashSet.