java.util.Collections is a class with a private constructor and seventy-one public static methods, verified by reflection on JDK 21. It is not final, but the private constructor means nobody instantiates or extends it: it is a namespace, not a type. Almost every tutorial presents it as an alphabetical list of those methods, which is the least useful shape the material can take: the list is too long to memorise and gives no hint that its entries behave in completely different ways.
There are only three kinds of thing in the class. Some methods are algorithms that rewrite the list you passed in. Some are wrappers that hand back a small object standing in front of your collection. Some are factories that hand back a fixed collection of their own. Which group a method belongs to decides whether it mutates your data, aliases your data, or has nothing to do with your data at all — and that is the only classification worth carrying around.
![]()
Every program, every line of output and every exception message below was compiled and run on OpenJDK 21.0.6 (arm64). The Javadoc passages are quoted from the src.zip shipped with that JDK.
The three kinds of thing inside java.util.Collections
| Group | What it does to your data | Returns | Examples |
|---|---|---|---|
| Algorithms | Rewrites it in place | mostly void | sort, reverse, shuffle, swap, rotate, fill, replaceAll |
| Queries | Reads it, changes nothing | a value | binarySearch, min, max, frequency, disjoint |
| Wrappers | Nothing — it keeps a reference to it | a view of your collection | unmodifiableXxx, synchronizedXxx, checkedXxx |
| Factories | Nothing — your data is not involved | a fixed collection | emptyList, singletonList, nCopies |
The row that causes real bugs is the third one. A wrapper is not a copy and not a new collection; it is one small object holding one reference to yours, forwarding every call. Everything surprising about Collections follows from taking that sentence literally.
Algorithms: the methods that rewrite your list
These take a List and return nothing. There is no result to assign because the result is the list you passed in.
import java.util.*;
public class Algos {
public static void main(String[] args) {
List<Integer> l = new ArrayList<>(List.of(5, 3, 9, 1, 7, 4));
System.out.println("start " + l);
Collections.sort(l);
System.out.println("sort(l) " + l);
Collections.reverse(l);
System.out.println("reverse(l) " + l);
Collections.rotate(l, 2);
System.out.println("rotate(l, 2) " + l);
Collections.swap(l, 0, 5);
System.out.println("swap(l, 0, 5) " + l);
Collections.shuffle(l, new Random(42));
System.out.println("shuffle(l, r42) " + l);
Collections.replaceAll(l, 9, 0);
System.out.println("replaceAll 9->0 " + l);
System.out.println("min / max " + Collections.min(l) + " / " + Collections.max(l));
List<String> words = new ArrayList<>(List.of("ant", "buffalo", "cow", "eel"));
System.out.println("max by length "
+ Collections.max(words, Comparator.comparingInt(String::length)));
List<String> tags = new ArrayList<>(List.of("red", "blue", "red", "green", "red"));
System.out.println("frequency red " + Collections.frequency(tags, "red"));
System.out.println("disjoint bw " + Collections.disjoint(tags, List.of("black", "white")));
System.out.println("disjoint br " + Collections.disjoint(tags, List.of("black", "red")));
List<String> sink = new ArrayList<>();
Collections.addAll(sink, "x", "y", "z");
System.out.println("addAll " + sink);
Collections.fill(sink, "-");
System.out.println("fill " + sink);
System.out.println("nCopies(4, ab) " + Collections.nCopies(4, "ab"));
}
}
start [5, 3, 9, 1, 7, 4]
sort(l) [1, 3, 4, 5, 7, 9]
reverse(l) [9, 7, 5, 4, 3, 1]
rotate(l, 2) [3, 1, 9, 7, 5, 4]
swap(l, 0, 5) [4, 1, 9, 7, 5, 3]
shuffle(l, r42) [1, 4, 5, 3, 7, 9]
replaceAll 9->0 [1, 4, 5, 3, 7, 0]
min / max 0 / 7
max by length buffalo
frequency red 3
disjoint bw true
disjoint br false
addAll [x, y, z]
fill [-, -, -]
nCopies(4, ab) [ab, ab, ab, ab]
shuffle(l, new Random(42)) is the two-argument overload. The one-argument version uses a shared internal source of randomness, so it is not reproducible; passing a seeded Random is what makes a shuffled example testable.

The cost of each is a count of element operations, not a stopwatch reading, and the counts come straight out of the implementations:
| Method | What it does | Element operations |
|---|---|---|
sort(list) | Delegates to list.sort(null) | O(n log n) comparisons |
reverse(list) | Swaps the ends inwards | n/2 swaps |
shuffle(list, rnd) | Fisher-Yates from the end downwards | n-1 swaps |
swap(list, i, j) | Exchanges two positions | 1 swap |
rotate(list, d) | Walks the permutation cycles | exactly n set calls |
fill(list, obj) | Writes one value everywhere | n set calls |
replaceAll(list, a, b) | Replaces every equal element | n reads, up to n set calls |
binarySearch(list, key) | Halves the range | about log2(n) comparisons |
min / max | One pass | n-1 comparisons |
frequency(c, obj) | Counts equal elements | n equals calls |
disjoint(c1, c2) | Iterates one, contains on the other | size(iterated) contains calls |
addAll(c, e...) | Appends a varargs array | n add calls |
nCopies(n, obj) | Builds a fixed list | one object allocated |
Two of these are less obvious than they look. rotate on an indexed list is not three reversals; rotate1 walks the permutation cycles and stops when nMoved equals size, so every element is written exactly once. And disjoint chooses which side to iterate: if either argument is a Set it calls contains on the Set, and if neither is, it iterates the smaller collection. Passing your big collection first is not a mistake, because the method reorders the work itself.
sort, min, max and binarySearch all have a second overload taking a Comparator, which is how Collections.max(words, Comparator.comparingInt(String::length)) returned buffalo above. Building comparators is the subject of the previous article in this course; here they are just arguments.
binarySearch returns the insertion point when the key is absent
Most people write if (index >= 0) and throw away everything else. The Javadoc is explicit that the negative branch carries information:
@return the index of the search key, if it is contained in the list;
otherwise, (-(insertion point) - 1). The
insertion point is defined as the point at which the
key would be inserted into the list: the index of the first
element greater than the key, or list.size() if all
elements in the list are less than the specified key. Note
that this guarantees that the return value will be >= 0 if
and only if the key is found.
Run it against a sorted list and the encoding is easy to read off:
List<String> sorted = new ArrayList<>(List.of("ant", "bee", "cow", "eel"));
System.out.println("list " + sorted);
for (String probe : List.of("ant", "eel", "dog", "aardvark", "zebra")) {
int r = Collections.binarySearch(sorted, probe);
System.out.println("binarySearch(\"" + probe + "\") = " + r + " "
+ (r >= 0 ? "found at index " + r
: "absent, insertion point " + (-r - 1)));
}
list [ant, bee, cow, eel]
binarySearch("ant") = 0 found at index 0
binarySearch("eel") = 3 found at index 3
binarySearch("dog") = -4 absent, insertion point 3
binarySearch("aardvark") = -1 absent, insertion point 0
binarySearch("zebra") = -5 absent, insertion point 4
-1 is why the encoding is not simply -insertionPoint: index 0 and "would go at index 0" would otherwise both be zero. The offset of one buys the guarantee in the last sentence of the Javadoc.
That number is worth keeping, because it is exactly the argument to add. Two utilities fall out of it in three lines each:
/** Inserts value and keeps the list sorted. Returns false if it was already there. */
static <T extends Comparable<? super T>> boolean insertSorted(List<T> list, T value) {
int i = Collections.binarySearch(list, value);
if (i >= 0) return false;
list.add(-i - 1, value);
return true;
}
/** Number of elements strictly less than key, for a list sorted ascending. */
static <T extends Comparable<? super T>> int countBelow(List<T> list, T key) {
int i = Collections.binarySearch(list, key);
return i >= 0 ? i : -i - 1;
}
public static void main(String[] args) {
List<String> log = new ArrayList<>(List.of("09:00", "10:30", "13:15"));
for (String t : List.of("11:45", "08:15", "23:59", "10:30")) {
System.out.println("insert " + t + " -> " + insertSorted(log, t) + " " + log);
}
System.out.println("entries before 11:00 : " + countBelow(log, "11:00"));
System.out.println("entries before 09:00 : " + countBelow(log, "09:00"));
}
insert 11:45 -> true [09:00, 10:30, 11:45, 13:15]
insert 08:15 -> true [08:15, 09:00, 10:30, 11:45, 13:15]
insert 23:59 -> true [08:15, 09:00, 10:30, 11:45, 13:15, 23:59]
insert 10:30 -> false [08:15, 09:00, 10:30, 11:45, 13:15, 23:59]
entries before 11:00 : 3
entries before 09:00 : 1
insertSorted maintains a sorted list at log2(n) comparisons plus one shift per insertion, instead of appending and re-sorting the whole thing. countBelow answers a range question with no loop at all. Both are one line of arithmetic on a return value that most code discards.
Which algorithm runs depends on RandomAccess
Several of these methods contain an instanceof RandomAccess test and pick between an index-driven implementation and an iterator-driven one, each with a size threshold below which the indexed path is taken anyway. An earlier article in this course instrumented that branch directly, wrapping the same elements in two delegating lists whose class bodies were identical apart from the marker interface, and counted the get, set and listIterator calls the JDK made on each; the two paths turned out to be genuinely different algorithms, not a micro-optimisation.
The consequence for this article is small but worth stating: the operation counts in the table above describe the indexed path. Hand Collections.shuffle a LinkedList large enough to miss the threshold and the JDK will copy it to an array, shuffle the array, and write it back through a ListIterator, precisely so that it does not walk a linked list ten thousand times. You do not have to do anything to get that; you only have to know that the method is allowed to.
nCopies gives you one object n times, not n objects
The Javadoc says what it returns, and the parenthesis is the whole story:
Returns an immutable list consisting of n copies of the
specified object. The newly allocated data object is tiny (it contains
a single reference to the data object). This method is useful in
combination with the List.addAll method to grow lists.
"A single reference to the data object" means the list is not n elements. It is one element, reported n times. Identity comparison settles it:
List<StringBuilder> four = Collections.nCopies(4, new StringBuilder("row"));
System.out.println("list " + four);
System.out.println("size " + four.size());
System.out.println("class " + four.getClass().getName());
System.out.println("get(0) == get(1) " + (four.get(0) == four.get(1)));
System.out.println("get(0) == get(3) " + (four.get(0) == four.get(3)));
four.get(0).append("!");
System.out.println("after append " + four);
List<StringBuilder> real = new ArrayList<>();
for (int i = 0; i < 4; i++) real.add(new StringBuilder("row"));
System.out.println("real get(0)==(1) " + (real.get(0) == real.get(1)));
List<String> padded = new ArrayList<>(List.of("a"));
padded.addAll(Collections.nCopies(3, ""));
System.out.println("padded " + padded + " size " + padded.size());
list [row, row, row, row]
size 4
class java.util.Collections$CopiesList
get(0) == get(1) true
get(0) == get(3) true
after append [row!, row!, row!, row!]
real get(0)==(1) false
padded [a, , , ] size 4
Mutating "one" element mutated all four, because there is only one. For immutable elements — String, Integer, an empty marker — that is a feature, and list.addAll(Collections.nCopies(3, "")) pads a list with three blanks while allocating nothing per slot. For a mutable element it is a shared-state bug waiting to be found somewhere else entirely.
⚠️
Collections.nCopies(n, new StringBuilder())does not give younbuilders. It gives you one builder,ntimes. If the element is mutable, build the list in a loop.
Wrappers are views, not copies
unmodifiableXxx, synchronizedXxx and checkedXxx are the same construction three times: a small object that holds one reference to your collection, intercepts the write path, and forwards everything else.

One write through the original reference proves it, because all three report it:
List<String> backing = new ArrayList<>(List.of("a", "b"));
List<String> unmod = Collections.unmodifiableList(backing);
List<String> sync = Collections.synchronizedList(backing);
List<String> chk = Collections.checkedList(backing, String.class);
System.out.println("before backing=" + backing + " unmodifiable=" + unmod
+ " synchronized=" + sync + " checked=" + chk);
backing.add("c");
System.out.println("after backing=" + backing + " unmodifiable=" + unmod
+ " synchronized=" + sync + " checked=" + chk);
System.out.println("unmodifiable class " + unmod.getClass().getName());
System.out.println("synchronized class " + sync.getClass().getName());
System.out.println("checked class " + chk.getClass().getName());
System.out.println("synchronized.iterator " + sync.iterator().getClass().getName());
before backing=[a, b] unmodifiable=[a, b] synchronized=[a, b] checked=[a, b]
after backing=[a, b, c] unmodifiable=[a, b, c] synchronized=[a, b, c] checked=[a, b, c]
unmodifiable class java.util.Collections$UnmodifiableRandomAccessList
synchronized class java.util.Collections$SynchronizedRandomAccessList
checked class java.util.Collections$CheckedRandomAccessList
synchronized.iterator java.util.ArrayList$Itr
Three inner classes, one list. Note the last line before moving on — it is the whole of the next-but-one subsection.
unmodifiableList blocks the caller, not the owner
Article 1 of this course demonstrated the consequence in an encapsulation setting: Collections.unmodifiableList returns a live view that keeps tracking the owner's writes, while List.copyOf returns a snapshot that does not. The after line above is the same fact from the other side — nobody touched unmod, and unmod changed.
What is worth adding here is that the wrapper is idempotent in a modern JDK, which older articles get wrong. The Javadoc carries an @implNote saying "This method may return its argument if the argument is already unmodifiable", and the method body starts with a class check for exactly that — present in both the JDK 17 and JDK 21 sources installed here. synchronizedList has no such check. Running both confirms it:
unmodifiable.add("d") threw java.lang.UnsupportedOperationException
unmodifiableList(unmodifiable) == unmodifiable true
synchronizedList(synchronized) == synchronized false
So wrapping an already-unmodifiable list twice costs nothing, while wrapping a synchronized list twice really does allocate a second wrapper that locks on a different object from the first. Wrap once, at the boundary.
synchronizedList does not cover your loop
Every method on a synchronizedList is guarded. A loop is not a method. The Javadoc is unusually direct about it:
It is imperative that the user manually synchronize on the returned
list when traversing it via Iterator, Spliterator or Stream:
List list = Collections.synchronizedList(new ArrayList());
...
synchronized (list) {
Iterator i = list.iterator(); // Must be in synchronized block
while (i.hasNext())
foo(i.next());
}
Failure to follow this advice may result in non-deterministic behavior.
The reason is in the output above: sync.iterator() returned java.util.ArrayList$Itr, the backing list's own iterator, with no locking anywhere in it. iterator() is synchronized; the object it hands back is not. Two threads, one appending and one walking, make the difference measurable as a count of failures:
List<Integer> list = Collections.synchronizedList(new ArrayList<>());
for (int i = 0; i < 500; i++) list.add(i);
Thread writer = new Thread(() -> { for (int i = 0; i < 2000; i++) list.add(i); });
Thread reader = new Thread(() -> {
long s = 0;
for (int v : list) s += v; // every call locks; the loop does not
});
Running that pair 200 times, and then the same pair again with the reader's loop wrapped in synchronized (list):
rounds 200
for (int v : list) failures 158
synchronized (list) { ... } failures 0
The interesting number is the second one. The first varies from run to run because it depends on thread interleaving; the second is zero because the block makes the whole traversal one critical section. Per-call locking never covers a sequence of calls, which is the same reason a synchronizedMap cannot express "get, and if absent, put" safely.
checkedList makes heap pollution fail at the insertion
This is the least-used wrapper and the one with the clearest payoff. Generics are erased, so an unchecked cast or a raw type can drop an Integer into a List<String> and the compiler will only warn:
warning: [unchecked] unchecked call to add(E) as a member of the raw type List
raw.add(value);
^
If you ignore that warning, nothing happens — until something reads the element and the synthetic cast fails, arbitrarily far from the insertion. The Javadoc for checkedCollection names this exact scenario as a use case:
Another use of dynamically typesafe views is debugging. Suppose a
program fails with a ClassCastException, indicating that an
incorrectly typed element was put into a parameterized collection.
Unfortunately, the exception can occur at any time after the erroneous
element is inserted, so it typically provides little or no information
as to the real source of the problem. If the problem is reproducible,
one can quickly determine its source by temporarily modifying the
program to wrap the collection with a dynamically typesafe view.
The same insertion into a plain list and into a checked one:
@SuppressWarnings({"unchecked", "rawtypes"})
static void pollute(List raw, Object value) {
raw.add(value); // unchecked call, compiles with a warning
}
public static void main(String[] args) {
List<String> plain = new ArrayList<>();
plain.add("ok");
pollute(plain, 42); // an Integer into a List<String>
System.out.println("plain list accepted it : " + plain);
try {
String s = plain.get(1); // the failure lands here, far from the cause
System.out.println(s);
} catch (ClassCastException e) {
System.out.println("reading it threw : " + e.getMessage());
}
List<String> checked = Collections.checkedList(new ArrayList<>(), String.class);
checked.add("ok");
try {
pollute(checked, 42);
} catch (ClassCastException e) {
System.out.println("checked add threw : " + e.getMessage());
}
System.out.println("checked list unchanged : " + checked);
}
plain list accepted it : [ok, 42]
reading it threw : class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
checked add threw : Attempt to insert class java.lang.Integer element into collection with element type class java.lang.String
checked list unchanged : [ok]
Compare the two messages. The first names two classes and a class loader and says nothing about where the bad value came from; the stack trace points at the read. The second names the offending element, the collection's element type, and throws on the add frame — the line that is actually wrong. The checked list also stayed clean, so the program can keep running.
The cost is one isInstance check per insertion. That is cheap enough to leave on permanently for a collection you hand to code you do not control, and cheap enough to switch on temporarily when a ClassCastException appears somewhere that cannot possibly have produced it.
Immutable factories versus unmodifiable wrappers
Collections has factories for fixed collections that predate List.of by two decades. They still exist, they behave differently, and the differences are visible with ==.

System.out.println("emptyList() == emptyList() " + (Collections.emptyList() == Collections.emptyList()));
System.out.println("List.of() == List.of() " + (List.of() == List.of()));
System.out.println("emptyList() == List.of() " + (Collections.emptyList() == List.of()));
System.out.println("emptyList class " + Collections.emptyList().getClass().getName());
System.out.println("List.of() class " + List.of().getClass().getName());
System.out.println("singletonList class " + Collections.singletonList("a").getClass().getName());
System.out.println("List.of(\"a\") class " + List.of("a").getClass().getName());
System.out.println("singletonList == singletonList "
+ (Collections.singletonList("a") == Collections.singletonList("a")));
List<String> viaArrays = Arrays.asList("a", null, "c");
System.out.println("Arrays.asList with null " + viaArrays);
try {
List.of("a", null, "c");
} catch (NullPointerException e) {
System.out.println("List.of with null threw " + e.getClass().getName());
}
System.out.println("new ArrayList<>().contains(null) " + new ArrayList<>(viaArrays).contains(null));
try {
System.out.println(List.of("a").contains(null));
} catch (NullPointerException e) {
System.out.println("List.of(\"a\").contains(null) threw " + e.getClass().getName());
}
List<String> mutable = new ArrayList<>(List.of("a", "b"));
List<String> immutable = List.of("a", "b");
List<String> unmodView = Collections.unmodifiableList(mutable);
System.out.println("copyOf(ArrayList) == source " + (List.copyOf(mutable) == mutable));
System.out.println("copyOf(List.of) == source " + (List.copyOf(immutable) == immutable));
System.out.println("copyOf(unmod view) == source " + (List.copyOf(unmodView) == unmodView));
List<String> snap = List.copyOf(mutable);
mutable.add("c");
System.out.println("owner mutated: view=" + unmodView + " copyOf snapshot=" + snap);
emptyList is a singleton, singletonList is not
emptyList() == emptyList() true
List.of() == List.of() true
emptyList() == List.of() false
emptyList class java.util.Collections$EmptyList
List.of() class java.util.ImmutableCollections$ListN
singletonList class java.util.Collections$SingletonList
List.of("a") class java.util.ImmutableCollections$List12
singletonList == singletonList false
Collections.emptyList() returns the same instance every call — there is one EmptyList in the JVM and it is handed out to everyone, which is safe precisely because it can never change. List.of() does the same thing with a different singleton, so the two are equal by equals and different by ==. Never write == between collections in real code; it is used here only to expose the identity.
singletonList allocates a new object each call. It is smaller than List.of("a") in intent but not in identity, so no caching is happening.
List.of rejects null where Arrays.asList accepts it
Arrays.asList with null [a, null, c]
List.of with null threw java.lang.NullPointerException
new ArrayList<>().contains(null) true
List.of("a").contains(null) threw java.lang.NullPointerException
The immutable collections introduced in Java 9 are null-hostile by design, and the hostility is not limited to construction. Arrays.asList happily builds a list with a null in the middle, and an ArrayList copy of it answers contains(null) with true. List.of refuses at construction, and List.of("a").contains(null) does not answer false — it throws. Code that migrates from Arrays.asList to List.of and happens to probe with a possibly-null value gets a NullPointerException out of a read-only query, which is a surprising place to find one.
List.copyOf may not copy at all
copyOf(ArrayList) == source false
copyOf(List.of) == source true
copyOf(unmod view) == source false
owner mutated: view=[a, b, c] copyOf snapshot=[a, b]
List.copyOf of an ArrayList copies. List.copyOf of a list that is already one of the Java 9 immutable implementations returns the very same object, because copying something that cannot change is pure waste. List.copyOf of a Collections.unmodifiableList wrapper does copy, and it has to: the wrapper is unmodifiable through that handle, but the list behind it is not, which the last line shows in one run.
That last line is the summary of this whole section. The view and the snapshot were taken at the same moment; one of them moved.
| Expression | Caller can write | Tracks the owner | Allocates |
|---|---|---|---|
Collections.unmodifiableList(src) | no | yes | one wrapper |
List.copyOf(src) | no | no | one list plus n references |
List.copyOf(alreadyImmutable) | no | not applicable | nothing |
Collections.emptyList() | no | not applicable | nothing |
Arrays.asList(array) | set yes, add no | tracks the array | one small wrapper |
What is now obsolete, and what still earns its place
Several methods here are older than their replacements and stayed for compatibility. Collections.sort is the clearest case — since Java 8 it is a one-line forwarder:
public static <T extends Comparable<? super T>> void sort(List<T> list) {
list.sort(null);
}
That is the entire method body in JDK 21. Override sort on a list subclass and the delegation is visible at runtime:
static class Loud<E> extends ArrayList<E> {
Loud(Collection<? extends E> c) { super(c); }
@Override public void sort(Comparator<? super E> c) {
System.out.println(" Loud.sort(" + (c == null ? "null" : "comparator") + ") called");
super.sort(c);
}
}
Collections.sort(list):
Loud.sort(null) called
result [1, 2, 3]
Arrays.asList is the other one people reach for out of habit. It is a fixed-size view over the array you passed, not a list of its own:
List<String> fixed = Arrays.asList("a", "b", "c");
System.out.println("Arrays.asList class " + fixed.getClass().getName());
fixed.set(0, "z");
System.out.println("set(0, \"z\") ok " + fixed);
try {
fixed.add("d");
} catch (UnsupportedOperationException e) {
System.out.println("add threw " + e.getClass().getName());
}
String[] src = { "a", "b" };
List<String> viewOfArray = Arrays.asList(src);
viewOfArray.set(1, "CHANGED");
System.out.println("array after set " + Arrays.toString(src));
Arrays.asList class java.util.Arrays$ArrayList
set(0, "z") ok [z, b, c]
add threw java.lang.UnsupportedOperationException
array after set [a, CHANGED]
set writes through into the original array. add throws because the array cannot grow. It is a wrapper wearing a factory's name.
| Legacy call | What to write now | Verdict |
|---|---|---|
Collections.sort(list) | list.sort(null) or list.sort(cmp) | Obsolete — it is literally that call |
Collections.unmodifiableList(new ArrayList<>(src)) | List.copyOf(src) | Obsolete — two objects for one job |
Collections.emptyList() | either, both are singletons | Fine — still the cheapest empty list |
Collections.singletonList(x) | List.of(x) | Prefer List.of — it rejects null |
Collections.synchronizedMap(new HashMap<>()) | ConcurrentHashMap | Obsolete for new code |
Arrays.asList(a, b, c) | List.of(a, b, c) unless you need set | Prefer List.of |
Collections.unmodifiableList(field) | keep it, when a live view is what you mean | Still correct |
Collections.checkedList(list, T.class) | keep it | Underused, not obsolete |
The ones worth keeping are easy to name. binarySearch on a sorted List, because nothing replaced it. nCopies, frequency and disjoint, because writing them out is longer and no clearer. unmodifiableXxx when a live view is genuinely what the API means. checkedXxx, both as a debugging tool and as a guard on a collection crossing a trust boundary. And emptyList, which allocates nothing.
The ones I would not write again are Collections.sort when list.sort is right there, synchronizedMap when ConcurrentHashMap exists, and unmodifiableList(new ArrayList<>(x)) when List.copyOf(x) says the same thing in one object and one call.
Frequently asked questions
Is Collections the same as Collection?
No, and the near-collision is unfortunate. java.util.Collection is the root interface implemented by List, Set and Queue. java.util.Collections is an uninstantiable utility class of static methods that operate on those. One is a type you implement, the other is a toolbox you call.
Why do the algorithms return void instead of a new list?
They were designed in Java 2 to mutate in place, before the language had anything better. The modern equivalents return new collections and live elsewhere: list.sort still mutates, but a stream pipeline collecting into a new list does not touch the source. If you need the original preserved, copy first and sort the copy.
Does Collections.unmodifiableList make my list thread-safe?
No. It prevents writes through that handle only. The owner can still mutate the backing list from another thread, and a reader iterating the view will see a ConcurrentModificationException for a write it did not make. Unmodifiable is about permissions, not about memory visibility or atomicity.
When should I use checkedList rather than fixing the warning?
Fix the warning when you can see it. checkedList is for the cases where you cannot: a collection handed to a library, a codebase where the unchecked cast is in code you do not own, or a ClassCastException in production whose stack trace points at a read rather than the insertion that caused it. Wrapping the collection turns the second case into the third.
Is binarySearch faster than contains on a small list?
Not necessarily, and the comparison is the wrong one. binarySearch requires the list to be sorted, and keeping it sorted costs something on every insertion. On a list that is already sorted for other reasons, it is log2(n) comparisons against n. On a list you would have to sort first, contains wins until you are doing many searches per sort.
Conclusion
java.util.Collections stops being a list of names once you sort its contents into algorithms that rewrite your data, wrappers that alias it, and factories that replace it. The algorithms return void because the result is the argument. The wrappers are one object holding one reference, which is why an unmodifiable view keeps changing and a synchronized list still needs your own block around a loop. The factories hand back something fixed, and List.of and List.copyOf have quietly made half of the old ones redundant. The two details worth taking away in full are the binarySearch insertion point, which turns a discarded negative number into a sorted insert, and checkedList, which moves a ClassCastException from the read that suffered it to the insertion that caused it.
That closes Part 2 of this course. Parts 1 and 2 covered the language and the collections framework — the shapes your data lives in and the tools that operate on them. Part 3 begins with the Stream API, which takes the same collections and replaces the loop with a pipeline: filter, map, reduce, laziness, and the point where a stream actually runs.