1. Введение
Проще говоря, совместное изменяемое состояние очень легко приводит к проблемам, когда задействован параллелизм. Если доступ к общим изменяемым объектам не управляется должным образом, приложения могут быстро стать подверженными некоторым трудно обнаруживаемым ошибкам параллелизма.
В этой статье мы вернемся к использованию блокировок для обработки параллельного доступа, рассмотрим некоторые недостатки, связанные с блокировками, и, наконец, представим в качестве альтернативы атомарные переменные.
2. Замки
Давайте посмотрим на класс:
public class Counter {
int counter;
public void increment() {
counter++;
}
}
В случае однопоточной среды это работает отлично; однако, как только мы разрешаем запись более чем одному потоку, мы начинаем получать противоречивые результаты.
Это происходит из-за простой операции приращения ( counter++
), которая может выглядеть как атомарная операция, но на самом деле представляет собой комбинацию трех операций: получение значения, увеличение и запись обновленного значения обратно.
Если два потока пытаются получить и обновить значение одновременно, это может привести к потере обновлений.
Одним из способов управления доступом к объекту является использование блокировок. Этого можно добиться, используя ключевое слово synchronized
в сигнатуре метода приращения .
Синхронизированное ключевое слово гарантирует, что только один поток может ввести метод одновременно (чтобы узнать больше о блокировке и синхронизации, обратитесь к Руководству по синхронизированному
ключевому слову в Java ):
public class SafeCounterWithLock {
private volatile int counter;
public synchronized void increment() {
counter++;
}
}
Кроме того, нам нужно добавить ключевое слово volatile
, чтобы обеспечить правильную видимость ссылок среди потоков.
Использование замков решает проблему. Тем не менее, производительность берет удар.
Когда несколько потоков пытаются получить блокировку, выигрывает один из них, а остальные потоки либо блокируются, либо приостанавливаются.
Процесс приостановки и последующего возобновления потока очень дорог и влияет на общую эффективность системы.
В небольшой программе, такой как счетчик
, время, затрачиваемое на переключение контекста, может значительно превышать фактическое выполнение кода, что значительно снижает общую эффективность.
3. Атомные операции
Существует направление исследований, направленное на создание неблокирующих алгоритмов для параллельных сред. Эти алгоритмы используют низкоуровневые атомарные машинные инструкции, такие как сравнение и замена (CAS), для обеспечения целостности данных.
Типичная операция CAS работает с тремя операндами:
- Ячейка памяти для работы (M)
- Существующее ожидаемое значение (A) переменной
- Новое значение (B), которое необходимо установить
Операция CAS атомарно обновляет значение в M до B, но только если существующее значение в M соответствует A, в противном случае никаких действий не предпринимается.
В обоих случаях возвращается существующее значение в M. Это объединяет три шага — получение значения, сравнение значения и обновление значения — в одну операцию на машинном уровне.
Когда несколько потоков пытаются обновить одно и то же значение через CAS, один из них выигрывает и обновляет значение. Однако, в отличие от блокировок, ни один другой поток не приостанавливается ; вместо этого им просто сообщается, что им не удалось обновить значение. Затем потоки могут приступить к дальнейшей работе, и переключения контекста полностью избегаются.
Еще одно последствие состоит в том, что основная логика программы становится более сложной. Это связано с тем, что нам приходится обрабатывать сценарий, когда операция CAS не удалась. Мы можем повторять это снова и снова, пока не добьемся успеха, или мы можем ничего не делать и двигаться дальше в зависимости от варианта использования.
4. Атомарные переменные в Java
Наиболее часто используемые классы атомарных переменных в Java — это AtomicInteger , AtomicLong , AtomicBoolean и AtomicReference . Эти классы представляют ссылки на int
, long
, boolean
и object соответственно, которые могут быть обновлены атомарно. Основные методы, предоставляемые этими классами:
get()
— получает значение из памяти, чтобы были видны изменения, сделанные другими потоками; эквивалентно чтениюизменчивой
переменнойset()
— записывает значение в память, чтобы изменение было видно другим потокам; эквивалентно записиизменчивой
переменнойlazySet()
— в конечном итоге записывает значение в память, возможно, переупорядочив его с последующими соответствующими операциями с памятью. Один из вариантов использования — аннулирование ссылок ради сборки мусора, к которому больше никогда не будет доступа. В этом случае лучшая производительность достигается за счет задержки нулевойэнергозависимой
записи .compareAndSet()
— то же, что описано в разделе 3, возвращает true в случае успеха, иначе falseweakCompareAndSet()
— то же самое, что описано в разделе 3, но слабее в том смысле, что не создает события перед порядком. Это означает, что он может не обязательно видеть обновления, сделанные для других переменных. Начиная с Java 9 , этот метод устарел во всех атомарных реализациях в пользуweakCompareAndSetPlain()
. Эффекты памяти от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
и снова в случае неудачи, так как хотим гарантировать, что вызов метода приращения
всегда увеличивает значение на 1.
5. Вывод
В этом кратком руководстве мы описали альтернативный способ обработки параллелизма, при котором можно избежать недостатков, связанных с блокировкой. Мы также рассмотрели основные методы, предоставляемые классами атомарных переменных в Java.
Как всегда, все примеры доступны на GitHub .
Чтобы изучить больше классов, которые внутренне используют неблокирующие алгоритмы, обратитесь к руководству по ConcurrentMap .