As a best-selling author, I invite you to explore my books on Amazon. Don't forget to follow me on Medium and show your support. Thank you! Your support means the world!
Java immutability provides a powerful approach to creating thread-safe applications without complex synchronization mechanisms. I've worked with immutable objects extensively in high-throughput systems and found them invaluable for reliable concurrent code. When objects cannot change after creation, many traditional threading issues simply disappear.
Understanding Java Immutability
Immutability means that once an object is created, its state cannot be modified. This fundamental property eliminates the need for locks in multi-threaded environments since immutable objects are inherently thread-safe. I've seen codebases transform from complex synchronization nightmares to clean, predictable systems through strategic application of immutability.
Immutable objects offer several advantages:
Thread safety without synchronization overhead
Freedom from race conditions and visibility issues
Simplified debugging and reasoning about code
Safe sharing across threads without defensive copying
Potential performance improvements through caching
Let's examine the five most effective immutability patterns I've implemented in production systems.
Technique 1: Basic Immutable Class Pattern
The foundation of Java immutability begins with proper class design. Here's how I implement basic immutable classes:
public final class Customer {
private final String id;
private final String name;
private final LocalDate registrationDate;
private final List<String> tags;
public Customer(String id, String name, LocalDate registrationDate, List<String> tags) {
this.id = Objects.requireNonNull(id, "ID cannot be null");
this.name = Objects.requireNonNull(name, "Name cannot be null");
this.registrationDate = Objects.requireNonNull(registrationDate, "Registration date cannot be null");
// Defensive copy of mutable collection
this.tags = Collections.unmodifiableList(new ArrayList<>(tags));
}
public String getId() {
return id;
}
public String getName() {
return name;
}
public LocalDate getRegistrationDate() {
return registrationDate;
}
public List<String> getTags() {
return tags;
}
// Operations return new objects
public Customer withName(String newName) {
return new Customer(this.id, newName, this.registrationDate, this.tags);
}
}
Notice the key elements:
- The class is marked
finalto prevent subclassing - All fields are
finalto prevent modification - Constructors validate inputs and create defensive copies of mutable objects
- No setters exist - only methods that return new objects
- Collections are wrapped with unmodifiable wrappers
This pattern forms the backbone of Java immutability. When I implement classes this way, I eliminate entire categories of threading bugs.
Technique 2: Records for Concise Immutability
Java 16 introduced records, which dramatically simplify immutable class creation. I've started using records extensively for data transfer objects and value objects:
public record Transaction(
UUID id,
BigDecimal amount,
String currency,
LocalDateTime timestamp,
TransactionType type
) {
// Compact constructor for validation
public Transaction {
Objects.requireNonNull(id, "ID cannot be null");
Objects.requireNonNull(amount, "Amount cannot be null");
Objects.requireNonNull(currency, "Currency cannot be null");
Objects.requireNonNull(timestamp, "Timestamp cannot be null");
Objects.requireNonNull(type, "Type cannot be null");
if (amount.compareTo(BigDecimal.ZERO) <= 0) {
throw new IllegalArgumentException("Amount must be positive");
}
}
// Additional methods can be added
public boolean isHighValue() {
return amount.compareTo(new BigDecimal("1000")) > 0;
}
// Custom creation method
public Transaction withNewAmount(BigDecimal newAmount) {
return new Transaction(id, newAmount, currency, timestamp, type);
}
}
Records automatically provide:
- Private final fields
- Constructor parameter matching
- Equals, hashCode, and toString implementations
- Accessor methods matching field names
The compact constructor syntax lets me add validation without repeating field assignments. Records have transformed how I write immutable classes, reducing boilerplate while enforcing immutability.
Technique 3: Builder Pattern for Complex Immutables
For immutable objects with many optional fields, the Builder pattern provides a flexible creation approach while maintaining immutability:
public final class Configuration {
private final String host;
private final int port;
private final Duration timeout;
private final boolean sslEnabled;
private final Map<String, String> properties;
private final List<String> allowedOrigins;
private Configuration(Builder builder) {
this.host = builder.host;
this.port = builder.port;
this.timeout = builder.timeout;
this.sslEnabled = builder.sslEnabled;
this.properties = Map.copyOf(builder.properties);
this.allowedOrigins = List.copyOf(builder.allowedOrigins);
}
// Accessor methods
public String getHost() { return host; }
public int getPort() { return port; }
public Duration getTimeout() { return timeout; }
public boolean isSslEnabled() { return sslEnabled; }
public Map<String, String> getProperties() { return properties; }
public List<String> getAllowedOrigins() { return allowedOrigins; }
public static class Builder {
// Default values
private String host = "localhost";
private int port = 8080;
private Duration timeout = Duration.ofSeconds(30);
private boolean sslEnabled = false;
private Map<String, String> properties = new HashMap<>();
private List<String> allowedOrigins = new ArrayList<>();
public Builder host(String host) {
this.host = Objects.requireNonNull(host);
return this;
}
public Builder port(int port) {
if (port <= 0) throw new IllegalArgumentException("Port must be positive");
this.port = port;
return this;
}
public Builder timeout(Duration timeout) {
this.timeout = Objects.requireNonNull(timeout);
return this;
}
public Builder sslEnabled(boolean sslEnabled) {
this.sslEnabled = sslEnabled;
return this;
}
public Builder addProperty(String key, String value) {
this.properties.put(
Objects.requireNonNull(key),
Objects.requireNonNull(value)
);
return this;
}
public Builder addAllowedOrigin(String origin) {
this.allowedOrigins.add(Objects.requireNonNull(origin));
return this;
}
public Configuration build() {
return new Configuration(this);
}
}
}
Usage looks like:
Configuration config = new Configuration.Builder()
.host("api.example.com")
.port(443)
.sslEnabled(true)
.timeout(Duration.ofSeconds(60))
.addProperty("retries", "3")
.addAllowedOrigin("https://example.com")
.build();
I've found this pattern particularly useful for configuration objects and complex domain entities where constructors with many parameters become unwieldy. The Builder pattern provides a fluent API while ensuring the resulting object is immutable.
Technique 4: Immutable Collections
Java provides several ways to create truly immutable collections. I prefer the factory methods introduced in Java 9:
// Creating immutable collections from elements
List<String> immutableList = List.of("one", "two", "three");
Set<Integer> immutableSet = Set.of(1, 2, 3, 4);
Map<String, Integer> immutableMap = Map.of(
"one", 1,
"two", 2,
"three", 3
);
// Creating immutable copies of existing collections
List<Transaction> originalList = getTransactions();
List<Transaction> immutableCopy = List.copyOf(originalList);
Map<String, User> userMap = getUserMap();
Map<String, User> immutableUserMap = Map.copyOf(userMap);
For more complex needs, I use the collectors from the Streams API:
// Creating immutable collections from streams
List<String> immutableNames = users.stream()
.map(User::getName)
.collect(Collectors.toUnmodifiableList());
Map<String, User> immutableUserMap = users.stream()
.collect(Collectors.toUnmodifiableMap(
User::getId,
Function.identity()
));
These approaches create truly immutable collections, not just unmodifiable views. Any attempt to modify these collections throws UnsupportedOperationException. I've found that consistently using immutable collections eliminates many subtle bugs in concurrent code.
Technique 5: Value-Based Caching
For immutable objects that are frequently created with the same values, caching improves performance. This pattern is used in the JDK itself for classes like Integer and Boolean:
public final class Money {
private static final Map<CacheKey, Money> CACHE = new ConcurrentHashMap<>();
private final BigDecimal amount;
private final Currency currency;
private Money(BigDecimal amount, Currency currency) {
this.amount = amount;
this.currency = currency;
}
public static Money of(BigDecimal amount, Currency currency) {
Objects.requireNonNull(amount, "Amount cannot be null");
Objects.requireNonNull(currency, "Currency cannot be null");
// For common values, use cached instances
if (isCacheable(amount)) {
CacheKey key = new CacheKey(amount, currency);
return CACHE.computeIfAbsent(key, k -> new Money(k.amount(), k.currency()));
}
// For other values, create new instances
return new Money(amount, currency);
}
private static boolean isCacheable(BigDecimal amount) {
// Cache only common monetary values between -1000 and 1000
// with no more than 2 decimal places
if (amount.scale() > 2) return false;
BigDecimal absAmount = amount.abs();
return absAmount.compareTo(new BigDecimal("1000")) <= 0;
}
public BigDecimal getAmount() {
return amount;
}
public Currency getCurrency() {
return currency;
}
// Value-based operations
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Cannot add different currencies");
}
return Money.of(this.amount.add(other.amount), this.currency);
}
public Money multiply(BigDecimal factor) {
return Money.of(this.amount.multiply(factor), this.currency);
}
// Required for cache key
private record CacheKey(BigDecimal amount, Currency currency) {}
}
In high-performance systems I've worked on, this pattern reduces memory pressure and garbage collection pauses. Since immutable objects can be safely shared across threads, caching works particularly well with them.
Practical Implementation Tips
When implementing immutable classes, I follow these best practices:
- Make defensive copies of mutable input parameters:
public ImmutablePerson(List<String> phoneNumbers) {
// Don't store the reference directly - it could be modified later
this.phoneNumbers = List.copyOf(phoneNumbers);
}
- Use factory methods instead of constructors for flexibility:
// Private constructor
private ImmutableConfig(Map<String, String> properties) {
this.properties = Map.copyOf(properties);
}
// Public factory method
public static ImmutableConfig create(Map<String, String> properties) {
// Validation logic here
return new ImmutableConfig(properties);
}
- Implement value semantics with proper
equals()andhashCode():
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Price price = (Price) o;
return amount.equals(price.amount) &&
currency.equals(price.currency);
}
@Override
public int hashCode() {
return Objects.hash(amount, currency);
}
- Provide "with" methods for creating modified copies:
public Customer withAddress(Address newAddress) {
return new Customer(this.id, this.name, newAddress, this.phoneNumbers);
}
- Use
finalconsistently at class and field level:
public final class ImmutableMessage {
private final String sender;
private final String content;
private final LocalDateTime timestamp;
// Constructor and methods...
}
Real-World Example: Immutable Domain Model
Here's a comprehensive example of an immutable domain model I might use in a financial application:
// Core value object
public record Money(BigDecimal amount, Currency currency) {
public Money {
Objects.requireNonNull(amount, "Amount cannot be null");
Objects.requireNonNull(currency, "Currency cannot be null");
}
public static Money of(String amount, String currencyCode) {
return new Money(
new BigDecimal(amount),
Currency.getInstance(currencyCode)
);
}
public Money add(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Cannot add different currencies");
}
return new Money(this.amount.add(other.amount), this.currency);
}
public Money subtract(Money other) {
if (!this.currency.equals(other.currency)) {
throw new IllegalArgumentException("Cannot subtract different currencies");
}
return new Money(this.amount.subtract(other.amount), this.currency);
}
public Money multiply(BigDecimal factor) {
return new Money(this.amount.multiply(factor), this.currency);
}
@Override
public String toString() {
return amount.toString() + " " + currency.getCurrencyCode();
}
}
// Value object with validation
public record BankAccount(
String accountNumber,
String routingNumber,
AccountType type,
Money balance
) {
public BankAccount {
if (!isValidAccountNumber(accountNumber)) {
throw new IllegalArgumentException("Invalid account number format");
}
if (!isValidRoutingNumber(routingNumber)) {
throw new IllegalArgumentException("Invalid routing number");
}
Objects.requireNonNull(type, "Account type cannot be null");
Objects.requireNonNull(balance, "Balance cannot be null");
if (type == AccountType.SAVINGS && balance.amount().compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalArgumentException("Savings accounts cannot have negative balance");
}
}
private static boolean isValidAccountNumber(String accountNumber) {
return accountNumber != null && accountNumber.matches("\\d{10,12}");
}
private static boolean isValidRoutingNumber(String routingNumber) {
return routingNumber != null && routingNumber.matches("\\d{9}");
}
public BankAccount deposit(Money amount) {
if (!amount.currency().equals(balance.currency())) {
throw new IllegalArgumentException("Currency mismatch");
}
return new BankAccount(
accountNumber,
routingNumber,
type,
balance.add(amount)
);
}
public BankAccount withdraw(Money amount) {
if (!amount.currency().equals(balance.currency())) {
throw new IllegalArgumentException("Currency mismatch");
}
Money newBalance = balance.subtract(amount);
// Business rule
if (type == AccountType.SAVINGS && newBalance.amount().compareTo(BigDecimal.ZERO) < 0) {
throw new IllegalStateException("Cannot overdraw savings account");
}
return new BankAccount(
accountNumber,
routingNumber,
type,
newBalance
);
}
public enum AccountType {
CHECKING, SAVINGS, MONEY_MARKET
}
}
// Aggregate root
public final class Customer {
private final UUID id;
private final String name;
private final Address address;
private final List<BankAccount> accounts;
private final LocalDate customerSince;
private Customer(Builder builder) {
this.id = builder.id;
this.name = builder.name;
this.address = builder.address;
this.accounts = List.copyOf(builder.accounts);
this.customerSince = builder.customerSince;
}
// Accessor methods
public UUID getId() { return id; }
public String getName() { return name; }
public Address getAddress() { return address; }
public List<BankAccount> getAccounts() { return accounts; }
public LocalDate getCustomerSince() { return customerSince; }
// Domain operations
public Customer updateAddress(Address newAddress) {
return toBuilder().address(newAddress).build();
}
public Customer addAccount(BankAccount account) {
Builder builder = toBuilder();
List<BankAccount> newAccounts = new ArrayList<>(accounts);
newAccounts.add(account);
builder.accounts(newAccounts);
return builder.build();
}
public Customer updateAccount(String accountNumber, Function<BankAccount, BankAccount> updater) {
List<BankAccount> newAccounts = new ArrayList<>(accounts.size());
boolean found = false;
for (BankAccount account : accounts) {
if (account.accountNumber().equals(accountNumber)) {
newAccounts.add(updater.apply(account));
found = true;
} else {
newAccounts.add(account);
}
}
if (!found) {
throw new IllegalArgumentException("Account not found: " + accountNumber);
}
return toBuilder().accounts(newAccounts).build();
}
// Return a builder pre-populated with this instance's values
public Builder toBuilder() {
return new Builder()
.id(this.id)
.name(this.name)
.address(this.address)
.accounts(new ArrayList<>(this.accounts))
.customerSince(this.customerSince);
}
public static class Builder {
private UUID id = UUID.randomUUID();
private String name;
private Address address;
private List<BankAccount> accounts = new ArrayList<>();
private LocalDate customerSince = LocalDate.now();
public Builder id(UUID id) {
this.id = Objects.requireNonNull(id);
return this;
}
public Builder name(String name) {
this.name = Objects.requireNonNull(name);
return this;
}
public Builder address(Address address) {
this.address = Objects.requireNonNull(address);
return this;
}
public Builder accounts(List<BankAccount> accounts) {
this.accounts = new ArrayList<>(accounts);
return this;
}
public Builder addAccount(BankAccount account) {
this.accounts.add(Objects.requireNonNull(account));
return this;
}
public Builder customerSince(LocalDate date) {
this.customerSince = Objects.requireNonNull(date);
return this;
}
public Customer build() {
Objects.requireNonNull(name, "Name is required");
Objects.requireNonNull(address, "Address is required");
return new Customer(this);
}
}
// Address as a nested record
public record Address(
String street,
String city,
String state,
String postalCode,
String country
) {
public Address {
Objects.requireNonNull(street, "Street cannot be null");
Objects.requireNonNull(city, "City cannot be null");
Objects.requireNonNull(state, "State cannot be null");
Objects.requireNonNull(postalCode, "Postal code cannot be null");
Objects.requireNonNull(country, "Country cannot be null");
}
}
}
To use this model:
// Create a customer with accounts
Customer customer = new Customer.Builder()
.name("Jane Smith")
.address(new Customer.Address(
"123 Main St",
"Anytown",
"State",
"12345",
"USA"
))
.addAccount(new BankAccount(
"1234567890",
"987654321",
BankAccount.AccountType.CHECKING,
Money.of("1000.00", "USD")
))
.build();
// Perform a withdrawal - note how this returns a new customer instance
Customer updatedCustomer = customer.updateAccount("1234567890", account ->
account.withdraw(Money.of("250.00", "USD"))
);
// The original customer is unchanged
System.out.println("Original balance: " +
customer.getAccounts().get(0).balance());
System.out.println("New balance: " +
updatedCustomer.getAccounts().get(0).balance());
This comprehensive example shows how immutable objects can create a robust domain model that is thread-safe by default.
Conclusion
Immutability patterns have transformed how I write Java code, especially for concurrent applications. The five techniques covered - basic immutable classes, records, the Builder pattern, immutable collections, and value caching - provide a powerful toolkit for creating thread-safe code without explicit synchronization.
I've seen firsthand how adopting immutability can reduce bugs, simplify code, and improve reliability in complex systems. While immutability isn't appropriate for every situation, it should be the default approach for most Java objects, especially those shared between threads.
By applying these immutability patterns consistently, you can write Java applications that are easier to reason about, naturally thread-safe, and potentially more performant than code relying on traditional synchronization mechanisms.
101 Books
101 Books is an AI-driven publishing company co-founded by author Aarav Joshi. By leveraging advanced AI technology, we keep our publishing costs incredibly low—some books are priced as low as $4—making quality knowledge accessible to everyone.
Check out our book Golang Clean Code available on Amazon.
Stay tuned for updates and exciting news. When shopping for books, search for Aarav Joshi to find more of our titles. Use the provided link to enjoy special discounts!
Our Creations
Be sure to check out our creations:
Investor Central | Investor Central Spanish | Investor Central German | Smart Living | Epochs & Echoes | Puzzling Mysteries | Hindutva | Elite Dev | JS Schools
We are on Medium
Tech Koala Insights | Epochs & Echoes World | Investor Central Medium | Puzzling Mysteries Medium | Science & Epochs Medium | Modern Hindutva
Top comments (0)