AtomicLong vs LongAdder: Micrometer 성능의 숨겨진 디테일
개요
애플리케이션의 메트릭을 관리할 때 많은 개발자들이 간편하고 익숙한 AtomicLong
을 사용합니다. 이는 흔히 할 수 있는 실수지요. 하지만 높은 동시성 환경에서 AtomicLong
의 사용은 성능상 큰 손해를 초래할 수 있습니다. AtomicLong
은 하나의 값을 CAS(compare-and-swap)
방식으로 업데이트하기 때문에, 다수의 스레드가 동시에 접근하면 contention(경합)
이 발생하고 처리 성능이 급격히 떨어집니다. 반면, LongAdder는 내부적으로 여러 셀(cell)
에 값을 분산시켜 동시성 이슈를 크게 감소시킵니다. 이 글에서는 Micrometer
같은 메트릭 라이브러리에서 AtomicLong
대신 LongAdder
를 사용해야 하는 이유를 명확하게 설명합니다.
1. 왜 Micrometer 카운터에서 LongAdder를 사용해야할까?
이유는 다음과 같습니다.
AtomicLong
은 여러 스레드가 동시에 값을 변경하면CAS(compare-and-swap) 연산
으로 인해 성능 저하가 발생- LongAdder는 값을 여러 개의
셀(cell)
로 나누어 각 스레드가 독립적으로 접근하도록 해 contention을 크게 줄임 - 특히 다수의 스레드 환경에서 처리량(throughput)이 AtomicLong 대비 최대 수십 배 높음
변경이 적게 발생하고 정확한 현재 값을 자주 읽어야 하는 경우엔 AtomicLong이 나을 수 있지만, 일반적인 메트릭을 다루는 상황에서는 LongAdder가 유리합니다.
다음 코드를 통해 성능을 테스트 해볼 수 있습니다.
package evergreen
import java.util.concurrent.atomic.AtomicLong
import java.util.concurrent.atomic.LongAdder
import kotlin.concurrent.thread
import kotlin.system.measureTimeMillis
fun testAtomicLong(threads: Int, incrementsPerThread: Int): Long {
val atomicLong = AtomicLong()
return measureTimeMillis {
val threadList = List(threads) {
thread {
repeat(incrementsPerThread) { atomicLong.incrementAndGet() }
}
}
threadList.forEach(Thread::join)
}
}
fun testLongAdder(threads: Int, incrementsPerThread: Int): Long {
val longAdder = LongAdder()
return measureTimeMillis {
val threadList = List(threads) {
thread {
repeat(incrementsPerThread) { longAdder.increment() }
}
}
threadList.forEach(Thread::join)
}
}
fun main() {
val threadCounts = listOf(1, 2, 4, 8, 16, 32)
val incrementsPerThread = 100_000_000
println("| Threads | AtomicLong (ms) | LongAdder (ms) |")
println("|---------|-----------------|----------------|")
threadCounts.forEach { threads ->
val atomicTime = testAtomicLong(threads, incrementsPerThread)
val adderTime = testLongAdder(threads, incrementsPerThread)
println("| ${threads.toString().padEnd(7)} | ${atomicTime.toString().padEnd(15)} | ${adderTime.toString().padEnd(14)} |")
}
}
스레드가 각각 1개, 2개, 4개, 8개, 16개, 32개인 상황이고 1천만번 더하기를 수행합니다. 결과는 다음과 같아요.
Threads | AtomicLong (ms) | LongAdder (ms) |
---|---|---|
1 | 74 | 74 |
2 | 459 | 77 |
4 | 1405 | 73 |
8 | 3907 | 81 |
16 | 11019 | 134 |
32 | 20823 | 271 |
실제로 Micrometer에서 사용하는 StepTimer 구현체에서도 LongAdder를 사용하고 있어요.
public class StepTimer extends AbstractTimer implements StepMeter {
private final LongAdder count = new LongAdder();
private final LongAdder total = new LongAdder();
// 생략
2. LongAdder의 성능의 비밀
LongAdder가 이러한 성능 우위를 보이는 것에는 크게 두 가지 이유가 존재합니다.
- 내부적인 값(카운터)을 Cell로 분산
- AtomicLong에서는 모든 스레드가 하나의 volatile 값에 대해 CAS 연산을 수행하기 때문에 경합으로 인한 성능 저하가 발생합니다.
- LongAdder에서는 여러개의 Cell을 만들어 스레드간 경합을 낮춥니다. 32개의 스레드가 있다면 나누어진 Cell 내에서 각각 경쟁하도록 유도하는 것입니다.
- 하지만 시스템 프로그래밍에 조예가 있는 개발자면 False Sharing으로 인한 성능 저하를 우려할 것입니다.
- False Sharing 문제 해결
- 같은 캐시 라인에 있는 스레드가 내용을 수정하면 False Sharing이 발생합니다.
- LongAdder의 Cell정의를 보면 Contended라는 에노테이션이 붙어 있습니다. 이는 JVM에게 클래스나 인스턴스에게 padding을 추가하라는 힌트입니다. 이 힌트를 보고 JVM은 Cell 클래스의 공간을 여유롭게 잡습니다. 패딩을 두면 캐시 라인을 공유하지 않기 때문에 false sharing이 발생하지 않습니다.
LongAdder의 구현을 확인해보면 이 글에 대한 이해가 한층 더 높아질거에요.
또, LongAdder를 선택할 때는 주의 사항이 있는데요. LongAdder에서는 정확한 값을 얻을 수 없어요. 이는 성능과 정합성의 trade-off 결과입니다. 그래서 정확한 카운트가 필요한 곳에서는 사용해서는 안돼요.
3. 자바인간인 내가 뭘 할 수 있는데?
앞서 배운 사실을 바탕으로 앞으로 다음을 고려하면 더 좋을 것입니다.
- 메트릭/통계에는 LongAdder 사용: 쓰기가 빈번한 경우 고려할만함
- 배열/데이터 구조에는 캐시라인 크기를 고려한 padding 사용
- 상태 공유는 안하면 베스트
댓글을 작성하려면 로그인이 필요합니다.
로그인하기