Collection Interface in Java: Methods and Contract
Call List.of("a", "b").remove("z") and you get an UnsupportedOperationException, even though there is nothing to remove. Call Arrays.asList("a", "b").remove("z") and it quietly returns false. Both lists implement the same Collection interface, and both follow its contract correctly. The difference lies in how each implementation treats the interface's optional operations.
The Collection interface (java.util.Collection<E>) is the root interface of the list, set and queue branch of the Java Collections Framework. It extends Iterable<E> and declares the operations shared by List, Set and Queue: adding and removing elements, membership checks, size, iteration, and conversion to an array or a stream. Map does not extend Collection. Where Collection sits in the overall picture is shown in Collection hierarchy in Java.
Collection interface methods in Java
The methods of the Collection interface fall into three groups: modifying the collection (add, remove, addAll, removeAll, retainAll, removeIf, clear), querying it (contains, containsAll, size, isEmpty), and iterating or converting it (iterator, toArray, stream). Every method that modifies the collection returns true if the collection actually changed.
The question marks in signatures such as Collection<? extends E> are wildcards: addAll() accepts a collection of E or of any subtype of E. The full list of methods with their exact contracts is in the Collection Javadoc.
Methods that modify a collection
| Method | What it does | Returns | Edge case |
|---|---|---|---|
boolean add(E e) | Adds an element | true if the collection changed | A Set that already holds the element returns false; ArrayDeque.add(null) throws NullPointerException |
boolean addAll(Collection<? extends E> c) | Adds all elements of c | true if at least one element was added | A HashSet that already has some of the elements still returns true |
boolean remove(Object o) | Removes one element equal to o by equals() | true if an element was found and removed | Only the first occurrence is removed; no match — false |
boolean removeAll(Collection<?> c) | Removes every element that is also in c, including duplicates | true if anything was removed | removeAll(List.of()) changes nothing — false |
boolean retainAll(Collection<?> c) | Keeps only the elements that are also in c | true if anything was removed | retainAll(List.of()) empties the collection |
default boolean removeIf(Predicate<? super E> filter) | Removes elements that match the condition (Java 8) | true if at least one was removed | filter == null — NullPointerException |
void clear() | Removes all elements | — | List.of().clear() throws UnsupportedOperationException even though the list is empty |
Query methods: contains, size, isEmpty
| Method | What it does | Edge case |
|---|---|---|
boolean contains(Object o) | Checks whether an element equal to o by equals() is present | List.of(1).contains(null) throws NullPointerException |
boolean containsAll(Collection<?> c) | Checks whether all elements of c are present | Always true for an empty c; duplicates in c are not counted |
int size() | Returns the number of elements | More than Integer.MAX_VALUE elements — returns Integer.MAX_VALUE |
boolean isEmpty() | Returns true if there are no elements | Reads better than size() == 0 and can be faster in some collections |
Iteration, arrays and streams
| Method | What it does | Edge case |
|---|---|---|
Iterator<E> iterator() | Returns an iterator over the elements | The order is defined by the implementation; a HashSet guarantees none |
default void forEach(Consumer<? super T> action) | Runs an action for each element (inherited from Iterable, Java 8) | Modifying the collection inside the action — ConcurrentModificationException |
Object[] toArray() | Creates a new Object[] array | Casting it to String[] — ClassCastException |
<T> T[] toArray(T[] a) | Creates an array of the given type (or fills a if it is large enough) | Incompatible element type — ArrayStoreException |
default <T> T[] toArray(IntFunction<T[]> generator) | Creates an array with a generator such as String[]::new (Java 11) | The same ArrayStoreException for an incompatible type |
default Stream<E> stream() | Returns a sequential stream (Java 8) | The stream never modifies the source collection |
default Stream<E> parallelStream() | Returns a possibly parallel stream (Java 8) | Usually slower than stream() on small collections |
default Spliterator<E> spliterator() | Returns a “splittable iterator” that streams are built on (Java 8) | Rarely called directly |
Two more methods are redeclared in Collection to refine their contract: boolean equals(Object o) and int hashCode(), which returns the hash code of the collection. How they work is explained in How Collection compares elements.
Example: add, remove, removeAll, retainAll
The easiest way to understand Collection methods is to watch their return values: true means the collection changed, false means it stayed the same. The variable below is declared with the Collection type, so only the methods of this interface are available — no indexes.
import java.util.ArrayList;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
public class CollectionMethodsExample {
public static void main(String[] args) {
Collection<String> names = new ArrayList<>(List.of("Ann", "Bob", "Ann", "Kate"));
System.out.println(names.add("Bob")); // true
System.out.println(names.remove("Ann")); // true
System.out.println(names); // [Bob, Ann, Kate, Bob]
System.out.println(names.removeAll(List.of("Bob", "Pete"))); // true
System.out.println(names); // [Ann, Kate]
System.out.println(names.retainAll(List.of("Ann", "Kate"))); // false
System.out.println(names.containsAll(List.of("Kate", "Ann"))); // true
System.out.println(names.addAll(List.of())); // false
names.clear();
System.out.println(names.isEmpty() + " " + names.size()); // true 0
Collection<String> tags = new HashSet<>(List.of("java"));
System.out.println(tags.add("java")); // false
System.out.println(tags.addAll(List.of("java", "sql"))); // true
}
} Output:
true
true
[Bob, Ann, Kate, Bob]
true
[Ann, Kate]
false
true
false
true 0
false
true Three details are easy to miss. remove("Ann") removed only the first “Ann”, while removeAll() removed both “Bob”s. The missing “Pete” did not stop removeAll() from returning true. And addAll() on the set returned true although only “sql” was added: the method reports whether the collection changed, not whether every element went in. Index-based methods such as get(int) and add(int, E) belong to the List interface, not to Collection.
removeIf(): remove elements by a condition
The removeIf(Predicate) method removes every element for which the condition returns true and returns true if at least one element was removed. It was added in Java 8 as a default method and replaces a manual loop with an iterator: one line instead of five, with no risk of ConcurrentModificationException.
Collection<Integer> numbers = new ArrayList<>(List.of(5, 12, 7, 20, 3));
System.out.println(numbers.removeIf(n -> n > 10)); // true
System.out.println(numbers); // [5, 7, 3]
System.out.println(numbers.removeIf(n -> n > 100)); // false - nothing removed If you call numbers.remove(n) inside a for-each loop instead, the next step of the loop usually fails with ConcurrentModificationException. Before Java 8 the safe way was an explicit Iterator and its remove() method; removeIf() does exactly that for you.
toArray(): three ways to get an array
The toArray() method copies the elements of a collection into a new array. There are three overloads: toArray() returns Object[], toArray(T[] a) returns an array of the requested type, and toArray(IntFunction) from Java 11 creates the array from a constructor reference such as String[]::new.
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collection;
import java.util.List;
public class ToArrayExample {
public static void main(String[] args) {
Collection<String> langs = new ArrayList<>(List.of("Java", "Kotlin"));
Object[] objects = langs.toArray();
String[] strings = langs.toArray(new String[0]);
String[] strings11 = langs.toArray(String[]::new); // Java 11+
System.out.println(Arrays.toString(strings11)); // [Java, Kotlin]
String[] wrong = (String[]) langs.toArray(); // ClassCastException
}
} Output:
[Java, Kotlin]
Exception in thread "main" java.lang.ClassCastException: class [Ljava.lang.Object; cannot be cast to class [Ljava.lang.String; ([Ljava.lang.Object; and [Ljava.lang.String; are in module java.base of loader 'bootstrap') For an ArrayList, toArray() creates a genuine Object[], and it cannot be cast to String[] even if every element is a string. If the element type does not fit the array type, toArray(new String[0]) throws ArrayStoreException. Passing new String[0] is the common idiom: the method allocates an array of the right size itself.
New array, same objects
The array returned by toArray() is independent of the collection: assigning array[0] = ... does not change the collection. But the elements themselves are not copied — the array holds references to the same objects. Put new StringBuilder("a") into a list, call ((StringBuilder) list.toArray()[0]).append("!") and print the list: you get [a!]. With immutable String or Integer you never notice; with mutable objects it matters.
Iterating a collection: iterator, forEach and stream
Any Java collection can be iterated in three ways: with a for-each loop (it works because Collection extends Iterable), with the forEach() method and a lambda, or through stream(). You need an explicit iterator() only when you remove elements while iterating.
Collection<String> words = List.of("set", "list", "queue");
words.forEach(System.out::println);
long longWords = words.stream()
.filter(w -> w.length() > 3)
.count();
System.out.println("Longer than three letters: " + longWords); Output:
set
list
queue
Longer than three letters: 2 stream() never changes the source collection: filter() only selects elements for further processing. To remove elements from the collection itself, use removeIf(). The iteration order is not part of the Collection contract: a List iterates by index, a HashSet in no guaranteed order.
Optional operations and UnsupportedOperationException
The methods that modify a collection (add, addAll, remove, removeAll, retainAll, removeIf, clear) are marked as optional operations in the Collection Javadoc. An implementation may choose not to support them and throw UnsupportedOperationException instead. That is how unmodifiable collections work: List.of(), Collections.unmodifiableCollection(), and — for size-changing operations — Arrays.asList().
Back to the example from the start of the lesson:
import java.util.Arrays;
import java.util.List;
public class OptionalOperationsExample {
public static void main(String[] args) {
List<String> fixed = Arrays.asList("a", "b");
List<String> immutable = List.of("a", "b");
System.out.println(fixed.remove("z")); // false
System.out.println(immutable.remove("z")); // exception
}
} Output:
false
Exception in thread "main" java.lang.UnsupportedOperationException The Javadoc allows both reactions: if a call would not change the collection, an optional operation may throw the exception, but it does not have to. The Arrays.asList() list searches for the element first and throws only when it really has to remove something: fixed.remove("a") ends with UnsupportedOperationException. Collections created by List.of() refuse immediately, without looking at the argument. The same difference shows up with removeIf(): on Arrays.asList(1, 2, 3) the condition n -> n > 5 returns false, while n -> n > 1 throws the exception.
The declared type says nothing about mutability
A declaration like Collection<String> c does not tell you whether the object supports add(). If a method receives a collection from outside and needs to modify it, make a copy first: new ArrayList<>(c). Code that “works” in tests with an ArrayList breaks in production the moment someone passes it a List.of(...).
The ways to create an unmodifiable collection (List.of, List.copyOf, Collections.unmodifiableList, stream().toList()) and how they differ on null and on the link to the source are compared in Immutable collections. Arrays.asList() refuses add() and remove() because it is a fixed-size view of the original array.
How Collection compares elements: equals and hashCode
The contains(), remove(), containsAll(), removeAll() and retainAll() methods find elements with equals(): an element matches if o.equals(e) returns true (for null — if e == null). So for your own classes stored in collections you need to override the equals() method; otherwise references are compared.
class PointClass {
int x, y;
PointClass(int x, int y) { this.x = x; this.y = y; }
}
record Point(int x, int y) {} // equals() and hashCode() are generated automatically
Collection<PointClass> a = new ArrayList<>(List.of(new PointClass(1, 2)));
Collection<Point> b = new ArrayList<>(List.of(new Point(1, 2)));
System.out.println(a.contains(new PointClass(1, 2))); // false - different objects
System.out.println(b.contains(new Point(1, 2))); // true Hash-based collections (HashSet, HashMap keys) compare hashCode() first and only then equals(), so there the two methods must be overridden together and stay consistent with each other.
When two collections are equal
Collection itself does not define when two collections are equal — its subinterfaces do. Two Lists are equal if they contain equal elements in the same order, regardless of the implementation class. Two Sets are equal if they contain the same elements. A list is never equal to a set. What does this code print?
List<Integer> arrayList = new ArrayList<>(List.of(1, 2, 3));
List<Integer> linkedList = new LinkedList<>(List.of(1, 2, 3));
Set<Integer> hashSet = new HashSet<>(List.of(1, 2, 3));
Set<Integer> treeSet = new TreeSet<>(List.of(3, 2, 1));
Deque<Integer> deque1 = new ArrayDeque<>(List.of(1, 2, 3));
Deque<Integer> deque2 = new ArrayDeque<>(List.of(1, 2, 3));
System.out.println(arrayList.equals(linkedList));
System.out.println(hashSet.equals(treeSet));
System.out.println(arrayList.equals(hashSet));
System.out.println(deque1.equals(deque2)); Output:
true
true
false
false The last line surprises most people. ArrayDeque is neither a List nor a Set and does not override equals(), so two deques with the same elements are compared by reference. The same applies to PriorityQueue and to the map.values() collection. The hash code also depends on the subinterface: for a List it takes element order into account, for a Set it is the sum of the elements' hash codes.
How the Collection interface changed across Java versions
The Collection interface appeared in Java 1.2 together with the whole Collections Framework. Since then it has been extended with default methods: they have a body right in the interface, so new methods could be added without breaking existing classes that implement Collection.
| Version | What changed |
|---|---|
| Java 1.2 | The Collection interface with abstract methods: add, remove, contains, size, iterator, toArray and others |
| Java 5 | The interface became generic: Collection<E>; it now extends Iterable, which enables the for-each loop |
| Java 8 | Default methods removeIf(), stream(), parallelStream(), spliterator(); forEach() in Iterable |
| Java 11 | Default method toArray(IntFunction<T[]>) |
| Java 21 | Subinterface SequencedCollection with getFirst(), getLast(), reversed() — see Collection hierarchy in Java |
Each branch of the hierarchy adds its own methods on top of Collection: List adds index access, Set guarantees unique elements, Queue and Deque add queue and stack operations. The core interfaces are compared in Core collection interfaces in Java, and what a list, stack, queue and set are as concepts is explained in Data Structures in Java.
Frequently Asked Questions
Why do contains() and remove() take Object instead of E?
Because elements are found with equals(), and by its contract objects of different classes may be equal. The downside is that the compiler cannot catch a type mistake: for List<Long> ids = List.of(1L, 2L), the call ids.contains(1) compiles and returns false, because 1 is boxed to an Integer, and an Integer is never equal to a Long. The correct call is ids.contains(1L).
Why is there no get(int index) method in Collection?
Sets and queues have no element positions, so index access exists only in the List subinterface. To take the “first” element of an arbitrary collection, use iterator().next() or stream().findFirst(); for a HashSet, which element comes first is not defined in advance.
How do I write my own collection without implementing every Collection method?
Extend the abstract class AbstractCollection and implement just two methods: iterator() and size(). The other methods (contains, toArray, containsAll, toString) are already written on top of them. For a modifiable collection, also override add() and the iterator's remove(); otherwise they throw UnsupportedOperationException.
What does addAll() return if only some elements were added?
true, as long as at least one element was added: the method reports whether the collection changed. For a HashSet that already had some of the elements, the result is still true; you get false only when nothing was added. See the output in Example: add, remove, removeAll, retainAll.
Comments