JuBin's personal study blog

[JAVA] JVM 구조 정리 본문

JAVA

[JAVA] JVM 구조 정리

JuBin 2024. 5. 2. 15:43
반응형

JVM 이란?

Java Virtual Machine의 약자로, JVM 기반의 언어(Java, Kotlin, Scala 등)로 작성된 어플리케이션이 동작할 수 있는 환경을 제공하는 가상 머신 입니다. JVM은 어플리케이션과 OS 사이에서 중계 역할을 수행하며, 어느 운영체제 환경에서도 실행될 수 있습니다.
Write Once And Run Anywhere!

 


JVM 구성 요소

JVM 구조

  • 클래스 로더(Class Loader)
  • 실행 엔진(Execution Engine)
    • 인터프리터(Interpreter)
    • JIT 컴파일러(Just-In-Time)
    • Garbage Collector
  • 런타임 데이터 영역(Runtime Data Area)
    • 메소드 영역(Method Area, Static Area, Class Area)
    • 힙 영역(Heap Area)
    • 스택 영역(Stack Area)
    • PC Register
    • Native Method Stack
  • Java Native Interface(JNI)
  • Native Method Library

클래스 로더(Class Loader)

class loader 구조

Class Loader는 컴파일러에 의해 컴파일된 .class파일(바이트코드)을 Runtime에 동적으로 Runtime Data Area에 적재를 진행합니다. 모든 클래스를 어플리케이션 실행시 로드하는것이 아니라, 실행시간에 필요로 하는 클래스파일을 동적으로 로딩하기 때문에 메모리를 효율적으로 관리할 수 있습니다.
또한 자바는 확장성이 좋은 언어이다 보니 직접 클래스 로더를 구현하여 사용할 수 있습니다. 이런 사용자 정의 클래스 로더(User Defined Class Loader)들은 가장 마지막에 호출되는 Application(System) Class Loader 이후에 호출되어 클래스를 로딩할 수 있습니다.
클래스파일 로딩 순서는 위 이미지의 왼쪽 순서부터 Loading → Linking → Initalization의 단계로 구성됩니다.

 

동작순서

1. Loading

  • 클래스파일을 읽어 Runtime Data Area의 Method Area에 로드 합니다.
    • 클래스파일의 constant pool을 참조하여 데이터를 로드합니다.
    • 클래스 or 인터페이스의 메타데이터, 또 그의 부모 정보
    • Enum 및 변수나 메소드 등 정보
  • 클래스파일 로딩은 아래 세가지 종류의 클래스로더로 부터 로딩 됩니다.
    • 부트스트랩 클래스 로더(Bootstrap Class Loader)
      • 가장 처음으로 실행되는 클래스 로더 입니다.
      • JDK에 있는 자바 클래스를 로드합니다.(java.lang.Object, java.lang.Class, java.util.* 등)
    • 확장 클래스 로더(Extenstion Class Loader)
      • 부트스트랩 클래스 로더를 부모로 갖는 클래스 로더로서, 확장된 자바 클래스들을 로드 합니다.
        • ${JAVA_HOME}/jre/lib/ext의 클래스 파일을 로드
    • 어플리케이션(시스템) 클래스 로더(Application(System) Class Loader)
      • ClassPath에 있는 클래스 파일이나 Jar에 속한 클래스파일들을 로드 합니다.
      • 우리가 만든 클래스파일을 로드 합니다.
  • 동작 방식
    • 클래스 로드 요청이 들어오면 Method Area에 미리 로드된 클래스가 있는지 확인 합니다.
    • 로드되어 있지 않은 경우, Application(System) Class Loader를 시작으로 로드를 시작합니다.
    • Application Class Loader는 확장 클래스 로더에 요청을 위임합니다.
    • Bootstrap Class Loader는 JDK/JRE/Library에 해당 클래스가 있는지 확인 합니다. 여기서 클래스가 존재하지 않는 경우 확장 클래스 로더에게 요청을 넘깁니다.
    • 확장 클래스 로더는 확장된 Classpath에서 해당클래스가 있는지 확인하고, 없는 경우 Application Class Loader로 요청을 넘깁니다.
    • Application Class Loader는 어플리케이션 Classpath에 해당 클래스가 있는지 확인하고, 여기서도 클래스가 존재하지 않다면 ClassNotFoundException 예외를 발생 시킵니다.

2. Linking

  • 검증(Verify)
    • 읽어들인 클래스가 JVM 명세대로 잘 구성이 되어있는지 검사합니다.
  • 준비(Prepare)
    • 클래스에 대한 메타데이터 정보를 준비하고, 클래스가 필요로 하는 메모리를 할당합니다.
  • 분석(Resloving)
    • 클래스파일의 Constant Pool 내 모든 심볼릭 레퍼런스를 실제 주소값인 다이렉트 레퍼런스로 변경합니다.

3. Initalization

  • static 변수와 같은 클래스 변수들을 설정된 값으로 초기화를 진행합니다.

실행 엔진(Execution Engine)

Write Code ~ Running Code

실행엔진은 클래스로더를 통해 Runtime Data Area에 배치된 class파일(바이트 코드)를 명령어 단위로 읽어서 실행하는 역할을 합니다.
실행엔진은 이와 같은 바이트 코드를 OS가 실행할 수 있는 기계어(Machine(Native) Code)로 변역하고, 이 수행 과정에서 인터프리터와 JIT 컴파일러가 바이트 코드를 번역 합니다.
기본적으로 Interpreter 방식으로 실행되지만, 일정 기준 반복되는 메소드가 발견되면 JIT 컴파일러로 바이트코드를 해석합니다. 보통 JVM Warm Up 진행시 JIT 컴파일러를 최적화 상태로 만듭니다.

 

인터프리터(Interpreter)

  • 바이트코드를 명령어 단위로 읽고 실행하는 방식으로 동작합니다. 기본적으로 Interpreter 방식으로 동작합니다.
  • JVM 시작시 빠른 실행을 가능하게 하지만, 같은 메소드라도 여러번 호출이 되면 매번 해석하고 수행해야 해서 속도는 느립니다.

JIT 컴파일러(Just-In-Time)

  • Interpreter의 단점(성능적으로 느리다)을 보완하기 위해 도입 되었습니다.
  • 반복되는 메소드가 발견되면(핫스팟 감지) 반복되는 바이트 코드 전체를 컴파일 하여 Machine(Native) Code로 번역하고, 이후 더 이상 인터프리팅 방식을 사용하지 않고 캐싱해 두었다가 캐싱된 Machine(Native) Code를 직접 실행하는 방식 입니다.
    캐시된 네이티브 코드는 JVM 내 Code Cache 영역에 저장되고, Code Cache는 JIT 컴파일러가 사용하는 메모리 영역 입니다. 처음 사이즈가 정해지면 확장이 불가능 합니다. GC의 대상입니다.
  • 반복의 기준은 컴파일 임계치를 기준으로 판단합니다. 임계치는 메소드가 호출된 횟수, 메서드의 반복문을 빠져 나오기까지 반복한 횟수 이 두개를 기반으로 임계치가 정해집니다. 이 두 수의 합계를 확인하고, 반복되는 코드가 컴파일이 수행할 자격이 있는지 여부를 확인 후 자격이 있다면 컴파일을 위해 대기큐에 넣습니다. 이후 반복되는 코드들은 컴파일 Thread에 의해 컴파일 됩니다.
  • 인터프리터 처럼 명령어를 하나 하나 실행하는것이 아니라, 컴파일된 Machine(Native) Code를 실행하여 전체적인 실행 속도는 인터프리터 보다 빠릅니다. 하지만 핫스팟이지 않은 영역(메소드)는 인터프리터 방식으로 호출 되는것이 더 효율적 입니다.
  • 참고로 반복되는 메소드 발견은(핫스팟 감지) JIT 컴파일러 내 Profiler를 통해 컴파일/최적화를 진행합니다.
  • Tip으로 JVM의 컴파일 단위는 메소드이므로, 메소드의 역할을 잘 구분지어 설계해 둔다면 자주 호출되는 메소드가 명확해 지므로 최적화할때 훨씬 간편해 집니다.
  • JIT 컴파일러를 통해 컴파일된 바이트코드라도 해당 메서드가 자주 쓰이지 않는다면 코드캐시에서 Machine(Native) Code를 삭제하고 다시 인터프리터로 명령어를 해석합니다.
  • JIT 컴파일러는 3가지 종류가 있습니다.
    • C1 - 클라이언트 컴파일러, 컴파일시간이 매우 짧다.
    • C2 - 서버 컴파일러, 컴파일 시간은 길지만 최적화가 매우 뛰어나서 장기적인 성능에 유리.
    • Graal - Java로 구현된 고성능과 최적화를 나온 JIT 컴파일러,- XX:+UseGraalCompiler 옵션으로 사용 가능.
  • Tiered Compilation(컴파일 과정이 최적화 수준에 따라 단계별로 나누어진것)
    • 레벨 0 : 인터프리터 사용, 초기에 모든 코드들이 이 단계를 거침
    • 레벨 1 : C1 컴파일러 사용(프로파일링 X), 가장 빠른 컴파일을 제공하지만, 최소한의 최적화만 수행.
    • 레벨 2 : C1 컴파일러 사용(부분적으로 프로파일링 데이터 수집), 제한된 수준으로 프로파일링&최적화 진행, C2 컴파일러 큐가 꽉찬 경우 실행
    • 레벨 3 : C1 컴파일러 사용(Full 프로파일링 데이터 수집), 대부분의 메소드는 이 레벨에서 실행됨.
    • 레벨 4 : C2 컴파일러 사용, 어플리케이션의 장기적인 성능을 위해 C2 컴파일러가 최적화를 진행함. 더이상 프로파일링 정보를 수집하지 않는다.
    • Tiered Compilation 방식으로 초기에는 C1으로 빠르게 컴파일 하고, 시간이 지나 더많은 최적화가 필요할때는 C2를 통해 재컴파일 하여 성능을 향상시킬 수 있습니다. 나아가 Graal 컴파일러로 더 나은 최적화를 진행할 수 있습니다.

Garbage Collection

Garbage Collection은 자동으로 메모리를 관리해주는 기능 중 하나로,  Runtime Data Area의 Heap 영역에 동적으로 생성된 객체들 중 더이상 참조되지 않는 객체들을 제거하는 역할을 수행합니다.
GC 진행시 어플리케이션이 일시적으로 멈추게 되는데 이를 STOP-THE-WORLD 라고 표현하고, GC는 굉장히 비용이 큰 작업입니다. 따라서 GC 알고리즘이 계속해서 개선된 버전이 나오거나, GC를 튜닝한다고 하면 보통 STOP-THE-WORLD 시간을 줄이고, 메모리를 효율적으로 나누어 효과적으로 GC를 수행하기 위함입니다.

 

Young Generation

  • 객체가 새로 생성되면 가장 먼저 할당되는 영역 입니다.
  • 이 영역에 생성된 객체는 대부분 금방 UnReachable(객체가 참조되고 있지 않음) 상태가 됩니다.
  • Young Generation은 1개의 Eden영역과, 2개의 Survivor 영역으로 구성됩니다.
    • Eden : 새로 생성된 객체가 할당되는 영역
    • Survivor : 최소 한번의 GC 이후 살아남은 객체가 존재하는 영역
  • 이 영역에서 발생되는 GC를 Minor GC라고 부릅니다.

Old Generation

  • Young Generation에서 살아남아 Promotion된 객체들이 할당되는 영역 입니다.
  • 크기가 Young Generation보다 큰 객체가 할당 되기도 합니다.
  • 보통 Young Generation 보다 크게 할당되며 GC 빈도수가 더 적지만, GC 발생시 STOP-THE-WORLD로 인한 정지시간이 깁니다.
  • 이 영역에서 발생되는 GC를 Minor GC, 또는 Full GC라고 부릅니다.

Garbage Collection 알고리즘(Mark, Sweep, Compact, Copy)

  • Mark : 메모리에서 사용되고 있는 객체를 마킹해 놓습니다. 보통 RSet(Root Set)에서 레퍼런스를 추적하여 마킹합니다.
    • 보통 RootSet은 Heap 영역을 참조하는 Method Area, Static 변수, Stack, Native Method Stack이 됩니다.
  • Sweep : Mark 단계에서 사용되지 마킹되지 않은 객체들을 메모리에서 해제 시킵니다.
  • Compact : Sweep 이후 파편화된 메모리 공간을 모아주는 작업을 진행합니다. 이로 메모리효율을 높일 수 있지만 Compact 작업과 레퍼런스를 업데이트 하는 작업이 필요해 오버헤드가 발생할 수 있습니다.(GC 종류에 따라 하지 않는 경우도 있음)

Minor GC 동작 과정(Young Generation)

  1. 처음 생성된 객체는 Young Generation > Eden 영역에 할당
  2. 계속 객체가 Eden 영역에 할당되서 꽉차게 되면 Minor GC 동작
  3. Mark 단계에서 Reachable 객체를 탐색
  4. Eden 영역에서 살아남은 객체는 두개의 Survivor 영역 중 한군데로 이동, 여기서는 예시로 0번 Survivor 영역으로 이동
    ✅ Survivor 영역은 두개로 구성되어 있고, 둘 중에 하나는 꼭 비워 있어야함
  5. Sweep 단계에서 UnReachable 객체들을 메모리에서 해제
  6. 살아남은 객체들에 대해 age값을 1씩 증가시킴
    age는 객체가 살아남은 횟수를 의미하고 Object Header에 기록됨. 보통 JVM의 age 임계값은 31이고 이 임계값을 넘으면 Old Generation 이동 여부를 결정함, 이를 Promotion을 진행한다고 표현.
  7. 다시 2번 과정처럼 Eden 영역에 새로 생성된 객체가 꽉차면 Minor GC 동작
  8. 살아남은 객체를 0번 Sruvivor 영역으로 이동, age 1씩 증가
  9. 이러한 과정들을 계속해서 반복, 0번 Survivor 영역이 가득 차게 되면 1번 Survivor로 살아남은 객체들을 이동시킴

Major GC(Full GC) 동작 과정(Old Generation)

Minor GC로 인해 Young Generation에서 age 임계값이 차서 Promotion된 객체들에 의해 Old Generation 영역이 가득차게 되면 Major GC(Full GC)가 수행되고, GC의 실행속도는 Minor GC보다 느립니다. 실행속도가 느린 이유는 Young Generation 보다 상대적으로 큰 공간이 할당되어, Major GC 수행시 어플리케이션이 일시적으로 멈추게 되는데(STOP-THE-WORLD), 보통 이 영역의 GC 시간을 단축시키기 위하여 GC 알고리즘이 발전하고 있습니다.

 

Garbage Collector

어느 Garbage Collector가 더 좋고 나쁜건 없습니다. 정확한 시나리오 하에서 정량적으로 비교하고, 자신의 어플리케이션에 맞는 Garbage Collector 선택이 되어야 합니다. 예를들면 메모리를 적게 사용하는 어플리케이션에서는 CMS 가 우수하고, 메모리를 많이 사용하는 어플리케이션에서는 G1의 장점이 최대로 발휘됩니다. 물론 JDK14 이후 핫스팟 VM에서는 CMS가 제공되지 않으니 고민할 필요도 없습니다.😅

Serial, Parallel, CMS GC

Serial GC

  • 서버의 CPU 코어가 1개일때 사용하기 위한 가장 간단한 GC
  • GC를 처리하는 쓰레드 갯수가 1개여서 가장 STOP-THE-WORLD 시간이 길다.
  • Minor GC 에는 Mark-Sweep, Major GC에는 Mark-Sweep-Compact를 사용한다.

Parallel GC

  • 기본적인 처리 과정은 Serial GC와 동일하다.
  • Minor GC 에는 멀티 쓰레드로 Parallel 하게 GC를 수행, Major GC는 싱글 쓰레드로 동작
  • 기본적으로 CPU 코어 갯수만큼 GC 스레드가 할당됨, 옵션을 통해 GC 쓰레드 갯수 설정 가능

CMS GC

  • Mark & Sweep 알고리즘에 기초한다.
  • GC 쓰레드와, Application 쓰레드가 Concurrent 하게 실행된다.
  • 다른 GC 대비 CPU 사용량이 높다.
  • Compaction 단계를 수행하지 않아서 메모리 파편화 문제 때문에 Java9 부터는 Depreacated, Java 14에서는 사용이 중지됨

G1 GC

G1(Garbage First)  GC(JDK 7)

  • CMS GC를 대체하기 위해 JDK 7버전에 릴리즈 되었고 Java9+ 부터 기본 GC
  • CMS GC에 비해 메모리 파편화 현상이 없어서 오래 운영되는 프로그램에 좋은 특성이 있다.
    • ✅ 파편화가 발생하면 사이즈가 큰 객체를 메모리를 할당할때 메모리 공간을 찾지 못해 GC가 추가적으로 발생되는 문제가 있다.
  • 작은 사이즈의 Heap에서는 오버헤드가 발생할 수 있어서, Heap Size가 4GB 이상 일때만 추천되는 GC 알고리즘
  • 최대 Stop The World 시간을 지정할 수 있다.
  • 기존 Heap 영역을 물리적으로 고정된 Young / Old Generation으로 사용하는 방식이 아닌, Region이라는 개념을 도입하여 Heap 영역을 체스판 모양으로 균등하게 여러개의 지역으로 분할하여 Eden, Survivor, Old 영역을 고정이 아닌 동적으로 부여함
  • 메모리를 Region단위로 나눠 회수 효율이 가장 좋은 Region을 그때그때 판단한다.
  • 기존 Young(Eden, Survivor), Old 영역에 추가적으로 Humonogus, Available/Unused 영역이 추가됨
    • Humonogus : Region 크기의 50%를 초과하는 객체를 저장하는 영역
    • Available/Unused : 사용되지 않는 Region
  • Garbage가 많이 차있는 Region을 우선적으로 GC

Z GC

Z GC(JDK 11(Beta), JDK 15)

  • Java15에 릴리즈됨
  • 대용량의 Heap Size(8MB ~ 16TB)를 효율적으로 관리함
  • G1의 Region 영역 처럼 Z GC는 ZPage라는 영역을 사용한다. G1 Region은 사이즈가 고정인데 비해, ZPage는 2MB 배수로 동적으로 운영된다.
  • 메모리 접근과 관리를 위하여 컬러 포인터를 사용하여 관리한다.
    • 흰색 : 아직 접근되지 않은, GC 대상이 될 수 있는 객체
    • 검정색 : 이미 접근되어 처리가 완료된, GC 대상이 되지 않는 객체
    • 회색 : 현재 처리중인 객체, 아직 GC가 완료되지 않은 객체
  • Z GC의 최대 장점은 Heap Size가 증가해도 STOP-THE-WORLD의 시간이 10ms를 넘지 않는다.

Shenandoah GC

Shenandoah(셰넌도어) GC(JDK 12)

  • Java12에 릴리즈됨
  • 기존 CMS GC의 단편화, G1 GC의 Pause 이슈를 해결한 GX
  • Application 쓰레드와 동시에 수행한다
  • Z GC와 유사하게 힙을 관리한다.

Java Native Interface(JNI)

JNI는 자바가 다른 언어로 만들어진 어플리케이션과 상호작용 할 수 있는 인터페이스를 제공합니다.
JNI는 JVM이 보통 C, C++로 만들어진 Native Method를 적재하고 수행할 수 있도록 합니다. 

 

Native Method Library

보통 C, C++로 만들어진 라이브러리를 이야기 합니다.
JNI는 이 라이브러리를 로딩하여 실행합니다.
반응형