java.util.Collection<E> is the root interface that almost every collection type in Java implements. It defines the common vocabulary every implementation must support: adding items, removing items, asking how many there are, checking membership, and walking through them. This lesson covers what Collection actually requires from its implementations, the methods used day to day, how iteration plugs in through Iterable, the default methods added in Java 8, and why writing code against Collection rather than against a specific class is a useful habit.
Before the Collections Framework, every Java collection class had its own way of doing things. Vector had addElement. Hashtable had put. Adding an item to one didn't look anything like adding to the other, and there was no way to write a method that worked across both. The framework fixed that by extracting the things every collection does into a single interface. That interface is Collection<E>.
The contract is small on purpose. A Collection is a group of zero or more elements. Code can ask how many there are, add one, remove one, ask whether a particular element is in there, and walk through them. That's the core. Specific subtypes layer extra rules on top: a List keeps insertion order and allows duplicates, a Set rejects duplicates, a Queue orders elements for processing. But every one of them is a Collection first.
The arrow from Iterable to Collection matters. Collection extends Iterable, which is what makes every collection usable in an enhanced for loop. Map is not under Collection. A Map is a separate hierarchy, because a map stores key-value pairs rather than single elements. The map-aware methods like put and get don't fit the Collection contract.
Here are the methods every Collection implementation has. Read this as the vocabulary you can rely on regardless of which concrete class you pick.
| Method | What it does |
|---|---|
boolean add(E e) | Adds an element. Returns true if the collection changed. |
boolean remove(Object o) | Removes a single instance of the element. Returns true if something was removed. |
boolean contains(Object o) | Returns true if the collection has at least one matching element. |
int size() | Returns the number of elements. |
boolean isEmpty() | Returns true when size() is 0. |
void clear() | Removes every element. |
Iterator<E> iterator() | Returns an iterator over the elements. |
Object[] toArray() | Returns a new array containing the elements. |
<T> T[] toArray(T[] a) | Returns a typed array. |
boolean addAll(Collection<? extends E> c) | Adds every element from another collection. |
boolean removeAll(Collection<?> c) | Removes every element that's also in the other collection. |
boolean retainAll(Collection<?> c) | Keeps only elements that are also in the other collection. |
boolean containsAll(Collection<?> c) | Returns true if this collection contains every element of the other. |
A small program puts the basics to work. Here we model the items in a shopping cart as a Collection<String> of product names. We deliberately use the interface type on the left side of the declaration, even though we're creating an ArrayList on the right.
Each call lines up with the table above. add returned true for every successful insertion. contains checked membership using equals, which is why string comparison works the way you'd expect. remove("Pen") returned true and dropped the matching element. clear emptied the whole collection, leaving isEmpty() to report true. The printed format [Notebook, Eraser] comes from AbstractCollection.toString(), which most implementations inherit.
contains and remove both scan the collection in the worst case. For an ArrayList, that's O(n). A HashSet does the same operations in average O(1) because it uses hashing. Pick the implementation that fits the operations you do most often.
The Collection interface is a contract, not just a list of method names. When a class implements Collection, it agrees to behave in specific ways, even though the interface can't enforce all of them at compile time.
The most important rules are these:
size() returns a non-negative count.isEmpty() returns true if and only if size() returns 0.contains(o) uses equals to compare elements, not ==.add(e) may return false if the collection refuses to add the element (a Set returns false for duplicates, for example).remove(o) removes at most one matching element per call.iterator() returns an iterator that visits every element exactly once.Some implementations strengthen the contract. A List guarantees positional access. A SortedSet guarantees iteration order matches the natural ordering. Others weaken it. An ArrayList allows null elements, but a TreeSet rejects null because it can't compare it.
The contract also covers what an implementation is allowed to refuse. Methods on Collection are marked as "optional operations" in the docs. An unmodifiable collection throws UnsupportedOperationException from add, remove, clear, and the bulk operations. The method exists on every Collection, but calling it on a read-only view fails at runtime.
List.of(...) returns an unmodifiable collection. The read operations work normally. The write operations exist on the interface, but the implementation refuses them by throwing UnsupportedOperationException. This is why "is this collection modifiable?" is a question worth asking when you accept a Collection as a method parameter.
Collection<E> extends Iterable<E>, which means every collection can be the target of an enhanced for loop. That single inheritance line is what makes this work:
Internally, the enhanced for loop calls cart.iterator() to get an Iterator<String>, then repeatedly calls hasNext() and next() until the iterator is exhausted. You can do the same thing yourself when you want more control, like skipping elements or removing during iteration:
The key detail is it.remove(). Calling cart.remove("Pen") while the enhanced for loop is iterating would throw ConcurrentModificationException, because the loop and the collection would disagree about what to visit next. The iterator's own remove method is safe, because the iterator coordinates the removal with its own state.
Iterator.remove() on an ArrayList is O(n) because the tail of the list shifts down. On a LinkedList, it's O(1) because the iterator already holds the node. Doing many removals while iterating is one of the rare cases where LinkedList beats ArrayList.
There's a forEach method on Iterable (also Java 8) that takes a Consumer and runs it for each element. It's the lambda-friendly version of the enhanced for loop:
forEach reads cleanly when the body is a single expression. The enhanced for loop is usually clearer when the body has multiple statements or needs to break out early.
Java 8 added three default methods to Collection. They're available on every implementation without any class needing to override them.
stream() returns a Stream<E> over the collection. Streams are the gateway to filtering, mapping, and aggregating without writing explicit loops:
The pipeline reads top to bottom: take the prices, keep only ones at least $5, convert to a double stream, and sum. The second pipeline keeps the cheaper items and collects them into a List. Because stream() is available on Collection, this pipeline works whether prices is backed by an ArrayList, a LinkedList, or a HashSet.
parallelStream() returns a stream that splits the work across multiple threads. For most everyday collections, the cost of coordination outweighs the benefit, so a plain stream() is the right default. Use parallelStream() only when the collection is large, the per-element work is non-trivial, and the operations are stateless.
removeIf(Predicate) removes every element that matches a condition. It's the structured replacement for the iterator pattern earlier:
removeIf walks the collection and drops every element the predicate accepts. It returns true if anything was removed. Internally it uses an iterator, but you don't have to write the loop yourself. Trying to do the same thing with an enhanced for loop and remove would throw ConcurrentModificationException, which is the bug removeIf was added to prevent.
The four bulk operations work on whole collections at a time. Each one takes another Collection and does the obvious thing.
Walk through what happened. addAll appended every element from wishlist, including the duplicate "Pen" because ArrayList allows duplicates. removeAll(outOfStock) dropped every occurrence of "Mouse" and "Pen", leaving Notebook and Keyboard. retainAll(categoryItems) kept only elements that also appeared in categoryItems. Since both remaining elements were in that set, nothing changed. containsAll then asked whether every current cart item appears in categoryItems, which is true.
The four bulk operations together implement basic set algebra over any collection. addAll is union (with duplicates if the underlying type allows them), retainAll is intersection, and removeAll is difference. You don't need a Set to use them. They work on any Collection.
Bulk operations are O(n times m) in the worst case, where n is the size of the receiver and m is the size of the argument. Each element of the receiver may be compared against every element of the argument. If both sides are large, consider whether one of them should be a HashSet so the per-element check is O(1).
Sometimes you need a plain Java array, like when a legacy API takes one as a parameter. Collection provides two toArray overloads.
toArray() returns an Object[]. It's safe but rarely what you want, because you lose the element type. toArray(T[] a) returns an array of the type you ask for. If the array you pass is large enough, the elements go into it. If not, a new array of the right type and size is created.
Passing new String[0] is the idiomatic call. It's a tiny array used purely as a type token. The collection creates a new String[] of the right size internally and returns it. The older pattern of pre-sizing the array (new String[cart.size()]) used to be a small optimization, but modern JVMs treat both forms the same.
Java 11 added toArray(IntFunction) as a cleaner alternative:
This reads as "convert this collection into an array, using String[]::new to create the array". It's the same result as cart.toArray(new String[0]), just with less ceremony.
The single most useful habit Collection enables is writing code that doesn't care which implementation it gets. If a method accepts a Collection<String>, the caller can pass an ArrayList, a HashSet, a LinkedHashSet, or anything else, without the method changing. That flexibility comes automatically when you keep the parameter type general.
Compare these two method signatures:
The first one only accepts ArrayList. If a caller has a HashSet<Double>, they have to copy it into an ArrayList first, just to satisfy the signature. The second one accepts any collection at all, because everything the method needs (iteration, size, maybe stream) is available on Collection. The body is identical; the contract is wider.
A worked example:
The same method handled both collections without knowing or caring whether they were backed by an ArrayList or a HashSet. If you later decide a LinkedHashSet is a better fit for the wishlist (preserves order and rejects duplicates), nothing about sumPrices changes. That's polymorphism through interfaces, and it's the reason the framework was built the way it was.
The flip side is also worth saying: declare your variables with the interface type as well, not the implementation type.
is better than
unless you specifically need a method that only ArrayList has. The interface declaration leaves you free to swap implementations later without touching every line that uses cart. You'd usually go one level more specific in practice and declare cart as a List<String>, because List adds positional access on top of Collection. The general rule is the same: pick the broadest interface that still gives you what you need.
10 quizzes