본문 바로가기
Java/Advanced

Java의 Atomic, Adder, Accumulator의 이해 / AtomicInteger, AtomicLong, LongAdder, LongAccumulator 동작 방식

by BestUgi 2020. 6. 24.

자바에서 병렬 프로그래밍에서 데이터에 대한 동시 접근을 제어하기 위해서 Lock(Intrinsic Lock vs Explicit lock)을 사용할 수 있을 것이다. 하지만, 공유 데이터가 Primitive 타입일 경우 값을 읽거나 업데이트 하는 경우가 전부인데 이때에는 Lock 보다는 원자성(Atomic) 변수가 더 효율적인 것을 다들 알고 있을 것이다. 소 잡는 칼로 닭을 굳이 잡을 필요는 없을 테니 말이다.

 

이번 포스팅에서는 기존의 Atomic 계열의 클래스(AtomicInteger, AtomiLong,. …)Java 8부터는 추가된 원자성의 데이터 타입(클래스)LongAdderLongAccumulator에 대해서 알아보고 이들의 차이점에 대해서 이야기하고자 한다.

 

Atomic 계열의 클래스

우선 Atomic 계열의 데이터 타입인 AtomicLong을 대표로 알아보도록 하자(다른 Atomic 클래스의 구현도 동일하다). 먼저 메모리 변수의 값을 증가시키는 코드를 살펴보도록 하자.

long value = 0;

value = value + 1; // 혹은 value++;

 

위의 코드에서 ‘value=value+1’이 실제 CPU에 의해 처리될 때 다음과 같이 동작한다.

value 변수의 값을 메모리에서 CPU 레지스터로 로드 (Load)

레지스터의 값을 1 증가 (Add)

레지스터의 값을 메모리에 저장 (Store)

 

이때, LoadAdd, Store 연산 각각은 원자적으로 수행되지만 3개의 연산 전체가 원자적으로 실행되는 것은 아니다. , 각각의 연산 중간에 다른 스레드가 메모리 값을 덮어 쓸 수 있는 상황이 발생할 수 있고 이로 인해 예상하지 못한 값이 메모리에 쓰여질 수 있다. (덮어쓰기)

 

AtomicLong을 사용할 경우 위의 값을 증가시키고 반환하는 코드는 어떻게 구현되어 있을까? 값을 증가시킨 후에 반환하는 AtomicLongincrementAdGet() API의 코드를 쉽게 표현하면 아래와 같다.

private volatile value;

public long incrementAndGet() {

long oldValue;

long newValue;

do {

  oldValue = value;

  newValue = oldValue + 1;

} while (!compareAndSwap(value, oldValue, newValue));

 

return newValue;

}

 

대상 값(value)를 스레드의 스택에 저장(oldValue 지역 변수)하고, 또 다른 지역 변수(newValue)를 미리 계산 한 이후에 원자적으로 수행되는 CAS(compareAndSwap)연산을 사용하여 valuenewValue로 교체한다. CAS 연산은 value(공유 변수)의 값이 oldValue(지역 변수)의 값과 동일할 경우에만 value의 값을 newValue로 변경하는 것을 원자적으로 수행할 수 있도록 도와준다. 만약 valueoldValue가 다를 경우(, 다른 스레드가 value를 변경해버린 경우)에는 반복해서 새롭게 계산한 newValue의 값을 넣기 위해 시도한다.

 

CASJVM에서 제공(운영체제와 CPU 아키텍처에 맞게 제작된)하는 Unsafe 함수를 사용한다. CASAtomic 계열 클래스의 핵심이며, CAS를 사용할 경우에 경합이 심한 경우 CPU를 많이 사용하겠지만 Lock을 사용하는 것보다 성능적인 측면에서는 우위일 것이다.

 

 

LongAdder LongAccumulator

이 두 개의 클래스는 AtomicLong에서 사용하는 CAS 연산에 의한 경합 과정에서 CPU 소모를 줄이기 위해 고안되었으며, 두 클래스 사이에는 약간의 기능적인 차이(제공되는 API)만 있을 뿐 내부적으로 구현 방식은 거의 동일하다.

l  서로 다른 변수를 접근하여 스레드 간의 경쟁(Race Condition)을 최소화 한다.

l  Cache‘False Sharing’을 방지하여 내부적인 성능 하락 요소를 최소화 한다.

 

경합의 최소화

AtomicLong의 경우에는 CAS를 사용하여 경합이 빈번한 상황에서 반복적인 메모리 읽기와 비교 연산을 수행하게 되는데 이는 CPU를 소모하게 된다. 위의 그림에서 볼 수 있듯이, AtomicLong은 이는 단일 Volatile 변수를 서로 공유하기 때문에 이와 같은 경합 과정이 발생하는 것이다. 하지만, LongAdderLongAccumulator는 이를 해결하기 위해서 스레드 별로 별도의 데이터를 배열로 관리하고 각 스레드가 경합이 발생하는 상황에서는 자신만의 변수를 접근하도록 설계되었다. 중요한 것은 경합이 발생하는(base 변수를 CAS로 업데이트 실패할 경우) 상황에서만 각 스레드가 자신의 변수(Cell 배열의 Element)를 접근하고, 일반적인(경합이 없는) 상황에서는 기본 변수(base)에 접근하여 작업을 수행한다. 이러한 개념을 “Dynamic Striping”이라고 하며, 이를 구현한 Striped64 클래스를 LongAdderLongAccumulator가 상속받는다.

 

CPU Cache “False Sharing” 최소화

LongAdderLongAccumulatorCell이라고 하는 여러 개의 개별 변수를 유지하는데, 이러한 변수들은 일반적으로 메모리상에 근접할 수 밖에 없게 된다. 이렇게 데이터들이 메모리상에 근접해 있을 경우, CPU Cache의 한 라인에 속할 가능성이 높고, 이것은 한 스레드가 자신의 Cell Value를 변경할 경우 Cache 일관성(Coherence)를 유지하기 위해 다른 스레드가 사용하는 CPUCache도 빈번히 업데이트 해줘야 하는 “False Sharing”이 발생할 수 있다는 의미가 된다. LongAdderLongAccumulator가 상속받은 Striped64 클래스는 이를 해결하기 위해서 Cellvalue CPU 캐시의 Line 크기가 되도록 Padding을 붙이게 된다(Contended 어노테이션).

 

@sun.misc.Contended static final class Cell {

        volatile long value;

        Cell(long x) { value = x; }

        final boolean cas(long cmp, long val) {

            return UNSAFE.compareAndSwapLong(this, valueOffset, cmp, val);

        }

 

        // Unsafe mechanics

        private static final sun.misc.Unsafe UNSAFE;

        private static final long valueOffset;

        static {

            try {

                UNSAFE = sun.misc.Unsafe.getUnsafe();

                Class<?> ak = Cell.class;

                valueOffset = UNSAFE.objectFieldOffset

                    (ak.getDeclaredField("value"));

            } catch (Exception e) {

                throw new Error(e);

            }

        }

    }

 

 

AtomicLong vs LongAdder vs LongAccumulator의 기능 차이

지원하는 API별로 기능의 차이를 간략하게 표로 정리하였으니 참고하길 바란다.

 

기능

AtomicLong

LongAdder

LongAccumulator

증가/감소

getAndIncrement

getAndDecrement

incrementAndGet

decrementAndGet

increment

decrement

LongBinaryOperator 인터페이스 구현

Delta 증가/감소 (사용자 지정 크기에 따른 증가와 감소)

 

addAndGet

add

Accumulate

데이터 증가/감소 전의 값 조회

데이터 증가/감수 후의 값 조회

지원

(e.g. getAndIncrement 호출 시 성공적으로 반영된 값의 이전 값을 반환)

미지원

미지원

Compare and set

compareAndSet

weakCompareAndSet

미지원

미지원

reset

set

reset

sumThenReset

reset

사용자 지정 operator 사용 가능 여부

(단순 증가와 감소가 아닌 사용자가 지정한 함수 사용 가능 여부)

getAndUpdate accumulateAndGet 에서 LongBinaryOperator 지정 가능

미지원

생성자에서 LongBinaryOperator 지정 가능

 

AtomicLong vs LongAdder(or LongAccumulator)의 성능 차이

그렇다면, AtomicLongLongAdder의 성능의 차이는 어느 정도인지 간단한 테스트를 통해서 알아보도록 하자. 실험은 8 코어 CPU(16 스레드) 환경에서 수행하였다.

 

업데이트 성능

스레드들이 공유하는 AtomicLongLongAdder에 대해서 1억번의 데이터 증가(AtomicLong의 경우 incrementAndGet(), LongAdder의 경우 increment() API)를 수행할 때 각 스레드의 총 소요 시간의 평균(모든 스레드 소요 시간(Second) / 스레드 개수)을 측정 하였다.

AtomicLong과 LongAdder의 쓰기 성능 비교

AtomicLong의 경우 스레드 개수 증가에 따른 성능 하락이 큰폭으로 증가한다. 반면 LongAdder는 스레드 개수에 비해 실행 시간이 비례해서 증가하지만 총 실행시간 자체가 상대적으로 매우 짧다.

읽기 성능

1~32개의 스레드들이 공유 데이터(AtomicLong, LongAdder)를 업데이트 하는 상황에서 단일 스레드가 해당 데이터를 1억번 읽기(AtomicLongget(), LongAddersum() API)에 소요되는 시간(Second)을 측정 하였다.

AtomicLong과 LongAdder의 읽기 성능 비교

AtomicLong은 내부에 단일 멤버 변수 하나를 사용하기 때문에 업데이트하는 스레드 개수에 상관없이 읽기 시간이 비등하지만, LongAdder의 경우에는 쓰기 스레드의 개수가 증가할수록 읽을 때 합산해야 할 값의 개수가 증가하므로 소요시간도 함께 증가하는 것을 볼 수 있다. 하지만, 테스트 환경에서 최대 동시 동작할 수 있는 스레드가 16개(=CPU 16 스레드)이므로 업데이트 스레드 개수가 16을 초과하는 경우에는 항상 읽기 소요시간이 동일하다(즉 LongAdder 내부의 Cell Element가 최대 16개까지 유지되므로).

 Conclusion,

AtomicLong CAS와 관련된 API들을 사용하거나 쓰기에 비해 압도적으로 읽기를 많이 수행하는 상황이라면 Atomic 클래스를 사용하는 것이 이득일 것이다. 하지만, 빈번한 쓰기가 발생하는 상황에서는 Adder나 Accumulator의 성능이 압도적으로 우세하기 때문에 이를 사용하는 것을 권장한다. 

댓글