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

Чому незмінні об'єкти потокобезпечні

Ім'ютабельні (незмінні) об'єкти є потокобезпечними (Thread-Safe) за своєю природою, тому що в багатопотоковості стан гонитви (Data Race / Race Condition) може виникнути лише за...


🟢 Junior Level

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

Ім’ютабельні (незмінні) об’єкти є потокобезпечними (Thread-Safe) за своєю природою, тому що в багатопотоковості стан гонитви (Data Race / Race Condition) може виникнути лише за одночасного виконання двох умов:

  1. Хоча б два потоки одночасно звертаються до однієї і тієї ж комірки пам’яті.
  2. Хоча б один із потоків виконує операцію запису (мутації).

Оскільки стан незмінного об’єкта після конструювання доступний виключно для читання:

  • Потоки фізично не можуть перезаписати дані один одного.
  • Для читання полів не потрібні блокування, ключові слова synchronized, ReentrantLock або volatile.
  • Будь-яка кількість потоків (10, 100, 10 000) може одночасно і паралельно читати стан об’єкта без блокувань та черг очікування.

Наочний приклад: Конфігурація сервісу

// Повністю потокобезпечний об'єкт
public final class ServiceEndpoint {
    private final String host;
    private final int port;

    public ServiceEndpoint(String host, int port) {
        this.host = host;
        this.port = port;
    }

    public String getHost() { return host; }
    public int getPort() { return port; }
}

// Використання в пулі потоків
public class Worker implements Runnable {
    private final ServiceEndpoint endpoint;

    public Worker(ServiceEndpoint endpoint) {
        this.endpoint = endpoint; // Десятки потоків безпечно читають один екземпляр
    }

    @Override
    public void run() {
        // Жодних блокувань: читання завжди повертає незмінні дані
        System.out.println("Connecting to " + endpoint.getHost() + ":" + endpoint.getPort());
    }
}

🟡 Middle Level

Небезпека частково сконструйованих об’єктів (Instruction Reordering)

Самого лише браку сетерів недостатньо для гарантії потокобезпеки. У сучасних багатоядерних архітектурах компілятор і процесор з метою оптимізації виконують перевпорядкування інструкцій (Out-of-Order Execution).

Якщо поля класу не оголошені як final, створення об’єкта процесором може відбуватися в такому порядку:

  1. Виділення пам’яті під об’єкт у купі.
  2. Присвоєння посилання на сирий об’єкт змінній sharedRef (публікація посилання).
  3. Ініціалізація полів значеннями з конструктора (host = "localhost", port = 8080).
[Потік 1: Творець]
  1. Алокація пам'яті ──► 2. Публікація sharedRef = ref ──► 3. Запис host = "localhost"
                                      │
[Потік 2: Читач]                     ▼
                        Бачить sharedRef != null,
                        але sharedRef.host == null! (Частково ініціалізований об'єкт)

У результаті потік-читач може перехопити посилання на об’єкт, у якого поля містять значення за замовчуванням (null або 0), що призводить до важковловних збоїв у продакшені.


Гарантія Safe Publication: Семантика final-полів (JMM §17.5)

Специфікація Java Memory Model вирішує цю фундаментальну проблему через спеціальне правило для final-полів:

  • Дія Freeze (Заморожування): у момент завершення конструктора середовище виконання JVM «заморожує» значення всіх final-полів.
  • Заборона Reordering: компілятор і процесор не мають права переставляти операцію публікації посилання на об’єкт раніше завершення ініціалізації його final-полів.
  • Безпечна публікація (Safe Publication): будь-який потік, який отримав посилання на коректно сконструйований об’єкт, гарантовано побачить коректні значення всіх його final-полів, навіть якщо посилання було передано через звичайну (не-volatile) змінну без синхронізації!

Різниця між незмінним об’єктом і незмінним посиланням

Дуже важливо розрізняти два поняття:

  1. Ім’ютабельний об’єкт: внутрішній стан об’єкта не можна змінити після створення.
  2. Мутабельне посилання: змінна, яка вказує на об’єкт, може бути перезаписана.
public class ClusterManager {
    // Об'єкт ServiceEndpoint незмінний, але саме посилання mutable!
    // Без volatile зміна посилання потоком 1 не буде видною потоку 2!
    private volatile ServiceEndpoint currentEndpoint;

    public void updateEndpoint(String host, int port) {
        this.currentEndpoint = new ServiceEndpoint(host, port); // Атомарна заміна
    }

    public ServiceEndpoint getEndpoint() {
        return currentEndpoint;
    }
}

🔴 Senior Level

Апаратні бар’єри пам’яті: StoreStore на x86 vs ARM

Як HotSpot JVM реалізує Freeze Action на рівні машинного коду:

  • x86 / x86-64: Модель пам’яті x86 має апаратну властивість TSO (Total Store Order): процесор апаратно гарантує, що операції запису в пам’ять не перевпорядковуються з іншими операціями запису (StoreStore дотримується апаратно). Тому JIT-компілятор на x86 не вставляє явних інструкцій шини пам’яті наприкінці конструктора, обмежуючись бар’єром компілятора (Compiler Barrier), що забороняє перестановку інструкцій на етапі генерації асемблерного коду.
  • ARM64 / Apple Silicon: Має слабку модель пам’яті (Weakly Ordered). Для дотримання JMM §17.5 JIT-компілятор C2 зобов’язаний згенерувати інструкцію бар’єра пам’яті DMB ISHST (Data Memory Barrier, Inner Shareable Store), що скидає буфери запису процесора перед публікацією посилання.

Апаратний протокол когерентності кешів (MESI)

У багатоядерних системах робота зі спільною пам’яттю керується протоколом когерентності кешів MESI (Modified, Exclusive, Shared, Invalid):

  • Коли потік на Ядрі 1 змінює мутабельний об’єкт, рядок кешу переходить у стан Modified (M). Ядро 1 зобов’язане розіслати широкомовне повідомлення RFO (Request For Ownership) іншим ядрам, переводячи їхні рядки кешу у стан Invalid (I).
  • Якщо ядро 2 намагається прочитати ці дані, виникає затримка на очікування інвалідації та перезавантаження кеш-лінії з L3 або RAM (Cache Bouncing).
  • Незмінні об’єкти: рядки кешу L1/L2 на всіх ядрах процесора стабільно перебувають у стані Shared (S). Читання відбувається паралельно на максимальній частоті ядер процесора з нульовим трафіком по шині між’ядерної взаємодії (QPI / Ultra Path Interconnect).

Критичний антипатерн: Витік посилання this (Constructor Escape)

Гарантія безпечної публікації JMM §17.5 анулюється, якщо посилання this «витікає» з конструктора до його повного завершення:

public class LeakingTask {
    private final int taskId;

    public LeakingTask(int id, TaskRegistry registry) {
        this.taskId = id;
        // ПОМИЛКА: витік 'this' до завершення конструктора!
        registry.register(this); 
    }

    public int getTaskId() { return taskId; }
}

Оскільки конструктор ще не завершено, дію Freeze Action не виконано. Робочий потік із registry може негайно викликати task.getTaskId() і прочитати 0 замість переданого id!


Lock-Free керування станом: Snapshot + AtomicReference

Незмінні об’єкти слугують основою для неблокуючих високонавантажених архітектур:

public class RoutingTableManager {
    // Незмінний знімок стану
    public record RoutingTable(Map<String, String> routes, long version) {}

    private final AtomicReference<RoutingTable> state = 
        new AtomicReference<>(new RoutingTable(Map.of(), 0L));

    public void addRoute(String path, String target) {
        // CAS-цикл для атомарного оновлення без synchronized та ReentrantLock
        state.updateAndGet(old -> {
            Map<String, String> newRoutes = new HashMap<>(old.routes());
            newRoutes.put(path, target);
            return new RoutingTable(Collections.unmodifiableMap(newRoutes), old.version() + 1);
        });
    }

    public String route(String path) {
        // Читання за 1 операцію без блокувань
        return state.get().routes().get(path);
    }
}

4 Tricky Questions

1. Якщо всі поля класу примітивні й оголошені без final, у класу немає сетерів та інших методів модифікації, чи може читаючий потік побачити неініціалізовані поля на процесорі ARM?

Відповідь: Так, абсолютно точно може! Без модифікатора final компілятор і процесор зі слабкою моделлю пам’яті (ARM64, PowerPC) не зобов’язані вставляти бар’єр StoreStore перед публікацією посилання на об’єкт. Процесор може спочатку записати посилання на об’єкт у спільну змінну (наприклад, статичне поле), а потім із затримкою скинути значення полів із буфера запису (Store Buffer) у кеш. Інший потік, що працює на сусідньому ядрі, прочитає посилання, звернеться до полів і побачить їхні значення за замовчуванням (0, 0.0, false). Відсутність сетерів захищає від змін після створення, але без final не гарантує безпечної публікації!


2. Чи може використання незмінного об’єкта призвести до гонитви даних, якщо посилання на нього передається через звичайну не-volatile змінну?

Відповідь: Сам незмінний об’єкт гонитви даних не викличе: завдяки JMM §17.5 поля об’єкта будуть гарантовано прочитані коректно. Проте гонитва даних (Data Race) виникне на рівні самого посилання:

  • Якщо потік А створює новий незмінний об’єкт і записує посилання у звичайне поле currentConfig = new Config(), а потік Б читає currentConfig, то потік Б через кешування процесора може годинами не бачити оновлене посилання і продовжувати працювати зі старим екземпляром конфігурації.
  • Щоб оновлення посилання стало миттєво видимим усім потокам, посилання зобов’язане бути оголошене як volatile або керуватися через AtomicReference.

3. Як протокол когерентності кешів MESI впливає на продуктивність паралельного читання незмінних об’єктів порівняно з читанням мутабельних об’єктів?

Відповідь: У протоколі MESI рядок кешу, що містить дані незмінного об’єкта, переходить у стан Shared (S) на всіх ядрах процесора. При паралельному читанні ядра не генерують повідомлень інвалідації через між’ядерну шину. Якщо ж об’єкт мутабельний, будь-який запис одним ядром переводить його кеш-лінію у стан Modified (M) і розсилає сигнал Invalidate усім іншим ядрам. Коли інші ядра намагаються прочитати дані, вони стикаються з кеш-промахом (Cache Miss) і змушені чекати пересилання даних із чужого кешу або пам’яті (Cache Bouncing). Крім того, якщо в одній 64-байтовій кеш-лінії з читаним полем розташоване поле, що безперервно перезаписується іншим потоком, виникає катастрофічне падіння швидкості через хибне розділення кеш-ліній (False Sharing). Для незмінних об’єктів False Sharing при читанні виключений.


4. У чому різниця за гарантіями JMM між ініціалізацією final-поля в тілі конструктора та ініціалізацією через допоміжний метод, що викликається з конструктора?

Відповідь: Специфікація JMM §17.5 надає гарантію безпечної публікації для final-полів незалежно від того, чи присвоюється значення прямо в конструкторі, чи обчислюється через приватний допоміжний метод, за однієї суворої умови: метод не повинен допускати витоку this у зовнішні структури до завершення конструктора. Проте якщо всередині конструктора викликається перевизначуваний (non-final) метод підкласу, цей метод підкласу може звернутися до final-полів підкласу, які на цей момент ще фізично не проініціалізовані (їхній конструктор ще не почав роботу). У такому разі потік побачить значення за замовчуванням 0 або null, незважаючи на наявність модифікатора final.


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

Чому незмінність надає Thread Safety

  1. Відсутність мутацій: гонитва даних неможлива, якщо немає операцій запису.
  2. Safe Publication (JMM §17.5): final-поля захищені дією Freeze Action; читач ніколи не побачить напівініціалізований об’єкт.
  3. Апаратні бар’єри: HotSpot вставляє StoreStore бар’єри (DMB ISHST на ARM) перед публікацією посилання.
  4. MESI-оптимальність: кеш-лінії процесора залишаються в стані Shared, виключаючи інвалідацію кешів та False Sharing.

Розмежування відповідальності

| Компонент | Вимога до безпеки | Що використовувати | | :— | :— | :— | | Сам об’єкт | Повинен бути незмінним | final class, усі поля private final, defensive copy | | Публікація посилання | Атомарна видимість зміни посилання між потоками | volatile посилання або AtomicReference | | Складені оновлення | Неподільність послідовності операцій | CAS через AtomicReference.updateAndGet() |

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

  • ❌ «Якщо у класу немає сетерів, то він автоматично потокобезпечний і ключове слово final не потрібне» — груба помилка: без final процесор може перевпорядкувати запис полів і опублікувати порожній об’єкт.
  • ❌ «Незмінні об’єкти не вимагають синхронізації, тому посилання на них у класі можна робити звичайним (не volatile) при регулярних оновленнях» — посилання зобов’язане бути volatile, інакше інші потоки не побачать оновлення.
  • ❌ «У конструкторі незмінного класу можна відразу зареєструвати об’єкт як слухач подій» — це витік this, що руйнує гарантії безпечної публікації.

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