Коли потрібно робити захисну копію
Захисна копія (Defensive Copy) є обов'язковою у всіх випадках, коли клас перетинає межі довіри та оперує змінними (мутабельними) об'єктами:
🟢 Junior Level
30-секундна відповідь
Захисна копія (Defensive Copy) є обов’язковою у всіх випадках, коли клас перетинає межі довіри та оперує змінними (мутабельними) об’єктами:
- При прийомі даних (Конструктори та фабричні методи): якщо аргумент конструктора є змінним типом (масив
byte[], колекціяList,Map,Dateабо змінний доменний клас), необхідно створити незалежну копію, щоб зовнішній код не міг порушити внутрішній стан об’єкта після його ініціалізації. - При поверненні даних (Гетери та методи доступу): якщо внутрішнє поле класу є змінним, повертати потрібно копію або незмінне представлення, щоб клієнтський код не міг модифікувати інваріанти класу.
Захисна копія КАТЕГОРИЧНО НЕ ПОТРІБНА для:
- Примітивних типів (
int,long,boolean,double). - Відомих незмінних класів (
String,BigDecimal,BigInteger,UUID). - Сучасних класів дати й часу пакета
java.time(LocalDate,Instant,Duration).
Наочний приклад: Коли копія обов’язкова
public final class UserRegistration {
private final String username; // Незмінний — копія НЕ потрібна
private final byte[] passwordHash; // Масив мутабельний — копія ОБОВ'ЯЗКОВА!
public UserRegistration(String username, byte[] passwordHash) {
this.username = username;
// Копія на вході: захищає від модифікації масиву кодом, що викликає конструктор
this.passwordHash = passwordHash.clone();
}
public String getUsername() {
return username; // Пряме повернення безпечне
}
public byte[] getPasswordHash() {
// Копія на виході: захищає внутрішній масив від перезапису ззовні
return passwordHash.clone();
}
}
🟡 Middle Level
4 типових сценарії обов’язкового захисного копіювання
1. Будь-які масиви (T[], byte[], int[])
Масиви в Java завжди змінювані за індексами. Навіть модифікатор final byte[] захищає лише саме посилання, але не його елементи.
- У конструкторі:
this.data = data.clone(); - У гетері:
return data.clone();
2. Колекції Java Collections Framework (List, Set, Map)
Стандартні колекції (ArrayList, HashMap, HashSet) надають методи модифікації add(), remove(), clear().
- У конструкторі (Java 10+):
this.items = List.copyOf(items);(абоnew ArrayList<>(items)з обгорткоюCollections.unmodifiableList). - Нюанс
null-безпеки:List.copyOf()викидаєNullPointerException, якщо переданий список міститьnull. Якщо елементиnullдопустимі за бізнес-логікою, використовуютьCollections.unmodifiableList(new ArrayList<>(items)).
3. Застарілі класи дати (java.util.Date, java.util.Calendar)
Клас Date має сетери setTime(), setYear(). У legacy-коді потрібне створення нового екземпляра: new Date(date.getTime()). У сучасному коді слід замінювати Date на java.time.Instant або LocalDate, які є незмінними за дизайном.
4. Вкладені змінні доменні сутності
Якщо об’єкт містить вкладену сутність Address із сетерами, захисне копіювання списку адрес List.copyOf(addresses) створить лише поверхневу копію (посилання вказуватимуть на ті самі об’єкти Address). Для повноцінного захисту необхідне глибоке копіювання:
this.addresses = addresses.stream()
.map(addr -> new Address(addr.getCity(), addr.getStreet()))
.toList();
Коли захисне копіювання є антипатерном
- Передача володіння (Handover of Ownership): якщо об’єкт конструюється через
Builder, і будівник гарантує, що створена колекція більше ніким не буде використовуватися чи модифікуватися. - Внутрішні ізольовані структури (Private Helpers): коли колекція створюється всередині приватного методу й ніколи не передається назовні.
- Об’єкти перенесення даних (Mutable DTO): якщо клас від початку проєктувався як змінний контейнер для десеріалізації фреймворками на кшталт Jackson або Hibernate.
🔴 Senior Level
Архітектурні межі довіри (Trust Boundaries)
Рішення про виконання захисного копіювання приймається на основі аналізу меж довіри архітектури:
- Зовнішня межа (Public API, бібліотеки, відкриті SDK, REST-контролери): довіра до клієнта дорівнює нулю. Захисне копіювання суворо обов’язкове як на вході, так і на виході.
- Внутрішня межа (Package-Private, модулі з високою зв’язністю): у критичних до затримок сервісах (HFT, телеметрія реального часу) суцільне $O(N)$ копіювання спричиняє неприпустимий GC Churn у поколінні Eden. У таких сценаріях межі документуються контрактом («Caller transfers ownership and must not mutate»), або використовуються спеціалізовані незмінні типи на рівні сигнатур методів.
Проблема TOCTOU при захисному копіюванні
Якщо конструктор перевіряє бізнес-правила (наприклад, «список прав не повинен бути порожнім і не повинен містити дублікатів»), перевірка має виконуватися суворо після зняття захисної копії:
// ✅ ЗАХИСТ ВІД TOCTOU:
public SecurityContext(List<String> permissions) {
// 1. Спочатку відсікаємо зовнішні потоки локальною копією:
List<String> copy = List.copyOf(permissions);
// 2. Валідуємо вже свою локальну незмінну копію:
if (copy.isEmpty() || !copy.contains("BASE_ACCESS")) {
throw new SecurityException("Invalid permissions");
}
this.permissions = copy;
}
Якщо спочатку перевірити permissions, а потім копіювати, інший потік може викликати permissions.clear() прямо в проміжку між перевіркою та копіюванням (Time-of-Check to Time-of-Use).
Альтернативи $O(N)$ копіюванню: Zero-Copy та Persistent Data Structures
У системах із високими вимогами до пропускної здатності алокація тисяч копій колекцій призводить до частих пауз Minor GC. Архітектурні альтернативи:
- Персистентні структури даних (Vavr, PCollections): використовують префіксні дерева (HAMT). Додавання елемента породжує новий вузол за $O(\log_{32} N)$, перевикористовуючи до 95% наявної структури в пам’яті (Structural Sharing).
- Zero-Copy Read-Only Views:
ByteBuffer.asReadOnlyBuffer()— накладає заборону на запис у нативний буфер без фізичного копіювання байтів.MemorySegment.asReadOnly()(Project Panama / JEP 454 Foreign Function & Memory API) — безпечний доступ до off-heap пам’яті без зайвих алокацій у купі (Heap).
4 Tricky Questions
1. Чи повинен метод build() у патерні Builder виконувати захисне копіювання внутрішньої колекції перед передачею в конструктор незмінного класу?
Відповідь:
Так, абсолютно обов’язково!
Якщо Builder.build() передасть посилання на свій внутрішній список this.items безпосередньо в конструктор незмінного об’єкта без копіювання, виникає небезпечна вразливість:
OrderBuilder builder = new OrderBuilder().addItem(item1);
Order order = builder.build();
// Зловмисник або неуважний розробник продовжує використовувати builder:
builder.addItem(item2); // Якщо build() не зробив копію, order змінився!
Або Builder має виконати List.copyOf(this.items) при виклику build(), або конструктор Order зобов’язаний зробити захисну копію. На практиці найнадійніше робити копію безпосередньо всередині конструктора цільового класу, щоб захиститися від будь-якого виклику.
2. Як у Foreign Function & Memory API (Java 22, JEP 454) вирішується проблема захисного копіювання нативної пам’яті без накладних витрат?
Відповідь:
При роботі з нативною пам’яттю (off-heap) копіювання мегабайтних буферів є вкрай неефективним.
Замість фізичного копіювання пам’яті метод MemorySegment.asReadOnly() створює легковажну обгортку-дескриптор, переводячи сегмент пам’яті в режим «тільки для читання».
Будь-яка спроба виклику нативних операцій запису (segment.set(...)) викличе негайний виняток UnsupportedOperationException на рівні перевірок рантайму HotSpot, гарантуючи незмінність даних у нативній пам’яті без виділення додаткової RAM (Zero-Copy Immutability).
3. Якщо вхідна колекція містить 100 000 елементів, як уникнути просідання продуктивності через $O(N)$ захисне копіювання?
Відповідь: Застосовуються 3 архітектурні патерни:
- Незмінні типи в контракті API: сигнатура методу проєктується так, щоб приймати не абстрактний
Collection<T>, а конкретний незмінний тип (наприклад,io.vavr.collection.List<T>або спеціалізований незмінний доменний тип). У цьому випадку відповідальність за незмінність переноситься на сторону викликача. - Передача володіння через Builder: код, що викликає, передає фабрику або Stream, а незмінний клас здійснює наповнення контейнера рівно один раз.
- Memory Mapped / Paged Views: якщо дані призначені тільки для пакетної обробки, вони мапляться в незмінні буфери пам’яті або загортаються в спеціалізовані структури зі структурним розділенням (Structural Sharing).
4. Чому виклик List.copyOf(list) працює за $O(1)$ без нових алокацій, якщо список уже був створений через List.of()?
Відповідь:
Метод List.copyOf() містить швидку перевірку оптимізації:
if (coll instanceof ImmutableCollections.AbstractImmutableCollection) {
return (List<E>) coll;
}
Усі списки, створені через List.of(), Set.of(), Map.of() або попередні виклики copyOf(), є нащадками системного внутрішнього класу AbstractImmutableCollection. JVM точно знає, що ці колекції фізично незмінні та не містять null. Тому метод миттєво повертає передане посилання без валідації та без копіювання елементів масиву, забезпечуючи нульову вартість виклику.
🎯 Шпаргалка для інтерв’ю
Матриця прийняття рішень: Чи робити Defensive Copy?
| Вхідні/Вихідні дані | Робити Defensive Copy? | Обґрунтування |
| :— | :— | :— |
| int, double, boolean | ❌ Ні | Примітиви передаються за значенням |
| String, UUID, BigDecimal | ❌ Ні | Класи вже є незмінними за специфікацією |
| LocalDate, Instant | ❌ Ні | Пакет java.time спроєктований незмінним |
| Масиви byte[], int[] | ✅ Так, завжди | Масиви завжди змінювані за індексами (.clone()) |
| List, Set, Map | ✅ Так, завжди | Зовнішній код може викликати .add() / .clear() (List.copyOf()) |
| Date, Calendar | ✅ Так, завжди | Містять методи-мутатори (new Date(time)) |
| Внутрішній довірений код | ⚠️ За домовленістю | Якщо задокументовано передачу володіння заради високої швидкодії |
Червоні прапорці на співбесіді (Чого говорити не можна)
- ❌ «Захисну копію потрібно робити взагалі для будь-яких об’єктів, включно зі String» — груба помилка, рядки незмінні, їх копіювання безглузде.
- ❌ «У конструкторі можна просто обгорнути вхідний список у Collections.unmodifiableList» — це не копія, а обгортка; зміна оригіналу пошкодить клас.
- ❌ «Масив final int[] захищений від змін» — модифікатор
finalфіксує лише посилання, елементи масиву залишаються змінюваними. - ❌ «Перевірку аргументів на валідність потрібно робити перед створенням захисної копії» — це відкриває вразливість TOCTOU (інший потік може змінити об’єкт між перевіркою та копіюванням).