Руководство по изменчивому ключевому слову в Java

1. Обзор

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

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

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

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

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

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

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

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

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

3. Когда использовать летучие

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

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

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

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

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

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

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

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

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

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 }

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

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. Заключение

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

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