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

HotSpot VM의 구조

HotSpot (Java HotSpot Performance Engine)

  • Sun에서는 자바의 성능을 개선하기 위해서 Just In Time(JIT) 컴파일러를 만들었고, 이름을 HotSpot으로 지었다.
  • JIT 컴파일러는 프로그램의 성능에 영향을 주는 지점에 대해서 지속적으로 분석한다.
  • 분석된 지점은 부하를 최소화하고, 높은 성능을 내기 위한 최적화의 대상이 된다.
  • JIT를 사용한다는 것은 '언제나 자바 메서드가 호출되면 바이트 코드를 컴파일하고 실행 가능한 네이티브 코드로 변환한다'는 의미다. 하지만 매번 JIT로 컴파일을 하면 성능 저하가 심하므로, 최적화 단계를 거치게 된다.

HotSpot VM의 세 가지 주요 컴포넌트

  • VM 런타임
    • JIT 컴파일용 API와 가비지 컬렉터용 API를 제공한다.
    • JVM을 시작하는 런처와 스레드 관리, JNI 등도 제공한다.
  • JIT 컴파일러
  • 메모리 관리자

JIT 옵티마이저

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

  • 수행 카운터: 메소드를 시작할 때마다 증가
  • 벡에지 카운터: 높은 바이트 코드 인덱스에서 낮은 인덱스로 컨트롤 흐름이 변경될 때마다 증가
    • 메서드가 루프가 존재하는지를 확인할 때 사용되며, 수행 카운터 보다 컴파일 우선순위가 높다.

이 카운터들이 인터프레터에 의해서 증가될 때마다, 그 값들이 한계치에 도달했는지를 확인하고, 도달했을 경우 인터프리터는 컴파일을 요청한다.

  • 수행 카운터 한계치: CompileThreshold
  • 백에지 카운터 한계치: CompileThreshold * OnStackReplacePercentage / 100
  • 이 두 개의 값들은 JVM이 시작할 때 지정 가능하며 시작 옵션에 지정할 수 있다.

컴파일이 요청되면 컴파일 대상 목록에 큐에 쌓이고, 하나 이상의 컴파일러 스레드가 이 큐를 모니터링한다. 만약 컴파일러 스레드가 바쁘지 않을 때는 큐에서 대상을 빼내서 컴파일을 시작한다.

보통 인터프리터는 컴파일이 종료되기를 기다리지 않는 대신, 수행 카운터를 리셋하고 인터프리터에서 메서드 수행을 계속 한다.

컴파일이 종료되면, 컴파일된 코드와 메서드가 연결되어 그 이후부터는 메서드가 호출되면 컴파일된 코드를 사용하게 된다. 만약 인터프리터에서 컴파일이 종료될 때까지 기다리도록 하려면, JVM 시작시 -Xbatch나 -XX:-BackgroundCompilation 옵션을 지정하여 컴파일을 기다리도록 할 수도 있다.

HotSpot VM은 OSR(On Stack Replacement)이라는 특별한 컴파일도 수행한다.

OSR

  • 인터프리터에서 수행한 코드 중 오랫동안 루프가 지속되는 경우에 사용된다.
  • 해당 코드의 컴파일이 완료된 상태애ㅔ서 최적화되지 않은 코드가 수행되고 있는 것을 발견한 경우에 인터프리터에 계속 머무르지 않고 컴파일된 코드로 변경한다.
  • 루프가 끝나지 않고 지속적으로 수행되고 있을 경우에 큰 도움이 된다.

IBM JVM의 JIT 컴파일 및 최적화 절차

  1. 인라이닝
    • 자주 호출되는 메서드이 성능이 향상된다.
  2. 지역 최적화
    • 작은 단위의 코드를 분석하고 개선하는 작업을 수행한다.
  3. 조건 구문 최적화
    • 메서드 내의 조건 구문을 최적화하고, 효율성을 위해서 코드의 수행 경로를 변경한다.
  4. 글로벌 최적화
    • 메서드 전체를 최적화하는 방식이다. 매우 비싼 방식이며, 컴파일 시간이 많ㅇ ㅣ소요된다는 단점이 있지만, 성능 개선이 많이 될 수 있다는 장점이 있다.
  5. 네이티브 코드 최적화
    • 아키텍처에 따라 최적화를 다르게 처리한다.

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

JVM의 구동 절차

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

자바의 가상 머신(JVM)을 생성하는 JNI_CreateJavaVM 단계

  1. JNI_CreateJavaVM는 동시에 두개의 스레드에서 호출할 수 없고, 오직 하나의 HotSpot VM 인스턴스가 프로세스 내에서 생성될 수 있도록 보장된다. HotSpot VM이 정적인 데이터 구조를 생성하기 때문에 다시 초기화는 불가능하기 때문에, 오직 하나의 HotSpot VM이 프로세스에서 생성될 수 있다.
  2. JNI 버전이 호환성이 있는지 점검하고, GC 로깅을 위한 준비도 완료된다.
  3. OS 모듈들이 초기화된다. 예를 들면 랜덤번호 생성기, PID 할당 등이 여기에 속한다.
  4. 커맨드 라인 변수와 속성들이 JNI_CreateJavaVM 변수에 전달되고, 나중에 사용하기 위해서 파싱한 수 보관한다.
  5. 표준 자바 시스템 속성(properties)이 초기화된다.
  6. 동기화, 메모리, safepoint 페이지와 같은 모듈들이 초기화된다.
  7. libzip, libhpi, libjava, libthread와 같은 라이브러리들이 로드된다.
  8. 시그널 처리기가 초기화 및 설정된다.
  9. 스데르 라이브러리가 초기화된다.
  10. 출력 스트림 로거가 초기화된다.
  11. JVM을 모니터링하기 위한 에이전트 라이브러리가 설정되어 있으면 초기화 및 시작된다.
  12. 스레드 처리를 위해서 필요한 스레드 상태와 스레드 로컬 저장소가 초기화된다.
  13. HotSpot VM의 '글로벌 데이터'들이 초기화된다. 글로벌 데이터에는 입엔트로그, OS 동기화, 성능 통계 메모리(perfMemory), 메모리 할당자(chunkPool)들이 있다.
  14. HotSpot VM에서 스레드를 생성할 수 있는 상태가 된다. main 스레드가 생성되고, 현재 OS 스레드에 붙는다. 그러나 아직 스레드 목록에 추가되지는 않는다.
  15. 자바 레벨의 동기화가 초기화 및 활성화된다.
  16. 부트 클래스로더, 코드 캐시, 인터프리터, JIT 컴파일러, JNI, 시스템 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 요청을 처리할 상황이 되었다고 전달해 준다.

이렇게 복잡한 JNI_CreateJavaVM 시작 단계를 거치고, 나머지 단계들을 거치면 JVM이 시작된다.

JVM의 종료 절차

JVM이 시작할 때 오류가 있어 시작을 중지할 때나, JVM에 심각한 에러가 있어서 중지할 필요가 있을 때는 DestroyJavaVM이라는 메서드를 HotSpot 런처에서 호출한다.

HotSpot VM의 종료는 다음의 DestroyJavaVM 메서드의 종료 절차를 따른다.

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

클래스 로딩의 절차

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

요약하면 loading -> linking -> initializing

클래스 로더가 클래스를 찾고 로딩할 때 다른 클래스 로더에 클래스를 로딩해 달라고 하는 경우가 있다. 이를 'class loader delegation'이라고 부른다.

클래스 로더는 계층적으로 구성되어 있다. 기본 클래스 로더는 '시스템 클래스 로더'라고 불리며 main 메서드가 있는 클래스과 클래스 패스에 있는 클래스들이 이 클래스 로더에 속한다. 그 하위에 있는 애플리케이션 클래스 로더는 자바 SE의 기본 라이브러리에 있는 것이 될 수도 있고, 개발자가 임의로 만든 것일 수도 있다.

내부 클래스 로딩 데이터의 관리

  • HotSpot VM은 클래스 로딩을 추적하기 위해서 다음의 3개의 해시 테이블을 관리한다.
  • SystemDictionary
    • 로드된 클래스를 포함하며, 클래스 이름 및 클래스 로더를 키를 갖고 그 값으로 klassOop를 갖고 있다.
    • SystemDictionary는 클래스 이름과 초기화한 로더의 정보, 클래스 일므과 정의한 로더의 정보도 포함한다. 이 정보들은 safepoint에서만 제거된다.
  • PlaceHolderTable
    • 현재 로딩된 클래스들에 대한 정보를 관리한다.
    • 이 테이블은 ClassCircularityError를 체크할 때 사용하며, 다중 스레드에서 클래스를 로딩하는 클래스 로더에서도 사용된다.
  • LoaderConstraintTable
    • 타입 체크시의 제약 사항을 추정하는 용도로 사용된다.

예외 처리의 절차

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

일반적인 예외 처리 경우

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

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

예외는

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

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

  • 현재 메서드
  • 현재 바이트 코드
  • 예외 객체

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

도대체 GC는 언제 발생할까?

더 이상 필요가 없는 객체, 이 쓰레기 객체를 효과적으로 처리하는 작업을 GC라고 한다.

GC 작업을 하는 가비지 콜렉터는 다음의 역할을 한다.

  • 메모리 할당
  • 사용 중인 메모리 인식
  • 사용하지 않는 메모리 인식

자바의 메모리 영역은 총 4개의 영역으로 나뉜다고 볼 수 있다.

  • Young 영역
    • Eden, Survivor 1, Survivor 2
  • Old 영역
    • 메모리 영역

GC의 종류

  • 마이너GC: Young 영역에서 발생하는 GC
  • 메이저GC: Old 영역이나 Perm 영역에서 발생하는 GC

5가지 GC 방식

  • Serial Collector
  • Parallel Collector
  • Parallel Compacting Collector
  • Concurrent Mark-Sweep (CMS)
  • Garbage First Collector

자바의 GC 방식을 외우면서 개발하거나 서버를 설정할 필요는 없다. 그냥 이해만 하고 있으면 된다.

필요할 때(시스템 오픈 전 성능 테스트 할 때나 서버 설정시), 알맞은 GC 방식을 개발한 시스템에 적용하는 정도가 가장 바람직하다.

GC를 강제로 하면 안 되는 이유

  • GC 방식이 무엇이든 관계없이 GC를 수행하는 동안 다른 애플리케이션의 성능에 영향을 미친다.

GC가 어떻게 수행되고 있는지 보고 싶다

GC를 관리할 수 있는 툴은 여러 가지가 있다. 여러 WAS가 메모리 사용량을 간단히 모니터링하는 기능을 제공한다.

하지만 이런 모니터링 기능은 데이터를 축적할 수가 없다. 따라서 문제가 발생했을 때 상황을 알 수 없고, 나중에 확인할 수도 없다.

추후에 GC 정보를 볼 수 있는 jvmstat이라는 툴이 있다.

  • verbosegc라는 옵션을 사용하는 방법에 대해서 알아보자.

GC를 분석하면, 코드 한 줄 한 줄 작성할 때 GC가 발생하지 않도록 하여 시스템을 효율적으로 운영할 수 있다.

Full GC가 발생한 이후의 메모리 사용량으로 메모리 릭 여부를 판단하자.

GC 튜닝을 항상 할 필요는 없다

자바의 GC 튜닝은 꼭 필요한 경우에 하는 것이 좋다. 기본적인 메모리 크기 정도만 지정하면 웬만큼 사용량이 많지 않은 시스템에서는 튜닝을 할 필요가 없다.

JVM의 메모리 크기도 지정하지 않았고, Timeout이 지속적으로 발생하고 있다면 시스템에서 GC 튜닝을 하는 것이 좋다.

시스템이 GC를 적게 하도록 하려면 객체 생성을 줄이는 작업을 먼저 해야 한다.

GC 튜닝의 목적

  • Old 영역으로 넘어가는 객체의 수를 최소화하는 것
    • Old 영역의 GC는 New 영역의 GC에 비하여 상대적으로 시간이 오래 소요되기 때문에 Old 영역으로 이동하는 객체의 수를 줄이면 Full GC가 발생하는 빈도를 많이 줄일 수 있다.
    • New 영역의 크기를 잘 조절함으로써 큰 효과를 볼 수 있다.
  • Full GC의 실행 시간을 줄이는 것
    • Full GC의 수행 시간은 상대적으로 Young GC에 비하여 길다. 그래서 Full GC 실행에 시간이 오래 소요되면(1초 이상) 연계된 여러 부분에서 타임아웃이 발생할 수 있다.
    • 그렇다고 Full GC 실행 시간을 줄이기 위해서 Old 영역의 크기를 줄이면 OutOfMemoryError가 발생하거나 Full GC 횟수가 늘어난다.
    • 반대로 Old 영역의 크기를 늘리면 Full GC 횟수는 줄어들지만 실행 시간이 늘어난다.
    • Old 영역의 크기를 적절하게 '잘' 설정해야 한다.

GC의 성능을 결정하는 옵션들

  • 이런 저런 옵션을 많이 설정하면 시스템의 GC 수행 속도가 오히려 더 느려질 확률이 높다.
  • 옵션을 추가한 서버의 성능이나 GC 시간이 개선된 때에만 옵션을 추가하는 것이 GC 튜닝의 기본 원칙이다.

GC 튜닝의 절차

  1. GC 상황 모니터링
  2. 모니터링 결과 분석 후 GC 튜닝 여부 결정
    • GC 수행 시간이 1초를 넘어가면 GC 튜닝을 진행해야 한다.
    • 튜닝 전에 시스템의 메모리를 왜 높게 잡아야 하는지에 대해 생각해 봐야만 한다.
    • 만약 메모리를 1GB나 2GB로 지정했을 때 OutOfMemoryError가 발생한다면, 힙 덤프를 따서 그 원인을 확인하고, 문제점을 제거해야만 한다.
  3. GC 방식 / 메모리 크기 지정
  4. 결과 분석
  5. 결과가 만족스러울 경우 전체 서버에 반영 및 종료

메모리 크기

  • NewRatio: 자신이 운영하는 서비스의 상황에 맞는 값을 찾아야 한다.

GC 로그 분석 시 살펴봐야하는 것들 우선 순위

  • Full GC 수행 시간
  • Minor GC 수행 시간
  • Full GC 수행 간격
  • Minor GC 수행 간격
  • 전체 Full GC 수행 시간
  • 전체 Minor GC 수행 시간
  • 전체 GC 수행 시간
  • Full GC 수행 횟수
  • Minor GC 수행 횟수

GC 튜닝은 튜닝의 가장 마지막에 하는 작업이다. 애플리케이션에서 임시 메모리를 가장 적게 생성하도록 튜닝을 하고, 애플리케이션 성능을 측정을 한 다음에도 문제가 있을 때 GC 튜닝을 진행하자.

+ 따끈한 최근 게시물