하드웨어와 운영체제
왜 Java 개발자들은 하드웨어에 신경 써야 하는가?
이 현상은 시간에 따른 컴퓨터 성능의 기하급수적 증가를 나타냅니다. 1965년에 처음 언급된 무어의 법칙은 인간 발전 역사상 거의 비할 데 없는 놀라운 장기 추세를 대표합니다. 무어의 법칙의 영향은 현대 세계의 많은(아니 대부분의) 분야에서 변혁적이었습니다.
참고 사항
무어의 법칙의 종말은 수십 년 동안 반복적으로 선포되어 왔습니다. 그러나 실용적인 측면에서 이 놀라운 트랜지스터 기술의 진보가(마침내) 끝났다고 여길 만한 매우 타당한 이유가 있습니다.
이로 인해 평범한 애플리케이션 개발자가 사용할 수 있는 성능의 대규모 증가는 복잡한 소프트웨어의 만개로 이어졌습니다. 소프트웨어 애플리케이션은 이제 전 세계 사회의 모든 측면에 깊이 침투해 있습니다.
Java는 컴퓨터 성능 증가의 혜택을 입은 대표적인 언어 중 하나입니다. 언어와 런타임의 설계는 프로세서 능력의 추세를 잘 활용하거나 운 좋게도 이를 이용할 수 있도록 되어 있습니다. 그러나 진정으로 성능에 민감한 Java 프로그래머는 플랫폼을 뒷받침하는 원리와 기술을 이해해야 가용 자원을 최적으로 활용할 수 있습니다.
현대 하드웨어 소개
많은 대학의 하드웨어 아키텍처 강좌는 여전히 이해하기 쉬운 고전적인 하드웨어 관점을 가르치고 있습니다. 이 "어머니와 사과 파이" 관점은 산술, 논리, 로드 및 저장 작업이 있는 레지스터 기반 머신의 단순한 관점에 초점을 맞춥니다. 결과적으로, CPU가 실제로 하는 일과 비교할 때 C 프로그래밍을 과도하게 강조합니다. 이는 현대 시대에 사실적으로 부정확한 세계관입니다.
이 장에서는 CPU 기술의 몇 가지 진보된 측면을 논의할 것입니다. 가장 중요한 것은 메모리의 동작으로, 이는 현대 Java 개발자에게 가장 중요한 부분입니다.
메모리
무어의 법칙이 진전됨에 따라 기하급수적으로 증가하는 트랜지스터 수는 초기에는 더 빠른 클럭 속도를 위해 사용되었습니다. 그 이유는 분명합니다: 더 빠른 클럭 속도는 초당 더 많은 명령을 완료할 수 있음을 의미하기 때문입니다. 이에 따라 프로세서의 속도는 크게 발전했으며, 오늘날의 2+ GHz 프로세서는 최초 IBM PC에 사용된 4.77 MHz 칩보다 수백 배 빠릅니다.
그러나 클럭 속도의 증가로 인해 또 다른 문제가 드러났습니다. 더 빠른 칩은 더 빠른 데이터 스트림을 필요로 합니다. 그림 3-1에서 볼 수 있듯이, 시간이 지남에 따라 주 메모리가 프로세서 코어의 새로운 데이터 요구를 따라가지 못하게 되었습니다.
이로 인해 문제가 발생합니다: CPU가 데이터를 기다리고 있다면, 더 빠른 사이클은 도움이 되지 않습니다. CPU는 필요한 데이터가 도착할 때까지 대기해야 하기 때문입니다.
메모리 캐시
이 문제를 해결하기 위해 CPU 캐시가 도입되었습니다. 이는 CPU 레지스터보다는 느리지만 주 메모리보다는 빠른 메모리 영역입니다. 아이디어는 CPU가 주 메모리를 재주소 지정하는 대신 자주 접근하는 메모리 위치의 복사본을 캐시에 채우는 것입니다.
현대 CPU는 여러 계층의 캐시를 가지고 있으며, 가장 자주 접근하는 캐시는 처리 코어에 가깝게 위치합니다. CPU에 가장 가까운 캐시는 일반적으로 L1(레벨 1 캐시)이라고 불리며, 다음은 L2, 그 다음은 L3 캐시로 불립니다. 다양한 프로세서 아키텍처는 다양한 수와 구성을 가진 캐시를 가지고 있지만, 일반적인 선택은 각 실행 코어에 전용의 개인 L1 및 L2 캐시를 두고, 일부 또는 모든 코어에 공유되는 L3 캐시를 두는 것입니다. 이러한 캐시가 접근 시간을 단축시키는 효과는 그림 3-2에서 볼 수 있습니다.
이러한 캐시 아키텍처 접근 방식은 접근 시간을 개선하고 코어가 운영할 데이터를 충분히 확보하도록 돕습니다. 클럭 속도 대비 접근 시간 격차로 인해 현대 CPU에서는 캐시에 더 많은 트랜지스터 예산이 할당됩니다.
결과적인 설계는 그림 3-3에서 볼 수 있습니다. 이는 각 CPU 코어에 전용 L1 및 L2 캐시가 있고, CPU의 모든 코어에 공통으로 사용되는 공유 L3 캐시가 있는 구조를 보여줍니다. 주 메모리는 노스브리지(Northbridge) 컴포넌트를 통해 접근되며, 이 버스를 통해 주 메모리에 접근할 때 접근 시간이 크게 떨어집니다.
캐싱 기술의 전반적인 효과는 메모리 접근 속도를 크게 증가시키는 것입니다. 이는 메모리 대역폭 측면에서 표현됩니다. 이론적인 최대치는 여러 요소에 기반합니다:
메모리의 클럭 주파수
메모리 버스의 너비(일반적으로 64비트)
인터페이스 수(현대 머신에서는 보통 두 개)
이론적인 최대 쓰기 속도는 DDR RAM의 경우 8–12 GB/s입니다. 물론 실제로는 시스템의 여러 다른 요소에 의해 제한될 수 있습니다. 현재 상태에서는 하드웨어와 소프트웨어가 얼마나 가까이 성능 최대치를 달성할 수 있는지를 확인할 수 있는 상당히 유용한 값을 제공합니다.
캐시 일관성 프로토콜
캐싱 아키텍처는 접근 시간을 개선하지만, 캐시에 데이터를 가져오고 다시 쓰는 방식을 결정하는 새로운 문제를 도입합니다. 이러한 문제에 대한 해결책은 일반적으로 캐시 일관성 프로토콜로 불립니다.
참고 사항: 멀티코어 환경에서 이러한 캐싱을 적용할 때 발생하는 다른 문제들도 있으며, 이는 본 책의 이후 장에서 다룰 예정입니다.
가장 기본적인 수준에서, MESI(MESI: Modified, Exclusive, Shared, Invalid)라는 프로토콜이 다양한 프로세서에서 흔히 발견됩니다. 이는 캐시의 각 라인에 대해 네 가지 상태를 정의합니다. 각 라인은 다음 중 하나입니다:
Modified (수정됨, 아직 주 메모리에 플러시되지 않음)
Exclusive (이 캐시에만 존재하며 주 메모리와 일치함)
Shared (다른 캐시에도 존재할 수 있으며 주 메모리와 일치함)
Invalid (사용할 수 없으며 가능한 한 빨리 제거됨)
이 프로토콜의 아이디어는 여러 프로세서가 동시에 Shared 상태일 수 있다는 것입니다. 그러나 프로세서가 Exclusive 또는 Modified 상태로 전환하면, 다른 모든 프로세서는 Invalid 상태로 강제됩니다. 이는 표 3-1에서 볼 수 있습니다.
M | E | S | I | |
---|---|---|---|---|
M | - | - | - | Y |
E | Y | - | - | - |
S | - | - | Y | Y |
I | Y | Y | Y | Y |
프로토콜은 프로세서가 상태를 변경하려는 의도를 브로드캐스트하여 작동합니다. 이는 공유 메모리 버스를 통해 전기 신호를 보내어 다른 프로세서들이 이를 인지하게 합니다. 상태 전환에 대한 전체 논리는 그림 3-4에서 보여줍니다.
원래, 프로세서는 모든 캐시 작업을 주 메모리에 직접 기록했습니다. 이를 write-through 동작이라고 했지만, 매우 비효율적이며 주 메모리 대역폭을 많이 요구했기 때문에 문제가 되었습니다. 최신 프로세서는 write-back 동작도 구현하여, 변경된(더러운) 캐시 블록만을 메모리에 다시 씀으로써 주 메모리로의 트래픽을 크게 줄였습니다.
캐싱 기술의 전반적인 효과는 메모리를 쓰거나 읽는 속도를 크게 향상시키는 것입니다. 이는 메모리 대역폭 측면에서 표현됩니다. 버스트 속도(이론적 최대)는 여러 요소에 기반합니다:
메모리 클럭 주파수
메모리 버스의 너비(일반적으로 64비트)
인터페이스 수(현대 머신에서는 보통 두 개)
이론적인 최대 쓰기 속도는 DDR RAM의 경우 8–12 GB/s입니다. 실제로는 시스템의 여러 다른 요소에 의해 제한될 수 있지만, 이는 하드웨어와 소프트웨어가 성능 최대치를 얼마나 가까이 달성할 수 있는지를 확인할 수 있는 유용한 값입니다.
캐시 일관성 프로토콜의 요점
MESI 프로토콜은 캐시 일관성을 유지하기 위한 기본 메커니즘으로, 캐시 라인의 상태를 관리하여 다중 프로세서 환경에서 데이터 일관성을 유지합니다.
Write-back 동작을 통해 불필요한 메모리 쓰기를 줄이고, 성능을 향상시킵니다.
번역 사이드 어사이드 버퍼 (TLB)
캐시 외에, 현대 프로세서에서 중요한 또 다른 캐시는 **번역 사이드 어사이드 버퍼 (Translation Lookaside Buffer, TLB)**입니다. 이는 가상 메모리 주소를 물리 주소로 매핑하는 페이지 테이블의 캐시로, 가상 주소 접근 시 물리 주소를 신속하게 찾을 수 있도록 합니다.
참고 사항: JVM의 메모리 관련 소프트웨어 기능에도 TLB라는 약어가 사용되지만, 이는 하드웨어의 TLB와는 다릅니다. TLB가 언급될 때는 항상 어떤 기능을 다루고 있는지 확인해야 합니다.
TLB가 없으면 모든 가상 주소 조회는 16 사이클이 걸리며, 이는 성능에 심각한 영향을 미칩니다. 따라서 TLB는 모든 현대 칩에서 필수적인 요소입니다.
분기 예측과 투기적 실행
현대 프로세서에서 나타나는 고급 기술 중 하나는 **분기 예측 (Branch Prediction)**입니다. 이는 조건 분기에서 필요한 값을 평가하기 전에 프로세서가 멈추지 않도록 하기 위한 것입니다. 현대 프로세서는 다단계 명령 파이프라인을 가지고 있으며, 이는 단일 CPU 사이클의 실행을 여러 개의 개별 단계로 나눕니다. 이로 인해 하나의 프로세서 사이클에서 여러 명령이 동시에 실행될 수 있습니다.
조건 분기에서 문제가 발생하는 이유는 조건을 평가하기 전까지는 분기 후의 다음 명령어가 무엇인지 알 수 없기 때문입니다. 이로 인해 프로세서는 여러 사이클 동안 대기해야 할 수 있습니다(실제로는 최대 20 사이클까지).
참고 사항: 투기적 실행은 2018년 초에 발견된 대규모 CPU 보안 문제의 원인이 되었습니다.
이 문제를 피하기 위해 프로세서는 분기가 더 많이 발생할 가능성이 높은 방향을 결정하기 위한 휴리스틱을 구축하는 데 트랜지스터를 할당합니다. 이 추측을 사용하여 프로세서는 도박을 통해 파이프라인을 채웁니다. 예측이 맞으면 프로세서는 아무 일도 없었던 것처럼 계속 실행합니다. 예측이 틀리면 일부 실행된 명령어를 폐기하고, 프로세서는 파이프라인을 비우는 페널티를 지불해야 합니다.
하드웨어 메모리 모델
멀티코어 시스템에서 메모리에 여러 CPU가 일관되게 접근할 수 있는 방법은 하드웨어에 크게 의존하지만, 일반적으로 Java 환경에서는 **Java 메모리 모델 (Java Memory Model, JMM)**이 다양한 프로세서 유형 간의 메모리 접근 일관성 차이를 고려하여 약한 모델로 설계되었습니다. 올바른 락 사용과 volatile 접근은 다중 스레드 코드가 제대로 작동하도록 보장하는 중요한 부분입니다. 이는 매우 중요한 주제로, 12장에서도 다시 다룰 예정입니다.
예를 들어, 다음과 같은 코드가 있다고 가정해 봅시다:
두 할당 사이에 코드가 없으므로, 실행 중인 스레드는 이들 사이의 실행 순서에 신경 쓸 필요가 없습니다. 따라서 환경은 명령어의 순서를 변경할 수 있습니다. 그러나 이는 다른 스레드에서 이러한 데이터 항목에 대한 가시성을 가지고 있는 경우, 두 번째 스레드에서 myInt
의 값을 오래된 값으로 읽을 수 있음을 의미합니다.
x86 칩에서는 이러한 재배치(스토어 후 스토어 이동)가 불가능하지만, 표 3-2에서 볼 수 있듯이 다른 아키텍처에서는 이러한 일이 발생할 수 있습니다.
ARMv7 | POWER | SPARC | x86_64 | AMD64 | zSeries | |
---|---|---|---|---|---|---|
Loads moved after loads | Y | Y | - | - | - | - |
Loads moved after stores | Y | Y | - | - | - | - |
Stores moved after stores | Y | Y | - | - | - | - |
Stores moved after loads | Y | Y | Y | Y | Y | Y |
Atomic moved with loads | Y | Y | - | - | - | - |
Atomic moved with stores | Y | Y | - | - | - | - |
Incoherent instructions | Y | Y | Y | Y | - | Y |
Java 환경에서 JMM은 다양한 프로세서 유형 간의 메모리 접근 일관성 차이를 고려하여 약한 모델로 설계되었습니다. 올바른 락과 volatile 접근은 다중 스레드 코드가 올바르게 작동하도록 보장하는 중요한 요소입니다. 이는 매우 중요한 주제로, 12장에서도 다시 다룰 예정입니다.
운영 체제
운영 체제의 주요 목적은 여러 실행 프로세스 간에 공유되어야 하는 자원에 대한 접근을 제어하는 것입니다. 모든 자원은 한정되어 있으며, 모든 프로세스는 탐욕적이므로 중앙 시스템이 접근을 중재하고 조절하는 것이 필수적입니다. 이러한 희소 자원 중 가장 중요한 두 가지는 보통 메모리와 CPU 시간입니다.
가상 주소 지정은 메모리 관리 장치(MMU)와 페이지 테이블을 통해 메모리에 대한 접근을 제어하는 핵심 기능으로, 하나의 프로세스가 다른 프로세스가 소유한 메모리 영역을 손상시키지 않도록 합니다. 이전에 논의한 TLB는 물리 메모리에 대한 조회 시간을 개선하는 하드웨어 기능입니다. 버퍼의 사용은 소프트웨어의 메모리 접근 시간에 대한 성능을 향상시킵니다. 그러나 MMU는 보통 개발자가 직접 영향을 미치거나 인지할 수 없을 정도로 낮은 수준입니다. 대신, 운영 체제의 프로세스 스케줄러를 자세히 살펴보겠습니다. 이는 CPU 접근을 제어하는 운영 체제 커널의 훨씬 더 사용자에게 보이는 부분입니다.
스케줄러
CPU에 대한 접근은 프로세스 스케줄러에 의해 제어됩니다. 이는 실행 큐(run queue)라는 대기 영역을 사용하여 실행될 자격은 있지만 CPU를 기다려야 하는 스레드나 프로세스를 대기시킵니다. 현대 시스템에서는 항상 실행될 스레드나 프로세스가 더 많기 때문에 CPU 경쟁이 발생하며, 이를 해결하기 위한 메커니즘이 필요합니다.
스케줄러의 역할은 인터럽트에 응답하고 CPU 코어에 대한 접근을 관리하는 것입니다. Java 스레드의 라이프사이클은 그림 3-6에 나타나 있습니다. 이론적으로, Java 사양은 Java 스레드가 반드시 운영 체제 스레드와 일치하지 않는 스레딩 모델을 허용합니다. 그러나 실제로 이러한 "그린 스레드" 접근 방식은 유용하지 않아 주류 운영 환경에서 버려졌습니다.
이 단순한 관점에서 운영 체제 스케줄러는 단일 코어에서 스레드를 제거하고 대기 큐의 뒤로 옮겨 실행할 스레드를 대기시킵니다. 시간 할당량(time quantum)이 끝나면 스케줄러는 스레드를 실행 대기 큐의 뒤로 이동시켜 다시 실행할 때까지 기다립니다.
스케줄러의 실제 동작
현실적인 하드웨어는 더 복잡하며, 거의 모든 현대 머신은 여러 코어를 가지고 있어 여러 실행 경로를 동시에 실행할 수 있습니다. 이는 진정한 멀티프로세싱 환경에서의 실행을 추론하는 것을 매우 복잡하고 직관에 어긋나게 만듭니다.
운영 체제의 중요한 기능 중 하나는 그 본질상 코드가 CPU에서 실행되지 않는 기간을 도입한다는 점입니다. 시간 할당량이 끝난 프로세스는 다시 실행 대기 큐의 앞에 도착할 때까지 CPU에 다시 올라오지 않습니다. 이는 CPU가 희소한 자원이기 때문에 코드가 실행되기보다는 더 자주 대기하게 됨을 의미합니다.
이로 인해 관찰하고자 하는 프로세스의 통계는 시스템의 다른 프로세스의 동작에 의해 영향을 받을 수 있습니다. 이러한 "지터(jitter)"와 스케줄링 오버헤드는 관찰된 결과의 주요 노이즈 원인입니다. 다음 섹션에서는 실제 결과의 통계적 속성과 처리 방법에 대해 논의할 것입니다.
GC (가비지 컬렉션)
HotSpot JVM(가장 널리 사용되는 JVM)에서는 메모리가 시작 시 할당되고 사용자 공간에서 관리됩니다. 이는 메모리 할당을 위해 시스템 호출(예: sbrk()
)이 필요 없음을 의미합니다. 따라서 가비지 컬렉션으로 인한 커널 스위칭 활동은 매우 최소화됩니다.
따라서 시스템이 높은 시스템 CPU 사용률을 보인다면, 이는 가비지 컬렉션 활동이 아니라 JVM 또는 사용자 코드 때문일 가능성이 높습니다. JVM 프로세스가 사용자 공간에서 100%에 가까운 CPU 사용률을 보인다면, 이는 종종 GC 서브시스템이 원인입니다. 성능 문제를 분석할 때, 간단한 도구(vmstat
등)를 사용하여 일관되게 100% CPU 사용률이 나타나면 JVM의 GC 로그를 확인하여 얼마나 자주 새로운 엔트리가 추가되는지 확인하는 것이 유용한 규칙입니다.
JVM의 가비지 컬렉션 로그는 매우 저렴하며, 분석을 위한 데이터 소스로서 매우 유용합니다. 따라서 모든 JVM 프로세스, 특히 프로덕션 환경에서 GC 로그가 활성화되어 있는지 확인하는 것이 필수적입니다.
I/O
파일 I/O는 전통적으로 전체 시스템 성능에서 모호한 측면 중 하나였습니다. 이는 물리적 하드웨어와의 밀접한 관계와 더불어, I/O가 다른 운영 체제에서처럼 깔끔한 추상화를 제공하지 않기 때문입니다. 그러나 대부분의 Java 애플리케이션은 일부 간단한 I/O를 포함하고 있으며, 고성능 I/O를 동시에 포화시키지 않는 경향이 있습니다.
성능 분석자에게는 애플리케이션의 I/O 동작을 인식하는 것이 중요합니다. iostat
과 같은 도구는 기본 카운터(예: 블록 인/아웃)를 제공하여 기본적인 진단을 수행하는 데 충분합니다.
커널 바이스 I/O
일부 고성능 애플리케이션에서는 데이터를 네트워크 카드에서 사용자 공간으로 직접 매핑하기 위해 커널을 우회하는 **커널 바이스 I/O(Kernel Bypass I/O)**를 사용합니다. 이는 "더블 카피(double copy)"를 피하고, 사용자 공간과 커널 간의 경계를 넘지 않도록 합니다. Java는 이 모델을 직접 지원하지 않지만, 고성능 I/O를 필요로 하는 시스템에서 점점 더 많이 사용되고 있습니다.
가상화
가상화는 여러 형태로 나타나지만, 가장 일반적인 형태 중 하나는 운영 체제의 복사본을 이미 실행 중인 운영 체제 위에서 단일 프로세스로 실행하는 것입니다. 이는 그림 3-10에서 볼 수 있듯이, 가상 환경이 실체적인 운영 체제 위에서 실행되는 단일 프로세스로 운영 체제를 가상화하는 상황을 보여줍니다.
가상화의 이론과 애플리케이션 성능 튜닝에 대한 영향은 너무 방대하여 여기서 모두 다루지는 않지만, 가상화가 Java 애플리케이션 성능에 미치는 차이를 간략히 언급하는 것이 적절합니다.
가상화는 원래 1970년대 IBM 메인프레임 환경에서 개발되었지만, 최근까지 x86 아키텍처에서 "진정한" 가상화를 지원할 수 있었습니다. 이는 다음 세 가지 조건으로 특징지을 수 있습니다:
가상화된 운영 체제에서 실행되는 프로그램은 사실상 실체적인 환경에서 실행되는 것과 동일하게 동작해야 합니다.
하이퍼바이저는 모든 하드웨어 자원에 대한 접근을 중재해야 합니다.
가상화의 오버헤드는 가능한 한 작아야 하며, 실행 시간의 상당 부분을 차지하지 않아야 합니다.
일반적인 비가상화된 시스템에서, 운영 체제 커널은 특수한 특권 모드에서 실행되어 하드웨어에 직접 접근할 수 있습니다. 그러나 가상화된 시스템에서는 게스트 OS가 하드웨어에 직접 접근할 수 없게 됩니다. 대신, 특권 명령어를 비특권 명령어로 다시 작성하거나, 일부 OS 커널 데이터 구조를 "쉐도잉(shadowing)"하여 캐시 플러싱을 방지하는 등의 접근 방식이 사용됩니다.
일부 최신 Intel 호환 CPU는 가상화된 OS의 성능을 향상시키기 위한 하드웨어 기능을 가지고 있습니다. 그러나 하드웨어 지원이 있더라도 가상화된 환경에서 실행되는 것은 성능 분석과 튜닝에 추가적인 복잡성을 초래합니다.
JVM과 운영 체제
JVM은 운영 체제와 독립적인 포터블 실행 환경을 제공하여 Java 코드에 대한 공통 인터페이스를 제공합니다. 그러나 스레드 스케줄링과 같은 기본 서비스는 운영 체제에 의존합니다. 이는 네이티브 메서드를 통해 제공되며, native
키워드로 표시됩니다. 예를 들어, java.lang.Object
클래스는 다음과 같은 네이티브 메서드를 선언합니다:
이들 메서드는 상대적으로 낮은 수준의 플랫폼 문제를 다루므로, 예를 들어 시스템 시간을 가져오는 것과 같은 단순한 예제를 살펴보겠습니다.
시스템 시간 가져오기 예제
os::javaTimeMillis()
함수는 Java의 System.currentTimeMillis()
정적 메서드를 구현하는 시스템 특정 코드입니다. 실제 작업을 수행하는 코드는 C++로 작성되었지만, Java의 System.currentTimeMillis()
메서드에서 C 코드의 "브리지"를 통해 접근됩니다. 다음은 HotSpot에서 이 코드가 어떻게 호출되는지를 보여줍니다.
JVM_CurrentTimeMillis()
는 JVM의 엔트리 포인트 메서드로, 이는 C 함수처럼 보이지만 실제로는 C++ 함수로 C 호출 규칙으로 내보내집니다. 이 호출은 OpenJDK 소스 코드의 OS 특정 하위 디렉토리에서 정의된 os::javaTimeMillis()
함수를 통해 이루어집니다. 이는 Java의 플랫폼 독립적인 부분이 기본 하드웨어 및 운영 체제의 서비스에 접근하는 방식을 보여주는 간단한 예제입니다.
Last updated