How do you know if a Java Collection is Mutable or Immutable?

Donald Raab
Javarevisited
Published in
10 min readNov 19, 2023

You don’t. You can’t. You won’t.

Photo by Michelle Tresemer on Unsplash

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).

Javadoc snippet from add method in Collection interface

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:

  1. Scala Collections
  2. Kotlin + ImmutableCollections
  3. Eclipse Collections

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, and MapIterable
  • Mutable
    MutableCollection, MutableList, MutableSet, and MutableMap
  • Immutable
    ImmutableCollection, ImmutableList, ImmutableSet and ImmutableMap

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.

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.

--

--

Donald Raab
Javarevisited

Java Champion. Creator of the Eclipse Collections OSS Java library (https://github.com/eclipse/eclipse-collections). Inspired by Smalltalk. Opinions are my own.