Посібник з мінливого ключового слова на Java

1. Огляд

За відсутності необхідних синхронізацій компілятор, час виконання або процесори можуть застосовувати всілякі оптимізації. Незважаючи на те, що ці оптимізації корисні більшу частину часу, іноді вони можуть спричиняти тонкі проблеми.

Кешування та впорядкування - це одна з тих оптимізацій, яка може здивувати нас в одночасному контексті. Java і JVM надають багато способів контролювати порядок пам'яті, і ключове слово volatile є одним із них.

У цій статті ми зупинимось на цій фундаментальній, але часто неправильно зрозумілій концепції мови Java - мінливому ключовому слові . Спочатку ми почнемо з невеликої довідки про те, як працює основна архітектура комп’ютера, а потім ознайомимося з порядком пам'яті в Java.

2. Спільна багатопроцесорна архітектура

Процесори відповідають за виконання інструкцій програми. Тому їм потрібно отримати як операційні вказівки, так і необхідні дані з оперативної пам'яті.

Оскільки центральні процесори здатні виконувати значну кількість інструкцій в секунду, вибір з оперативної пам'яті не є для них ідеальним. Щоб покращити цю ситуацію, процесори використовують такі трюки, як «Виконання замовлення», «Прогнозування філій», «Спекулятивне виконання» і, звичайно, кешування.

Тут вступає в силу наступна ієрархія пам'яті:

Оскільки різні ядра виконують більше інструкцій і обробляють більше даних, вони заповнюють свої кеші більш відповідними даними та інструкціями. Це покращить загальну ефективність за рахунок введення проблем з когерентністю кешу .

Простіше кажучи, нам слід двічі подумати про те, що відбувається, коли один потік оновлює кешоване значення.

3. Коли використовувати летючі речовини

Для того, щоб детальніше розширити цілісність кеш-пам’яті, давайте запозичимо один приклад із книги Java Concurrency на практиці:

public class TaskRunner { private static int number; private static boolean ready; private static class Reader extends Thread { @Override public void run() { while (!ready) { Thread.yield(); } System.out.println(number); } } public static void main(String[] args) { new Reader().start(); number = 42; ready = true; } }

Клас TaskRunner підтримує дві прості змінні. У своєму основному методі він створює ще один потік, який обертається до готової змінної, доки вона хибна. Коли змінна стає істинною, потік просто надрукує числову змінну.

Багато хто може очікувати, що ця програма просто надрукує 42 після короткої затримки. Однак насправді затримка може бути значно більшою. Це може навіть зависнути назавжди, а то і надрукувати нуль!

Причиною цих аномалій є відсутність належної видимості пам'яті та впорядкування . Оцінимо їх детальніше.

3.1. Видимість пам'яті

У цьому простому прикладі ми маємо два потоки додатків: основний і потік зчитувача. Давайте уявимо сценарій, в якому ОС планує ці потоки на двох різних ядрах процесора, де:

  • Основний потік має свою копію готових та числових змінних у своєму кеші ядра
  • Потік читача також закінчується своїми копіями
  • Основний потік оновлює кешовані значення

У більшості сучасних процесорів запити на запис не застосовуються відразу після їх надсилання. Насправді процесори, як правило, ставлять ці записи в чергу у спеціальний буфер запису . Через деякий час вони одночасно застосують ці записи до основної пам'яті.

Попри все сказане, коли основний потік оновлює кількість і готові змінні, немає гарантії щодо того, що може побачити потік читача. Іншими словами, потік зчитувача може побачити оновлене значення відразу, або з деякою затримкою, або взагалі ніколи!

Ця видимість пам'яті може спричинити проблеми з оживленням у програмах, які покладаються на видимість.

3.2. Переупорядкування

Що ще гірше, потік читача може бачити ці записи в будь-якому порядку, крім фактичного порядку програми . Наприклад, оскільки ми вперше оновили числову змінну:

public static void main(String[] args) { new Reader().start(); number = 42; ready = true; }

Ми можемо очікувати, що зчитувач роздрукує 42. Однак насправді можна побачити нуль як друковану величину!

Переупорядкування - це техніка оптимізації для підвищення продуктивності. Цікаво, що цю оптимізацію можуть застосовувати різні компоненти:

  • Процесор може змити свій буфер запису в будь-якому порядку, відмінному від порядку програми
  • Процесор може застосовувати техніку виконання, що не працює в порядку
  • Компілятор JIT може оптимізувати за допомогою переупорядкування

3.3. мінливий порядок пам'яті

Щоб гарантувати, що оновлення змінних поширюються передбачувано на інші потоки, ми повинні застосувати мінливий модифікатор до цих змінних:

public class TaskRunner { private volatile static int number; private volatile static boolean ready; // same as before }

Таким чином, ми спілкуємося з виконанням і процесор не перезаказа ніяких інструкцій з участю летких змінним. Крім того, процесори розуміють, що їм слід негайно очистити будь-які оновлення цих змінних.

4. нестабільна та потокова синхронізація

Для багатопотокових додатків нам потрібно забезпечити кілька правил послідовної поведінки:

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

синхронізовані методи та блоки забезпечують обидва вищезазначені властивості за рахунок продуктивності програми.

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

5. Буває до замовлення

The memory visibility effects of volatile variables extend beyond the volatile variables themselves.

To make matters more concrete, let's suppose thread A writes to a volatile variable, and then thread B reads the same volatile variable. In such cases, the values that were visible to A before writing the volatile variable will be visible to B after reading the volatile variable:

Technically speaking, any write to a volatile field happens before every subsequent read of the same field. This is the volatile variable rule of the Java Memory Model (JMM).

5.1. Piggybacking

Because of the strength of the happens-before memory ordering, sometimes we can piggyback on the visibility properties of another volatile variable. For instance, in our particular example, we just need to mark the ready variable as volatile:

public class TaskRunner { private static int number; // not volatile private volatile static boolean ready; // same as before }

Anything prior to writing true to the ready variable is visible to anything after reading the ready variable. Therefore, the number variable piggybacks on the memory visibility enforced by the ready variable. Put simply, even though it's not a volatile variable, it is exhibiting a volatile behavior.

Використовуючи цю семантику, ми можемо визначити лише кілька змінних у нашому класі як нестійкі та оптимізувати гарантію видимості.

6. Висновок

У цьому підручнику ми детальніше вивчили мінливе ключове слово та його можливості, а також вдосконалення, внесені до нього, починаючи з Java 5.

Як завжди, приклади коду можна знайти на GitHub.