Notes
Introduction
This article notes the caveats of Stream#toList().
Stream#toList() is a new method introduced in Java 16, and like Stream#collect(Collectors.toList()), it converts a Stream to a List.
However, there is a trap.
Even so, some articles merely say "replace Stream#collect(Collectors.toList()) with Stream#toList()" and stop there.
So I wrote this as a warning. The problem statement and conclusion overlap with JDK 16 : stream.toList() に見る API 設計の難しさ - A Memorandum, but here I look at more detailed implementations and code with similar behavior.
LITERALLY TL;DL
Stream#collect()performs mutable reduction.Stream#toList()returns an unmodifiable view of a list.- You should not casually rewrite
Stream#collect(Collectors.toList())toStream#toList(). - Read the Javadoc.
Converting Stream to List
Let's think about code that converts a Stream to a List in Java.
Here, we compare behavior before and after Stream#toList() was added to the JDK.
Java 8
Since Java 8, you can convert Stream to List with Stream#collect(Collectors.toList()).
Here we use jshell to check behavior.
To understand Stream#collect(Collectors.toList()), define a utility method timesN that takes a List<Integer>
and returns a new List with each element multiplied by n.
1jshell> class ListUtils {2...> static List<Integer> timesN(List<Integer> xs, int n) {3...> return xs.stream()4...> .map(x -> x * n)5...> .collect(Collectors.toList());6...> }7...> }8| 次を作成しました: クラス ListUtils
Using this method, you get a list where each element of List.of(1, 2, 3) is doubled.
1jshell> ListUtils.timesN(List.of(1, 2, 3), 2);2$1 ==> [2, 4, 6]
Now try adding an element to the list returned by timesN.
1jshell> $1.add(8);2$2 ==> true34jshell> $15$1 ==> [2, 4, 6, 8]
You can add elements without issue.
This is because Collectors.toList() returns an ArrayList.
1jshell> $1.getClass();2$3 ==> class java.util.ArrayList
However, the Javadoc does not state that it returns an ArrayList.
It also says nothing is guaranteed about mutability.
But if you check the OpenJDK implementation, you can see it returns ArrayList.
1public class Collectors {2// ...34/**5* Returns a {@code Collector} that accumulates the input elements into a6* new {@code List}. There are no guarantees on the type, mutability,7* serializability, or thread-safety of the {@code List} returned; if more8* control over the returned {@code List} is required, use {@link #toCollection(Supplier)}.9*10* @param <T> the type of the input elements11* @return a {@code Collector} which collects all the input elements into a12* {@code List}, in encounter order13*/14public static <T>15Collector<T, ?, List<T>> toList() {16return new CollectorImpl<>(ArrayList::new, List::add,17(left, right) -> { left.addAll(right); return left; },18CH_ID);19}2021// ...22}
OpenJDK returns ArrayList, but you should not write code assuming Collectors.toList() returns ArrayList.
According to the standard Javadoc, the behavior of code like $1.add(8) is undefined.
Another JDK might not use ArrayList.
In reality, code that adds elements to the result of Stream#collect(Collectors.toList()) is probably being written.
Many people might think they never do that.
It's true that you rarely call List#add() immediately after collecting,
but if you return the List from a method (as here), someone could easily call add() later.
Java 16
Now try Stream#toList() added in Java 16.
It converts to List similarly.
1jshell> class ListUtils {2...> static List<Integer> timesN(List<Integer> xs, int n) {3...> return xs.stream()4...> .map(x -> x * n)5...> .toList();6...> }7...> }8| 次を作成しました: クラス ListUtils
Of course, it returns the doubled list just like collect(toList()).
1jshell> ListUtils.timesN(List.of(1, 2, 3), 2);2$4 ==> [2, 4, 6]
Now try adding an element as before.
1jshell> $4.add(8);2| 例外java.lang.UnsupportedOperationException3| at ImmutableCollections.uoe (ImmutableCollections.java:142)4| at ImmutableCollections$AbstractImmutableCollection.add (ImmutableCollections.java:147)5| at (#4:1)
Oops 😕
An exception occurred. Check the class.
1jshell> $4.getClass();2$5 ==> class java.util.ImmutableCollections$ListN
Where did we go wrong?
Let's stop and re-read the docs for Stream#collect() and Stream#toList().
Docs and implementation
Stream#collect(Collectors.toList())
Java's Stream#collect() performs mutable reduction.
This article is already long, so I will write a separate article about
Stream#collect()later.
Stream#toList()
The Javadoc says:
The returned List is unmodifiable. Calls to mutator methods always throw UnsupportedOperationException.
It also says:
Implementation Requirements:
Implementations return a List created as follows:
Collections.unmodifiableList(new ArrayList<>(Arrays.asList(this.toArray())))
The point is simple: Stream#toList() is not a replacement for Stream#collect(Collectors.toList()).
Stream#collect(Collectors.toList()) does not guarantee mutability,
but Stream#toList() guarantees immutability via Collections#unmodifiableList.
To understand Stream#toList(), you need to know:
this.toArray()Arrays#asListArrayListconstructorCollections#unmodifiableList
this.toArray()
Looking at the source, Stream#toArray() is abstract.
1/**2* Returns an array containing the elements of this stream.3*4* <p>This is a <a href="package-summary.html#StreamOps">terminal5* operation</a>.6*7* @return an array, whose {@linkplain Class#getComponentType runtime component8* type} is {@code Object}, containing the elements of this stream9*/10Object[] toArray();
So it depends on how the Stream implementation is written.
The most common implementation used in classes implementing List is probably ArrayList.
ArrayList implements toArray() in AbstractCollection.
1/**2* {@inheritDoc}3*4* @implSpec5* This implementation returns an array containing all the elements6* returned by this collection's iterator, in the same order, stored in7* consecutive elements of the array, starting with index {@code 0}.8* The length of the returned array is equal to the number of elements9* returned by the iterator, even if the size of this collection changes10* during iteration, as might happen if the collection permits11* concurrent modification during iteration. The {@code size} method is12* called only as an optimization hint; the correct result is returned13* even if the iterator returns a different number of elements.14*15* <p>This method is equivalent to:16*17* <pre> {@code18* List<E> list = new ArrayList<E>(size());19* for (E e : this)20* list.add(e);21* return list.toArray();22* }</pre>23*/24public Object[] toArray() {25// Estimate size of array; be prepared to see more or fewer elements26Object[] r = new Object[size()];27Iterator<E> it = iterator();28for (int i = 0; i < r.length; i++) {29if (! it.hasNext()) // fewer elements than expected30return Arrays.copyOf(r, i);31r[i] = it.next();32}33return it.hasNext() ? finishToArray(r, it) : r;34}
It copies elements into an array using an Iterator.
So it scans the list from beginning to end once.
Arrays#asList
Next, check the implementation of Arrays#asList.
1/**2* Returns a fixed-size list backed by the specified array. Changes made to3* the array will be visible in the returned list, and changes made to the4* list will be visible in the array. The returned list is5* {@link Serializable} and implements {@link RandomAccess}.6*7* <p>The returned list implements the optional {@code Collection} methods, except8* those that would change the size of the returned list. Those methods leave9* the list unchanged and throw {@link UnsupportedOperationException}.10*11* @apiNote12* This method acts as bridge between array-based and collection-based13* APIs, in combination with {@link Collection#toArray}.14*15* <p>This method provides a way to wrap an existing array:16* <pre>{@code17* Integer[] numbers = ...18* ...19* List<Integer> values = Arrays.asList(numbers);20* }</pre>21*22* <p>This method also provides a convenient way to create a fixed-size23* list initialized to contain several elements:24* <pre>{@code25* List<String> stooges = Arrays.asList("Larry", "Moe", "Curly");26* }</pre>27*28* <p><em>The list returned by this method is modifiable.</em>29* To create an unmodifiable list, use30* {@link Collections#unmodifiableList Collections.unmodifiableList}31* or <a href="List.html#unmodifiable">Unmodifiable Lists</a>.32*33* @param <T> the class of the objects in the array34* @param a the array by which the list will be backed35* @return a list view of the specified array36* @throws NullPointerException if the specified array is {@code null}37*/38@SafeVarargs39@SuppressWarnings("varargs")40public static <T> List<T> asList(T... a) {41return new ArrayList<>(a);42}
The ArrayList here is not java.util.ArrayList, but Arrays.ArrayList, an inner class of Arrays.
1/**2* @serial include3*/4private static class ArrayList<E> extends AbstractList<E>5implements RandomAccess, java.io.Serializable6{7@java.io.Serial8private static final long serialVersionUID = -2764017481108945198L;9@SuppressWarnings("serial") // Conditionally serializable10private final E[] a;1112ArrayList(E[] array) {13a = Objects.requireNonNull(array);14}
The constructor just keeps the passed array after a null check.
Mutable operations like add are not implemented in this ArrayList, but in its parent AbstractList.
1/**2* {@inheritDoc}3*4* @implSpec5* This implementation always throws an6* {@code UnsupportedOperationException}.7*8* @throws UnsupportedOperationException {@inheritDoc}9* @throws ClassCastException {@inheritDoc}10* @throws NullPointerException {@inheritDoc}11* @throws IllegalArgumentException {@inheritDoc}12* @throws IndexOutOfBoundsException {@inheritDoc}13*/14public void add(int index, E element) {15throw new UnsupportedOperationException();16}
As you can see, it always throws UnsupportedOperationException, so Arrays#asList(...) returns an unmodifiable list.
ArrayList constructor
ArrayList's constructor is implemented as follows.
1/**2* Constructs a list containing the elements of the specified3* collection, in the order they are returned by the collection's4* iterator.5*6* @param c the collection whose elements are to be placed into this list7* @throws NullPointerException if the specified collection is null8*/9public ArrayList(Collection<? extends E> c) {10Object[] a = c.toArray();11if ((size = a.length) != 0) {12if (c.getClass() == ArrayList.class) {13elementData = a;14} else {15elementData = Arrays.copyOf(a, size, Object[].class);16}17} else {18// replace with empty array.19elementData = EMPTY_ELEMENTDATA;20}21}
It takes any collection and converts it to an array with Collection#toArray().
If the collection is java.util.ArrayList, it keeps the array as-is;
otherwise it copies it with Arrays#copyOf.
In our case, the constructor receives java.util.Arrays$ArrayList (not java.util.ArrayList),
so the array is copied via Arrays#copyOf.
Before checking Arrays#copyOf, let's see how toArray is implemented in the ArrayList returned by Arrays#asList().
1@Override2public Object[] toArray() {3return Arrays.copyOf(a, a.length, Object[].class);4}
Inside Arrays#asList, Arrays#copyOf is already called.
Surprisingly, in this flow, ArrayList's constructor calls Arrays#copyOf twice.
Maybe I'm missing something.
This double copy only happens for ArrayList; for other data structures, this.toArray() might behave differently.
Now let's check Arrays#copyOf.
1/**2* Copies the specified array, truncating or padding with nulls (if necessary)3* so the copy has the specified length. For all indices that are4* valid in both the original array and the copy, the two arrays will5* contain identical values. For any indices that are valid in the6* copy but not the original, the copy will contain {@code null}.7* Such indices will exist if and only if the specified length8* is greater than that of the original array.9* The resulting array is of the class {@code newType}.10*11* @param <U> the class of the objects in the original array12* @param <T> the class of the objects in the returned array13* @param original the array to be copied14* @param newLength the length of the copy to be returned15* @param newType the class of the copy to be returned16* @return a copy of the original array, truncated or padded with nulls17* to obtain the specified length18* @throws NegativeArraySizeException if {@code newLength} is negative19* @throws NullPointerException if {@code original} is null20* @throws ArrayStoreException if an element copied from21* {@code original} is not of a runtime type that can be stored in22* an array of class {@code newType}23* @since 1.624*/25@IntrinsicCandidate26public static <T,U> T[] copyOf(U[] original, int newLength, Class<? extends T[]> newType) {27@SuppressWarnings("unchecked")28T[] copy = ((Object)newType == (Object)Object[].class)29? (T[]) new Object[newLength]30: (T[]) Array.newInstance(newType.getComponentType(), newLength);31System.arraycopy(original, 0, copy, 0,32Math.min(original.length, newLength));33return copy;34}
It allocates a same-size array and copies it.
This is as far as we can follow in Java.
It uses System.arraycopy,
which is a JVM native method, not Java code.
So in ArrayList's constructor, if the collection stores data in an array, it copies it efficiently.
Collections#unmodifiableList()
Finally, let's check Collections#unmodifiableList().
1/**2* Returns an <a href="Collection.html#unmodview">unmodifiable view</a> of the3* specified list. Query operations on the returned list "read through" to the4* specified list, and attempts to modify the returned list, whether5* direct or via its iterator, result in an6* {@code UnsupportedOperationException}.<p>7*8* The returned list will be serializable if the specified list9* is serializable. Similarly, the returned list will implement10* {@link RandomAccess} if the specified list does.11*12* @implNote This method may return its argument if the argument is already unmodifiable.13* @param <T> the class of the objects in the list14* @param list the list for which an unmodifiable view is to be returned.15* @return an unmodifiable view of the specified list.16*/17@SuppressWarnings("unchecked")18public static <T> List<T> unmodifiableList(List<? extends T> list) {19if (list.getClass() == UnmodifiableList.class || list.getClass() == UnmodifiableRandomAccessList.class) {20return (List<T>) list;21}2223return (list instanceof RandomAccess ?24new UnmodifiableRandomAccessList<>(list) :25new UnmodifiableList<>(list));26}
Since Collections#unmodifiableList() receives a java.util.ArrayList instance,
it returns new UnmodifiableRandomAccessList<>(list).
Most of UnmodifiableRandomAccessList is implemented in UnmodifiableList.
1/**2* @serial include3*/4static class UnmodifiableRandomAccessList<E> extends UnmodifiableList<E>5implements RandomAccess6{7UnmodifiableRandomAccessList(List<? extends E> list) {8super(list);9}1011public List<E> subList(int fromIndex, int toIndex) {12return new UnmodifiableRandomAccessList<>(13list.subList(fromIndex, toIndex));14}1516@java.io.Serial17private static final long serialVersionUID = -2542308836966382001L;1819/**20* Allows instances to be deserialized in pre-1.4 JREs (which do21* not have UnmodifiableRandomAccessList). UnmodifiableList has22* a readResolve method that inverts this transformation upon23* deserialization.24*/25@java.io.Serial26private Object writeReplace() {27return new UnmodifiableList<>(list);28}29}
UnmodifiableList wraps the provided list and throws UnsupportedOperationException for mutating operations.
1/**2* @serial include3*/4static class UnmodifiableList<E> extends UnmodifiableCollection<E>5implements List<E> {6@java.io.Serial7private static final long serialVersionUID = -283967356065247728L;89@SuppressWarnings("serial") // Conditionally serializable10final List<? extends E> list;1112UnmodifiableList(List<? extends E> list) {13super(list);14this.list = list;15}1617public boolean equals(Object o) {return o == this || list.equals(o);}18public int hashCode() {return list.hashCode();}1920public E get(int index) {return list.get(index);}21public E set(int index, E element) {22throw new UnsupportedOperationException();23}24public void add(int index, E element) {25throw new UnsupportedOperationException();26}27// ...
Collections#unmodifiableList() wraps the original list without copying data.
Therefore, in Stream#toList(), if the source collection is ArrayList,
there is no scan from head to tail—only two array copies.
Collectors#toUnmodifiableList()
If you've looked at Stream methods since Java 11, you might think:
"There is Collectors#toUnmodifiableList(). So isn't Stream#toList() a replacement for Stream#collect(Collectors.toUnmodifiableList())?"
Let's look at the implementation.
1public class Collectors {2// ...34/**5* Returns a {@code Collector} that accumulates the input elements into an6* <a href="../List.html#unmodifiable">unmodifiable List</a> in encounter7* order. The returned Collector disallows null values and will throw8* {@code NullPointerException} if it is presented with a null value.9*10* @param <T> the type of the input elements11* @return a {@code Collector} that accumulates the input elements into an12* <a href="../List.html#unmodifiable">unmodifiable List</a> in encounter order13* @since 1014*/15public static <T>16Collector<T, ?, List<T>> toUnmodifiableList() {17return new CollectorImpl<>(ArrayList::new, List::add,18(left, right) -> { left.addAll(right); return left; },19list -> {20if (list.getClass() == ArrayList.class) { // ensure it's trusted21return SharedSecrets.getJavaUtilCollectionAccess()22.listFromTrustedArray(list.toArray());23} else {24throw new IllegalArgumentException();25}26},27CH_NOID);28}29}
Going into Collector behavior would make this even longer, so I won't explain CollectorImpl in detail.
This CollectorImpl accumulates elements into an ArrayList, merges lists, and finally converts to an unmodifiable list via listFromTrustedArray.
The interesting part is SharedSecrets. Let's look at it.
1/** A repository of "shared secrets", which are a mechanism for2calling implementation-private methods in another package without3using reflection. A package-private class implements a public4interface and provides the ability to call package-private methods5within that package; the object implementing that interface is6provided through a third package to which access is restricted.7This framework avoids the primary disadvantage of using reflection8for this purpose, namely the loss of compile-time checking. */910public class SharedSecrets {11// ...1213public static JavaUtilCollectionAccess getJavaUtilCollectionAccess() {14var access = javaUtilCollectionAccess;15if (access == null) {16try {17Class.forName("java.util.ImmutableCollections$Access", true, null);18access = javaUtilCollectionAccess;19} catch (ClassNotFoundException e) {}20}21return access;22}2324// ...25}
Why is it written this way? The Javadoc explains it, and so does this StackOverflow answer, which also touches on JPMS introduced in Java 9.
Here java.util.ImmutableCollections$Access provides a JavaUtilCollectionAccess implementation, so let's check that.
1static class Access {2static {3SharedSecrets.setJavaUtilCollectionAccess(new JavaUtilCollectionAccess() {4public <E> List<E> listFromTrustedArray(Object[] array) {5return ImmutableCollections.listFromTrustedArray(array);6}7public <E> List<E> listFromTrustedArrayNullsAllowed(Object[] array) {8return ImmutableCollections.listFromTrustedArrayNullsAllowed(array);9}10});11}12}
It converts an array to an unmodifiable list via ImmutableCollections#listFromTrustedArray.
The implementation is below, and it explicitly checks for null on each element before conversion.
So it scans the list from beginning to end once.
1/**2* Creates a new List from a trusted array, checking for and rejecting null3* elements.4*5* <p>A trusted array has no references retained by the caller. It can therefore be6* safely reused as the List's internal storage, avoiding a defensive copy. The array's7* class must be Object[].class. This method is declared with a parameter type of8* Object... instead of E... so that a varargs call doesn't accidentally create an array9* of some class other than Object[].class.10*11* @param <E> the List's element type12* @param input the input array13* @return the new list14*/15@SuppressWarnings("unchecked")16static <E> List<E> listFromTrustedArray(Object... input) {17assert input.getClass() == Object[].class;18for (Object o : input) { // implicit null check of 'input' array19Objects.requireNonNull(o);20}2122return switch (input.length) {23case 0 -> (List<E>) ImmutableCollections.EMPTY_LIST;24case 1 -> (List<E>) new List12<>(input[0]);25case 2 -> (List<E>) new List12<>(input[0], input[1]);26default -> (List<E>) new ListN<>(input, false);27};28}
From this code, the list returned by Collectors#toUnmodifiableList() is one of:
ImmutableCollections.EMPTY_LISTList12ListN
Their implementations are similar to UnmodifiableList, so I skip them here.
Differences between Stream#collect(Collectors.toList()) and Stream#toList()
Stream#collect(Collectors.toList()) and Stream#toList() both return unmodifiable lists.
They look the same, but if you followed the implementation, you should see two clear differences.
When elements include null
Stream#collect(Collectors.toUnmodifiableList()) and Stream#toList() behave differently when elements contain null.
Let's check in jshell.
1jshell> Stream.of(1, null, 3).toList();2$1 ==> [1, null, 3]34jshell> Stream.of(1, null, 3).collect(Collectors.toUnmodifiableList());5| 例外java.lang.NullPointerException6| at Objects.requireNonNull (Objects.java:209)7| at ImmutableCollections.listFromTrustedArray (ImmutableCollections.java:213)8| at ImmutableCollections$Access$1.listFromTrustedArray (ImmutableCollections.java:124)9| at Collectors.lambda$toUnmodifiableList$6 (Collectors.java:303)10| at ReferencePipeline.collect (ReferencePipeline.java:686)11| at (#13:1)
Stream#toList() allows null, but Stream#collect(Collectors.toUnmodifiableList()) throws when null is present.
That's a big difference.
Runtime overhead
As mentioned, Stream#collect(Collectors.toUnmodifiableList()) first scans the list to accumulate into an array,
then scans again to perform explicit null checks.
So it scans the list twice, causing more overhead.
Collectors.toList() also scans once to fill an ArrayList,
but since it doesn't do null checks, it scans once.
Stream#toList() copies the array twice, but does not refill an ArrayList; it just wraps it with Collections.unmodifiableList().
So in code, it looks much faster than the other two.
Summary of implementations
Summarizing the findings:
| Expression | Mutability | Null | Efficiency |
|---|---|---|---|
Stream#collect(Collectors.toList()) | 👎 Mutable | 👍 OK | 👎 Bad |
Stream#collect(Collectors.toUnmodifiableList()) | 👍 Immutable | 👎 NG | 👎 Worst |
Stream#toList() | 👍 Immutable | 👍 OK | 👍 Best |
I did not know this relationship before writing the article.
Everyone is different and everyone is good.
Misuzu Kaneko
Naming is important
The core problem is lack of naming consistency.
Traditionally in Java, List typically meant a mutable list [citation needed].
Indeed, Collectors#toList() returns a mutable list.
And for unmodifiable collections, methods were named with the unmodifiable- prefix, like Collectors#toUnmodifiableList().
So perhaps Stream#toList() should have been named Stream#toUnmodifiableList().
I haven't traced why Stream#toList() was added, but the person who chose the name bears a heavy sin (personal opinion).
A half-baked implementation
Who is happy about collections being mutable across method/class boundaries today? Mutability makes code harder to follow and causes bugs.
To meet that need, Java 10 added Collectors#toUnmodifiableList().
It returns an unmodifiable List.
But looking at its implementation, Collectors#toUnmodifiableList() is clearly slower than the other two methods,
so it is hard to recommend.
Lesson
This shows the difficulty of API design. Not only standard library API design, but also API design in code that uses the standard library. We should not expose more information than necessary, and we should avoid APIs that require users to know internal details. In a standard library, you would think you could be less careful, but with Java collections, the burden falls on users.
Therefore, when changing collection-related code, you must pay attention not just to the interface but the concrete instance.
Conclusion
This article was about Stream#toList()'s caveats, but it is more accurate to say it is about Stream#collect(Collectors.toList())
and the issues in the Java standard library. I didn't expect it to become such a long journey.
In general, programmers must understand data structures and choose appropriately, but today many people code without learning API design or data structures. And not only beginners, but even experienced developers often do not read official docs before using classes and methods. For this issue, the naming and API design of the Java standard library seem responsible.
This may not be unique to Java, but in my (limited) experience, Java collections are hard. The Java practice of throwing runtime exceptions for invalid operations makes it worse.
This mutability Q&A is described in the Collections Framework FAQ.
It is long, but I still think it might have been better to separate APIs by mutability.
Fighting collections where mutability is unknown until you call a method is painful.
Half-baked immutability leads to traps like Stream#toList().
At that point, you might think: why not just always use mutable collections?
This case shows that in Java, immutable collections give almost no static-analysis benefit.
Languages like Scala center on immutable collections, but it seems hard for a language built around mutable collections like Java to transition.
I look forward to the day Java collections are rebuilt.
At least after reading this article, you won't be so afraid of Stream#toList() that you can only sleep at night.
Please nap peacefully.
If you feel relieved, I'd be happy if you react with 👍 in Giscus.

