Chapter 09. JVM의 코드 실행
Opened this issue · 4 comments
정리
- 바이트코드 해석
- JVM 인터프리터는 일종의 Stack Machine처럼 작동함
- 물리적 CPU와는 달리 계산 결과를 바로 보관하는 레지스터는 없음
- 작업할 값은 모두 평가 스택에 둠
- JVM 인터프리터는 일종의 Stack Machine처럼 작동함
- JVM에서 데이터를 담아놓는 공간
- 평가 스택: 메서드별 하나씩 생성
- 로컬 변수: 결과를 임시 저장
- 객체 힙: 메서드끼리, 스레드끼리 공유
- JVM Bytecode
- Stack Machine 작업 코드(opcode)는 1byte로 나타냄 (그래서 이름도 'byte'code)
-
0 ~ 255 까지 지정 가능하며, Java 17 Hotspot 기준으로 약 203개
- Java는 처음부터 이식성을 염두에 두었음
- Big Endian, Little Endian 하드웨어 아키텍처 모두 바이트코드 변경없이 실행 가능하도록 명세에 규정
-
자바 1.0 이후 새로 추가된 opcode는 invokedynamic 하나뿐이라고 나와있는데, 찾아보니 더 있음
-
ChatGPT 말로는 더 여러개 있다는데 GPT가 틀린 것 같기도..!
-
- Bytecode Categories
- load / store
- 산술
- 흐름 제어
-
tableswitch
,lookupswitch
는 java 7에 추�가
-
- 메서드 호출
- AOT(Ahead-Of-Time) & JIT(Just-In-Time)
- AOT: 미리 실행환경에 맞게 Binary code(a.k.a Native code, Machine language)로 컴파일해둠
- 최적화할 수 있는 순간이 딱 한 번뿐
- 이식성 안 좋음
- 대신 실행환경 정해져있으면 그에 최적화할 수 있고, 워밍업이 필요 없어 빨리 구동 가능
- JIT: 우선 JVM Bytecode로 컴파일한 후, 인터프리터가 매번 번역해서 실행하면서, 프로파일링을 따로 돌리다가, 많이 실행되는 메서드는 통째로 바이너리코드로 컴파일해서 Code Cache라는 메모리 공간에 캐싱해두고 사용함
- 실행하면서 최적화 가능
- 이식성 좋음
- 워밍업을 해야하고, 초기 구동속도가 상대적으로 느림
-XX:+PrintCompilation
플래그를 이용해서 stdout으로 컴파일 이벤트 로그 출력 가능- Code Cache 공간은 초기 실행 시 max값이 지정되며 더 이상 확장되지 않음
- 꽉 차면 더 이상 JIT 컴파일 되지 않음
- Code Cache 단편화 문제가 있을 수 있음
-
-
InitialCodeCacheSize – the initial code cache size, 160K default
-
-XX:ReservedCodeCacheSize
– the default maximum size is 48MB -
CodeCacheExpansionSize – the expansion size of the code cache, 32KB or 64KB
-
–XX:+PrintCodeCache
플래그를 통해 확인 가능
- AOT: 미리 실행환경에 맞게 Binary code(a.k.a Native code, Machine language)로 컴파일해둠
- 간단한 JIT 튜닝법
컴파일을 원하는 메서드에게 아낌없이 리소스를 베풀라
-
- PrintCompilation 스위치를 켠다
-
- 컴파일 로그를 수집한다
-
- ReservedCodeCacheSize를 통해 코드 캐시를 늘린다
- 늘렸는데도 컴파일드 메서드 수가 같다면 늘려도 성능 향상 효과 없다는 것
더 알아본 내용
- 전체 bytecode 목록
- https://javaalmanac.io/bytecode/opcodes/
- https://github.com/openjdk/jdk/blob/master/src/hotspot/share/interpreter/bytecodes.cpp
-
jsr이랑 ret이 보이네?.?
-
- Dalvik의 bytecode (그냥 찾다가 보여서 가져와봄)
정리
- 인프프리터로 해석하여 구동하는 환경은 대체로 기계어를 직접 실행하는 환경보다 성능이 떨어짐. 그래서 최신 자바 환경은 동적 컴파일 기능을 통해 이 기능을 해결 → JIT 컴파일
- 바이트 코드
-
JVM 인터프리터는 일종의 스택 머신처럼 작동
-
바이트코드 해석 - 데이터 저장 공간
- 평가 스택: 메서드별로 하나씩 생성
- 로컬 변수: 결과를 임시로 저장
- 객체 힙: 메서드끼리, 스레드끼리 공유
-
각 스택 머신 작업 코드(옵코드)는 1 Byte
-
Big endian/Little endian 모두 바이트코드 변경없이 실행 가능하도록 명세에 규정되어 있음.
-
자바 플랫폼 초창기에는 클래스 파일의 크기를 최대한 압축시키는 문제가 꽤 중요한 설계 결정이었다. 클래스 파일을 14.4Kbps 모뎀을 통해 내려받아야 했기 때문.
-
메서드 호출 바이트 코드
- invokevirtual: 인스턴스 메서드 호출
- invokeinterface: 자바 인터페이스에 선언된 메서드 호출
- invokespecial: 컴파일 타입에 디스패치할 메서드를 특정할 수 있는 경우 - 즉, private method나 super class
- invokedynamic: Java 7 부터 지원을 시작했으며, 람다 표현식 등을 더 효율적으로 지원할 수 있게 되었다 (Less bytecode… etc)
-
SafePoint
- JVM이 어떤 관리 작업을 수행하고 내부 상태를 일관되게 유지하는 데 필요한 지점.
- 일관된 상태를 유지하려면 JVM이 관리 작업 수행 도중 공유 힙이 변경되지 않게 모든 Application 스레드를 멈추어야 한다.
- JVM application thread 하나하나가 OS 스레드와 대응됨. Application 스레드가 실행하는 것은 유저 코드가 아니라 JVM 인터프리터 코드. 그렇기에 ‘바이트코드 사이사이’가 Application 스레드를 멈추기에 이상적인 시점.
-
AOT 컴파일 vs JIT 컴파일
- AOT 컴파일: 실행할 플랫폼과 프로세서 아키텍처에 딱 맞은 실행 코드를 얻는 것
- 실행할 하드웨어와 똑같은 하드웨어에서 빌드하면 컴파일러가 모든 프로세서의 최적화 기법을 총동원할 수 있을 것.
- 문제는 확장성. 다양한 아키텍처에서 최대 성능을 내려면 아키텍처마다 특화된 실행 코드가 필요
- Java 9에 AOT 컴파일을 지원했다고 하는데, Java17에 지워짐 (다른 Article을 보니깐 제한적인 사용이었고, Java16에서 지웠는데 아무도 불평하지 않았다고..)
- JIT 컴파일: 런타임에 프로그램을 고도로 최적화한 기계어로 변환하는 기법. 프로그램의 런타임 실행 정보를 수집해서 어느 부분이 자주 쓰이고, 최적화해야 효과가 좋은지 프로파일을 만들어 결정 → PGO(Profile Guided Optimization)
- 바이트 코드를 네이티브 코드로 컴파일 하는 비용은 런타임에 지불된다.
- Application은 실행 시마다 성능이 편차를 보이기에 그 때 프로파일을 해서 optimize를 하는 것은 효과적일 것이다.
- AOT 컴파일: 실행할 플랫폼과 프로세서 아키텍처에 딱 맞은 실행 코드를 얻는 것
-
핫스팟 JIT 기초
-
핫스팟은 멀티스레드 C++ Application. OS 관점에서는 실제로 한 Multi-thread application의 일부
-
JIT 컴파일 서브시스템을 구성하는 스레드는 핫스팟 내부에서 가장 중요한 스레드 - 컴파일 대상 메서드를 찾아내는 프로파일링 스레드, 기계어를 생성하는 컴파일러 스레드도 다 포함
-
JIT 컴파일 로깅
- 컴파일 이벤트 로그가 표준 출력 스트림에 생성
-XX:PrintComilation
- JIT 컴파일러가 어떤 결정을 내렸는지는 아래를 옵션. JITWatch라는 오픈 소스 툴을 이용하면, 로그파일을 파싱해서 더 이해하기 쉬운 형태로 나타낼 수 있음.
-XX:LogCompilation -XX:UnlockDiagnosticVMOptions
- 코드 캐시
- JIT 컴파일드 코드는 코드 캐시라는 메모리 영역에 저장
- VM 시작 시 코드 캐시는 설정된 값으로 최대 크기가 고정되므로 확장이 불가. 이 영역이 꽉 차면, 그 때부터 더 이상 JIT 컴파일은 안 되며, 컴파일되지 않은 코드는 인터프리터에서만 실행.
- Java 8의 default code cache size: 240MB(with TieredCompilation)
-XX:ReservedCodeCacheSize=
- JIT 튜닝 대 원칙 - ‘컴파일을 원하는 메서드에게 아낌없이 리소스를 베풀라’는 것
- 두 가지 명백한 사실
- 캐시 크기를 늘리면 컴파일드 메서드 규모가 유의미한 방향으로 커지는가?
- 주요 트랜잭션 경로상에 위치한 주요 메서드가 모두 컴파일되고 있는가?
- 두 가지 명백한 사실
-
-
개요
- 표준 자바 구현체가 코드를 실행하는 방법은 보통 VMSpec이라고 부르는 자바 가상 머신 명세에 기술되어있다고 한다.
- VM Spec에는 인터프리터로 자바 바이트코드를 실행하는 사양이 나오지만, 인터프리터로 해석하여 구동하는 환경은 대체로 기계어를 직접 실행하는 프로그래밍 환경보다 성능이 떨어진다. ( e.q. C, Rust )
- 이번 장은 코드 실행에 초점이 맞춰져있다.
바이트코드 해석
-
JVM은 주로 스택 머신처럼 작동한다. 다만 CPU와 다르게 계산 결과를 보관하는 레지스터는 따로 없다.
-
평가스택 : 메서드별로 하나씩 생성된다.
-
로컬 변수 : 결과를 임시 저장한다. ( 특정 메서드별로 저장한다. )
-
객체 힙 : 메서드끼리, 쓰레드끼리 공유한다.
-
JVM은 빅 엔디언이든 리틀 엔디언이든 하드웨어 아키텍처 모두 바이트코드 변경없이 실행하도록 명세에 규정되어있다고 한다.
- 인텔같은 최신 CPU들은 대부분 리틀엔디언을 사용하는데, 항상 최하위 주소에 최하위 바이트가 저장되므로 프로세스가 더 쉽게 엑세스할 수 있다고 합니다. ( 수학적 연산이 쉬워짐)
- 실제 핫스팟은 단순한 VM 작업을 구현하고 네이티브 플랫폼의 스택 프레임 레이아웃을 최대한 활용해서 성능을 높이기 위해 상당히 많은 어셈블리 코드를 작성되어있다고 한다.
AOT & JIT
- AOT는 프로그램을 실행할 플랫폼과 프로세서 아키텍처에 딱 맞는 실행 코드를 얻는 것!
- 결국 AOT는 CPU 기능을 최대한으로 활용하지 못하는 경우가 많아서 성능 향상의 여지가 많지만, AOT 컴파일 자체의 속도는 빠를 수 밖에 없다. ( 일단 코드 -> 기계어로 만들어버리니깐)
- JIT는 런타임에 프로그램을 기계어로 변환하는 기법이다.
- 최신 버전의 javac는 일부러
dumb bytecode
를 지향한다고 한다. 한정된 최적화만 수행하는 대신 JIT 컴파일러가 이해하기 쉬운 형태로 프로그램을 만들기 때문이다.
AOT VS JIT
- AOT는 결국 아키텍처에서 최대한의 성능을 내려면 특화된 코드가 필요로 하게 된다.
- 반면 핫스팟은 새로운 프로세서 기능에 대한 최적화 코드를 추가할 수 있고, 애플리케이션은 기존 클래스나 JAR를 다시 컴파일하지 않더라도 신기능을 계속 사용할 수 있다고 한다.
- 자바 역시 계속해서 AOT에 대한 지원을 밀고 있는데, 이는 결국 JIT가 런타임에 코드를 해석하는 과정이므로, 이를 더 빠르게 하려는 자바 개발자의 노오력이 아닌가 싶었음.
핫스팟 JIT 기초
- 핫스팟은 결국 멀티스레드 C++ 애플리케이션이다.
- 핫스팟에는 C1, C2가 존재하는데 C1은 GUI, C2는 실행 시간이 긴 서버에서 사용한다고 하는데, 요즘은 그냥 메서드 호출 횟수에 따라 컴파일러가 트리거링 된다고 한다. https://www.youtube.com/watch?v=CQi3SS2YspY&ab_channel=kakaotech ( C1, C2는 여기서 그 내용이 나온다 )
- JIT 컴파일된 코드는 코드 캐시라는 영역에 저장된다.
- 코드 캐시가 꽉 차면 JIT가 동작 안하고, 인터프리터에서만 동작할 수 있음.
- 메서드를 지닌 클래스가 언로딩 되거나 역최적화 ( 최적화하려고 해놨는데 실제로 코드가 그렇게 실행 안될 경우 이전 코드로 돌려놓는 행위), 다른 컴파일 버전으로 교체될 때 네이티브 코드는 캐시에서 제거된다고 함.
바이트 코드 해석
- 스택 기반 evaluation
- 평가 스택에서 값을 pop해서 계산 후 결과를 다시 스택에 push하는 방식
- 스택 머신 작업은 op code(1바이트)로 나타냄
iadd
,dadd
같이 명령코드에서 사용되는 오퍼랜드의 기본형을 구분할 수 있게함- 자바는 이식성을 염두에 두고 설계된 언어이기 때문에
- 하드웨어 아키텍처에 관계없이 바이트코드를 작성하기 위함
- 메서드 호출 시 아래 opcode가 실행됨
invokevirtual
: 기본적인 가상 디스패치를 통해 호출함. (인스턴스 호출은 보통 이걸로 변환됨)invokespecial
: private 메서드나 슈퍼클래스를 호출하는 경우invokeinterface
: 인터페이스를 호출하는 경우invokedynamic
: 람다를 사용하는 경우
AOT vs JIT compiler
- AOT : 프로그램을 실행할 플랫폼과 프로세서 아키텍처에 딱 맞은 실행 코드를 얻는 것. (이식성 포기)
- JIT : 이식성을 위해 바이트코드를 만들어서 네이티브 코드로 컴파일하는 비용은 런타임에 지불함