You called add, remove or clear on a list and got an exception with no message at all:
Exception in thread "main" java.lang.UnsupportedOperationException
at java.base/java.util.AbstractList.add(AbstractList.java:155)
at java.base/java.util.AbstractList.add(AbstractList.java:113)
at Orders.main(Orders.java:7)If the list came from List.of, Stream.toList() or List.copyOf, the frames name the JDK’s immutable collections instead:
Exception in thread "main" java.lang.UnsupportedOperationException
at java.base/java.util.ImmutableCollections.uoe(ImmutableCollections.java:142)
at java.base/java.util.ImmutableCollections$AbstractImmutableCollection.add(ImmutableCollections.java:147)
at Orders.main(Orders.java:7)UnsupportedOperationException means the object implements the List interface but has chosen not to support that particular operation. The List interface marks mutators such as add and remove as optional, and several standard factory methods return lists that refuse them. The frame names (AbstractList versus ImmutableCollections) tell you which kind of list you are holding; the line numbers inside them vary by JDK release.
Quick fix checklist
- Find where the list was created:
Arrays.asList,List.of,List.copyOf,Stream.toList(),Collections.unmodifiableList,Collections.emptyList()or a library method that returns one of these. - If the code that throws genuinely needs to modify the list, copy it first:
new ArrayList<>(list). - If you own the method that created the list and callers are expected to modify it, return an
ArrayListand document that. - If the list should never change, the exception found a real bug: fix the code that tried to modify it.
- Watch for the related
NullPointerExceptionfromList.of(null)orList.of(...).contains(null).
Before you start
You should know the difference between the List interface and its implementations. A variable typed List<String> tells you nothing about whether the object behind it is resizable, writable or neither. That is the root of the confusion: the compiler allows list.add(x) on every List, and only the runtime object decides. The examples assume Java 17 or later, where Stream.toList() exists (it was added in Java 16).
Why it happens
There are three families of “restricted” lists in the JDK, and they behave differently.
Fixed-size views: Arrays.asList. It wraps the array you pass in without copying it. Because an array cannot grow or shrink, the wrapper does not override add or remove, and the inherited AbstractList versions throw. It does support set, and writes go straight through to the array in both directions:
String[] codes = {"A", "B", "C"};
List<String> view = Arrays.asList(codes);
view.set(0, "Z"); // allowed: codes[0] is now "Z"
codes[1] = "Y"; // view.get(1) is now "Y"
view.add("D"); // UnsupportedOperationExceptionThat is why Collections.sort(Arrays.asList(...)) works (sorting only uses set), while removing does not.
Unmodifiable lists: List.of, List.copyOf, Stream.toList(). These are documented as unmodifiable: every mutator throws, including set, sort, replaceAll and removeIf. List.of and List.copyOf also reject null elements with NullPointerException, and even contains(null) throws on them. Stream.toList() is the exception that allows null elements. Map.of follows the same rules for maps and additionally throws IllegalArgumentException for duplicate keys.
Unmodifiable views: Collections.unmodifiableList. This wraps an existing list and blocks writes through the wrapper, but it is not a copy. Anyone holding the original list can still change it, and the view shows the changes.
List<String> source = new ArrayList<>(List.of("a"));
List<String> readOnly = Collections.unmodifiableList(source);
source.add("b");
System.out.println(readOnly); // [a, b]Step-by-step walkthrough
Step 1: Reproduce the failure
import java.util.Arrays;
import java.util.List;
public class Orders {
public static void main(String[] args) {
List<String> statuses = Arrays.asList("NEW", "PAID", "SHIPPED");
statuses.set(0, "PENDING"); // fine
statuses.add("DELIVERED"); // throws
}
}The trace shows AbstractList.add above line 8 of Orders. That AbstractList frame is the signature of a list that never implemented add.
Step 2: Identify the list’s real class
When the list comes from somewhere far away, ask it what it is:
System.out.println(list.getClass().getName());java.util.Arrays$ArrayList is the fixed-size wrapper (a private class, not java.util.ArrayList). Names such as java.util.ImmutableCollections$ListN or ImmutableCollections$List12 mean List.of or a related factory. java.util.Collections$UnmodifiableRandomAccessList is the view from Collections.unmodifiableList. Now you know which rules apply.
Step 3: Decide who should own mutation
This is the real decision. Two healthy designs exist:
- The producer returns an unmodifiable list, and any consumer that needs changes makes its own copy. This protects the producer’s internal state.
- The producer returns a fresh mutable list that the caller owns outright.
Problems start when the producer returns an unmodifiable list and a consumer assumes it can modify it, or when the producer returns its internal mutable list and a consumer modifies it by accident.
Step 4: Apply the fix
If the consumer needs to change the data, copy at the point of change:
List<String> statuses = new ArrayList<>(Arrays.asList("NEW", "PAID", "SHIPPED"));
statuses.add("DELIVERED"); // fine: a real ArrayListIf you need a growable list from a stream, ask for one explicitly rather than relying on Collectors.toList(), whose mutability is not guaranteed by its specification:
List<String> codes = orders.stream()
.map(Order::code)
.collect(Collectors.toCollection(ArrayList::new));If the list must be immutable, use List.copyOf at the boundary so later changes to the source cannot leak in.
Step 5: Make the contract visible
Document return values (“returns an unmodifiable list”), and prefer immutable return values for anything that exposes internal state. Name methods so mutation is obvious (addItem on the owning class, rather than getItems().add).
Worked scenario
A pricing service builds default discount rules and lets each request add its own:
class DiscountRules {
static List<String> defaults() {
return List.of("LOYALTY", "SEASONAL");
}
}
class CheckoutService {
List<String> rulesFor(Customer customer) {
List<String> rules = DiscountRules.defaults();
if (customer.isStudent()) {
rules.add("STUDENT");
}
return rules;
}
}It worked until the first student checked out. The trace showed ImmutableCollections$AbstractImmutableCollection.add above CheckoutService.rulesFor.
Diagnosis: defaults() used to return new ArrayList<>(Arrays.asList(...)). A refactor changed it to the tidier List.of(...), which is unmodifiable. No test covered a student customer, so nothing failed at build time.
The original design was wrong in a quieter way too: if defaults() had returned a shared static ArrayList, the first student request would have added STUDENT to everyone’s defaults. Keeping defaults() immutable is the right choice. The consumer that wants to add rules makes its own copy:
class CheckoutService {
List<String> rulesFor(Customer customer) {
List<String> rules = new ArrayList<>(DiscountRules.defaults());
if (customer.isStudent()) {
rules.add("STUDENT");
}
return List.copyOf(rules);
}
}The method now returns its own immutable result, so callers further downstream cannot corrupt it either.
Common mistake
The tempting fix is to make the producer return a mutable list “so nothing breaks”. That removes the exception and the protection with it: callers can now edit shared state, and the bug moves from a loud exception to silent data corruption.
Other mistakes:
- Wrapping with
Collections.unmodifiableListand assuming the data can no longer change. It is a view; useList.copyOffor a real snapshot. - Using
Arrays.asListon a primitive array.Arrays.asList(new int[] {1, 2})returns aList<int[]>with one element, not a list of two integers. UseIntStream.of(...).boxed().toList()orList.of(1, 2). - Trusting a test that calls
removeIfon anArrays.asListlist with a predicate that matches nothing. Nothing is removed, so nothing throws; production data that matches will throw. Lists fromList.ofthrow fromremoveIfregardless.
Verify the behavior
Write tests that pin the contract of the producer and the behaviour of the consumer:
import static org.junit.jupiter.api.Assertions.*;
import java.util.List;
import org.junit.jupiter.api.Test;
class CheckoutServiceTest {
@Test
void defaultsCannotBeModified() {
List<String> defaults = DiscountRules.defaults();
assertThrows(UnsupportedOperationException.class, () -> defaults.add("HACK"));
}
@Test
void studentGetsExtraRuleWithoutChangingDefaults() {
Customer student = new Customer("Sam", true);
List<String> rules = assertDoesNotThrow(() -> new CheckoutService().rulesFor(student));
assertEquals(List.of("LOYALTY", "SEASONAL", "STUDENT"), rules);
assertEquals(List.of("LOYALTY", "SEASONAL"), DiscountRules.defaults());
}
}The tests assume a record Customer(String name, boolean student) with an isStudent() method that returns student. The second test covers both the exception and the shared-state risk.
Interview exercise
What does this print, and why?
String[] arr = {"x", "y"};
List<String> a = Arrays.asList(arr);
List<String> b = List.of(arr);
arr[0] = "z";
System.out.println(a.get(0) + " " + b.get(0));
a.set(1, "w");
b.set(1, "w");Answer and reasoning
It prints z x, then throws UnsupportedOperationException on the last line. Arrays.asList wraps the array without copying, so changing arr[0] is visible through a. List.of(arr) copies the elements into a new unmodifiable list, so b still sees x. The call a.set(1, "w") succeeds because the fixed-size view supports replacement (and writes "w" into arr[1]), but b.set(1, "w") throws because List.of lists support no mutators at all. A strong answer names the three behaviours separately (view with write-through, fixed size, full immutability) and mentions that List.of would also have thrown NullPointerException if the array had contained a null.