락도 공유 변수도 없는데 왜 느려지는가 — False Sharing과 @Contended

seonwoo_jung·6일 전

두 스레드가 서로 다른 변수를 각각 갱신하면, 변수를 공유하지 않으니 동시성 비용이 없어야 정상이다. 그런데 실제로 측정해 보면 처리량이 단일 스레드보다도 떨어지는 경우가 있다. 락도 없고 같은 변수를 동시에 건드리지도 않는데 왜 느려질까. 이 질문의 답이 False Sharing이고, JVM이 이를 막으려고 제공하는 도구가 @Contended다.

이 글은 LongAdderAtomicLong보다 고경합에서 빠른 이유를 소스로 따라가다 만난 @Contended를, 캐시 라인·MESI 레벨까지 풀어 정리한 것이다.

1. 비용의 단위는 "변수"가 아니라 "캐시 라인"이다

먼저 깨야 할 직관 하나. 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이 갱신
        둘은 독립 변수지만 같은 라인 → 운명 공동체

ab는 논리적으로 완전히 독립이지만, 물리적으로 같은 라인에 얹혀 있으면 한쪽을 건드릴 때 다른 쪽까지 영향을 받는다. 그래서 "가짜 공유(false sharing)"다 — 소프트웨어는 공유한 적이 없는데 하드웨어가 공유로 취급한다.

2. MESI: 왜 "남의 변수"가 내 캐시를 무효화하는가

코어마다 캐시를 따로 가지므로, 같은 메모리를 여러 캐시가 들고 있을 때 일관성을 맞춰야 한다. 이를 담당하는 것이 캐시 일관성 프로토콜이고, 대표적인 것이 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에서 라인을 끌어오는 비용(수십~수백 사이클) 으로 바뀐다. 락도 없고 같은 변수를 동시에 수정하지도 않았지만, 하드웨어 레벨의 경합이 생긴 것이다.

3. 해법은 "같은 라인에 안 얹히게" 떨어뜨리는 것

원인이 데이터 레이아웃이니, 해법도 레이아웃이다. 핫 필드 주위를 더미로 채워 라인 하나를 통째로 점유시키면 된다. 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가 항상 정답이 아니다. 손으로 숫자를 맞추는 방식은 이식성이 없다.

4. @Contended: JVM이 패딩을 보장한다 (JEP 142)

Java 8의 JEP 142가 이 패턴을 런타임 차원에서 표준화했다. 필드(또는 클래스)에 애너테이션을 붙이면 객체 레이아웃 단계에서 JVM이 그 필드 앞뒤로 패딩을 삽입해 다른 필드와 라인을 공유하지 않게 만든다.

  • Java 8: sun.misc.Contended
  • Java 9+: 모듈 캡슐화로 jdk.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). 일반 필드에 무심코 붙여놓고 "패딩됐겠지" 단정하면 안 된다는 뜻이다.

5. 실전: LongAdder는 false sharing을 설계로 회피한다

이제 처음의 질문으로 돌아온다. AtomicLong은 단일 value에 모든 스레드가 CAS를 건다 → 고경합이면 그 한 라인이 코어 사이를 계속 핑퐁한다. 반면 LongAdder(Striped64 기반)는 값을 여러 Cellstriping하고, 각 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가 고경합에서 빠른 핵심 이유 중 하나다.

6. 정리

  • false sharing 비용의 단위는 변수가 아니라 캐시 라인이다. 독립 변수라도 같은 64B 라인에 있으면 MESI 무효화로 경합이 생긴다.
  • 근본 원인은 volatile이나 락이 아니라 데이터 레이아웃이다. volatile은 store 전파를 앞당겨 증상을 더 잘 드러낼 뿐이다.
  • @Contended는 만능 스위치가 아니다. 유저 클래스패스에선 -XX:-RestrictContended가 필요하고, 무분별한 패딩은 객체 크기를 키운다 — 진짜 핫한 고경합 필드에만 선택적으로 써야 한다.

더 파고들 만한 주제로는, JOL(Java Object Layout)로 실제 필드 오프셋과 @Contended 패딩을 덤프해 눈으로 확인하는 것, 그리고 MOESI/MESIF의 Owner/Forward 상태가 라인 전송 비용을 어떻게 줄이는지가 있다.

참고 자료

  • JEP 142: Reduce Cache Contention on Specified Fields
  • OpenJDK jdk.internal.vm.annotation.Contended 소스
  • JVM 플래그: -XX:ContendedPaddingWidth(기본 128), -XX:-RestrictContended
  • java.util.concurrent.atomic.Striped64 / LongAdder 소스
  • Intel 64 and IA-32 Architectures Optimization Reference Manual (캐시 라인·MESI·프리페치)

0개의 댓글