Как работает volatile в Java

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

Основная задача, которую решает ключевое слово volatile:

  • Обеспечение видимости изменений: когда один поток изменяет значение переменной, все другие потоки немедленно видят это изменение.

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

Работа с памятью

Когда компьютер выполняет многопоточную программу, разные потоки могут выполняться на разных процессорах. Каждый процессор имеет свою иерархию кешей, которые бывают разных уровней (L1, L2, L3). Когда поток выполняет операции чтения и записи, они сначала обрабатываются в кеше L1, и только после этого синхронизируются с основной памятью (RAM).

Когда используется ключевое слово volatile, Java Virtual Machine (JVM) и аппаратная часть работают вместе, чтобы обеспечить когерентность кешей между процессорами. Здесь важнейшую роль играет процесс инвалидации кеша.

Рассмотрим следующий пример:

public class VolatileExample {
    private volatile int counter = 0;

    public void increment() {
        counter++;
    }

    public int getCounter() {
        return counter;
    }

    public static void main(String[] args) {
        VolatileExample example = new VolatileExample();

        Thread t1 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                example.increment();
            }
        });

        Thread t2 = new Thread(() -> {
            for (int i = 0; i < 5; i++) {
                example.increment();
            }
        });

        t1.start();
        t2.start();

        try {
            t1.join();
            t2.join();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        System.out.println("Counter value: " + example.getCounter());
    }
}

Каждый шаг инкремента происходит следующим образом:

A. Чтение значения counter: Поток (скажем, t1) читает значение переменной counter. Так как переменная объявлена как volatile, процессор t1 гарантирует, что будет прочитано актуальное значение counter из кеша L1 или, в случае его отсутствия, из более высоких уровней кеша или основной памяти.

B. Инкремент значения: Поток t1 увеличивает значение counter на 1.

C. Запись обратно: Поток t1 записывает новое значение обратно в переменную counter. Так как переменная является volatile, процессор t1 гарантирует, что изменение будет записано непосредственно в основную память и, после этого, обновит кеш L1.

D. Инвалидация кешей других процессоров:

Поток t2 будет опираться на протокол когерентности кеша, который обеспечивает согласованность данных между разными процессорами. Один из распространенных протоколов когерентности кеша – это MESI (Modified, Exclusive, Shared, Invalid).

Протокол MESI обеспечивает когерентность кешей путем присвоения одного из четырех состояний каждому блоку данных в кеше: Modified, Exclusive, Shared или Invalid. Вот как работает этот процесс в контексте нашего примера с volatile переменной counter:

  1. Поток t1 изменяет значение counter. После этого, значение counter в кеше L1 процессора, на котором работает поток t1, получает состояние “Modified”.
  2. Изменение записывается в основную память. После записи изменения в основную память, протокол когерентности кеша (в данном случае, MESI) оповещает остальные процессоры о том, что их копии данных устарели.
  3. Обновление состояния кеша процессора потока t2. Когда оповещение достигает процессора, на котором работает поток t2, протокол MESI обновляет состояние соответствующего блока данных в кеше L1 на “Invalid” (недействительный). Это указывает потоку t2, что его локальная копия значения counter устарела.
  4. Чтение актуального значения. Когда поток t2 пытается прочитать значение counter, его процессор обнаруживает, что локальная копия в кеше L1 имеет состояние “Invalid”. В этом случае, процессор потока t2 обращается к основной памяти, чтобы получить актуальное значение переменной counter.

Таким образом, поток t2 поймет, что значение переменной counter устарело благодаря протоколу когерентности кеша, который обеспечивает согласованность данных между процессорами.

E. Чтение и запись вторым потоком: Теперь поток t2 будет читать новое значение counter из своего кеша L1 или основной памяти (если кеш был инвалидирован) и продолжит свою работу по инкременту переменной. Процесс повторяется для каждой итерации цикла.

Таким образом, с помощью volatile переменной и механизмов когерентности кеша гарантируется видимость изменений переменной counter между потоками t1 и t2, даже если они исполняются на разных процессорах с отдельными кешами L1.

Стоит особо отметить, что в примере выше переменная counter не гарантирует атомарность операций инкремента. Volatile гарантирует видимость и упорядоченность операций, но не атомарность.

В примере существует состояние гонки (race condition) между потоками t1 и t2 при инкременте переменной counter. Операция инкремента counter++ не является атомарной и состоит из трех шагов: чтение, модификация и запись.

Возможные значения counter находятся в диапазоне от 2 до 10, потому что два потока могут одновременно читать значение counter, затем увеличивать его на 1 и записывать обратно. Это может привести к потере инкремента одного из потоков, если оба потока выполняют чтение и запись одновременно.

  1. Сценарий, когда значение переменной counter остается равным 2. Это происходит, если в момент, когда один поток записывает значение 1 в counter, другой поток еще не считал значение этой переменной. В этом случае, когда второй поток начнет работу, он получит текущее значение переменной counter равное 1, и после своей работы увеличит его еще на 1, т.е. переменная counter останется равной 2.
  2. Сценарий, когда значение переменной counter становится равным 3. Это возможно, если оба потока инкрементируют переменную counter последовательно, без перекрытия своих операций записи. Таким образом, каждый поток сначала увеличит переменную counter на 1, после чего другой поток сделает то же самое. В результате, после выполнения обоих потоков, значение переменной counter станет равным 3.
  3. Сценарий, когда значение переменной counter становится равным 4, 5, 6, 7, 8, 9 или 10. В этом случае, оба потока выполняют операции инкрементирования переменной counter параллельно, и значения этой переменной могут изменяться в любом порядке. В результате, значение переменной counter может быть равно любому числу от 4 до 10.

Для гарантии атомарности операций инкремента в многопоточной среде, в Java можно использовать классы, такие как AtomicInteger, которые предоставляют атомарные операции инкремента и декремента.

В данной статье мы рассмотрели особенности работы ключевого слова volatile в Java. Итак, утверждение о том, что volatile переменная не кешируется, является некорректным. Как и утверждение о том, что volatile гарантирует атомарность вычислений. Волатильная переменная действительно кешируется, и когерентность данных (не атомарность) обеспечивается через барьеры памяти и протоколы когерентности кеша.

Leave a Reply

Your email address will not be published. Required fields are marked *