How do you know if a Java Collection is Mutable or Immutable?
You don’t. You can’t. You won’t.
I wrote this blog to help Java developers understand the benefits of differentiating between mutable and immutable collection interfaces. Java is an amazingly successful programming language and has been around for almost three decades now. The Java Collections framework is one of the most heavily used parts of the standard Java library and has played an important part in the success of Java. Java has continued to evolve to meet new demands and through this evolution has maintained its spot as one of the top programming languages. As is said with many endeavors, past success is not a guarantee of future results.
Floor wax or dessert topping?
Java has a long history of favoring mutability in its collections interfaces. Since Java 8 was released, the Java language and libraries have begun a slow and steady move to favoring immutability in its collections and other types.
Unfortunately, this move to favoring immutability has been made by adding new implementations, and leveraging the same old collection framework interfaces (e.g. Collection
, List
, Set
, Map
). These interfaces have always been “conditionally” mutable. There is no guarantee of mutability, as specified in the Javadoc for the mutating methods of the interfaces (e.g. add
, remove
).

This is an unfortunate way of saying a Java Collection
may be either a floor wax or a dessert topping (search for SNL skit about floor wax and dessert topping if this reference is unfamiliar). A Collection
may be unsafe to use as a food topping or a cleaning agent, but you won’t know until you try it. Bon Appétit!
Let’s look at the impact this ambiguous design approach has had in the standard Java library over the years. We’ll look at several mutable and immutable implementations of the List
interface in the standard Java libraries.
The following code examples create List
instances using different implementation alternatives. I will indicate which are mutable, and which are immutable, but you will notice they all share the same interface type of List
.
ArrayList
ArrayList
is a mutable implementation of List
.
@Test
public void arrayList()
{
// Mutable
List<String> list = new ArrayList<>();
list.add("✅"); // Works
Assertions.assertEquals(List.of("✅"), list);
}
LinkedList
LinkedList
is a mutable implementation of List
.
@Test
public void linkedList()
{
// Mutable
List<String> list = new LinkedList<>();
list.add("✅"); // Works
Assertions.assertEquals(List.of("✅"), list);
}
CopyOnWriteArrayList
CopyOnWriteArrayList
is a mutable and thread-safe implementation of List
.
@Test
public void copyOnWriteArrayList()
{
// Mutable
List<String> list = new CopyOnWriteArrayList<>();
list.add("✅"); // Works
Assertions.assertEquals(List.of("✅"), list);
}
Arrays.asList()
Arrays.asList()
returns a mutable but non-growable implementation of List
. This means you can set
the elements in the List
to different values, but you can’t add
or remove
from the List
. This implementation of List
is similar to a Java array.
@Test
public void arraysAsList()
{
// Mutable but not growable
List<String> list = Arrays.asList("✅");
list.set(0, "✔️"); // Works
Assertions.assertThrows(
UnsupportedOperationException.class,
() -> list.add(0, "⛔️"));
Assertions.assertEquals(List.of("✔️"), list);
}
Collections.emptyList()
Collections.emptyList()
returns an immutable empty List
.
@Test
public void collectionsEmptyList()
{
// Immutable
List<String> list = Collections.emptyList();
Assertions.assertThrows(
UnsupportedOperationException.class,
() -> list.add(0, "⛔️"));
Assertions.assertEquals(List.of(), list);
}
Collections.singletonList()
Collections.singletonList()
returns an immutable singleton List
.
@Test
public void collectionsSingletonList()
{
// Immutable
List<String> list =
Collections.singletonList("✅");
Assertions.assertThrows(
UnsupportedOperationException.class,
() -> list.add("⛔️"));
Assertions.assertEquals(List.of("✅"), list);
}
Collections.unmodifiableList(List)
Collections.unmodifiableList()
returns an “unmodifiable view” of a List
, but the List
it is wrapping could be modified separately as the code below shows. The List
it is wrapping could be mutable or immutable, but if it was immutable, the view would be redundant.
@Test
public void collectionsUnmodifiableList()
{
// Mutable
List<String> arrayList = new ArrayList<>();
arrayList.add("✅");
// "Unmodifiable" but arrayList is still Mutable
List<String> list = Collections.unmodifiableList(arrayList);
Assertions.assertThrows(
UnsupportedOperationException.class,
() -> list.add("⛔️"));
Assertions.assertEquals(List.of("✅"), list);
arrayList.add("⛔️");
Assertions.assertEquals(List.of("✅", "⛔️"), list);
}
Collections.synchronizedList(List)
Collections.synchronizedList
returns a “conditionally thread-safe” instance of List
. By “conditionally thread-safe” it means methods like iterator
, stream
, and parallelStream
are unsafe and have to be protected with an explicit lock by the developer.
@Test
public void collectionsSynchronizedList()
{
// Mutable
List<String> arrayList = new ArrayList<>();
arrayList.add("✅");
// Mutable and "Conditionally Thread-safe"
List<String> list = Collections.synchronizedList(arrayList);
Assertions.assertEquals(List.of("✅"), list);
list.add("✅");
Assertions.assertEquals(List.of("✅", "✅"), list);
}
List.of()
List.of()
returns an immutable List
.
@Test
public void listOf()
{
// Immutable
List<String> list = List.of("✅");
Assertions.assertThrows(
UnsupportedOperationException.class,
() -> list.add("⛔️"));
Assertions.assertEquals(List.of("✅"), list);
}
List.copyOf(List)
List.copyOf() copies the contents of another List
and returns an immutable List
.
@Test
public void listCopyOf()
{
// Immutable
List<String> list = List.copyOf(new ArrayList<>(List.of("✅")));
Assertions.assertThrows(
UnsupportedOperationException.class,
() -> list.add("⛔️"));
Assertions.assertEquals(List.of("✅"), list);
}
Stream.toList()
Stream.toList()
returns an immutable List
.
@Test
public void streamToList()
{
// Immutable
List<String> list = Stream.of("✅").toList();
Assertions.assertThrows(
UnsupportedOperationException.class,
() -> list.add("⛔️"));
Assertions.assertEquals(List.of("✅"), list);
}
Stream.collect(Collectors.toList())
Stream.collect(Collectors.toList())
will return a mutable List
today, but there is no guarantee that it will continue to be mutable in the future.
@Test
public void streamCollectCollectorsToList()
{
// Mutable
List<String> list = Stream.of("✅")
.collect(Collectors.toList());
list.add("✅");
Assertions.assertEquals(List.of("✅", "✅"), list);
}
Stream.collect(Collectors.toUnmodifiableList())
Stream.collect(Collectors.toUnmodifiableList())
will return an unmodifiable List
today, which is effectively immutable, since no one can easily get a pointer to the mutable List
that it wraps.
@Test
public void streamCollectCollectorsToUnmodifiableList()
{
// Mutable
List<String> list = Stream.of("✅")
.collect(Collectors.toUnmodifiableList());
Assertions.assertThrows(
UnsupportedOperationException.class,
() -> list.add("⛔️"));
Assertions.assertEquals(List.of("✅"), list);
}
List
has a lot of potential implementations, with no way to differentiate whether they are mutable or immutable.
Hobson’s choice by design
A free choice in which only one thing is actually offered.
-Hobson’s Choice from Wikipedia
The Java Collections framework has favored simplicity and minimalism in its hierarchy design for the past 25 years. The Java Collections framework has been hugely successful, based on at least one measure of success. This measure of success is that millions of developers have been able to learn and use the framework to build useful applications for the past 25 years. This has been a huge win for Java.
The simple design of the Java Collections Framework makes it straightforward for developers to learn the four basic Collection
types. These types are named Collection
, List
, Set
, Map
and have been available since JDK 1.2. Most developers can get up to speed using collections quickly by learning these basic types.
In a mutable world, these and a few more type variations (e.g. bag, sorted, ordered) would satisfy most daily Java developer needs. The reality is that we now live in a hybrid world where mutability and immutability need to coexist. If we want our code to communicate better to future developers, then we need a differentiation between mutable and immutable types.
The Land of the List. Extending the API with Stream.
I will demonstrate what using List
looks like without differentiation of types using the Java Collections framework and Java Stream library. Look at the result types of filter
, map
, collect
. Can you tell by looking only at this code if the return types for methods used in this example are mutable or immutable?
@Test
public void landOfTheList()
{
var mapping =
Map.of("🍂", "Leaves", "🍁", "Leaf", "🥧", "Pie", "🦃", "Turkey");
// Mutable or Immutable?
List<String> november =
Arrays.asList("🍂", "🍁", "🥧", "🦃");
// Mutable or Immutable?
List<String> filter =
november.stream()
.filter("🦃"::equals)
.toList();
// Mutable or Immutable?
List<String> filterNot =
november.stream()
.filter(Predicate.not("🦃"::equals))
.collect(Collectors.toList());
// Mutable or Immutable?
List<String> map =
november.stream()
.map(mapping::get)
.collect(Collectors.toUnmodifiableList());
// Mutable or Immutable?
Map<Boolean, List<String>> partition =
november.stream()
.collect(Collectors.partitioningBy("🦃"::equals));
// Mutable or Immutable?
Map<String, List<String>> groupBy =
november.stream()
.collect(Collectors.groupingBy(mapping::get));
// Mutable or Immutable?
Map<String, Long> countBy =
november.stream()
.collect(Collectors.groupingBy(mapping::get,
Collectors.counting()));
Assertions.assertEquals(List.of("🦃"), filter);
Assertions.assertEquals(List.of("🍂", "🍁", "🥧"), filterNot);
Assertions.assertEquals(List.of("Leaves", "Leaf", "Pie", "Turkey"), map);
Assertions.assertEquals(filter, partition.get(true));
Assertions.assertEquals(filterNot, partition.get(false));
var expectedGroupBy =
Map.of("Leaves", List.of("🍂"),
"Leaf", List.of("🍁"),
"Pie", List.of("🥧"),
"Turkey", List.of("🦃"));
Assertions.assertEquals(expectedGroupBy, groupBy);
Assertions.assertEquals(
Map.of("Leaves", 1L, "Leaf", 1L, "Pie", 1L, "Turkey", 1L), countBy);
}
With this design approach, the safest alternative is often to trust no one and create copies of collections before operating on them. This can lead to unnecessary waste due to a lack of trust.
Nothing in this world is free. The choices available today for Java developers who want a hybrid collections framework with a clear differentiation between mutable and immutable collection interfaces are as follows:
Differentiation by design
Imagine a Java world where there are three kinds of collections interfaces that provide a clear differentiation of types between readable, mutable, and immutable. The interfaces that follow are the names used in Eclipse Collections.
- Readable
RichIterable
,ListIterable
,SetIterable
, andMapIterable
- Mutable
MutableCollection
,MutableList
,MutableSet
, andMutableMap
- Immutable
ImmutableCollection
,ImmutableList
,ImmutableSet
andImmutableMap
Tripling the total number of types necessary to provide differentiation means it will take a developer more time to learn frameworks like Scala Collections, Kotlin Collections, and Eclipse Collections. This is the equivalent of a Java developer moving on from a high-school education to higher education at a university.
I am only going to show how this differentiation looks in practice for Eclipse Collections. Having differentiated collection types along with a functional and fluent API results in extensive usage of Covariant Return Types in the Eclipse Collections API. Covariant Return Types is an awesome feature that has been available since Java 5.
Eclipse Collections Type Differentiation
The following examples will follow the example I used in “Land of the List” above and focus on two of the basic Java Collection types — List
and Set
. I will show examples of using both mutable and immutable differentiation types in Eclipse Collections. The examples will cover MutableList
, MutableSet
, ImmutableList
, ImmutableSet
and show differentiated Covariant Return Types for each of these types for the following methods.
select
(akafilter
)— returns aRichIterable
typereject
(akafilterNot
) — returns aRichIterable
typecollect
(akamap
)— returns aRichIterable
typepartition
(akapartitioningBy
)— returns aPartitionIterable
typegroupBy
(akagroupingBy
)— returns aMultimap
typecountBy
(akagroupingBy
+counting
)— returns aBag
type
The question for each of these methods’ return types is whether the return types are mutable or immutable?
MutableList
The return types for the methods on MutableList
are covariant overrides of the parent RichIterable
interface. Are the return types from the methods for MutableList
mutable or immutable?
@Test
public void covariantReturnTypesMutableList()
{
var mapping =
Maps.immutable.of("🍂", "Leaves", "🍁", "Leaf", "🥧", "Pie", "🦃", "Turkey");
// Mutable or Immutable?
MutableList<String> november =
Lists.mutable.of("🍂", "🍁", "🥧", "🦃");
// Mutable or Immutable?
MutableList<String> select =
november.select("🦃"::equals);
// Mutable or Immutable?
MutableList<String> reject =
november.reject("🦃"::equals);
// Mutable or Immutable?
MutableList<String> collect =
november.collect(mapping::get);
// Mutable or Immutable?
PartitionMutableList<String> partition =
november.partition("🦃"::equals);
// Mutable or Immutable?
MutableListMultimap<String, String> groupBy =
november.groupBy(mapping::get);
// Mutable or Immutable?
MutableBag<String> countBy =
november.countBy(mapping::get);
Assertions.assertEquals(List.of("🦃"), select);
Assertions.assertEquals(List.of("🍂", "🍁", "🥧"), reject);
Assertions.assertEquals(
List.of("Leaves", "Leaf", "Pie", "Turkey"), collect);
Assertions.assertEquals(select, partition.getSelected());
Assertions.assertEquals(reject, partition.getRejected());
var expectedGroupBy =
Multimaps.mutable.list.with("Leaves", "🍂", "Leaf", "🍁", "Pie", "🥧")
.withKeyMultiValues("Turkey", "🦃");
Assertions.assertEquals(expectedGroupBy, groupBy);
Assertions.assertEquals(
Bags.mutable.of("Leaves", "Leaf", "Pie", "Turkey"), countBy);
}
ImmutableList
The return types for the methods on ImmutableList
are covariant overrides of the parent RichIterable
interface. Are the return types from the methods for ImmutableList
mutable or immutable?
@Test
public void covariantReturnTypesImmutableList()
{
var mapping =
Maps.immutable.of("🍂", "Leaves", "🍁", "Leaf", "🥧", "Pie", "🦃", "Turkey");
// Mutable or Immutable?
ImmutableList<String> november =
Lists.immutable.of("🍂", "🍁", "🥧", "🦃");
// Mutable or Immutable?
ImmutableList<String> select =
november.select("🦃"::equals);
// Mutable or Immutable?
ImmutableList<String> reject =
november.reject("🦃"::equals);
// Mutable or Immutable?
ImmutableList<String> collect =
november.collect(mapping::get);
// Mutable or Immutable?
PartitionImmutableList<String> partition =
november.partition("🦃"::equals);
// Mutable or Immutable?
ImmutableListMultimap<String, String> groupBy =
november.groupBy(mapping::get);
// Mutable or Immutable?
ImmutableBag<String> countBy =
november.countBy(mapping::get);
Assertions.assertEquals(List.of("🦃"), select);
Assertions.assertEquals(List.of("🍂", "🍁", "🥧"), reject);
Assertions.assertEquals(
List.of("Leaves", "Leaf", "Pie", "Turkey"), collect);
Assertions.assertEquals(select, partition.getSelected());
Assertions.assertEquals(reject, partition.getRejected());
var expectedGroupBy =
Multimaps.mutable.list.with("Leaves", "🍂", "Leaf", "🍁", "Pie", "🥧")
.withKeyMultiValues("Turkey", "🦃").toImmutable();
Assertions.assertEquals(expectedGroupBy, groupBy);
Assertions.assertEquals(
Bags.immutable.of("Leaves", "Leaf", "Pie", "Turkey"), countBy);
}
MutableSet
The return types for the methods on MutableSet
are covariant overrides of the parent RichIterable
interface. Are the return types from the methods for MutableSet
mutable or immutable?
@Test
public void covariantReturnTypesMutableSet()
{
var mapping =
Maps.immutable.of("🍂", "Leaves", "🍁", "Leaf", "🥧", "Pie", "🦃", "Turkey");
// Mutable or Immutable?
MutableSet<String> november =
Sets.mutable.of("🍂", "🍁", "🥧", "🦃");
// Mutable or Immutable?
MutableSet<String> select =
november.select("🦃"::equals);
// Mutable or Immutable?
MutableSet<String> reject =
november.reject("🦃"::equals);
// Mutable or Immutable?
MutableSet<String> collect =
november.collect(mapping::get);
// Mutable or Immutable?
PartitionMutableSet<String> partition =
november.partition("🦃"::equals);
// Mutable or Immutable?
MutableSetMultimap<String, String> groupBy =
november.groupBy(mapping::get);
// Mutable or Immutable?
MutableBag<String> countBy =
november.countBy(mapping::get);
Assertions.assertEquals(Set.of("🦃"), select);
Assertions.assertEquals(Set.of("🍂", "🍁", "🥧"), reject);
Assertions.assertEquals(
Set.of("Leaves", "Leaf", "Pie", "Turkey"), collect);
Assertions.assertEquals(select, partition.getSelected());
Assertions.assertEquals(reject, partition.getRejected());
var expectedGroupBy =
Multimaps.mutable.set.with("Leaves", "🍂", "Leaf", "🍁", "Pie", "🥧")
.withKeyMultiValues("Turkey", "🦃");
Assertions.assertEquals(expectedGroupBy, groupBy);
Assertions.assertEquals(
Bags.mutable.of("Leaves", "Leaf", "Pie", "Turkey"), countBy);
}
ImmutableSet
The return types for the above methods on ImmutableSet
are covariant overrides of the parent RichIterable
interface. Are the return types from the methods for ImmutableSet
mutable or immutable?
@Test
public void covariantReturnTypesImmutableSet()
{
var mapping =
Maps.immutable.of("🍂", "Leaves", "🍁", "Leaf", "🥧", "Pie", "🦃", "Turkey");
// Mutable or Immutable?
ImmutableSet<String> november =
Sets.immutable.of("🍂", "🍁", "🥧", "🦃");
// Mutable or Immutable?
ImmutableSet<String> select =
november.select("🦃"::equals);
// Mutable or Immutable?
ImmutableSet<String> reject =
november.reject("🦃"::equals);
// Mutable or Immutable?
ImmutableSet<String> collect =
november.collect(mapping::get);
// Mutable or Immutable?
PartitionImmutableSet<String> partition =
november.partition("🦃"::equals);
// Mutable or Immutable?
ImmutableSetMultimap<String, String> groupBy =
november.groupBy(mapping::get);
// Mutable or Immutable?
ImmutableBag<String> countBy =
november.countBy(mapping::get);
Assertions.assertEquals(Set.of("🦃"), select);
Assertions.assertEquals(Set.of("🍂", "🍁", "🥧"), reject);
Assertions.assertEquals(Set.of("Leaves", "Leaf", "Pie", "Turkey"), collect);
Assertions.assertEquals(select, partition.getSelected());
Assertions.assertEquals(reject, partition.getRejected());
var expectedGroupBy =
Multimaps.mutable.set.with("Leaves", "🍂", "Leaf", "🍁", "Pie", "🥧")
.withKeyMultiValues("Turkey", "🦃").toImmutable();
Assertions.assertEquals(expectedGroupBy, groupBy);
Assertions.assertEquals(Bags.immutable.of("Leaves", "Leaf", "Pie", "Turkey"), countBy);
}
Trust but Verify — Internal vs. External API users
One point of type differentiation is to communicate intent more clearly to developers who will read and use your code. There is an argument that says that by providing interfaces for immutable types, developers may be evil and lie and provide a mutable implementation of an immutable interface. A developer can also provide an immutable implementation of a mutable interface. Both of these are indeed possibilities. One option available in Java today is to use Sealed types to limit implementation alternatives in the hierarchy design. I blogged about this possibility a few years ago.
If you cannot trust the developers using your API (e.g. unknown external users), then your only option is to copy data for incoming collection parameters, regardless of which interface is passed to you. Differentiated return types should be safe, and can still communicate intent more clearly.
If you can trust the developers using your API (internal users), then the differentiation of types will make the intent of your code much clearer.
I hope this blog helps developers understand the benefits of type differentiation for mutable and immutable collections in Java. Thank you for reading!
Further Reading
After I published this blog and shared it on Twitter/X, one of my readers commented and shared a link to a great blog he wrote back in March, 2023 about understanding the nature of a List
in Java. Here is the blog written by Stefano Fago. Thank you for sharing, Stefano!
I am the creator of and committer for the Eclipse Collections OSS project, which is managed at the Eclipse Foundation. Eclipse Collections is open for contributions.