1. Обзор
Хотя ключевое слово volatile
в Java обычно обеспечивает безопасность потоков, это не всегда так.
В этом руководстве мы рассмотрим сценарий, когда общая изменчивая
переменная может привести к состоянию гонки.
2. Что такое изменчивая
переменная?
В отличие от других переменных, volatile
переменные записываются и считываются из основной памяти. ЦП не кэширует значение изменчивой
переменной.
Давайте посмотрим, как объявить volatile
переменную:
static volatile int count = 0;
3. Свойства летучих
переменных
В этом разделе мы рассмотрим некоторые важные особенности volatile
- переменных.
3.1. Гарантия видимости
Предположим, у нас есть два потока, работающих на разных процессорах, которые обращаются к общей энергонезависимой
переменной. Далее предположим, что первый поток записывает в переменную, а второй поток читает ту же самую переменную.
Каждый поток копирует значение переменной из основной памяти в соответствующий кэш ЦП для повышения производительности.
В случае энергонезависимых
переменных JVM не гарантирует, когда значение будет записано обратно в основную память из кэша.
Если обновленное значение из первого потока не будет немедленно сброшено обратно в основную память, существует вероятность того, что второй поток в конечном итоге прочитает более старое значение.
На приведенной ниже диаграмме показан описанный выше сценарий:
Здесь первый поток обновил значение переменной count
до 5. Но возврат обновленного значения в основную память не происходит мгновенно. Поэтому второй поток считывает более старое значение. Это может привести к неправильным результатам в многопоточной среде.
С другой стороны, если мы объявим count
как volatile
, каждый поток увидит свое последнее обновленное значение в основной памяти без какой-либо задержки .
Это называется гарантией видимости ключевого слова volatile
. Это помогает избежать вышеупомянутой проблемы несогласованности данных.
3.2. Случается до гарантии
JVM и ЦП иногда переупорядочивают независимые инструкции и выполняют их параллельно для повышения производительности.
Например, давайте рассмотрим две независимые инструкции, которые могут выполняться одновременно:
a = b + c;
d = d + 1;
Однако некоторые инструкции не могут выполняться параллельно, потому что последняя инструкция зависит от результата предыдущей инструкции :
a = b + c;
d = a + e;
Кроме того, может иметь место переупорядочивание независимых инструкций. Это может привести к неправильному поведению в многопоточном приложении.
Предположим, у нас есть два потока, обращающихся к двум разным переменным:
int num = 10;
boolean flag = false;
Далее, давайте предположим, что первый поток увеличивает значение num
, а затем устанавливает флаг
в значение true
, а второй поток ждет, пока флаг
не будет установлен в значение true
. И как только значение flag
установлено в true
, второй поток считывает значение num.
Следовательно, первый поток должен выполнять инструкции в следующем порядке:
num = num + 10;
flag = true;
Но предположим, что ЦП переупорядочивает инструкции следующим образом:
flag = true;
num = num + 10;
В этом случае, как только флаг будет установлен в true
, начнется выполнение второго потока. А поскольку переменная num
еще не обновлена, второй поток прочитает старое значение num
, равное 10. Это приводит к неправильным результатам.
Однако, если мы объявим флаг
как volatile
, вышеуказанного переупорядочения инструкций не произошло бы.
Применение ключевого слова volatile
к переменной предотвращает переупорядочивание инструкций, предоставляя гарантию «происходит до».
Это гарантирует, что все инструкции до записи volatile
переменной гарантированно не будут переупорядочены после нее. Точно так же инструкции после чтения изменчивой
переменной не могут быть переупорядочены так, чтобы они выполнялись до нее.
4. Когда ключевое слово volatile
обеспечивает потокобезопасность?
Ключевое слово volatile
полезно в двух многопоточных сценариях:
- Когда только один поток пишет в
volatile
переменную, а другие потоки читают ее значение. Таким образом, читающие потоки видят последнее значение переменной. - Когда несколько потоков записывают в общую переменную, так что операция является атомарной. Это означает, что новое записанное значение не зависит от предыдущего значения.
5. Когда volatile
не обеспечивает потокобезопасность?
Ключевое слово volatile
— это упрощенный механизм синхронизации.
В отличие от синхронизированных
методов или блоков, он не заставляет другие потоки ждать, пока один поток работает над критическим разделом. Следовательно, ключевое слово volatile
не обеспечивает потокобезопасности , когда над общими переменными выполняются неатомарные операции или составные операции .
Такие операции, как инкремент и декремент, являются составными операциями. Эти операции внутри состоят из трех шагов: чтение значения переменной, его обновление, а затем запись обновленного значения обратно в память.
Короткий промежуток времени между чтением значения и записью нового значения обратно в память может создать состояние гонки. Другие потоки, работающие с той же переменной, могут считывать и работать с более старым значением в течение этого промежутка времени.
Более того, если несколько потоков выполняют неатомарные операции с одной и той же общей переменной, они могут перезаписывать результаты друг друга.
Таким образом, в таких ситуациях, когда потокам необходимо сначала прочитать значение общей переменной, чтобы вычислить следующее значение, объявление переменной как volatile
не сработает .
6. Пример
Теперь мы попытаемся понять приведенный выше сценарий, когда объявление переменной как volatile
не является потокобезопасным, с помощью примера.
Для этого мы объявим общую изменчивую
переменную с именем count
и инициализируем ее нулем. Мы также определим метод для увеличения этой переменной:
static volatile int count = 0;
void increment() {
count++;
}
Далее мы создадим два потока t1
и t2.
Эти потоки тысячу раз вызывают указанную выше операцию увеличения:
Thread t1 = new Thread(new Runnable() {
@Override
public void run() {
for(int index=0; index<1000; index++) {
increment();
}
}
});
Thread t2 = new Thread(new Runnable() {
@Override
public void run() {
for(int index=0; index<1000; index++) {
increment();
}
}
});
t1.start();
t2.start();
t1.join();
t2.join();
Из приведенной выше программы мы можем ожидать, что окончательное значение переменной count
будет равно 2000. Однако каждый раз, когда мы запускаем программу, результат будет другим. Иногда он печатает «правильное» значение (2000), а иногда нет.
Давайте посмотрим на два разных вывода, которые мы получили при запуске примера программы:
value of counter variable: 2000
value of counter variable: 1652
Вышеупомянутое непредсказуемое поведение связано с тем, что оба потока выполняют операцию увеличения общей переменной count
. Как упоминалось ранее, операция приращения не является атомарной . Он выполняет три операции — чтение, обновление и затем запись нового значения переменной в основную память. Таким образом, существует высокая вероятность того, что эти операции будут чередоваться, когда обе операции t1
и t2
выполняются одновременно.
Предположим, что t1
и t2
работают одновременно, и t1
выполняет операцию увеличения переменной count .
Но прежде чем записать обновленное значение обратно в основную память, поток t2
считывает значение переменной count
из основной памяти. В этом случае t2
прочитает более старое значение и выполнит операцию увеличения на том же самом. Это может привести к тому, что в основную память будет обновлено неверное значение переменной count
. Таким образом, результат будет отличаться от ожидаемого — 2000.
7. Заключение
В этой статье мы увидели, что объявление общей переменной как volatile
не всегда будет потокобезопасным.
Мы узнали, что для обеспечения безопасности потоков и предотвращения условий гонки для неатомарных операций использование синхронизированных
методов или блоков или атомарных переменных является жизнеспособным решением.
Как обычно, полный исходный код приведенного выше примера доступен на GitHub .