락이 없는데도 멀티코어에서 느려지는 현상. 원인은 CPU 캐시의 단위에 있다.
캐시 라인
CPU 는 메모리를 바이트 단위로 읽지 않는다
- 캐시 라인(보통 64바이트) 단위로 가져온다
코어 A 가 라인을 수정하면
- 그 라인을 캐시에 갖고 있던 다른 코어의 사본이 무효화된다
- 다른 코어는 다시 메모리(또는 A의 캐시)에서 읽어야 한다
문제가 생기는 모습
class Counters {
volatile long a; // 스레드 1 이 갱신
volatile long b; // 스레드 2 가 갱신
}
a 와 b 는 서로 무관한 변수다. 논리적으로 경합이 없다
그런데 둘 다 8바이트라 '같은 64바이트 캐시 라인' 에 놓인다
- 스레드 1 이 a 를 쓰면 스레드 2 의 캐시 라인이 무효화된다
- 스레드 2 가 b 를 쓰면 스레드 1 의 라인이 무효화된다
- 서로 아무 관계 없는데 계속 캐시를 주고받는다 (핑퐁)
코어를 늘릴수록 느려지는 기이한 현상이 나타난다
해결 — 패딩으로 떼어 놓는다
// 수동 패딩 — a 와 b 사이를 캐시 라인 크기만큼 벌린다
class Counters {
volatile long a;
long p1, p2, p3, p4, p5, p6, p7; // 56바이트
volatile long b;
}
// JVM 에게 맡긴다 (JDK 내부용 애노테이션)
@jdk.internal.vm.annotation.Contended
volatile long a;
// 활성화하려면 -XX:-RestrictContended
표준 라이브러리가 이미 쓰고 있다
LongAdder 의 Cell 클래스가 @Contended 로 패딩돼 있다
- 여러 스레드가 각자 다른 셀을 증가시켜도 캐시 핑퐁이 없다
- 이게 고경합에서 AtomicLong 보다 빠른 이유의 절반이다
어떻게 알아채나
증상: 스레드를 늘렸는데 처리량이 오히려 준다. 프로파일러에는 특정 병목이 안 보인다
확인: perf c2c (리눅스) 로 캐시 라인 경합을 직접 측정한다
- JMH 로 패딩 전후를 비교해 본다
실무에서 얼마나 신경 쓰나
대부분의 애플리케이션 코드에서는 신경 쓸 일이 없다
- 알고리즘 · I/O · GC 가 훨씬 큰 요인이다
의미 있는 곳
- 고성능 락프리 자료구조를 직접 만들 때
- 스레드마다 카운터를 두는 메트릭 수집
- 초당 수백만 연산을 다루는 저지연 시스템
"이런 게 있다"를 아는 것 자체가 값어치다 — 원인 모를 확장성 저하를 만났을 때 후보에 올릴 수 있다.
면접 함정
- ❌ "락이 없으면 경합도 없다" → 하드웨어 수준의 경합이 남는다.
- ❌ "volatile을 빼면 해결된다" → 가시성이 깨진다. 패딩으로 떼어 놓는 것이 답이다.
재현해 보기
// 패딩 없이 두 스레드가 각자 필드를 증가시킨다
static class NoPad { volatile long a, b; }
static class Padded { volatile long a; long p1,p2,p3,p4,p5,p6,p7; volatile long b; }
// 각각 두 스레드로 1억 번 증가시켜 시간을 잰다
// 코어 수가 많은 머신일수록 NoPad 가 몇 배 느리게 나온다
JMH로 재 보면 수 배 차이가 나는 것이 보통이다. 알고리즘도 락도 그대로인데 필드 배치만 바꾼 결과다.
배열에서도 생긴다
// 스레드마다 인덱스를 나눠 쓰는 흔한 패턴
long[] counters = new long[threadCount];
// 스레드 0 은 counters[0], 스레드 1 은 counters[1] ...
// → long 이 8바이트라 8개가 한 캐시 라인에 들어간다 → 전부 거짓 공유
// 간격을 벌린다
long[] counters = new long[threadCount * 8];
// 스레드 i 는 counters[i * 8] 만 쓴다
객체 헤더 축소와의 관계
Compact Object Headers 로 객체가 작아지면
- 한 캐시 라인에 더 많은 객체가 들어간다
- 대개는 이득(캐시 지역성 향상)이지만
- 스레드마다 따로 쓰는 객체라면 거짓 공유 확률은 오히려 오른다
"작을수록 좋다" 가 항상 맞지는 않는 드문 예다