한 줄 정의

핵심 메시지

무어의 법칙은 트랜지스터 수의 지수적 증가를 가능하게 했지만, 그 혜택은 더 이상 클럭 속도 향상으로 돌아오지 않습니다. 메모리 속도가 CPU 속도를 따라잡지 못하는 프로세서-메모리 성능 격차가 현대 하드웨어 설계의 핵심 제약이 되었기 때문입니다.

이 격차를 메우기 위해 등장한 것이 다단계 캐시, TLB, 분기 예측, 추측 실행 같은 복잡한 마이크로아키텍처 기법들입니다. 결과적으로 “단순한 레지스터 기반 기계” 사고 모델로는 더 이상 성능을 추론할 수 없게 되었습니다.

자바 개발자에게 이 사실이 중요한 이유는, JVM이 추상화 계층을 제공하더라도 궁극적으로 코드는 캐시 라인 위에서 실행되며 컨텍스트 스위치 비용을 치른다는 점입니다. 고성능·저지연 시스템에서는 이 하드웨어 현실을 이해하는 기계적 공감(mechanical sympathy)이 필수입니다.

쉽게 말하면

CPU와 메모리는 같이 빨라지지 않았습니다. CPU는 매년 빨라졌는데 메모리는 거의 그대로였습니다. 그래서 빠른 CPU가 항상 데이터가 도착하기를 기다리는 모양새가 되었습니다 — 마치 F1 머신이 주유소에서 기름이 채워지길 기다리는 상황입니다.

해결책은 자주 쓰는 데이터를 CPU 가까이 두는 것입니다. CPU 안에 작은 메모리(L1, L2, L3 캐시)를 만들어 두고, 메인 메모리에서 한 번 가져온 데이터를 거기에 보관합니다. 캐시 적중률이 높으면 CPU가 놀지 않고 일할 수 있습니다.

운영체제 측면에서도 비슷한 이야기가 있습니다. CPU는 한 번에 한 스레드만 실행할 수 있는데, 여러 스레드를 번갈아 실행하려면 컨텍스트 스위치라는 비용을 치러야 합니다. 캐시가 비워지고, 다시 채워지는 동안 CPU는 본래 성능을 내지 못합니다.

자바는 JVM이라는 추상화 덕분에 이 모든 것을 신경 쓰지 않아도 됩니다. 하지만 고성능이 필요한 순간에는 캐시 라인 충돌, 거짓 공유, 컨텍스트 스위치 같은 저수준 현실을 직시해야 합니다.

왜 중요한가?

클라우드 네이티브 환경의 노이즈 네이버

컨테이너로 패킹된 환경에서는 한 호스트에 여러 워크로드가 같이 돌아갑니다. 다른 컨테이너가 디스크 I/O나 네트워크 대역폭을 점유하면 내 자바 애플리케이션 응답 시간이 갑자기 튀는데, 이를 노이즈 네이버(noisy neighbor) 문제라고 부릅니다. 자바 코드만 봐서는 원인을 찾을 수 없고, 호스트 수준 지표(vmstat, iostat)를 같이 봐야 합니다.

멀티스레드 성능의 진짜 병목

synchronized, volatile, ConcurrentHashMap을 잘 쓰는 것만으로는 멀티코어 성능을 다 끌어낼 수 없습니다. 두 스레드가 같은 캐시 라인 안의 다른 변수를 수정하면 거짓 공유로 인해 캐시가 계속 무효화되며, 동기화가 전혀 없어도 성능이 떨어집니다. 자바 8의 @Contended 어노테이션이 이 문제를 해결하기 위해 도입되었습니다.

마이크로벤치마크가 거짓말을 하는 이유

“16배 더 많은 작업을 했는데 실행 시간이 같다.” 이 책의 캐싱 예제(touchEveryItem vs touchEveryLine)가 보여주는 결과는 직관에 반합니다. 실제 병목은 연산 횟수가 아니라 메모리 대역폭이기 때문입니다. 벤치마크 설계 시 무엇이 진짜 측정되는지 알지 못하면 잘못된 결론에 도달합니다.

핵심 내용

7.1 현대 하드웨어 소개

대학 수업의 고전적인 하드웨어 관점은 간단한 레지스터 기반 기계를 중심으로 산술/논리 연산과 로드·저장 작업에 초점을 맞춥니다. 이 모델은 1990년대 이후 인텔 x86/x64 아키텍처가 주류였고, 최근 ARM 칩이 부상하면서 영역이 확장되고 있습니다.

문제는 이 단순한 모델이 더 이상 현실을 정확히 반영하지 않는다는 점입니다. 단순한 사고 모델에 기반한 직관적 추론은 완전히 잘못된 결론으로 이어질 가능성이 큽니다. 그래서 캐시, TLB, 분기 예측 같은 현대 CPU 기술 전반을 메모리 관점에서 살펴봐야 합니다.

무어의 법칙과 그 한계
대량 생산된 칩에 포함된 트랜지스터의 수는 약 18개월마다 두 배로 증가한다.
                                                  — 무어의 법칙 (비공식 정의)

이 법칙은 1965년 제기된 이래 컴퓨팅 역사상 유례없는 장기 발전 패턴을 보였지만, 이제는 한계가 보입니다.

  • 물리적 한계: 상용화된 가장 작은 트랜지스터의 너비가 약 3 nm로, 인간 DNA 한 가닥(약 2.5 nm)보다 약간 더 넓은 수준입니다. 원자보다 얇은 전선은 만들 수 없습니다.
  • 경제적 한계: 2021년 IBM이 2 nm 칩을 발표했지만, 이러한 발전은 이제 비용이 과도하게 증가하고 진행 속도도 매우 느려졌습니다.
  • 소프트웨어 오버헤드: 트랜지스터 예산이 늘어도 소프트웨어 복잡도가 같이 증가해 실제 체감 성능이 오히려 떨어지는 경향이 있습니다.

소프트웨어가 세상을 집어삼키고 있습니다.

— 마크 앤드리슨

자바 언어와 런타임 설계는 프로세서 성능 발전 추세를 잘 활용하도록 설계되었기 때문에 무어의 법칙의 혜택을 가장 크게 누린 사례 중 하나입니다. 그러나 성능을 중시하는 자바 개발자라면 플랫폼의 기반이 되는 원리와 기술을 이해해야 가용 자원을 최적화할 수 있습니다.

7.2 메모리

무어의 법칙에 따라 트랜지스터 수가 늘어나면서 초기에는 클럭 속도(clock speed)를 높이는 데 집중했습니다. 클럭 속도가 빨라질수록 초당 처리할 수 있는 명령어 수가 많아지기 때문입니다. 오늘날 우리가 사용하는 2 GHz 이상의 프로세서는 초기 IBM PC의 4.77 MHz 칩보다 수백 배 더 빠릅니다.

그러나 이러한 발전은 새로운 문제를 동반했습니다. CPU가 데이터를 기다리는 상황에서는 클럭 속도가 빨라져도 성능이 향상되지 않습니다. 더 빠른 프로세서는 더 빠른 데이터 스트림을 요구하지만, 주 메모리는 그 속도를 따라가지 못하게 되었습니다.

graph LR
    CPU[CPU<br/>매년 60%+ 향상] -.->|점점 벌어지는 격차| Gap{프로세서-메모리<br/>성능 격차}
    Memory[메모리<br/>매년 ~10% 향상] -.-> Gap
    Gap --> Stall[CPU 스톨<br/>유휴 상태로 대기]

이 격차는 1980~2010년 사이 지수적으로 벌어졌으며, 현대 CPU 설계의 모든 복잡성은 본질적으로 이 격차를 메우기 위한 시도입니다.

7.2.1 메모리 캐시

CPU와 메인 메모리 사이에 CPU 캐시(CPU cache)를 두어 자주 접근하는 메모리 위치를 복사해 두는 방식이 도입되었습니다. CPU 레지스터보다는 느리지만 주 메모리보다는 훨씬 빠른 영역입니다.

다단계 캐시 구조

현대 CPU는 여러 계층의 캐시를 가지며, 자주 접근할수록 코어 가까이 배치됩니다.

캐시위치공유 범위
L1코어 옆코어 전용
L2코어 옆코어 전용
L3칩 내부여러 코어 공유
메인 메모리칩 외부시스템 전체
graph TD
    Core1[코어 1] --- L1a[L1]
    Core1 --- L2a[L2]
    Core2[코어 2] --- L1b[L1]
    Core2 --- L2b[L2]
    L1a --- L3[L3 공유 캐시]
    L1b --- L3
    L2a --- L3
    L2b --- L3
    L3 ---|노스브릿지| Main[메인 메모리]
접근 시간 비교

작업당 클록 사이클로 표시한 액세스 시간(구글 공개 데이터):

메모리 유형사이클
L1 캐시 연속 접근4
L1 캐시 페이지 무작위 접근4
L1 캐시 풀 무작위 접근4
L2 캐시 연속 접근11
L2 캐시 페이지 무작위 접근11
L2 캐시 풀 무작위 접근11
L3 캐시 연속 접근14
L3 캐시 페이지 무작위 접근18
L3 캐시 풀 무작위 접근38
메인 메모리167

여기서 백엔드 관점의 핵심은 두 가지입니다. 첫째, L1과 메인 메모리는 약 40배 차이가 납니다. 캐시 미스가 잦은 코드는 같은 알고리즘이라도 압도적으로 느립니다. 둘째, L3 캐시 접근은 패턴에 따라 14~38사이클로 큰 편차를 보입니다. 데이터 접근 패턴이 성능을 결정합니다.

캐시 일관성 프로토콜

캐시는 성능을 크게 개선하지만, 같은 메모리 영역이 여러 캐시에 동시에 존재할 수 있는 새 문제를 만듭니다. 어느 캐시가 최신 값을 가지고 있는가? 이를 해결하기 위한 방법이 캐시 일관성 프로토콜(cache consistency protocol)입니다.

가장 일반적인 것이 MESI 프로토콜입니다. 캐시에 저장된 각 라인(일반적으로 64바이트)의 네 가지 상태를 정의합니다.

상태의미
수정(Modified)수정되었지만 아직 주 메모리로 반영되지 않은 상태
단독(Exclusive)현재 캐시에만 존재하며 주 메모리와 일치
공유(Shared)다른 캐시에도 존재할 수 있고, 주 메모리와 일치
무효화(Invalid)사용 불가능, 가능한 한 빨리 삭제될 상태

여러 프로세서가 동시에 공유 상태를 유지할 수 있지만, 한 프로세서가 단독 또는 수정 상태로 전환하면 다른 모든 프로세서는 강제로 무효화 상태로 전환됩니다.

MESI
M---Y
E---Y
S--YY
IYYYY

Y는 두 캐시가 동시에 그 상태로 공존할 수 있음을, -는 불가능함을 의미합니다.

동기식 쓰기 vs 쓰기 반영
방식동작비용
동기식 쓰기(write-through)모든 캐시 작업을 주 메모리에 직접 기록매우 비효율적, 큰 대역폭 요구
쓰기 반영(write-back)캐시 블록이 교체될 때 수정된 블록만 메모리에 기록메모리 트래픽 크게 감소

현대 프로세서는 거의 모두 쓰기 반영을 사용합니다.

메모리 대역폭

이론적인 최대 대역폭인 버스트 속도(burst rate)는 다음 요인에 따라 결정됩니다.

  • 메모리의 클록 주파수
  • 메모리 버스의 폭 (보통 64비트)
  • 인터페이스 수 (현대 시스템에서는 보통 두 개)

DDR RAM(double data rate RAM)은 클록 신호의 상승과 하강 에지 모두에서 통신하므로 이 값이 두 배가 됩니다.

2024년 기준 상용 하드웨어에서는 코어당 이론적인 최대 쓰기 속도는 17 GB/s 이상, 시스템 전체 대역폭은 70~90 GB/s에 달합니다. 이 값은 하드웨어와 소프트웨어가 얼마나 이론적 한계에 근접할 수 있는지 평가하는 기준점입니다.

캐싱 예제

캐시 효과를 직접 측정하려면 다음 코드를 실행해 보면 됩니다.

public class Caching {
    private final int ARR_SIZE = 2 * 1024 * 1024;
    private final int[] testData = new int[ARR_SIZE];
 
    private void run() {
        // 워밍업
        for (int i = 0; i < 15_000; i++) {
            touchEveryLine();
            touchEveryItem();
        }
        // 측정
        for (int i = 0; i < 100; i++) {
            long t0 = System.nanoTime();
            touchEveryLine();
            long t1 = System.nanoTime();
            touchEveryItem();
            long t2 = System.nanoTime();
            long elItem = t2 - t1;
            long elLine = t1 - t0;
            double diff = elItem - elLine;
            System.err.println(elItem + " " + elLine + " " + (100 * diff / elLine));
        }
    }
 
    private void touchEveryItem() {
        for (int i = 0; i < testData.length; i++)
            testData[i]++;
    }
 
    private void touchEveryLine() {
        for (int i = 0; i < testData.length; i += 16)
            testData[i]++;
    }
}

직관적으로 보면 touchEveryItem()touchEveryLine()보다 16배 더 많은 항목을 업데이트합니다. 그러나 두 함수의 실제 실행 시간은 거의 동일합니다.

이유는 이 코드의 진짜 영향은 메모리 버스 활용에 있다는 점입니다. 배열 데이터를 주 메모리에서 캐시로 전송하는 비용이 지배적이며, 캐시 라인이 일단 로드되면 라인 안의 16개 정수를 모두 증가시키든 1개만 증가시키든 거의 차이가 없습니다.

하드웨어 프리페처

현대 CPU에는 하드웨어 프리페처(hardware prefetcher)가 내장되어 있어 규칙적인 간격(stride)으로 데이터를 순회하는 패턴을 감지합니다. 이 예제는 그 동작을 활용해 메모리 접근 대역폭의 현실적인 최대치에 가까운 성능을 달성합니다.

전체 실행 시간은 함수당 약 3 ms, 100 MB의 메모리 청크를 순회합니다. 효과적 메모리 대역폭은 약 3.5 GB/s로, 이론적 최대치보다는 낮지만 합리적인 수치입니다. 이론적 한계가 아닌 경험적 수치를 기준선으로 삼아야 실제 시스템 설계가 가능합니다.

7.3 현대 프로세서의 특징

하드웨어 엔지니어들이 “트랜지스터 예산을 활용한다”라고 표현하는 다양한 기술들이 있습니다. 메모리 캐시 외에도 다음 기능들이 추가되었습니다.

7.3.1 변환 조회 버퍼

변환 조회 버퍼(translation lookaside buffer, TLB)는 페이지 테이블을 위한 하드웨어 캐시입니다. 애플리케이션 코드에서 사용하는 가상 메모리 주소를 하드웨어의 물리적 메모리 주소로 매핑합니다.

TLB가 없다면 가상 주소 조회는 페이지 테이블(page tables)이 L1 캐시에 저장되어 있더라도 16 사이클이 소요됩니다. 모든 메모리 접근에 16 사이클을 추가하는 것은 성능 측면에서 받아들일 수 없는 수준입니다. 따라서 TLB는 모든 현대적 칩에서 기본적으로 필수적인 구성 요소입니다.

용어 혼동 주의

핫스팟의 가비지 컬렉션에서 사용하는 스레드-로컬 할당 기능(TLAB)을 일부 자료에서 “변환 조회 버퍼”라고 부르기도 하는데, 두 개념은 전혀 관련이 없습니다. 컨텍스트에서 어떤 기능을 다루는지 확인해야 합니다.

7.3.2 분기 예측과 추측 실행

현대 프로세서는 다단계 명령어 파이프라인을 가지며, 하나의 CPU 명령어 처리를 여러 단계로 나눠 수행합니다. 조건부 분기의 경우 조건이 평가될 때까지는 분기 이후 어떤 명령어가 실행될지 알 수 없습니다.

이때 프로세서는 여러 사이클(실제로는 최대 20사이클) 동안 멈춤 상태(stall)에 빠질 수 있습니다. 분기 뒤에 위치한 다단계 파이프라인이 사실상 비워진 상태로 대기해야 함을 의미합니다.

분기 예측

이 문제를 해결하기 위해 분기 예측(branch prediction)이 도입되었습니다. 어느 분기가 더 가능성이 높은지 추정하는 휴리스틱을 트랜지스터로 구축하고, 예측된 경로를 기준으로 파이프라인을 채워 실행을 진행합니다.

  • 예측이 맞을 경우: CPU는 아무 문제없이 계속 실행
  • 예측이 틀릴 경우: 부분적으로 실행된 명령어를 폐기하고, 파이프라인을 비우는 대가

보안 함의

추측 실행(speculative execution)은 2018년 발견된 멜트다운(Meltdown)과 스펙터(Spectre)를 포함한 주요 보안 문제의 원인으로 잘 알려져 있습니다. 대규모의 CPU에 영향을 미쳤습니다.

7.3.3 하드웨어 메모리 모델

멀티코어 시스템에서 메모리 관련 가장 중요한 질문은 “여러 CPU가 동일한 메모리 위치를 어떻게 일관되게 접근할 수 있는가?” 입니다.

이 답은 하드웨어에 크게 좌우됩니다. 일반적으로 javac, JIT 컴파일러, CPU는 코드 실행 순서를 변경할 수 있으며, 이 변경은 현재 스레드에서 관찰할 수 있는 결과에 영향을 주지 않는 범위에서만 허용됩니다.

명령어 재배열 예시
myInt = otherInt;
intChanged = true;

두 개의 할당 문장 사이에 다른 코드가 없으므로, 실행 중인 스레드는 이 두 작업이 실행 순서를 신경 쓸 필요가 없습니다. 따라서 실행 환경은 명령어의 순서를 자유롭게 변경할 수 있습니다.

그러나 이렇게 되면 해당 데이터를 볼 수 있는 다른 스레드에서는 순서가 달라질 수 있습니다. 이 경우, 다른 스레드에서 intChangedtrue로 관찰되더라도, myInt의 값은 이전 값으로 읽힐 가능성이 생깁니다.

아키텍처별 재배열 허용 여부

이러한 종류의 재배열(스토어 명령이 다른 스토어 뒤로 이동)은 x86 아키텍처에서는 발생하지 않습니다. 하지만 다른 아키텍처에서는 발생할 수 있고 실제로 발생합니다.

ARMv7POWERSPARCx86AMD64zSeries
로드 후 로드 이동YY----
스토어 후 로드 이동YY----
스토어 후 스토어 이동YY----
로드 후 스토어 이동YYYYYY
로드와 함께 이동된 원자적 연산YY----
스토어와 함께 이동된 원자적 연산YY----
비일관적인 명령어YYYYYY

x86/AMD64에서는 “잘 동작하던” 멀티스레드 코드가 ARM에서는 재배열로 인해 깨질 수 있습니다. ARM 기반 클라우드(AWS Graviton, Apple Silicon Mac)가 보편화되면서 이는 점점 실제 문제가 되고 있습니다.

자바 메모리 모델의 선택

자바 환경에서 자바 메모리 모델(JMM)은 다양한 프로세서 아키텍처 간의 메모리 접근 일관성 차이를 고려하여 의도적으로 약한 모델로 설계되었습니다. 멀티스레드 코드가 올바르게 동작하도록 보장하려면, 올바른 잠금(lock)과 volatile 키워드를 올바르게 사용하는 것이 중요합니다.

기계적 공감의 등장

소프트웨어 개발자들이 하드웨어 동작 방식을 깊이 이해하려는 경향을 설명하기 위해 기계적 공감(mechanical sympathy)이라는 용어가 만들어졌습니다.

기계적 공감의 유래

‘기계적 공감’이라는 개념은 전설적인 F1 드라이버이자 3회 월드 챔피언인 재키 슈튜어트에서 유래되었습니다. 그는 최고의 드라이버란 자동차의 구조와 작동 원리를 깊이 이해하고, 이를 최대한 활용해 차량과 조화를 이루는 사람이라고 믿었습니다. — 마틴 톰슨

이 개념은 잠금이 없는 알고리즘과 데이터 구조에 대한 최근 연구에서도 핵심으로 등장합니다.

7.4 운영 체제

운영 체제의 핵심 목적은 여러 실행 프로세스 간에 공유해야 하는 자원에 대한 접근을 제어하는 것입니다. 모든 자원은 유한하며, 모든 프로세스는 자원을 최대한 사용하려는 경향이 있기 때문에 중앙 시스템이 접근을 중재해야 합니다.

가장 중요한 두 자원은 메모리CPU 시간입니다.

자원관리 메커니즘
메모리MMU + 페이지 테이블 (가상 주소 지정)
CPU 시간프로세스 스케줄러

메모리 관리 장치(MMU)와 페이지 테이블은 한 프로세스가 다른 프로세스의 메모리 영역을 손상시키는 것을 방지합니다. 이는 너무 저수준이라 개발자가 직접 측정하거나 다루기 어렵습니다. 대신 운영 체제의 프로세스 스케줄러를 자세히 살펴봅니다.

7.4.1 스케줄러

프로세스 스케줄러의 역할은 CPU 코어에 대한 접근을 관리하고 인터럽트에 대응하는 것입니다. 현대 시스템에서는 동시에 실행할 수 있는 플랫폼 스레드보다 더 많은 스레드가 존재하는 경우가 거의 항상 발생합니다.

가상 스레드 예외

이 절에서는 운영 체제 수준의 플랫폼 스레드를 다룹니다. 자바 21 이상에서 제공하는 가상 스레드는 이 모델을 따르지 않으며, 가상 스레드가 다중화되는 캐리어 스레드(carrier threads)가 이 모델을 따릅니다.

실행 대기 큐와 스레드 생명주기

실행 대기 큐(run queue)는 실행할 자격은 있지만 CPU를 사용하기 위해 순서를 기다려야 하는 플랫폼 스레드(platform threads)를 보관하는 공간입니다.

stateDiagram-v2
    [*] --> 실행준비완료: start()
    실행준비완료 --> 실행중: 스케줄러에 따라 선택됨
    실행중 --> 실행준비완료: 스케줄러에 따라 스레드 전환됨
    실행중 --> 대기슬립: Thread.sleep()
    대기슬립 --> 실행준비완료: 시간 만료
    실행중 --> 대기중: Object.wait()
    대기중 --> 실행준비완료: Object.notify()/notifyAll()
    실행중 --> IO차단: I/O 또는 동기화로 차단됨
    IO차단 --> 실행준비완료: 데이터/동기화 신호 수신
    실행중 --> [*]: Done
시간 할당량과 스케줄링 비용

시간 할당량(보통 이전 운영 체제에서는 10 ms 또는 100 ms)이 끝나면, 스케줄러는 해당 스레드를 실행 대기 큐의 끝으로 이동시킵니다. 스레드는 자발적으로 sleep()이나 wait()을 통해 시간을 포기할 수도 있고, I/O나 소프트웨어 잠금에 의해 차단될 수도 있습니다.

운영 체제에서 간과되는 특징 중 하나는, 본질적으로 코드가 CPU에서 실행되지 않는 시간을 만들어낸다는 점입니다. 프로세스가 할당된 시간을 소진하면, 다시 실행 대기 큐의 맨 앞으로 올 때까지 CPU에서 실행되지 않습니다. 코드가 실행되는 시간보다 대기하는 시간이 더 길어지는 경우가 많습니다.

지터

특정 프로세스에서 수집된 통계 데이터는 같은 시스템에서 실행 중인 다른 프로세스의 영향을 받습니다. 이러한 현상을 지터(jitter)라고 하며, 스케줄링 오버헤드는 관찰된 결과에 노이즈를 유발하는 주요 원인 중 하나입니다.

스케줄링 오버헤드 측정
long start = System.currentTimeMillis();
for (int i = 0; i < 1_000; i++) {
    Thread.sleep(1);
}
long end = System.currentTimeMillis();
System.out.println("Millis elapsed: " + (end - start) / 1000.0);

대부분의 유닉스 계열 운영 체제는 약 10~20%의 오버헤드를 보고합니다. 반면 초기 윈도우는 스케줄러 성능이 악명 높았고, 일부 윈도우 XP 버전에서는 스케줄링 오버헤드가 최대 180% 에 달했습니다. 1,000번의 1 ms 슬립이 2.8초가 소요될 수 있다는 의미입니다.

7.4.2 자바 가상 머신과 운영 체제

자바 가상 머신은 자바 코드에 공통 인터페이스를 제공하여 운영 체제와 독립적인 실행 환경을 구성합니다. 그러나 스레드 스케줄링이나 시스템 시계에서 현재 시간을 가져오는 작업과 같은 기본적인 서비스에는 기본 운영 체제에 접근해야 합니다.

이 기능은 네이티브 메서드(native methods)를 통해 제공되며, native 키워드로 표시됩니다. 네이티브 메서드는 C 언어로 작성되지만, 일반 자바 메서드처럼 접근할 수 있습니다. 이 인터페이스는 자바 네이티브 인터페이스(Java Native Interface, JNI)라고 불립니다.

핫스팟 호출 스택

System.currentTimeMillis() 호출은 다음 경로를 따라갑니다.

graph TD
    Java[Java: System.currentTimeMillis] --> C[C: JVM_CurrentTimeMillis]
    C --> Cpp[C++: os::javaTimeMillis]
    Cpp --> Platform[플랫폼 명세 코드]
  • JVM_CurrentTimeMillis(): 가상 머신 엔트리 포인트 메서드. C 함수로 표현되지만 실제로는 C 호출 규칙으로 내보낸 C++ 함수입니다.
  • os::javaTimeMillis(): 핫스팟의 os 네임스페이스에 정의되어 있으며, 운영 체제에 따라 구현이 달라집니다. OpenJDK 소스 코드의 운영 체제별 하위 디렉토리에서 이 메서드의 정의가 제공됩니다.

이 매핑은 java/lang/System.c에 포함된 자바 네이티브 인터페이스 Java_java_lang_System_registerNatives() 메커니즘을 통해 이루어집니다.

7.4.3 컨텍스트 스위치

컨텍스트 스위치(context switch)는 운영 체제 스케줄러가 현재 실행 중인 플랫폼 스레드를 제거하고 다른 스레드로 교체하는 과정입니다. 일반적으로 실행 중인 명령어와 스레드의 스택 상태를 교체하는 작업이 포함됩니다.

사용자 스레드 간 전환 vs 모드 스위치

컨텍스트 스위치는 사용자 스레드 간 또는 사용자 모드에서 커널 모드로 전환될 때(모드 스위치, mode switch라고도 함) 발생하며, 후자는 상당한 비용이 소요됩니다.

특히 모드 스위치의 경우 사용자 스레드는 자신의 시간 할당량 중간에 특정 기능을 수행하기 위해 커널 모드로 전환해야 할 수도 있습니다. 이러한 전환은 명령어 캐시와 기타 캐시를 비우게 만듭니다. 사용자 공간 코드가 접근하는 메모리 영역과 커널이 접근하는 메모리 영역이 일반적으로 겹치지 않기 때문입니다.

TLB까지 무효화

컨텍스트 스위치에서 커널 모드로 전환되면 변환 조회 버퍼를 무효화하며, 잠재적인 다른 캐시들도 무효화됩니다. 호출이 반환될 때 이러한 캐시는 다시 채워져야 하고, 커널 모드 전환의 영향은 제어가 사용자 공간으로 돌아온 후에도 지속됩니다. 시스템 호출의 실제 비용이 가려질 수 있습니다.

[그림 7-8]은 인터프로세스 통신(IPC) 호출의 비용을 강조합니다. 시스템 호출 예외(전환을 의미)가 발생하면 성능이 하락하고 캐시가 다시 채워지는 동안 천천히 회복됩니다.

vDSO

컨텍스트 스위치의 비용을 줄이기 위해, 리눅스는 가상 동적 공유 객체(virtual dynamically shared object, vDSO)라는 메커니즘을 제공합니다. 사용자 공간 내 메모리 영역으로, 커널 권한이 실제로 필요하지 않은 시스템 호출을 가속화하기 위해 사용됩니다.

대표적인 예가 gettimeofday()입니다. 운영 체제가 이해하는 실제 경과 시간(wallclock time)을 반환하지만, 내부적으로 커널 데이터 구조를 읽기만 하므로 실제로 특권 접근이 필요하지 않습니다. vDSO를 사용해 이 데이터 구조를 사용자 프로세스의 주소 공간에 매핑하면, 커널 모드로 전환할 필요가 없어집니다. 자바 애플리케이션이 시간 정보를 자주 조회한다는 점을 고려하면 매우 유용한 최적화입니다.

7.5 간단한 시스템 모델

성능 문제의 기본 원인을 설명하기 위한 단순한 시스템 모델을 다룹니다. 운영 체제의 기본 서브시스템에 대한 관찰 가능 항목을 기반으로 표현되며, 표준 유닉스 명령줄 도구의 출력과 직접 연결될 수 있습니다.

graph LR
    Traffic[수신 트래픽] --> JVM[자바 가상 머신<br/>App]
    JVM --> External[외부 시스템]
    OS[운영 시스템]
    HW[하드웨어]

모델의 기본 구성 요소는 다음과 같습니다.

  • 애플리케이션이 실행되는 하드웨어와 운영 체제
  • 애플리케이션이 실행되는 자바 가상 머신(또는 컨테이너)
  • 애플리케이션 코드 자체
  • 애플리케이션이 호출하는 외부 시스템
  • 애플리케이션으로 들어오는 요청 트래픽

시스템의 구성 요소 중 어느 하나라도 성능 병목의 원인이 될 수 있습니다.

자원 한계 = 성능 문제

애플리케이션이 하나 이상의 자원 한계에 도달한다면, 이는 곧 성능 문제로 이어질 것입니다.

성능 진단의 첫 번째 단계는 어떤 자원 한계에 도달했는지 인식하는 것입니다. 자원의 부족 문제를 해결하지 않고는 성능을 조정할 수 없습니다.

운영 체제 자체가 일반적으로 시스템 자원 사용률에 주요한 영향을 미쳐서는 안 된다는 점도 중요합니다. 운영 체제의 역할은 사용자 프로세스를 대신하여 자원을 관리하는 것이지, 스스로 자원을 소비하는 것이 아닙니다. 예외는 자원이 극도로 부족한 경우뿐이며, 대부분 I/O 요구가 시스템 용량을 크게 초과할 때만 발생합니다.

7.5.1 CPU 활용

애플리케이션 성능의 주요 지표 중 하나는 CPU 활용률입니다. CPU에 의존적인 애플리케이션은 높은 부하 상태에서 가능한 한 100%에 가까운 CPU 사용률을 목표로 해야 합니다.

유휴 상태에서의 분석은 의미 없음

애플리케이션 성능을 분석할 때, 시스템이 충분한 부하를 받고 있어야 분석이 의미가 있습니다.

세 가지 기본 도구
도구역할
vmstat가상 메모리 통계 보고. 메모리 크기, I/O, 접근 관련 정보
ifstat네트워크 인터페이스 통계. 네트워크 수준의 프로세스 상호작용 디버깅
iostat디바이스의 입력/출력 모니터링. 디바이스 상호작용 문제 확인
vmstat 출력 해석
$ vmstat 1
 r  b   swpd   free   buff  cache    si   so   bi   bo   in   cs us sy id wa st
 2  0      0 759860 248412 2572248    0    0    0   80   63  127  8  0 92  0  0
 2  0      0 759002 248412 2572248    0    0    0    0   55  103 12  0 88  0  0
 1  0      0 758854 248412 2572248    0    0    0   80   57  116  5  1 94  0  0
영역컬럼의미
프로세스r, b실행 가능 / 차단된 프로세스 수
메모리swpd, free, buff, cache스왑/여유/버퍼/캐시 메모리
스왑si, so디스크로부터/디스크로 스왑된 메모리
블록 I/Obi, bo512바이트 블록 단위 입출력
시스템in, cs초당 인터럽트 수 / 컨텍스트 스위치 수
CPUus, sy, id, wa, st사용자/커널/유휴/대기/가상 머신 도난 시간 (%)

컨텍스트 스위치 수(cs) 가 핵심 지표입니다. 사용자 공간 CPU 사용률(us)이 100%에 도달하지 못하면서 동시에 높은 컨텍스트 스위치 비율을 보인다면, 해당 프로세스는 다음 중 하나일 가능성이 높습니다.

  • I/O에서 차단되었거나
  • 스레드 잠금 경합을 겪고 있거나
  • 불필요한 컨텍스트 스위치를 유발하는 방식으로 작성된 것
도구 선택의 원칙

복잡한 도구는 잘못된 방향으로 이끌 수 있는 반면, 프로세스와 운영 체제에 가까운 단순한 도구는 시스템 동작을 명확하고 간결하게 보여줄 수 있습니다. 기본 도구를 간과하지 않는 것이 중요합니다.

7.5.2 가비지 컬렉션

핫스팟 자바 가상 머신에서는 메모리가 시작 시 할당되고 사용자 공간에서 관리됩니다. sbrk()와 같은 시스템 호출이 필요하지 않으므로, 가비지 컬렉션으로 인한 커널 전환 활동은 매우 적은 편입니다.

사용자 공간 100% = GC 의심 신호

자바 가상 머신 프로세스가 사용자 공간에서 100%에 가까운 CPU 사용률을 보인다면, 가비지 컬렉션이 원인일 가능성이 있습니다. vmstat과 같은 간단한 도구로 일관된 100% CPU 사용률을 확인했지만, 거의 모든 CPU 사이클이 사용자 공간에서 소비되고 있다면, “이 CPU 사용률은 자바 가상 머신 때문인지, 아니면 사용자 코드 때문인지?”와 같은 질문을 해봐야 합니다.

많은 경우 자바 가상 머신에 의한 높은 사용자 공간 활용은 가비지 컬렉션 서브시스템에 의해 발생합니다. 따라서 유용한 규칙은 가비지 컬렉션 로그를 확인하여 새로운 항목이 얼마나 자주 추가되는지 확인하는 것입니다.

GC 로깅은 비용이 거의 없다

자바 가상 머신의 가비지 컬렉션 로깅은 비용이 매우 낮습니다. 전체 비용에 대한 가장 정확한 측정조차도 임의의 배경 잡음과 구별하기 어려울 정도입니다. 모든 자바 가상 머신 프로세스에서 가비지 컬렉션 로그를 활성화하는 것이 필수적입니다. 특히 프로덕션 환경에서는 더욱 중요합니다.

7.5.3 I/O

파일 I/O는 전통적으로 시스템 성능 분석에서 가장 복잡한 영역으로 여겨졌습니다. 이는 부분적으로 베어메탈과 더 밀접하게 연결되어 있기 때문입니다. 엔지니어들은 종종 하드 디스크를 ‘회전하는 러스트’(spinning rust)라고 농담하며 이를 언급합니다. 또한, I/O는 운영 체제의 다른 영역에서 볼 수 있는 것처럼 정리된 추상화를 제공하지 못합니다.

노이즈 네이버

가상화 환경(거의 모든 클라우드 애플리케이션에서 사용됨)에서는 I/O 집약적인 애플리케이션이 노이즈 네이버(noisy neighbor) 문제를 야기할 수 있습니다. 하나의 컨테이너가 대역폭 또는 디스크 I/O와 같은 자원에 대해 높은 요구를 가질 때 발생하며, 동일한 물리적 머신에서 실행 중인 다른 사용자들의 성능에 부정적인 영향을 미칩니다.

성능 엔지니어는 이러한 가능성에 특히 주의를 기울여야 합니다. 이 문제는 직접적으로 탐지하기 어려운 경우가 많기 때문입니다.

7.5.4 기계적 공감

기계적 공감은 하드웨어에 대한 이해가 최대 성능을 끌어내야 할 경우 매우 중요하다는 개념입니다.

Quote

레이싱 드라이버가 엔지니어일 필요는 없지만, 기계적 공감은 반드시 필요합니다. — 재키 스튜어트

자바 가상 머신의 양면성

대부분의 자바 개발자에게는 기계적 공감을 무시하는 것이 가능합니다. 자바 가상 머신이 하드웨어로부터 일정 수준의 추상화 계층을 제공하여, 개발자가 성능 관련 문제를 덜 신경 쓰도록 해주기 때문입니다. 그러나 자바 가상 머신 또는 하드웨어와의 상호작용을 이해하면 자바와 자바 가상 머신을 활용하여 고성능 또는 저지연 환경에서 성공적으로 애플리케이션을 개발할 수 있습니다.

다만, 주의할 점은 자바 가상 머신이 성능 문제와 기계적 공감을 다루는 것을 더 어렵게 만든다는 것입니다. 자바 가상 머신은 고려해야 할 요소를 더 많이 추가하기 때문입니다.

거짓 공유

캐시 라인은 메모리 블록을 가져오는 데 유용하지만, 멀티스레드 환경에서는 문제가 될 수 있습니다. 두 개의 스레드가 동일한 캐시 라인에 위치한 변수에 접근하려고 할 때 경쟁 상태가 발생할 수 있습니다.

sequenceDiagram
    participant T1 as 스레드 1
    participant Line as 공유 캐시 라인<br/>[var A | var B]
    participant T2 as 스레드 2

    T1->>Line: var A 수정
    Note over Line,T2: T2의 캐시 라인 무효화
    T2->>Line: var B 읽기 (메모리 다시 가져옴)
    Note over T1,Line: T1의 캐시 라인 무효화
    T1->>Line: var A 수정 (다시 가져옴)
    Note over Line,T2: 무한 핑퐁

이러한 핑퐁 동작은 성능 저하를 초래하며, 이를 거짓 공유(false sharing)라고 부릅니다.

패딩으로 해결

자바에서는 객체의 필드 레이아웃이 보장되지 않으므로, 변수들이 같은 캐시 라인을 공유하게 되는 일이 흔합니다. 이를 피하는 한 가지 방법은 변수 주위에 패딩을 추가하여 변수들이 서로 다른 캐시 라인에 배치되도록 강제하는 것입니다.

자바 8부터는 @Contended 어노테이션이 이를 자동으로 처리해 줍니다. 다만 JVM 옵션을 명시적으로 활성화해야 작동합니다.

7.6 정리

프로세서 설계와 현대 하드웨어는 엄청난 변화를 겪어왔습니다. 무어의 법칙과 엔지니어링의 한계(특히 상대적으로 느린 메모리 속도)에 의해 발전한 프로세서 설계는 점점 더 난해한 영역으로 발전하고 있습니다. 캐시 누락 비율은 애플리케이션 성능을 평가하는 가장 중요한 지표가 되었습니다.

자바 환경에서는 자바 가상 머신 설계 덕분에 단일 스레드 애플리케이션 코드에서도 추가적인 프로세서 코어를 활용할 수 있습니다. 자바 애플리케이션은 다른 환경과 비교해 하드웨어 발전으로 인한 성능 이점을 크게 누리고 있습니다.

무어의 법칙이 점차 한계에 도달함에 따라, 소프트웨어의 상대적 성능에 다시 초점이 맞춰질 것입니다. 성능을 중시하는 엔지니어는 현대 하드웨어와 운영 체제의 기본적인 원리를 이해해야 하며, 이를 통해 하드웨어를 최대한 활용하고, 하드웨어와의 불필요한 충돌을 피할 수 있습니다.

비교 / 트레이드오프

캐시 계층별 속도와 용량 트레이드오프
계층접근 시간 (사이클)용량공유
레지스터1수 KB코어 전용
L1432~64 KB코어 전용
L211256 KB~1 MB코어 전용
L314~384~64 MB코어 공유
메인 메모리167GB 단위시스템 전체

레지스터에서 메인 메모리로 갈수록 속도는 느려지지만 용량은 커지는 명확한 트레이드오프 곡선입니다.

Write-Through vs Write-Back
항목Write-ThroughWrite-Back
쓰기 시점즉시 메모리에 반영캐시 블록 교체 시
메모리 대역폭 부담높음낮음
일관성 보장 난이도낮음높음 (MESI 등 필요)
사용처초기 CPU현대 CPU 표준
강한 메모리 모델 vs 약한 메모리 모델
항목강한 모델 (x86/AMD64)약한 모델 (ARM, POWER)
명령어 재배열 허용제한적광범위
코드 이식성”잘못된” 멀티스레드 코드도 동작동기화 누락이 즉시 드러남
성능 잠재력보수적적극적
자바 개발자 영향보호받음JMM 준수 필수
AOT(GraalVM) vs JIT 관점에서의 하드웨어 활용
항목AOTJIT
프로세서 고유 명령어 사용빌드 타깃에 종속런타임에 감지·활성화
새 CPU 기능 활용재빌드 필요JVM 업그레이드만으로 가능
시작 시간빠름느림 (워밍업 필요)

내 생각

캐시는 모든 추상화 위에서 지배한다

JVM, 데이터베이스, 분산 시스템 — 모든 계층에서 “데이터 지역성”이 성능의 핵심입니다. 자주 함께 접근하는 데이터를 물리적으로 가까이 두기라는 원칙은 캐시 라인 설계부터 데이터베이스 클러스터링 인덱스, CDN, 마이크로서비스의 데이터 동시 배치(co-location)까지 똑같이 적용됩니다. 한 계층의 직관이 다른 계층에서도 통하는 이유입니다.

vmstat은 가장 저평가된 도구

APM 대시보드 화려한 차트보다 vmstat 1 한 줄이 더 빠르게 문제를 가리키는 경우가 많습니다. 특히 cs(컨텍스트 스위치)와 wa(I/O 대기)가 동시에 튀는 패턴은 잠금 경합 또는 I/O 병목의 강력한 신호입니다. Kubernetes 환경에서는 kubectl exec 후 vmstat을 직접 보는 습관이 필요합니다.

거짓 공유는 측정해야만 보인다

거짓 공유는 코드 리뷰로 잡기 거의 불가능합니다. 자바는 객체 필드 레이아웃을 보장하지 않으므로, JOL(Java Object Layout) 같은 도구로 실제 메모리 배치를 확인해야 합니다. 고성능 동시 데이터 구조(예: LMAX Disruptor)는 모두 이 문제를 의식하고 패딩으로 해결합니다.

컨텍스트 스위치 비용은 가상 스레드의 가치를 설명한다

자바 21 가상 스레드가 인기 있는 이유는 단순히 “가벼워서”가 아니라, 모드 스위치를 피하기 때문입니다. 한 캐리어 스레드 위에서 수천 개의 가상 스레드를 다중화하면 커널 모드 전환과 TLB 무효화 비용을 거의 피할 수 있습니다. 이 챕터의 컨텍스트 스위치 이해 없이는 가상 스레드의 진짜 가치를 파악하기 어렵습니다.

”기계적 공감”은 SRE의 자세이기도 하다

이 용어는 보통 저지연 트레이딩 시스템 개발자의 영역으로 여겨지지만, 클라우드 환경 SRE에게도 동일하게 필요합니다. 노이즈 네이버, EBS 버스트 크레딧, NUMA 노드 어피니티 같은 개념은 모두 “하드웨어가 어떻게 동작하는지 이해해야 디버깅 가능”한 영역입니다.

더 알아볼 것

  • perf stat로 자바 애플리케이션의 캐시 미스율(L1/L3) 측정
  • JOL(Java Object Layout)로 실제 객체 메모리 레이아웃 확인
  • @Contended 어노테이션을 활용한 거짓 공유 회피 벤치마크 작성
  • taskset으로 자바 프로세스를 특정 CPU 코어에 고정하고 컨텍스트 스위치 빈도 비교
  • ARM(Graviton) vs x86 환경에서 동일한 멀티스레드 코드의 거동 차이 관찰
  • 리눅스 vmstat 1, iostat -x 1, ifstat 1을 동시에 띄워 상관관계 분석 연습
  • LMAX Disruptor 소스에서 패딩과 캐시 라인 정렬 방식 학습
  • vDSO를 사용하는 시스템 콜 목록 조사 (ls -la /proc/self/maps | grep vdso)

관련 개념

출처

자바 최적화 2판 (오라일리), Ben Evans 외 저, Chapter 7: Hardware and Operating Systems