자바 최적화 책 정리
바이트코드 해석
✅ 평가 스택
JVM 인터프리터는 일종의 스택 머신처럼 작동한다.
-> 작업할 값을 모두 평가 스택에 놓고 스택 최상단에 위치한 값을 변환하는 식으로 작동한다.
JVM은 다음 세 공간에 주로 데이터를 담아 놓는다.
- 평가 스택: 메서드별로 하나씩 생성된다.
- 로컬 변수: 결과를 임시 저장한다(특정 메서드별로 존재한다).
- 객체 힙: 메서드끼리, 스레드끼리 공유한다.
JVM 바이트코드 개요
✅ 옵코드
JVM에서 각 스택 머신의 값을 변환하는 작업 코드(옵코드)는 1바이트로 나타낸다 (그래서 이름도 바이트코드다)
옵코드는 패밀리 단위로 구성되어 있고, 각각 기본형, 참조형 등을 쓸 수 있게 한다.
ex) store 패밀리 -> dstore(double형 지역 변수로 스토어), astore(참조형 지역 변수로 스토어)
✅ 이식성
자바는 처음부터 이식성을 염두에 두고 설계된 언어다.
-> JVM은 빅 엔디언, 리틀 엔디언 하드웨어 모두 실행 가능해야 되며, JVM은 어느 엔디언을 따를지 결정해야 한다.
엔디언 관련 참고: https://code-lab1.tistory.com/179
✅ 단축형
load 같은 옵코드 패밀리는 단축형이 있어서 인수를 생략할 수 있고, 그만큼 클래스 파일의 인수 공간을 절약할 수 있다.
✅ 로드/스토어 카테고리
앞으로 표시하는 바이트코드 테이블에서 c1은 2바이트짜리 상수 풀 인덱스, i1은 현재 메서드의 지역 변수, 괄호는 해당 옵코드 패밀리 중 단축형을 지닌 옵코드가 있음을 의미한다.
먼저 상수 풀에서 데이터를 로드하거나 스택 상단을 힙에 있는 객체 필드에 저장하는 로드/스토어 카테고리를 알아보자.
Family nameArgumentsDescription
패밀리 명 | 인수 | 설명 |
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 | 주어진 오프셋으로 무조건 분기한다. |
✅ 메서드 호출 카테고리
자바 프로그램에서 새 메서드로 제어권을 넘기는 유일한 장치다.
패밀리 명 | 인수 | 설명 |
invokevirtual | c1 | CP#c1이 가리키는 메서드를 가상 디스패치를 통해 호출한다. -> 보통 인스턴스 메서드 호출 시 사용 |
invokespecial | c1 | CP#c1이 가리키는 메서드를 특별한 디스패치를 통해(즉, 정확하게) 호출한다. -> 컴파일 타임에 디스패치할 메서드를 특정할 수 있는 경우(프라이빗 or 슈퍼클래스 호출) |
invokeinterface | c1, count, 0 | CP#c1이 가리키는 인터페이스 메서드를 인터페이스 오프셋 룩업을 이용해 호출한다. -> 자바 인터펭스에 선언된 메서드 호출 시 사용 |
invokestatic | c1 | CP#c1이 가리키는 정적 메서드를 호출한다. -> 정적 메서드 호출 시 사용 |
invokedynamic | c1, 0, 0 | 호출해서 실행할 메서드를 동적으로 찾는다. -> 람다 표현식을 동적으로 호출할 때 사용 |
✅ 플랫폼 카테고리
객체별로 힙 저장 공간을 새로 할당하거나, 고유 락을 다루는 명령어들이다.
옵코드명 | 인수 | 설명 |
new | c1 | CP#c1이 가리키는 타입의 객체에 공간을 할당한다. |
newarray | prim | 기본형 배열에 공간을 할당한다. |
anewarray | c1 | CP#c1이 가리키는 타입의 객체 배열에 공간을 할당한다. |
arraylength | 스택 상단에 위치한 객체를 그 길이로 치환한다. | |
monitorenter | 스택 상단의 객체 모니터를 잠금한다. | |
monitorexit | 스택 상단의 객체 모니터를 잠금 해제한다. |
✅ 세이브포인트
JVM이 어떤 관리 작업(GC 등)을 수행하고 내부 상태를 일관되게 유지하는 데 필요한 지점.
-> 일관된 상태를 유지하려면 작업 수행(GC 등) 도중 공유 힙이 변경되지 않게 모든 애플리케이션 메서드를 멈추어야 한다.
-> '바이트코드 사이사이'가 애플리케이션 스레드를 멈추기에 이상적인 시점이자 단순한 세이브포인트다.
단순 인터프리터
✅ switch 문이 포함된 while 루프 형태
이게 가장 단순한 인터프리터 형태일 것이다.
-> 오슬롯 프로젝트는 교육용으로 JVM 인터프리터 일부를 구현한 프로젝트다.
ex)
public EvalValue execMethod(final byte[] instr) {
if (instr == null || instr.length == 0)
return null;
EvaluationStack eval = new EvaluationStack();
int current = 0;
LOOP:
while (true) {
byte b = instr[current++];
Opcode op = table[b & 0xff];
if (op == null) {
System.err.println("Unrecognized opcode byte: " + (b & 0xff));
System.exit(1);
}
byte num = op.numParams();
switch (op) {
case IADD:
eval.iadd();
break;
case ICONST_0:
eval.iconst(0);
break;
// ...
case IRETURN:
return eval.pop();
case ISTORE:
istore(instr[current++]);
break;
case ISUB:
eval.isub();
break;
// Dummy implementation
case ALOAD:
case ALOAD_0:
case ASTORE:
case GETSTATIC:
case INVOKEVIRTUAL:
case LDC:
System.out.print("Executing " + op + " with param bytes: ");
for (int i = current; i < current + num; i++) {
System.out.print(instr[i] + " ");
}
current += num;
System.out.println();
break;
case RETURN:
return null;
default:
System.err.println("Saw " + op + " : can't happen. Exit.");
System.exit(1);
}
}
}
핫스팟에 특정한 내용
✅ 프라이빗 바이트코드
핫스팟은 상용 JVM이자, 인터프리티드 모드에서도 빠르게 실행될 수 있도록 여러 고급 확장 기능을 가지고 있다.
-> 오슬록 같은 단순 인터프리터와 달리, 핫스팟은 템플릿 인터프리터라서 동적으로 특정 바이트코드에 맞는 템플릿을 제공한다.
심지어 핫스팟 전용 프라이빗 바이트코드까지 정의해서 쓴다.
ex 1) final 메서드는 오버라이드할 수 없으니 컴파일하면 invokespecial 옵코드가 나오리라 예상할 수 있다.
그러나 자바 언어 명세에는 다음과 같은 문구가 있다.
"final 메서드를 final이 아닌 메서드로 변경하는 건 기존 바이너리와의 호환성을 깨뜨리지 않는다"
-> final을 non-final로 변경해도 바이트코드는 변경되지 않는다.
그렇다면 만약 final 메서드 A를 호출하는 어떤 클래스가 invokespecial로 컴파일 됐는데,
해당 메서드가 non-final로 변경되고 이를 오버라이드한 자식 클래스의 메서드 B를 호출하는 코드가 있다면 이는 invokespecial이 아닌 invokevirtual이 되야 한다. (invokespecial은 동적이 아닌 정확한 디스패치를 해야 하기 때문)
-> 따라서 핫스팟에는 final 메서드를 디스패치하는 전용 프라이빗 바이트코드가 준비돼 있다.
ex 2) 자바 언어 명세를 보면, 종료화 대상 객체는 반드시 종료화 서브시스템에 등록해야 한다고 씌어 있다.
즉, Object 생성자의 init 호출이 완료되면 곧바로 객체를 서브시스템에 등록해야 하는데,
JVMTI와 같은 바이트 코드를 건드리는 툴에서는 이런 객체 등록 코드의 위치가 모호해질 수 있다.
-> 핫스팟은 진짜 Object 생성자에서 반환되는 지점을 표시하는 프라이빗 바이트코드를 따로 두어 엄격하게 명세를 준수한다.
AOT와 JIT 컴파일
AOT 컴파일
✅ C/C++ 개발 경험자라면 익숙한 컴파일
그냥 '컴파일'이라고도 불렀던 AOT 컴파일은,
소스 코드를 컴파일러에 넣고 바로 실행 가능한 기계어를 뽑아내는 과정이다.
✅ AOT의 목표
프로그램을 실행할 플랫폼과 프로세서 아키텍처에 딱 맞은 실행 코드를 얻는 것이다.
-> 이렇게 고정된 바이너리는 프로세서의 특수 기능을 십분 활용해 프로그램 속도를 높일 수 있다.
✅ AOT의 한계
대부분의 실행 코드는 자신이 어떤 플랫폼에서 실행될지 모르는 상태에서 생성되므로,
어떤 기능이 있을 거란 전제하에 컴파일한 코드가 그렇지 못한 환경에서 실행되면 전혀 작동하지 않을 수 있다.
-> AOT 컴파일은 CPU 기능을 최대한 활용하지 못하는 경우가 다반사고 성능 향상의 숙제가 남는다.
JIT 컴파일
✅ 핫스팟을 비롯한 대부분의 JVM에서 사용
런타임에 프로그램을 고도로 최적화한 기계어로 변환하는 기법이다.
-> 프로그램의 런타임 실행 정보를 수집해서 어느 부분을 최적화해야 좋은지 프로파일을 만들어 결정을 내린다. (PGO)
✅ 애플리케이션을 실행할 때마다 최적화를 재수행하는 것은 낭비 아닌가?
미리 최적화된 컴파일 코드를 저장했다가 다음 애플리케이션이 시작할 때 그대로 쓰면 안되나?
-> 애플리케이션 코드는 언제 트래픽이 많이 몰릴지 모르고, 매번 성능이 심한 편차를 보이는 현상이 아주 흔하다.
-> 미리 계산된 최적화를 이용한 시스템이 PGO를 활용한 시스템보다 경쟁력이 떨어진다.
-> 그래서 핫스팟은 프로파일링 정보를 보관하지 않고 VM이 꺼지면 일체 폐기한다.
AOT 컴파일 vs JIT 컴파일
✅ AOT 컴파일
1. 장점
상대적으로 이해하기 쉽다.
특정 프로세서에만 사용할거라면 극단적으로 성능이 중요한 유스케이스에는 유용하다.
2. 단점
최적화 결정을 내리는 데 유용한 런타임 정보를 포기한다.
다양한 아케틱처에서 좋은 성능을 내려면 아키텍처마다 특화된 실행 코드가 필요하다. (확장성이 부족)
✅ JIT 컴파일
새로운 프로세서 기능에 관한 최적화 코드를 추가할 수 있다. (확장성이 좋음)
심지어 핫스팟은 AOT 컴파일 옵션을 제공하기도 한다.
핫스팟 JIT 기초
klass 워드, vtable, 포인터 스위즐링
✅ JIT 시스템을 구성하는 스레드
- 프로파일링 스레드: 컴파일 대상 메서드를 찾아내는 스레드.
- 컴파일러 스레드: 실제 기계어를 생성하는 스레드.
✅ 포인터 스위즐링
최적화된 기계어가 생성되면 해당 klass의 vtable(메서드의 실제 구현을 저장)은 새로 컴파일된 코드를 가리키도록 수정된다.
-> 이렇게 vtable 포인터를 업데이트하는 작업을 포인터 스위즐링이라고 한다.
JIT 컴파일 로깅
✅ 컴파일 관련 로깅
-XX:+PrintCompilation
-> 어떤 메서드가 컴파일되고 있는지 파악.
-XX:+LogCompilation
-XX:+UnlockDiagnosticVMOptions // LogCompilation이 진단용 옵션이라 해당 플래그로 진단 모드로 해제
-> JIT 컴파일러가 어떤 결정을 내렸는지 더 자세한 정보 확인.
핫스팟 내부의 컴파일러
✅ C1, C2 컴파일러
핫스팟에 있는 두 JIT 컴파일러.
-> 역사적으로 C1은 클라이언트 프로그램에, C2는 서버에 주로 사용됐지만, 요즘은 이런 기준이 뚜렷하지 않고 각자 최대한 성능을 발휘하도록 변화했다.
차이점으로는 C1이 C2보다 컴파일 시간이 짧고 단순하게 설계된 까닭에 C2처럼 풀 최적화는 안한다.
공통점으로는 변수에 값이 확정됐다면 일체 재할당하지 않는 코드로 변환하는 단일 정적 할당(SSA)를 사용한다.
-> 자바 관점에서는 final 변수만 쓰는 코드로 탈바꿈 시키는 것이다.
핫스팟의 단계별 컴파일
✅ 단계별 컴파일
인터프리터 모드로 실행되다가 -> 단순한 C1 컴파일 형식으로 바뀌고 -> 다시 이를 C2가 보다 고급 최적화를 수행하는 방식.
- 레벨 0: 인터프리터
- 레벨 1: C1 - 풀 최적화(프로파일링 없음)
- 레벨 2: C1 - 호출 카운터 + 백엣지 카운터
- 레벨 3: C1 - 풀 프로파일링
- 레벨 4: C2
-> 이 모든 레벨을 다 거치는 것은 아니고, 컴파일 방식마다 경로가 다르다.
경로 | 설명 |
0-3-4 | 인터프리터, C1 - 풀 프로파일링, C2 |
0-2-3-4 | 인터프리터, C2는 바쁘니까 재빨리 C1 컴파일 후 C2 풀 컴파일, 그 다음에 C2 |
0-3-1 | 단순 메서드 |
0-4 | 단계별 컴파일 안함(C2로 직행) |
코드 캐시
✅ JIT 컴파일드 코드가 저장되는 메모리 영역
코드 캐시는 최대 크기가 고정되므로 확장이 불가하다.
-> 코드 캐시가 꽉차면 그때부터 JIT 컴파일이 안 된다.
코드 캐시의 최대 크기는 다음 스위치로 조정한다.
-XX:ReservedCodeCacheSize=<n>
✅ 코드 캐시 동작 방식
미할당 영역과 네이티브 코드가 제거될 때마다 추가되는 프리 리스트로 구성된다.
블록 재활용은 스위퍼라는 프로세스가 담당한다.
-> 컴파일드 코드를 담기에 크기가 충분한 블록을 프리 리스트에서 찾아보고 없으면 미할당 영역에 생성한다.
✅ 코드 캐시에서 제거
다음 경우, 네이티브 코드는 코드 캐시에서 제거된다.
- (추측성 최적화를 적용한 결과 틀린 것으로 판명되어) 역최적화 될 때
- 다른 컴파일 버전으로 교체됐을 때 (단계별 컴파일)
- 메서드를 지닌 클래스가 언로딩될 때
단편화
✅ 코드 캐시의 단편화
컴파일드 코드가 삭제되는 일이 잦아지면,
미할당 영역이 소진되고, 프리 리스트가 커진다.
-> 새로운 컴파일드 코드는 분주히 프리 리스트를 찾아야 한다.
-> 압착을 하지 않는다면 코드 캐시도 단편화되고 컴파일은 중단될 것이다.
간단한 JIT 튜닝법
✅ 컴파일을 원하는 메서드에게 아낌없이 리소스를 배풀라
- 먼저, PrintCompilation 스위치를 켜고 애플리케이션을 실행한다.
- 어느 메서드가 컴파일됐는지 기록된 로그를 수집한다.
- ReservedCodeCacheSize를 통해 코드 캐시를 늘린다.
- 애플리케이션을 재실행한다.
- 확장된 캐시에서 컴파일드 메서드를 살펴본다.
✅ 확인해볼 것
- 캐시 크기를 늘리면 컴파일드 메서드 규모가 유의미한 방향으로 커지는가?
- 주요 트랜잭션 경로상에 위치한 주요 메서드가 모두 컴파일되고 있는가?
-> 캐시 크기를 늘려도 컴파일드 메서드 개수가 그대로라면, JIT 컴파일러의 리소스가 부족한게 아니다.
-> 이 때, 트랜잭션 경로상의 주요 메서드가 컴파일 로그에 나타나지 않는다면, 컴파일되지 않는 근본 원인을 찾아야 한다.
-> 즉, 코드 캐시 공간이 모자라는 일이 없게 함으로써 JIT 컴파일이 절대 끊기지 않도록 보장하는 전략.
'book > 자바 최적화' 카테고리의 다른 글
[자바 최적화] 자바 언어의 성능 향상 기법 (0) | 2023.10.09 |
---|---|
[자바 최적화] JIT 컴파일의 세계로 (0) | 2023.09.20 |
[자바 최적화] GC 로깅, 모니터링, 튜닝, 툴 (0) | 2023.09.05 |
[자바 최적화] 가비지 수집 고급 (0) | 2023.08.29 |
[자바 최적화] 가비지 수집 기초 (2) | 2023.08.28 |