Immutable collections are collections whose elements cannot be added, removed, or modified once they are created. In Java, immutability offers several benefits:
Starting with Java 9, the List
, Set
, and Map
interfaces introduced convenient static factory methods: List.of()
, Set.of()
, and Map.of()
. These allow concise creation of immutable collections.
import java.util.List;
import java.util.Set;
import java.util.Map;
public class ImmutableCollectionsDemo {
public static void main(String[] args) {
List<String> fruits = List.of("apple", "banana", "cherry");
Set<Integer> numbers = Set.of(1, 2, 3);
Map<String, Integer> ageMap = Map.of("Alice", 30, "Bob", 25);
System.out.println(fruits);
System.out.println(numbers);
System.out.println(ageMap);
}
}
Output:
[apple, banana, cherry]
[1, 2, 3]
{Alice=30, Bob=25}
These collections are immutable. Any attempt to modify them throws an UnsupportedOperationException
.
fruits.add("orange"); // Throws UnsupportedOperationException
Collections.unmodifiableXXX()
Prior to Java 9, immutability was achieved using wrappers like:
List<String> modifiable = new ArrayList<>();
List<String> unmodifiable = Collections.unmodifiableList(modifiable);
However, this only creates a view of the original collection that prevents direct modifications. If the original collection is changed, the unmodifiable view reflects those changes—meaning it's not truly immutable.
In contrast, List.of()
and its siblings create genuinely immutable collections that cannot be changed by any reference.
List.of()
and similar methods throw a NullPointerException
if any element or key/value is null
.List<String> badList = List.of("a", null); // Throws NullPointerException
Immutable collections introduced in Java 9 through factory methods (List.of
, Set.of
, Map.of
) are concise, null-safe, and truly immutable. They improve program safety and simplify concurrent code by eliminating unintended side effects. When you need fixed, read-only data structures, prefer these new factory methods over older wrapper-based approaches.
The Builder Pattern is a design pattern that provides a flexible and readable way to construct complex objects step-by-step. When applied to collections, builders offer a fluent and expressive way to assemble data structures—especially when the collection requires a complex setup or when immutability is desired.
Traditional constructors or factory methods like List.of()
are great for simple, fixed sets of data. But when:
…a builder pattern becomes more appropriate and maintainable.
In Java, the Collectors
utility class includes a toUnmodifiableList()
, which works well with streams to simulate a builder-like pipeline:
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
public class StreamBuilderExample {
public static void main(String[] args) {
List<String> names = Stream.of("Alice", "Bob", "Charlie")
.filter(name -> name.startsWith("A") || name.startsWith("C"))
.collect(Collectors.toUnmodifiableList());
System.out.println(names); // [Alice, Charlie]
}
}
This method creates an immutable collection through a builder-like fluent chain of operations.
You can also create your own builder for more controlled scenarios:
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
class ListBuilder<T> {
private final List<T> items = new ArrayList<>();
public ListBuilder<T> add(T item) {
items.add(item);
return this;
}
public ListBuilder<T> addAll(List<T> otherItems) {
items.addAll(otherItems);
return this;
}
public List<T> build() {
return Collections.unmodifiableList(new ArrayList<>(items));
}
}
public class CustomBuilderDemo {
public static void main(String[] args) {
List<String> names = new ListBuilder<String>()
.add("Apple")
.add("Banana")
.add("Cherry")
.build();
System.out.println(names); // [Apple, Banana, Cherry]
// names.add("Date"); // Throws UnsupportedOperationException
}
}
This builder allows step-by-step construction and produces an immutable list at the end.
return this
) for fluent usage.build()
).Builder patterns offer a flexible and expressive way to construct complex or immutable collections. They’re ideal when collection construction is conditional, iterative, or involves computed values. Java 9’s unmodifiable collections and fluent stream collectors work well for simple needs, while custom builders give fine-grained control and readability in more complex scenarios.
While Java’s standard collection classes (like ArrayList
, HashSet
, HashMap
) cover most needs, there are scenarios where creating a custom collection becomes valuable. These include:
To implement a custom collection, Java provides several interfaces:
Collection<E>
: The root interface for most collections.List<E>
, Set<E>
, Map<K, V>
: Specialized subinterfaces for ordered, unique, or key-value structures.Iterator<E>
: For enabling iteration over your collection.When building a custom collection, you'll typically extend an abstract base class like:
AbstractCollection<E>
AbstractList<E>
AbstractSet<E>
AbstractMap<K, V>
These classes provide default implementations for many methods, allowing you to focus only on the essential overrides.
1. Mutability: Decide if your collection should allow modifications. If immutable, throw UnsupportedOperationException
in methods like add()
or remove()
.
2. Thread-safety: Will the collection be accessed concurrently? If so, consider using synchronization or concurrent-friendly data structures.
3. Performance: Analyze time and space complexity. Avoid unnecessary copying or boxing.
The following is a simple wrapper over a list that logs every get()
call:
import java.util.AbstractList;
import java.util.List;
public class LoggingList<E> extends AbstractList<E> {
private final List<E> internalList;
public LoggingList(List<E> list) {
this.internalList = list;
}
@Override
public E get(int index) {
E value = internalList.get(index);
System.out.println("Accessed index " + index + ": " + value);
return value;
}
@Override
public int size() {
return internalList.size();
}
// Making it read-only
@Override
public E set(int index, E element) {
throw new UnsupportedOperationException("This list is read-only");
}
@Override
public boolean add(E e) {
throw new UnsupportedOperationException("This list is read-only");
}
@Override
public E remove(int index) {
throw new UnsupportedOperationException("This list is read-only");
}
public static void main(String[] args) {
List<String> original = List.of("Alpha", "Beta", "Gamma");
LoggingList<String> loggingList = new LoggingList<>(original);
// Test access
System.out.println(loggingList.get(1)); // Logs and prints "Beta"
// loggingList.add("Delta"); // Throws UnsupportedOperationException
}
}
Explanation:
get()
logs the index and value.add
, remove
, set
) are disabled for immutability.Custom collection implementations allow developers to tailor behavior, enforce rules, or optimize performance beyond what standard collections offer. By extending abstract base classes and carefully considering mutability and concurrency, you can integrate custom logic while staying compatible with Java’s collection framework. Always ensure your implementation adheres to the contracts of the interfaces to avoid unexpected behavior.
This section provides practical, self-contained examples that illustrate how to:
List.of
, Set.of
, Map.of
import java.util.List;
import java.util.Set;
import java.util.Map;
public class ImmutableDemo {
public static void main(String[] args) {
List<String> list = List.of("Java", "Python", "Go");
Set<Integer> set = Set.of(1, 2, 3);
Map<String, Integer> map = Map.of("A", 1, "B", 2);
// Uncommenting below lines will throw UnsupportedOperationException
// list.add("Rust");
// set.remove(2);
// map.put("C", 3);
System.out.println("Immutable List: " + list);
System.out.println("Immutable Set: " + set);
System.out.println("Immutable Map: " + map);
}
}
Explanation: Java 9 introduced convenient static methods to create immutable collections. These are thread-safe, concise, and ideal for constants or read-only views.
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
class CollectionBuilder<T> {
private final List<T> items = new ArrayList<>();
public CollectionBuilder<T> add(T item) {
items.add(item);
return this;
}
public List<T> buildImmutable() {
return Collections.unmodifiableList(new ArrayList<>(items));
}
}
public class BuilderDemo {
public static void main(String[] args) {
List<String> list = new CollectionBuilder<String>()
.add("One")
.add("Two")
.add("Three")
.buildImmutable();
System.out.println("Built Immutable List: " + list);
// list.add("Four"); // Throws UnsupportedOperationException
}
}
Explanation: This builder provides a readable, chainable way to add elements and create an immutable result, improving maintainability.
import java.util.AbstractSet;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Set;
class LoggingSet<E> extends AbstractSet<E> {
private final Set<E> internalSet = new HashSet<>();
@Override
public boolean add(E e) {
System.out.println("Adding: " + e);
return internalSet.add(e);
}
@Override
public Iterator<E> iterator() {
return internalSet.iterator();
}
@Override
public int size() {
return internalSet.size();
}
}
public class CustomSetDemo {
public static void main(String[] args) {
Set<String> logSet = new LoggingSet<>();
logSet.add("Apple");
logSet.add("Banana");
logSet.add("Apple"); // Duplicate, won't be added again
System.out.println("Set contents: " + logSet);
}
}
Explanation: This LoggingSet
overrides add()
to print added elements. It wraps HashSet
and can be extended further for validation, statistics, etc.
These examples demonstrate best practices for modern Java collections:
Together, these patterns promote safer, more maintainable, and purpose-fit collection usage in real-world Java applications.