Post

자바 성능 튜닝 이야기 7주차

자바 성능 튜닝 이야기 7주차

들어가며

이 포스트는 이상민의 「자바 성능 튜닝 이야기」 Chpater12 ~ 14을 읽고 개인적으로 학습한 내용을 정리한 글입니다.

  • 책: 자바 성능 튜닝 이야기
  • 저자: 이상민
  • 출판사: 인사이트
  • 챕터: Chapter16 ~ Chapter 17

핵심 정리 내용

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

HotSpot VM은 어떻게 구성되어 있을까?

  • HotSpot VM
    • Just In Time(JIT) 컴파일러의 이름
      • JIT을 사용하면, ‘언제나 자바 메서드가 호출되면 바이트 코드를 컴파일하고 실행 가능한 네이티브 코드로 변환
    • JIT 컴파일러는 프로그램의 성능에 영향을 주는 지점을 지속적으로 분석해 부하를 최소화하고, 높은 성능을 내기 위한 최적화 대상으로 분석 지점을 선정
    • 높은 성능과 확장성 제공
  • HotSpot VM의 주요 컴포넌트
    • VM(Virtual Machine) 런타임
    • JIT(Just In Time) 컴파일러
    • 메모리 관리자
  • HotSpot VM 런타임 방식에 GC 방식, JIT 컴파일러를 끼워 맞춰 사용할 수 있다
    • VM 런타임은 JIT 컴파일러용 API, GC API를 제공, JVM 을 시작하는 런처와 스레드 관리, JNI 등도 VM 런타임에게 제공

JIT Optimizer라는 게 도대체 뭘까?

  • 자바는 javac 컴파일러 사용 > 소슼도르르 바이트 코드로 된 class 파일로 변환
  • JVM은 항상 바이트 코드로 시작해, 동적으로 기계에 의존적인 코드로 변환
  • JIT는 애플리케이션에서 각각의 메서드를 컴파일할 만큼 시간적 여유가 많지 않아, 모든 코드는 초기에 인터프리터에 의해 시작되고, 해당 코드가 충분히 많이 사용될 경우 컴파일 대상으로 지정
  • HotSpot VM에서 이 작업은 각 메서드에 있는 카운터를 통해서 통제
  • HotSpot VM이 메서드에 대한 카운터
    • 수행 카운터(invocation counter) : 메서드를 시작할 때마다 증가
    • 백에지 카운터(backedge counter) : 높은 바이트 코드 인덱스에서 낮은 인덱스로 컨트롤 흐름이 변경될 때마다 증가
    • 백에지 카운터는 메서드가 루프가 존재하는지를 확인할 때 사용, 수행 카운터보다 컴파일 우선순위가 높음
    • 이 카운터들은 인터프리터에 의해 증가할 때마다, 그 값들이 한계치에 도달했는지를 확인하고, 도달했을 경우 인터프리터는 컴파일을 요청
    • 한계치(CompileThreshold) -> 백에지 카운터의 한계치 공식 : CompileThreshold * OnStackReplacePercentage / 100
    • 수행 카운터 & 백에지 카운터 시작 옵션 설정
      • XX:CompileThreshold=35000XX:OnStackReplacePercentage=80
  • 컴파일이 요청되면 컴파일 대상 목록의 큐에 쌓이고, 하나 이상의 컴파일러 스레드가 이 큐를 모니터링
    • 컴파일러 스레드가 바쁘지 않으면 큐에서 대상을 빼내서 컴파일을 시작
    • 보통 인터프리터는 컴파일이 종료되기 기다리지 않는 대신, 수행 카운터를 리셋하고 인터프리터에서 메서드를 계속 수행
    • 컴파일 종료 시 컴파일된 코드롸 메서드가 연결되어 그 이후 메서드가 호출되면 컴파일된 코드를 사용
  • HotSpot VM
    • OSR(On Stack Replacement) 특별한 컴파일
      • 인터프리터에서 수행한 코드 중 오랫동안 루프가 지속되는 경우에 사용
      • 해당 코드의 컴파일이 완료된 상태에서 최적화되지 않은 코드가 수행되고 있는 것을 발견한 경우 인터프리터에 계속 머무르지 않고 컴파일된 코드로 변경

JRockit의 JIT 컴파일 및 최적화 절차

  • JRockit의 JIT 컴파일 및 최적화 절차
    1. JRockit runs JIT compilation
      • 자바 애플리케이션을 실행하면 1단계인 JIT 컴파일을 거친 후 실행
      • 이 단계를 거친 후 메서드가 수행되면, 그 다음부터 컴파일된 코드를 호출해 처리 성능이 빨라짐
      • 애플리케이션이 시작하는 동안 몇천 개의 새로운 메서드가 수행되어 다른 JVM보다 JRockit JVM이 느려질 수 있음
      • JIT를 사용하면 시작할 때의 성능은 느리지만, 지속적으로 수행할 때 더 빠른 처리가 가능
    2. JRockit monitors threads
      • JRockit에는 ‘sampler thread’라는 스레드가 존재하며 주기적으로 애플리케이션의 스레드를 점검
      • sampler thread는 어떤 스레드가 동작 중인지 여부와 수행 내력을 관리
      • 이를 통해 어떤 메서드가 많이 사용되는지 확인해 최적화 대상을 찾음
    3. JRockit JVM Runs Optimization
      • sampler thread가 식별한 대상을 최적화
      • 이 작업은 백그라운드에서 진행ㄹ되며 수행중인 애플리케이션에 영향을 주지는 않는다

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

  • IBM JVM의 JIT 컴파일 방식
    • 인라이닝(Inlining)
      • 메서드가 단순할 때 적용되는 방식
      • 호출된 메서드가 단순할 경우 그 내용이 호출한 메서드의 코드에 포함시킴
      • 이 경우 자주 호출되는 메서드의 성능이 향상
    • 지역 최적화(Local optimizations)
      • 작은 단위의 코드를 분석하고 개선하는 작업을 수행
    • 조건 구문 최적화(Control flow Optimizations)
      • 메서드 내의 조건 구문을 최적화하고, 효율성을 ㅜ이해 코드의 수행 경로를 변경
    • 글로벌 최적화(Global Optimizations)
      • 메서드 전체를 최적화하는 방식
      • 매우 비싼 방식, 컴파일 시간이 만힝 소요되지만, 성능 개선이 많이 일어날 수 있음
    • 네이티브 코드 최적화(Native code generation)
      • 아키텍처에 따라 최적화를 다르게 함

JVM이 시작할 때의 절차는 이렇다

  1. java 명령어 줄에 있는 옵션 파싱
    • 일부 명령은 자바 실행 프로그램에서 적절한 JIT 컴파일러를 선택하는 등의 작업을 하기 위해 사용하고, 다른 명령들은 HotSpot VM에 전달
  2. 자바 힙 크기 할당 및 JIT 컴파일러 타입 지정
    • 메모리 크기나 JIT 컴파일러 종류가 명시적으로 지정되지 않은 경우 자바 실행 프로그램이 시스템의 상황에 맞게 선정
  3. CLASSPATH와 LD_LIBRARY_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() 메서드가 수행
  • 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, libhpi, libjava, libthread와 같은 라이브러리들이 로드
    8. 시그널 처리기가 초기화 및 설정
    9. 스레드 라이브러리가 초기화
    10. output 스트림 로거가 초기화
    11. JVM을 모니터링하기 위한 에이전트 라이브러리가 설정되어 있으면 초기화 및 시작
    12. 스레드 처리를 위해 필요한 스레드 상태와 로컬 저장소 초기화
    13. HotSpot VM이 ‘글로벌 데이터’들이 초기화
      • 글로벌 데이터 종류 : event log, OS 동기화, 성능 통계 메모리(perf memory), 메모리 할당자(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과 관련된 각종 스레드들이 시작
    20. JNIEnv가 시작되며, HotSpot VM을 시작한 호출자에게 새로운 JNI 요청을 처리할 상황이 되었다고 전달

JVM이 종료될 때의 절차는 이렇다

  • HotSpot VM의 종료절차 > DestoryJavaVM 메서드 호출 > HotSpot 런처에서 호출
    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, state sampler, watcher, garbage collector 스레드를 종료시킨다. 이 작업들이 종료되면 JVMTI를 비활성화하며, Signal 스레드를 종료
    4. HotSpot의 JavaHhread::exit() 메서드를 호출해 JNI 처리 블록을 해제. guard pages, 스레드 목록에 있는 스레드들을 삭제. 이 순간부터는 HotSpot VM에서 자바 코드를 실행하지 못함
    5. HotSpot VM 스레드를 종료. 이 작업을 수행하면 HotSpot VM에 남아있는 HotSpot VM 스레드들을 safepoint로 옮기고, JIT 컴파일러 스레드들을 중지시킴
    6. JNI, HotSpot VM, JVMTI barrier에 있는 tracing 기능을 종료시킴
    7. 네이티브 스레드에서 수행하고 있는 스레드들을 위해 HotSpot의 “vm exited” 값을 설정
    8. 현재 스레드를 삭제
    9. 입출력 스트림을 삭제하고, PerfMemory 리소스 연결을 해제
    10. JVM 종료를 호출한 호출자로 복귀

클래스 로딩 절차도 알고 싶어요?

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

Bootstrap 클래스 로더

  • HotSpot VM은 부트스트랩 클래스 로더를 구현
  • Bootstrap 클래스 로더
    • HotSpot VM의 BOOTCLASSPATH에서 클래스들을 로드
      • java SE(Standard Edition) 클래스 라이브러리를 포함

HotSpot의 클래스 메타데이터

  • HotSpot VM 내에서 클래스를 로딩하면 클래스에 대한 instanceKlass와 arrayKlass라는 내부적인 형식을 VM의 Perm 영역에 생성
  • instanceKlass는 클래스의 정보를 포함하는 java.lang.Class 클래스의 인스턴스
  • HotSpot VM은 내부 데이터 구조인 klassOop라는 것을 사용해 내부적으로 instanceKlass에 접근
    • Oop는 Ordinary object pointer

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

  • SystemDictionary
    • 로드된 클래스를 포함하며, 클래스 이름 및 클래스 로더를 키를 갖고 그 키 값으로 klassOop를 갖고 있음
    • 클래스 이름과 초기화한 로더의 정보, 클래스 이름과 정의한 로더의 정보도 포함
    • safepoint에서만 제거됨
  • PlaceholderTable
    • 현재 로딩된 클래스들에 대한 정보를 관리
    • ClassCircularity Error를 체크할 때 사용
    • 다중 스레드에서 클래스를 로딩하는 클래스 로더에서도 사용
  • LoaderConstraintTable
    • 타입 체크시의 제약 사항을 추정하는 용도로 사용

예외는 JVM에서 어떻게 처리될까?

  • 일반적인 예외 처리 경우
    • 예외를 발생한 메서드에서 잡을 경우
    • 호출한 메서드에 의해 잡힐 경우
  • 예외
    • 던져진 바이크 코드에 의해서 초기화 가능
    • VM 내부 호출의 결과로 넘어올 수도 있고
    • JNI 호출로부터 넘어올 수도 있고
    • 자바 호출로부터 넘어올 수도 있다

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

GC란?

  • GC
    • 자바에서 쓰레기는 객체
    • 하나의 객체는 메모리를 점유하고, 필요하지 않으면 메모리에서 해제되어야 한다

자바의 Runtime data area는 이렇게 구성된다

  • 자바에서 사용하는 메모리 영역
    • pc 레지스터
    • JVM 스택
    • Heap
    • 메서드 영역
    • 런타임 상수 풀
    • 네이티브 메서드 스택
  • GC는 힙 영역에서 발생
    • 나머지는 GC 대상이 아님
  • 클래스 로더 서브 시스템
    • 클래스나 인터페이스를 JVM으로 로딩하는 기능울 수행
    • 실행 엔진은 로딩된 클래스읭 메서드들에 포함되어 있는 모든 인스트럭션 정보를 실행

Heap 메모리

  • 클래스 인스턴스, 배열이 이 메모리에 쌓인다
  • 이 메모리는 shard 메모리라고도 불리며, 여러 스레드에서 공유하는 데이터들이 저장되는 메모리

Non-Heap 메모리

  • 메서드 영역
    • 모든 JVM 스레드에서 공유
    • 이 영역에 저장되는 데이터들
      • 런타임 상수 풀
        • 메서드 데이터, 메서드와 ,생성자 코드
  • JVM 스택
    • 스레드가 시작할 때 JVM 스택이 생성
    • 메서드가 호출되는 정보인 frame이 저장됨
    • 지역 변수, 임시 결과와 메서드 수행과 리턴에 관한 정보도 포함
  • 네이티브 메서드 스택
    • 자바 코드가 아닌 다른 언어로 된 코드들이 실행하게 될 때의 스택 정보를 관리
  • pc 레지스터
    • 자바 스레드들은 각자의 pc(Program Counter) 레지스터를 갖는다
    • 네이티브한 코드를 제외한 모든 자바 코드들이 수행될 때 JVM의 인스트럭션 주소를 pc 레지스터에 보관

GC의 원리

  • GC의 역할
    • 메모리 할당
    • 사용 중인 메모리 인식
    • 사용하지 않은 메모리 인식
  • 사용하지 않는 메모리를 인식하는 작업을 수행하지 않으면, 할당한 메모리 영역이 꽉 차서 JVM에 hang이 걸리거나, 더 많은 메모리를 할당하려는 현상이 발생함
  • 자바의 heap 영역
    • Young, Old, Perm 영역
    • Perm 영역은 거의 사용되지 않는 영역
      • 클래스와 메서드 정보와 같이 자바 언어 레벨에서 사용하는 영역이 아님
      • JDK8 부터 이 영역이 사라짐
    • Young 영역
      • Eden 영역
        • 데이터가 꽉차면 Survivor 영역으로 이동
      • 두 개의 Survivor 영역
        • 둘 중 하나는 반드시 비어있어야한다
        • 할당된 Survivor 영역이 차면, GC가 되면서 Eden 영역에 있는 객체와 꽉 찬 Survivor 영역에 있는 객체가 비어 있는 Survivor 영역으로 이동
      • Young 영역에서 Old 영역으로 넘어가는 객체 중 Survivor 영역을 거치지 않고 바로 Old 영역으로 이동하는 객체가 있을 수 있음
        • 객체가 매우 큰 경우

GC의 종류

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

5가지 GC 방식

  • JDK7 이상에서 지원하는 GC 방식
    • Serial Collector
    • parallel Collector
    • parallel Compating Collector
    • Concurrent Mark-Sweep Collector
    • Garbage First Collector

시리얼 콜렉터

  • Young , Old 영역이 Serial 하게 처리
  • 하나의 CPU를 사용
  • stop-the-world => 콜렉션이 수행될 때 애플리케이션 수행이 정지
  • 수행 순서
    1. 일단 살아 있는 객체들은 Eden 영역에 있다
    2. Eden 영역이 꼬가차게 되면 To Survivor영역(비어 있는 영역)으로 살아 있는 개체가 이동 Survivor 영역에 들어가기에 너무 큰 객체는 바로 Old 영역으로 이동 From Survivor 영역에 살아 있는 객체는 To Survivor영역으로 이동
    3. To Survivor 영역이 꽉 찼을 경우, Eden 영역이나 From Survivor 영역에 남아 있는 객체들은 Old 영역으로 이동
  • 이후 Old 영역이나 Perm 영역에 있는 객체들은 Mark-sweep-compact 콜렉션 알고리즘을 따른다
  • Mark-sweep-compact 콜렉션 알고리즘 수행 순서
    1. Old 영역으로 이동된 객체들 중 살아 있는 객체를 식별 ( 표시 단계 )
    2. Old 영역의 객체들을 훑는 작업을 수행해 쓰레기 객체를 식별( 스윕 단계 )
    3. 필요 없는 객체들을 지우고 살아 있는 객체들을 한 곳으로 모은다( 콤팩트 단계 )

병렬 콜렉터

  • 이 방식의 목표는 다른 CPU가 대기 상태로 남아 있는 것을 최소화하는 것
  • Young 영역을 병렬로 처리
  • Old 영역의 GC는 시리얼 콜렉터와 마찬가지로 Mark-sweep-compact 콜렉션 알고리즘 사용

병렬 콤팩팅 콜렉터

  • Young 영역은 병렬 콜렉터와 동일
  • Old 영역 GC의 3단계
    • 표시 단계 : 살아 있는 객체를 식별해 표시하는 단계
    • 종합 단계 : 이전에 GC를 수행해 컴팩션 된 영역에 살아 있는 객체의 위치를 조사하는 단계
    • 컴팩션 단계 : 컴팩션을 수행하는 단계. 수행 이후에는 컴팩션된 영역과 비어 있는 영역으로 구분

CMS 콜렉터

  • low-latency collector라고도 불림
  • 힙 메모리 영역이 크기가 클 때 적합
  • Young 영역은 병렬 콜렉터와 동일
  • Old 영역 GC 방법
    • 초기 표시 단계: 매우 짧은 대기 시간으로 살아 있는 객체를 찾는 단계
    • 컨커런트 표시 단계 : 서버 수행과 도ㅠㅇ시에 살아 있는 객체에 표시 하는 단계
    • 재표시 단계 : 컨커런트 표시 단계에서 표시하는 동안 변경된 객체에 대해 다시 표시하는 단계
    • 컨커런트 스윕 단계 : 표시되어 있는 쓰레기를 정리하는 단계
  • 2개 이상의 프로세서를 사용하는 서버에 적당
  • 점진적 방식을 지원 > Young 영역의 GC를 더 잘게 쪼개어 서버의 대기 시간을 줄일 수 있음
  • CPU가 많지 않고 시스템의 대기 시간이 짧아야 할 때 사용하면 좋음

G1 콜렉터

  • G1은 Young 영역, Old영역으로 구분되어 있지 않고, 각 크기 구역별로 구분되어 있으며 이 구역의 크기는 모두 동일
  • 구역의 개수는 약 2000개
  • G1의 Young GC
    1. 몇 개의 구역을 선정해 Young 영역으로 지정
    2. 이 선형적이지 않은 구역에 객체가 생성되면서 데이터가 쌓임
    3. Young 영역으로 할당된 구역에 데이터가 꽉 차면, GC를 수행
    4. GC를 수행하면서 살아있는 객체들만 Survivor 구역으로 이동시킴
      • 몇 번 aging 작업 수행 시 Old 영역으로 승격
  • G1의 Old GC
    • 초기 표시 단계 : Old 영역에 있는 객체에서 Survivor 영역의 객체를 참조하고 있는 객체들을 표시
    • 기본 구역 스캔 단계 : Old 영역 참조를 위해 Survivor 영역을 훑는다. 이 작업은 Young GC가 발생하기 전에 수행
    • 컨커런트 표시 단계 : 전체 힙 영역에 살아있는 객체를 찾는다. 만약 이 때 Young GC가 발생하면 잠시 멈춘다
    • 재표시 단계 : 힙에 있는 살아있는 객체들의 표시 작업을 완료. 이 때 snapshot-at-the-begining 알고리즘 사용. CMS GC에서 사용하는 방식보다 빠름
    • 청소 단계 : 살아있는 객체와 비어 있는 구역을 식별하고, 필요 없는 개체들을 지운다. 그리고 비어 있는 구역을 초기화
    • 복사 단계 : 살아있는 객체들을 비어 있는 구역으로 모은다

강제로 GC시키기

This post is licensed under CC BY 4.0 by the author.