Chapter 9. JVM의 코드 실행
- JVM이 제공하는 중요한 서비스
- 메모리 관리
- 사용하기 쉬운 애플리케이션 코드 실행 컨테이너
VMSpec - 표준 자바 구현체가 코드를 실행하는 방법을 기술한 자바 가상 머신 명세
- VMSpec을 보면 인터프리터로 자바 바이트 코드를 실행하는 사양이 나온다.
- 그러나 인터프리터로 해석하여 구동하는 환경은 기계어를 직접 실행하는 것보다 성능이 떨어진다.
- 대부분 최신 자바 환경은 동적 컴파일 기능을 통해 이 문제를 해결 (JIT 컴파일)
9.1 바이트코드 해석
- JVM 인터프리터는 일종의 스택 머신처럼 작동
- 물리적 CPU와 달리 계산 결과를 보관하는 레지스터는 없다.
- 작업할 값을 모두 평가 스택에 놓고 스택 머신 명령어로 스택 최상단의 값을 변환하는 식으로 작동
- JVM은 다음 세 공간에 주로 데이터를 담는다.
- 평가 스택: 메서드별로 하나씩 생성
- 로컬 변수: 결과를 임시 저장(특정 메서드별로 존재)
- 객체 힙: 메서드끼리, 스레드끼리 공유된다.
9.1.1 JVM 바이트코드 개요
- JVM에서 각 스택 머신 작업 코드(옵코드)는 1바이트이다.
- 옵코드는 0 ~ 255까지 지정 가능하며 약 200개를 사용하고 있다. (자바 10 기준)
- 바이트코드 명령어는 스택 상단의 두 값의 기본형을 구분할 수 있게 표기한다.
- ex)
iadd
(int 값 덧셈), dadd
(double 값 덧셈)
바이트코드 명령어는 대부분 한쪽은 각 기본형, 다른 한 쪽은 참조형으로 쓸 수 있게 ‘패밀리’ 단위로 구성
- JVM은 빅 엔디언, 리틀 엔디언 모두에서 바이트코드 변경 없이 실행 가능하도록 명세에 지정되어 있다.
load
같은 옵코드 군엔 단축형이 존재해 인수를 생략할 수 있다.
- 그만큼 클래스 파일 인수 바이트 공간을 절약
aload_0
: 현재 객체(this
)를 스택 상단에 넣는 명령어. 자주 쓰이기에 클래스 파일 크기가 상당이 준다.
- 바이트코드는 개념적으론 단순하지만 기본 작업보다 훨씬 많은 옵코드가 할당되어 있다.
- 여러 옵코드가 개념상 동일한 작업을 나타내는 경우도 있기 때문
주요 바이트코드 명령어
- 앞으로 표시하는 테이블에서 사용할 기호
c1
- 2바이트짜리 상수 풀 인덱스
i1
- 현재 메서드의 지역 변수
()
- 해당 옵코드 패밀리 중 단축형을 지닌 옵코드가 있음
- 스택에 데이터를 넣고 빼는 로드/스토어 카테고리 옵코드
패밀리 명 |
인수 |
설명 |
load |
(i1) |
지역 변수 i1 값을 스택에 로드 |
store |
(i1) |
스택 상단을 지역 변수 i1에 저장 |
ldc |
c1 |
CP#c1이 가리키는 값을 스택에 로드 |
const |
|
단순 상숫값을 스택에 로드 |
pop |
|
스택 상단에서 값을 제거 |
dup |
|
스택 상단에 있는 값을 복제 |
getField |
c1 |
스택 상단에 위치한 객체에서 CP#c1이 가리키는 필드명을 찾아 스택에 로드 |
putField |
c1 |
스택 상단 값을 CP#c1이 가리키는 필드에 저장 |
getstatic |
c1 |
CP#c1이 가리키는 정적 필드값을 스택에 로드 |
putstatic |
c1 |
스택 상단 값을 CP#c1이 가리키는 정적 필드에 저장 |
- 산술 바이트코드
- 기본형에만 적용
- 순수 스택 기반 연산을 수행하므로 인수는 없다.
페밀리 명 |
설명 |
add |
스택 상단의 두 값을 더한다 |
sub |
스택 상단의 두 값을 뺀다 |
div |
스택 상단 두 값을 나눈다 |
mul |
스택 상단 두 값을 곱한다 |
(cast) |
스택 상단 값을 다른 기본형으로 캐스팅 |
neg |
스택 상단 값을 부정 |
rem |
스택 상단 두 값을 나눈 나머지를 구한다 |
- 흐름을 제어하는 바이트코드 (for, if, while, switch 등)
페밀리 명 |
인수 |
설명 |
if |
(i1) |
조건이 참일 경우 인수가 가리키는 위치로 분기 |
goto |
i1 |
주어진 오프셋으로 무조건 분기 |
- 몇 개 없어 보이지만 `if` 옵코드 페밀리에 속한 옵코드가 상당이 많다.
옵코드 명 |
인수 |
설명 |
invokevirtual |
c1 |
CP#c1이 가리키는 메서드를 가상 디스패치로 호출 |
invokespecial |
c1 |
CP#c1이 가리키는 메서드를 특별한 디스패치로 호출 |
invokeinterface |
c1, count, 0 |
CP#c1이 가리키는 인터페이스 메서드를 인터페이스 오프셋 룩업으로 호출 |
invokestatic |
c1 |
CP#c1이 가리키는 정적 메서드 호출 |
invokedynamic |
c1, 0, 0 |
호출해서 실행할 메서드를 동적으로 찾는다 |
- JVM 설계 구조상 메서드 호출 옵코드를 명시적으로 사용해야 하고 기계어에는 이와 동일한 호출 작업이 없다.
- 대신 JVM 바이트코드엔 몇 전문 용어를 사용
- 호출부 - 메서드(호출자) 내부에서 다른 메서드(피호출자)를 호출한 지점
- 수신자 객체 - 호출되는 비정적 메서드를 가지고 있는 객체
- 수신자 타입 - 수신자 객체의 런타임 타입
- 자바 객체가 메서드를 호출할 때 실제 호출 컨텍스트에 따라 세 바이트코드 중 하나로 변환된다.
invokevirtual
- 인스턴스 메서드 호출
invokespecial
- 프라이빗 메서드나 슈퍼 클래스를 호출하는 경우. 컴파일 타임에 디스패치할 메서드를 특정할 수 있는 경우
invokeinterface
- 인터페이스 메서드 호출
- 정적 메서드 호출은 항상
invokestatic
으로 컴파일되며 수신자 객체는 없다.
invokedynamic
은 자바 8 이후 자바 언어 핵심으로 급부상하여 고급 기능을 지원하는 데 활용된다.
- ex) 람다 표현식
- 플랫폼 옵코드
- 객체별로 힙 저장 공간을 새로 할당
- 고유 락(모니터 락)을 다루는 명령어
옵코드 명 |
인수 |
설명 |
new |
c1 |
CP#c1이 가리키는 타입의 객체에 공간을 할당 |
newarray |
prim |
기본형 배열에 공간 할당 |
anewarray |
c1 |
CP#c1이 가리키는 타입 객체 배열에 공간을 할당 |
arraylength |
|
스택 상단 객체를 그 길이로 치환 |
monitorenter |
|
스택 상단 객체 모니터를 잠금 |
monitorexit |
|
스택 상단 객체 모니터를 잠금 해제 |
newarray
, anewarray
실행 시 할당할 배열 길이가 스택 상단에 있어야 한다.
- 바이트코드는 구현 복잡도에 따라 대단위, 소단위 바이트코드로 구분된다.
- 산술 연산은 매우 소단위 작업이라 핫스팟에서 순수 어셈블리어로 구현된다.
- 대단위 연산(상수 풀 룩업, 메서드 디스패치)은 핫스팟 VM을 다시 호출할 수밖에 없다.
바이트코드 개별 의미와 세이프포인트
- 세이프포인트 - JVM이 어떤 관리 작업을 수행하고 내부 상태를 일관되게 유지하는 데 필요한 지점
- 객체 그래프가 들어있다.
- 일관된 상태를 유지하려면 JVM이 관리 작업 수행 도중 공유 힙이 변경되지 않도록 STW가 필요하다.
- JVM 애플리케이션 스레드 하나하나는 진짜 OS 스레드
- 스레드에 대해 옵코드가 디스패치되는 시점에 스레드는 유저 코드가 아닌 JVM 인터프리터 코드를 실행한다.
- 따라서 힙 상태 일관성이 보장되며 애플리케이션 스레드를 멈출 수 있다.
- ‘바이트코드 사이사이’가 애플리케이션 스레드를 멈추기에 이상적인 시점
- JIT 컴파일드 메서드는 해결 방법이 더 복잡하지만 기본적으로 기계어에도 동일한 배리어를 끼워넣는다.
9.1.2 단순 인터프리터
- 가장 단순한 인터프리터는
switch
문이 포함된 while
루프 형태
- 이 인터프리터의
execMethod()
메서드는 단일 메서드 바이트코드를 해석한다.
- 정수 계산, “Hello World” 출력 정도를 할 수 있는 옵코드가 구현되어 있음
9.1.3 핫스팟에 특정한 내용
- 핫스팟은 상용 제품급 JVM이자 완전한 구현체
- 핫스팟은 탬플릿 인터프리터라 시작할 때마다 동적으로 인터프리터를 구축한다.
- 때문에 이해하기 훨씬 복잡하고 인터프리터 소스 코드 분석도 만만치 않다.
- 게다가 성능 때문에 상당히 많은 어셈블리어 코드로 작성돼 있다.
- VMSpec에 없는 핫스팟 전용 바이트코드까지 정의해서 사용한다.
- 특정 옵코드의 일반적인 유스케이스와 핫(hot)하게 쓰는 경우를 차별화하기 위해
- 핫스팟 전용 바이트코드가 처리하는 특이 사례
final
메서드는 오버라이드할 수 없기에 컴파일하면 invokespecial
옵코드가 나온다.
- 그러나 자바 언어 명세에 ‘final 메서드를 final 아닌 메서드로 변경하는 건 기존 바이너리와의 호환성을 깨뜨리지 않는다.’라는 문구가 있다.
public class A {
public Final void fMethod() {
// ...
}
}
public class CallA {
public void otherMethod(A obj) {
obj.fMethod();
}
}
final
메서드 호출부가 invokespecial
로 컴파일되면 CallA::otherMehtod
는 아래와 같이 컴파일된다.
public void otherMethod()
Code:
0: aload_1
1: invokespecial #4
4: return
- 이 때
A::fMethod
를 논 final
로 바꾸면 이 메서드는 서브 클래스에서 오버라이드가 가능하다.
- 서브 클래스 B 인스턴스를
otherMehtod()
메서드의 인수로 넘기면 바이트코드 수준에선 invokespecial
명령이 실행되니 메서드가 잘못 호출된다.
- 이는 자바의 객체지향을 위반, 특히 리스코프 치환 원칙을 위반한다.
- 때문에
final
메서드는 반드시 invokevirtual
명령으로 컴파일되야 한다.
- 하지만 final은 오버라이드가 안되기 때문에 JVM의 핫스팟엔
final
메서드를 디스패치하는 전용 프라이빗 바이트코드가 존재한다.
9.2 AOT와 JIT 컴파일
9.2.1 AOT 컴파일
- AOT 컴파일은 소스 코드를 컴파일러에 넣고 바로 실행 가능한 기계어를 뽑아내는 과정이다.
소스 코드를 AOT 컴파일하면 어떤 식으로든 최적화할 기회는 단 한 번뿐이다.
- AOT의 목표는 프로그램을 실행할 플랫폼과 프로세서 아키텍처에 딱 맞은 실행 코드를 얻는 것
- 대상이 고정된 바이너리는 프로세서 별로 특수한 기능을 십분 활용해 성능을 높일 수 있다.
- 하지만 대부분의 실행 코드는 자신이 실행될 플랫폼을 모른다.
- 그래서 AOT 컴파일은 사용 가능한 프로세서 기능에 대해 가장 보수적인 선택을 해야 한다.
- AOT 컴파일한 바이너리는 CPU 기능을 최대한 활용하지 못하는 경우가 다반사고 성능 향상의 숙제가 남는다.
9.2.2 JIT 컴파일
- JIT 컴파일은 런타임에 프로그램을 고도로 최적화한 기계어러 변환하는 기법
- just-in-time compile
- 핫스팟을 비롯한 대부분 상용 JVM이 선택한 방식
- 프로파일 기반 최적화(profile-guided optimization, PGO)
- 프로그램 런타임 실행 정보를 수집
- 어느 부분을 최적화해야 가장 효과가 좋은지 프로파일을 만들어 결정을 내린다.
- JIT 서브 시스템은 실행 프로그램과 VM 리소스를 공유
- 바이트코드 → 네이티브 코드 컴파일 비용은 런타임에 지불
- 컴파일 과정에서 일부 리소스(CPU, 메모리)가 소비되므로 JIT 컴파일은 산발적으로 수행
- 컴파일하기 적정한 한계치를 넘어선 메서드가 발견되면 컴파일 스레드가 바이트코드를 기계어로 변환한다.
- 핫스팟은 프로파일링 정보를 보관하지 않고 VM이 꺼지면 일체 폐기한다.
- 애플리케이션은 실행될 때마다 성능이 심한 편차를 보이는 현상은 흔하다.
- 때문에 항상 프로파일은 애플리케이션 재실행 시 처음부터 다시 만드는 것이 좋다.
9.2.3 AOT 컴파일 vs JIT 컴파일
- AOT 컴파일
- 소스 코드에서 바로 기계어 생성
- 컴파일 단위별 기계어를 어셈블리로 바로 사용 가능
- 반면 AOT는 최적화 결정을 내리는 데 유용한 런타임 정보를 포기
- 특정 프로세서를 타깃으로 AOT 컴파일을 하면 해당 프로세서에만 사용 가능한 코드가 된다.
- 저지연 또는 극단적으로 성능이 중요한 유스케이스에선 유용한 기법
- AOT 컴파일은 위와 같은 이유로 확장성이 떨어진다.
- JIT 컴파일
- 핫스팟은 릴리즈할 때마다 새로운 프로세서 기능에 관한 최적화 코드 추가 가능
- 기존 클래스 및 JAR 파일을 다시 컴파일하지 않아도 신기능 사용 가능
- 자바 프로그램에 AOT 컴파일을 지원하는 상용 VM이 나오기도 했다.
- 자바 9부터 핫스팟 VM은 JDK 코어 클래스를 대상으로 AOT 컴파일 옵션을 제공하기 시작했다.
9.3 핫스팟 JIT 기초
- 핫스팟의 기본 컴파일 단위는 전체 메서드
- 한 메서드에 해당하는 바이트코드는 한꺼번에 네이티브 코드로 컴파일된다.
- 온-스택 치환(on-stack replacement, OSR)
- 핫스팟은 핫 루프를 온-스택 치환 기법을 통해 컴파일하는 기능도 지원한다.
- OSR은 자주 호출되지는 않지만 컴파일하기 적합한 루프가 포함돼 있고 루프 바디 자체가 메서드인 경우 사용한다.
- 핫스팟은 주로 klass 메타 데이터 구조체에 있는 vtable을 이용해 JIT 컴파일을 구현한다.
9.3.1 klass 워드, vtable, 포인트 스위즐링
- 핫스팟은 한마디로 멀티스레드 C++ 애플리케이션
- 실행 중인 자바 프로그램은 OS 관점에선 한 멀티스레드 애플리케이션의 일부일 뿐
- JIT 컴파일 서브시스템을 구성하는 스레드는 핫스팟 내부에서 가장 중요한 스레드들이다.
- 컴파일 대상 메서드를 찾아내는 프로파일링 스레드
- 실제 기계어를 생성하는 컴파일러 스레드
- 포인터 스위즐링 (pointer swizzling)
- 포인터 스위즐링이란 vtable 포인터를 업데이트 하는 작업
- 컴파일 대상 메서드는 컴파일러 스레드에 올리고 백그라운드에서 컴파일한다.
- 기계어가 생성되면 해당 klass의 vtable은 새로 컴파일된 코드를 기리키도록 수정된다.
9.3.2 JIT 컴파일 로깅
-XX:PrintCompilation
- 이 스위치를 켜면 컴파일 이벤트 로그가 표준 출력 스트림에 생성된다.
- 어떤 메서드가 컴파일되고 있는지 파악할 수 있다.
PrintComplication
출력 결과 형식
- 메서드 컴파일 시간이 제일 먼저 나온다.
- 이번 차례에 컴파일된 메서드의 순번이 표시된다.
n
: 네이티브 메서드이다
s
: 동기화 메서드이다.
!
: 예외 핸들러를 지닌 메서드이다.
%
: OSR을 통해 컴파일된 메서드이다.
-XX:+LogComilation
LogCompilation
은 진단용 옵션이라 진단 모드를 해제해야 한다. (-XX:+UnlockDiagnosticVMOptions
)
- VM이 컴파일 시 어떻게 최적화했는지, 큐잉은 어떻게 처리했는지 정보를 XML 태그 형태로 로그파일로 출력하라는 지시
JITWatch
오픈소스 툴을 사용하면 로그 파일을 파싱해서 더 이해하기 쉬운 형태로 볼 수 있다.
9.3.3 핫스팟 내부의 컴파일러
- 핫스팟 JVM에는 두 컴파일러가 존재한다.
- C1 - 클라이언트 컴파일러. GUI 애플리케이션 및 기타 ‘클라이언트 프로그램에 사용
- C2 - 서버 컴파일러. 실행 시간이 긴 ‘서버’ 애플리케이션에 사용
- 요즘 자바 애플리케이션에선 이렇게 구분하는 기준이 뚜력하지는 않다.
- 환경에 맞게 최대한 성능을 발휘하도록 변화함
- 컴파일러 컴파일 과정
- 핵심 측정값, 메서드 호출 횟수에 따라 컴파일이 트리거링된다.
- VM이 해당 메서드를 컴파일 큐에 넣는다.
- 컴파일 프로세스는 메서드의 내부 표현형을 생성
- 인터프리티드 단계에서 수집한 프로파일링 정보를 바탕으로 최적화 로직 적용
- 같은 코드라도 C1와 C2가 생성한 내부 표현형은 전혀 다르다.
- C1은 C2보다 컴파일 시간이 짧고 단순하게 설계됨
- C1은 C2처럼 풀 최적화는 하지 않는다.
9.3.4 핫스팟의 단계별 컴파일
- 자바 6부터 단계별 컴파일(tiered compilation) 모드를 지원
- 인터프리티드 모드로 실행되다 단순한 C1 컴파일 방식으로, 다시 이를 C2가 고급 최적화를 수행하는 방식
- 단계별 컴파일 VM 내부 5개의 실행 레벨
- 레벨0: 인터프리터
- 레벨1: C1 - 풀 최적화(프로파일링 없음)
- 레벨2: C1 - 호출 카운터 + 백엣지 카운터
- 레벨3: C1 - 풀 프로파일링
- 레벨 4 C2
- 컴파일 방식마다 경로가 달라 모든 레벨을 거치는 것은 아니다.
경로 |
설명 |
0-3-4 |
인터프리터, C1 - 풀 프로파일링, C2 |
0-2-3-4 |
인터프리터, C2는 바쁘니까 재빨리 C1 풀 컴파일 후 C1 풀 컴파일, 그 다음 C2 |
0-3-1 |
단순 메서드 |
0-4-1 |
단계별 컴파일 안 함 (C2로 직행) |
- 단계별 컴파일은 옛날부터 디폴트라 성늉 튜닝 시 조절할 일은 거의 없다.
9.4 코드 캐시
- JIT 컴파일된 코드는 코드 캐시라는 메모리 영역에 저장된다.
- VM 시작 시 코드 캐시는 설정된 값으로 최대 크기가 고정되어 있어 확장 불가하다.
- 코드 캐시가 가득 차면 더 이상 JIT 컴파일은 안 된다.
- 코드 캐시는 미할당 영역과 프리 블록 연결 리스트를 담은 힙으로 구현된다.
- 네이티브 코드가 제거될 때마다 해당 블록이 프리 리스트에 추가된다.
- 블록 재활용은 스위퍼 프로세스가 담당
- 네이티브 메서드가 새로 저장되면 컴파일드 코드를 담기 충분한 블록을 프리 리스트에서 찾는다.
- 다음 경우 네이티브 코드는 코드 캐시에서 제거된다.
- (추측성 최적화 결과 틀린 것으로 판명되어) 역최적화될 때
- 다른 컴파일 버전으로 교체됐을 때 (단계별 컴파일)
- 메서드를 지닌 클래스가 언로딩될 때
9.4.1 단편화
- 코드 캐시는 단편화될 수도 있다.
- C1 컴파일러를 거친 중간 단계 컴파일드 코드가 C2 컴파일로 치환된 후 삭제되는 일이 잦아지는 경우 (java 8 이전)
- 단편화가 심해지면결국 미할당 영역이 모두 소진되고 여유 공간은 전부 프리 리스트에 있는 것으로 나타난다.
- 새로 컴파일한 네이티브 코드를 담기 위해 연결 리스트를 샅샅이 뒤져야 한다.
- 스위퍼 역시 프리 리스트로 재활용 가능한 블록을 찾느라 분주해진다.
- 압착하지 않으면 코드 캐시는 단편화되고 컴파일은 중단될 것이다.
9.5 간단한 JIT 튜닝법
- 단순 JIT 튜닝 대원칙 - ‘컴파일을 원하는 메서드에게 아낌없이 리소스를 베풀라’
- 대원칙을 달성하려면 다음 항목을 점검해야 한다.
PrintCompliation
스위치를 켜고 애플리케이션 실행
- 메서드 컴파일 기록 로그 수집
ReservedCodeCacheSize
를 통해 코드 캐시를 늘린다.
- 애플리케이션 재실행
- 확장된 캐시에서 컴파일드 메서드를 살펴본다.
- JIT 컴파일에 내재된 불확장성을 고려해야 하는데 다음 두 사실을 쉽게 관찰할 수 있다.
- 캐시 크기를 늘리면 컴파일드 메서드 규모가 유의미하게 커지는가?
- 캐시 크기를 늘려도 컴파일드 메서드 개수가 그대로라면 리소스가 부족한 게 아니다.
- 주요 트랜잭션 경로상에 위치한 주요 메서드가 모두 컴파일되고 있는가?
- 트랜잭션이 몰리는 경로의 메서드가 컴파일 로그에 나타나지 않는다면 그 근본 원인을 찾아야 한다.
- 즉 단순한 JIT 튜닝이란 코드 캐시 공간이 모자라는 일이 없게 함으로써 JIT 컴파일이 절대 끊기지 않도록 보장하는 전략
- 더 정교한 기법도 있지만 단순 튜닝만 잘 알고 있어도 애플리케이션 성능을 끌어올리는 데 큰 도움이 된다.