🔒 Розділ 13 · Питання #9

Що робити, якщо поле класу посилається на змінний об'єкт

Якщо поле проєктованого незмінного класу посилається на мутабельний (змінний) об'єкт — такий як колекція (ArrayList, HashMap), масив (int[], byte[]), дата (java.util.Date) або з...


🟢 Junior Level

30-секундна відповідь

Якщо поле проєктованого незмінного класу посилається на мутабельний (змінний) об’єкт — такий як колекція (ArrayList, HashMap), масив (int[], byte[]), дата (java.util.Date) або змінний доменний об’єкт, необхідно застосовувати стратегію захисного копіювання (Defensive Copying) за «правилом двох точок»:

  1. Точка 1 (Вхід / Конструктор): ніколи не зберігати пряме посилання на переданий ззовні змінний об’єкт. Обов’язково створити його незалежну копію та зберегти посилання на неї.
  2. Точка 2 (Вихід / Гетер): ніколи не повертати пряме посилання на внутрішній об’єкт, якщо клієнт може його модифікувати. Повертати або нову копію, або незмінне представлення (Unmodifiable View).

Наочний приклад: Дата народження (мутабельний клас java.util.Date)

import java.util.Date;
import java.util.Objects;

public final class Employee {
    private final String name;
    private final Date birthDate; // Date мутабельний через метод setTime()!

    public Employee(String name, Date birthDate) {
        this.name = Objects.requireNonNull(name);
        Objects.requireNonNull(birthDate, "Birth date required");

        // 1. ЗАХИСНА КОПІЯ НА ВХОДІ:
        // Створюємо новий об'єкт Date з тим самим timestamp, ізолюючи внутрішній стан
        this.birthDate = new Date(birthDate.getTime());
    }

    public String getName() {
        return name;
    }

    public Date getBirthDate() {
        // 2. ЗАХИСНА КОПІЯ НА ВИХОДІ:
        // Повертаємо копію, щоб виклик getBirthDate().setTime(0) не змінив поле класу
        return new Date(birthDate.getTime());
    }
}

🟡 Middle Level

1. Захист колекцій у сучасних версіях Java

До Java 10 для захисного копіювання колекцій вимагалося створювати копію й огортати її:

// Java 8 патерн:
this.items = Collections.unmodifiableList(new ArrayList<>(items));

Починаючи з Java 10, рекомендується використовувати методи List.copyOf(), Set.copyOf(), Map.copyOf():

public final class ShoppingCart {
    private final List<String> itemSkus;

    public ShoppingCart(List<String> itemSkus) {
        // List.copyOf() перевіряє аргумент на null, створює компактну 
        // незмінну копію і не допускає елементів null
        this.itemSkus = (itemSkus == null) ? List.of() : List.copyOf(itemSkus);
    }

    public List<String> getItemSkus() {
        // Повертаємо напряму, оскільки List.copyOf гарантує незмінність колекції
        return itemSkus;
    }
}

2. Захист масивів

Масиви в Java завжди мутабельні. Для масивів виклик clone() або Arrays.copyOf() є обов’язковим на вході та на виході:

public final class SecurityToken {
    private final byte[] tokenBytes;

    public SecurityToken(byte[] tokenBytes) {
        // Копія на вході
        this.tokenBytes = tokenBytes.clone();
    }

    public byte[] getTokenBytes() {
        // Копія на виході
        return tokenBytes.clone();
    }
}

3. Глибоке копіювання (Deep Copy) колекцій об’єктів

Якщо список містить мутабельні об’єкти, одного лише List.copyOf недостатньо, оскільки копіюються лише посилання на елементи:

public record Order(Long id, List<OrderItem> items) {
    public Order {
        // Клонуємо КОЖЕН елемент списку через конструктор копіювання:
        items = (items == null) ? List.of() : items.stream()
            .map(item -> new OrderItem(item.getSku(), item.getPrice(), item.getQuantity()))
            .toList();
    }
}

🔴 Senior Level

Загроза Time-of-Check to Time-of-Use (TOCTOU) у конструкторах

Фундаментальне правило багатопотокової безпеки: захисну копію параметрів конструктора необхідно знімати ДО їхньої валідації, а не після:

// ❌ ВРАЗЛИВИЙ КОД: схильний до TOCTOU-атаки в паралельному середовищі
public UserPeriod(Date start, Date end) {
    if (start.after(end)) { // 1. Time-of-Check (валідація оригіналу)
        throw new IllegalArgumentException("start must be before end");
    }

    // У цей мікромомент паралельний потік викликає start.setTime(System.currentTimeMillis() + 1000000)!

    this.start = new Date(start.getTime()); // 2. Time-of-Use (копіювання підміненого стану)
    this.end = new Date(end.getTime());
}

Безпечна реалізація (Захист від TOCTOU):

// ✅ АБСОЛЮТНО БЕЗПЕЧНИЙ КОД
public UserPeriod(Date start, Date end) {
    // 1. Спочатку робимо захисну локальну копію:
    Date copyStart = new Date(start.getTime());
    Date copyEnd = new Date(end.getTime());

    // 2. Валідуємо вже ВЛАСНУ локальну копію:
    if (copyStart.after(copyEnd)) {
        throw new IllegalArgumentException("start must be before end");
    }

    // 3. Зберігаємо перевірену копію:
    this.start = copyStart;
    this.end = copyEnd;
}

Атака через поліморфізм та метод clone()

Джошуа Блох в Effective Java (Стаття 50) акцентує увагу: ніколи не використовуйте метод clone() для захисного копіювання параметрів, типи яких можуть бути розширені стороннім кодом:

  • Якщо метод приймає Date date і робить this.date = (Date) date.clone();, зловмисник може передати об’єкт шкідливого підкласу:
    public class MaliciousDate extends Date {
        private Date stolenReference;
        @Override
        public Object clone() {
            this.stolenReference = this; // Перехоплення посилання
            return this; // Повернення себе ж замість копії!
        }
    }
    
  • У результаті захисне копіювання обійдено! Використовувати clone() безпечно тільки для масивів, оскільки масиви мають закритий фіксований тип часу виконання.

Продуктивність: Персистентні структури даних (Structural Sharing)

Копіювання колекцій розміру $N$ має складність $O(N)$ за часом і пам’яттю. Якщо незмінний об’єкт повинен часто оновлюватися у високонавантажених системах (наприклад, біржові склянки, фінансові книги):

  • Замість захисного копіювання стандартних java.util.List використовують бібліотеки персистентних структур даних (Vavr, PCollections).
  • Персистентні структури даних (наприклад, префіксні дерева HAMT — Hash Array Mapped Trie) забезпечують незмінність зі спільним використанням структури (Structural Sharing), виконуючи модифікації за $O(\log_{32} N)$ із мінімальним виділенням пам’яті.

4 Tricky Questions

1. Чому захисне копіювання в конструкторі зобов’язане виконуватися ДО перевірки аргументів на валідність, а не після? Наведіть сценарій експлойту.

Відповідь: Якщо перевірка аргументів виконується до копіювання, виникає вразливість TOCTOU (Time-of-Check to Time-of-Use). Сценарій експлойту:

  1. Потік А викликає конструктор new DateInterval(start, end), передаючи валідні дати.
  2. Конструктор виконує if (start.after(end)), перевірка проходить успішно.
  3. До виклику рядка this.start = new Date(start.getTime()) планувальник операційної системи перемикає контекст на Потік Б.
  4. Потік Б, що утримує посилання на об’єкт start, викликає start.setTime(Long.MAX_VALUE).
  5. Потік А відновлює роботу і копіює в this.start уже скомпрометовану дату з майбутнього. Інваріант класу порушено: у незмінному об’єкті start виявився пізнішим за end. Виконання копіювання до перевірки виключає цей вектор атаки, оскільки перевіряється вже приватна локальна копія, недоступна іншим потокам.

2. Чому Джошуа Блох забороняє використовувати метод clone() для захисного копіювання об’єктів нестандартних класів, але дозволяє для масивів?

Відповідь: Метод clone() є віртуальним. Якщо клас аргументу не оголошено як final (наприклад, java.util.Date), переданий об’єкт може бути екземпляром недовіреного підкласу (SubDate extends Date), створеного зловмисником. Підклас може перевизначити clone() так, щоб повернути вихідний об’єкт (або зберегти посилання на нього в статичному полі), зробивши «захисну копію» фікцією. Для масивів це правило безпечне, оскільки масиви в JVM є об’єктами спеціального низькорівневого типу, який неможливо успадкувати або перевизначити: array.clone() гарантовано викликається з рантайму JVM і повертає фізично новий масив.


3. Чи оптимізує метод List.copyOf() виділення пам’яті, якщо в нього передано список, уже створений через List.of()?

Відповідь: Так, метод List.copyOf(Collection<? extends E> coll) оптимізовано:

  1. Він перевіряє, чи є вхідна колекція екземпляром внутрішнього системного класу платформи java.util.ImmutableCollections.AbstractImmutableCollection.
  2. Якщо вхідна колекція вже незмінна (створена через List.of(), Set.of(), List.copyOf()), метод не виділяє пам’ять і миттєво повертає переданий екземпляр: return (List<E>) coll;.
  3. Якщо ж передано звичайний ArrayList або LinkedList, створюється нова компактна незмінна колекція.

4. Припустимо, ми зробили захисну копію списку через this.items = List.copyOf(items). Список містить об’єкти типу StringBuilder. Чи є наш клас глибоко незмінним?

Відповідь: Ні, клас є лише поверхнево незмінним (Shallow Immutable). Метод List.copyOf() заморожує лише сам список (не можна додати або видалити елемент). Проте елементами списку є посилання на мутабельні об’єкти StringBuilder. Будь-який клієнт, що викличе obj.getItems().get(0).append("HACKED"), змінить внутрішній стан об’єкта в купі, порушивши незмінність. Для досягнення глибокої незмінності необхідно скопіювати самі елементи, перетворивши їх на незмінні рядки: items.stream().map(Object::toString).toList().


🎯 Шпаргалка для інтерв’ю

Звід правил захисту мутабельних полів

| Тип поля | Як захистити на вході (Конструктор) | Як захистити на виході (Гетер) | | :— | :— | :— | | Колекція (List, Set, Map) | List.copyOf(input) (Java 10+) | Повернення посилання на List.copyOf напряму | | Масив (byte[], int[]) | input.clone() | this.array.clone() | | Дата (Date) | new Date(input.getTime()) | new Date(this.date.getTime()) (краще перейти на Instant) | | Колекція мутабельних об’єктів | input.stream().map(Item::new).toList() | Повернення незмінного списку клонованих копій |

Червоні прапорці на співбесіді (Чого говорити не можна)

  • ❌ «Для захисту колекції в конструкторі достатньо викликати Collections.unmodifiableList(list)» — це обгортка над оригіналом, оригінал ззовні можна продовжувати змінювати.
  • ❌ «Спочатку потрібно перевірити параметри на валідність, а потім робити копію» — груба помилка безпеки, що відкриває TOCTOU-атаку.
  • ❌ «Метод clone() — найкращий спосіб скопіювати об’єкт» — clone() архітектурно зламаний і схильний до атаки через поліморфні підкласи (крім масивів).
  • ❌ «Якщо ми зробили List.copyOf(), то елементи списку захищені від змін» — захищений лише контейнер, мутабельні елементи залишаються вразливими.

Пов’язані запитання