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

Модель пам'яті Java (Java Memory Model, JMM) описує поведінку потоків у середовищі виконання Java. Модель пам'яті — частина семантики мови Java, і вона визначає, на що може і на що не може розраховувати програміст, який розробляє ПЗ не для конкретної Java-машини, а для Java загалом.

Вихідна модель пам'яті Java (до якої, зокрема, належить “потоколокальна пам'ять”), розроблена в 1995 році, вважається невдалою: неможливо провести багато оптимізації без втрати гарантії безпеки коду. Зокрема, є кілька варіантів написати багатопотокового “одинака”:

  • або кожен акт доступу до одинака (навіть коли об'єкт давно створено і нічого вже не може змінитися) викликатиме міжпоточне блокування;
  • або за певного збігу обставин система видасть недобудованого одинака;
  • або за певного збігу обставин система створить два одинака;
  • або конструкція залежатиме від особливостей поведінки тієї чи іншої машини.

Тому механізм роботи пам'яті було перероблено. У 2005 році з виходом Java 5 було презентовано новий підхід, який додатково покращили з виходом Java 14.

В основі нової моделі лежать три правила:

Правило № 1: однопотокові програми виконуються псевдопослідовно. Це означає: насправді процесор може виконувати кілька операцій за такт, заодно змінивши їхній порядок, проте всі залежності за даними залишаються, отже поведінка не відрізняється від послідовної.

Правило № 2: нема значень, що казна-звідки взялися. Читання будь-якої змінної (крім не-volatile long та double, для яких це правило може не виконуватися) видаватиме або значення за замовчуванням (нуль), або щось, що записане туди іншою командою.

І правило № 3: інші події виконуються послідовно, якщо пов'язані відношенням суворого часткового порядку "виконується раніше" (happens before).

2. Happens before

Леслі Лемпорт придумав поняття Happens before. Це відношення суворого часткового порядку, яке ввели між атомарними командами (++ і -- не атомарні) і не означає "фізично раніше".

Воно говорить про те, що друга команда буде "в курсі" змін, проведених першою.

Happens before

Наприклад, одне виконується перед іншим для таких операцій:

Синхронізація та монітори:

  • захоплення монітора (метод lock, початок synchronized) і все, що відбувається в тому ж потоці після нього;
  • повернення монітора (метод unlock, кінець synchronized) і все, що відбувається в тому ж потоці перед ним;
  • повернення монітора та подальше захоплення іншим потоком.

Запис та читання:

  • запис до будь-якої змінної та подальше читання її ж в одному потоці;
  • усе, що в тому ж потоці перед записом в volatile-змінну, і сам запис. volatile-читання і все, що в тому ж потоці після нього;
  • запис до volatile-змінної і подальше зчитування її ж. Volatile-запис взаємодіє з пам'яттю так само, як і повернення монітора, а читання як захоплення. Виходить, що якщо один потік записав до volatile-змінної, а другий виявив це, все, що передує запису, виконується перед тим, що йде після читання; дивись малюнок.

Обслуговування об'єкту:

  • статична ініціалізація та будь-які дії з будь-якими екземплярами об'єктів;
  • запис до final-поля у конструкторі і все, що після конструктора. Як виняток – співвідношення happens-before не поєднується транзитивно з іншими правилами і тому може викликати міжпотокову гонку;
  • будь-яка робота з об'єктом та finalize().

Обслуговування потоку:

  • запуск потоку та будь-який код у потоці;
  • занулення змінних, що відносяться до потоку, та будь-який код у потоці;
  • код у потоці та join(); код у потоці та isAlive() == false;
  • interrupt() потоку та виявлення факту зупинки.

3. Нюанси роботи Happens before

Звільнення (releasing) монітора happens-before відбувається перед отриманням (acquiring) того ж монітора. Варто звернути увагу, що саме звільнення, а не вихід, тобто за безпеку при використанні wait можна не турбуватися.

Розглянемо, як це знання допоможе виправити наш приклад. У такому випадку все дуже просто: достатньо прибрати зовнішню перевірку та залишити синхронізацію як є. Тепер другий потік гарантовано побачить усі зміни, тому що він отримає монітор лише після того, як інший його потік відпустить. А оскільки він його не відпустить, доки все не проініціалізує, ми побачимо всі зміни відразу, а не окремо:


public class Keeper {
    private Data data = null;

    public Data getData() {
        synchronized(this) {
            if(data == null) {
                data = new Data();
            }
        }

        return data;
    }
}

Запис до volatile змінної happens-before читання з тієї ж змінної. Та зміна, яку ми внесли, звісно, виправляє некоректність, але повертає того, хто написав початковий код, туди, звідки він прийшов — до блокування кожного разу. Врятувати може ключове слово volatile. Фактично розглянуте твердження означає, що під час читання всього, що оголошено volatile, ми завжди отримуватимемо актуальне значення.

Крім того, як ми казали раніше, для volatile полів запис завжди (в тому числі long та double) є атомарною операцією. Ще один важливий момент: якщо у тебе є volatile сутність, що має посилання на інші сутності (наприклад, масив, List або якийсь ще клас), то завжди "свіжою" буде лише посилання на саму сутність, але не на все, що до неї входить.

Отже, повернемося до наших Double-locking баранів. З використанням volatile виправити ситуацію можна так:


public class Keeper {
    private volatile Data data = null;

    public Data getData() {
        if(data == null) {
            synchronized(this) {
                if(data == null) {
                    data = new Data();
                }
            }
        }
        return data;
    }
}

Тут у нас, як і раніше, є блокування, але лише у випадку, якщо data == null. Інші випадки ми відсіюємо за допомогою volatile read. Коректність забезпечується тим, що volatile store happens-before volatile read і всі операції, які відбуваються в конструкторі видно тому, хто читає значення поля.