Java Immutable Collections
Java Immutable Collections
An immutable collection cannot be modified after it is created. No element can be added, removed, or replaced. This single guarantee eliminates an entire class of bugs: shared state corrupted by unexpected mutation, concurrent modification exceptions, and defensive copy boilerplate scattered across every method boundary. Java 9 introduced clean factory methods — List.of(), Set.of(), Map.of() — that create truly immutable collections in one line.
What Are Java Immutable Collections?
An immutable collection is one where every mutating operation — add(), remove(), set(), put(), clear() — throws UnsupportedOperationException. The collection's contents are fixed at creation time and cannot change through any reference. Java provides three layers of immutability with increasing strength.
THREE LAYERS OF COLLECTION IMMUTABILITY IN JAVA: LAYER 1 — UNMODIFIABLE WRAPPER (Java 1.2+): Collections.unmodifiableList(list) Collections.unmodifiableSet(set) Collections.unmodifiableMap(map) ───────────────────────────────────── Mutation attempts on the WRAPPER throw UnsupportedOperationException. BUT: the backing collection is still mutable. Changes to the backing collection ARE VISIBLE through the wrapper. This is a READ-ONLY VIEW, not true immutability. LAYER 2 — TRULY IMMUTABLE FACTORY COLLECTIONS (Java 9+): List.of(e1, e2, e3) Set.of(e1, e2, e3) Map.of(k1, v1, k2, v2) Map.entry(key, value) Map.ofEntries(Map.entry(k1,v1), Map.entry(k2,v2)) ───────────────────────────────────────────────── No backing mutable structure. The collection IS the data — no reference to a mutable original. Cannot be modified by ANY reference, not just the one you hold. NULL elements or keys/values throw NullPointerException. Set.of() and Map.of() reject duplicate keys/elements. Iteration ORDER IS NOT GUARANTEED (implementation may vary). LAYER 3 — IMMUTABLE DEFENSIVE COPY (Java 10+): List.copyOf(existingCollection) Set.copyOf(existingCollection) Map.copyOf(existingMap) ───────────────────────────────── Creates a new immutable collection from any existing collection. If the input is already an immutable collection of the same type, no copy is made — the same instance may be returned. NULL elements throw NullPointerException. Changes to the original collection are NOT reflected.
Basic Overview — The Full Immutable API
LIST.OF() — IMMUTABLE LIST:
List<E> List.of() ← empty
List<E> List.of(e1) ← 1 element
List<E> List.of(e1, e2, ..., e10) ← up to 10 element overloads
List<E> List.of(E... elements) ← varargs for 11+
Properties: null REJECTED, duplicates ALLOWED, order PRESERVED
SET.OF() — IMMUTABLE SET:
Set<E> Set.of() ← empty
Set<E> Set.of(e1, e2, ..., e10) ← up to 10 element overloads
Set<E> Set.of(E... elements) ← varargs for 11+
Properties: null REJECTED, duplicates REJECTED (IllegalArgumentException),
iteration order NOT GUARANTEED
MAP.OF() — IMMUTABLE MAP (up to 10 entries):
Map<K,V> Map.of() ← empty
Map<K,V> Map.of(k1,v1, k2,v2, ..., k10,v10)← alternating key-value pairs
Map<K,V> Map.ofEntries(Map.Entry<K,V>...) ← for 11+ entries
Map.Entry<K,V> Map.entry(key, value) ← for ofEntries()
Map<K,V> Map.copyOf(existingMap) ← Java 10+
Properties: null keys/values REJECTED, duplicate keys REJECTED,
iteration order NOT GUARANTEED
LIST.COPYOF / SET.COPYOF / MAP.COPYOF (Java 10+):
Creates an immutable collection from any Collection or Map.
If input already is an appropriate immutable collection: MAY return same ref.
NULL elements throw NullPointerException.
Set.copyOf() and Map.copyOf() reject duplicates in the source.
COLLECTIONS.UNMODIFIABLE*() (Java 1.2+, legacy pattern):
Returns a READ-ONLY VIEW of the backing collection.
Changes to backing collection are visible through the wrapper.
Use only when you need to expose a live mutable collection as read-only.
For new code: prefer List.of(), Set.of(), Map.of() for truly immutable data.
When to Use Immutable Collections
USE immutable collections WHEN:
1. A method returns a collection that callers must not modify
— configuration data, lookup tables, allow-lists
— returning a field from a service: callers get read-only access
— before Java 9: Collections.unmodifiableList(internalList)
— Java 9+: return List.copyOf(internalList) for true immutability
2. Thread safety is needed without synchronisation overhead
— immutable collections can be read by any number of threads safely
— no lock, no volatile, no ConcurrentHashMap needed for reads
— Java Memory Model: publication through final fields makes visible
3. Map or Set constants in the codebase
— static final Map<String, Integer> HTTP_STATUS = Map.of(...)
— static final Set<String> ALLOWED_DOMAINS = Set.of(...)
— impossible to accidentally mutate from test code or other classes
4. Defensive programming in constructors of immutable classes
— store List.copyOf(input) not input itself — decouples from caller's reference
— caller cannot invalidate the stored collection by mutating their original
5. API design: signalling intent
— returning List.of() says "this result has no elements — do not add to it"
— returning new ArrayList<>() accidentally implies callers may add to it
CHOOSE mutable collections WHEN:
— Elements must be added, removed, or updated after creation
— The collection is built incrementally and finalised later
— Accumulated in a loop, then returned as immutable:
List.Builder pattern or collect + copyOf
DO NOT USE immutable collections AS:
— A thread-safe accumulator (use ConcurrentHashMap, synchronizedList)
— A queue or work list (use ArrayDeque, LinkedBlockingQueue)
— A cache that evicts entries (use LinkedHashMap with removeEldestEntry)
How Immutable Factory Collections Work Internally
List.of() IMPLEMENTATION:
List.of() with 0-2 elements returns specialised ImmutableCollections.List0,
ImmutableCollections.List1, or ImmutableCollections.List2.
List.of() with 3-10 elements returns ImmutableCollections.ListN backed by
a final Object[] array with the elements.
All mutating methods (add, remove, set, clear, replaceAll, sort) call:
throw new UnsupportedOperationException();
SIZE, GET, CONTAINS, ITERATOR are supported.
ITERATOR does NOT support iterator.remove() — throws UnsupportedOperationException.
Set.of() IMPLEMENTATION:
NOT backed by a HashMap.
Uses a custom hash table with open addressing (probe-based, compact).
Iteration order is intentionally unspecified and may differ per JVM run.
This prevents code from accidentally depending on order.
Map.of() IMPLEMENTATION:
Uses a custom compact hash table — NOT a HashMap.
Iteration order is intentionally unspecified.
For Map.ofEntries() with many entries: also a compact implementation.
SHARED CHARACTERISTICS OF JAVA 9+ FACTORY COLLECTIONS:
— All implement Serializable
— All are value-based: equals() and hashCode() work correctly
— No null keys, values, or elements (NPE thrown immediately)
— Duplicate keys/set elements throw IllegalArgumentException at creation
— Random iteration order for Set.of() and Map.of() prevents order dependence
Core Operations with Examples
List.of(), Set.of(), Map.of() — The Java 9+ API
1// File: ImmutableCollectionsBasicsDemo.java
2
3import java.util.List;
4import java.util.Map;
5import java.util.Set;
6
7public class ImmutableCollectionsBasicsDemo {
8
9 public static void main(String[] args) {
10
11 // List.of() — ordered, duplicates allowed, null rejected
12 System.out.println("=== List.of() ===");
13 List<String> frameworks = List.of("Spring", "Quarkus", "Micronaut", "Vert.x");
14 System.out.println("List : " + frameworks);
15 System.out.println("get(1) : " + frameworks.get(1));
16 System.out.println("contains : " + frameworks.contains("Spring"));
17 System.out.println("size : " + frameworks.size());
18
19 try {
20 frameworks.add("Jakarta EE"); // mutation attempt
21 } catch (UnsupportedOperationException e) {
22 System.out.println("add() throws UnsupportedOperationException");
23 }
24 try {
25 frameworks.set(0, "Helidon"); // set attempt
26 } catch (UnsupportedOperationException e) {
27 System.out.println("set() throws UnsupportedOperationException");
28 }
29
30 System.out.println();
31
32 // Set.of() — unique elements, order NOT guaranteed, null rejected
33 System.out.println("=== Set.of() ===");
34 Set<String> roles = Set.of("ADMIN", "DEVELOPER", "VIEWER", "ANALYST");
35 System.out.println("Set : " + roles);
36 System.out.println("contains : " + roles.contains("ADMIN"));
37
38 try {
39 Set.of("A", "B", "A"); // duplicate
40 } catch (IllegalArgumentException e) {
41 System.out.println("Duplicate in Set.of() throws IllegalArgumentException");
42 }
43 try {
44 roles.add("MANAGER"); // mutation attempt
45 } catch (UnsupportedOperationException e) {
46 System.out.println("add() throws UnsupportedOperationException");
47 }
48
49 System.out.println();
50
51 // Map.of() — unique keys, order NOT guaranteed, null rejected
52 System.out.println("=== Map.of() ===");
53 Map<String, Integer> httpCodes = Map.of(
54 "OK", 200,
55 "Created", 201,
56 "Bad Request", 400,
57 "Unauthorized", 401,
58 "Not Found", 404,
59 "Internal Error", 500
60 );
61 System.out.println("Map size : " + httpCodes.size());
62 System.out.println("get(OK) : " + httpCodes.get("OK"));
63 System.out.println("get(Missing): " + httpCodes.get("Gone")); // null — key absent
64
65 try {
66 httpCodes.put("Conflict", 409);
67 } catch (UnsupportedOperationException e) {
68 System.out.println("put() throws UnsupportedOperationException");
69 }
70
71 System.out.println();
72
73 // Null is rejected by all factory methods
74 System.out.println("=== Null rejection ===");
75 try { List.of("A", null, "C"); }
76 catch (NullPointerException e) { System.out.println("List.of(null) throws NPE"); }
77 try { Set.of("A", null); }
78 catch (NullPointerException e) { System.out.println("Set.of(null) throws NPE"); }
79 try { Map.of("key", null); }
80 catch (NullPointerException e) { System.out.println("Map.of(null value) throws NPE"); }
81 }
82}Output:
=== List.of() ===
List : [Spring, Quarkus, Micronaut, Vert.x]
get(1) : Quarkus
contains : true
size : 4
add() throws UnsupportedOperationException
set() throws UnsupportedOperationException
=== Set.of() ===
Set : [ADMIN, ANALYST, DEVELOPER, VIEWER]
contains : true
Duplicate in Set.of() throws IllegalArgumentException
add() throws UnsupportedOperationException
=== Map.of() ===
Map size : 6
get(OK) : 200
get(Missing): null
put() throws UnsupportedOperationException
=== Null rejection ===
List.of(null) throws NPE
Set.of(null) throws NPE
Map.of(null value) throws NPE
copyOf() and Unmodifiable Wrappers — The Difference
1// File: CopyOfVsUnmodifiableDemo.java
2
3import java.util.ArrayList;
4import java.util.Collections;
5import java.util.HashMap;
6import java.util.HashSet;
7import java.util.List;
8import java.util.Map;
9import java.util.Set;
10
11public class CopyOfVsUnmodifiableDemo {
12
13 public static void main(String[] args) {
14
15 // DEMONSTRATION: unmodifiable wrapper is a VIEW
16 System.out.println("=== unmodifiableList — VIEW of backing list ===");
17 List<String> backing = new ArrayList<>(List.of("Kafka", "Redis", "MySQL"));
18 List<String> view = Collections.unmodifiableList(backing);
19
20 System.out.println("view before backing change: " + view);
21 backing.add("MongoDB"); // mutate the backing list
22 System.out.println("view AFTER backing change : " + view); // reflects the change!
23
24 try { view.add("Cassandra"); }
25 catch (UnsupportedOperationException e) {
26 System.out.println("view.add() blocked — but backing changes propagate");
27 }
28
29 System.out.println();
30
31 // DEMONSTRATION: List.copyOf() is a TRUE immutable snapshot
32 System.out.println("=== List.copyOf() — TRUE immutable snapshot ===");
33 List<String> source = new ArrayList<>(List.of("Kafka", "Redis", "MySQL"));
34 List<String> snapshot = List.copyOf(source); // creates a new immutable copy
35
36 System.out.println("snapshot before change: " + snapshot);
37 source.add("MongoDB"); // mutate the source
38 System.out.println("snapshot AFTER change : " + snapshot); // NOT reflected
39
40 System.out.println();
41
42 // DEMONSTRATION: Set.copyOf() from a mutable set
43 System.out.println("=== Set.copyOf() — from mutable Set ===");
44 Set<String> mutableSet = new HashSet<>();
45 mutableSet.add("Java"); mutableSet.add("Go"); mutableSet.add("Python");
46 Set<String> immutableSet = Set.copyOf(mutableSet);
47 System.out.println("immutableSet: " + immutableSet);
48 mutableSet.add("Rust"); // original changes
49 System.out.println("immutableSet unchanged: " + immutableSet);
50
51 System.out.println();
52
53 // DEMONSTRATION: Map.copyOf() for config protection
54 System.out.println("=== Map.copyOf() — protect config ===");
55 Map<String, String> configMap = new HashMap<>();
56 configMap.put("db.host", "prod-db-01.internal");
57 configMap.put("db.port", "5432");
58 configMap.put("cache.ttl", "300");
59 Map<String, String> immutableConfig = Map.copyOf(configMap);
60 System.out.println("Config: " + immutableConfig);
61 configMap.put("db.host", "test-db-01"); // source modified
62 System.out.println("Immutable config unchanged: " + immutableConfig.get("db.host"));
63
64 System.out.println();
65
66 // BUILD then FREEZE pattern — common in service layer
67 System.out.println("=== Build-then-freeze pattern ===");
68 List<String> buildBuffer = new ArrayList<>();
69 buildBuffer.add("Step 1: Validate");
70 buildBuffer.add("Step 2: Authenticate");
71 buildBuffer.add("Step 3: Process");
72 buildBuffer.add("Step 4: Respond");
73 List<String> pipeline = List.copyOf(buildBuffer); // freeze
74 buildBuffer.clear(); // clear buffer — pipeline unaffected
75 System.out.println("Frozen pipeline: " + pipeline);
76 }
77}Output:
=== unmodifiableList — VIEW of backing list ===
view before backing change: [Kafka, Redis, MySQL]
view AFTER backing change : [Kafka, Redis, MySQL, MongoDB]
view.add() blocked — but backing changes propagate
=== List.copyOf() — TRUE immutable snapshot ===
snapshot before change: [Kafka, Redis, MySQL]
snapshot AFTER change : [Kafka, Redis, MySQL]
=== Set.copyOf() — from mutable Set ===
immutableSet: [Go, Java, Python]
immutableSet unchanged: [Go, Java, Python]
=== Map.copyOf() — protect config ===
Config: {db.host=prod-db-01.internal, cache.ttl=300, db.port=5432}
Immutable config unchanged: prod-db-01.internal
=== Build-then-freeze pattern ===
Frozen pipeline: [Step 1: Validate, Step 2: Authenticate, Step 3: Process, Step 4: Respond]
Map.ofEntries() and Large Immutable Maps
1// File: ImmutableMapAdvancedDemo.java
2
3import java.util.List;
4import java.util.Map;
5import java.util.Set;
6import java.util.stream.Collectors;
7
8public class ImmutableMapAdvancedDemo {
9
10 // HTTP status code registry — static constant, never changes
11 private static final Map<Integer, String> HTTP_STATUS_MAP = Map.ofEntries(
12 Map.entry(200, "OK"),
13 Map.entry(201, "Created"),
14 Map.entry(204, "No Content"),
15 Map.entry(301, "Moved Permanently"),
16 Map.entry(302, "Found"),
17 Map.entry(400, "Bad Request"),
18 Map.entry(401, "Unauthorized"),
19 Map.entry(403, "Forbidden"),
20 Map.entry(404, "Not Found"),
21 Map.entry(409, "Conflict"),
22 Map.entry(422, "Unprocessable Entity"),
23 Map.entry(429, "Too Many Requests"),
24 Map.entry(500, "Internal Server Error"),
25 Map.entry(502, "Bad Gateway"),
26 Map.entry(503, "Service Unavailable")
27 );
28
29 // Allowed payment methods — immutable constant set
30 private static final Set<String> ALLOWED_PAYMENT_METHODS =
31 Set.of("UPI", "CREDIT_CARD", "DEBIT_CARD", "NET_BANKING", "WALLET");
32
33 // Feature flag defaults — immutable map
34 private static final Map<String, Boolean> DEFAULT_FEATURE_FLAGS = Map.of(
35 "dark_mode", true,
36 "beta_checkout", false,
37 "ai_suggestions", true,
38 "express_delivery", true,
39 "gamification", false
40 );
41
42 public static void main(String[] args) {
43
44 System.out.println("=== HTTP status map (15 entries via ofEntries) ===");
45 System.out.println("200 → " + HTTP_STATUS_MAP.get(200));
46 System.out.println("404 → " + HTTP_STATUS_MAP.get(404));
47 System.out.println("503 → " + HTTP_STATUS_MAP.get(503));
48 System.out.println("Map size: " + HTTP_STATUS_MAP.size());
49
50 System.out.println();
51
52 System.out.println("=== Allowed payment method check ===");
53 List<String> incoming = List.of("UPI", "CRYPTO", "CREDIT_CARD", "GIFT_CARD");
54 incoming.forEach(method -> {
55 boolean allowed = ALLOWED_PAYMENT_METHODS.contains(method);
56 System.out.printf(" %-16s %s%n", method, allowed ? "ALLOWED" : "REJECTED");
57 });
58
59 System.out.println();
60
61 System.out.println("=== Default feature flags ===");
62 DEFAULT_FEATURE_FLAGS.entrySet().stream()
63 .sorted(Map.Entry.comparingByKey())
64 .forEach(e -> System.out.printf(" %-20s %s%n",
65 e.getKey(), e.getValue() ? "ENABLED" : "DISABLED"));
66
67 System.out.println();
68
69 // Using copyOf to merge and freeze
70 System.out.println("=== Merging and freezing with copyOf ===");
71 Map<String, Boolean> userOverrides = Map.of(
72 "dark_mode", false,
73 "gamification", true
74 );
75
76 // Merge defaults with overrides into a new mutable map, then freeze
77 Map<String, Boolean> merged = new java.util.HashMap<>(DEFAULT_FEATURE_FLAGS);
78 merged.putAll(userOverrides); // override defaults
79 Map<String, Boolean> userFlags = Map.copyOf(merged); // freeze
80
81 userFlags.entrySet().stream()
82 .sorted(Map.Entry.comparingByKey())
83 .forEach(e -> System.out.printf(" %-20s %s%n",
84 e.getKey(), e.getValue() ? "ENABLED" : "DISABLED"));
85 }
86}Output:
=== HTTP status map (15 entries via ofEntries) ===
200 → OK
404 → Not Found
503 → Service Unavailable
Map size: 15
=== Allowed payment method check ===
UPI ALLOWED
CRYPTO REJECTED
CREDIT_CARD ALLOWED
GIFT_CARD REJECTED
=== Default feature flags ===
ai_suggestions ENABLED
beta_checkout DISABLED
dark_mode ENABLED
express_delivery ENABLED
gamification DISABLED
=== Merging and freezing with copyOf ===
ai_suggestions ENABLED
beta_checkout DISABLED
dark_mode DISABLED
express_delivery ENABLED
gamification ENABLED
Real-World Example — Swiggy Restaurant Configuration Service
A restaurant configuration service at Swiggy loads cuisine types, delivery zone constants, and platform fee rules at startup. These are immutable lookup tables — they never change during a service lifetime, are read by hundreds of threads per second, and must never be accidentally modified by any code path. Map.of(), Set.of(), and List.copyOf() make this contract explicit in the type system.
1// File: RestaurantConfig.java
2
3import java.util.List;
4import java.util.Map;
5import java.util.Set;
6
7public final class RestaurantConfig {
8
9 // Cuisine → list of allergen tags — never changes at runtime
10 public static final Map<String, List<String>> CUISINE_ALLERGENS = Map.ofEntries(
11 Map.entry("Indian", List.of("GLUTEN", "DAIRY", "NUTS")),
12 Map.entry("Chinese", List.of("SOY", "GLUTEN", "SHELLFISH")),
13 Map.entry("Italian", List.of("GLUTEN", "DAIRY", "EGGS")),
14 Map.entry("Mexican", List.of("GLUTEN", "DAIRY")),
15 Map.entry("Thai", List.of("NUTS", "SOY", "SHELLFISH")),
16 Map.entry("Continental",List.of("GLUTEN", "DAIRY", "EGGS")),
17 Map.entry("Desserts", List.of("DAIRY", "EGGS", "NUTS", "GLUTEN"))
18 );
19
20 // Delivery partner tier → surge multiplier
21 public static final Map<String, Double> SURGE_MULTIPLIERS = Map.of(
22 "STANDARD", 1.0,
23 "PEAK", 1.5,
24 "HEAVY_RAIN",2.0,
25 "FESTIVAL", 1.75,
26 "MIDNIGHT", 1.25
27 );
28
29 // Zones where Swiggy Instamart operates
30 public static final Set<String> INSTAMART_ZONES = Set.of(
31 "Koramangala", "Indiranagar", "HSR Layout",
32 "Whitefield", "Marathahalli","Jayanagar",
33 "JP Nagar", "Hebbal", "Bannerghatta Road"
34 );
35
36 // Platform fee slabs by order amount — immutable ordered list
37 public static final List<double[]> FEE_SLABS = List.of(
38 new double[]{0, 100, 5.0}, // Rs.0–100: Rs.5 flat fee
39 new double[]{100, 300, 10.0}, // Rs.100–300: Rs.10 flat fee
40 new double[]{300, 500, 15.0}, // Rs.300–500: Rs.15 flat fee
41 new double[]{500, 999, 20.0}, // Rs.500–999: Rs.20 flat fee
42 new double[]{999, Double.MAX_VALUE, 25.0} // Rs.999+: Rs.25
43 );
44
45 private RestaurantConfig() {} // utility class — not instantiable
46
47 public static double platformFee(double orderAmount) {
48 for (double[] slab : FEE_SLABS) {
49 if (orderAmount >= slab[0] && orderAmount < slab[1]) return slab[2];
50 }
51 return 25.0;
52 }
53
54 public static double surgeMultiplier(String tier) {
55 return SURGE_MULTIPLIERS.getOrDefault(tier.toUpperCase(), 1.0);
56 }
57
58 public static boolean instamartAvailable(String zone) {
59 return INSTAMART_ZONES.contains(zone);
60 }
61
62 public static List<String> allergens(String cuisine) {
63 return CUISINE_ALLERGENS.getOrDefault(cuisine, List.of());
64 }
65}1// File: ConfigServiceDemo.java
2
3public class ConfigServiceDemo {
4
5 public static void main(String[] args) {
6
7 // Fee calculation
8 System.out.println("=== Platform fee by order amount ===");
9 double[] amounts = {50, 150, 350, 650, 1200};
10 for (double amount : amounts) {
11 System.out.printf(" Rs.%5.0f → Rs.%.1f platform fee%n",
12 amount, RestaurantConfig.platformFee(amount));
13 }
14
15 System.out.println();
16
17 // Surge pricing
18 System.out.println("=== Surge multipliers ===");
19 String[] tiers = {"STANDARD", "PEAK", "HEAVY_RAIN", "FESTIVAL", "UNKNOWN"};
20 for (String tier : tiers) {
21 System.out.printf(" %-14s %.2fx%n", tier,
22 RestaurantConfig.surgeMultiplier(tier));
23 }
24
25 System.out.println();
26
27 // Zone check
28 System.out.println("=== Instamart zone availability ===");
29 String[] zones = {"Koramangala", "Sarjapur", "Whitefield", "Electronic City"};
30 for (String zone : zones) {
31 System.out.printf(" %-22s %s%n", zone,
32 RestaurantConfig.instamartAvailable(zone) ? "AVAILABLE" : "NOT AVAILABLE");
33 }
34
35 System.out.println();
36
37 // Allergen lookup
38 System.out.println("=== Cuisine allergens ===");
39 String[] cuisines = {"Thai", "Italian", "Japanese"};
40 for (String cuisine : cuisines) {
41 System.out.printf(" %-14s %s%n", cuisine,
42 RestaurantConfig.allergens(cuisine));
43 }
44
45 System.out.println();
46
47 // Proving immutability — config cannot be corrupted by any code path
48 System.out.println("=== Mutation attempts blocked ===");
49 try {
50 RestaurantConfig.INSTAMART_ZONES.add("Domlur");
51 } catch (UnsupportedOperationException e) {
52 System.out.println("INSTAMART_ZONES.add() blocked");
53 }
54 try {
55 RestaurantConfig.SURGE_MULTIPLIERS.put("SUPER_PEAK", 3.0);
56 } catch (UnsupportedOperationException e) {
57 System.out.println("SURGE_MULTIPLIERS.put() blocked");
58 }
59 try {
60 RestaurantConfig.FEE_SLABS.add(new double[]{-1, 0, 0});
61 } catch (UnsupportedOperationException e) {
62 System.out.println("FEE_SLABS.add() blocked");
63 }
64 }
65}Output:
=== Platform fee by order amount ===
Rs. 50 → Rs.5.0 platform fee
Rs. 150 → Rs.10.0 platform fee
Rs. 350 → Rs.15.0 platform fee
Rs. 650 → Rs.20.0 platform fee
Rs. 1200 → Rs.25.0 platform fee
=== Surge multipliers ===
STANDARD 1.00x
PEAK 1.50x
HEAVY_RAIN 2.00x
FESTIVAL 1.75x
UNKNOWN 1.00x
=== Instamart zone availability ===
Koramangala AVAILABLE
Sarjapur NOT AVAILABLE
Whitefield AVAILABLE
Electronic City NOT AVAILABLE
=== Cuisine allergens ===
Thai [NUTS, SOY, SHELLFISH]
Italian [GLUTEN, DAIRY, EGGS]
Japanese []
=== Mutation attempts blocked ===
INSTAMART_ZONES.add() blocked
SURGE_MULTIPLIERS.put() blocked
FEE_SLABS.add() blocked
Performance Considerations
| Factory | Memory | Lookup | Notes |
|---|---|---|---|
List.of() | Compact Object[] | O(n) contains, O(1) get | No overhead beyond the array |
Set.of() | Custom open-addressing hash table | O(1) avg contains | More compact than HashMap — no Entry objects |
Map.of() | Custom compact table | O(1) avg get | ~50% less memory than HashMap per entry |
Collections.unmodifiableList() | Wrapper + backing list memory | Same as backing | 1 wrapper object; backing still allocated |
List.copyOf() | New Object[] copy | O(n) contains, O(1) get | If input is already immutable List, may return same ref |
ArrayList (mutable) | Object[] + overhead | O(n) contains, O(1) get | More flexible but more memory |
HashMap (mutable) | Node[] + Node objects | O(1) avg | ~3x memory per entry vs Map.of() |
Set.of() and Map.of() use significantly less memory than HashSet and HashMap. The JDK implementation uses compact open-addressing hash tables with no wrapper Node objects — roughly 50% less memory per entry. For large static lookup tables, this difference is significant.
List.copyOf() may avoid copying. If the source collection is already an immutable List created by List.of() or List.copyOf(), the JDK may return the same instance without allocating a new array. This optimisation makes List.copyOf() safe to call defensively without worrying about unnecessary allocation.
Thread safety is free. Immutable collections need no synchronized wrappers, no ConcurrentHashMap, and no volatile fields. They can be read by any number of threads simultaneously with no overhead. Publishing through a final field or via safe publication makes them visible to all threads under the Java Memory Model.
Best Practices
Use List.of(), Set.of(), Map.of() for static constants instead of initialised mutable collections. private static final Map<String, Integer> CODES = new HashMap<>() followed by a static initialiser block has four problems: the map is mutable (any code path can corrupt it), the initialiser block is verbose, HashMap uses more memory than Map.of(), and thread safety requires extra care. private static final Map<String, Integer> CODES = Map.of("OK", 200, "Created", 201) is immutable, compact, and thread-safe by default.
Return List.copyOf(internalList) from service methods instead of the live list. When a service returns an internal ArrayList, callers could modify it — corrupting the service's state without any indication. return List.copyOf(this.items) creates a snapshot that callers can iterate safely. Unlike Collections.unmodifiableList(), which still exposes the backing list to mutation by the owner, List.copyOf() is a true disconnected snapshot.
Use the build-then-freeze pattern for incrementally constructed immutable collections. List.copyOf(buildBuffer) after the buffer is fully populated is cleaner than attempting to build immutably from the start. Accumulate into an ArrayList or HashMap in a loop, then freeze with List.copyOf() / Map.copyOf() before returning or storing. This gives full mutability during construction and full immutability afterwards.
Prefer Map.ofEntries(Map.entry(...), ...) for maps with more than 10 entries. Map.of() has overloads up to 10 key-value pairs. The 11th entry needs Map.ofEntries(). Map.entry(key, value) is the clean companion factory. Do not use new AbstractMap.SimpleImmutableEntry<>() — Map.entry() is the intended API.
Common Mistakes
Mistake 1 — Assuming unmodifiableList() Protects Against All Mutation
1List<String> internal = new ArrayList<>(List.of("A", "B", "C"));
2List<String> exposed = Collections.unmodifiableList(internal);
3
4// Caller receives exposed and cannot modify it directly
5// exposed.add("D"); → UnsupportedOperationException
6
7// BUT: the service itself can still mutate internal
8internal.add("D"); // no one blocked THIS
9
10// Caller now sees "D" through exposed — the "protection" was one-sided
11System.out.println(exposed); // [A, B, C, D]
12
13// CORRECT — use List.copyOf() for true protection
14List<String> snapshot = List.copyOf(internal);
15internal.add("E"); // owner mutates
16System.out.println(snapshot); // still [A, B, C, D] — not affectedMistake 2 — Putting Duplicate Elements in Set.of() or Duplicate Keys in Map.of()
1// WRONG — Set.of() throws IllegalArgumentException for duplicates
2// at CONSTRUCTION time (not at runtime during use)
3Set<String> roles = Set.of("ADMIN", "DEVELOPER", "ADMIN"); // IAE immediately
4
5// WRONG — Map.of() throws IllegalArgumentException for duplicate keys
6Map<String, Integer> scores = Map.of("Alice", 95, "Alice", 88); // IAE immediately
7
8// CORRECT — ensure uniqueness before calling factory methods
9// If the source may have duplicates, build a Set/Map first:
10List<String> rawRoles = List.of("ADMIN", "DEVELOPER", "ADMIN");
11Set<String> uniqueRoles = Set.copyOf(rawRoles); // deduplicated first by Set.copyOfMistake 3 — Trying to Sort or Shuffle an Immutable List
1List<String> immutable = List.of("Banana", "Apple", "Cherry");
2
3// WRONG — Collections.sort() calls list.sort() which mutates in-place
4java.util.Collections.sort(immutable); // UnsupportedOperationException
5
6// CORRECT — create a mutable copy, sort it, then optionally freeze again
7List<String> sortable = new ArrayList<>(immutable);
8java.util.Collections.sort(sortable);
9System.out.println("Sorted: " + sortable); // [Apple, Banana, Cherry]
10
11// OR: use Stream.sorted() which creates a new sorted stream
12List<String> sorted = immutable.stream().sorted().toList(); // Java 16+
13System.out.println("Stream sorted: " + sorted);Output:
Sorted: [Apple, Banana, Cherry]
Stream sorted: [Apple, Banana, Cherry]
Mistake 4 — Using an Array-Backed asList() When an Immutable List Is Needed
1String[] data = {"Java", "Spring", "Kafka"};
2
3// WRONG — Arrays.asList() returns a FIXED-SIZE but NOT immutable list
4// set() works and writes through to the array — NOT safe as a constant
5java.util.List<String> fixedSize = java.util.Arrays.asList(data);
6fixedSize.set(0, "Python"); // succeeds — writes to data[0]!
7System.out.println(data[0]); // "Python" — array was changed
8
9// CORRECT — List.of() for a truly immutable list with no backing array
10java.util.List<String> immutable = java.util.List.of("Java", "Spring", "Kafka");
11// immutable.set(0, "Python"); → UnsupportedOperationExceptionInterview Questions
Q1. What is the difference between Collections.unmodifiableList() and List.of() in Java?
Collections.unmodifiableList(list) creates a read-only wrapper around an existing mutable list. Mutation attempts on the wrapper throw UnsupportedOperationException. But the backing list is still mutable — the original owner can modify it, and those changes are visible through the wrapper. List.of() (Java 9+) creates a truly immutable list with no backing mutable structure. No reference can modify it. It also rejects null elements immediately. For static constants and defensive return values, List.of() and List.copyOf() are always preferable.
Q2. What happens when you pass null to List.of(), Set.of(), or Map.of()?
All three throw NullPointerException immediately at construction time — not when the null element is accessed. This is by design: null is explicitly prohibited in all Java 9+ immutable collection factory methods. Collections.unmodifiableList() allows nulls because it wraps an existing list that may already contain them. For collections that require null-tolerant immutability, the only option before Java 9 was Collections.unmodifiableList() wrapping a null-containing ArrayList.
Q3. Does Set.of() guarantee the same iteration order across runs?
No. The iteration order of Set.of() and Map.of() is intentionally unspecified and may vary between JVM runs, JDK versions, or even between calls in the same program. This is a deliberate design decision — it prevents code from accidentally depending on a specific order that is not part of the Set contract. LinkedHashSet preserves insertion order if ordered iteration matters. When the order of a Set.of() result is important for tests, sort it before asserting.
Q4. When should you use List.copyOf() over List.of()?
Use List.copyOf(existingCollection) when the source data already exists as a Collection and you want to snapshot it immutably. It copies all elements into a new immutable list. If the source collection is already an immutable List, the JDK may return the same instance without allocating. Use List.of(e1, e2, ...) when the elements are known at compile time or code-time — it is syntactically cleaner for static constants. Both produce equivalent immutable lists; the choice is about whether elements come from existing collections or are specified inline.
Q5. Are immutable collections thread-safe in Java?
Yes. Immutable collections created by List.of(), Set.of(), Map.of(), and copyOf() are fully thread-safe for concurrent reads without any synchronisation. Since no thread can mutate the collection, there is no risk of data races, ConcurrentModificationException, or visible inconsistency. Publishing an immutable collection through a final field or via safe publication makes it visible to all threads under the Java Memory Model. This is one of the primary reasons to prefer immutable collections in multi-threaded services.
Q6. What is the build-then-freeze pattern and when is it needed?
The build-then-freeze pattern accumulates elements into a mutable collection (like ArrayList or HashMap), then produces an immutable version with List.copyOf() or Map.copyOf() before returning or storing. It is needed when a collection cannot be fully specified at a single call site — for example, when elements are added conditionally inside a loop, read from a database, or assembled from multiple sources. The mutable collection serves as a builder; the copyOf() call freezes it into an immutable result that the caller can hold safely.
FAQs
What is the difference between immutable and unmodifiable in Java?
"Unmodifiable" means the collection cannot be modified through a specific reference — the wrapper view. But another reference (to the backing mutable collection) can still modify the underlying data. "Immutable" means no reference can modify the collection — the data is fixed permanently. Collections.unmodifiableList() is unmodifiable. List.of() is truly immutable. The distinction matters when the owner of the backing mutable collection might change it.
Can I add elements to a List.of() by creating a new list?
Not by mutating the existing list — that always throws. But you can create a new immutable list that includes the additional element. The pattern: List<String> extended = Stream.concat(existing.stream(), Stream.of("newElement")).toList(). This creates a new list from the existing elements plus the new one. The original List.of() result is unchanged.
Are Java 9 immutable collections serialisable?
Yes. List.of(), Set.of(), Map.of(), and copyOf() all implement Serializable. They use a serialisation proxy pattern to ensure deserialization produces a canonical immutable collection instance, not a raw internal implementation class. This makes them safe to use in distributed caches, JMS messages, and session replication.
What is Map.entry() and when do I use it?
Map.entry(key, value) creates an immutable Map.Entry<K, V> object. Its primary use is as an argument to Map.ofEntries(Map.entry(k1,v1), Map.entry(k2,v2), ...) for creating immutable maps with more than 10 entries. It can also be used independently wherever an immutable Map.Entry is needed. Unlike new AbstractMap.SimpleEntry<>(k, v), Map.entry() explicitly rejects null keys and values.
Does List.of() with one null throw immediately or when the null is accessed?
Immediately, at the call site. List.of("A", null, "C") throws NullPointerException before the method returns. This is faster feedback than a collection that accepts null but throws later during a sort or serialisation. It enforces the contract at the boundary where the violation occurs.
How do immutable collections interact with Java Streams?
Immutable collections are perfectly compatible as stream sources. list.stream(), set.stream(), map.entrySet().stream() all work on immutable collections. The terminal operation .toList() (Java 16+) also returns an unmodifiable list. Collectors.toUnmodifiableList(), Collectors.toUnmodifiableSet(), and Collectors.toUnmodifiableMap() produce immutable views. List.copyOf(stream.collect(Collectors.toList())) produces a fully immutable copy from any stream pipeline.
Summary
Java's immutable collections exist on a spectrum. Collections.unmodifiable*() (Java 1.2+) provides a read-only view that still exposes the backing collection to mutation by its owner — useful when the owner and consumer have different access rights. List.of(), Set.of(), Map.of() (Java 9+) are truly immutable — no reference can change them, nulls are rejected at construction, and they use compact memory layouts. List.copyOf(), Set.copyOf(), Map.copyOf() (Java 10+) create immutable snapshots from existing collections, decoupling from the source.
The practical rules: use List.of() and Map.of() for static constants and fixed lookup tables. Return List.copyOf() from service methods to prevent callers from seeing internal mutations. Use the build-then-freeze pattern when data must be assembled incrementally. Never assume unmodifiableList() fully protects against mutation — it only protects one side of the reference.
For interviews: explain the view-vs-copy distinction between unmodifiableList() and List.copyOf(), describe null rejection in factory methods, explain why Set.of() iteration order is unspecified, and describe the thread-safety guarantee of immutable collections without locks.
What to Read Next
| Topic | Link |
|---|---|
| How ArrayList's mutable array contrasts with the fixed array backing List.of() | Java ArrayList → |
| How HashMap compares to the compact hash table used internally by Map.of() | Java HashMap → |
| How Collections utility class provides the older unmodifiable wrapper methods | Java Collections Framework → |
| How Java Streams and toList() produce immutable results from pipeline operations | Java Streams API → |
| How Generics make List.of() and Map.of() type-safe with bounded wildcards | Java Generics → |