← Fundamentals
Guided lesson 6 questions · Java only

Java collections and unmodifiable views

Which list do you hold: one you can change, one that refuses, a view of another list, or a list over an array?

The type List names methods, not behavior: a List can be changeable, unmodifiable, a view that reads another list, or a fixed-size list over an array. Know where its elements live. A copy has its own, a view reads another list at every call, and Arrays.asList writes into its array.

Written for Java.

Before you begin

Why this matters

Java code passes lists between methods all the time, and the declared type is almost always List. If you know which kind of list a method made, you can predict whether a later change reaches it, whether add throws, and whether a loop that removes elements finishes correctly.

What this helps you reason about

  • Predict what List.of, List.copyOf, and Map.of refuse: changes, null, and a repeated key.
  • Choose between an unmodifiable view and a copy, and say what Arrays.asList shares with its array.
  • Remove elements during a loop without skipping one or throwing ConcurrentModificationException.

In Kotlin, a read-only List has no add method at all. In Python, every list can change. In Java, every List has add, set, and remove, and some lists throw when you call them. Mutability and constants showed the first difference: List.of refuses changes, Collections.unmodifiableList is a view, and List.copyOf copies. This lesson covers the rest: what the factory methods refuse besides changes, what a view still shares, what Arrays.asList shares with its array, and how to remove elements during a loop.

Already know this? The short version
  • List, Set, and Map are interfaces, so the declared type never says whether add works; the code that made the list decides.
  • List.of, List.copyOf, Set.of, and Map.of refuse changes and null: creating one with null, and even contains(null) or Map.get(null) on one, throws NullPointerException; Map.of and Set.of also refuse a repeated key or element with IllegalArgumentException.
  • Collections.unmodifiableList is a wrapper that reads the original list at every call; List.copyOf copies the references once, and returns its argument unchanged when the argument is already a List.of list.
  • Arrays.asList uses the array as its storage: set writes into the array and the array’s writes show in the list, while add and remove throw; copy it with new ArrayList<>(…) to change it freely.
  • Removing with the list’s own remove inside an enhanced for loop skips the next element or throws; use removeIf or Iterator.remove. The section “What goes wrong with collections in real programs?” covers views kept as records, null in List.copyOf, Arrays.asList passed to code that adds, and the order of Map.of.

Go to the final check· Go to the practice tickets

Words in this lesson (6)
unmodifiable list
A list whose add, set, and remove methods throw UnsupportedOperationException, such as the lists that List.of and List.copyOf return.
unmodifiable view
A list that refuses changes through itself but reads another list, such as the one Collections.unmodifiableList(list) returns. Changes made to the other list show through the view.
List.copyOf
A static method (Java 10 and later) that returns an unmodifiable list with the same elements. The elements are not copied: both lists hold the same references.
fixed-size list
A list whose set works but whose add and remove throw UnsupportedOperationException, such as the list Arrays.asList(array) returns. It stores its elements in that array.
ConcurrentModificationException
The exception a list or its view throws when it is used after its list changed size in a way it was not told about, such as a subList used after an add to its list.
removeIf
A method of every Java collection that takes a lambda returning a boolean and removes each element for which the lambda returns true.

Examples are focused snippets; imports, enclosing functions, or classes may be omitted. C examples use C11; Haskell examples use Haskell 2010. Expand the notes for language-specific depth.

01 / List.of

What does List.of promise?

Mutability and constants showed that a list made with List.of refuses add, set, and remove: each one throws an UnsupportedOperationException. This section shows what else List.of promises, and why the declared type cannot tell you.

List, Set, and Map are interfaces: types that list methods, such as add, get, and size, without saying how the elements are stored. ArrayList, HashSet, and HashMap are classes that provide those methods. A variable is usually declared with the interface, as in List<String> pupils = new ArrayList<>(), so the code that uses it depends on the methods alone. But every List has an add method, and an unmodifiable list throws when add is called. So List<String> does not say which list you hold. The code that made the list decides.

List.of also refuses null. List.of("Ana", null) throws a NullPointerException when the list is made. And because such a list can never hold null, it refuses to search for null too: contains(null) and indexOf(null) throw a NullPointerException, where an ArrayList answers false or -1.

Map.of follows the same rules: it refuses changes, null keys, and null values, and get(null) throws a NullPointerException. It also refuses a key given twice. Map.of("Ana", 1, "Ana", 2) throws IllegalArgumentException: duplicate key: Ana. Set.of refuses a repeated element in the same way.

Main.java
List<String> pupils = List.of("Ana", "Ben", "Cy");
// throws NullPointerException
pupils.contains(null);
// false: an ArrayList can hold null, so it searches
new ArrayList<>(pupils).contains(null);
// throws IllegalArgumentException: duplicate key: Ana
Map.of("Ana", 1, "Ana", 2);

The type List names methods, not behavior. List.of and Map.of refuse changes, null, and a repeated key, and they throw even when asked to search for null.

Coming from Kotlin: A Kotlin List has no add method, so the compiler stops the mistake. Every Java List has add, so the mistake shows only when the program runs.

Coming from Python: None in names is False for any Python list. List.of("Ana").contains(null) throws in Java.

Precisely: what is promised, and what is observed

The List and Map documentation, under “Unmodifiable Lists” and “Unmodifiable Maps”, promises that these collections refuse changes and null, and that a repeated key makes Map.of throw. For contains(null), the Collection documentation allows a collection that refuses null to throw NullPointerException; List.of lists do. These lists are also “value-based”: do not compare them with == or use them as locks. The class names in the stepper, ImmutableCollections$List12 for one or two elements and ImmutableCollections$ListN for more, are observations of Java 17, not promises. In the runner, a List.of list also throws UnsupportedOperationException for removeIf, even when the lambda matches nothing.

Experiment

Search a List.of list for null

A list made by List.of never holds null, and its contains method throws a NullPointerException when it is asked for null. An ArrayList can hold null, so it searches its elements and answers false.

Why it matters Before you pass null to contains, indexOf, or Map.get, know which list or map you hold. A List.of list throws where an ArrayList answers.

Predict what prints. Then change List.of("Ana", "Ben", "Cy") to new ArrayList<>(List.of("Ana", "Ben", "Cy")), add import java.util.ArrayList; at the top, and predict again.

import java.util.List;

class Main {
    public static void main(String[] args) {
        List<String> pupils = List.of("Ana", "Ben", "Cy");
        try {
            System.out.println(pupils.contains(null));
        } catch (NullPointerException e) {
            System.out.println("refused: " + e);
        }
    }
}
What to look for

pupils shows java.util.ImmutableCollections$ListN@ and an id, then ["Ana", "Ben", "Cy"]. ImmutableCollections$ListN is the class that List.of chose; the declared type List does not show it. The number after @ is the debugger’s id for an object, not an address. From the println line, the next stop is the catch line, and e is listed as a java.lang.NullPointerException. The panel lists the exception’s internal fields too, such as backtrace. The run prints refused: java.lang.NullPointerException, with no message after the class name. After the edit, pupils shows java.util.ArrayList@ and an id, the catch block never runs, and the run prints false.

Expected output

refused: java.lang.NullPointerException

02 / Views and copies

What does an unmodifiable view still share?

A view is a list with no elements of its own: it reads them from another list. Collections.unmodifiableList(pupils) returns a small wrapper object that keeps a reference to pupils. Each read through the wrapper reads pupils at that moment. Each method that would change the list throws an UnsupportedOperationException.

That makes a view the right answer when a class wants other code to read its list but not change it. The getter below hands out a view, so a caller sees every pupil who joins later, and cannot add one itself.

List.copyOf(pupils) is the other answer. It makes a new unmodifiable list with the references pupils holds now, so later changes to pupils never reach it. Both refuse add and set, so a test that only tries to change the list cannot tell them apart. Choose by what the receiver needs: the current contents, at every call, or the contents as they are now.

subList is a third kind of view: it reads part of a list, and writes through to it. Slices, views, and shared storage shows it.

Main.java
class ClassList {
    private final List<String> pupils = new ArrayList<>();

    void join(String name) {
        pupils.add(name);
    }

    // callers can read the list, never change it
    List<String> names() {
        return Collections.unmodifiableList(pupils);
    }
}

An unmodifiable view refuses changes through itself but reads its list at every call; List.copyOf keeps the contents as they were.

Coming from C#: list.AsReadOnly() in C# is a view, like Collections.unmodifiableList. ImmutableList.CreateRange is a copy, like List.copyOf.

Precisely: when no new object is made

List.copyOf returns its argument unchanged when the argument is already a List.of list, because that list can never change; in the runner, List.copyOf(list) == list printed true. For a view, it makes a real copy, because the list behind a view can change. In Java 17, Collections.unmodifiableList also returns its argument unchanged when the argument is already such a view. Neither one copies the elements themselves: both lists hold the same references, as Shallow and deep copying explains.

Experiment

Look inside an unmodifiable view

Collections.unmodifiableList makes a small wrapper object that keeps a reference to pupils. Every read goes to pupils at that moment, and every method that would change the list throws. List.copyOf makes a new list with the elements pupils holds now, so later changes to pupils do not reach it.

Why it matters A view and a copy both refuse changes, so add and set cannot tell them apart. Ask what the receiver must see: the list as it changes, or the list as it is now.

Predict both lines. Then change Collections.unmodifiableList(pupils) to List.copyOf(pupils) and predict again.

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

class Main {
    public static void main(String[] args) {
        List<String> pupils = new ArrayList<>(List.of("Ana", "Ben"));
        List<String> shown = Collections.unmodifiableList(pupils);
        pupils.add("Cy");
        System.out.println(shown);
        try {
            shown.add("Dev");
        } catch (UnsupportedOperationException e) {
            System.out.println("refused");
        }
    }
}
What to look for

pupils shows java.util.ArrayList@48 ["Ana", "Ben"]. shown shows java.util.Collections$UnmodifiableRandomAccessList@ and an id, then {list: java.util.ArrayList@48 …, c: java.util.ArrayList@48 …}: the view has no elements of its own, and both of its fields hold the same ArrayList as pupils (the id is 48 in this run). After pupils.add("Cy"), pupils shows three names, and so do both fields of shown. The run prints [Ana, Ben, Cy]. shown.add jumps to the catch block, and the run prints refused. After the edit, shown shows java.util.ImmutableCollections$List12@ and a new id, then ["Ana", "Ben"]: its own elements, with no field that leads to pupils. It keeps two names after the add, and the run prints [Ana, Ben], then refused.

Expected output

[Ana, Ben, Cy]
refused

03 / Arrays.asList

What does Arrays.asList share with its array?

Arrays.asList(desks) turns an array into a List without copying anything. The list it returns uses the array itself as its storage. So row.set(0, "Dev") writes into desks[0], and desks[2] = "Eli" shows when the list is read.

An array has a fixed length, so this list cannot grow or shrink: add and remove throw UnsupportedOperationException. It is a fixed-size list: set works, add and remove do not. Its class is java.util.Arrays$ArrayList, a different class from java.util.ArrayList with the same short name.

To get a list that shares nothing with the array and can grow, copy the elements: new ArrayList<>(Arrays.asList(desks)). List.of(desks) also copies the elements, into an unmodifiable list, and it throws if an element is null.

Main.java
String[] desks = {"Ana", "Ben", "Cy"};
List<String> row = Arrays.asList(desks);
// writes into desks[0]
row.set(0, "Dev");
// row.add("Fay"); throws UnsupportedOperationException
List<String> own = new ArrayList<>(Arrays.asList(desks));
// allowed, and desks does not change
own.add("Fay");

Arrays.asList shares the array: set writes into it, and add and remove throw. Copy it into an ArrayList to change it on its own.

Coming from JavaScript: Array.from(desks) copies the elements into a new array. Arrays.asList does not copy: the list and the array are one set of elements.

Precisely: set, sort, and null

Methods that only replace elements work on a fixed-size list, so Collections.sort(row) sorts the array behind it too. Arrays.asList("Ana", "Ben"), with the values written in the call, makes a hidden array and returns the same kind of list. Unlike List.of, Arrays.asList allows null elements, and its contains(null) answers.

Experiment

Write through a list made by Arrays.asList

Arrays.asList does not copy the array. The list it returns uses the array itself as its storage, so a write through either one is a write to the same elements. An array has a fixed length, so the list refuses add and remove. new ArrayList<>(…) copies the elements into storage of its own, which can grow.

Why it matters A list from Arrays.asList and its array are one set of elements. Copy it into an ArrayList before you change it, or before you need it to grow.

Predict the three lines. Then change Arrays.asList(desks) to new ArrayList<>(Arrays.asList(desks)), add import java.util.ArrayList; at the top, and predict again.

import java.util.Arrays;
import java.util.List;

class Main {
    public static void main(String[] args) {
        String[] desks = {"Ana", "Ben", "Cy"};
        List<String> row = Arrays.asList(desks);
        row.set(0, "Dev");
        desks[2] = "Eli";
        System.out.println(desks[0] + " " + row.get(2));
        try {
            row.add("Fay");
        } catch (UnsupportedOperationException e) {
            System.out.println("cannot grow");
        }
        System.out.println(row.size());
    }
}
What to look for

row shows java.util.Arrays$ArrayList@ and an id: a class inside Arrays, not java.util.ArrayList. After row.set(0, "Dev"), desks and row both show "Dev" first. After desks[2] = "Eli", both show "Eli" last. The run prints Dev Eli. row.add jumps to the catch block, the run prints cannot grow, and then 3. After the edit, row shows java.util.ArrayList@ and an id. row.set changes row only, desks[2] = "Eli" changes desks only, and the run prints Ana Cy. The add works, row shows four names, the catch block never runs, and the run prints 4.

Expected output

Dev Eli
cannot grow
3

04 / Removing in a loop

How do you remove elements while looping over a list?

For loops and iteration protocols showed that adding to an ArrayList inside an enhanced for loop over it throws ConcurrentModificationException. Removing is worse, because it does not always throw.

The loop’s iterator keeps a cursor: the index of the next element it will give. remove moves every later element one place to the left. So the element after the removed one moves to an index the cursor has already passed, and the loop never checks it. If the removed element was second to last, the cursor now equals the new size, hasNext() returns false, and the loop ends quietly. Otherwise next() notices that the size changed and throws ConcurrentModificationException.

removeIf does the whole job in one call. It takes a lambda that returns a boolean, and removes each element for which the lambda returns true. When the loop must do more than remove, use the iterator yourself. Iterator<String> it = pupils.iterator() gives the iterator that the enhanced for loop uses. it.remove() removes the element that it.next() just returned, and keeps the iterator’s cursor correct.

On a List<Integer>, list.remove(i) with an int i removes the element at index i, not the value i, as Java final, primitives, and boxing showed. removeIf(p -> p == 0) compares values, so it avoids that trap too.

Main.java
List<String> absent = List.of("Ben", "Cy");
// removes every pupil for which the lambda returns true
pupils.removeIf(pupil -> absent.contains(pupil));

// the same with the iterator, when the loop does more
Iterator<String> it = pupils.iterator();
while (it.hasNext()) {
    String pupil = it.next();
    if (absent.contains(pupil)) {
        it.remove();
    }
}

Never call the list’s own remove inside an enhanced for loop over that list. Use removeIf, or Iterator.remove in a loop you write yourself.

Coming from Python: Removing from a Python list inside for x in items also skips elements, and never raises an error. Java sometimes throws instead, so a quiet wrong result in one test and an exception in another come from the same bug.

Precisely: fail-fast is not a promise

The ArrayList documentation calls its iterators fail-fast and says that the exception is thrown “on a best-effort basis”: a program must not depend on it. The quiet end of the loop is the case where it is not thrown. An indexed loop, for (int i = 0; i < list.size(); i++), with list.remove(i) never throws, and skips the element after each one it removes, unless it counts down from the end. removeIf on an ArrayList checks every element before it moves any.

Experiment

Remove absent pupils inside a for loop

The loop’s iterator keeps a cursor, the index of the next element, which is 2 after "Ben". remove moves "Cy" from index 2 to index 1, so the list now has size 2. hasNext() compares the cursor with the size, finds 2 and 2, and ends the loop before "Cy" is checked. removeIf walks the list itself and checks every element before anything moves.

Why it matters A remove inside an enhanced for loop over the same list skips the next element, or throws. Use removeIf, or Iterator.remove in a loop you write yourself.

Ben and Cy are absent. Predict the printed list. Then replace the whole for loop (lines 8 to 12) with pupils.removeIf(pupil -> absent.contains(pupil)); and predict again.

import java.util.ArrayList;
import java.util.List;

class Main {
    public static void main(String[] args) {
        List<String> pupils = new ArrayList<>(List.of("Ana", "Ben", "Cy"));
        List<String> absent = List.of("Ben", "Cy");
        for (String pupil : pupils) {
            if (absent.contains(pupil)) {
                pupils.remove(pupil);
            }
        }
        System.out.println(pupils);
    }
}
What to look for

The loop stops on the if line with pupil "Ana", then on the loop’s closing brace, then on the if line with pupil "Ben". After pupils.remove(pupil), the stop on the closing brace shows pupils as ["Ana", "Cy"]. The next stop is the println line: the loop has ended, no stop ever shows pupil "Cy", and no exception was thrown. The run prints [Ana, Cy]. After the edit, the stepper stops three times in a frame named Main.lambda in main, with pupil "Ana", "Ben", then "Cy", and then pupils shows ["Ana"]. The run prints [Ana].

Expected output

[Ana, Cy]

05 / In production

What goes wrong with collections in real programs?

Each bug below follows from a rule in this lesson, and each one can pass every test with small, clean data.

A view kept as a record. A getter that returns Collections.unmodifiableList(pupils) is safe from writes. But code that stores the result, to send or compare it later, holds the live list, and the stored record changes when the class changes its list. A receiver that keeps a list needs List.copyOf, or a copy of its own.

null in a copy. List.copyOf, List.of, Set.of, and Map.of throw a NullPointerException for a null element, and that exception has no message. Data from a form, a file, or a database often uses null for “no value”, so a copy that worked in every test fails on real data. Decide what null means before you copy: remove it, replace it, or copy with new ArrayList<>(list), which keeps it.

Arrays.asList far from its array. A method that receives a List cannot see that the list came from Arrays.asList, so its add throws far from the line that made the list. And Arrays.asList(points) on an int[] gives a List<int[]> with one element, the whole array, because a list cannot hold int.

The order of Map.of and Set.of. They promise no order, and in practice the order changes from one run to the next: one program printed [Ben, Cy, Dev, Ana, Eli], then [Eli, Ana, Dev, Cy, Ben]. A test that compares printed output then passes on one run and fails on the next. When order matters, use a LinkedHashMap, which keeps the order in which keys were added, or a TreeMap, which keeps keys sorted.

Main.java
List<String> pegs = new ArrayList<>(List.of("Ana", "Ben"));
// one coat peg is empty
pegs.add(null);
// NullPointerException, with no message
List.copyOf(pegs);
// [Ana, Ben, null]
new ArrayList<>(pegs);

Copy a list that a receiver keeps, decide what null means before List.copyOf, do not pass Arrays.asList to code that adds, and never depend on the order of Map.of or Set.of.

Experiment

Copy a list that holds null

List.copyOf checks every element, and it throws a NullPointerException for the first null. The exception names no element and no position. new ArrayList<>(pegs) copies every reference, null included, into a list that allows changes.

Why it matters Before you copy data that came from outside the program with List.copyOf, decide what an empty value means, and remove or replace each null first.

One coat peg is empty, so pegs holds null. Predict what prints. Then change List.copyOf(pegs) to new ArrayList<>(pegs) and predict again.

import java.util.ArrayList;
import java.util.List;

class Main {
    public static void main(String[] args) {
        List<String> pegs = new ArrayList<>(List.of("Ana", "Ben"));
        pegs.add(null);
        try {
            List<String> saved = List.copyOf(pegs);
            System.out.println(saved);
        } catch (NullPointerException e) {
            System.out.println("copy failed: " + e.getMessage());
        }
    }
}
What to look for

After pegs.add(null), pegs shows ["Ana", "Ben", null]. From the List.copyOf line, the next stop is the catch line: saved never appears, and e is a java.lang.NullPointerException. The run prints copy failed: null, because getMessage() returns null: List.copyOf threw the exception without a message. After the edit, saved appears inside the try block as java.util.ArrayList@ and a new id, then ["Ana", "Ben", null], and the run prints [Ana, Ben, null].

Expected output

copy failed: null

06 / Try it

Can you tell which lists share elements?

One array, three lists. Decide for each list whether it shares the array’s elements, reads another list, or holds a copy.

Main.java
import java.util.Arrays;
import java.util.Collections;
import java.util.List;

class Main {
    public static void main(String[] args) {
        String[] day = {"art", "maths", "music"};
        List<String> fixed = Arrays.asList(day);
        List<String> view = Collections.unmodifiableList(fixed);
        List<String> copy = List.copyOf(fixed);
        day[1] = "drama";
        fixed.set(2, "sport");
        System.out.println(view + " " + copy);
        System.out.println(day[2]);
    }
}

A school day has three lessons, and two of them change. What are the two printed lines?

Reveal the reasoning

[art, drama, sport] [art, maths, music], then sport. fixed uses day as its storage, so day[1] = "drama" shows in fixed, and fixed.set(2, "sport") writes into day[2]. view reads fixed at every call, so it shows both changes. copy took the three references when it was made, before either change, so it still holds art, maths, music.

Practice tickets

  1. JAVA-301 · Pupils who left the art club stay on its list, or the update crashes → Free
  2. JAVA-302 · A swap for one week changes the school’s whole assembly order → Sign in · Pro
  3. JAVA-303 · The printed exam seating plan changes, and the hall screen can rewrite seats → Sign in · Pro

Keep exploring

References, pointers, and handles →

Primary references