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

В чём разница между shallow copy и deep copy

Разница между Shallow Copy (поверхностным копированием) и Deep Copy (глубоким копированием) заключается в глубине дублирования графа объектов в памяти:


🟢 Junior Level

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

Разница между Shallow Copy (поверхностным копированием) и Deep Copy (глубоким копированием) заключается в глубине дублирования графа объектов в памяти:

  • Shallow Copy (Поверхностная копия): создаёт новый объект-контейнер, но копирует лишь значения примитивных полей и ссылки на вложенные объекты. В результате оригинальный объект и его копия ссылаются на одни и те же экземпляры в куче. Изменение внутреннего объекта через копию изменяет его и в оригинале!
  • Deep Copy (Глубокая копия): рекурсивно создаёт дубликаты как самого контейнера, так и всех вложенных объектов по всей цепочке зависимостей. Оригинал и копия становятся на 100% изолированными: никакие изменения в одном объекте не могут повлиять на другой.

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

class Address {
    String city;
    Address(String city) { this.city = city; }
}

class User {
    String name;
    Address address;

    User(String name, Address address) {
        this.name = name;
        this.address = address;
    }
}

public class CopyDemo {
    public static void main(String[] args) {
        Address addr = new Address("Berlin");
        User user1 = new User("Alice", addr);

        // 1. Поверхностная копия (Shallow Copy)
        User shallowCopy = new User(user1.name, user1.address);

        // Изменяем город через копию:
        shallowCopy.address.city = "Munich";

        // Пострадал оригинал!
        System.out.println(user1.address.city); // "Munich" — данные испорчены!

        // 2. Глубокая копия (Deep Copy)
        User deepCopy = new User(user1.name, new Address(user1.address.city));
        deepCopy.address.city = "Hamburg";

        System.out.println(user1.address.city); // "Munich" — оригинал изолирован!
    }
}

Схема в памяти (Heap)

Shallow Copy:
[user1]       ──► address ──┐
                            ▼
[shallowCopy] ──► address ──► [Address Object: "Berlin"] (Один разделяемый объект!)

Deep Copy:
[user1]       ──► address ──► [Address Object: "Berlin"]
[deepCopy]    ──► address ──► [Address Object: "Hamburg"] (Два разных объекта в памяти!)

🟡 Middle Level

Способы реализации Deep Copy в Java

1. Конструкторы копирования и фабричные методы (Рекомендуемый подход)

Самый надёжный, быстрый и типобезопасный способ в Java (рекомендован Джошуа Блохом):

public class Order {
    private final String id;
    private final List<OrderItem> items;

    // Конструктор копирования
    public Order(Order other) {
        this.id = other.id; // String иммутабелен, глубокая копия не нужна
        this.items = other.items.stream()
            .map(OrderItem::new) // Вызов конструктора копирования каждого элемента
            .toList();
    }
}

2. Интерфейс Cloneable и метод Object.clone()

По умолчанию нативный метод Object.clone() делает исключительно поверхностное копирование (побитовое копирование полей объекта). Для реализации Deep Copy необходимо вручную переопределить метод clone() и рекурсивно склонировать каждое поле:

@Override
public User clone() {
    try {
        User copy = (User) super.clone(); // Копирует примитивы и name (String)
        copy.address = this.address.clone(); // Ручное глубокое клонирование
        return copy;
    } catch (CloneNotSupportedException e) {
        throw new AssertionError();
    }
}

Минусы: архитектурно сломанный механизм (Effective Java, Item 13), не вызывает конструкторы, требует кастинга типов и обработки проверяемых исключений.

3. Сериализация (Java Serialization или JSON/Kryo)

Объект сериализуется в байты и десериализуется обратно в новый граф:

// Через стандартную сериализацию:
ByteArrayOutputStream baos = new ByteArrayOutputStream();
try (ObjectOutputStream oos = new ObjectOutputStream(baos)) {
    oos.writeObject(original);
}
ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());
try (ObjectInputStream ois = new ObjectInputStream(bais)) {
    User deepCopy = (User) ois.readObject();
}

Минусы: колоссальные накладные расходы процессора и памяти (медленнее конструкторов в 50–100 раз), требует реализации Serializable.


🔴 Senior Level

Проблема циклических ссылок в графе объектов (Graph Cycles)

Если объект $A$ ссылается на $B$, а $B$ ссылается обратно на $A$ ($A \leftrightarrow B$):

  • Наивная рекурсивная реализация Deep Copy через конструкторы приведёт к бесконечной рекурсии и фатальной ошибке StackOverflowError.
  • Промышленные алгоритмы глубокого копирования (например, в сериализаторах Kryo, Jackson или Apache Commons SerializationUtils) используют карту идентичности:
    Map<Object, Object> visited = new IdentityHashMap<>();
    
  • Перед созданием копии алгоритм проверяет visited.containsKey(original). Если объект уже копировался, берётся готовая ссылка из мапы, что разрывает циклы и сохраняет топологию графа.

Альтернатива: Структурное разделение (Structural Sharing)

Выполнение полного $O(N)$ глубокого копирования при каждом изменении состояния уничтожает пропускную способность сервиса в высоконагруженных системах (HFT, игровые движки, распределённые хранилища). Решение — персистентные структуры данных (Persistent Data Structures):

  • Используются сбалансированные префиксные деревья (HAMT — Hash Array Mapped Trie, реализованные в Vavr или Clojure).
  • При «модификации» создаётся не полная копия, а копируются лишь узлы дерева вдоль пути от корня к изменяемому листу ($O(\log_{32} N)$).
  • Все остальные ветви дерева (до 98% узлов) безопасно разделяются между старой и новой версией (Structural Sharing), гарантируя иммутабельность без издержек глубокого копирования.
До добавления элемента:           После добавления нового узла X:
       [Root 1]                               [Root 2]
       /      \                               /      \
    [Node A]  [Node B]                    [Node A']  [Node B] (РАЗДЕЛЯЕМЫЙ!)
    /      \                              /      \
 [Leaf 1]  [Leaf 2]                   [Leaf 1]   [Node X]

4 Tricky Questions

1. Как обработать циклические ссылки при реализации глубокого копирования вручную?

Ответ: Для предотвращения StackOverflowError метод глубокого копирования должен принимать контекст обхода — структуру IdentityHashMap<Object, Object> visited, сопоставляющую оригинальные объекты с их уже созданными копиями:

  1. Перед инстанцированием копии проверяется: if (visited.containsKey(obj)) return (T) visited.get(obj);.
  2. До рекурсивного заполнения полей копия регистрируется в мапе: visited.put(obj, copy);.
  3. Поля копии заполняются рекурсивными вызовами deepCopy(field, visited). Если поле ссылается обратно на родительский объект, метод вернёт уже зарегистрированную копию, корректно замкнув цикл ссылок.

2. Почему метод Object.clone() выполняет именно поверхностное побитовое копирование, и почему он обходит вызов конструкторов?

Ответ: Object.clone() — это нативный метод JVM (JVM_Clone), реализованный на C++. Он работает на уровне низкоуровневого копирования блоков памяти (аналог системного вызова memcpy):

  • Выделяется блок памяти того же размера в куче, и в него побайтово копируется содержимое всех полей исходного объекта (значения примитивов и 32/64-битные адреса ссылок на другие объекты).
  • Конструкторы класса не вызываются вовсе, так как память инициализируется прямым копированием битов. По этой причине вложенные ссылочные типы получают точные копии указателей (адресов памяти), что и приводит к поверхностному копированию (Shallow Copy).

3. В чём разница в производительности между глубоким копированием через Java Serialization и конструктором копирования?

Ответ: Разница в производительности достигает двух порядков (в 50–100 раз) в пользу конструктора копирования:

  • Java Serialization: сопряжена с колоссальным оверхедом: инспекция метаданных классов через JVM reflection, запись служебных заголовков потока, создание промежуточных байтовых буферов (ByteArrayOutputStream), перехват дескрипторов классов и непрерывная аллокация объектов в памяти.
  • Конструктор копирования: представляет собой прямой компилируемый Java-код. JIT-компилятор C2 оптимизирует такие вызовы, полностью инлайнит создание объектов и обращается к полям напрямую по фиксированным смещениям в заголовке объекта без рефлексии и сериализации.

4. Достаточно ли Shallow Copy для коллекции, если элементами являются объекты java.lang.String или java.math.BigDecimal?

Ответ: Да, абсолютно достаточно! Поскольку String, BigDecimal, UUID и примитивные обёртки (Integer, Long) являются глубоко неизменяемыми (Immutable), их внутреннее состояние невозможно изменить никаким легальным методом API. Поэтому разделение ссылок на одни и те же экземпляры между оригиналом и копией полностью безопасно: никто не сможет модифицировать объект по ссылке. Создание Deep Copy для таких объектов было бы бессмысленной тратой памяти и процессорного времени.


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

Сводная таблица сравнения

| Критерий | Shallow Copy (Поверхностная) | Deep Copy (Глубокая) | | :— | :— | :— | | Что копируется | Только ссылки на вложенные объекты | Полные дубликаты всех объектов | | Изоляция оригинала | ❌ Частичная (вложенные объекты общие) | ✅ Полная изоляция | | Время выполнения | $O(N)$ для контейнера | $O(V + E)$ рекурсивный обход графа | | Когда применять | Элементы неизменяемы (String, примитивы) | Элементы мутабельны | | Риск StackOverflowError| Исключён | Возможен при циклических ссылках |

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

  • ❌ «Метод Object.clone() по умолчанию выполняет глубокое копирование» — он выполняет только побитовое поверхностное копирование.
  • ❌ «Для коллекций строк всегда нужно делать Deep Copy» — строки иммутабельны, Shallow Copy абсолютно безопасна и оптимальна.
  • ❌ «Сериализация через JSON/Java IO — отличный способ глубокого копирования в продакшене» — это самый медленный способ из возможных.
  • ❌ «Конструктор копирования легко рекурсивно копирует любые структуры» — при наличии циклических ссылок он упадёт с StackOverflowError без карты visited.

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