Command Palette

Search for a command to run...

[Advanced Java] The Collections Utility Class: Algorithms, Wrappers and Factories

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.

One class name and its three groups: algorithms, wrappers, factories

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

GroupWhat it does to your dataReturnsExamples
AlgorithmsRewrites it in placemostly voidsort, reverse, shuffle, swap, rotate, fill, replaceAll
QueriesReads it, changes nothinga valuebinarySearch, min, max, frequency, disjoint
WrappersNothing — it keeps a reference to ita view of your collectionunmodifiableXxx, synchronizedXxx, checkedXxx
FactoriesNothing — your data is not involveda fixed collectionemptyList, 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.

One list stepping through sort, reverse, rotate and swap, and the binarySearch return value decoded

The cost of each is a count of element operations, not a stopwatch reading, and the counts come straight out of the implementations:

MethodWhat it doesElement operations
sort(list)Delegates to list.sort(null)O(n log n) comparisons
reverse(list)Swaps the ends inwardsn/2 swaps
shuffle(list, rnd)Fisher-Yates from the end downwardsn-1 swaps
swap(list, i, j)Exchanges two positions1 swap
rotate(list, d)Walks the permutation cyclesexactly n set calls
fill(list, obj)Writes one value everywheren set calls
replaceAll(list, a, b)Replaces every equal elementn reads, up to n set calls
binarySearch(list, key)Halves the rangeabout log2(n) comparisons
min / maxOne passn-1 comparisons
frequency(c, obj)Counts equal elementsn equals calls
disjoint(c1, c2)Iterates one, contains on the othersize(iterated) contains calls
addAll(c, e...)Appends a varargs arrayn add calls
nCopies(n, obj)Builds a fixed listone 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 you n builders. It gives you one builder, n times. 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.

Three wrappers pointing at one ArrayList, with the owner's own reference bypassing all of them

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 ==.

An unmodifiable wrapper tracking the owner against a copyOf snapshot that does not

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.

ExpressionCaller can writeTracks the ownerAllocates
Collections.unmodifiableList(src)noyesone wrapper
List.copyOf(src)nonoone list plus n references
List.copyOf(alreadyImmutable)nonot applicablenothing
Collections.emptyList()nonot applicablenothing
Arrays.asList(array)set yes, add notracks the arrayone 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 callWhat to write nowVerdict
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 singletonsFine — still the cheapest empty list
Collections.singletonList(x)List.of(x)Prefer List.of — it rejects null
Collections.synchronizedMap(new HashMap<>())ConcurrentHashMapObsolete for new code
Arrays.asList(a, b, c)List.of(a, b, c) unless you need setPrefer List.of
Collections.unmodifiableList(field)keep it, when a live view is what you meanStill correct
Collections.checkedList(list, T.class)keep itUnderused, 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.

Related Posts

[Advanced Java] The Java Stream API: map, filter, reduce and collect

The Java Stream API on OpenJDK 21: the source-intermediate-terminal pipeline, laziness proved with an interleaved println trace, map, filter, all three reduce overloads, collect and the Collectors factory, primitive streams and the allocation cost of boxing, and the traps around peek, findAny, stateful lambdas and parallelStream.

[Advanced Java] Nested, Inner, Local and Anonymous Classes in Java

Static nested, inner, local and anonymous classes in Java on OpenJDK 21: the synthetic this$0 field proved with javap, Outer.this and outer.new Inner(), the memory leak an inner class causes, effectively final capture, the Outer$1 class file, and a concrete comparison of anonymous classes against lambdas.

[Advanced Java] Set Implementations in Java: HashSet, LinkedHashSet and TreeSet

HashSet, LinkedHashSet and TreeSet on OpenJDK 21: what a Set actually guarantees, the HashMap hiding inside HashSet, NavigableSet lookups, the two different rules that decide a duplicate, compareTo that disagrees with equals, ClassCastException and null in a TreeSet, EnumSet, and why removeAll can go quadratic.

[Advanced Java] Queue, Deque, Stack and PriorityQueue in Java

Queue, Deque, Stack and PriorityQueue on OpenJDK 21: the two families of Queue methods and exactly what each one does on an empty and a full queue, the full Deque method table and the stack view, why Stack extends Vector is a design mistake with both surprises demonstrated, and proof that a PriorityQueue is a binary heap whose toString and iterator are not in priority order.