Не-атомарность i++, volatile и synchronized

Постфиксный инкремент в 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:

1
2
3
4

Вызов 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.