한 줄 정의
논리적으로는 독립된 변수들이 물리적으로 같은 캐시 라인(보통 64바이트)에 놓여 있을 때, 한 스레드의 쓰기가 다른 스레드의 캐시 라인 전체를 무효화하면서 의도치 않은 성능 저하를 일으키는 현상입니다.
쉽게 말하면
도서관에서 책꽂이 단위로만 책을 빌릴 수 있다고 상상해봅니다. A는 1번 책만 보고 싶고 B는 2번 책만 보고 싶지만, 두 책이 같은 책꽂이에 꽂혀 있으면 한 명이 책꽂이를 가져갈 때마다 다른 사람은 자기 책꽂이를 반납하고 다시 빌려와야 합니다. 두 사람은 서로 다른 책을 보는데도 끊임없이 책꽂이를 주고받게 됩니다.
CPU 캐시도 마찬가지입니다. CPU는 변수 한 개가 아니라 캐시 라인 단위(보통 64바이트)로 메모리를 가져옵니다. 서로 다른 변수라도 같은 라인에 들어 있으면, 다른 코어의 캐시는 MESI 프로토콜에 의해 통째로 무효화됩니다.
왜 이렇게 설계했는가?
해결하는 문제
캐시 일관성을 유지하면서 트래픽을 줄이기 위해서입니다. 만약 캐시 동기화 단위가 1바이트라면 추적 메타데이터가 폭증하고 버스 프로토콜이 복잡해집니다. 64바이트라는 비교적 큰 단위로 묶으면 공간 지역성(spatial locality)도 활용할 수 있어 일반적인 코드는 더 빨라집니다.
이게 없다면
캐시 라인을 도입하지 않으면 CPU는 메모리에서 항상 워드 단위로 읽어와야 하고, 순차 접근의 이점을 거의 누릴 수 없습니다. 즉 거짓 공유는 캐시 라인이라는 일반적으로 유익한 설계의 부작용이지, 그 자체가 의도된 동작은 아닙니다.
어떻게 동작하는가?
1단계: 두 스레드가 같은 라인을 공유
스레드 1은 변수 a만, 스레드 2는 변수 b만 수정합니다. 하지만 a와 b는 같은 64바이트 캐시 라인 안에 있습니다. 양쪽 코어의 L1 캐시는 라인을 Shared(공유) 상태로 가지고 있습니다.
2단계: 한쪽이 쓰면 다른 쪽이 무효화
스레드 1이 a를 수정하면, MESI 프로토콜은 그 라인을 코어 1에서는 Modified, 코어 2에서는 Invalid로 전이시킵니다. 코어 2는 b를 건드리지도 않았는데 자기 캐시 라인이 죽어버립니다.
3단계: 무한 핑퐁
스레드 2가 b를 읽거나 쓰려면 무효화된 라인을 다시 가져와야 합니다. 그 순간 코어 1의 라인은 또 무효화되고, 다시 스레드 1이 쓰면 코어 2가 무효화됩니다. L1 히트(4사이클)가 사실상 L3 또는 메모리 접근(38~167사이클)으로 둔갑합니다.
핵심 지표
거짓 공유의 흔적은 perf stat의 LLC-load-misses, cache-references, 그리고 Intel VTune의 HITM(Hit-Modified) 이벤트로 잡힙니다. 코드 리뷰만으로는 절대 보이지 않습니다.
시각화
sequenceDiagram participant C1 as 코어 1 (L1) participant Line as 캐시 라인 64B<br/>[a | b | ...] participant C2 as 코어 2 (L1) Note over C1,C2: 초기: 양쪽 모두 Shared C1->>Line: a += 1 Note over C1: Modified Note over C2: Invalid (b 안 건드렸는데!) C2->>Line: 라인 재요청 Note over C1: Invalid Note over C2: Modified C2->>Line: b += 1 C1->>Line: 라인 재요청 (핑퐁) Note over C1,C2: 매 쓰기마다 L1 → L3/메모리 왕복
비교
진짜 공유 vs 거짓 공유
| 기준 | 진짜 공유(true sharing) | 거짓 공유(false sharing) |
|---|---|---|
| 접근 대상 | 같은 변수 | 다른 변수 |
| 동기화 필요성 | 필요함 (의도된 공유) | 없음 (논리적으로 독립) |
| 코드로 보이는가 | 예 (lock, volatile) | 아니오 |
| 해결 방향 | 락 최적화, 무경합 알고리즘 | 메모리 레이아웃 분리 |
패딩 방식 비교
| 방식 | 설명 | 권장도 |
|---|---|---|
| 수동 long 패딩 | long p1,p2,...,p7 필드 추가 | JIT가 죽은 필드로 판단해 제거할 수 있음 |
| 클래스 상속 패딩 | 부모 클래스에 패딩, 자식에 핫 필드 | 객체 헤더 위치까지 고려해야 함 |
@jdk.internal.vm.annotation.Contended | JVM이 캐시 라인 정렬·패딩 자동 처리 | 권장, -XX:-RestrictContended 필요 |
| 배열 분리 | 스레드별 카운터를 멀리 떨어진 인덱스에 배치 | 단순하지만 캐시 효율 떨어짐 |
코드 / 실습
거짓 공유가 일어나는 코드
public class FalseSharingDemo {
static class Counters {
public volatile long a; // 스레드 1 전용
public volatile long b; // 스레드 2 전용
}
public static void main(String[] args) throws Exception {
Counters c = new Counters();
long N = 500_000_000L;
Thread t1 = new Thread(() -> { for (long i = 0; i < N; i++) c.a++; });
Thread t2 = new Thread(() -> { for (long i = 0; i < N; i++) c.b++; });
long start = System.nanoTime();
t1.start(); t2.start();
t1.join(); t2.join();
System.out.println((System.nanoTime() - start) / 1_000_000 + " ms");
}
}a와 b는 같은 64바이트 라인 안에 있을 확률이 높습니다. 두 스레드는 서로 다른 변수만 수정하지만 처리량은 싱글 스레드보다 느려집니다.
@Contended로 해결
import jdk.internal.vm.annotation.Contended;
static class Counters {
@Contended public volatile long a;
@Contended public volatile long b;
}JVM 옵션 -XX:-RestrictContended 또는 --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED이 필요합니다. JVM이 필드 주위에 자동으로 128바이트(라인 2개분) 패딩을 넣어 라인을 분리합니다.
JOL로 실제 레이아웃 확인
import org.openjdk.jol.info.ClassLayout;
System.out.println(ClassLayout.parseClass(Counters.class).toPrintable());각 필드의 오프셋과 사이즈를 출력해 같은 캐시 라인에 들어 있는지 눈으로 확인할 수 있습니다.
실무에서 만난 사례
LMAX Disruptor의 Sequence
LMAX Disruptor는 코어 간 시퀀스 카운터를 패딩으로 보호합니다. Sequence 클래스는 value 앞뒤로 7개의 long 더미 필드를 둬서 자신의 캐시 라인을 독점합니다. 이 덕분에 produer/consumer가 각자의 시퀀스를 갱신할 때 서로 캐시를 침범하지 않습니다.
java.util.concurrent의 Striped64·LongAdder
LongAdder가 AtomicLong보다 고경합 상황에서 빠른 이유는, 내부의 Cell[] 배열 각 셀에 @Contended가 붙어 있어 거짓 공유가 차단되기 때문입니다. 카운터를 여러 셀에 분산해 경합을 줄이는데, 그 셀끼리 다시 거짓 공유로 묶이면 분산의 의미가 없어집니다.
ConcurrentHashMap의 CounterCell
크기 카운터를 셀로 쪼개고 각 셀에 @Contended를 적용합니다. 같은 패턴입니다.
백엔드 일반 사례
스레드별 통계(요청 수, 누적 응답 시간)를 long[] 배열에 코어 인덱스로 저장하면 거짓 공유가 발생할 가능성이 높습니다. 메트릭 라이브러리(Micrometer 등)도 내부적으로 LongAdder를 쓰는 이유가 여기에 있습니다.
더 알아볼 것
-
perf c2c(cache-to-cache)로 거짓 공유 라인 직접 식별해보기 - JMH 벤치마크로 패딩 유무에 따른 처리량 차이 정량 측정
-
@Contended가 적용된 클래스의 JOL 출력 비교 - ARM(Apple Silicon, Graviton)은 캐시 라인이 64B가 아닌 128B인 경우가 있는데, 패딩 크기를 어떻게 잡아야 하는가
-
VarHandle로 무경합 패턴을 짤 때 거짓 공유까지 고려한 설계 - Disruptor의
Sequence소스를 직접 읽고 패딩 위치 확인
관련 개념
- Ch07 하드웨어와 운영 시스템 — 캐시 라인·MESI의 출처
- MESI 프로토콜 — 거짓 공유가 발생하는 메커니즘
- 캐시 일관성 — 상위 개념
- 기계적 공감 — 거짓 공유를 인식·회피하는 사고방식
- 자바 메모리 모델 —
volatile·동기화와의 관계
출처
- 자바 최적화 2판 (오라일리), Ben Evans 외, Chapter 7
- Martin Thompson, “Mechanical Sympathy” 블로그 시리즈
- LMAX Disruptor 기술 문서
- OpenJDK
java.util.concurrent.atomic.Striped64소스