JavaRush /Курси /JAVA 25 SELF /Java Memory Model (JMM)

Java Memory Model (JMM)

JAVA 25 SELF
Рівень 58 , Лекція 4
Відкрита

1. Знайомство з Java Memory Model (JMM)

Проблема видимості та впорядкування

В однопотоковій програмі все просто: записали значення у змінну — і відразу можете його прочитати. У багатопоточній реальності це працює інакше. Процесори кешують значення, компілятор і JVM іноді змінюють порядок інструкцій, і один потік може бачити «старе» значення, навіть якщо інший потік щойно його змінив.

Java Memory Model (JMM) якраз описує, як потоки спілкуються через пам’ять: коли зміни одного потоку стають видимими іншим і в якому порядку відбуваються операції. Якщо цього не враховувати, програма може поводитися непередбачувано, хоча на перший погляд усе виглядає нормально.

Розуміння JMM допомагає збагнути, чому іноді потік не бачить свіжі дані, як правильно використовувати volatile, synchronized і атомарні класи, і чому баги в багатопоточному коді можуть проявлятися тільки у продакшені. Простіше кажучи, JMM — це правила роботи з пам’яттю, і якщо їх ігнорувати, навіть найакуратніший код може зіграти проти вас.

Аналогія

Уявіть, що у вас є двоє людей (потоків), які пишуть і читають записки (змінні) на дошці (пам’яті). Іноді один пише, а інший ще не бачить новий запис — бо дивиться на свою копію дошки (кеш). JMM визначає, коли і як ці записки стають видимими для всіх.

2. happens-before: фундамент JMM

Що таке happens-before?

happens-before — це відношення між двома діями в програмі: якщо дія A happens-before дією B, то всі зміни, зроблені в A, гарантовано видимі у B.

Важливо: happens-before — це не просто «сталося раніше», а саме «гарантовано видно».

Основні правила happens-before

1. У межах одного потоку

Усе, що відбувається в одному потоці, упорядковано: якщо ви записали у змінну, а потім її прочитали — ви побачите свою зміну.

2. Синхронізовані блоки/монітори

Усе, що відбувається до виходу із синхронізованого блоку (synchronized), стає видимим для потоку, який потім увійде до цього блоку.

synchronized(lock) {
    sharedVar = 42; // запис
}
// ...
synchronized(lock) {
    System.out.println(sharedVar); // гарантовано побачимо 42
}

3. volatile-запис/читання

Запис у поле volatile happens-before будь-якому подальшому читанню цього поля іншим потоком.

volatile boolean ready = false;

// Потік 1
data = 123;
ready = true; // volatile write

// Потік 2
if (ready) { // volatile read
    System.out.println(data); // гарантовано побачимо data = 123
}

4. Запуск і завершення потоків

  • Виклик Thread.start() happens-before початком роботи потоку.
  • Завершення потоку happens-before поверненням із Thread.join().

5. Завершення завдання в Executor

Якщо ви надіслали завдання в Executor і дочекалися його завершення (Future.get()), усі зміни, зроблені в завданні, видимі після get().

6. Фінальні поля

Ініціалізація полів із модифікатором final у конструкторі happens-before публікації посилання на об’єкт. Це важливо для незмінних об’єктів.

3. Безпечна публікація об’єктів

Проблема: «несвіжий» об’єкт

Якщо один потік створює об’єкт і передає його іншому потоку без синхронізації, інший потік може побачити «сирі» значення полів (наприклад, неініціалізовані або старі значення).

class Holder {
    int value;
    Holder() { value = 42; }
}

Holder holder = null;

// Потік 1
holder = new Holder(); // створюємо об’єкт

// Потік 2
if (holder != null) {
    System.out.println(holder.value); // може побачити 0, а не 42!
}

Як правильно публікувати об’єкти?

1. Через final-поля

Якщо всі поля об’єкта final і ініціалізуються в конструкторі, об’єкт можна безпечно публікувати без додаткової синхронізації.

class SafeHolder {
    final int value;
    SafeHolder() { value = 42; }
}

2. Через volatile-посилання

Якщо посилання на об’єкт оголошено як volatile, то після присвоєння об’єкт гарантовано видимий іншим потокам.

volatile Holder holder;

// Потік 1
holder = new Holder();

// Потік 2
if (holder != null) {
    System.out.println(holder.value); // гарантовано побачимо 42
}

3. Через однопотокову ініціалізацію до публікації

Якщо об’єкт створюється й ініціалізується до того, як посилання на нього стає доступним іншим потокам, усе безпечно.

Holder holder = new Holder(); // тільки в одному потоці
// ... потім holder стає доступним іншим потокам

4. Через блокування

Якщо об’єкт створюється всередині синхронізованого блоку, а посилання на нього читається тільки всередині такого ж блоку, усе безпечно.

Holder holder;

synchronized(lock) {
    if (holder == null) {
        holder = new Holder();
    }
}

// ... в іншому потоці
synchronized(lock) {
    if (holder != null) {
        // безпечно
    }
}

4. Double-checked locking і volatile

Що таке double-checked locking?

Це патерн для лінивої ініціалізації singleton-об’єкта:

class Singleton {
    private static Singleton instance;

    public static Singleton getInstance() {
        if (instance == null) { // 1-ша перевірка
            synchronized (Singleton.class) {
                if (instance == null) { // 2-га перевірка
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

Проблема: Без volatile-посилання на instance цей код працює некоректно! Потік може побачити не повністю ініціалізований об’єкт.

Чому без volatile погано?

JVM може «переставити» інструкції так, що посилання на об’єкт присвоїться до завершення конструктора. Інший потік побачить неініціалізований об’єкт.

Як правильно?

Оголосіть instance як volatile:

private static volatile Singleton instance;

Тепер double-checked locking працює коректно: volatile гарантує відношення happens-before між записом і читанням посилання.

Альтернатива: статична ініціалізація

Найпростіший і безпечний спосіб зробити singleton — використовувати статичну ініціалізацію:

class Singleton {
    private static final Singleton INSTANCE = new Singleton();
    public static Singleton getInstance() { return INSTANCE; }
}

Тут JVM сама гарантує коректну ініціалізацію.

5. VarHandle: сучасний низькорівневий доступ

Що таке VarHandle?

VarHandle — це сучасний API (Java 9+), який дозволяє працювати зі змінними на низькому рівні: читати, записувати, виконувати атомарні операції, керувати видимістю та порядком інструкцій.

Навіщо потрібен VarHandle, якщо є атомарні класи?
VarHandle дозволяє працювати з будь-якими полями (не лише з int/long/Reference).
— Дозволяє явно обирати семантику доступу: volatile, acquire/release, opaque.
— Використовується для реалізації високопродуктивних структур даних.

Семантики доступу

  • Volatile: повна гарантія happens-before (як у volatile-поля).
  • Acquire/Release: слабша гарантія, але швидше (використовується для lock-free структур).
  • Opaque: мінімальна гарантія видимості, але максимальна продуктивність.

Приклад використання VarHandle

import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;

class Counter {
    int value;
    static final VarHandle VALUE_HANDLE;

    static {
        try {
            VALUE_HANDLE = MethodHandles.lookup().findVarHandle(Counter.class, "value", int.class);
        } catch (Exception e) {
            throw new Error(e);
        }
    }
}

Counter counter = new Counter();
Counter.VALUE_HANDLE.setVolatile(counter, 42);
int v = (int) Counter.VALUE_HANDLE.getVolatile(counter);

Коли використовувати VarHandle?

  • Для реалізації власних lock-free структур даних.
  • Коли потрібна максимальна продуктивність і контроль над порядком інструкцій.
  • У звичайних застосунках найчастіше достатньо атомарних класів і synchronized.

6. Хибний поділ (false sharing) і вирівнювання кешу

False sharing — це ситуація, коли два потоки працюють із різними змінними, але ці змінні лежать в одній кеш-лінії процесора. У результаті потоки заважають одне одному, тому що зміна однієї змінної призводить до інвалідації кешу для іншої.

Аналогія: Двоє людей сидять за одним столом (кеш-лінія), але кожен пише на своїй половині. Якщо один щось змінює, іншому доводиться «перечитувати» весь аркуш.

Чому це погано?

  • Продуктивність різко падає: процесори витрачають час на синхронізацію кешів.
  • Особливо критично для «гарячих» змінних, які часто змінюються різними потоками.

Як уникнути?

Розносіть «гарячі» поля у різних об’єктах або використовуйте спеціальні анотації/структури для вирівнювання (наприклад, @Contended). У сучасних JVM можна увімкнути опцію -XX:-RestrictContended і використовувати @sun.misc.Contended (Java 8+) для вирівнювання полів.

Приклад:

@sun.misc.Contended
public volatile long value1;

@sun.misc.Contended
public volatile long value2;

NB: Анотація @Contended не входить до стандартного API, але використовується в JDK для оптимізації атомарних класів.

7. Практика: виправляємо singleton і мікробенчмарки JMH

Виправляємо некоректний singleton

Погано (без volatile):

class BrokenSingleton {
    private static BrokenSingleton instance;
    public static BrokenSingleton getInstance() {
        if (instance == null) {
            synchronized (BrokenSingleton.class) {
                if (instance == null) {
                    instance = new BrokenSingleton();
                }
            }
        }
        return instance;
    }
}

Добре (з volatile):

class SafeSingleton {
    private static volatile SafeSingleton instance;
    public static SafeSingleton getInstance() {
        if (instance == null) {
            synchronized (SafeSingleton.class) {
                if (instance == null) {
                    instance = new SafeSingleton();
                }
            }
        }
        return instance;
    }
}

Найкраще — статична ініціалізація:

class StaticSingleton {
    private static final StaticSingleton INSTANCE = new StaticSingleton();
    public static StaticSingleton getInstance() { return INSTANCE; }
}

Мікробенчмарки JMH: видимість і атомарність

Увага: JMH — це спеціальний фреймворк для мікробенчмарків у Java. Не намагайтеся робити висновки про продуктивність без JMH — результати можуть бути оманливими!

Приклад: перевірка видимості volatile

public class VolatileVisibility {
    volatile boolean flag = false;

    public void writer() {
        flag = true;
    }

    public void reader() {
        while (!flag) {
            // крутимося, поки не побачимо true
        }
        // побачили зміну
    }
}

Приклад: неатомарність volatile

public class VolatileNotAtomic {
    volatile int counter = 0;

    public void increment() {
        counter++; // неатомарно!
    }
}

Попри volatile, за одночасного інкременту з кількох потоків підсумкове значення буде меншим за очікуване (операція counter++ розпадається на читання, обчислення і запис).

8. Типові помилки під час роботи з JMM, volatile і публікацією

Помилка № 1: Очікування атомарності від volatile.
volatile гарантує лише видимість змін, але не атомарність операцій. Операція counter++ не стає атомарною, якщо змінна volatile.

Помилка № 2: Публікація об’єктів без синхронізації.
Якщо ви створюєте об’єкт в одному потоці й віддаєте його іншому без volatile, synchronized чи final-полів — інший потік може побачити «сирі» значення.

Помилка № 3: Double-checked locking без volatile.
Без volatile-посилання на singleton можна отримати неініціалізований об’єкт в іншому потоці.

Помилка № 4: Використання старих блокувань із віртуальними потоками.
Деякі старі механізми синхронізації (native monitor) можуть заважати JVM ефективно керувати віртуальними потоками.

Помилка № 5: Ігнорування хибного поділу.
Якщо «гарячі» змінні лежать поруч у пам’яті, потоки заважатимуть одне одному через кеш-лінії.

Помилка № 6: Робити висновки про продуктивність без JMH.
Мікробенчмарки без JMH часто дають хибні результати через оптимізації JVM і кеш процесора.

1
Опитування
Занурюємось у багатопоточність, рівень 58, лекція 4
Недоступний
Занурюємось у багатопоточність
Занурюємось у багатопоточність
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ