Постфиксный инкремент в Java не является атомарным. Это означает, что в реальности за вызовом i++
скрывается несколько последовательных операций. Переменная сперва считывается из памяти, затем увеличивается на единицу, а результат снова записывается в память.
При использовании операции в многопоточном окружении эта особенность может привести к нежелательным последствиям. Если несколько потоков одновременно выполняют инкремент переменной int i
с начальным значением 3, то может случиться так, что поток №1
выполнит чтение в локальную переменную, затем увеличит локальное значение на 1 (iLocal = 4
), в этот момент поток №2
считает из памяти iLocal = 3
(ведь инкремент, выполненный потоком №1
, еще не был записан в память), поток №1
отправит значение i = 4
в память, а поток №2
увеличит свою локальную переменную на 1 (iLocal = 4
) и также запишет полученный результат. В результате оба потока будут конкурировать друг с другом в попытках инкрементировать i
. Возникает так называемое состояние гонки.
Попробуем заставить три потока посчитать от 1-го до 30-ти. Рассмотрим класс Main
:
public class Main { // Количество миллисекунд задержки при вызове в цикле public static final int TIME = 500; // Количество выполнений цикла в каждом потоке public static final int QTY = 10; private long value = 1; public long getValue() { return value; } // Возвращает инкрементированное значение public long getNext() { return value++; } }
Здесь long value
– инкрементируемое значение, public long getNext()
– метод, в котором выполняется инкремент, смысл констант TIME
и QTY
станет понятен далее.
Создадим класс, наследующий Thread
:
class TestThread extends Thread { Main main; String threadName; public TestThread(Main main, String threadName) { this.main = main; this.threadName = threadName; } @Override public void run() { for (int i = 1; i <= Main.QTY; i++) { System.out.println(i + ". " + threadName + ": " + main.getNext()); try { Thread.sleep(Main.TIME); } catch (InterruptedException e) { e.printStackTrace(); } if (i == Main.QTY) { // Информируем о том, что данная итерация - последняя System.out.println("Last cycle of: " + threadName + " Target value is: " + main.getValue()); } } } }
В конструктор TestThread
передается экземпляр класса Main
и имя потока для логирования в консоль. В переопределенном методе run()
выполняется цикл от 1 до константы QTY
. В каждой итерации цикла вызывается Thread.sleep
, который имитирует выполнение неких операций внутри цикла. На последней итерации выводится соответствующее информационное сообщение.
Сведём все воедино в классе Test
:
class Test { public static void main(String[] args) throws InterruptedException { // Создаём общий экземпляр Main для всех потоков Main main = new Main(); // Создаём потоки TestThread th1 = new TestThread(main, "A"); TestThread th2 = new TestThread(main, "B"); TestThread th3 = new TestThread(main, "C"); // Запускаем потоки th1.start(); th2.start(); th3.start(); } }
Попробуем несколько раз подряд запустить метод main
из класса Test
:
Вызов main
каждый раз возвращает разные результаты, несмотря на то, что цикл в каждом из потоков выполняется ровно десять раз. Потоки находятся в состоянии гонки, поэтому невозможно с уверенностью сказать, какое значение value
будет видно каждому из них в определенный момент времени.
volatile
Проблема примера выше в том, что изменение переменной value
не являлось атомарным. Потоки многократно считывали устаревшие значения из памяти и перезаписывали изменения, внесенные другими потоками. Попробуем исправить эту ситуацию, добавив ключевое слово volatile
к переменной value
(подсказка – это не поможет):
private volatile long value = 1;
Снова запустим метод main
:
Результат снова противоречит начальному замыслу. Позволю себе привести вольный перевод определения volatile
из статьи Джереми Мэнсона:
Ключевое слово volatile
– это пример специального механизма, который гарантирует коммуникацию между потоками. Когда первый поток записывает volatile-переменную и второй поток видит эту запись, первый поток сообщает второму потоку обо всём содержимом памяти до тех пор, пока тот не выполнит запись в эту переменную. Другими словами, изменения volatile-переменной, сделанные в одном потоке, становятся сразу видны всем другим потокам. Нашу проблему невозможно решить простым объявлением переменной value
как volatile
, так как операция инкремента выполняется в несколько шагов, то в промежуток между чтением value
в локальную переменную и сохранением инкрементированного значения обратно в память, могут выполниться несколько итераций аналогичного цикла в другом потоке.
synchronized
Java позволяет избежать состояния гонки путём синхронизации доступа к общим ресурсам. Метод, помеченный как synchronized
, может выполняться одновременно только одним потоком.
Изменим метод getNext
:
// // Возвращает инкрементированное значение public synchronized long getNext() { return value++; }
Теперь при каждом запуске метод возвращает одинаково корректный результат. Непоследовательность в выводе чисел на консоль может быть обусловлена задержками самой консоли. В каждом блоке итераций мы видим три не повторяющихся числа и в итоге добираемся до 31.
1 Response
[…] поля класса помечены как volatile (см. статью блога. — прим. переводчика), это означает, что они могут […]