Перейти к основному содержимому

LongAdder и LongAccumulator в Java

· 5 мин. чтения

1. Обзор

В этой статье мы рассмотрим две конструкции из пакета java.util.concurrent : LongAdder и LongAccumulator .

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

2. Длинный сумматор

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

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

Когда мы хотим увеличить экземпляр LongAdder, нам нужно вызвать метод increment() . Эта реализация хранит массив счетчиков, который может увеличиваться по запросу .

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

Давайте создадим экземпляр класса LongAdder и обновим его из нескольких потоков:

LongAdder counter = new LongAdder();
ExecutorService executorService = Executors.newFixedThreadPool(8);

int numberOfThreads = 4;
int numberOfIncrements = 100;

Runnable incrementAction = () -> IntStream
.range(0, numberOfIncrements)
.forEach(i -> counter.increment());

for (int i = 0; i < numberOfThreads; i++) {
executorService.execute(incrementAction);
}

Результат счетчика в LongAdder недоступен, пока мы не вызовем метод sum() . Этот метод будет перебирать все значения нижнего массива и суммировать эти значения, возвращая правильное значение. Мы должны быть осторожны, потому что вызов метода sum() может быть очень дорогостоящим:

assertEquals(counter.sum(), numberOfIncrements * numberOfThreads);

Иногда после вызова sum() мы хотим очистить все состояние, связанное с экземпляром LongAdder, и начать отсчет с самого начала. Для этого мы можем использовать метод sumThenReset() :

assertEquals(counter.sumThenReset(), numberOfIncrements * numberOfThreads);
assertEquals(counter.sum(), 0);

Обратите внимание, что последующий вызов метода sum() возвращает ноль, что означает, что состояние было успешно сброшено.

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

3. Длинный аккумулятор

LongAccumulator также является очень интересным классом, который позволяет нам реализовать алгоритм блокировки без блокировки в ряде сценариев. Например, его можно использовать для накопления результатов в соответствии с предоставленным LongBinaryOperator — это работает аналогично операции reduce() из Stream API.

Экземпляр LongAccumulator можно создать, передав LongBinaryOperator и начальное значение его конструктору. Важно помнить, что LongAccumulator будет работать правильно, если мы снабдим его коммутативной функцией, где порядок накопления не имеет значения.

LongAccumulator accumulator = new LongAccumulator(Long::sum, 0L);

Мы создаем LongAccumulator , который добавит новое значение к значению, которое уже было в аккумуляторе. Мы устанавливаем начальное значение LongAccumulator равным нулю, поэтому при первом вызове метода calculate () предыдущее значение будет иметь нулевое значение.

Давайте вызовем метод calculate() из нескольких потоков:

int numberOfThreads = 4;
int numberOfIncrements = 100;

Runnable accumulateAction = () -> IntStream
.rangeClosed(0, numberOfIncrements)
.forEach(accumulator::accumulate);

for (int i = 0; i < numberOfThreads; i++) {
executorService.execute(accumulateAction);
}

Обратите внимание, как мы передаем число в качестве аргумента методу collect() . Этот метод вызовет нашу функцию sum() .

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

Во-первых, он выполняет действие, определенное как LongBinaryOperator, а затем проверяет, изменилось ли предыдущее значение. Если оно было изменено, действие выполняется снова с новым значением. Если нет, то удается изменить значение, хранящееся в аккумуляторе.

Теперь мы можем утверждать, что сумма всех значений всех итераций равна 20200 :

assertEquals(accumulator.get(), 20200);

Интересно, что Java также предоставляет DoubleAccumulator с той же целью и API, но для двойных значений.

4. Динамическое чередование

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

Вот простое описание того, что делает Striped64 :

./277964f986ad9d2731ca9f7b8a392095.svg

Разные потоки обновляют разные ячейки памяти. Поскольку мы используем массив (то есть полосы) состояний, эта идея называется динамическим чередованием. Интересно, что Striped64 назван в честь этой идеи и того факта, что он работает с 64-битными типами данных.

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

Чтобы быть более конкретным, JVM может размещать эти состояния рядом друг с другом в куче. Это означает, что несколько состояний могут находиться в одной и той же строке кэша ЦП. Таким образом, обновление одной области памяти может привести к промаху кеша ближайших к ней состояний . Это явление, известное как ложное совместное использование , снижает производительность .

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

./3358c3a494619476b4f49e833669e2d5.png

За добавление этого заполнения отвечает аннотация @Contended . Заполнение повышает производительность за счет большего потребления памяти.

5. Вывод

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

Реализацию всех этих примеров и фрагментов кода можно найти в проекте GitHub — это проект Maven, поэтому его должно быть легко импортировать и запускать как есть.