Котик из Kandisnky бегает по потоку воды
Котик из Kandisnky бегает по потоку воды

Привет, Хабр!

Если кто‑то сказал вам, что многопоточность в Java — это просто, то этот кто‑то явно что‑то недоговаривает. Многопоточность может быть настоящим кошмаром, особенно когда речь заходит о синхронизации данных между потоками. Но есть одно хитрое средство — @Volatile, которое, словно волшебная палочка, помогает синхронизировать потоки без всяких блокировок.

@Volatile — это такой бюджетный способ синхронизации. Он не блокирует потоки, как старый добрый synchronized, но делает важное дело: гарантирует, что все изменения переменной моментально видны всем потокам. Без него потоки могут весело жить с устаревшими данными и даже не догадываться, что все вокруг давно изменилось.

Но сразу скажу:@Volatile — это не универсальная таблетка от всех проблем многопоточности. Он хорош для простых задач, где нужна только видимость изменений. Но как только ваши требования начинают включать атомарные операции или сложную логику — вот тут @Volatile сдаёт позиции. И это нормально. Каждый инструмент имеет свои ограничения, и важно понимать, когда его использовать, а когда бежать за чем‑то посерьёзнее.

Об ограничениях этого инструмента и не только поговорим в этой статье. И начнем с его механизма работы.

Механизм работы @Volatile

Начнём с того, что происходит, когда вы добавляете @Volatile перед переменной. По сути, это директива для процессора и компилятора, которая гарантирует две ключевые вещи:

  • Видимость изменений: Когда один поток изменяет значение переменной, это изменение становится сразу же доступным для других потоков. Это достигается за счёт того, что запись в volatile переменную сразу сбрасывается в основную память, а не в кеш процессора.

  • Запрет переупорядочивания инструкций: Компилятор и процессор не могут переупорядочивать операции с volatile переменными.

Пример кода:

public class VolatileExample {
    private volatile boolean flag = false;

    public void writer() {
        flag = true;
    }

    public void reader() {
        if (flag) {
            // Делаем что-то, только если флаг установлен
            System.out.println("Flag is true");
        }
    }
}

Здесь метод writer изменяет значение переменной flag. Благодаря @Volatile, как только один поток изменяет flag, другой поток сразу же увидит это изменение при следующем чтении.

Взаимодействие @Volatile с памятью процессора

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

Когда один поток записывает данные в переменную, они сначала попадают в кеш процессора, обрабатывающего данный поток. И вот тут возникает дилемма: другие потоки, работающие на других ядрах или процессорах, могут не увидеть эти изменения немедленно, так как они будут работать с устаревшими данными из своего кеша. Это может привести к некорректному поведению программы — и именно в таких ситуациях хорош @Volatile.

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

Memory barriers

Теперь о ещё одном важном моменте работы @Volatilememory barriers. Это барьеры, которые предотвращают переупорядочивание инструкций компилятором или процессором. Без этих барьеров код может выполняться не в том порядке, в котором вы его написали, особенно когда процессор пытается оптимизировать производительность.

Когда вы используете @Volatile, компилятор и процессор обязаны вставить memory barriers перед и после записи/чтения из volatile переменной. Это гарантирует, что:

  • Все операции записи, выполненные до записи в volatile переменную, завершены до того, как произойдет запись.

  • Все операции чтения, выполненные после чтения из volatile переменной, начнутся после этого чтения.

Посмотрим на пример:

public class VolatileMemoryBarrierExample {
    private volatile boolean ready = false;
    private int number = 0;

    public void writer() {
        number = 42;  // Операция записи (без @Volatile)
        ready = true;  // Запись в @Volatile переменную, после чего установится memory barrier
    }

    public void reader() {
        if (ready) {
            System.out.println("Number: " + number);  // Благодаря memory barriers, number всегда будет 42
        }
    }
}

Здесь @Volatile гарантирует, что запись в ready произойдёт после записи в number, даже если процессор решит оптимизировать и выполнить инструкции в другом порядке.

MESI-протокол

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

MESI расшифровывается как:

  • Modified: Данные в кеше были изменены, и они ещё не сброшены в основную память.

  • Exclusive: Данные находятся только в кеше данного процессора и совпадают с данными в основной памяти.

  • Shared: Данные могут находиться в кешах других процессоров.

  • Invalid: Данные недействительны и должны быть обновлены из основной памяти.

Когда переменная помечена как volatile, процессор использует этот протокол, чтобы координировать работу кешей. Вот что происходит при записи:

  1. Запись в volatile переменную сразу же помечает кеш-линии как Invalid в других процессорах.

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

Пример работы @Volatile на уровне процессора:

public class VolatileMESIExample {
    private volatile int sharedData = 0;

    public void updateData() {
        sharedData = 10;  // Это помечает кеш-линии других процессоров как Invalid
    }

    public int readData() {
        return sharedData;  // Это заставляет процессор загружать данные из основной памяти
    }
}

Примеры применения

Флаг остановки потока

Один из самых распространённых примеров использования @Volatile — это флаг для остановки потока. Допустим, есть поток, который выполняет какую-то работу в бесконечном цикле, и нужно остановить его из другого потока:

public class StopFlagExample {
    private volatile boolean stopRequested = false;

    public void run() {
        while (!stopRequested) {
            // Выполняем какую-то работу
        }
    }

    public void stop() {
        stopRequested = true;
    }
}

Метод stop устанавливает флаг stopRequested в true. Благодаря тому, что переменная помечена как volatile, другой поток, выполняющий метод run, немедленно увидит это изменение и завершит выполнение.

Двойная проверка инициализации Singleton

Прежде чем Java 5 появилась поддержка @Volatile, реализация паттерна Singleton с двойной проверкой была ненадёжной из-за проблем с переупорядочиванием инструкций.

public class Singleton {
    private static volatile Singleton instance;

    private Singleton() {
        // Приватный конструктор
    }

    public static Singleton getInstance() {
        if (instance == null) {  // Первая проверка
            synchronized (Singleton.class) {
                if (instance == null) {  // Вторая проверка
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

Здесь @Volatile гарантирует, что изменения переменной instance будут немедленно видны всем потокам.

Обмен данными между потоками

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

public class DataSharingExample {
    private volatile boolean dataReady = false;
    private int data = 0;

    public void producer() {
        data = 42;  // Генерация данных
        dataReady = true;  // Сигнал о готовности данных
    }

    public void consumer() {
        while (!dataReady) {
            // Ожидание готовности данных
        }
        System.out.println("Data: " + data);  // Обработка данных
    }
}

Здесь переменная dataReady сигнализирует другому потоку, что данные готовы. Благодаря @Volatile, изменение переменной dataReady немедленно становится видимым для другого потока.

Счётчик доступа — когда @Volatile не работает

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

public class VolatileCounterExample {
    private volatile int counter = 0;

    public void increment() {
        counter++;  // Не атомарная операция
    }

    public int getCounter() {
        return counter;
    }
}

Вместо этого используйте AtomicInteger:

import java.util.concurrent.atomic.AtomicInteger;

public class SafeCounter {
    private AtomicInteger counter = new AtomicInteger(0);

    public void increment() {
        counter.incrementAndGet();  // Атомарная операция инкремента
    }

    public int getCounter() {
        return counter.get();
    }
}

А теперь подробней про ограничения.

Ограничения @Volatile

Теперь поговорим о том, где @Volatile начинает быть неактуальным и не может решить все проблемы многопоточности.

Проблема атомарности

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

Атомарность — это свойство операций, при котором они выполняются полностью или не выполняются вовсе. Для атомарных операций гарантируется, что никакой другой поток не сможет вмешаться в процесс выполнения. Однако @Volatile не гарантирует атомарности операций, таких как инкрементация, что делает его непригодным для сценариев, где несколько потоков одновременно читают и модифицируют одну переменную. Например, операция инкремента состоит из нескольких шагов: чтение, увеличение и запись. Между этими шагами нет синхронизации, что приводит к гонкам данных, когда оба потока могут записать одинаковый результат, теряя одно увеличение.

Дополнительно, спецификация Java указывает, что чтение и запись 64-битных переменных (таких как long и double) по умолчанию не являются атомарными. Процессор может разбивать операции записи и чтения таких переменных на две 32-битные части. Если одно ядро процессора одновременно с другим ядром пытается выполнить операцию с такой переменной, это может привести к частичной записи или чтению некорректного значения. В многопоточной среде это становится источником ошибок и багов. Пометка переменной как volatile устраняет проблему разбиения данных, делая операции чтения и записи целостными, но это не решает проблему атомарности для более сложных операций.

Например, даже если long-переменная помечена как volatile, инкрементация всё равно останется небезопасной, поскольку включает несколько шагов. Для таких случаев необходимо использовать более сильные механизмы синхронизации, такие как synchronized или атомарные классы AtomicLong.

Отсутствие защиты от гонок данных

Гонки данных возникают, когда несколько потоков одновременно работают с одной переменной. @Volatile гарантирует видимость изменений, но не защищает от некорректной модификации данных.

public class VolatileRaceCondition {
    private volatile int value = 0;

    public void writer() {
        value = value + 1;  // Не атомарная операция
    }

    public int reader() {
        return value;
    }
}

Даже несмотря на использование @Volatile, эта программа подвержена гонке данных.

Отсутствие поддержки нескольких связанных операций

Если нужно выполнить несколько операций, зависящих друг от друга, @Volatile не поможет. Например, если нужно одновременно обновить несколько переменных или выполнить несколько зависимых действий, @Volatile не даст целостности этих операций:

public class VolatileMultiVariable {
    private volatile int x = 0;
    private volatile int y = 0;

    public void updateBoth(int newX, int newY) {
        x = newX;
        y = newY;
    }

    public void check() {
        if (x > 0 && y == 0) {
            // Нарушение инварианта!
        }
    }
}

Здесь не гарантируется согласованность значений x и y, потому что операции записи выполняются отдельно, и потоки могут видеть состояние, где одно из значений уже обновлено, а другое — ещё нет.

Заключение

Итак, @Volatile — это как мини-карманный инструмент для многопоточности: идеален для простых задач, когда нужно лишь обеспечить видимость изменений между потоками. Он лёгкий, быстрый, не тормозит потоки... но если вы попытаетесь им забивать гвозди, то, скорее всего, сломаете инструмент. Так что, как и в любой другой ситуации, используйте его по назначению. А если вам понадобится что-то более серьёзное, то пора достать из ящика с инструментами synchronized, Locks или атомарные классы.

Пользуясь случаем, напомню об открытых уроках, которые пройдут в рамках курса Otus "Java Developer. Professional":

Комментарии (8)


  1. Regis
    08.10.2024 18:16
    +2

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

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

    Это неверные утрвеждения. Спецификация Java такого не требует, имплмементации JVM такого не делают. Совершенно не обязательно, чтобы каждое обращение к volatlie приводило к ожиданию основной памяти. Например, какой смысл это делать, если данные уже есть в кэше процессора в статусе Exclusive?


  1. RodionGork
    08.10.2024 18:16
    +1

    Чем больше я встречал людей рассказывающих о сложности многопоточности / concurrency в том числе в Java - да и сам порой сталкивался с теми или иными проблемами - тем больше начинал понимать что мы что-то делаем не так. Пришёл к мысли что в идеале нужно стремиться дизайнить приложение так чтобы многопоточность не вела к конкурентности. Нужно минимизировать использование расшаренных переменных (кроме, может, атомарных счётчиков) и взаимодействие между потоками упростить до какого-нибудь единого механизма, например, сообщений (а-ля неблокирующие очереди). Дополнительный плюс в том что впоследствии такой дизайн гораздо легче разделять на микросервисы объединённые внешними очередями сообщений. А всевозможные volatile, потокобезопасные мапы и массивы - потихоньку понимаешь что в большинстве случаев это также плохо как если вытащить на свет Божий что-нибудь типа shared memory линуксовых и в бизнес-приложении заюзать.


    1. lorc
      08.10.2024 18:16
      +1

      Да, вы думаете в правильном направлении. Еще немного и получится Erlang. Совершенно дикая многопоточность благодаря отсутствию shared state.


  1. NickDoom
    08.10.2024 18:16

    А что сразу Жаба… вполне общее свойство волатилек, насколько мне известно.

    Я выкладывал пример того, как на них делается синхронизация. И да, это было ну совершенно ни разу не просто. Тесты за тестами (с рандомизированными задержками) снова и снова выясняли, что я снова и снова не предусмотрел какое-то особо череззаборногузадерищенское сочетание состояний и задержек, из-за которого всё снова тарабахается навзничь или, хуже того, портит данные. Более того, я и на момент выкатывания этого чучела не уверен, что предусмотрел их все :-D

    В принципе, один кольцевой буфер делается легко: источник данных модифицирует только волатильку индекса хвоста, а читает обе (дописал докуда можно — переместил вперёд, мол, данные готовы). Приёмник данных модифицирует только волатильку индекса начала данных, а читает обе (обработал начало — переместил вперёд, мол, можно затирать). Убедитесь только, что на вашей платформе выбранные для индексов типы атомарны!

    А вот когда я попробовал распространить этот принцип на более сложную синхронизацию — ох и попрыгал же я вприсядку! :-D Подытоживая эти пляски — можно сделать сложную синхру и на волатильках. И у меня есть пример таковой. Но вы не захотите. И правильно сделаете :-D Я это оставил как памятник бессмысленной и беспощадной оптимизации скорости работы, тем более, что раз уж сделано, не выбрасывать же… а сломать там что-то маловероятно, потому что спектр его возможностей упирается в фундаментальные свойства архивов как таковых (понятие окна распаковки и иже с ним). Проще говоря, новые фичи туда добавлять и заново прыгать вприсядку до эры квантовых вычислений не придётся.


  1. CbIHKA
    08.10.2024 18:16
    +3

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

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


  1. lorc
    08.10.2024 18:16
    +3

    • . Это достигается за счёт того, что запись в volatile переменную сразу сбрасывается в основную память, а не в кеш процессора.

    [Citation needed]

    Ну серьезно, покажите мне современную SMP систему без когерентных кешей. Приколы с volatile будут наименьшей проблемой.

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

    [Citation needed]

    Как образом то что вы помечаете переменную как volatile приводит к таким интересным последствиям? Откуда процессор вообще знает что вот эта переменная - она volatile , а соседняя - уже нет?

    Вы делаете как-то очень много допущений о поведении процессора и компилятора, хотя единственными железные гарантиями являются только те, которые описаны в Java Memory Model. Все остальное - это спекуляции о поведении конкретной Java VM на конкретном семействе процессоров.


  1. Beholder
    08.10.2024 18:16
    +3

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


    1. tbl
      08.10.2024 18:16
      +6

      Потому что это очередная говностатья от инфоцыган OTUS за авторством ChatGPT, которую перед публикацией никто не вычитывал. Они тоннами такое каждый день выливают сюда с 2023 года, а редакции хабра это ок. Я не знаю, какого качества у них сами курсы, но судя по их витрине на хабре - не ок.

      PS: плюсы статье через какое-то время нагонят