Immutability in Java
Immutability is the most frequently asked Interview question in Java interviews. This is used to check if the candidates know how immutability works internally. So it's very important to understand the concept and be able to explain the interviewer.
What is Immutability?
An immutable object is one whose state cannot be modified after creation. Once instantiated, its data remains constant throughout its lifetime.
The benefits
- Caching: Hashcodes can be cached safely
- Thread Safety: Immutable objects are inherently thread-safe
- Side-effect Free: Methods don’t unexpectedly modify state
- Reasoning: Code becomes easier to understand and debug
- Functional Programming: Enables functional programming patterns
Java’s Built-in Immutable Classes
String- The most famous immutable class- Wrapper classes (
Integer,Boolean,Double, etc.) BigIntegerandBigDecimal
The String Immutability Deep Dive
In Java, string immutability means that once a String object is created, its value cannot be changed. Any operation that appears to modify a string, such as concatenation, replacement, or substring, actually creates a new String object with the modified value, leaving the original string unchanged.
String object is created using two ways: using the new operator and the other is a string literal.
1. Using the new Operator
- When you use new String(“hello”):
- Java creates a new String object in the heap every time, even if the same value exists.
- The string literal “hello” is placed in the String Constant Pool (SCP) if it is not already there.
- Example:
String s1 = new String("hello"); // 1 heap object, 1 SCP object (if not present)2. Using String Literals:
- Java checks the SCP. If the literal exists, it reuses it; otherwise, it creates it.
- Multiple variables can safely share the same string object
- Example:
String s4 = "hello"; // First occurrence - creates in SCP else reuses in SCP
String s5 = "hello"; // Reuses SCP "hello"Memory Management and Garbage Collection
- Heap Objects: String objects created in the heap (via
new String(...)) are eligible for garbage collection when there are no references pointing to them. - String Constant Pool (SCP): Objects in the SCP are not eligible for garbage collection as long as the JVM is running. The JVM maintains internal references to these literals for reuse, preventing their collection.
Why String Immutability Matters: Four Critical Reasons
1. Security Implications
Strings are extensively used for sensitive information like file paths, URLs, database connections, and passwords. If strings were mutable, it could lead to security vulnerabilities:
public class SecurityExample {
public static void main(String[] args) {
String filename = "/etc/passwd"; // Sensitive system file
// If String were mutable, this could be dangerous:
// filename.replace("passwd", "shadow"); // Could modify original!
// But since String is immutable:
String modifiedName = filename.replace("passwd", "shadow");
System.out.println("Original: " + filename); // /etc/passwd
System.out.println("Modified: " + modifiedName); // /etc/shadow
// The original filename remains unchanged - SECURE!
}
}2. String Pool Optimization
public class StringPoolExample {
public static void main(String[] args) {
// String literals go to String Pool
String str1 = "Java";
String str2 = "Java"; // Reuses same object from pool
String str3 = new String("Java"); // Creates new object in heap
System.out.println("str1 == str2: " + (str1 == str2)); // true
System.out.println("str1 == str3: " + (str1 == str3)); // false
System.out.println("str1.equals(str3): " + str1.equals(str3)); // true
// Memory addresses prove they're different objects
System.out.println("str1 memory: " + System.identityHashCode(str1));
System.out.println("str2 memory: " + System.identityHashCode(str2)); // Same as str1
System.out.println("str3 memory: " + System.identityHashCode(str3)); // Different
}
}This optimization saves massive amounts of memory in real applications where string literals are reused frequently.
3. Thread Safety
Immutable objects are naturally thread-safe. Multiple threads can read from the same String object without any synchronization.
public class ThreadSafetyExample {
private static String sharedString = "Programming";
public static void main(String[] args) throws InterruptedException {
Runnable task = () -> {
for (int i = 0; i < 5; i++) {
// Safe operations - no synchronization needed
String upper = sharedString.toUpperCase();
String lower = sharedString.toLowerCase();
System.out.println(Thread.currentThread().getName() +
": " + sharedString + " -> " + upper);
}
};
Thread t1 = new Thread(task, "Thread-1");
Thread t2 = new Thread(task, "Thread-2");
t1.start(); t2.start();
t1.join(); t2.join();
// sharedString is still "Programming" - unchanged!
System.out.println("Final: " + sharedString);
}
}4. HashCode Caching
Since immutable objects never change, their hash codes can be computed once and cached forever.
This caching is only safe because String objects never change. If they were mutable, the cached hash code could become invalid, breaking hash-based collections like HashMap and HashSet.
public class HashCodeCachingExample {
public static void main(String[] args) {
String str = "Hello World";
// First hashCode() call - computed and cached
long start1 = System.nanoTime();
int hash1 = str.hashCode();
long end1 = System.nanoTime();
// Second hashCode() call - returned from cache
long start2 = System.nanoTime();
int hash2 = str.hashCode();
long end2 = System.nanoTime();
System.out.println("Hash: " + hash1);
System.out.println("First call time: " + (end1 - start1) + " ns");
System.out.println("Second call time: " + (end2 - start2) + " ns");
// Second call is typically much faster due to caching
// This is only safe because String is immutable!
// Demonstrate HashMap performance benefit
Map<String, Integer> map = new HashMap<>();
String key = "CachedHashCode";
// HashMap uses hashCode for efficient lookups
map.put(key, 42);
Integer value = map.get(key); // Fast lookup due to cached hash
System.out.println("Retrieved value: " + value);
}
}How do you create an immutable class?
To create an immutable class in Java, follow these essential principles:
- Declare the class as
finalto prevent inheritance - Make all fields
privateto restrict direct access - Make all fields
finalto ensure single assignment - Don’t provide setter methods for any fields
- Initialize all fields through constructor using deep copy for mutable objects
- Return copies of mutable objects in getter methods
Example: Creating an Immutable Person Class
Let’s start with a basic example of an immutable class:
public final class Person {
private final String name;
private final int age;
public Person(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() {
return name;
}
public int getAge() {
return age;
}
public static void main(String[] args) {
Person person = new Person("John", 25);
System.out.println("Name: " + person.getName());
System.out.println("Age: " + person.getAge());
// This would cause compilation error - no setter methods
// person.setName("Jane"); // Error!
}
}This Person class is immutable because:
- The class is declared
final - All fields are
privateandfinal - No setter methods exist
- Getter methods return primitive values or immutable objects
Handling Mutable Objects
When your immutable class contains mutable objects, you need to be more careful. Here’s an example with a Studentclass that contains a mutable List:
import java.util.*;
public final class Student {
private final String name;
private final int id;
private final List<String> subjects;
public Student(String name, int id, List<String> subjects) {
this.name = name;
this.id = id;
// Deep copy to prevent external modification
this.subjects = new ArrayList<>(subjects);
}
public String getName() {
return name;
}
public int getId() {
return id;
}
// Return a copy to maintain immutability
public List<String> getSubjects() {
return new ArrayList<>(subjects);
}
public static void main(String[] args) {
List<String> subjectList = new ArrayList<>();
subjectList.add("Math");
subjectList.add("Science");
Student student = new Student("Alice", 123, subjectList);
System.out.println("Student: " + student.getName());
System.out.println("Subjects: " + student.getSubjects());
// Modifying original list doesn't affect student object
subjectList.add("History");
System.out.println("Subjects after external modification: " + student.getSubjects());
// Modifying returned list doesn't affect student object
List<String> retrievedSubjects = student.getSubjects();
retrievedSubjects.add("Geography");
System.out.println("Subjects after modifying returned list: " + student.getSubjects());
}
}When you run this code, you’ll see:
Student: Alice
Subjects: [Math, Science]
Subjects after external modification: [Math, Science]
Subjects after modifying returned list: [Math, Science]The output demonstrates that the Student object remains unchanged despite modifications to external lists.
Example2 : with HashMap
Here’s a more complex example showing how to handle HashMap objects in immutable classes:
import java.util.*;
public final class Employee {
private final String name;
private final int employeeId;
private final Map<String, String> projects;
public Employee(String name, int employeeId, Map<String, String> projects) {
this.name = name;
this.employeeId = employeeId;
// Deep copy of HashMap
this.projects = new HashMap<>();
for (Map.Entry<String, String> entry : projects.entrySet()) {
this.projects.put(entry.getKey(), entry.getValue());
}
}
public String getName() {
return name;
}
public int getEmployeeId() {
return employeeId;
}
public Map<String, String> getProjects() {
// Return a copy to maintain immutability
return new HashMap<>(projects);
}
public static void main(String[] args) {
Map<String, String> projectMap = new HashMap<>();
projectMap.put("P001", "Website Development");
projectMap.put("P002", "Mobile App");
Employee emp = new Employee("Bob", 456, projectMap);
System.out.println("Employee: " + emp.getName());
System.out.println("Projects: " + emp.getProjects());
// Modify original map
projectMap.put("P003", "Database Design");
System.out.println("Projects after external modification: " + emp.getProjects());
// Modify returned map
Map<String, String> empProjects = emp.getProjects();
empProjects.put("P004", "Testing");
System.out.println("Projects after modifying returned map: " + emp.getProjects());
}
}Output:
Employee: Bob
Projects: {P001=Website Development, P002=Mobile App}
Projects after external modification: {P001=Website Development, P002=Mobile App}
Projects after modifying returned map: {P001=Website Development, P002=Mobile App}What Happens Without Proper Immutability?
Let’s see what happens when we don’t follow immutability rules properly
import java.util.*;
public final class BadStudent {
private final String name;
private final List<String> subjects;
public BadStudent(String name, List<String> subjects) {
this.name = name;
this.subjects = subjects; // Shallow copy - Wrong!
}
public String getName() {
return name;
}
public List<String> getSubjects() {
return subjects; // Returning original reference - Wrong!
}
public static void main(String[] args) {
List<String> subjectList = new ArrayList<>();
subjectList.add("Math");
BadStudent student = new BadStudent("Charlie", subjectList);
System.out.println("Initial subjects: " + student.getSubjects());
// This will modify the student's internal state!
subjectList.add("Physics");
System.out.println("After external modification: " + student.getSubjects());
// This will also modify the student's internal state!
student.getSubjects().add("Chemistry");
System.out.println("After getter modification: " + student.getSubjects());
}
}Output:
Initial subjects: [Math]
After external modification: [Math, Physics]
After getter modification: [Math, Physics, Chemistry]Best Practices and Advanced Patterns
1. Use Sealed Classes for Type Safety (Java 17+)
public sealed interface Result<T> permits Success, Failure {
record Success<T>(T value) implements Result<T> {}
record Failure<T>(String error) implements Result<T> {}
}
public String processResult(Result<String> result) {
return switch (result) {
case Success<String>(var value) -> "Got: " + value;
case Failure<String>(var error) -> "Error: " + error;
};
}Sealed classes work perfectly with immutable design by restricting inheritance and enabling exhaustive pattern matching.
2. Builder Pattern for Complex Objects
public final class ImmutableBook {
private final String title;
private final String author;
private final int pages;
private final List<String> genres;
private ImmutableBook(Builder builder) {
this.title = builder.title;
this.author = builder.author;
this.pages = builder.pages;
this.genres = new ArrayList<>(builder.genres);
}
// Getters...
public String getTitle() { return title; }
public String getAuthor() { return author; }
public int getPages() { return pages; }
public List<String> getGenres() { return new ArrayList<>(genres); }
public static class Builder {
private String title;
private String author;
private int pages;
private List<String> genres = new ArrayList<>();
public Builder setTitle(String title) {
this.title = title;
return this;
}
public Builder setAuthor(String author) {
this.author = author;
return this;
}
public Builder setPages(int pages) {
this.pages = pages;
return this;
}
public Builder addGenre(String genre) {
this.genres.add(genre);
return this;
}
public ImmutableBook build() {
return new ImmutableBook(this);
}
}
}3. Prefer Records for Simple Data (Java 14+)
Records dramatically simplify immutable class creation:
Get Veenarao’s stories in your inbox
Join Medium for free to get updates from this writer.
Basic Record:
// Old way (20+ lines)
public final class PersonOld {
private final String name;
private final int age;
public PersonOld(String name, int age) {
this.name = name;
this.age = age;
}
public String getName() { return name; }
public int getAge() { return age; }
// Plus equals(), hashCode(), toString()...
}
// New way (1 line!)
public record Person(String name, int age) {}Records with Validation:
public record Person(String name, int age) {
public Person {
if (age < 0) throw new IllegalArgumentException("Age cannot be negative");
if (name == null || name.trim().isEmpty()) {
throw new IllegalArgumentException("Name cannot be empty");
}
}
}Records with Mutable Objects:
public record Student(String name, List<String> subjects) {
public Student {
// Defensive copy to ensure immutability
subjects = List.copyOf(subjects);
}
// The subjects() method automatically returns the immutable list
}4. Use Factory Methods for Collections (Java 9+)
Avoid This Common Mistake:
// Wrong - original list changes affect the "immutable" view
List<String> mutable = new ArrayList<>();
mutable.add("test");
List<String> unmodifiable = Collections.unmodifiableList(mutable);
mutable.add("another"); // This affects unmodifiable too!Use Modern Factory Methods Instead:
// Right - truly immutable
List<String> immutable = List.of("test", "another");
Set<Integer> numbers = Set.of(1, 2, 3);
Map<String, Integer> ages = Map.of("Alice", 25, "Bob", 30);5. Validate in Record Constructors (Java 14+)
public record Email(String address) {
public Email {
if (!address.contains("@")) {
throw new IllegalArgumentException("Invalid email");
}
}
}6. Leverage Optional for Immutable Containers (Java 8+)
public record UserProfile(String name, Optional<String> email, Optional<Integer> age) {
public UserProfile {
// Optional is already immutable, no defensive copying needed
Objects.requireNonNull(name, "Name cannot be null");
}
// Usage example
public static void main(String[] args) {
UserProfile profile = new UserProfile("John", Optional.of("john@example.com"), Optional.empty());
// Safe access without null checks
profile.email().ifPresent(email -> System.out.println("Email: " + email));
String displayAge = profile.age().map(String::valueOf).orElse("Not provided");
}
}7. Use Streams for Immutable Transformations (Java 8+)
public record StudentGrades(String name, List<Integer> grades) {
public StudentGrades {
grades = List.copyOf(grades); // Defensive copy
}
// Return new immutable objects instead of modifying existing ones
public StudentGrades withBonusPoints(int bonus) {
List<Integer> newGrades = grades.stream()
.map(grade -> grade + bonus)
.collect(Collectors.toUnmodifiableList());
return new StudentGrades(name, newGrades);
}
public double getAverage() {
return grades.stream()
.mapToInt(Integer::intValue)
.average()
.orElse(0.0);
}
}Frequently Asked Questions
What’s the difference between Collections.unmodifiableList() and List.of()?
Answer:
Collections.unmodifiableList(): Creates a wrapper view; original list changes affect itList.of(): Creates a truly immutable list with its own storage
List<String> mutable = new ArrayList<>();
mutable.add("test");
List<String> unmodifiable = Collections.unmodifiableList(mutable);
mutable.add("another"); // Affects unmodifiable list!
List<String> immutable = List.of("test");
// No backing mutable collectionCan you modify a Record after creation?
Answer: No, Records are immutable by design. All fields are automatically final. To "modify" a record, you create a new instance:
Person person = new Person("Alice", 30);
Person olderPerson = new Person(person.name(), person.age() + 1);What happens if you try to modify an immutable collection?
Answer: It throws UnsupportedOperationException:
List<String> list = List.of("a", "b", "c");
list.add("d"); // Throws UnsupportedOperationExceptionHow do you handle mutable objects in immutable classes?
Answer: Use defensive copying:
public record PersonWithHobbies(String name, List<String> hobbies) {
public PersonWithHobbies {
hobbies = List.copyOf(hobbies); // Defensive copy
}
public List<String> hobbies() {
return hobbies; // Already immutable, safe to return
}
}What’s the Builder pattern for immutable objects?
Answer: Use a mutable builder to construct immutable objects:
public record Person(String name, int age, List<String> hobbies) {
public static class Builder {
private String name;
private int age;
private List<String> hobbies = new ArrayList<>();
public Builder name(String name) {
this.name = name;
return this;
}
public Builder age(int age) {
this.age = age;
return this;
}
public Builder addHobby(String hobby) {
this.hobbies.add(hobby);
return this;
}
public Person build() {
return new Person(name, age, List.copyOf(hobbies));
}
}
}Performance Considerations
- Memory Efficiency: Modern immutable collections (Java 9+) are more memory-efficient than mutable counterparts for small sizes
- Creation Cost: Records have minimal overhead compared to traditional classes
- Garbage Collection: Immutable objects often have better GC characteristics and can be optimized by the JVM
Common Pitfalls and How to Avoid Them
1. Forgetting Defensive Copying
// Wrong
public record Person(List<String> hobbies) {}
// Right
public record Person(List<String> hobbies) {
public Person {
hobbies = List.copyOf(hobbies);
}
}2. Assuming Unmodifiable Means Immutable
List<String> mutable = new ArrayList<>();
List<String> view = Collections.unmodifiableList(mutable);
mutable.add("oops"); // Modifies the "immutable" view!3. Not Considering Nested Mutability
// Potentially problematic
public record Container(List<List<String>> nestedList) {}
// Better
public record Container(List<List<String>> nestedList) {
public Container {
nestedList = nestedList.stream()
.map(List::copyOf)
.collect(Collectors.toUnmodifiableList());
}
}Conclusion
We can create immutable objects with minimal effort while enjoying better performance, thread safety, and code maintainability.
As Java continues to evolve, immutability remains not just a best practice, but an increasingly natural and efficient way to write robust applications.
Thank you for reading this article. Please provide your valuable suggestions/ feedback.
- Clap and Share if you liked the content.
- 📰 Read more content on my Medium (on Java Developer interview questions)
- 🔔 Follow me on: LinkedIn
- Reach out to me for resume preparation and interview ready . contact me here.
Please find my other useful articles on Java Developer interview questions
Following are some of the famously asked Java8 Interview Questions
Frequently asked Java Programs
Dear Readers, these are the commonly asked java programs to check your ability on writing the logic
SpringBoot Interview Questions | Medium









