🔒 Раздел 13 · Вопрос #10

Что такое defensive copy (защитная копия)

Главное назначение защитной копии — гарантировать целостность и неизменяемость объекта независимо от действий вызывающего кода. Паттерн реализуется по «правилу двух точек»:


🟢 Junior Level

30-секундный ответ

Защитная копия (Defensive Copy) — это идиома программирования, при которой создаётся независимый дубликат мутабельного объекта для изоляции внутреннего состояния класса от внешних изменений.

Главное назначение защитной копии — гарантировать целостность и неизменяемость объекта независимо от действий вызывающего кода. Паттерн реализуется по «правилу двух точек»:

  1. Точка входа (Конструктор): создаётся копия переданных параметров, чтобы предотвратить модификацию внутреннего состояния через исходные ссылки вызывающей стороны.
  2. Точка выхода (Геттер): возвращается копия внутреннего объекта (или неизменяемое представление), чтобы клиент не мог разрушить инварианты класса через полученную ссылку.

Аналогия из реальной жизни

Представьте нотариально заверенную копию паспорта:

  • Если клиент просит ваш паспорт для оформления договора, вы отдаёте ксерокопию.
  • Если клиент поставит на копии штамп или сделает пометку ручкой, ваш оригинальный паспорт в кармане останется целым и невредимым.

Наглядный пример

import java.util.Date;

public final class Appointment {
    private final String title;
    private final Date time; // java.util.Date мутабелен!

    public Appointment(String title, Date time) {
        this.title = title;
        // ТОЧКА 1: Защитная копия на входе
        this.time = new Date(time.getTime());
    }

    public String getTitle() {
        return title;
    }

    public Date getTime() {
        // ТОЧКА 2: Защитная копия на выходе
        return new Date(time.getTime());
    }
}

🟡 Middle Level

1. Поверхностная (Shallow) vs Глубокая (Deep) защитная копия

При создании защитных копий коллекций критически важно учитывать тип элементов:

  • Shallow Copy (Поверхностная): создаётся новый контейнер, но ссылки внутри указывают на те же экземпляры. Достаточна, если сами элементы неизменяемы (String, Integer, UUID):
    // Достаточно, так как String иммутабелен
    this.tags = List.copyOf(tags);
    
  • Deep Copy (Глубокая): рекурсивно копируется как сам контейнер, так и каждый вложенный изменяемый объект. Обязательна, если элементы мутабельны:
    // Глубокое копирование мутабельных элементов через конструктор копирования:
    this.items = items.stream()
        .map(item -> new OrderItem(item.getSku(), item.getPrice()))
        .toList();
    

2. Защитная копия vs Неизменяемая обёртка (Wrapper)

Частая путаница на собеседованиях: Collections.unmodifiableList() vs List.copyOf():

  • Collections.unmodifiableList(original) — это обёртка (View) с $O(1)$ по времени и памяти. Она запрещает вызов add()/remove() через себя, но не защищает от мутаций оригинального списка. Если внешний код изменит original, изменения мгновенно отразятся в обёртке!
  • List.copyOf(original) (Java 10+) — это полноценная защитная копия со сложностью $O(N)$. Она физически отсекает связь с оригиналом и возвращает неизменяемую коллекцию платформы.
List<String> original = new ArrayList<>(List.of("A", "B"));

List<String> view = Collections.unmodifiableList(original);
List<String> copy = List.copyOf(original);

original.add("C");

System.out.println(view); // [A, B, C] — ОБЁРТКА ИЗМЕНИЛАСЬ!
System.out.println(copy); // [A, B] — ЗАЩИТНАЯ КОПИЯ СОХРАНИЛА СОСТОЯНИЕ!

3. Когда защитная копия НЕ нужна

  1. Иммутабельные типы: примитивы, String, BigDecimal, классы пакета java.time (LocalDate, Instant).
  2. Закрытый доверенный код (Private/Package-Private API): когда класс и вызывающий код находятся внутри одного пакета и гарантируют отсутствие мутаций ради экстремальной производительности.

🔴 Senior Level

Защита от TOCTOU-атак (Time-of-Check to Time-of-Use)

В многопоточной среде валидация аргументов без предварительного копирования открывает критическую уязвимость:

// ❌ УЯЗВИМАЯ РЕАЛИЗАЦИЯ:
public TimeInterval(Date start, Date end) {
    if (start.compareTo(end) > 0) { // Time of Check
        throw new IllegalArgumentException();
    }
    // ОКНО УЯЗВИМОСТИ: Другой поток может вызвать start.setTime(Long.MAX_VALUE) прямо здесь!
    this.start = new Date(start.getTime()); // Time of Use
    this.end = new Date(end.getTime());
}
// ✅ ПРАВИЛЬНАЯ РЕАЛИЗАЦИЯ: Копия ДО валидации!
public TimeInterval(Date start, Date end) {
    // 1. Сначала копируем во внутренние/локальные переменные:
    Date localStart = new Date(start.getTime());
    Date localEnd = new Date(end.getTime());

    // 2. Валидируем изолированную копию:
    if (localStart.compareTo(localEnd) > 0) {
        throw new IllegalArgumentException();
    }

    this.start = localStart;
    this.end = localEnd;
}

Гарантии JMM §17.5 при создании защитных копий

Если защитная копия создаётся внутри конструктора и сохраняется в final-поле:

  1. Все объекты, аллоцированные в процессе создания защитной копии (например, внутренние узлы List.copyOf или новый массив byte[]), считаются частью инициализации объекта.
  2. Действие Freeze Action в конце конструктора устанавливает барьер StoreStore, гарантируя, что поля защитной копии сброшены в память до публикации ссылки на основной объект.
  3. Читающие потоки гарантированно видят консистентное состояние защитной копии без дополнительных блокировок.

Устранение аллокаций JIT-компилятором (Escape Analysis)

Senior-разработчик должен понимать издержки $O(N)$ копирования в горячих путях:

  • При вызове геттера, возвращающего new Date(time.getTime()) или array.clone(), JVM создаёт новый объект в Eden.
  • Однако если вызывающий код использует результат локально:
    long epoch = appointment.getTime().getTime();
    
  • Высокоуровневый JIT-компилятор C2 выполняет Escape Analysis (Анализ побега): обнаружив, что возвращённая защитная копия не покидает стек текущего метода, C2 производит Scalar Replacement (Скалярное замещение), полностью исключая аллокацию объекта Date в куче!

4 Tricky Questions

1. В каком сценарии возврат Collections.unmodifiableList(this.list) из геттера на 100% безопасен и не требует выделения памяти на копирование?

Ответ: Этот подход 100% безопасен, если внутренний список this.list был надёжно изолирован в конструкторе (через защитное копирование new ArrayList<>(input)), ссылка на него сохранена в private final поле, ни один метод класса никогда его не модифицирует, а обёртка Collections.unmodifiableList(this.list) создана один раз в конструкторе и сохранена во второе поле private final List<T> unmodifiableView;. В этом случае геттер просто возвращает ссылку на предсозданный unmodifiableView за $O(1)$ без аллокаций и без риска изменения данных.


2. Защищает ли List.copyOf(input) в конструкторе от вредоносной реализации List, переданной злоумышленником?

Ответ: Да, метод List.copyOf() спроектирован специально для защиты от атак через недоверенные реализации интерфейса List:

  1. List.copyOf() проверяет класс аргумента: если это не внутренний доверенный ImmutableCollections, метод запрашивает массив элементов через стандартный итератор или метод toArray().
  2. Внутренний массив элементов немедленно упаковывается в закрытый неизменяемый системный класс (List12, ListN), запрещающий добавление null и любых модификаций.
  3. Любые переопределённые вредоносные методы переданного внешнего списка отсекаются, обеспечивая полную безопасность инвариантов.

3. Почему для полей-массивов (byte[], int[]) вызов array.clone() обязателен, даже если поле объявлено как private final?

Ответ: Модификатор final запрещает лишь переприсвоение ссылки на сам массив. Сам массив в Java является мутабельным объектом фиксированной длины: любой клиент, имеющий ссылку на массив, может выполнить array[0] = 999;. Поскольку у массивов нет геттеров и сеттеров, а доступ к элементам осуществляется напрямую по индексу, единственным способом защитить массив является вызов .clone() или Arrays.copyOf() как при приёме массива в конструкторе, так и при возврате из геттера.


4. Может ли C2 JIT-компилятор устранить затраты на защитную копию, возвращаемую из геттера? При каком условии?

Ответ: Да, через механизм Escape Analysis и Scalar Replacement: Если метод-геттер инлайнится (inlined) в вызывающий контекст, и скомпилированный код видит, что возвращённая копия (например, массив или объект Date) не передаётся во внешние методы, не сохраняется в статические/экземплярные поля и не используется другими потоками, C2 компилятор:

  1. Полностью отменяет физическую аллокацию нового объекта в куче (Heap).
  2. Заменяет поля объекта локальными регистрами процессора или стековыми переменными.
  3. Читает данные напрямую из памяти оригинального объекта, сводя оверхед защитной копии практически к нулю.

🎯 Шпаргалка для интервью

Сводная таблица подходов к Defensive Copy

| Тип объекта | В конструкторе (Вход) | В геттере (Выход) | Сложность | | :— | :— | :— | :— | | Коллекция (Immutable элементы) | List.copyOf(input) | Прямой возврат this.list | $O(N)$ вход / $O(1)$ выход | | Коллекция (Mutable элементы) | stream().map(Item::new).toList() | Возврат списка клонированных копий | $O(N)$ глубокое копирование | | Массивы (byte[]) | input.clone() | this.array.clone() | $O(N)$ вход / $O(N)$ выход | | Устаревший Date | new Date(input.getTime()) | new Date(this.date.getTime()) | $O(1)$ аллокация | | Современный Instant | Прямое присвоение (иммутабелен) | Прямой возврат | $O(1)$ без аллокаций |

Красные флаги на собеседовании (Чего говорить нельзя)

  • ❌ «Collections.unmodifiableList() создаёт защитную копию списка» — это обёртка, при изменении оригинала данные в ней меняются.
  • ❌ «Проверку аргументов на null и валидность нужно делать ДО создания защитной копии» — открывает уязвимость TOCTOU.
  • ❌ «Для иммутабельных типов вроде String и LocalDate тоже нужно делать defensive copy» — бессмысленная трата ресурсов, иммутабельные типы копировать не нужно.
  • ❌ «Если поле объявлено как final byte[], его элементы нельзя изменить» — элементы массива можно свободно перезаписывать по индексу.

Связанные вопросы