Вступление
Многопоточность - частая причина головной боли программистов. Поскольку люди, естественно, не привыкли к такому «параллельному» мышлению, разработка многопоточной программы становится гораздо менее простой задачей, чем написание программного обеспечения с одним потоком выполнения.
В этой статье мы рассмотрим некоторые распространенные проблемы
многопоточности, которые можно решить с помощью ключевого слова
volatile
Мы также рассмотрим некоторые более сложные проблемы, в которых
volatile
недостаточно, чтобы исправить ситуацию, а это означает, что
необходимы обновления до других механизмов безопасности.
Переменная видимость
Распространенная проблема с видимостью переменных в многопоточных средах. Предположим, что у нас есть общая переменная (или объект), к которой обращаются два разных потока (каждый поток на своем собственном процессоре).
Если один поток обновляет переменную / объект, мы не можем точно знать, когда именно это изменение станет видимым для другого потока. Причина, по которой это происходит, заключается в кэшировании ЦП .
Каждый поток, использующий переменную, делает локальную копию (т. Е. Кеш) своего значения на самом ЦП. Это позволяет выполнять операции чтения и записи более эффективно, поскольку обновленное значение не должно «перемещаться» полностью в основную память, а вместо этого может быть временно сохранено в локальном кеше:
{.ezlazyload}
[Изображение предоставлено: Jenkov
Tutorials]{.small}
Если поток 1 обновляет переменную, он обновляет ее в кеше, а поток 2 все еще имеет устаревшую копию в своем кеше. Работа потока 2 может зависеть от результата потока 1 , поэтому работа с устаревшим значением даст совершенно другой результат.
Наконец, когда они хотят зафиксировать изменения в основной памяти, значения полностью отличаются, и одно перекрывает другое.
В многопоточной среде это может быть дорогостоящей проблемой, поскольку может привести к серьезным несогласованным действиям. Вы не сможете полагаться на результаты, и вашей системе придется проводить дорогостоящие проверки, чтобы попытаться получить обновленное значение - возможно, без гарантии.
Короче говоря, ваше приложение сломается .
Неустойчивое ключевое слово
volatile
отмечает переменную как изменчивую . Таким образом, JVM
гарантирует, что результат каждой операции записи записывается не в
локальную память, а в основную память.
Это означает, что любой поток в среде может без проблем получить доступ к общей переменной с новейшим актуальным значением.
Подобное, но не идентичное поведение может быть достигнуто с помощью ключевого слова synchronized.
Примеры
Давайте посмотрим на несколько примеров использования ключевого слова
volatile
Простая общая переменная
В приведенном ниже примере кода мы видим класс, представляющий зарядную станцию для ракетного топлива, которая может использоваться несколькими космическими кораблями. Ракетное топливо представляет собой общий ресурс / переменную (что-то, что может быть изменено "извне"), а космические корабли представляют собой потоки (вещи, которые изменяют переменную).
Давайте теперь продолжим и определим RocketFuelStation
. Каждый
Spaceship
будет иметь RocketFuelStation
в качестве поля, поскольку
они назначены ему, и, как и ожидалось, fuelAmount
является static
.
Если космический корабль забирает топливо со станции, это должно
отразиться и на экземпляре, принадлежащем другому объекту:
public class RocketFuelStation {
// The amount of rocket fuel, in liters
private static int fuelAmount;
public void refillShip(Spaceship ship, int amount) {
if (amount <= fuelAmount) {
ship.refill(amount);
this.fuelAmount -= amount;
} else {
System.out.println("Not enough fuel in the tank!");
}
}
// Constructor, Getters and Setters
}
Если amount
мы хотим залить в корабль, превышает количество
fuelAmount
оставшееся в баке, мы уведомляем пользователя, что залить
такое количество невозможно. Если нет, мы с радостью заправляем корабль
и уменьшаем количество, оставшееся в баке.
Теперь, поскольку каждый Spaceship
будет работать в другом Thread
,
нам придется extend
класс:
public class Spaceship extends Thread {
private int fuel;
private RocketFuelStation rfs;
public Spaceship(RocketFuelStation rfs) {
this.rfs = rfs;
}
public void refill(int amount) {
fuel += amount;
}
// Getters and Setters
public void run() {
rfs.refillShip(this, 50);
}
Здесь следует отметить несколько моментов:
RocketFuelStation
передается конструктору, это общий объект.- Класс
Spaceship
расширяетThread
, что означает, что нам нужно реализовать методrun()
. - Как только мы создадим экземпляр
Spaceship
и вызовемstart()
, методrun()
также будет выполнен.
Это означает, что как только мы создадим космический корабль и запустим
его, он будет заправляться от общей RocketFuelStation
50 литрами
топлива.
И, наконец, давайте запустим этот код, чтобы проверить его:
RocketFuelStation rfs = new RocketFuelStation(100);
Spaceship ship = new Spaceship(rfs);
Spaceship ship2 = new Spaceship(rfs);
ship.start();
ship2.start();
ship.join();
ship2.join();
System.out.println("Ship 1 fueled up and now has: " + ship.getFuel() + "l of fuel");
System.out.println("Ship 2 fueled up and now has: " + ship2.getFuel() + "l of fuel");
System.out.println("Rocket Fuel Station has " + rfs.getFuelAmount() + "l of fuel left in the end.");
Поскольку мы не можем гарантировать, какой поток будет запущен первым в
Java, System.out.println()
располагаются после запуска join()
в
потоках. Метод join()
ожидает завершения потока, поэтому мы знаем, что
распечатываем результаты после фактического завершения потоков. В
противном случае мы можем столкнуться с неожиданным поведением. Не
всегда, но это возможно.
new RocketFuelStation()
состоит из 100 литров топлива. Как только мы
запустим оба корабля, у обоих должно быть по 50 литров топлива, а на
станции должно остаться 0 литров топлива.
Посмотрим, что произойдет, когда мы запустим код:
Ship 1 fueled up and now has: 0l of fuel
Rocket Fuel Station has 0l of fuel left
Rocket Fuel Station has 50l of fuel left
Ship 2 fueled up and now has: 50l of fuel
Rocket Fuel Station has 0l of fuel left in the end.
Это не правильно. Давайте снова запустим код:
Ship 1 fueled up and now has: 0l of fuel
Ship 2 fueled up and now has: 0l of fuel
Rocket Fuel Station has 50l of fuel left
Rocket Fuel Station has 0l of fuel left
Rocket Fuel Station has 100l of fuel left in the end.
Сейчас оба пусты, включая заправочную станцию. Попробуем еще раз:
Ship 1 fueled up and now has: 50l of fuel
Rocket Fuel Station has 0l of fuel left
Rocket Fuel Station has 50l of fuel left
Ship 2 fueled up and now has: 50l of fuel
Rocket Fuel Station has 0l of fuel left in the end.
Сейчас у обоих по 50 литров, а станция пуста. Но это чистая удача.
Давайте продолжим и обновим класс RocketFuelStation
public class RocketFuelStation {
// The amount of rocket fuel, in liters
private static volatile int fuelAmount;
// ...
Единственное, что мы меняем, - это сообщить JVM, что fuelAmount
является изменчивым и что она должна пропустить этап сохранения значения
в кеше и зафиксировать его прямо в основной памяти.
Мы также изменим класс Spaceship
public class Spaceship extends Thread {
private volatile int fuel;
// ...
Так как fuel
тоже может кэшироваться и обновляться некорректно.
Теперь, когда мы запускаем предыдущий код, мы получаем:
Rocket Fuel Station has 50l of fuel left
Rocket Fuel Station has 0l of fuel left
Ship 1 fueled up and now has: 50l of fuel
Ship 2 fueled up and now has: 50l of fuel
Rocket Fuel Station has 0l of fuel left in the end.
Идеально! У обоих кораблей по 50 литров топлива, а станция пуста. Попробуем еще раз, чтобы убедиться:
Rocket Fuel Station has 50l of fuel left
Rocket Fuel Station has 0l of fuel left
Ship 1 fueled up and now has: 50l of fuel
Ship 2 fueled up and now has: 50l of fuel
Rocket Fuel Station has 0l of fuel left in the end.
И опять:
Rocket Fuel Station has 0l of fuel left
Rocket Fuel Station has 0l of fuel left
Ship 1 fueled up and now has: 50l of fuel
Ship 2 fueled up and now has: 50l of fuel
Rocket Fuel Station has 0l of fuel left in the end.
Если мы столкнемся с подобной ситуацией, где начальным оператором будет
«У Rocket Fuel Station осталось 0 fuelAmount -= amount
до того, как
первый поток System.out.println()
строка в этом выражении if
if (amount <= fuelAmount) {
ship.refill(amount);
fuelAmount -= amount;
System.out.println("Rocket Fuel Station has " + fuelAmount + "l of fuel left");
}
Хотя это, казалось бы, дает неправильный результат - это неизбежно,
когда мы работаем параллельно с этой реализацией. Это происходит из-за
отсутствия взаимного исключения при использовании ключевого слова
volatile
Подробнее об этом в разделе «Недостаточность
волатильности» .
Важно то, что конечный результат - 50 литров топлива в каждом космическом корабле и 0 литров топлива на станции.
Произойдет до гарантии
Теперь предположим, что наша зарядная станция немного больше и у нее две
ТРК вместо одной. Мы ловко назовем количество топлива в этих двух баках
« fuelAmount1
и « fuelAmount2
.
Также предположим, что космические корабли теперь заправляют два типа топлива вместо одного (а именно, у некоторых космических кораблей есть два разных двигателя, которые работают на двух разных типах топлива):
public class RocketFuelStation {
private static int fuelAmount1;
private static volatile int fuelAmount2;
public void refillFuel1(Spaceship ship, int amount) {
// Perform checks...
ship.refill(amount);
this.fuelAmount1 -= amount;
}
public void refillFuel2(Spaceship ship, int amount) {
// Perform checks...
ship.refill(amount);
this.fuelAmount2 -= amount;
}
// Constructor, Getters and Setters
}
Если теперь первый космический корабль решит заправить оба вида топлива, он может сделать это следующим образом:
station.refillFuel1(spaceship1, 41);
station.refillFuel2(spaceship1, 42);
Затем топливные переменные будут обновлены внутри как:
fuelAmount1 -= 41; // Non-volatile write
fuelAmount2 -= 42; // Volatile write
В этом случае, даже если только fuelAmount2
является энергозависимым,
fuelAmount1
также будет записано в основную память сразу после
энергозависимой записи. Таким образом, обе переменные сразу будут видны
второму космическому кораблю.
Гарантия Happens-Before Guarantee гарантирует , что все обновленные переменные (включая энергонезависимые) будут записаны в основную память вместе с изменчивыми переменными.
Однако стоит отметить, что такое поведение возникает только в том случае, если энергонезависимые переменные обновляются раньше, чем изменчивые. Если ситуация обратная, то никаких гарантий не дается.
Недостаточность летучих
До сих пор мы упоминали несколько способов, которыми volatile
может
быть очень полезным. Давайте теперь посмотрим на ситуацию, когда этого
недостаточно.
Взаимное исключение
В многопоточном программировании есть одна очень важная концепция - взаимное исключение . Наличие взаимного исключения гарантирует, что к общей переменной / объекту может получить доступ только один поток одновременно. Первый, кто получит к нему доступ, блокирует его, и пока он не завершит выполнение и не разблокирует его, другие потоки должны ждать.
Таким образом мы избегаем состояния гонки между несколькими потоками, которое может привести к повреждению переменной. Это один из способов решить проблему с несколькими потоками, пытающимися получить доступ к переменной.
Давайте проиллюстрируем эту проблему на конкретном примере, чтобы понять, почему условия гонки нежелательны:
Представьте, что два потока используют счетчик. Поток A считывает текущее значение счетчика (
41
), добавляет1
, а затем записывает новое значение (42
) обратно в основную память. Тем временем (т.е. пока поток A добавляет1
к счетчику), поток B делает то же самое: считывает (старое) значение из счетчика, добавляет1
, а затем записывает его обратно в основную память.Поскольку оба потока читают одно и то же начальное значение (
41
), окончательное значение счетчика будет42
вместо43
.
В таких случаях использования volatile
недостаточно, потому что это не
гарантирует взаимного исключения . Это именно тот случай, о котором
говорилось выше - когда оба потока достигают fuelAmount -= amount
до
того, как первый поток достигает оператора System.out.println()
Вместо этого здесь можно использовать ключевое слово
synchronized, потому что оно
обеспечивает как видимость, так и взаимное исключение , в отличие от
volatile
которое обеспечивает только видимость .
Почему бы тогда не использовать
synchronized
всегда?
Не переусердствуйте из-за снижения производительности. Если вам нужно и
то , и synchronized
. Если вам нужна только видимость, используйте
volatile
.
Условия состязания возникают в ситуациях, когда два или более потока одновременно читают и записывают в общую переменную, новое значение которой зависит от старого значения .
В случае, если потокам никогда не нужно читать старое значение переменной для определения нового, этой проблемы не возникает, потому что нет короткого промежутка времени, в течение которого могло бы произойти состояние гонки.
Заключение
volatile
- это ключевое слово Java, используемое для обеспечения
видимости переменных в многопоточных средах. Как мы видели в последнем
разделе, это не идеальный механизм безопасности потоков, но этого не
должно было быть.
volatile
можно рассматривать как более легкую версию synchronized
поскольку он не обеспечивает взаимного исключения, поэтому его не
следует использовать в качестве замены.
Однако, поскольку он обеспечивает меньшую защиту, чем synchronized
,
volatile
также вызывает меньше накладных расходов, поэтому его можно
использовать более свободно.
В конце концов, все сводится к конкретной ситуации, с которой нужно
справиться. Если производительность не является проблемой, то наличие
полностью поточно-ориентированной программы со всем synchronized
не
повредит. Но если приложению требуется быстрое время отклика и низкие
накладные расходы, то необходимо выделить время и определить критические
части программы, которые должны быть особо безопасными, и те, которые не
требуют таких строгих мер.