You have an ArrayList of orders and you need it sorted, searched, shuffled for a sample, or handed to a legacy API that still wants a synchronized list. None of that is a new collection type. It is static methods on Collections and Arrays.
Collections and Arrays are helpers around the types you already picked — not a fourth List. This post is the methods people actually hit. The Collections hub owns views vs copies and unmodifiable vs immutable; factories own List.of. We use those words and move.
Same checkout records as the rest of the series:
public record LineItem(String sku, int quantity, BigDecimal unitPrice) {}
public record Order(
String id,
String customerEmail,
List<LineItem> items,
BigDecimal total,
boolean active) {}
Sort in place, then search
Collections.sort mutates the list. So does List.sort (Java 8), which is the form you want in new code. Both are stable.
List<Order> pack = new ArrayList<>();
pack.add(new Order("o-100", "ada@example.com", List.of(), new BigDecimal("12.00"), true));
pack.add(new Order("o-101", "linus@example.com", List.of(), new BigDecimal("4.50"), true));
pack.add(new Order("o-102", "grace@example.com", List.of(), new BigDecimal("28.50"), true));
pack.sort(Comparator.comparing(Order::total));
// o-101, o-100, o-102 — lowest total first
Collections.sort(pack) needs Order to be Comparable. A comparator is the usual checkout move: sort by total, by id, by email. List.of results throw on sort — unmodifiable. Sort a mutable copy.
binarySearch only means what you think if the list is already sorted in the same order:
pack.sort(Comparator.comparing(Order::id));
int at = Collections.binarySearch(pack, new Order("o-101", "", List.of(), BigDecimal.ZERO, true),
Comparator.comparing(Order::id));
// index of o-101, or (-(insertion point) - 1) if missing
An unsorted list makes binarySearch undefined — not “throws,” not “returns −1.” You may get a plausible-looking index that is simply wrong. Sort first, same comparator.
| Method | Mutates? | Precondition |
|---|---|---|
list.sort(cmp) / Collections.sort | yes, in place | list must support set |
Collections.binarySearch(list, key) | no | list sorted in that order |
Collections.reverse(list) | yes | list supports set |
Collections.shuffle(list) | yes | list supports set |
Collections.reverse(pack); // last becomes first
Collections.shuffle(pack); // random permutation
Collections.shuffle(pack, new Random(42)); // reproducible shuffle
Shuffle is how you sample packing order without inventing a loop. Reverse is the cheap “oldest first ↔ newest first” when the list is already in one of those orders. Neither allocates a second list.
Frequency, disjoint, nCopies
Three methods that surprise people because they look like they belong on List and do not.
List<String> skus = new ArrayList<>();
skus.add("SKU-MUG");
skus.add("SKU-TEA");
skus.add("SKU-MUG");
int mugs = Collections.frequency(skus, "SKU-MUG"); // 2
boolean overlap = !Collections.disjoint(skus, List.of("SKU-BIN", "SKU-TEA")); // true
frequency uses equals. disjoint is true when the two collections share no element. Pass a Set as one argument when you can — contains then stops being a scan.
nCopies builds an immutable list of length n where every index is the same reference:
LineItem placeholder = new LineItem("SKU-PAD", 0, BigDecimal.ZERO);
List<LineItem> pads = Collections.nCopies(3, placeholder);
System.out.println(pads.size()); // 3
pads.set(0, placeholder); // UnsupportedOperationException
Need a mutable list of three placeholders? new ArrayList<>(Collections.nCopies(3, placeholder)). Note: if the element is mutable, every index sees the same mutation. nCopies does not clone.
Empty, singleton, checked
Immutable empties and one-element collections are real return values, not “remember to new ArrayList<>().”
List<Order> none = Collections.emptyList();
Set<String> noSkus = Collections.emptySet();
Map<String, Order> noMap = Collections.emptyMap();
List<Order> justOne = Collections.singletonList(order);
Set<String> oneSku = Collections.singleton("SKU-MUG");
Map<String, Order> oneEntry = Collections.singletonMap("o-100", order);
emptyList() is a shared unmodifiable empty. add throws. new ArrayList<>(0) is a distinct mutable list that happens to start with no elements.
| Call | Mutable? | Nulls | Typical use |
|---|---|---|---|
Collections.emptyList() | no | n/a (empty) | “no orders, and there never will be via this reference” |
new ArrayList<>(0) | yes | later adds may be null | caller will add |
List.of() | no | n/a | same job as emptyList, Java 9 factory |
singletonList(x) | no | null allowed | one element, including “explicitly null” |
List.of(x) | no | null forbidden | one element, factories contract |
Return emptyList() when the caller must not add. Return a new ArrayList when they will. Returning the shared empty and then having a caller add is how you get UnsupportedOperationException in a place that “always worked in tests” — the tests used a mutable stub.
checkedList is the runtime generic you wish List<Order> already was when you pass the list to a raw-typed API:
List<Order> orders = new ArrayList<>();
List<Order> checked = Collections.checkedList(orders, Order.class);
@SuppressWarnings("rawtypes")
List raw = checked;
raw.add("not an order");
// ClassCastException — heap pollution caught at the add
Without the wrapper, the bad add succeeds and a later Order cast blows up far from the crime. Use checkedList / checkedSet / checkedMap at the boundary with legacy raw types. Do not wrap every list in the service for sport. Why raw types exist after erasure, and PECS when a helper should accept List<? extends Order>, is Generics.
Unmodifiable wrappers are views
List<Order> live = new ArrayList<>();
live.add(order);
List<Order> read = Collections.unmodifiableList(live);
live.add(another);
System.out.println(read.size()); // 2
read.add(another); // UnsupportedOperationException
unmodifiableList, unmodifiableSet, unmodifiableMap (and the Java 10 unmodifiableXxx copies of existing wrappers) refuse mutation through the wrapper. They are views. The hub already defined that. At an API boundary where you want a snapshot, List.copyOf is the factory that copies first.
Synchronized wrappers lock the whole collection
List<Order> shared = Collections.synchronizedList(new ArrayList<>());
shared.add(order); // this call is synchronized on the wrapper
Every mutative and query method takes the wrapper’s lock. That is whole-collection synchronized, the same idea as legacy Vector. Iteration is not covered by those per-call locks.
List<Order> shared = Collections.synchronizedList(new ArrayList<>());
synchronized (shared) {
for (Order o : shared) {
System.out.println(o.id());
}
}
You must synchronize on the wrapper while you iterate — iterator, enhanced for, forEach, spliterator. Skip the lock and you can see ConcurrentModificationException or a torn walk. A stream over a synchronized list is not magically safe either.
Prefer java.util.concurrent types for shared data: ConcurrentHashMap for maps, CopyOnWriteArrayList when reads dominate and you want snapshot iterators, or don’t share — confine the ArrayList to one thread. The wrapper is for talking to an API that still demands a synchronized List, not for designing a new shared structure.
synchronizedSet and synchronizedMap have the same iterator rule: lock on the wrapper, not on the backing collection.
Arrays.asList: fixed-size, backed by the array
This is the method people confuse with List.of. It is not a factory in that family.
LineItem mug = new LineItem("SKU-MUG", 2, new BigDecimal("12.00"));
LineItem tea = new LineItem("SKU-TEA", 1, new BigDecimal("4.50"));
LineItem[] array = { mug, tea };
List<LineItem> view = Arrays.asList(array);
view.set(1, mug); // array[1] is now mug — writes through
System.out.println(array[1].sku()); // SKU-MUG
view.add(tea); // UnsupportedOperationException
view.set(0, null); // allowed — asList permits nulls
Arrays.asList(array) | List.of(e1, e2) | new ArrayList<>(List.of(...)) | |
|---|---|---|---|
| Size | fixed | fixed | grows |
set | writes through to the array | UnsupportedOperationException | mutates the list only |
add / remove | throws | throws | works |
| Nulls | allowed | NullPointerException | allowed |
| Storage | the array you passed | own unmodifiable storage | own ArrayList storage |
Arrays.asList(mug, tea) is the varargs form: the compiler builds an array, then the list wraps it. Changing set still writes that hidden array. There is no separate ArrayList.
Note: Arrays.asList on an int[] is a one-element List<int[]>, not a list of boxed integers. Use IntStream or box first. List.of has the same varargs trap if you pass a primitive array as a single object.
Need a real independent mutable list from an array?
List<LineItem> copy = new ArrayList<>(Arrays.asList(array));
copy.add(tea); // fine — copy is a true ArrayList
array[0] = tea; // copy is unaffected
Or List.copyOf(Arrays.asList(array)) when the result should stay unmodifiable and null-free.
Arrays.sort and Arrays.binarySearch are the array-side twins of list.sort and Collections.binarySearch. Same precondition: search only after a sort in the same order. They mutate / read the array, not a List. Reach for them when you still have an array; wrap with asList only when the next API wants a List.
Copy helpers you actually use
Collections.copy(dest, src) overwrites dest in place; dest must be at least as long as src or you get IndexOutOfBoundsException. It is not new ArrayList<>(src).
List<Order> dest = new ArrayList<>(Collections.nCopies(pack.size(), dummy));
Collections.copy(dest, pack);
Most application code wants the constructor copy or List.copyOf, not Collections.copy. Keep copy in mind for the “overwrite an existing list of the same size” job — buffers, recycled rows — and skip it as a synonym for clone.
Arrays.copyOf and System.arraycopy stay on the array side. They are how you grow or slice the backing store; they are not collection factories.
Interview lens
Interviewers poke the wrapper myths and the asList / search preconditions.
| Question | Honest answer |
|---|---|
Does synchronizedList make iteration thread-safe? | No. Lock on the wrapper for the whole iteration. Prefer concurrent types or confinement. |
Is unmodifiableList a snapshot? | No. It is a view. Mutate the original, the wrapper shows it. |
Why does Arrays.asList(...).add(x) throw? | Fixed-size array view. set is allowed; structural add/remove is not. |
binarySearch on an unsorted list? | Undefined result. Sort with the same order first. |
emptyList() vs new ArrayList<>(0)? | Shared immutable empty vs a mutable list you can grow. |
nCopies(3, item) — three objects? | Three slots, one reference. Mutate the object, every index sees it. |
Wrong answer: “Collections.synchronizedList makes iteration thread-safe automatically.” The javadoc’s synchronized (list) around the iterator is mandatory. Forgetting it is the bug.
What to draw: a box around the list labeled “lock per call,” and a second box around the for loop labeled “you lock this.” Concurrent maps do not work that way — that is the next reason to leave wrappers behind.
Cheat sheet
Sort / search list.sort(cmp) binarySearch requires sorted
Rearrange reverse, shuffle in place, needs set
Query frequency, disjoint
Build nCopies(n, x) singleton / singletonList / singletonMap
Empty emptyList/Set/Map immutable, shared
Checked checkedList(list, Foo.class) raw-type boundary
Wrap unmodifiableXxx VIEW
synchronizedXxx whole-collection lock; lock while iterating
Arrays.asList fixed-size, backed by array, set writes through, add throws
nulls allowed — not List.of
emptyList() cannot add
new ArrayList<>(0) can add, distinct instance
Prefer j.u.c ConcurrentHashMap, CopyOnWriteArrayList — not synchronizedXxx
Do:
- Sort, then
binarySearchwith the same comparator. - Return
emptyList()/singletonListwhen the result must stay size-fixed. - Synchronize on the synchronized wrapper for every iteration, or don’t use the wrapper.
Don’t:
- Assume
synchronizedListmakes afor-each safe. - Call
addonArrays.asListor onemptyList(). - Use
unmodifiableListwhen you meantList.copyOf. - Treat
Collectionsas a collection type younew.
Wrap-up
Collections and Arrays are the toolbox around List, Set, and Map: sort and search, shuffle and reverse, empty and singleton, checked and unmodifiable views, synchronized wrappers, and asList. None of them replaces ArrayList. The traps that show up in reviews are consistent — binarySearch without a sort, asList.add, emptyList returned to a caller who adds, and a synchronized wrapper whose iterator still needs your lock.
Those synchronized wrappers are the modern cousin of types that were born locked. The next post is why Vector and Hashtable still compile, and why you still do not choose them.