DEV Community

Cover image for Java Immutability: 5 Techniques for Thread-Safe Code Without Synchronization
Nithin Bharadwaj
Nithin Bharadwaj

Posted on

Java Immutability: 5 Techniques for Thread-Safe Code Without Synchronization

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice the key elements:

  1. The class is marked final to prevent subclassing
  2. All fields are final to prevent modification
  3. Constructors validate inputs and create defensive copies of mutable objects
  4. No setters exist - only methods that return new objects
  5. 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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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);
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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();
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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()
    ));
Enter fullscreen mode Exit fullscreen mode

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) {}
}
Enter fullscreen mode Exit fullscreen mode

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:

  1. 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);
}
Enter fullscreen mode Exit fullscreen mode
  1. 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);
}
Enter fullscreen mode Exit fullscreen mode
  1. Implement value semantics with proper equals() and hashCode():
@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);
}
Enter fullscreen mode Exit fullscreen mode
  1. Provide "with" methods for creating modified copies:
public Customer withAddress(Address newAddress) {
    return new Customer(this.id, this.name, newAddress, this.phoneNumbers);
}
Enter fullscreen mode Exit fullscreen mode
  1. Use final consistently at class and field level:
public final class ImmutableMessage {
    private final String sender;
    private final String content;
    private final LocalDateTime timestamp;

    // Constructor and methods...
}
Enter fullscreen mode Exit fullscreen mode

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");
        }
    }
}
Enter fullscreen mode Exit fullscreen mode

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());
Enter fullscreen mode Exit fullscreen mode

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)