-
[JVM] 컴파일과 최적화 (2) - 백엔드 컴파일과 최적화DevBook/JVM 밑바닥까지 파헤치기 2025. 4. 6. 21:16
- 바이트코드를 프로그래밍 언어의 중간 표현이라고 생각하면, 컴파일러가 클래스 파일을 로컬 환경(하드웨어 명령어 집합, 운영체제)에 맞는 네이티브 코드로 변환하는 과정을 전체 컴파일 과정의 백엔드로 간주할 수 있음
- JVM에서 JIT와 AOT 컴파일러는 필수는 아님
- <자바 가상 머신 명세>는 어떤 컴파일러를 제공해야 한다고 규정하지 않았음
- 하지만 백엔드 컴파일러의 컴파일 성능과 최적화 품질은 상용 가상 머신의 우수성을 측정하는 핵심 지표가 됨
- 여기서는 JVM 내부 백엔드 컴파일러의 작업 절차와 원리를 살펴볼 것임
- 따로 명시하지 않는 한 JIT 컴파일러는 핫스팟 VM의 내장 컴파일러를 뜻하고, 가상 머신은 핫스팟 VM을 가리킴
- 주류 JVM들의 백엔드 컴파일러들은 많은 면에서 서로 비슷함
JIT 컴파일러
- 핫스팟 VM과 OpenJ9은 자바 프로그램을 먼저 인터프리터로 해석해 실행함
- 이후 아주 자주 실행되는 메서드나 코드 블록이 발견되면 해당 코드를 네이티브 코드로 컴파일하고 다양한 최적화를 적용해 실행 효율을 높임
- 이러한 코드 블록을 '핫스팟 코드' 또는 '핫 코드'라고 하며, 런타임에 이 작업을 수행하는 컴파일러를 'JIT 컴파일러'라고 함
- 여기서는 핫스팟 VM에서 JIT 컴파일러의 동작 방식을 이해하고, 다음 문제들을 해결할 것임
- 핫스팟 VM이 인터프리터와 JIT 컴파일러를 함께 사용하는 이유는?
- 핫스팟 VM이 여러 가지 JIT 컴파일러를 내장하는 이유는?
- 프로그램 실행 시 언제 인터프리터가 사용되고 언제 컴파일러가 사용되나?
- 네이티브 코드로 컴파일되는 프로그램 코드 종류와 그 방법은?
- JIT 컴파일러의 컴파일 과정과 컴파일 결과를 외부에서 볼 수 있는 방법은?
1) 인터프리터와 컴파일러
- 현재 주류 상용 가상 머신인 핫스팟과 OpenJ9은 모두 인터프리터와 컴파일러를 함께 사용함
- 인터프리터와 컴파일러는 각각 고유한 장점이 있음
- 프로그램을 빠르게 시작해야 할 때는 인터프리터가 먼저 컴파일(바이트코드 --> 네이티브 코드) 없이 곧바로 실행할 수 있음
- 프로그램이 시작된 후에는 컴파일러의 역할이 커짐. 더 많은 코드를 네이티브 코드로 컴파일해 실행 효율을 높이는 것임
- 메모리가 부족한 환경에서 인터프리터 방식으로 메모리를 절약할 수 있음
- 적극적으로 최적화하는 컴파일러의 비상구 역할도 수행함
- 컴파일러가 최적화를 적극적으로 하다 보면 잘못된 선택을 하기도 함
- 대부분의 경우에는 성능이 개선되지만, 특수한 상황에서는 올바른 결과를 내지 못하는 최적화를 적용하는 것
- ex) 새로운 클래스를 로드한 후 클래스 상속 구조가 바뀌는 등
- 적극적 최적화의 가정이 무너지는 경우 최적화를 취소(deopti-mization)하고 다시 인터프리터에 실행을 맏길 수 있음
- 컴파일러가 최적화를 적극적으로 하다 보면 잘못된 선택을 하기도 함
- 프로그램을 빠르게 시작해야 할 때는 인터프리터가 먼저 컴파일(바이트코드 --> 네이티브 코드) 없이 곧바로 실행할 수 있음
인터프리터와 컴파일러의 협력 모델
- JVM의 전체 실행 구조에서 인터프리터와 컴파일러는 항상 서로 협력하여 프로그램을 실행함
- 핫스팟 VM에는 JIT 컴파일러가 2개 또는 3개 내장되어 있음
- 2개는 오래전부터 존재했고, 각각 클라이언트 컴파일러(c1)와 서버 컴파일러(c2)로 불림
- 서버 컴파일러는 일부 자료와 JDK 소스 코드에서 Opto 컴파일러라고도 함
- 세 번째 JIT 컴파일러는 JDK 10과 함께 등장한 그랄 컴파일러로, JDK 16부터 표준 JDK에서 배제된 채 그랄VM이라는 별도 프로젝트에서 개발되고 있음
- 2개는 오래전부터 존재했고, 각각 클라이언트 컴파일러(c1)와 서버 컴파일러(c2)로 불림
계층형 컴파일 모드
- 핫스팟 VM은 자체 버전과 호스트 머신의 하드웨어 성능에 맞춰 실행 모드를 자동으로 선택함
- JDK 9부터는 기본적으로 서버 모드로 실행됨
- 어떤 컴파일러가 쓰이느냐에 상관없이 VM에서 인터프리터와 컴파일러를 함께 사용하는 방식을 혼합 모드라고 함
- -Xint 매개 변수 지정하여 가상 머신을 '해석 모드'로 고정하거나, -Xcomp 매개 변수 지정하여 '컴파일 모드'로 고정할 수 있음
- 해석 모드에서는 컴파일러가 전혀 개입하지 않고, 컴파일 모드에서는 컴파일 완료한 후 코드를 실행함
- 단, 컴파일 모드라도 컴파일이 실패하면 인터프리터가 실행에 개입해야 함
- JIT 컴파일러가 바이트코드를 네이티브 코드로 컴파일하려면 시간이 걸리며, 일반적으로 최적화를 많이 할수록 컴파일도 오래 걸림
- 더 많이 최적화된 코드로 컴파일하기 위해 인터프리터가 성능 모니터링(프로파일링) 정보를 수집할 수 있고, 이 작업 역시 해석과 실행 단계 속도에 영향을 줌
- 프로그램 시작 응답 속도와 운영 효율 사이에서 최상의 균형을 찾기 위해 핫스팟 VM은 컴파일 서브시스템에 계층형 컴파일 기능을 추가함
- JDK 7 때 서버 모드 가상 머신의 기본 컴파일 전략으로 승격됨
- 계층형 컴파일은 컴파일과 최적화 규모와 소요 시간에 따라 다음과 같이 여러 단계의 수준으로 수행됨
- 계층 0
- 인터프리터가 프로그램을 순수하게 해석 실행함. 성능 모니터링 사용 X
- 계층 1
- 클라이언트 컴파일러를 사용해 바이트코드를 네이티브 코드로 컴파일하고 실행함
- 간단하고 안정적인 최적화만 수행하며, 성능 모니터링은 사용 X
- 계층 2
- 클라이언트 컴파일러를 사용
- 메서드 및 반환 횟수 통계 등 몇 가지 성능 모니터링만 수행함
- 계층 3
- 여전히 클라이언트 컴파일러 사용
- 분기 점프와 가상 메서드 호출 버전 등 모든 성능 모니터링 정보 수집함
- 계층 4
- 서버 컴파일러 사용
- 성능 모니터링 정보를 활용해 더 오래 걸리는 최적화까지 수행함
- 이때 신뢰도가 낮은 공격적인 최적화를 수행하기도 함
- 계층 0
- 계층형 컴파일 도입 후 인터프리터, 클라이언트 컴파일러, 서버 컴파일러가 협력해 동작하면서 핫 코드가 여러 번 컴파일될 수 있음
- 빠르게 컴파일할 때는 클라이언트 컴파일러를 사용하고, 성능을 더 높여야 할 때는 서버 컴파일러를 사용함
- 서버 컴파일러가 매우 복잡한 최적화 알고리즘을 수행해야 할 때 우선 클라이언트 컴파일러로 간단한 최적화를 한 뒤, 복잡한 최적화는 백그라운드로 마무리하는 방식도 가능함
참고) 실행 모드 확인
추가) 계층형 컴파일의 상호 작용 관계
더보기- 가상 머신 버전과 실행 매개 변수에 따라 계층의 종류와 구체적인 동작 방식이 달라질 수 있음
2) 컴파일 대상과 촉발 조건
- 런타임에 JIT 컴파일러가 컴파일하는 대상을 '핫 코드'라고 함. 핫 코드의 가장 대표적인 유형은 두 가지임
- 여러 번 호출되는 메서드
- 여러 번 실행되는 순환문의 본문
- 두 유형 모두에서 컴파일 대상은 개별 순환문의 본문이 아니라 '메서드 전체'임
- 첫 번째 유형의 컴파일은 메서드 호출에 의해 촉발되므로 컴파일러는 메서드 전체를 컴파일 대상으로 삼음
- 가상 머신의 표준 JIT 컴파일 방법이기도 함
- 두 번째 유형의 컴파일은 순환문 본문에 의해 촉발되지만 컴파일 대상은 여전히 '메서드 전체'임
- 메서드의 실행 진입점(메서드의 첫 번째 바이트코드 명령어)이 살짝 달라지며, 컴파일 시 달라진 진입점의 바이트코드 인덱스 값을 컴파일러에 전달함
- 메서드가 실행되는 도중에, 즉 해당 메서드의 스택 프레임이 여전히 스택에 존재하는 상태에서 메서드가 치환되기 때문에 '온스택 치환'이라고도 함
- 첫 번째 유형의 컴파일은 메서드 호출에 의해 촉발되므로 컴파일러는 메서드 전체를 컴파일 대상으로 삼음
Q. 여러 번 호출/실행을 기준으로 '핫 코드'로 구분되는데, 그렇다면 몇 번 실행되어야 '여러 번'이라고 할 수 있을까? JVM은 메서드나 특정 코드 블록이 실행되는 횟수를 어떻게 계산할까?
- 특정 코드 블록이 핫 코드인지 그래서 JIT 컴파일을 촉발시켜야 하는지 판단하는 동작을 '핫스팟 코드 탐지' 또는 '핫스팟 탐지'라고 함
- 현재 핫스팟 탐지에 주로 쓰이는 방식들은 두 가지임
- 샘플 기반 핫스팟 코드 탐지
- 각 스레드의 호출 스택 상단을 샘플링(주기적으로 확인)하여 특정 메서드 또는 메서드의 일부가 자주 발견되면 해당 메서드를 '핫 메서드'로 간주함
- 구현하기 쉽고 효율적이며 메서드 호출 관계를 쉽게 파악할 수 있는 방식(호출 스택을 훑으면 됨)
- 하지만 메서드의 핫한 정도를 정확하게 알기는 어렵고 스레드 블로킹 등의 외부 요인이 핫스팟 탐지를 방해하기 쉬움
- 카운터 기반 핫스팟 코드 탐지
- 각 메서드와 코드 블록에 대한 카운터를 설정하고 나서 개별 실행 횟수를 기록함
- 실행 횟수가 문턱값을 초과하면 '핫 메서드'로 간주함
- 메서드 각각에 대한 카운터를 설정하고 유지해야 해서 구현하기 더 번거롭고, 메서드 호출 관계도 직접 얻을 수 없음
- 반면 더 정확하고 엄격한 결과를 얻을 수 있음
- 샘플 기반 핫스팟 코드 탐지
- J9는 샘플 방식을, 핫스팟 VM은 카운터 방식을 사용함
- 핫스팟 VM은 메서드 각각에 대해 '메서드 호출 카운터'와 '백 에지(back edge) 카운터'를 준비함
- *참고) 메서드 호출 카운터는 위의 첫 번째 유형을 위한 카운터, 백 에지 카운터는 위의 두 번째 유형을 위한 카운터임
- 백 에지는 순환문 경계에서 순환문 처음으로 점프한다는 뜻
- 문턱값을 넘어서면 JIT 컴파일이 촉발됨 (각 카운터의 문턱값은 가상 머신의 실행 매개 변수에 따라 다름)
메서드 호출 카운터에 의한 JIT 컴파일 촉발
- 이 카운터에는 메서드가 호출된 횟수가 기록됨
- 기본 문턱값은 클라이언트 모드에서 1500회이고, 서버 모드에서 1만 회임
- -XX:CompileThreshold 매개 변수로 직접 설정할 수 있음
추가) JIT 컴파일 촉발 과정
더보기- 메서드가 호출되면 가상 머신은 해당 메서드의 JIT 컴파일 버전이 있는지 확인함
- 컴파일된 버전이 있으면, 컴파일된 네이티브 코드 버전을 실행함
- 컴파일된 버전이 없으면, 메서드의 호출 카운터를 1 증가시킴
- 메서드 호출 카운터와 백 에지 카운터 값의 합 > 문턱값 이면, JIT 컴파일러에 컴파일을 요청함
- 기본적으로 가상 머신의 실행 엔진은 컴파일이 완료될 때까지는 계속해서 인터프리터로 실행함
- 즉, JIT 컴파일은 백그라운드에서 비동기로 진행함
- 컴파일이 완료되면 메서드의 호출 진입점 주소가 시스템에 의해 자동으로 새 값으로 덮어써지고, 그다음 호출부터는 컴파일된 버전이 사용됨
- 기본적으로 메서드 호출 카운터는 메서드가 호출된 절대 횟수가 아니라 '단위 시간당 호출 횟수'를 계산함
- 단위 시간 동안 호출 횟수가 문턱값에 도달하지 못할 때는 카운터의 값을 절반으로 줄임
- 이 방식을 '카운터 감쇠 메서드 호출(counter decay method invocation)'이라 하며, 단위 시간을 '카운터의 반감기(counter half life time)'라 함
- -XX:-UseCounterDecay 매개 변수로 카운터 감쇠를 비활성화하면 절대 호출 횟수를 계산함
- 이 모드에서는 시스템이 충분히 오랜 시간 운영된다면 대부분의 메서드가 네이티브 코드로 변환됨
- -XX:CounterHalfLifeTime 매개 변수로는 반감기를 초 단위로 설정할 수 있음
- -XX:-UseCounterDecay 매개 변수로 카운터 감쇠를 비활성화하면 절대 호출 횟수를 계산함
참고) 위는 클라이언트 모드 가상 머신의 JIT 컴파일 방식임 (서버 모드 가상 머신은 지금까지의 설명보다 더 복잡하게 구현되어 있음)
백 에지 카운터에 의한 JIT 컴파일 촉발
- 백 에지 카운터는 특정 순환문의 본문 코드가 실행되는 횟수를 계산함
- 횟수를 계산하는 목적은 온스택 치환(OSR) 컴파일을 촉발하기 위해서임
- -XX:BackEdgeThreshold 매개 변수로 문턱값을 간접적으로 조정할 수 있음
추가) 백 에지 카운터 문턱값 계산식
더보기- -XX:BackEdgeThreshold 매개 변수로 문턱값을 간접적으로 조정할 수 있음
클라이언트 모드로 구동한 경우
- 클라이언트 모드에서 OSR 비율의 기본값은 933이므로 두 값 모두 기본값을 사용하면 백 에지 카운터 문턱값은 13995임
서버 모드로 구동한 경우
- 서버 모드에서 OSR 비율의 기본값은 140이고 인터프리터 모니터링 비율의 기본값은 33임
- 세 값 모두 기본값을 사용하면 백 에지 카운터 문턱값은 10700임
추가) JIT 컴파일 촉발 과정
더보기- 인터프리터는 백 에지 명령어를 만나면 실행할 코드 조각의 컴파일된 버전이 있는지 확인함
- 있으면 컴파일된 코드를 실행함
- 없으면 백 에지 카운터 1 증가시킴
- 메서드 호출 카운터와 백 에지 카운터 값의 합 > 백 에지 카운터의 문턱값 이면 OSR(온스택 치환) 컴파일을 요청함
- 이때 백 에지 카운터 값을 약간 줄임
- 컴파일러가 컴파일을 마칠 때까지 인터프리터에서 순환문을 계속 실행하기 위해서임
- 메서드 호출 카운터와 달리 백 에지 카운터는 문턱값 초과하지 않았을 때 카운터 값 감쇠 없이 절대 실행 횟수를 계산함
- 백 에지 카운터가 오버플로되면 메서드 호출 카운터의 값도 오버플로 상태로 설정하여, 메서드가 다음번 호출될 때 표준 컴파일 절차가 진행되게 함 (여러 번 호출되는 메서드로 인해 JIT 컴파일이 촉발되는 것이 가상 머신의 표준 JIT 컴파일 방법이기 때문)
참고) 위는 클라이언트 모드 가상 머신의 JIT 컴파일 방식임 (서버 모드 가상 머신은 지금까지의 설명보다 더 복잡하게 구현되어 있음)
AOT 컴파일러
1) AOT 컴파일러의 장점과 단점
- AOT 컴파일러 관련 연구는 크게 두 가지 형태로 나뉨
- 프로그램이 실행되기 전에 프로그램 코드를 네이티브 코드로 컴파일하는 형태 (기존 C/C++ 컴파일러와 비슷)
- 원래 JIT 컴파일러가 런타임에 수행해야 하는 작업을 미리 수행해 캐시에 저장해 두고 다음번 실행 시 사용하는 형태
- 공통 라이브러리 코드를 같은 시스템의 다른 자바 프로세스와 공유할 수 있음
- 형태1
- 프로그램 실행에 앞서 네이티브 코드로 컴파일하는 것
- AOT의 전통적인 형태이자 자바에서 JIT 방식의 가장 큰 약점에 해당함
- JIT 컴파일에 소요된 시간과 컴퓨팅 자원은 어떤 경우든 어플리케이션 실행에 사용할 수 있었던 것이었기 때문
- 예시) 시간이 오래 걸리는 최적화
- 컴파일(프로그램 코드 or 바이트코드 --> 네이티브 코드) 과정에서 시간이 가장 오래 걸리는 최적화로는 '프로시저 간 분석(전체 프로그램 분석)'이 있음
- 전체 프로그램을 대상으로 하여 시간이 매우 오래 걸리는 계산을 수행해야 함(흐름 감지, 경로 감지, 콘텍스트 감지, 필드 감지 등)
- 모든 JVM은 프로시저 간 분석을 다소 제한적으로만 수행함
- 정확한 데이터가 아니라 가능성이 높은 시나리오를 가정해 최적화하고, 혹시 문제가 생기면 인터프리터 방식으로 돌아감
- 한편 프로그램이 실행되기 전에 컴파일을 정적으로 수행하면 이처럼 시간 소모적인 최적화도 부담 없이 수행할 수 있음
- ex) 그랄 VM의 서브스트레이트 VM
- 컴파일(프로그램 코드 or 바이트코드 --> 네이티브 코드) 과정에서 시간이 가장 오래 걸리는 최적화로는 '프로시저 간 분석(전체 프로그램 분석)'이 있음
- 형태2
- JIT 컴파일러가 런타임에 수행해야 하는 작업을 미리 수행하여 캐시해 두는 연구
- 이 형태의 본질은 JIT 컴파일러의 캐시 역할을 극대화하여 자바 프로그램 구동 시간을 단축하고, 구동 후 빠르게 최상의 성능을 내는 것임
- '동적 AOT' 또는 'JIT 캐싱'이라고 함
- 현재 주류 상용 JDK들은 이러한 방식의 고급 컴파일을 모두 지원함
- 예시) jaotc 컴파일러
- OpenJDK/오라클 JDK 9의 jatoc 컴파일러
- 그랄 컴파일러를 기반으로 구현된 AOT 컴파일러로, 대상 시스템에 최적화된 어플리케이션을 사용자가 미리 컴파일할 수 있도록 해줌
- 핫스팟 런타임은 컴파일된 결과를 곧바로 로드하여 프로그램 구동 속도를 높이고, 최고 성능으로 실행되는 데까지 걸리는 시간도 단축할 수 있었음
- 이런 AOT 컴파일은 대상 물리 머신뿐 아니라 핫스팟 VM의 런타임 매개 변수도 고려해야 해서 실제로 적용하기 쉽지 않음
- 이러한 난제가 많이 남아 있지만 AOT 컴파일은 의심할 여지없이 성능(구동 시간과 응답 속도)을 극한까지 끌어내는 수단임
- 예시) jaotc 컴파일러
JIT의 반격
- 시간과 컴퓨팅 자연 측면의 단점을 무시할 수는 없지만 JIT도 여전히 나름대로 장점이 있음
- JIT가 AOT보다 본질적으로 나은 점 세 가지
- 성능 모니터링 기반 최적화
- 인터프리터나 클라이언트 컴파일러가 실행되는 동안 다양한 성능 모니터링 정보를 수집함
- ex) 프로그램이 창조하는 추상 클래스의 실제 타입, 주로 선택되는 조건 분기, 메서드 호출 시 선택되는 버전, 순환문의 일반적인 반복 횟수 등
- 이런 정보는 정적 분석 단계에서는 얻을 수 없거나, 경험 법칙에 기초해 추측만 가능함 (반면 프로그램을 실제로 실행해 보면 매우 분명한 데이터가 쌓임)
- 인터프리터나 클라이언트 컴파일러가 실행되는 동안 다양한 성능 모니터링 정보를 수집함
- 많은 JIT 컴파일 최적화 측정의 기초가 되는 급진적 예측 최적화
- AOT 같은 정적 최적화에서는 프로그램 실행 결과뿐 아니라 겉보기 효과까지도 최적화 전후가 완벽히 같아야 함
- 그렇지 않으면 프로그램에서 오류가 나거나 잘못된 결과를 냄
- JIT 컴파일러는 AOT 컴파일처럼 보수적일 필요가 없음
- 성능 모니터링 정보를 토대로 높은 확률로 정확한 판단을 내릴 수 있음 (즉, 가능성 높은 가정을 믿고 과감하게 최적화하는 것)
- 혹시라도 낮은 확률의 동작이 실행된다면 하위 계층 컴파일러나 인터프리터로 실행하는 방식
- AOT 같은 정적 최적화에서는 프로그램 실행 결과뿐 아니라 겉보기 효과까지도 최적화 전후가 완벽히 같아야 함
- 링크타임 최적화
- 자바 언어는 본질적으로 동적으로 링크됨
- 클래스가 런타임에 가상 머신 메모리에 로드된 다음 JIT 컴파일러에 의해 최적화된 네이티브 코드로 만들어짐
- 성능 모니터링 기반 최적화
'DevBook > JVM 밑바닥까지 파헤치기' 카테고리의 다른 글
[JVM] 컴파일과 최적화 (1) - 프론트엔드 컴파일과 최적화 (0) 2025.04.06 [JVM] 바이트코드 실행 엔진 (0) 2025.04.05 [JVM] 가상 머신 실행 서브시스템 (2) - 클래스 로딩 메커니즘 (0) 2025.04.01 [JVM] 가상 머신 실행 서브시스템 (1) - 클래스 파일 구조 (0) 2025.03.29 [JVM] 가비지 컬렉터와 메모리 할당 전략 - 메모리 할당과 회수 전략 (0) 2025.03.27