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

Введение в атомарные переменные в Java

Узнайте, как использовать атомарные переменные для решения проблем параллелизма.

Автор оригинала: Deep Jain.

1. введение

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

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

2. Замки

Давайте взглянем на класс:

public class Counter {
    int counter; 
 
    public void increment() {
        counter++;
    }
}

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

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

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

Одним из способов управления доступом к объекту является использование блокировок. Это может быть достигнуто с помощью ключевого слова synchronized в сигнатуре метода increment . Ключевое слово synchronized гарантирует, что только один поток может одновременно ввести метод (подробнее о блокировке и синхронизации см. – Руководство по ключевому слову Synchronized в Java ):

public class SafeCounterWithLock {
    private volatile int counter;
 
    public synchronized void increment() {
        counter++;
    }
}

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

Использование блокировок решает проблему. Тем не менее, производительность принимает удар.

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

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

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

3. Атомарные операции

Существует раздел исследований, посвященный созданию неблокирующих алгоритмов для параллельных сред. Эти алгоритмы используют низкоуровневые атомарные машинные инструкции, такие как compare-and-swap (CAS), для обеспечения целостности данных.

Типичная операция CAS работает с тремя операндами:

  1. Ячейка памяти, в которой выполняется операция (M)
  2. Существующее ожидаемое значение (А) переменной
  3. Новое значение (B), которое необходимо установить

Операция CAS атомарно обновляет значение в M до B, но только в том случае, если существующее значение в M совпадает с A, в противном случае никаких действий не предпринимается.

В обоих случаях возвращается существующее значение в M. Это объединяет три этапа – получение значения, сравнение значения и обновление значения – в одну операцию на уровне машины.

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

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

4. Атомарные переменные в Java

Наиболее часто используемыми классами атомарных переменных в Java являются AtomicInteger , AtomicLong , AtomicBoolean и AtomicReference . Эти классы представляют собой int , long , boolean, и ссылку на объект соответственно, которые могут быть атомарно обновлены. Основными методами, предоставляемыми этими классами, являются:

  • get() – получает значение из памяти, чтобы изменения, внесенные другими потоками, были видны; эквивалентно чтению переменной volatile
  • set() – записывает значение в память, чтобы изменение было видно другим потокам; эквивалентно записи переменной volatile
  • lazySet() – в конечном итоге записывает значение в память, возможно, переупорядоченное с последующими соответствующими операциями с памятью. Один из вариантов использования-аннулирование ссылок ради сбора мусора, к которому больше никогда не будет доступа. В этом случае лучшая производительность достигается за счет задержки записи null volatile
  • compareAndSet() – то же, что описано в разделе 3, возвращает true при успешном выполнении, иначе false
  • weakCompareAndSet() – то же самое, что описано в разделе 3, но слабее в том смысле, что он не создает заказы перед заказами. Это означает, что он может не обязательно видеть обновления, внесенные в другие переменные. Начиная с Java 9 этот метод устарел во всех атомарных реализациях в пользу weakCompareAndSet Plain() . Эффекты памяти weakCompareAndSet() были простыми, но их названия подразумевали эффекты изменчивой памяти. Чтобы избежать этой путаницы, они устарели этот метод и добавили четыре метода с различными эффектами памяти, такими как weakCompareAndSetPlain() или weakCompareAndSetVolatile()

Потокобезопасный счетчик, реализованный с помощью AtomicInteger , показан в примере ниже:

public class SafeCounterWithoutLock {
    private final AtomicInteger counter = new AtomicInteger(0);
    
    public int getValue() {
        return counter.get();
    }
    public void increment() {
        while(true) {
            int existingValue = getValue();
            int newValue = existingValue + 1;
            if(counter.compareAndSet(existingValue, newValue)) {
                return;
            }
        }
    }
}

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

5. Заключение

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

Как всегда, все примеры доступны на GitHub .

Чтобы изучить больше классов, которые внутренне используют неблокирующие алгоритмы, обратитесь к руководству по ConcurrentMap .