두 스레드가 서로 다른 변수를 각각 갱신하면, 변수를 공유하지 않으니 동시성 비용이 없어야 정상이다. 그런데 실제로 측정해 보면 처리량이 단일 스레드보다도 떨어지는 경우가 있다. 락도 없고 같은 변수를 동시에 건드리지도 않는데 왜 느려질까. 이 질문의 답이 False Sharing이고, JVM이 이를 막으려고 제공하는 도구가 @Contended다.
이 글은 LongAdder가 AtomicLong보다 고경합에서 빠른 이유를 소스로 따라가다 만난 @Contended를, 캐시 라인·MESI 레벨까지 풀어 정리한 것이다.
먼저 깨야 할 직관 하나. CPU 캐시는 바이트 단위가 아니라 캐시 라인(cache line) 단위로 메모리를 읽고 쓴다. x86-64에서 라인 크기는 통상 64바이트다. 8바이트짜리 long 하나만 읽어도, 그 변수가 속한 64바이트 블록 전체가 L1 캐시로 올라온다.
문제는 인접한 필드들이 한 라인에 같이 실린다는 점이다.
한 객체의 필드 a, b가 메모리상 인접 → 같은 64B 라인에 적재
cache line (64 bytes)
┌───────────────────────────────────────────────┐
│ long a (8B) │ long b (8B) │ ... 나머지 48B ... │
└───────────────────────────────────────────────┘
↑ Core0이 갱신 ↑ Core1이 갱신
둘은 독립 변수지만 같은 라인 → 운명 공동체
a와 b는 논리적으로 완전히 독립이지만, 물리적으로 같은 라인에 얹혀 있으면 한쪽을 건드릴 때 다른 쪽까지 영향을 받는다. 그래서 "가짜 공유(false sharing)"다 — 소프트웨어는 공유한 적이 없는데 하드웨어가 공유로 취급한다.
코어마다 캐시를 따로 가지므로, 같은 메모리를 여러 캐시가 들고 있을 때 일관성을 맞춰야 한다. 이를 담당하는 것이 캐시 일관성 프로토콜이고, 대표적인 것이 MESI다(변형으로 MESIF/MOESI). 각 캐시 라인은 코어별로 다음 4상태 중 하나를 가진다.
| 상태 | 의미 |
|---|---|
| Modified | 이 코어만 가진 수정본. 메모리와 불일치(dirty). |
| Exclusive | 이 코어만 가졌고 메모리와 동일(clean). |
| Shared | 여러 코어가 같은 라인을 읽기 공유 중. |
| Invalid | 무효. 다시 읽어와야 함. |
핵심 규칙 하나만 기억하면 된다.
어떤 코어가 라인을 쓰려면(Modified로 전이), 그 라인을 가진 다른 모든 코어의 사본을 Invalid로 만들어야 한다.
이 무효화 요청이 RFO(Request For Ownership)다. 이제 a는 Core0이, b는 Core1이 갱신하는데 둘이 같은 라인 L에 있다고 하자.
초기: Core0[L]=S Core1[L]=S (둘 다 읽기 공유)
Core0이 a 쓰기:
Core0 → RFO 브로드캐스트
Core1[L] → I (b는 안 건드렸는데 무효화됨!)
Core0[L] → M
Core1이 b 쓰기:
Core1[L]=I 이므로 라인을 다시 읽어와야 함
Core1 → RFO
Core0[L] → I (a도 안 건드렸는데 무효화됨!)
Core1[L] → M (Core0의 M 라인을 write-back 후 가져옴)
Core0이 다시 a 쓰기 → 또 RFO → Core1 무효화 → ...
라인 L이 두 코어의 L1 사이를 끝없이 왕복(ping-pong)한다. 매 접근이 L1 히트(수 사이클)가 아니라 다른 코어 캐시나 L3에서 라인을 끌어오는 비용(수십~수백 사이클) 으로 바뀐다. 락도 없고 같은 변수를 동시에 수정하지도 않았지만, 하드웨어 레벨의 경합이 생긴 것이다.
원인이 데이터 레이아웃이니, 해법도 레이아웃이다. 핫 필드 주위를 더미로 채워 라인 하나를 통째로 점유시키면 된다. Java 7 시절의 수동 패딩 관용구는 이랬다.
// 수동 패딩 — value 하나가 라인 하나를 독점
class PaddedLong {
public volatile long value; // 8B
public long p1, p2, p3, p4, p5, p6, p7; // 56B 더미 → 합 64B
}
하지만 이 방식엔 두 가지 약점이 있다. (1) JIT/JVM이 "사용되지 않는 필드"라며 제거하거나 재배치할 수 있고, (2) 라인 크기와 프리페치 정책이 하드웨어마다 달라 56B가 항상 정답이 아니다. 손으로 숫자를 맞추는 방식은 이식성이 없다.
Java 8의 JEP 142가 이 패턴을 런타임 차원에서 표준화했다. 필드(또는 클래스)에 애너테이션을 붙이면 객체 레이아웃 단계에서 JVM이 그 필드 앞뒤로 패딩을 삽입해 다른 필드와 라인을 공유하지 않게 만든다.
sun.misc.Contendedjdk.internal.vm.annotation.Contended로 이동(내부 API).import jdk.internal.vm.annotation.Contended;
class Counter {
@Contended volatile long a; // a는 자기 라인을 독점
@Contended volatile long b; // b도 별도 라인
}
패딩 폭은 -XX:ContendedPaddingWidth로 정해지며, 공식 문서에 따르면 기본값은 128바이트다(0~8192, 8의 배수). 라인이 64B인데 128B를 비우는 이유는 인접 라인 프리페처(adjacent-line prefetcher) 때문으로 알려져 있다 — 하드웨어가 라인을 가져올 때 다음 라인까지 미리 끌어오는 경우가 있어, 두 라인 폭을 비워야 프리페치로 인한 false sharing까지 막힌다는 것이다.
여기 흔히 빠지는 함정이 하나 있다.
@Contended는 기본적으로 부트클래스패스(JDK 내부)에서만 동작한다.
애플리케이션 코드(유저 클래스패스)에서 효과를 보려면 -XX:-RestrictContended로 제한을 풀어야 한다(RestrictContended는 기본 enabled). 일반 필드에 무심코 붙여놓고 "패딩됐겠지" 단정하면 안 된다는 뜻이다.
이제 처음의 질문으로 돌아온다. AtomicLong은 단일 value에 모든 스레드가 CAS를 건다 → 고경합이면 그 한 라인이 코어 사이를 계속 핑퐁한다. 반면 LongAdder(Striped64 기반)는 값을 여러 Cell로 striping하고, 각 Cell을 @Contended로 분리한다.
// java.util.concurrent.atomic.Striped64 (요지)
@jdk.internal.vm.annotation.Contended
static final class Cell {
volatile long value;
// ... CAS 메서드
}
스레드들이 서로 다른 Cell에 흩어져 더하고, 각 Cell이 별도 라인을 차지하므로 코어 간 라인 경합이 사라진다. 최종 값은 sum()이 모든 Cell을 합산해 구한다. Striped64는 JDK 내부 클래스라 RestrictContended 제약 없이 패딩이 적용된다 — LongAdder가 고경합에서 빠른 핵심 이유 중 하나다.
volatile이나 락이 아니라 데이터 레이아웃이다. volatile은 store 전파를 앞당겨 증상을 더 잘 드러낼 뿐이다.@Contended는 만능 스위치가 아니다. 유저 클래스패스에선 -XX:-RestrictContended가 필요하고, 무분별한 패딩은 객체 크기를 키운다 — 진짜 핫한 고경합 필드에만 선택적으로 써야 한다.더 파고들 만한 주제로는, JOL(Java Object Layout)로 실제 필드 오프셋과 @Contended 패딩을 덤프해 눈으로 확인하는 것, 그리고 MOESI/MESIF의 Owner/Forward 상태가 라인 전송 비용을 어떻게 줄이는지가 있다.
jdk.internal.vm.annotation.Contended 소스-XX:ContendedPaddingWidth(기본 128), -XX:-RestrictContendedjava.util.concurrent.atomic.Striped64 / LongAdder 소스