Skip to main content

3. 하드웨어와 운영체제

자바 성능을 진지하게 높일려면, 자바 플랫폼의 근간 원리와 기술에 대해 알아야합니다.

메모리#

무어의 법칙에 따라 개수가 급증한 트랜지스터는 처음에는 클론 속도를 높이는데 쓰였습니다. 그러나 클론 속도가 증가하다 보니 프로세스 코어의 데이터 수요를 메인 메모리가 맞추기 힘들어졌습니다. 즉, 클론 속도가 올라가도 데이터가 도착할 때까지 CPU가 기다리게 됩니다.

메모리 캐시#

이를 위해서 CPU 캐시가 등장했습니다. 레지스터보다 빠르고 메인 메모리보다 빠르며, 자주 액세스하는 메모리 위치는 CPU 캐시에 보관하자는 아이디어로 등장했습니다. 일반적으로 CPU와 가까운 캐시 순으로 L1, L2, L3 등이 있으며 L1과 L2는 전용 프라이빗 캐시입니다.

image

이를 통해서 프로세서 처리율이 높아졌으나, 캐시한 데이터를 어떻게 메모리에 다시 써야 할지 결정해야합니다. 이 문제를 해결하기 위해서 캐시 일관성 프로토콜(cache consistency protocol) 라는 방법으로 해결합니다.

프로세서의 가장 저수준에서 MESI 프로토콜을 사용하는데 이 MESI 프로토콜은 캐시 라인 상태를 네가지로 정리합니다.

  • Modified(수정): 데이터가 수정된 상태
  • Exclusive(배타): 이 캐시에만 존재하고 메인 메모리 내용과 동일한 상태
  • Shared(공유): 둘 이상의 캐시에 데이터가 들어 있고 메모리 내용과 동일한 상태
  • Invalid(무효): 다른 프로세스가 데이터를 수정하여 무효한 상태

정리하자면, 멀리 프로세서는 동시에 공유 상태로 존재할 수 있습니다. 하지만 어느 한 프로세서가 배타나 수정 상태로 바뀌면 다른 프로세서는 강제로 무효상태가 됩니다.

이러한 캐시 스킬을 통해서, 데이터를 신속하게 메모리에서 쓰고 읽을 수 있게 되었습니다.

캐시 하드웨어의 작동 원리 코드를 살펴볼 수 있습니다.

public class Caching {
private Final int ARR_SIZE = 2 * 1024 * 1024;
private Final int[] testData = new int[ARR_SIZE];
private void run() {
System.err.println("Start: "+ System.currentTimeMillis());
for (int i = 0; i < 15_000; i++) {
touchEveryLine();
touchEveryItem();
}
System.err.println("Warmup Finished: "+ System.currentTimeMillis());
System.err.println("Item Line");
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]++;
}
public static void main(String[] args) {
Caching c = new Caching();
c.run();
}
}

위 코드를 실행하면, touchEveryItem() 메서드가 touchEveryLine() 메서드보다 더 많은 일을 할 것 같지만, 소요시간은 비슷합니다. 그 이유는 메모리 버스를 예열시키는 부분이 가장 큰 영향이 미쳐서 그렇습니다.

즉, 자바 성능을 논할 때는 객체 할당률에 따른 애플리케이션 민감도가 중요합니다.


최신 프로세서의 특성#

메모리 캐시가 트랜지스터를 활용하는 가장 큰 분야이지만 이외에도 여러 기술들이 나왔습니다.

변환 색인 버퍼(TLB)#

여러 캐시에서 중요하게 사용하는 장치입니다. 가상 메모리 주소를 물리 메모리 주소로 매핑하는 페이지 테이블의 캐시 역할을 수행합니다. 이 덕분에 가상 주소를 참조해 물리 주소에 액세스하는 빈번한 작업 속도가 매우 빨라집니다. 현재의 칩에서는 TLB는 거의 필수입니다.

분기 예윽과 추측 실행#

분기 예측은 최신 프로세서의 고급 기법 중 하나이며 프로세서가 조건 분기하는 기준 값을 평가하느라 대기하는 현상을 방지합니다. 요즘의 프로세서는 다단계 명령 파이프라인을 통해서 1사이클을 여러 개발 단계로 나누어 실행합니다.

조건문을 다 평가하기 전까지 분기 이후 명령을 알수 없는 것이 문제가 되는데, 프로게세서는 잉여 트랜지스터를 사용해 발생 가능성이 큰 브랜치를 미리 결정하는 휴리스틱을 형성합니다. 추측이 맞을 때는 CPU는 다음 작업을 진행하고, 틀리면 부분적으로 실행한 명령을 모두 폐기하느 방식으로 갑니다.

하드웨어 메모리 모델#

서로 다른 CPU가 일관되게 동일한 메모리 주소를 액세스 할 수 있을까라는 질문을 해결하기 위해서 나왔습니다.

단계를 최적화하기 위해 코드 조건에 따라 순서를 바꿀 수 있습니다. JVM은 프로세서 타입별로 상이한 메모리 액세스 일관성을 고려해서 명시적인 약한 모델로 설계되었습니다. 따라서 코드가 제대로 작동하기 위해서는 락과 volatile을 정확하게 알고 사용해야합니다.


운영체제#

OS의 주 임무는 여러 실행 프로세스가 공유하는 리소스 액세스를 관장하는 일입니다. 한정된 리소스를 골고루 나눠줄 기능이 있어야합니다.

메모리 관리 유닛(MMU, Memory Management Unit)을 통해 가상 주소 방식과 페이지 테이블은 메모리 액세스 제어의 핵심으로 한 프로세스가 소유한 메모리 영역을 다른 프로세스가 함부로 훼손하지 못하게 합니다.

스케줄러#

프로세스 스케줄러는 실행 큐를 통해서 CPU 액세스를 통제합니다. 스케줄러는 인터럽트에 응답하고 CPU 코어 액세스를 관리합니다.

스레드 생명주기

스케줄러의 움직임을 확인하는 가장 쉬운 방법은 OS가 스케줄링 과정에서 발생시킨 오버헤드를 관측하는 것입니다.

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) / 4000.0);

이 코드는 OS마다 결과가 다릅니다. Mac OS 제 pc 기준으로는 0.3075 밀리초가 나옵니다.

타이밍은 성능 측정, 프로세스 스케줄링, 기타 애플리케이션 스택의 다양한 파트에서 아주 중요ㅕ합니다.

시간 문제#

OS는 저마다 다르게 동작합니다. 그래서 System.currentTimeMillis() 와 같은 메서드도 OS가 제공하는 기능에 의존하기 때문에 다릅니다.

컨텍스트 교환#

컨첵스트 교환은 OS 스케줄러가 현재 실행 중인 스레드/테스크를 없애고 대기 중인 다른 스레드/테스크로 대체하는 프로세스입니다. 다만 이 경우에 유저 모드에서 커널 모드로 바뀌기 때문(mode switch)에 비싼작업입니다. 특히 커널공간와 유저공간이 바뀌게 된다면, 다른 캐시를 어쩔 수 없이 강제로 비워야합니다.커널 모드로 컨텍스트가 교환되면 TLB를 비롯한 다른 캐시까지도 무효화가 됩니다.

이러한 문제를 해결하기 위해서 리눅스는 가상 동적 공유 객체(vDSO, virtual Dynamically Shared Object)라는 장치를 제공합니다. 이는 커널의 권한이 필요 없는 시스템 콜의 속도를 높이려고 쓰는 유저 공간의 메모리 영역입니다. 이 경우에는 컨텍스트 교환이 일어나지 않기 때문에 빠릅니다.


단순 시스템 모델#

자바 애플리케이션의 단순 개념으로 기본 컴포넌트로 구성됩니다.

  • 애플리케이션이 실행되는 하드웨어와 OS
  • 애플리케이션이 실행되는 JVM/컨테이너
  • 애플리케이션 코드 자체
  • 애플리케이션이 호출되는 외부 시스템
  • 애플리케이션으로 유입되는 트래픽

image


기본 감지 전략#

애플리케이션이 잘돌아가는 것은 CPU 사용량, 메모리, 네트워크, I/O 대역폭 등 시스템 리소스를 효율적으로 잘 이용하고 있습니다.

성능 진단의 첫 단계는 어느 리소스가 한계에 달했는지 확인하는 일입니다.

CPU 사용률#

CPU 사용률은 애플리케이션 성능을 나타내는 핵심 지표입니다. 부하가 집중될때는 사용률이 가능한 100%에 가까워지는 것이 좋습니다.

일반적으로는 기본 툴 2가지 vmstatiostat 정도는 쓸 줄 알아야합니다. 특히 대다수 vmstat은 컨텍스트 교환 발생 횟수를 나타내는데, CPU 사용률이 100% 근처도 못갔는데 컨텍스트 교환 비율이 높으면 I/O에서 블로킹이 일어났거나 스레드 락 경합이 발생했을 확률 이 높습니다.

다만 vmstat 출력 결과를 봐서는 여러가지 경우의 수를 확인하기 어렵기 때문에 VisualVM을 사용하는 것이 좋습니다.

가비지 수집#

핫스팟 JVM은 시작 시메모리를 유저 공간에 할당/관리합니다. 그래서 메모리를 할당하느라 시스템 콜을 할 필요가 없습니다. 즉, 가비지 수집을 하려고 커널 교환을 할 일이 없습니다.

일반적으로 CPU 사용률이 아주 높다면 GC 문제는 아니지만, JVM 프로세스가 유저 공간에 CPU를 100% 사용하고 있다면 GC를 의심해야합니다.

입출력#

파일 I/O는 예부터 전체 시스템 성능에 암적인 존재였습니다. 그렇기에 대부분의 자바 프로그램은 단순한 I/O만 처리합니다. 일반적으로 I/O가 집중되는 애플리케이션이 하나만 있는 경우, iostat 같은 툴은 기초 진단에 좋아집니다.

커널 바이패스 I/O#

커널을 통해 데이터를 복사해 유저 공간에 넣는 작업은 비싼 작업입니다. 그래서 이를 대신 매핑해주는 전용 HW/SW를 사용합니다. 이를 통해서 컨텍스트 교환이나 이중 복사를 막을 수 있습니다.

image

기계 공감#

기계 공감은 성능을 조금이라도 더 높여야하는 상황이라면, 공감할 수 있는 능력이 필요합니다. 톡히 고성능, 저지연이 필수인 분야에서 개발자가 자바/VJM을 효과적으로 활용하려면 JVM이 무엇이고 하드웨어와 어떻게 상화작용하는 지 이해해야합니다.


가상화#

가상화의 특징은 다음과 같습니다.

  • 가상화 OS에서 실행하는 프로그램은 비가상화 OS에서 실행할 때와 동일하게 작동해야합니다.
  • 하이퍼바이저는 모든 하드웨어 리소스 액세스를 조정해야합니다.
  • 가상화 오버헤드는 가급적 작아야 하며 실행 시간의 상당 부분을 차지해서는 안됩니다.

가상화 시스템에서는 비가상화 시스템처럼 게스트 OS가 하드웨어에 직접 액세스 할 수 없습니다. 따라서 대부분의 커널 명령어를 unpriviledged 명령어로 고쳐서 사용해야합니다. 또 과한 TLB가 일어나지 않도록 일부 OS 커널의 자료 구조는 섀도 해야합니다.


JVM과 운영체제#

JVM은 자바 코드에 공용 인터페이스르를 제공해서 OS에 독립적인 실행 환경을 제공합니다. 하지만 스레드 스케줄링과 같은 기본적인 서비스조차 하부 OS에 반드시 액세스해야하며 이런 기능은 natvie 키워드를 붙인 네이티브 메서드로 구현합니다. 이작업을 대행하는 공통 인터페이스를 자바 네이티브 인터페이스(JNI, Java Native Interface)라고 합니다.

이러한 핫스팟 호출을 확인하면 다음처럼 확인할 수 있습니다.

image

Last updated on