Рубрики
Без рубрики

Параллелизм в Java: Ключевое слово volatile

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

Автор оригинала: Luka Čupić.

Вступление

Многопоточность-распространенная причина головной боли для программистов. Поскольку люди, естественно, не привыкли к такого рода “параллельному” мышлению, разработка многопоточной программы становится гораздо менее прямолинейной, чем написание программного обеспечения с одним потоком выполнения.

В этой статье мы рассмотрим некоторые распространенные проблемы многопоточности, которые мы можем преодолеть с помощью ключевого слова volatile .

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

Переменная Видимость

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

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

Каждый поток, использующий переменную, создает локальную копию (т. Е. кэш) ее значения на самом процессоре. Это позволяет повысить эффективность операций чтения и записи, поскольку обновленному значению не нужно “перемещаться” полностью в основную память, а вместо этого его можно временно сохранить в локальном кэше:

Кредит на изображение: Учебники Дженкова

Если Поток 1 обновляет переменную, она обновляет ее в кэше, и Поток 2 все еще имеет устаревшую копию в своем кэше. Операция Потока 2 может зависеть от результата Потока 1 , поэтому работа с устаревшим значением приведет к совершенно другому результату.

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

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

Короче говоря, ваше приложение сломается .

Ключевое слово volatile

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

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

Аналогичное, но не идентичное поведение может быть достигнуто с помощью ключевого слова synchronized.

Примеры

Давайте рассмотрим некоторые примеры используемого ключевого слова volatile .

Простая Общая Переменная

В приведенном ниже примере кода мы видим класс, представляющий зарядную станцию для ракетного топлива, которая может использоваться несколькими космическими кораблями совместно. Ракетное топливо представляет собой общий ресурс/переменную (что-то, что можно изменить “извне”), в то время как космические корабли представляют потоки (вещи, которые изменяют переменную).

Давайте теперь продолжим и определим Ракетную заправочную станцию . Каждый Космический корабль будет иметь Ракетную заправочную станцию в качестве поля, поскольку они назначены ему, и, как и ожидалось, количество топлива является статическим . Если космический корабль забирает немного топлива со станции, это должно быть отражено и в экземпляре, принадлежащем другому объекту:

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
}

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

Теперь, поскольку каждый Космический корабль будет работать в другом потоке , нам придется расширить класс:

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);
    }

Здесь следует отметить несколько вещей:

  • Ракетная заправочная станция передается конструктору, это общий объект.
  • Класс Spaceship расширяет Поток , что означает, что мы должны реализовать метод run () .
  • Как только мы создадим экземпляр класса Spaceship и вызовем start() , метод run() также будет выполнен.

Это означает, что как только мы создадим космический корабль и запустим его, он будет заправляться с общей Ракетной заправочной станции 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() ожидает завершения потока, поэтому мы знаем, что распечатаем результаты после завершения потоков. В противном случае мы можем столкнуться с неожиданным поведением. Не всегда, но это возможно.

A новая ракетная заправочная станция() изготовлена из 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 литров, а станция пуста. Но это происходит по чистой случайности.

Давайте продолжим и обновим Ракетную заправочную станцию класс:

Git Essentials

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

public class RocketFuelStation {
        // The amount of rocket fuel, in liters
        private static volatile int fuelAmount;

        // ...

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

Мы также изменим Космический корабль класс:

public class Spaceship extends Thread {
    private volatile int 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.

Если мы столкнемся с подобной ситуацией, когда начальное утверждение “На ракетной заправочной станции осталось 0 литров топлива” – второй поток попал в строку fuelAmount до того, как первый поток попал в строку 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 литров топлива на станции.

Случается-До Гарантии

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

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

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

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

Происходит-До гарантии гарантирует, что все обновленные переменные (в том числе энергонезависимые) будут записаны в основную память вместе с изменяемыми переменными.

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

Недостаточность летучих

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

Взаимное Исключение

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

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

Давайте проиллюстрируем эту проблему конкретным примером, чтобы понять, почему условия гонки нежелательны:

Представьте, что два потока совместно используют счетчик. Поток A считывает текущее значение счетчика ( 41 ), добавляет 1 , а затем записывает новое значение ( 42 ) вернуться в основную память. В то же время (т. е. пока Поток A добавляет 1 к счетчику), Поток B делает то же самое: считывает (старое) значение со счетчика, добавляет 1 , а затем записывает это обратно в основную память.

Поскольку оба потока считывают одно и то же начальное значение ( 41 ), конечное значение счетчика будет равно 42 вместо 43 .

В подобных случаях использования volatile недостаточно, потому что это не гарантирует Взаимного исключения . Это точно случай, выделенный выше – когда оба потока достигают количества топлива оператора до того, как первый поток достигнет System.out.println() оператора.

Вместо этого здесь можно использовать ключевое слово synchronized , поскольку оно обеспечивает как видимость , так и взаимное исключение , в отличие от volatile , которое обеспечивает только видимость .

Почему бы тогда не использовать синхронизировано всегда?

Из-за влияния на производительность не переусердствуйте. Если вам нужны оба , используйте синхронизировано . Если вам нужна только видимость, используйте volatile .

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

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

Вывод

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

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

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

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