Java Immutable Objects, final Keyword & Thread Safety

โ˜• Java 21+ LTS ๐ŸŸข Chapter 45 of 47 ๐Ÿ“‚ Phase 10: Encapsulation & Access Modifiers ๐Ÿ“… 2026 Edition
๐Ÿ“Œ Covered in this chapter:

Immutable Classes: Definition & Benefits ยท 5 Rules for Creating Immutable Classes ยท final Fields ยท final Methods ยท final Classes ยท Java String Immutability Recap ยท Immutable Collections ยท Thread Safety via Immutability ยท Java 16+ record Keyword

Mastering immutability in Java: understanding the 5 rules to build correct immutable classes, the 3 uses of the final keyword (field, method, class), why immutability is a core thread-safety strategy, the design of java.lang.String, and the modern Java 16+ record feature as a concise immutable data carrier.

1. What is an Immutable Object?

An Immutable Object is an object whose state cannot change after it is created. Once constructed, every field value remains fixed for the entire lifetime of the object.

Famous Immutable Classes in Java Standard Library:
- java.lang.String โ€” String content never changes; operations create new strings.
- java.lang.Integer, Long, Double โ€” Primitive wrapper classes.
- java.time.LocalDate, LocalTime โ€” Modern date/time API.
- java.math.BigDecimal, BigInteger โ€” Arbitrary precision numbers.

Why Immutability is Valuable:
1. Thread Safety: Multiple threads can read the same immutable object simultaneously without synchronization locks.
2. Safe Sharing: The object can be passed around freely without risk of unexpected mutation.
3. Cacheable: Immutable objects can be cached and reused (like the Integer cache for -128 to 127).
4. Reliable HashMap Keys: Immutable objects make safe, stable HashMap keys since their hash never changes.

2. The 5 Rules for Creating an Immutable Class

Rule 1: Declare the class as final (prevents subclasses from adding mutability).
  Rule 2: Declare all fields as private final (cannot be reassigned after construction).
  Rule 3: No setter methods (no way to modify state after construction).
  Rule 4: Initialize all fields via the constructor only.
  Rule 5: Defensive copy of mutable fields (arrays, Dates) in constructor AND getters.
public final class Money {            // Rule 1: final class
    private final double amount;      // Rule 2: private final
    private final String currency;    // Rule 2: private final

public Money(double amount, String currency) { // Rule 4: constructor only
if (amount < 0) throw new IllegalArgumentException("Amount negative!");
this.amount = amount;
this.currency = currency.toUpperCase().trim();
}

// Rule 3: NO setters
public double getAmount() { return amount; }
public String getCurrency() { return currency; }

// New object instead of mutation (the immutable update pattern)
public Money add(Money other) {
if (!this.currency.equals(other.currency))
throw new IllegalArgumentException("Currency mismatch!");
return new Money(this.amount + other.amount, this.currency);
}
}

3. The 3 Uses of the final Keyword

Applied To Effect Example
Field / Variable Value can be assigned only once. All subsequent assignments cause compile error. private final String id = "ID-001";
Method Cannot be overridden by any subclass. public final double getBalance() { ... }
Class Cannot be extended/subclassed by any class. public final class String { ... }

4. Java 16+ Records: Concise Immutable Data Carriers

Java 16 introduced the record keyword as a compact syntax for immutable data-carrying classes. The compiler auto-generates: private final fields, a canonical constructor, getters (no "get" prefix!), equals(), hashCode(), and toString():

// Traditional immutable class: ~30 lines of boilerplate
// Record equivalent: 1 line!
public record Point(double x, double y) {}

// Usage:
Point p = new Point(3.0, 4.0);
System.out.println(p.x()); // 3.0 (Getter without "get" prefix!)
System.out.println(p.y()); // 4.0
System.out.println(p); // Point[x=3.0, y=4.0] (Auto toString!)

Records also support compact constructors for validation:

public record Range(int min, int max) {
Range { // Compact constructor (no parameter list)
if (min > max) throw new IllegalArgumentException("min > max!");
}
}

Beginner Example & Code Anatomy

โ˜• Main.java โ€” Chapter 45 Core Example
import java.util.Arrays;

// ---------------------------------------------------------------
// IMMUTABLE CLASS: Money (5 Rules Applied)
// ---------------------------------------------------------------
final class Money {
    private final double amount;
    private final String currency;

    public Money(double amount, String currency) {
        if (amount < 0) throw new IllegalArgumentException("Negative amount: " + amount);
        if (currency == null || currency.isBlank()) throw new IllegalArgumentException("Currency required.");
        this.amount   = amount;
        this.currency = currency.toUpperCase().trim();
    }

    public double getAmount()   { return amount; }
    public String getCurrency() { return currency; }

    // Immutable update pattern: returns NEW object instead of mutating!
    public Money add(Money other) {
        if (!this.currency.equals(other.currency))
            throw new IllegalArgumentException("Cannot add different currencies: "
                    + this.currency + " and " + other.currency);
        return new Money(this.amount + other.amount, this.currency);
    }

    public Money multiply(double factor) {
        return new Money(this.amount * factor, this.currency);
    }

    @Override
    public String toString() {
        return String.format("Money{%.2f %s}", amount, currency);
    }
}

// ---------------------------------------------------------------
// IMMUTABLE CLASS WITH ARRAY FIELD: Snapshot (Defensive Copying)
// ---------------------------------------------------------------
final class DataSnapshot {
    private final String label;
    private final int[] readings; // Mutable array โ€” must defensive copy!
    private final long timestamp;

    public DataSnapshot(String label, int[] readings) {
        this.label     = label;
        this.readings  = readings.clone(); // Rule 5: defensive copy IN constructor
        this.timestamp = System.currentTimeMillis();
    }

    public String getLabel()    { return label; }
    public long getTimestamp()  { return timestamp; }
    public int[] getReadings()  { return readings.clone(); } // Rule 5: defensive copy OUT getter

    public double getAverage() {
        int sum = 0;
        for (int r : readings) sum += r;
        return (double) sum / readings.length;
    }

    @Override
    public String toString() {
        return String.format("Snapshot{label='%s', avg=%.1f, readings=%s}",
                label, getAverage(), Arrays.toString(readings));
    }
}

// ---------------------------------------------------------------
// Java 16+ RECORD: Concise immutable data
// ---------------------------------------------------------------
record Point(double x, double y) {
    // Compact constructor with validation
    Point {
        if (Double.isNaN(x) || Double.isNaN(y))
            throw new IllegalArgumentException("Coordinates cannot be NaN!");
    }

    // Custom method (records can have methods!)
    public double distanceTo(Point other) {
        double dx = this.x - other.x;
        double dy = this.y - other.y;
        return Math.sqrt(dx * dx + dy * dy);
    }
}

public class Main {
    public static void main(String[] args) {
        System.out.println("=== 1. Immutable Money Operations ===");
        Money price    = new Money(49.99, "usd");
        Money tax      = new Money(8.99,  "usd");
        Money subtotal = price.add(tax);           // Returns new Money object!
        Money doubled  = subtotal.multiply(2.0);   // Returns new Money object!

        System.out.println("Price    : " + price);
        System.out.println("Tax      : " + tax);
        System.out.println("Subtotal : " + subtotal);
        System.out.println("Doubled  : " + doubled);

        System.out.println("
=== 2. Defensive Copy Protection in DataSnapshot ===");
        int[] sensorData = {45, 78, 92, 61, 55};
        DataSnapshot snap = new DataSnapshot("Temperature Sensor A", sensorData);

        // Try to corrupt via original array (protected by constructor copy)
        sensorData[0] = 0;
        System.out.println("Original array corrupted: sensorData[0] = " + sensorData[0]);
        System.out.println("Snapshot internal safe  : " + snap.getReadings()[0]);
        System.out.println("Snapshot                : " + snap);

        // Try to corrupt via returned array (protected by getter copy)
        int[] returned = snap.getReadings();
        returned[0] = 9999;
        System.out.println("Returned array changed  : returned[0] = " + returned[0]);
        System.out.println("Snapshot still safe     : " + snap.getReadings()[0]);

        System.out.println("
=== 3. Java Record Demo ===");
        Point origin = new Point(0, 0);
        Point target = new Point(3.0, 4.0);

        System.out.println("Origin      : " + origin);
        System.out.println("Target      : " + target);
        System.out.printf("Distance    : %.2f units%n", origin.distanceTo(target));
        System.out.println("x component : " + target.x());
        System.out.println("y component : " + target.y());

        System.out.println("
=== 4. Currency Mismatch Exception ===");
        try {
            Money usd = new Money(100, "USD");
            Money eur = new Money(90,  "EUR");
            usd.add(eur); // Different currencies!
        } catch (IllegalArgumentException e) {
            System.out.println("Currency error: " + e.getMessage());
        }
    }
}
๐Ÿ’ป Program Console Output
=== 1. Immutable Money Operations === Price : Money{49.99 USD} Tax : Money{8.99 USD} Subtotal : Money{58.98 USD} Doubled : Money{117.96 USD} === 2. Defensive Copy Protection in DataSnapshot === Original array corrupted: sensorData[0] = 0 Snapshot internal safe : 45 Snapshot : Snapshot{label='Temperature Sensor A', avg=66.2, readings=[45, 78, 92, 61, 55]} Returned array changed : returned[0] = 9999 Snapshot still safe : 45 === 3. Java Record Demo === Origin : Point[x=0.0, y=0.0] Target : Point[x=3.0, y=4.0] Distance : 5.00 units x component : 3.0 y component : 4.0 === 4. Currency Mismatch Exception === Currency error: Cannot add different currencies: USD and EUR

๐Ÿ” Line-by-Line Code Explanation

final class Money

final prevents any subclass from adding mutable state or overriding the immutable design.

return new Money(this.amount + other.amount, this.currency);

Immutable update pattern: never modifies existing object; always returns a brand new Money instance.

this.readings = readings.clone();

Defensive copy in constructor ensures the snapshot's array is independent from the caller's array.

record Point(double x, double y)

Java 16+ record: compiler auto-generates private final fields, constructor, getters (x(), y()), equals, hashCode, toString.

Practical Real-World Example

โ˜• PracticalApplication.java โ€” Industry Implementation
// Industry Simulation: Audit Log Entry (Must be immutable for compliance!)
final class AuditEntry {
    private final String userId;
    private final String action;
    private final long timestamp;

    public AuditEntry(String userId, String action) {
        if (userId == null || action == null) throw new IllegalArgumentException("Fields required.");
        this.userId    = userId;
        this.action    = action;
        this.timestamp = System.currentTimeMillis();
    }

    public String getUserId()   { return userId; }
    public String getAction()   { return action; }
    public long getTimestamp()  { return timestamp; }

    @Override
    public String toString() {
        return String.format("AuditEntry{user='%s', action='%s', ts=%d}", userId, action, timestamp);
    }
}

public class PracticalApplication {
    public static void main(String[] args) {
        AuditEntry e1 = new AuditEntry("USR-001", "LOGIN");
        AuditEntry e2 = new AuditEntry("USR-001", "TRANSFER_FUNDS");
        AuditEntry e3 = new AuditEntry("USR-002", "CHANGE_PASSWORD");

        System.out.println("=== Compliance Audit Trail (Immutable Log) ===");
        System.out.println(e1);
        System.out.println(e2);
        System.out.println(e3);
    }
}
๐Ÿ’ป Practical Console Output
=== Compliance Audit Trail (Immutable Log) === AuditEntry{user='USR-001', action='LOGIN', ts=1723789543000} AuditEntry{user='USR-001', action='TRANSFER_FUNDS', ts=1723789543001} AuditEntry{user='USR-002', action='CHANGE_PASSWORD', ts=1723789543002}
โš ๏ธ Common Mistakes & Professional Best Practices
  • Declaring fields final but returning a mutable reference (like an array or Date) directly from a getter โ€” this breaks immutability even with final fields!
  • Making a class final but forgetting defensive copies of mutable fields โ€” still mutable via the array returned from a getter.
  • Confusing final field (value fixed at assignment) with final class (cannot be subclassed).
  • Using records but adding setters or mutable fields inside โ€” records are immutable by design; don't fight the design.
๐ŸŽฏ Hands-on Coding Challenge

Test your understanding by writing the code directly in your editor or running in our online Java compiler:

โ˜• Challenge.java
// Coding Challenge:
// Create an immutable class Version (like a software version number):
// Fields: final int major, minor, patch.
// Rule: All fields must be >= 0.
// Methods: isNewerThan(Version other), toString() returns "major.minor.patch".

final class Version {
    private final int major;
    private final int minor;
    private final int patch;

    public Version(int major, int minor, int patch) {
        if (major < 0 || minor < 0 || patch < 0) throw new IllegalArgumentException("Version parts must be >= 0");
        this.major = major;
        this.minor = minor;
        this.patch = patch;
    }

    public boolean isNewerThan(Version other) {
        if (this.major != other.major) return this.major > other.major;
        if (this.minor != other.minor) return this.minor > other.minor;
        return this.patch > other.patch;
    }

    @Override
    public String toString() {
        return major + "." + minor + "." + patch;
    }
}

public class Challenge {
    public static void main(String[] args) {
        Version v1 = new Version(2, 1, 0);
        Version v2 = new Version(2, 0, 5);
        System.out.println(v1 + " newer than " + v2 + ": " + v1.isNewerThan(v2));
    }
}

๐Ÿ’ก Frequently Asked Questions & Interview Insights

โ“ Is String immutable in Java? Why?

Yes. String is immutable by design for 4 reasons: (1) Thread safety โ€” shared between threads without locks. (2) String pool caching โ€” literals are reused from the pool. (3) HashMap key reliability โ€” hash never changes. (4) Security โ€” passwords and file paths cannot be mutated mid-operation.

โ“ Does final field guarantee deep immutability?

No. `final int[] arr = {1, 2, 3}` means `arr` cannot point to a different array, but `arr[0] = 99` is still valid! Deep immutability of arrays requires defensive copying on all access paths.

โ“ When should I use a Java record vs a regular class?

Use `record` for simple, pure data-carrying entities (DTOs, value objects) that need immutability with minimal boilerplate. Use regular classes when you need inheritance, mutable state, complex constructors, or additional design patterns.

๐Ÿš€ Quick Chapter Recap

  • Immutable objects cannot change state after construction โ€” implement the 5 rules to ensure correctness.
  • final on a field prevents reassignment; final on a method prevents overriding; final on a class prevents subclassing.
  • Defensive copying is essential for arrays and mutable object fields in immutable classes.
  • Java 16+ record keyword generates an immutable data class with minimal boilerplate.
  • Immutability is the simplest and safest path to thread-safe object sharing.
โ† Prev: 44. Getters, Setters & Validation Next: 46. Package Access & Class Design โ†’
OC
Curated by Our Compiler Java Technical Editorial Team
Published for 2026 Academic & Enterprise Reference ยท 100% Free & Open Access