Перейти к основному содержимому

Руководство по ключевому слову Volatile в Java

· 6 мин. чтения

1. Обзор

При отсутствии необходимых синхронизаций компилятор, среда выполнения или процессоры могут применять всевозможные оптимизации. Несмотря на то, что эти оптимизации в большинстве случаев полезны, иногда они могут вызывать тонкие проблемы.

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

В этой статье мы сосредоточимся на фундаментальном, но часто неправильно понимаемом понятии языка Java — ключевом слове volatile . Во-первых, мы начнем с небольшого предыстории того, как работает базовая компьютерная архитектура, а затем познакомимся с порядком памяти в Java.

2. Общая многопроцессорная архитектура

Процессоры отвечают за выполнение программных инструкций. Следовательно, им необходимо извлекать как программные инструкции, так и необходимые данные из ОЗУ.

Поскольку ЦП способны выполнять значительное количество инструкций в секунду, выборка из ОЗУ для них не идеальна. Чтобы исправить эту ситуацию, процессоры используют такие приемы, как Out of Order Execution , Branch Prediction , Speculative Execution и, конечно же, Caching.

Здесь в игру вступает следующая иерархия памяти:

./743abaa3d6135c5b7f4d0e5861e2111c.png

По мере того как разные ядра выполняют больше инструкций и обрабатывают больше данных, они заполняют свои кэши более релевантными данными и инструкциями. Это улучшит общую производительность за счет появления проблем с когерентностью кэша .

Проще говоря, мы должны дважды подумать о том, что происходит, когда один поток обновляет кэшированное значение.

3. Когда использовать volatile

Чтобы больше рассказать о когерентности кеша, давайте позаимствуем один пример из книги Java Concurrency in Practice :

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 поддерживает две простые переменные. В своем основном методе он создает еще один поток, который использует готовую переменную до тех пор, пока она имеет значение false. Когда переменная станет истинной, поток просто напечатает числовую переменную.

Многие могут ожидать, что эта программа просто напечатает 42 после небольшой задержки. Однако на самом деле задержка может быть намного больше. Он может даже зависнуть навсегда или даже напечатать ноль!

Причиной этих аномалий является отсутствие надлежащей видимости и переупорядочивания памяти . Оценим их подробнее.

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

В этом простом примере у нас есть два потока приложения: основной поток и поток чтения. Давайте представим сценарий, в котором ОС распределяет эти потоки по двум разным ядрам ЦП, где:

  • Основной поток имеет свою копию готовых и числовых переменных в своем основном кеше .
  • Поток чтения также заканчивается его копиями
  • Основной поток обновляет кэшированные значения

На большинстве современных процессоров запросы на запись не будут применяться сразу после их отправки. На самом деле, процессоры склонны ставить эти записи в очередь в специальном буфере записи . Через некоторое время они будут сразу применять эти записи к основной памяти.

С учетом всего сказанного, когда основной поток обновляет число и готовые переменные, нет никаких гарантий относительно того, что может увидеть поток чтения. Другими словами, поток чтения может увидеть обновленное значение сразу, или с некоторой задержкой, или вообще никогда!

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

3.2. Изменение порядка

Что еще хуже, поток чтения может видеть эти записи в любом порядке, отличном от фактического порядка программы . Например, поскольку мы сначала обновляем числовую переменную:

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

Мы можем ожидать, что поток чтения напечатает 42. Однако на самом деле можно увидеть ноль в качестве напечатанного значения!

Переупорядочивание — это метод оптимизации для повышения производительности. Интересно, что эту оптимизацию могут применять разные компоненты:

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

3.3. изменчивый порядок памяти

Чтобы гарантировать предсказуемое распространение обновлений переменных на другие потоки, мы должны применить модификатор volatile к этим переменным:

public class TaskRunner {

private volatile static int number;
private volatile static boolean ready;

// same as before
}

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

4. volatile и синхронизация потоков

Для многопоточных приложений нам нужно обеспечить пару правил для согласованного поведения:

  • Взаимное исключение — только один поток выполняет критическую секцию за раз
  • Видимость — изменения, внесенные одним потоком в общие данные, видны другим потокам для обеспечения согласованности данных.

синхронизированные методы и блоки обеспечивают оба вышеуказанных свойства за счет производительности приложения.

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

5. Бывает-перед заказом

Эффекты видимости памяти для volatile - переменных выходят за рамки самих volatile - переменных.

Для большей конкретики предположим, что поток A записывает в изменчивую переменную, а затем поток B читает ту же самую изменчивую переменную. В таких случаях значения, которые были видны A до записи volatile - переменной, будут видны B после считывания volatile - переменной:

./ff61a31d4a9da4aae505dec3977af1df.png

С технической точки зрения, любая запись в volatile поле происходит до каждого последующего чтения того же поля . Это правило volatile - переменной модели памяти Java ( JMM ).

5.1. Совмещение

Из-за того, что происходит до упорядочения памяти, иногда мы можем использовать свойства видимости другой изменчивой переменной . Например, в нашем конкретном примере нам просто нужно пометить готовую переменную как volatile :

public class TaskRunner {

private static int number; // not volatile
private volatile static boolean ready;

// same as before
}

Все, что было до записи true в готовую переменную, видно всем после чтения готовой переменной. Таким образом, числовая переменная совмещает видимость памяти, обеспечиваемую готовой переменной. Проще говоря , даже если это не изменчивая переменная, она демонстрирует изменчивое поведение.

Используя эту семантику, мы можем определить только несколько переменных в нашем классе как volatile и оптимизировать гарантию видимости.

6. Заключение

В этом руководстве мы больше узнали о ключевом слове volatile и его возможностях, а также об улучшениях, внесенных в него, начиная с Java 5.

Как всегда, примеры кода можно найти на GitHub .