Backed Collections and Array-Backed Lists in Java
Turn an array into a list with List<String> list = Arrays.asList(array) and call list.set(0, "apple") — the array changes too: array[0] is now "apple" as well. Yet the very next line, list.add("pear"), fails with java.lang.UnsupportedOperationException. Both effects have the same cause: this list stores no data of its own, it is just a “window” onto the array.
A backed collection (a collection view) is a collection object without its own storage: every read and write is forwarded to the source collection or array. That is why changes to the source are immediately visible through the view, and changes made through the view go straight into the source. An array-backed list is the special case where the source is an array; it is what Arrays.asList() returns. Views are created in O(1), without copying anything.
Which methods return views
In Java, views are returned by Arrays.asList(), List.subList(), the keySet(), values() and entrySet() methods of Map, the range methods of sorted collections (headSet(), subMap() and the others), descendingMap(), reversed() (Java 21) and the wrappers of the Collections class. Each view allows its own set of operations.
| Method | View of what | What you can do | Edge case / error |
|---|---|---|---|
Arrays.asList(T... a) | An array | get(), set(), sorting | add(), remove(int) — UnsupportedOperationException: the array size cannot change |
list.subList(from, to) | Part of a list, indexes [from, to) | Everything the source list allows, including add() and clear() | add()/remove() on the source list — ConcurrentModificationException on the next access to the sublist |
map.keySet(), values(), entrySet() | The keys, values or entries of a map | Iterate, remove (the pair disappears from the map), entry.setValue() | add() — UnsupportedOperationException |
headSet(), subSet(), tailSet() | A range of TreeSet elements | Add and remove inside the range | An element outside the range — IllegalArgumentException: key out of range |
headMap(), subMap(), tailMap() | A range of TreeMap keys | put() and remove() inside the range | A key outside the range — IllegalArgumentException: key out of range |
descendingMap(), descendingSet(), list.reversed() (Java 21) | The same collection in reverse order | The same as the source collection | No copy: changes made through the view go into the original |
Collections.unmodifiableList(list) and other wrappers | The source collection | Read only | Changes made bypassing the wrapper are visible through it |
Map views are covered in detail in Map.Entry, keySet(), values() and entrySet(), set ranges in SortedSet, NavigableSet and TreeSet, and the unmodifiableList() and synchronizedList() wrappers in Wrappers of the Collections class. Below are two classic backed collection examples and sublists.
Backed collection example: subMap() of a TreeMap
The subMap(fromKey, toKey) method of the SortedMap interface returns a view of the keys from fromKey inclusive to toKey exclusive. Entries added to the source map inside this range appear in the view, and entries added through the view end up in the source map.
import java.util.SortedMap;
import java.util.TreeMap;
public class BackedCollections {
public static void main(String[] args) {
SortedMap<String, String> map = new TreeMap<>();
map.put("a", "apricot");
map.put("c", "cherry");
map.put("e", "elderberry");
SortedMap<String, String> subMap = map.subMap("b", "g");
System.out.println(map + " " + subMap);
map.put("b", "banana");
subMap.put("d", "date");
map.put("w", "watermelon");
// subMap.put("p", "pear");
System.out.println(map + " " + subMap);
}
} Output:
{a=apricot, c=cherry, e=elderberry} {c=cherry, e=elderberry}
{a=apricot, b=banana, c=cherry, d=date, e=elderberry, w=watermelon} {b=banana, c=cherry, d=date, e=elderberry} Key “b” was added to the source map, but it shows up in subMap because it falls into the range from “b” to “g”. “d” was added through subMap and landed in map. “w” exists only in the source map: it lies outside the range. If you uncomment the line subMap.put("p", "pear"), the program ends with an exception:
Exception in thread "main" java.lang.IllegalArgumentException: key out of range What does subMap.put("B", "blueberry") do? It also throws IllegalArgumentException, even though “B” is alphabetically the same letter as the lower bound “b”. Strings are compared by character codes: uppercase “B” is U+0042 and lowercase “b” is U+0062, so for a TreeMap every uppercase key comes before “b” and outside the range. The headMap() and tailMap() methods and the overloads with inclusive flags follow the same rules — see headMap, subMap and tailMap are live views and Ranges and reverse order in NavigableMap.
Array-backed list: Arrays.asList()
The Arrays.asList(array) method returns a fixed-size list that holds a reference to the array you pass in, not a copy of it. Replacing an element with set() changes the array, a write to the array is visible in the list, and methods that change the size (add(), remove(), clear()) throw UnsupportedOperationException.
import java.util.Arrays;
import java.util.List;
public class BackedArrayCollection {
public static void main(String[] args) {
String[] array = {"apricot", "cherry", "melon"};
List<String> list = Arrays.asList(array);
System.out.println("List: " + list);
System.out.println("Array: " + Arrays.toString(array));
list.set(0, "apple");
array[1] = "banana";
System.out.println("List: " + list);
System.out.println("Array: " + Arrays.toString(array));
}
} Output:
List: [apricot, cherry, melon]
Array: [apricot, cherry, melon]
List: [apple, banana, melon]
Array: [apple, banana, melon] “apple” was written through the list and “banana” directly into the array, yet both changes are visible in both places: it is the same data. Which operations such a list supports:
| Operation | Result | Why |
|---|---|---|
get(i), size(), contains(), indexOf() | Work | They read the array |
set(i, e), list.sort(...), Collections.shuffle(list) | Work and change the array | The size stays the same, elements are rearranged in place |
add(e), add(i, e), remove(int), addAll() | UnsupportedOperationException | The length of an array cannot change |
remove(Object), removeIf(), clear() | UnsupportedOperationException if there is something to remove | For a missing element, remove("pear") simply returns false, and clear() on an empty list does nothing |
A null element | Allowed | Unlike List.of(), Arrays.asList("a", null) is fine |
What does this code print?
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class AsListSortExample {
public static void main(String[] args) {
String[] array = {"melon", "apricot", "cherry"};
List<String> list = Arrays.asList(array);
list.sort(null);
System.out.println(Arrays.toString(array));
System.out.println(list.getClass());
System.out.println(list instanceof ArrayList);
list.add("pear");
}
} Output:
[apricot, cherry, melon]
class java.util.Arrays$ArrayList
false
Exception in thread "main" java.lang.UnsupportedOperationException Sorting the list sorted the array too. The list's class is Arrays$ArrayList — a private nested class of Arrays that has no size-changing methods, not the familiar java.util.ArrayList. Because the names look alike in a stack trace, developers often look for the bug in the wrong place.
An array of primitives does not become a list of numbers
Arrays.asList(new int[]{1, 2, 3}) returns a List<int[]> with one element — the array itself: the generic parameter T cannot be a primitive, so the whole int[] becomes a single varargs argument. The line List<Integer> list = Arrays.asList(new int[]{1, 2, 3}) does not compile. With an Integer[] or a list of values, Arrays.asList(1, 2, 3), everything works as expected.
The subList() method: a view of part of a list
The list.subList(from, to) method returns a view of the elements from index from inclusive to to exclusive. Through a sublist you can read, replace, add and remove elements — every change is reflected in the source list at once. But if the size of the source list is changed bypassing the sublist, the sublist becomes invalid.
import java.util.ArrayList;
import java.util.List;
public class SubListExample {
public static void main(String[] args) {
List<String> list = new ArrayList<>(List.of("a", "b", "c", "d", "e"));
List<String> sub = list.subList(1, 4);
System.out.println(sub);
sub.set(0, "B");
System.out.println(list);
sub.clear();
System.out.println(list + " " + sub);
list.add("f");
System.out.println(sub);
}
} Output:
[b, c, d]
[a, B, c, d, e]
[a, e] []
Exception in thread "main" java.util.ConcurrentModificationException The line sub.clear() removed the elements at indexes 1–3 from the source list — this is the standard way to remove a range: list.subList(from, to).clear(). After list.add("f") the sublist “knows” that the source list was structurally modified and answers any access (toString(), size(), get()) with ConcurrentModificationException. Replacing an element with list.set() is not a structural modification and does not break the sublist. How this fail-fast mechanism works is explained in the Iterator lesson.
Wrong bounds are checked right away: for a five-element list, subList(3, 1) throws IllegalArgumentException: fromIndex(3) > toIndex(1), and subList(0, 10) throws IndexOutOfBoundsException: toIndex = 10. A sublist of a list from Arrays.asList() is fixed-size too: subList(0, 2).clear() throws UnsupportedOperationException.
View or copy
A list from Arrays.asList() is a view of the array, while List.of(array), List.copyOf() and new ArrayList<>(...) create a copy: after the source array changes, they keep the old values. Immutability and a link to the source are different properties: List.of() is immutable, but it is not a view.
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
public class ViewVsCopyExample {
public static void main(String[] args) {
String[] array = {"apricot", "cherry"};
List<String> view = Arrays.asList(array); // view
List<String> copy = new ArrayList<>(Arrays.asList(array)); // mutable copy
List<String> frozen = List.of(array); // immutable copy, Java 9
List<String> frozenCopy = List.copyOf(view); // immutable copy, Java 10
array[0] = "apple";
System.out.println("Arrays.asList: " + view);
System.out.println("new ArrayList: " + copy);
System.out.println("List.of: " + frozen);
System.out.println("List.copyOf: " + frozenCopy);
}
} Output:
Arrays.asList: [apple, cherry]
new ArrayList: [apricot, cherry]
List.of: [apricot, cherry]
List.copyOf: [apricot, cherry] Only view saw the change to the array. You make an independent copy of any view the same way: new ArrayList<>(list.subList(1, 4)), new TreeMap<>(map.subMap("b", "g")), new HashSet<>(map.keySet()). Collections.unmodifiableList(list) is the opposite case: you cannot change the list through the wrapper, but it is still a view, and changes to the original list are visible through it. Other differences between the ways to create an unmodifiable list (null support, Java versions) are compared in Immutable collections.
Where developers get tripped up
Almost every bug with backed collections comes from treating a view as a standalone collection: expecting to add elements to it, expecting it not to change together with the source, or expecting the source to be freely modifiable. The typical mistakes and their fixes:
| What you did | What happens | How to fix it |
|---|---|---|
Arrays.asList(...).add(e) or remove(i) | UnsupportedOperationException | new ArrayList<>(Arrays.asList(...)) |
Passed an int[] to Arrays.asList() | A List<int[]> with one element | Integer[] or Arrays.stream(ints).boxed().toList() |
Changed the list with set() without expecting the array to change | The source array is changed too | Work with a copy: new ArrayList<>(Arrays.asList(array)) |
Kept a subList(), then called add()/remove() on the source list | ConcurrentModificationException on the next access to the sublist | Change the data through the sublist, or copy it right away into new ArrayList<>(...) |
put() into subMap()/headMap() with a key outside the range | IllegalArgumentException: key out of range | Add to the source map |
Returned Collections.unmodifiableList(list) from a method as a “snapshot” | Later changes to list are visible to the caller | List.copyOf(list) |
Frequently Asked Questions
What is the difference between Arrays.asList() and List.of()?
Arrays.asList() is a fixed-size view of an array: set() is allowed and changes the array, and null is accepted. List.of() (Java 9) is an immutable copy: set() throws UnsupportedOperationException, null throws NullPointerException, and changes to the source array are not visible. An example with output is in View or copy.
How do I convert an int[] array to a List<Integer>?
Arrays.asList() will not help here. Use a stream: Arrays.stream(ints).boxed().toList() (Java 16, an unmodifiable list) or Arrays.stream(ints).boxed().collect(Collectors.toList()) for a mutable one. The result is a copy with no link to the source array. More options are in Convert int array to List.
Can subList() cause a memory leak?
Yes. A sublist holds a reference to the whole source list, so if you store bigList.subList(0, 10) of a million-element list in a field, the garbage collector cannot free any of those elements. When you need a long-lived fragment, copy it: new ArrayList<>(bigList.subList(0, 10)) or List.copyOf(...).
Why use views if you can just copy the data?
A view is created in O(1) and takes no memory for elements, and changes made through it go straight into the source. That is why list.subList(a, b).clear() removes a range in one operation, map.keySet().removeIf(...) removes pairs from a map, and Arrays.asList() lets you pass an array to methods that take a List without copying. A copy is needed when the data must live independently of the source.
Comments