자바 최적화 책 정리
최신 하드웨어 소개
✅ 최신 하드웨어 지식
1990년대 이후, 애플리케이션 개발자 세상은 대부분 인텔 x86/x64 아키텍처 위주로 돌아갔다.
이 장에서는 이를 포함해 그간 발전된 여러 가지 CPU 기술들을 알아볼 것이다.
메모리
✅ CPU와 메모리의 갭
CPU를 구성하는 트랜지스터는 처음에 클록 속도(clock speed)를 높이는데 쓰였다.
하지만, 클록 속도가 증가할수록 데이터도 빨리 움직여야 하는데,
시간이 갈수록 프로세서 코어의 데이터 수요를 메인 메모리가 맞추기 어려워졌다.
메모리 캐시
✅ CPU 캐시
그래서 CPU 캐시가 고안됐다.
CPU 캐시는 CPU에 있는 메모리 영역으로, 레지스터보다는 느리지만 메인 메모리보다는 훨씬 빠르다.
-> 자주 액세스하는 메모리 위치는 CPU가 메인 메모리의 사본을 떠서 CPU 캐시에 보관하자는 아이디어다.
✅ 캐시 종류
액세스 빈도가 높은 캐시일수록 프로세서 코어와 더 가까이 위치하는 식으로 여러 캐시 계층이 있다.
- L1: CPU와 가장 가까운 캐시, 실행 코어 전용 프라이빗 캐시
- L2: 그 다음 가까운 캐시, 실행 코어 전용 프라이빗 캐시
- L3: 전체 코어가 공유하는 캐시
✅ MESI
캐시 메모리의 일관성을 유지하기 위한 프로토콜.
- Modified(수정): 데이터가 수정된 상태, 다른 프로세서가 접근할 수 없다.
- Exclusive(배타): 이 캐시에만 존재하고 메인 메모리 내용과 동일한 상태, 다른 프로세서가 접근할 수 없다.
- Shared(공유): 둘 이상의 캐시에 데이터가 들어 있고 메모리 내용과 동일한 상태, 다른 프로세서가 접근할 수 있다.
- Invalid(무효): 다른 프로세스가 데이터를 수정하여 무효한 상태, 다른 프로세서가 접근할 수 없다.
-> 프로세서가 캐시의 상태를 바꾸겠다는 의사를 공유 메모리 버스를 통해 다른 프로세서들에게 브로드캐스팅한다.
✅ 캐시 -> 메모리 기록
동시 기록(write-through): 캐시 연산 결과를 바로 메모리에 기록, 메모리 대역폭을 너무 많이 소모해서 요즘은 거의 안 쓴다.
후기록(write-back): 캐시 연산 결과 데이터가 수정되면 더티(dirty) 상태로 표시하고, 이후에 캐시 블록이 교체될 때, 더티 상태의 캐시만 메모리에 기록한다. 이러면 메모리 트래픽을 줄일 수 있다.
-> 이러한 캐시 기술로 인해 데이터를 신속하게 메모리에서 쓰고 읽을 수 있게 됐다.
최신 프로세서의 특성
변환 색인 버퍼(TLB)
✅ TLB
가상 메모리 주소를 물리 메모리 주소로 매핑하는 페이지 테이블의 캐시 역할을 수행한다.
최근에 일어난 가상 메모리와 물리 주소의 변환 테이블을 저장해둔다.
분기 예측과 추측 실행
✅ 분기 예측
프로세서가 조건 분기하는 기준값을 평가하느라 대기하는 현상을 방지한다.
조건문을 다 평하기 전까지 분기 이후 다음 명령을 알 수 없는데, 이런 경우 프로세서는 다른 작업을 수행하지 못하고 대기해야 한다.
이런 일이 없도록 프로세서는 가장 발생 가능성이 큰 분기를 미리 결정하는 알고리즘을 사용한다.
운 좋게 맞아떨어지면 아무 일도 없었던 것처럼 다음 작업을 진행하고,
틀리면 부분적으로 실행한 명령을 모두 폐기한다.
하드웨어 메모리 모델
✅ 어떻게 서로 다른 CPU가 일관되게 동일한 메모리를 액세스할 수 있을까?
멀티코어 시스템에서 메모리에 관한 가장 근본적인 질문이다.
CPU는 일반적으로 코드 실행 순서를 바꿔서 메모리 정렬 순서를 변경할 수 있는데,
JMM은 프로세서 별로 상이한 메모리 액세스 일관성을 고려하여 메모리 정렬에 대해 약한 모델(weak model)로 설계됐다.
+) 약한 모델: 메모리의 특정 정렬 규칙을 지키지 않아도 문제가 발생하지 않도록 하는 모델 (참고자료)
따라서 멀티스레드 코드가 제대로 작동하게 하려면 락과 volatile을 정확히 알고 사용해야 하는데, 이는 12장에서 다시 살펴본다.
운영체제
✅ OS의 임무
여러 실행 프로세스가 공유하는 리소스 액세스를 관장한다.
-> 한정된 리소스를 골고루 나누어줄 중앙 시스템.
+) 메모리 관리 유닛(MMU): 가상 주소 방식과 페이지 테이블을 통한 메모리 액세스 제어
한 프로세스가 소유한 메모리 영역을 다른 프로세스가 함부로 훼손하지 못하게 한다.
스케줄러
✅ 프로세스 스케줄러
CPU 액세스를 통제한다.
이 때 실행 큐를 사용하는데, 스레드의 할당 시간이 끝나면 다시 큐로 되돌리고 다음 스레드를 수행한다.
수많은 스레드에 리소스를 할당하려면 결국 코드가 실행되는 시간보다 기다리는 시간이 많다는 것인데,
이는 어떤 프로세스의 통계치가 다른 프로세스의 동작에 영향을 받는다는 것이고,
결국 스케줄링 오버헤드는 어떤 프로세스의 통계 결과에 노이즈를 끼게 만드는 주요인이다.
-> 이에 대해 실제 결과를 처리하는 문제는 5장에서 다룬다.
+) 스케줄러의 움직임을 확인하는 가장 쉬운 방법은 Thread.sleep 등을 반복적으로 수행하는 등, 스케줄링 오버헤드를 관측하는 것이다.
시간 문제
✅ OS마다 시간(타이밍)을 다르게 측정한다
OS는 저마다 다르게 동작하는데, 대표적인 예가 os::javaTImeMillis()이다.
같은 System.currentTimeMillis() 메서드라도, OS마다 (맥/윈도우) 다른 함수를 사용한다.
심지어 윈도우는 실제 물리 하드웨어에 따라 달리지기도 한다.
컨텍스트 교환
✅ 컨텍스트 교환
스케줄러가 현재 실행 중인 스레드를 대기 중이 다른 스레드로 대체하는 프로세스.
유저 스레드 사이에서, 혹은 유저 모드에서 커널 모드로 바뀌면서 일어난다.
특히, 후자는 유독 비싼 작업이다.
-> 유저 공간이 액세스하는 메모리 영역은 커널 공간과 거의 공유할 부분이 없기 때문에, 모드가 바뀌면 TLB 등의 캐시를 어쩔 수 없이 강제로 비워야 한다.
✅ 가상 동적 공유 객체(vDSO)
리눅스는 이런 부담을 최대한 만회하려고 vDSO라는 장치를 제공한다.
vDSO는 커널 프리빌리지가 필요 없는 시스템 콜의 속도를 높이려고 쓰는 유저 공간의 메모리 영역이다.
ex) gettimeofday()로 OS가 인지한 시간을 얻고자 할 때,
굳이 커널 모드로 바꾸지 않고 vDSO에 매핑할 수 있다면 성능을 끌어올릴 수 있다.
단순 시스템 모델
✅ 단순한 모델로 성능 문제를 일으키는 근원을 알아보자
앞으로 위 모델을 통해, 성능 문제의 원인을 밝혀내는 단순한 진단 기법들을 알아보겠다.
기본 감지 전략
✅ 성능 진단의 첫 단추
애플리케이션이 잘 돌아간다는 건?
-> CPU 사용량, 메모리, 네트워크, I/O 대역폭 등 시스템 리소스를 효율적으로 잘 이용하고 있다는 뜻이다.
성능 진단의 첫 단추는 어느 리소스가 한계에 다다랐는지 밝히는 일이다.
CPU 사용률
✅ vmstat
CPU 사용률은 애플리케이션 성능을 나타내는 핵심 지표이며,
부하가 집중되는 도중에는 사용률이 가능한 한 100%에 가까워야 한다.
-> vmstat은 현재 가상 메모리 및 I/O 서브시스템 상태에 관한 유용한 데이터를 신속히 제공한다.
- proc: 실행 가능한(r) 프로세스, 블로킹된(b) 프로세스 개수
- memory: 스왑 메모리(swpd), 미사용 메모리(free), 버퍼로 사용한 메모리(buff), 캐시로 사용한 메모리(cache)
- swap: 디스크로 교체되어 들어간 메모리(si), 디스크에서 교체되어 빠져나온 메모리(so)
- io: 블록-인(bi), 블록-아웃(bo) 개수
- system: 인터럽트(in), 초당 컨텍스트 교환(cs) 횟수
- cpu: 유저 시간(us), 커널 시간(sy), 유휴 시간(id), 대기 시간(wa), 가상 머신에 할애된 시간(st)
ex) vmstat 활용: CPU 사용률이 100%에 근접하지 않는다면?
-> vmstat에서 컨텍스트 교환 횟수가 높게 나타난다면 I/O에서 블로킹이 일어났거나, 스레드 락 경합이 벌어졌을 가능성이 크다.
가비지 수집
✅ GC는 유저 공간에 있다
핫스팟 JVM은 메모리를 유저 공간에 할당/관리한다.
-> 메모리 할당을 위해 시스템 콜을 할 필요가 없다.
즉, 커널 공간의 CPU 사용률이 높다면?
-> 보통 GC가 주범은 아니다.
그러나, 유저 공간에서 CPU를 100% 가깝게 사용하고 있다면?
-> 여기서는 GC를 의심해봐야 한다.
-> 운영 환경에서는 GC 로그를 꼭 남기자.
입출력
✅ I/O는 힘들어
파일 I/O는 예로부터 전체 시스템 성능에 암적인 존재였다.
-> 메모리 분야는 가상 메모리라도 있지만, I/O는 적절히 추상화할 장치가 없다.
다행히 자바 프로그램은 대부분 단순한 I/O만 처리하고, I/O를 심하게 가동하는 클래스도 비교적 적은 편이다.
-> I/O를 많이 쓰는 프로세스를 활발하게 모니터링 하고, 어떻게 I/O가 일어나는지 인지하는 것만으로 충분하다.
+) 자바는 주로 애플리케이션 서버에 사용되고, 이는 주로 데이터베이스 혹은 다른 서버와 통신하는 I/O 정도가 주를 이루기 때문에, 심하게 I/O를 사용하지 않는다.
반면에, C/C++은 하드웨어 제어나 시스템 프로그래밍 등에서 I/O 작업을 많이 처리한다. Python은 데이터 처리, 파일 입출력 등에서 I/O 작업을 많이 처리한다.
✅ 커널 바이패스 I/O
어떤 하드웨어에 있는 데이터를 유저 공간에 넣어야 하는데,
커널을 이용해 데이터를 복사하는건 상당히 비싸기 때문에,
RDMA 등의 유저가 직접 데이터를 매핑하는 전용 하드웨어/소프트웨어를 사용할 수 있다.
-> 아주 유용하기 때문에 초고성능 I/O가 필요한 시스템에서 일반적으로 구현하고 있다.
기계 공감
✅ 기계 공감
성능을 조금이라도 쥐어짜내야 하는 상황에서 하드웨어를 폭넓게 이해하고 공감할 수 있는 능력이 무엇보다 중요하다는 생각.
-> 자바/JVM을 활용하려면 JVM과 JVM이 하드웨어와 어떻게 상호작용하는지 이해해야 한다.
ex) 캐시 라인
여러 스레드가 같은 캐시 라인에 동시 접근하면 경합이 발생한다.
-> 서로 해당 캐시 라인에 대해 무효화를 하면서 계속 메모리에서 데이터를 읽는다.
-> 캐시 라인을 공유하는 변수들 주변에 패딩을 넣어 서로 다른 캐시 라인으로 보내자.
가상화
✅ 가상화 특징
호스트 OS 위에 게스트 OS를 하나의 프로세스로 실행시키는 것.
-> 요즘은 가상 환경에서 애플리케이션을 작동시키는 것이 대세로 굳혀지고 있다.
- 가상화 OS는 호스트 OS에서 실행할 때와 동일하게 동작해야 한다.
- 하이퍼바이저(가상화 OS를 생성하고 실행하는 프로세스)는 모든 하드웨어 리소스 액세스를 조정해야 한다.
- 가상화 오버헤드는 가급적 작아야 하며 실행 시간의 상당 부분을 차지해선 안 된다.
가상화 OS는 하드웨어에 직접 액세스할 수 없고,
프리빌리지드 명령어(하드웨어 레벨에서 실행하는 특권 명령어)를 사용할 수 없기 때문에,
프리빌리지드 명령어를 언프리빌리지드 명령어로 고쳐 쓴다.
-> 프리빌리지드한 명령은 하이퍼바이저 등에서 처리해준다.
JVM과 운영체제
✅ native 메서드
JVM은 OS에 독립적인 환경을 제공하지만,
시스템에서 시간 정보를 얻는 등 OS에 액세스해야 하는 경우가 있다.
-> 이런 기능은 native 키워드를 붙인 네이티브 메서드로 구현한다.
✅ System.currentTimeMillis()
네이티브 메서드는 C언어로 작성하지만, 자바 네이티브 인터페이스(JNI)가 자바 메서드처럼 액세스할 수 있도록 한다.
ex) 네이티브 메서드 System.currentTimeMillis()는,
JVM_CurrentTimeMillis()라는 JVM 엔트리 포인트 메서드에 매핑된다.
해당 메서드가 OS의 os::javaTimeMillis()를 호출한다.
-> 이 메서드는 당연히 OS에 의존하는데, JVM은 각 OS에 맞는 네이티브 라이브러리를 갖고 있기 때문에, OS에 맞는 os::javaTimeMillis()를 호출할 수 있다.
참고자료
https://preshing.com/20120930/weak-vs-strong-memory-models/
https://hg.openjdk.org/jdk8/jdk8/hotspot/file/87ee5ee27509/src/os/linux/vm/os_linux.cpp
'book > 자바 최적화' 카테고리의 다른 글
[자바 최적화] 가비지 수집 기초 (2) | 2023.08.28 |
---|---|
[자바 최적화] 마이크로벤치마킹과 통계 (2) | 2023.08.23 |
[자바 최적화] 성능 테스트 패턴 및 안티패턴 (2) | 2023.08.10 |
[자바 최적화] JVM이야기 (0) | 2023.07.23 |
[자바 최적화] 성능과 최적화 (0) | 2023.07.17 |