Что такое «неизменяемый объект» и зачем он нужен
Неизменяемый объект — это объект, чьё состояние невозможно изменить после построения (конструкции).
Плюсы:
- Потокобезопасность по умолчанию (без синхронизации).
- Простые инварианты: если объект валиден в конструкторе — он валиден всегда.
- Безопасное шаринг/кэширование (можно свободно передавать между потоками, класть в кэши).
- Удобно как ключи в
Map/элементы вSet(стабильныйhashCode). - Проще в тестировании и reasoning.
Правила конструирования immutable-класса
- Сделать класс
final(или все поляprivate final+ запретить наследование «логически»). - Все поля —
private final. - Инициализация только в конструкторе (после — никаких сеттеров).
- Не «утекать
this» из конструктора (не передавать ссылку на себя наружу до завершения инициализации). - Дефенсивное копирование (defensive copy) для:
- входящих изменяемых аргументов (массивы, коллекции,
Date, любые mutable-типы); - возвращаемых значений (геттеры не должны возвращать «живые» внутренние ссылки).
- входящих изменяемых аргументов (массивы, коллекции,
- Глубокая неизменяемость: если поле — коллекция/массив/объект, позаботься о глубокой защите (см. ниже).
Базовый пример (правильный)
import java.util.List;
import java.util.Objects;
public final class Money {
private final String currency; // неизменяемый тип (String — immutable)
private final long minorUnits; // копейки/центы
private final List<Integer> tags; // ПРИМ.: коллекция — mutable по природе
public Money(String currency, long minorUnits, List<Integer> tags) {
this.currency = Objects.requireNonNull(currency);
this.minorUnits = minorUnits;
// defensive copy + неизменяемая обёртка
this.tags = List.copyOf(tags); // Java 10+: по сути unmodifiable snapshot
}
public String currency() { return currency; }
public long minorUnits() { return minorUnits; }
public List<Integer> tags() {
// можно вернуть прямо tags, т.к. copyOf сделал неизменяемый снимок
return tags;
}
// функциональный стиль "с изменением": создать НОВЫЙ объект с другим значением
public Money withMinorUnits(long newMinorUnits) {
return new Money(this.currency, newMinorUnits, this.tags);
}
@Override public boolean equals(Object o) { /* стандартно по двум полям + tags */ }
@Override public int hashCode() { /* консистентен с equals */ }
@Override public String toString() { /* удобно для логов */ }
}Пояснения:
List.copyOf(...)возвращает неизменяемый снимок текущего содержимого (и броситNullPointerExceptionприnull-элементах).- Если нужна глубокая неизменяемость (элементы коллекции сами mutable) — копируй элементы (см. «глубина неизменяемости»).
Работа с массивами и коллекциями
Массивы
Массивы изменяемы. Значит:
- В конструкторе:
this.arr = Arrays.copyOf(arr, arr.length); - В геттере:
return Arrays.copyOf(arr, arr.length);
Коллекции
- Для поверхностной неизменяемости:
List.copyOf,Set.copyOf,Map.copyOf(Java 10+) илиCollections.unmodifiableXxx(...). unmodifiableListтолько запрещает мутацию через wrapper, но если держишь ссылку на исходную mutable-коллекцию — изменения видны. Поэтому:- либо создавай новую коллекцию и оборачивай её,
- либо используй
copyOf(делает snapshot).
- Для глубокой неизменяемости: копируй и элементы (если они mutable).
Частые ошибки (интервью любят)
- Возврат живой ссылки на внутреннюю коллекцию/массив:
«immutable» класс, который отдаётgetList()и тот можно изменить — это не immutable. - Нет defensive copy в конструкторе:
передали список, сохранили ссылку как есть → внешняя сторона изменит список после конструктора — класс «сломался». - Mutable-типы внутри immutable-оболочки:
хранитьjava.util.Date(mutable) без копии → изменения через ссылку. Использоватьjava.time(immutable). - Наследование от immutable-класса:
потомок может добавить изменяемое состояние/методы — разломает гарантию. Итог: делай базовый классfinal(или конструкторprivate+ фабрики). - Нарушение equals/hashCode при «мутации»:
если объект как ключ вHashMap, егоhashCodeдолжен оставаться стабильным. Поэтому — immutability.
Неизменяемость и record (Java 16+)
record — синтаксический сахар для «данных». Пример:
public record Point(int x, int y) {}Особенности:
- Все компоненты
final, геттеры генерятся,equals/hashCode/toStringуже готовы. - Но: неизменяемость поверхностная. Если поле — коллекция/массив/любой mutable-тип, нужно defensive copy в компактном конструкторе:
public record Team(String name, List<String> members) {
public Team {
name = Objects.requireNonNull(name);
members = List.copyOf(members); // делаем snapshot
}
}Неизменяемость и Lombok
@Value→ делает классfinal, поляprivate final, геттеры,equals/hashCode.- Всё равно самостоятельно обеспечивай defensive copy для массивов/коллекций/
Date.
@lombok.Value
public class UserDto {
String name;
List<String> roles; // в конструкторе сам сделай List.copyOf(...)
}«Функциональные апдейты»: with*-методы
В immutable-модели изменения делают через создание нового объекта:
public Money withCurrency(String newCurrency) {
if (this.currency.equals(newCurrency)) return this; // микро-оптимизация
return new Money(newCurrency, this.minorUnits, this.tags);
}Это удобно и безопасно для многопоточности.
Сериализация immutable-классов
- Нормально сериализуются/десериализуются (Java/JSON/Protobuf).
- Если нужен синглтон — добавь
readResolve(). - При JSON-десериализации (Jackson) пользуйся:
- полноаргументным конструктором или
@JsonCreator+@JsonProperty.
Почему String — immutable в Java
- Безопасность
Строки часто используются как идентификаторы, имена классов, ключи ресурсов, параметры ClassLoader’а, SQL-строки и т.п. Если бы кто-то мог изменить строку «на месте», это открывало бы массив уязвимостей (подмена пути, имени класса, SQL-инъекции на уровне объекта и т.д.). - String Pool (интернирование)
Литералы строк хранятся в пуле: одинаковые литералы указывают на один объект ради экономии памяти и ускорения сравнения==между литералами. Пул возможен только если строки неизменяемы (иначе изменение «Алисы» меняло бы все ссылки на неё). - Кэширование
hashCode
String.hashCode()вычисляется один раз и кэшируется в поле. Если бы строку можно было менять, кэш стал бы неверен → нарушение контрактовMap/Set. - Потокобезопасность и шаринг
Одинаковая строка может свободно разделяться между потоками без синхронизации. - Оптимизации JVM/JIT
Неизменяемые объекты легче оптимизировать (escape-analysis, постоянный инлайн, константные свёртки).
Итог: неизменяемость — фундамент String ради безопасности, производительности и предсказуемости.
Мини-примеры: правильно/неправильно
❌ Неправильно (утечка внутреннего массива)
public final class Bad {
private final int[] data;
public Bad(int[] data) { this.data = data; } // нет копии!
public int[] getData() { return data; } // отдали живую ссылку!
}✅ Правильно
public final class Good {
private final int[] data;
public Good(int[] data) { this.data = data.clone(); }
public int[] data() { return data.clone(); }
}✅ Правильно для дат: использовать java.time
public final class Booking {
private final java.time.Instant from;
private final java.time.Instant to;
public Booking(Instant from, Instant to) {
this.from = from; this.to = to; // Instant — immutable
}
}Глубина неизменяемости (deep immutability)
Если поле — список объектов Address, а Address — mutable, то:
- Сделай immutable Address (предпочтительно), или
- В конструкторе делай глубокое копирование элементов и оборачивай список в
List.copyOf.
public record Address(String city, String street) {} // immutable
public final class Person {
private final List<Address> addresses;
public Person(List<Address> addresses) {
this.addresses = List.copyOf(addresses); // элементы уже immutable
}
public List<Address> addresses() { return addresses; }
}Best practices (короткий чек-лист)
- Делай
finalкласс + все поляprivate final. - Без сеттеров. «Изменения» — через
with*-методы, создающие новый объект. - Defensive copy для массивов/коллекций/mutable-типов и на входе, и на выходе.
- Используй
java.timeвместоDate/Calendar. - Для коллекций —
List.copyOf/Map.copyOf(илиCollections.unmodifiableXxx+ предварительная копия). - Не допускай escape
thisв конструкторе (никаких колбэков/регистрации слушателей там). - Храни инварианты в одном месте — в конструкторе/фабрике, проверяй
requireNonNull, диапазоны и т.д. - Для производительности можно кэшировать производные значения (например,
hashCode) — это безопасно, если объект immutable.