JVM은 도대체 어떻게 구동될까?

Gunjoo Ahn·2023년 4월 7일
1
post-custom-banner

HotSpot VM

왜 HotSpot일까?

자바를 만든 Sun에서 자바 성능 개선을 위해 JIT 컴파일러를 만들고 HotSpot이라 명명했다. JIT 컴파일러가 프로그램 성능에 영향을 주는 HotSpot 에 대해서 지속적으로 분석하고 최적화하기에 HotSpot이라 명명하였다.

HotSpot VM의 대략적인 구조


HotSpot VM은 세 가지 주요 컴포넌트가 있다.

  • VM 런타임
  • JIT 컴파일러
  • 메모리 관리자

위 그림에서 보듯 VM 런타임에 다양한 GC 방식과 JIT을 골라 끼울 수 있는 구조를 가지고 있다. 골라 끼울 수 있도록 하기 위해서 VM 런타임은 JIT 컴파일러용 API와 가비지 컬렉터용 API를 제공한다.

그 외 JVM을 시작하는 런처와, 스레드 관리, JNI 등도 VM 런타임에서 제공한다.

JIT 옵티마이저

JVM은 바이트 코드를 읽어들여 동적으로 기계에 의존적인 코드로 변환하는 방식이다.

카운터

JIT는 메서드를 컴파일할 만큼 시간적 여유는 없다. 따라서 모든 코드는 인터프리터에 의해서 시작되고, 해당 코드가 충분히 많이 사용될 경우, 즉 HotSpot 일 경우에 컴파일할 대상이 된다. HotSpot VM에서 이 작업은 각 메서드에 있는 카운터를 통해서 통제되며, 메서드에는 두 개의 카운터가 존재한다.

  • 수행 카운터(invocation counter): 메서드를 시작할 때마다 증가
  • 백에지 카운터(backedge counter): 높은 바이트 코드 인덱스에서 낮은 인덱스로 컨트롤 흐름이 변경될 때마다 증가

백에지 카운터는 루프가 존재하는지를 확인할 때 사용한다. 수행 카운터보다 컴파일 우선순위가 높다.

각 카운터들이 한계치에 도달하면 컴파일을 하게된다.

  • 수행 카운터가 CompileThreshold에 도달하면 컴파일을 하고,
  • 백에지 카운터는 CompileThreshold * OnStackRelaceRecentage / 100에 도달하면 컴파일한다.

JVM 시작시, 다음과 같이 지정할 수 있다.

  • XX:CompileThreshold=35000
  • XX:OnStackReplacePercentage=80

이렇게 지정할 경우, 메서드가 35000번 호출되었을 때 JIT에서 컴파일하며, 백에지 카운터는 35000 * 0.8 = 28000번 호출되었을 때 컴파일한다.

컴파일 요청

컴파일이 요청되면 컴파일 대상 목록 큐에 쌓이고, 하나 이상의 컴파일러 스레드가 이 큐에서 대상을 빼내서 컴파일한다. 인터프리터에서는 컴파일이 종료될 때까지 기다리지 않으나, -Xbatch-XX:-BackgroundCompilation 옵션을 지정하여 컴파일을 기다리도록 할 수 있다.

OSR(On Stack Replacement)

HotSpot VM은 OSR이라는 특별한 컴파일도 수행한다. 오랫동안 루프가 지속되는 경우에 사용하는 것으로, 만약 해당 코드의 컴파일이 완료된 상태에서 최적화되지 않은 코드가 수행되고 있는 것을 발견한 경우에 인터프리터에 계속 머무르지 않고 컴파일된 코드로 변경한다.

JRockit JVM JIT

예시로 JRockit JIT 컴파일 동작 방식을 알아보자.

JRockit runs JIT compileation

JIT-Compiled machine code로 컴파일한다. 애플리케이션이 시작하는 동안 몇천 개의 새로운 메서드가 수행되면 JRockit은 새로 시작하는 메서드를 JIT 컴파일하느라 다른 JVM보다 JRockit JVM이 시작은 더 느릴 수도 있다. 그러나 지속적으로 수행할 때는 JRockit이 더 빠른 처리가 가능하다.

모든 메서드를 컴파일하고 최적화하는 작업은 JVM 시작을 느리게 하기 때문에 시작시 모든 메서드를 최적화하지는 않는다.

JRockit mointors threads

주기적으로 애플리케이션의 스레드를 점검하여, 최적화 대상을 모니터링하는 스레드가 있다.

JRockit runs optimization

모니터링을 통하여 식별한 대상을 Highly optimized machine code로 최적화한다. 이 작업은 백그라운드에서 진행되며 수행중인 애플리케이션에 영향을 주지 않는다.

IBM JVM JIT

IBM JVM의 JIT 컴파일 방식은 5가지로 나뉜다.

인라이닝(Inlining)

메서드가 단순할 때 적용되는 방식이다. 인라이닝은 callee 함수를 caller 함수에 넣어 함수 콜스택을 줄이는 것이다.

지역 최적화(Local optimizations)

작은 단위의 코드를 분석하고 개선하는 작업을 수행한다.

조건 구문 최적화(Control flow optimizations)

메서드 내의 조건 구문을 최적화하고, 효율성을 위해서 코드의 수행 경로를 변경한다.

글로벌 최적화(Global optimizations)

메서드 전체를 최적화한다. 매우 비용이 비싼 방식이며, 컴파일 시간이 많이 소요된다. 성능 개선이 많이 될 수 있다는 장점이 있다.

네이티브 코드 최적화(Native code generation)

이 방식은 플랫폼 아키텍처에 의존적이다. 다시 말해서 아키텍처에 따라서 최적화를 다르게 처리하는 것을 말한다.

컴파일한 코드는 코드 캐시라고 하는 JVN 프로세스 영역에 저장된다. 결과적으로 JVM 프로세스는 JVM 수행 파일컴파일된 JIT 코드의 집합으로 구분된다.

JVM 구동 절차

  1. java 명령어 줄에 있는 옵션 파싱
  2. 자바 힙 크기 할당 및 JIT 컴파일러 타입이 명령줄에 지정되지 않았을 경우 해당 옵션 지정
  3. CLASSPATHLD_LIBARARY_PATH 등 환경 변수 지정
  4. Main 클래스가 지정되지 않았으면, Jar 파일의 manifest 파일에서 Main 클래스 확인
  5. JNI 표준 API인 JNI_CreateJavaVM을 사용하여 새로 생성한 non-primordial 스레드에서 HotSpot VM을 생성
  6. HotSpot VM이 생성되고 초기화되면, Main 클래스가 로딩된 런처에서는 main() 메서드의 속성 정보를 읽는다
  7. CallStaticVoidMethod는 네이티브 인터페이스를 불러 HotSpot VM에 있는 main() 메서드가 수행된다. 이때 자바 실행 시 Main 클래스 뒤에 있는 값들이 전달된다.

5번 JNI_CreateJavaVM 단계 세부 절차

  1. JNI_CreateJavaVM 동시에 두개의 스레드에서 호출할 수 없고, 오직 하나의 HotSpot VM 인스턴스가 프로세스 내에서 생성할 수 있도록 보장한다. HotSpot VM이 정적인 데이터 구조를 생성하기 때문에 다시 초기화는 불가능하다. 따라서 오직 하나의 HotSpot VM이 프로세스에서 생성될 수 있다.

  2. JNI 버전 호환성 점검 및 GC 로깅 준비

  3. OS 모듈들 초기화. 예들 들어 랜덤 번호 생성기, PID 할당 등이 여기에 속한다.

  4. 커맨드 라인 변수와 속성들이 JNI_CreateJavaVM 변수에 전달되고, 나중에 사용하기 위해서 파싱후 보관

  5. 표준 자바 시스템 속성 초기화

  6. 동기화, 메모리, safepoint 페이지와 같은 모듈 초기화

  7. libzip, libhip, libjava, libthread 와 같은 라이브러리들 로드

  8. 시그널 처리기 초기화 및 설정

  9. 스레드 라이브러리 초기화

  10. 출력 스트림 로거 초기화

  11. JVM 모니터링 에이전트 라이브러리가 설정되어 있으면 초기화 및 시작

  12. 스레드 처리를 위해서 필요한 스레드 상태와 스레드 로컬 저장소 초기화

  13. HotSpot VM의 글로벌 데이터들이 초기화, 글로벌 데이터에는 이벤트 로그, OS 동기화, 성능 통계 메모리, 메모리 할당자들이 있다.

  14. HotSpot VM에서 스레드를 생성할 수 있는 상태가 된다. main 스레드가 생성되고, 현재 OS 스레드에 붙는다. 그러나 아직 스레드 목록에 추가되지는 않는다.

  15. 자바 레벨의 동기화가 초기화 및 활성화

  16. 부트 클래스로더, 코드 캐시, 인터프리터, JIT 컴파일러, JIN, 시스템 dictionary, 글로벌 데이터 구조의 집합인 universe 등 초기화

  17. 스레드 목록에 자바 main 스레드 추가, universe 상태 점검. HotSpot VM의 중요한 기능을 하는 HotSpot VMThread가 생성된다. 이 시점에 HotSpot VM의 현재 상태를 JVMTI에 전달

  18. java.lang 패키지에 있는 String, System, Thread, ThreadGroup, Class 클래스와 java.lang 하위 패키지인 Method, Finalizer 클래스 등이 로딩되고 초기화된다.

  19. HotSpot VM의 시그널 핸들러 스레드가 시작되고, JIT 컴파일러가 초기화되며, HotSpot의 컴파일 브로커 스레드가 시작된다. HotSpot VM과 관련된 각종 스레드들이 시작한다. 이때부터 HotSpot VM의 전체 기능이 동작한다.

  20. JNIEnv가 시작되며, HotSpot VM을 시작한 호출자에게 새로운 JNI 요청을 처리할 상황이 되었다고 전달

JVM 종료 절차

OS의 kill -9 시그널로 죽이면 해당 절차를 밟지 않음을 유의하자.

DestroyJavaVM 메서드를 HotSpot 런처에서 호출한다. HotSpot VM의 종료는 다음의 DestroyJavaVM 메서드의 종료 절차를 따른다.

  1. HotSpot VM이 작동중인 상황에서는 단 하나의 데몬이 아닌 스레드가 수행될 때까지 대기한다.
  2. java.lang 패키지에 있는 Shutdown 클래스의 shutdown() 메서드가 수행된다. 이 메서드가 수행되면 자바 레벨의 shutdown hook이 수행되고, finalization-on-exit이라는 값이 true일 경우에 자바 객체 finalizer를 수행한다.
  3. HotSpot VM 레벨의 shutdown hoot이 수행함으로 HotSpot VM의 종료를 준비한다. 이 작업은 JVM_OnExit() 메서드를 통해서 지정. gc 스레드 등 종료한 이후JVMTI를 비활성화하고 시그널 스레드를 종료
  4. HotSpot VM의 JavaThread::exit() 메서드를 호출하여 JNI 처리 블록을 해제한다. 이 대부터 HotSpot VM은 자바 코드 수행을 할 수 없다.
  5. HotSpot VM 스레드를 종료한다. 남아 있는 스레드들을 safepoint로 옮기고, JIT 컴파일러 스레드를 중지한다.
  6. JNI, HotSpot VM에 있는 추적 기능을 종료한다.
  7. 네이티브 스레드에서 수행하고 있는 스레드들을 위해서 vm exited 값을 설정한다.
  8. 현재 스레드를 삭제한다.
  9. 입출력 스트림을 삭제하고, PerfMemory 리로스 연결을 해제한다.
  10. JVM 종료를 호출한 호출자로 복귀

클래스 로딩의 절차

  1. 주어진 클래스의 이름으로 CLASSPATH에 있는 바이너리로 된 자바 클래스를 찾는다.
  2. 자바 클래스를 정의한다
  3. 해당 클래스를 나타내는 java.lang 패키지의 Class 클래스의 객체를 생성한다.
  4. 링크 작업을 수행한다. 이 단계에서 static 필드를 생성 및 초기화하고, 메서드 테이블을 할당
  5. 클래스의 초기화가 진행되며, 클래스의 static 블럭과 static 필드가 가장 먼저 초기화 된다. 해당 클래스가 초기화되기 전에 부모 클래스 초기화가 선행된다.

loading -> linking -> Initializing 으로 기억하면 된다.

Class loader delegation

클래스 로더가 클래스를 찾고 로딩할 때 다른 클래스 로더에 클래스를 로딩해달라 하는 경우가 있는데 이를 class loader delegation이라 한다. 클래스 로더는 계층적으로 구성되어 있으며, 기본 클래스 로더는 시스템 클래스 로더라고 불리며 main 메서드가 있는 클래스와 클래스 패스에 있는 클래스들이 이 클래스 로더에 속한다.

예외 처리의 절차

JVM은 자바 언어의 제약을 어겼을 때 예외라는 시그널로 처리한다. HotSpot VM 인터프리터, JIT 컴파일러 및 다른 HotSpot VM 컴포넌트는 예외 처리와 모두 관련되어 있다. 일반적인 예외 처리의 경우는 아래 두 가지 경우다.

  • 예외를 발생한 메서드에서 잡을 경우
  • 호출한 메서드에 의해서 잡힐 경우

후자의 경우는 보다 복잡하며, 스택을 뒤져서 적당한 핸들러를 찾는 작업이 필요하다.

예외는
1. 던져진 바이트 코드에 의해서 초기화 될 수 있으며
2. VM 내부 호출의 결과로 넘어올 수도 있고
3. JNI 호출로 부터 넘어올 수도 있고
4. 자바 호출로부터 넘어올 수도 있다

VM이 예외가 던져졌다는 것을 알아차렸을 때, 해당 예외를 처리하는 가장 가까운 핸들러를 찾기 위해서 HotSpot VM 런타임 시스템이 수행된다. 이 때, 핸들러를 찾기 위해서는 다음의 3개의 정보가 사용된다.

  1. 현재 메서드
  2. 현재 바이트 코드
  3. 예외 객체

만약 현재 메서드에서 핸들러를 찾지 못했을 때는 현재 수행되는 스택 프레임을 통해서 이전 프레임을 찾는 작업을 수행한다. 적당한 핸들러를 찾으면, HotSpot VM 수행 상태가 변경되며, HotSpot VM은 핸들러로 이동하고 자바 코드 수행을 계속된다.

Reference

https://sungjk.github.io/2019/04/16/java-performance-tuning-4.html
자바 성능 튜닝 이야기 16장

profile
Backend Developer
post-custom-banner

0개의 댓글