KERBE.io

TECH / java

JVM 내부 동작 원리 완전 정리: 메모리 구조부터 GC, 컴파일까지

#Java#JVM#GC#HotSpot

JVM 내부 동작 원리 완전 정리: 메모리 구조부터 GC, 컴파일까지

자바 개발자라면 한 번쯤 "JVM은 어떻게 동작하는가?"라는 질문을 마주하게 됩니다. 이 글은 OpenJDK 소스 코드(버전 28 기준, HotSpot VM)를 근거로 자바 소스 코드가 실행되기까지의 전 과정 — 컴파일, 클래스 로딩, 메모리 구조, 가비지 컬렉션, JIT 컴파일 — 을 정리합니다. 자바를 처음 공부하는 사람도 따라올 수 있도록 개념 설명과 실제 구현 근거를 함께 담았습니다.


1. 자바 소스는 어떻게 실행 파일이 되는가: javac 컴파일 파이프라인

자바는 소스 코드(.java)를 곧바로 기계어로 바꾸지 않습니다. 먼저 javac 컴파일러가 소스 코드를 바이트코드(.class 파일)로 변환하고, 이 바이트코드를 JVM이 해석·실행합니다. 이것이 자바가 "한 번 작성하면 어디서든 실행된다(Write Once, Run Anywhere)"를 실현하는 방식입니다.

javac의 컴파일 과정은 여러 단계로 나뉘어 순차적으로 실행됩니다.

  1. 파싱(Parse) — 소스 코드 텍스트를 읽어 추상 구문 트리(AST, Abstract Syntax Tree)를 만듭니다.
  2. 진입(Enter) — 파싱된 클래스와 심볼을 컴파일러의 심볼 테이블에 등록합니다.
  3. 애노테이션 처리(Annotation Processing) — @Override, 커스텀 애노테이션 프로세서 등을 처리합니다. 이 단계에서 새로운 소스 파일이 생성되면 파싱 단계부터 다시 반복될 수 있습니다.
  4. 속성 부여(Attribute) — 타입 검사, 이름 해석(어떤 변수·메서드가 어떤 선언을 가리키는지 확정) 등 의미 분석을 수행합니다.
  5. 흐름 분석(Flow) — 도달 불가능한 코드, 반드시 초기화되지 않은 변수 사용, 예외 처리 누락 등을 검사합니다.
  6. 문법 풀어내기(Desugar) — 제네릭의 소거(erasure), 오토박싱, 향상된 for문, 람다식, switch 표현식처럼 프로그래머의 편의를 위해 제공되는 자바 문법을 JVM이 이해할 수 있는 단순한 형태로 풀어서 변환합니다.
  7. 생성(Generate) — 최종적으로 .class 바이트코드 파일을 출력합니다.

이 파이프라인은 javac 소스 코드에서 다음과 같이 하나의 호출 체인으로 나타납니다.

generate(desugar(warn(flow(attribute(todo)))));

가장 안쪽부터 바깥쪽으로 attribute → flow → warn → desugar → generate 순서로 실행되는 구조입니다. 즉 타입 검사와 이름 확정이 가장 먼저 끝나야 흐름 분석이 가능하고, 흐름 분석이 끝나야 편의 문법을 단순한 형태로 풀어내는 변환(desugar)이 가능하며, 그 결과물을 바탕으로 최종 바이트코드가 생성됩니다.

컴파일된 .class 파일은 매직 넘버(0xCAFEBABE), 클래스 파일 버전, 상수 풀(constant pool), 필드·메서드 정보, 그리고 각 메서드 본문에 해당하는 바이트코드 명령어들로 구성됩니다. 이 바이트코드는 특정 CPU 명령어 집합이 아니라 JVM이라는 가상의 스택 기반 컴퓨터를 위한 명령어입니다.


2. 클래스 로딩: 바이트코드가 메모리에 올라가는 과정

.class 파일이 만들어졌다고 해서 곧바로 실행되는 것은 아닙니다. JVM이 이 파일을 읽어 메모리에 적재하고, 검증하고, 실행 가능한 상태로 준비시키는 과정이 필요합니다. 이를 클래스 로딩이라 부르며, 크게 로딩(Loading) → 링크(Linking: 검증·준비·해석) → 초기화(Initialization) 순서로 진행됩니다.

2.1 클래스 로더 계층 구조

자바는 클래스를 로드하는 책임을 계층화된 클래스 로더들에게 나눠 맡깁니다. JDK 내부(jdk.internal.loader.ClassLoaders)를 보면 세 종류의 로더가 부모-자식 관계로 초기화됩니다.

가장 아래에는 부트스트랩 클래스 로더(Bootstrap ClassLoader)가 있습니다. java.lang.Object를 비롯한 자바 표준 라이브러리의 핵심 클래스를 로드하며, JVM 네이티브 코드로 구현되어 있어 자바 코드 상에서는 null로 표현됩니다. 그 위에 플랫폼 클래스 로더(Platform ClassLoader)가 부트스트랩 로더를 부모로 두고 JDK의 나머지 표준 모듈들을 로드합니다. 과거 버전에서는 "확장(Extension) 클래스 로더"라 불렸습니다. 가장 위에는 애플리케이션 클래스 로더(Application ClassLoader)가 있는데, 플랫폼 로더를 부모로 두고 클래스패스(classpath)에 지정된 사용자 애플리케이션 클래스를 로드합니다.

세 로더는 실제로 다음과 같은 부모 관계로 생성됩니다.

BOOT_LOADER = new BootClassLoader(bootUcp);
PLATFORM_LOADER = new PlatformClassLoader(BOOT_LOADER);
APP_LOADER = new AppClassLoader(PLATFORM_LOADER, ucp);

2.2 부모 위임 모델(Parent Delegation Model)

클래스 로더가 클래스를 요청받으면, 자신이 직접 찾기 전에 먼저 부모 로더에게 위임합니다. ClassLoader.loadClass()의 실제 구현은 다음과 같은 순서로 동작합니다.

  1. 이미 로드된 클래스인지 캐시를 확인한다(findLoadedClass).
  2. 없다면 부모 로더에게 위임한다. 부모가 없으면(자신이 부트스트랩이면) 부트스트랩 클래스 탐색을 시도한다.
  3. 부모가 클래스를 찾지 못했을 때만, 비로소 자기 자신이 findClass()를 호출해 클래스를 찾는다.
if (parent != null) {
    c = parent.loadClass(name, false);
} else {
    c = findBootstrapClassOrNull(name);
}
if (c == null) {
    c = findClass(name);
}

이 구조 덕분에 상위 로더가 이미 로드한 클래스를 하위 로더가 임의로 재정의할 수 없습니다. 예를 들어 애플리케이션 코드에서 java.lang.String이라는 이름의 클래스를 직접 만들어도, 부모 위임 모델에 의해 항상 부트스트랩 로더가 로드한 진짜 java.lang.String이 사용되어, 핵심 API가 임의로 대체되는 것을 막습니다.

2.3 링크(Linking) 단계: 검증 → 준비 → 해석

로딩된 클래스는 실행 가능한 상태가 되기 전에 세 단계를 거칩니다. HotSpot VM의 InstanceKlass::link_class_impl()에 이 과정이 구현되어 있습니다.

첫 단계인 검증(Verification)에서는 바이트코드가 JVM 명세를 위반하지 않는지 검사합니다(verify_code()). 스택 오버플로우/언더플로우, 잘못된 타입 변환, 존재하지 않는 메서드 호출 등을 이 시점에 걸러내며, 검증에 실패하면 VerifyError가 발생합니다. 다음은 준비(Preparation) 단계로, 클래스의 정적(static) 필드를 위한 메모리를 할당하고 기본값(0, null, false 등)으로 초기화합니다. 이때는 아직 프로그래머가 작성한 초기화 값이 대입되지 않습니다. 마지막으로 해석(Resolution) 단계에서는 상수 풀에 있는 심볼릭 참조(다른 클래스, 메서드, 필드의 이름 기반 참조)를 실제 메모리 주소나 구조체에 대한 직접 참조로 바꿉니다.

링크 과정에서 부모 클래스와 구현 인터페이스가 먼저 재귀적으로 링크되며, 이후 가상 메서드 테이블(vtable)과 인터페이스 메서드 테이블(itable)이 초기화됩니다. 이 테이블들은 다형성(polymorphism), 즉 메서드 오버라이딩이 런타임에 정확한 구현체를 찾아 호출할 수 있게 해주는 핵심 자료구조입니다.

2.4 초기화(Initialization): <clinit>의 실행

링크가 끝난 클래스는 마지막으로 초기화됩니다. 이 단계에서 정적 필드에 실제 초기값이 대입되고, static { ... } 블록(정적 초기화 블록)이 실행됩니다. 이 모든 코드는 컴파일러가 자동으로 만들어주는 <clinit>이라는 특수 메서드에 모아집니다.

클래스 초기화는 스레드 안전하게 설계되어 있습니다. InstanceKlass::initialize_impl()은 클래스별 락(init_lock)을 사용해 동시성을 제어하며, 다음과 같은 상태를 명시적으로 검사합니다.

  • 다른 스레드가 이미 초기화 중이라면, 현재 스레드는 대기한다.
  • 같은 스레드가 재귀적으로 초기화를 시도하는 경우(예: <clinit> 내부에서 자기 클래스를 다시 참조), 대기하지 않고 통과시킨다.
  • 이미 초기화가 끝났다면 즉시 반환한다.
  • 과거 초기화 시도가 예외로 실패했다면(in_error_state), NoClassDefFoundError를 던진다.

이 상태 머신 덕분에, 여러 스레드가 동시에 같은 클래스를 처음 사용하더라도 <clinit>은 정확히 한 번만 실행됩니다. 이는 싱글턴 패턴을 구현할 때 정적 초기화 방식(초기화 지연 홀더, initialization-on-demand holder idiom)이 스레드 안전한 이유이기도 합니다.

클래스 초기화는 필요한 시점까지 미뤄지는 지연 초기화(lazy initialization) 방식입니다. 클래스가 처음 능동적으로 사용되는 시점(인스턴스 생성, 정적 메서드 호출, 정적 필드 접근 등)에 트리거됩니다.


3. JVM 런타임 메모리 구조

JVM이 프로그램을 실행하는 동안 사용하는 메모리는 크게 스레드 간에 공유되는 영역과, 스레드마다 독립적으로 가지는 영역으로 나뉩니다.

3.1 힙(Heap) — 모든 스레드가 공유

new로 생성되는 모든 객체와 배열은 힙에 저장됩니다. 힙은 스레드 간에 공유되는 메모리 영역이며, 가비지 컬렉터가 관리하는 대상입니다.

전통적인 세대별(generational) GC 설계에서 힙은 다음과 같이 나뉩니다.

  • Young Generation(신생대) — 새로 생성된 객체가 위치합니다. 다시 Eden 영역과 두 개의 Survivor 영역(S0, S1)으로 나뉩니다. 객체는 처음 Eden에 생성되고, GC에서 살아남을 때마다 Survivor 영역을 오가며 나이(age)를 먹습니다.
  • Old Generation(구세대, Tenured) — Young 영역에서 일정 횟수 이상 살아남은 객체가 옮겨지는 영역입니다.

이 나이 기준값이 MaxTenuringThreshold이며, HotSpot VM의 기본값은 15입니다. 즉 객체가 Young 영역에서 GC를 15번 넘게 견디면 Old 영역으로 승격(promotion)됩니다. 이 밖에도 다음과 같은 기본 비율 설정이 존재합니다.

  • NewRatio 기본값 2 — Old 영역 크기가 Young 영역 크기의 2배가 되도록 힙을 나눕니다.
  • SurvivorRatio 기본값 8 — Eden 영역과 Survivor 영역 하나의 크기 비율이 8:1이 되도록 설정합니다.

세대를 나누는 이유는 "대부분의 객체는 생성된 직후 금방 쓸모없어진다"는 경험적 사실(약한 세대 가설, weak generational hypothesis)에 기반합니다. 자주 생성되고 금방 사라지는 객체가 몰려 있는 Young 영역만 자주, 빠르게 청소하면 전체 GC 비용을 크게 줄일 수 있습니다. 단, G1이나 ZGC 같은 최신 컬렉터는 힙을 물리적인 세대 영역으로 나누지 않고 다른 방식으로 이 개념을 구현합니다(4장에서 다룹니다).

3.2 메타스페이스(Metaspace) — 클래스 메타데이터 저장소

클래스 자체의 정보(클래스 구조, 메서드 바이트코드, 상수 풀, 필드 정보 등 "메타데이터")는 힙이 아니라 메타스페이스라는 별도의 네이티브 메모리 영역에 저장됩니다. 메타스페이스는 JVM의 힙 밖, 운영체제 네이티브 메모리 영역에 위치하며, 클래스 로더 단위로 나뉘어 관리됩니다. 클래스 로더가 언로드되면 그 클래스 로더가 소유한 메타스페이스 영역도 함께 회수될 수 있습니다.

관련 기본값은 다음과 같습니다. GC가 처음 트리거되는 초기 임계값(MetaspaceSize)은 32비트 환경에서 16MB, 64비트 환경에서 21MB이며, 이 값을 넘어서면 메타스페이스 확장을 위한 GC가 발생할 수 있습니다. 상한값(MaxMetaspaceSize)은 기본적으로 제한이 없어 시스템 네이티브 메모리가 허용하는 한 계속 확장됩니다. 압축된 클래스 포인터를 사용할 때 클래스 메타데이터 전용 공간의 기본 크기(CompressedClassSpaceSize)는 1GB입니다.

과거(자바 7 이전) 버전에서는 이 역할을 힙 내부의 "퍼머넌트 영역(PermGen)"이 담당했으나, PermGen은 고정 크기라는 한계로 인해 클래스를 대량으로 동적 생성하는 애플리케이션(스크립트 언어 엔진, 프록시 기반 프레임워크 등)에서 OutOfMemoryError: PermGen space를 자주 유발했습니다. 메타스페이스는 이 문제를 해결하기 위해 네이티브 메모리 기반으로 재설계된 영역입니다.

3.3 스레드별 메모리 — 스택, PC 레지스터, 네이티브 메서드 스택

힙과 메타스페이스가 모든 스레드에 공유되는 반면, 아래 영역들은 스레드마다 독립적으로 존재하며 다른 스레드가 접근할 수 없습니다.

자바 가상 머신 스택(JVM Stack)에는 스레드가 메서드를 호출할 때마다 프레임(Frame)이 하나씩 쌓입니다. 각 프레임은 해당 메서드의 지역 변수 배열(local variables)과, 연산에 사용되는 임시 값들을 담는 오퍼랜드 스택(operand stack)을 가지고 있습니다. HotSpot의 frame 클래스는 스택 포인터(_sp), 인터프리터 프레임의 지역 변수 접근(interpreter_frame_locals), 오퍼랜드 스택 접근(interpreter_frame_expression_stack) 등을 명시적으로 관리합니다. 메서드가 끝나면 해당 프레임은 스택에서 제거(pop)되며, 재귀 호출이 너무 깊어 이 스택 공간을 넘어서면 StackOverflowError가 발생합니다.

PC 레지스터(Program Counter Register)는 현재 실행 중인 바이트코드 명령어의 주소를 가리키는, 스레드마다 독립적인 값입니다. 네이티브 메서드 스택(Native Method Stack)은 자바 코드가 아닌 네이티브(C/C++) 코드를 호출할 때(JNI 등) 사용하는 별도의 스택입니다.

3.4 TLAB — 힙은 공유되지만 할당은 스레드별로

힙 자체는 공유 영역이지만, 여러 스레드가 객체를 생성할 때마다 매번 락을 걸고 힙에서 공간을 할당받는다면 성능에 큰 병목이 생깁니다. 이를 피하기 위해 HotSpot VM은 각 스레드에게 Eden 영역의 일부를 미리 잘라 전용으로 할당해주는 **TLAB(Thread-Local Allocation Buffer)**을 사용합니다. 스레드는 이 전용 버퍼 안에서는 락 없이 포인터만 이동시키며 빠르게 객체를 생성할 수 있습니다. UseTLAB 플래그는 기본값이 true이며, 최소 크기(MinTLABSize)는 2KB로 설정되어 있습니다.


4. 가비지 컬렉터의 종류와 동작 방식

가비지 컬렉션(GC)은 더 이상 어디에서도 참조되지 않는 객체를 찾아 자동으로 메모리를 회수하는 기능입니다. HotSpot VM은 여러 종류의 GC 구현체를 제공하며, 상황에 맞게 선택할 수 있습니다. 소스 코드 기준으로 G1 GC가 기본(ergonomic default) 컬렉터로 지정되어 있습니다.

4.1 공통 메커니즘: 카드 테이블과 배리어

세대(혹은 리전)를 나눠서 관리하는 GC는 "Old 영역에 있는 객체가 Young 영역의 객체를 참조하는 경우"를 추적해야 합니다. 그렇지 않으면 Young 영역만 검사할 때 Old에서 들어오는 참조를 놓쳐서 살아있는 객체를 잘못 회수할 수 있기 때문입니다.

이를 위해 JVM은 **카드 테이블(Card Table)**을 사용합니다. 힙 전체를 작은 단위(카드)로 나누고, 각 카드에 해당하는 1바이트짜리 표시를 유지합니다. 객체 참조 필드에 값을 쓸 때마다 배리어(barrier) 코드가 실행되어, 그 필드가 속한 카드에 "더티(dirty)" 표시를 남깁니다. GC는 힙 전체를 스캔하는 대신 더티 카드만 확인하면 되므로 스캔 범위를 크게 줄일 수 있습니다. 이 배리어를 추상화한 것이 BarrierSet 클래스로, 각 GC 알고리즘은 자신만의 배리어 구현체를 갖고 있으며, 인터프리터·C1·C2 각 실행 경로에 대응하는 코드 생성 로직을 따로 가지고 있어 어떤 실행 경로에서도 GC가 참조 변경을 놓치지 않도록 보장합니다.

4.2 GC 루트(GC Roots)와 도달 가능성 분석

GC는 기본적으로 "GC 루트로부터 참조를 따라가며 도달할 수 있는 객체는 살아있고, 도달할 수 없는 객체는 죽은 것"이라는 도달 가능성 분석(reachability analysis)을 사용합니다. GC 루트에는 다음과 같은 것들이 포함됩니다(HotSpot의 RootProcessor 계열 코드 기준).

  • 각 스레드의 스택 프레임에 있는 지역 변수/파라미터 (스레드별 강한 루트)
  • 클래스 로더 데이터 그래프(ClassLoaderDataGraph)에 등록된 정적 필드 참조
  • JNI를 통해 네이티브 코드가 붙잡고 있는 참조
  • JVM 내부에서 관리하는 오브젝트 스토리지(OopStorageSet)에 등록된 참조들

4.3 각 GC 구현체의 특징

Serial GC(gc/serial)는 단일 스레드로 GC 작업을 수행하는 가장 단순한 컬렉터입니다. Young 영역은 복사(copying) 방식으로, Old 영역은 mark-sweep-compact 방식으로 처리합니다. GC가 실행되는 동안 애플리케이션의 모든 스레드가 멈추는 stop-the-world 방식이며, 힙과 CPU 코어 수가 적은 환경(예: 컨테이너, 소형 클라이언트 애플리케이션)에 적합합니다.

Parallel GC(gc/parallel)는 Serial GC와 알고리즘의 큰 틀은 비슷하지만, 여러 스레드를 동시에 사용해 GC 작업 자체를 병렬로 처리합니다. 힙 구조는 Young 영역을 담당하는 PSYoungGen과 Old 영역을 담당하는 PSOldGen으로 명확히 분리되어 있습니다. GC 중에는 여전히 애플리케이션 스레드가 전부 멈추지만, 여러 스레드가 동시에 작업하는 만큼 정지 시간 자체는 짧아지는 것을 목표로 합니다. 처리량(throughput)을 우선시하는 배치성 애플리케이션에 적합합니다.

G1 GC(Garbage-First, gc/g1)는 힙을 고정된 세대 영역으로 나누지 않고, 동일한 크기의 여러 리전(Region)으로 분할하여 관리합니다. 각 리전은 상황에 따라 Eden, Survivor, Old 역할을 동적으로 부여받습니다(G1HeapRegionType에 EdenTag, SurvTag, OldTag 등으로 정의됨). 리전 크기는 최소 1MB에서 최대 512MB 사이이며, 별도 설정이 없으면 힙 전체 크기를 기준으로 리전이 대략 2048개가 되도록 자동으로 산정됩니다(G1HeapRegionBounds). GC 시점마다 회수 효율이 가장 좋은("garbage 비율이 가장 높은") 리전을 우선적으로 골라 회수하는 방식이 이름의 유래입니다.

G1은 리전 간 참조를 추적하기 위해 리전마다 Remembered Set이라는 자료구조를 유지합니다. 이는 카드 테이블 메커니즘 위에 구축되어 "어떤 다른 리전들이 나를 참조하고 있는가"를 리전 단위로 기록합니다. G1은 목표 정지 시간(pause time goal)을 갖고 동작하며, 이 목표는 MaxGCPauseMillis 옵션으로 지정합니다. 이 목표를 얼마나 잘 지키는지는 G1MMUTracker(Minimum Mutator Utilization)라는 컴포넌트가 추적합니다. G1은 Young 영역만 회수하는 Young GC와, Young 영역에 더해 회수 효율이 좋은 일부 Old 리전까지 함께 처리하는 Mixed GC를 구분해서 수행하며, 이 STW 구간 사이사이에 전체 힙에 대한 마킹 작업(concurrent marking)은 애플리케이션 스레드와 동시에 진행합니다.

ZGC(gc/z)는 매우 짧은 정지 시간(low-latency)을 목표로 하는 컬렉터로, 대부분의 작업을 애플리케이션 스레드와 동시에(concurrent) 수행합니다. 객체 포인터의 상위 비트에 리매핑(remap)·마킹(mark)·로드 배리어(load barrier) 관련 상태를 인코딩하는 "컬러드 포인터(colored pointer)" 기법을 사용해, 객체가 이동하는 동안에도 GC 스레드와 애플리케이션 스레드가 안전하게 협력할 수 있도록 설계되어 있습니다(zAddress.hpp).

Shenandoah(gc/shenandoah)도 ZGC와 마찬가지로 저지연을 목표로 하며, 객체 이동(compaction)까지 대부분 애플리케이션 실행과 동시에 수행합니다. 객체마다 포워딩 포인터(forwardee)를 두어, 객체가 새 위치로 옮겨진 뒤에도 이전 참조가 새 위치를 찾아갈 수 있게 하는 방식을 사용합니다(shenandoahForwarding.hpp).

Epsilon GC(gc/epsilon)는 실제로 메모리를 회수하지 않는 "무동작(no-op)" GC입니다. 메모리를 할당만 하다가 힙이 가득 차면 애플리케이션을 종료시킵니다. 성능 테스트나 극히 짧은 수명의 프로세스처럼 GC 개입 자체가 필요 없는 특수한 상황을 위한 실험적·교육적 목적의 컬렉터입니다.

4.3 참조 유형과 회수 우선순위

자바는 java.lang.ref 패키지를 통해 일반적인 강한 참조(strong reference) 외에도 GC에 대한 민감도가 다른 참조 유형을 제공합니다. HotSpot의 ReferenceProcessor가 이를 처리합니다.

소프트 참조(SoftReference)는 메모리가 부족해질 때까지는 회수되지 않아서 캐시 구현에 흔히 사용됩니다. 약한 참조(WeakReference)는 GC가 발생하면(도달 가능성과 무관하게 강한 참조 사슬에 없다면) 즉시 회수 대상이 되며, WeakHashMap이 대표적인 사용 예입니다. 팬텀 참조(PhantomReference)는 객체가 이미 완전히 회수되기 직전, 참조를 통해 객체에 다시 접근하는 것을 막으면서도 회수 시점을 통지받고 싶을 때 사용합니다.


5. 바이트코드 실행: 인터프리터와 JIT 컴파일러

클래스 로딩이 끝나면 JVM은 바이트코드를 실제로 실행합니다. HotSpot VM은 실행 속도를 높이기 위해 두 가지 실행 방식을 함께 사용하는 혼합 실행 모델을 채택하고 있습니다.

5.1 인터프리터: 템플릿 인터프리터

JVM이 처음 메서드를 실행할 때는 바이트코드를 한 줄씩 해석하며 실행합니다. HotSpot은 이를 위해 **템플릿 인터프리터(Template Interpreter)**를 사용합니다. 각 바이트코드 명령어(invokevirtual, iadd, aload 등)마다 미리 만들어둔 어셈블리 코드 조각(템플릿)을 대응시켜 놓은 디스패치 테이블을 두고, 실행할 바이트코드가 나올 때마다 해당 테이블에서 코드 조각의 주소를 찾아 곧바로 실행합니다(templateInterpreter.hpp의 디스패치 테이블 구조). 이는 매번 바이트코드를 해석하는 범용 스위치문 방식보다 빠르지만, 네이티브 코드로 직접 컴파일된 코드에는 미치지 못합니다.

5.2 JIT 컴파일: 자주 실행되는 코드만 골라 최적화

인터프리터로 모든 코드를 실행하면 반복적으로 실행되는 코드(예: 루프, 자주 호출되는 메서드)의 해석 비용이 누적되어 비효율적입니다. 이를 보완하기 위해 HotSpot VM은 JIT(Just-In-Time) 컴파일러를 사용해, 실행 빈도가 높은 "핫스팟(hot spot)" 코드만 골라 네이티브 기계어로 컴파일하고 캐싱합니다. HotSpot이라는 이름 자체가 여기서 유래했습니다.

HotSpot VM은 두 개의 서로 다른 JIT 컴파일러를 함께 사용하는 계층형 컴파일(Tiered Compilation) 방식을 채택하고 있습니다.

  • C1 컴파일러(클라이언트 컴파일러) — 컴파일 속도가 빠른 대신 최적화 수준은 상대적으로 낮습니다. 프로파일링 정보를 함께 수집하는 역할도 겸합니다.
  • C2 컴파일러(서버 컴파일러) — 컴파일에 걸리는 시간은 길지만, 인라이닝(inlining), 루프 최적화, 탈출 분석(escape analysis) 등 훨씬 정교한 최적화를 적용해 더 빠른 코드를 만들어냅니다.

메서드는 실행되는 동안 아래와 같은 5단계(코드상의 컴파일 레벨)를 거칩니다.

레벨 코드상 이름 설명
Tier 0 CompLevel_none 인터프리터로 실행하며 호출 횟수를 카운트
Tier 1 CompLevel_simple C1으로 컴파일, 프로파일링 없이 최대한 빠르게
Tier 2 CompLevel_limited_profile C1으로 컴파일, 호출·백엣지 카운터만 수집
Tier 3 CompLevel_full_profile C1으로 컴파일, 전체 프로파일링 포함 (C2로 넘어가기 위한 정보 수집)
Tier 4 CompLevel_full_optimization C2로 컴파일, 프로파일링 정보를 바탕으로 적극적인 최적화 수행

이 단계 전환은 메서드 호출 횟수와 루프 반복(백엣지, back-edge) 횟수를 기준으로 이뤄지며, HotSpot 소스 코드에는 이 기준값들이 실제 상수로 정의되어 있습니다(compiler_globals.hpp).

  • Tier3InvocationThreshold 기본값 200회 — 메서드 호출 횟수가 이 값을 넘으면 Tier 3(C1 + 전체 프로파일링) 컴파일 대상이 됩니다.
  • Tier3CompileThreshold 기본값 2000회 — 호출 횟수와 백엣지 횟수를 합산한 값이 이 임계값을 넘어도 Tier 3 컴파일이 트리거됩니다.
  • Tier4InvocationThreshold 기본값 5000회 — 이 정도로 자주 호출되는 메서드는 C2(Tier 4)로 최종 컴파일할 후보가 됩니다.
  • Tier4CompileThreshold 기본값 15000회 — 누적 실행량이 이 값을 넘으면 C2 컴파일이 확정적으로 트리거됩니다.

TieredStopAtLevel 옵션(기본값 4)으로 도달 가능한 최고 레벨을 제한할 수도 있습니다. 예를 들어 이 값을 1로 설정하면 C1까지만 사용하고 C2는 아예 쓰지 않게 됩니다.

즉 아주 가끔 호출되는 메서드는 인터프리터로만 실행되어 컴파일 비용을 아끼고, 어느 정도 자주 호출되면 빠르게(C1) 네이티브 코드로 바꿔 우선 이득을 보고, 정말 뜨거운 코드만 시간을 들여(C2) 최고 수준으로 최적화합니다. 이 점진적 전략 덕분에 애플리케이션은 시작 초반의 짧은 지연(warm-up)과 장기 실행 시의 높은 처리량이라는 두 마리 토끼를 함께 노릴 수 있습니다.

메서드가 컴파일된 이후에도, 컴파일 당시의 가정이 실행 중에 깨지는 경우(예: 클래스 로딩으로 인해 다형성 가정이 바뀌는 경우)가 있습니다. 이때 JVM은 **역최적화(Deoptimization)**를 수행해 해당 네이티브 코드를 폐기하고 다시 인터프리터로 되돌아갑니다. 이는 JIT 컴파일이 항상 실행 중에 관찰된 정보를 바탕으로 한 "추측적(speculative)" 최적화이기 때문에 필요한 안전장치입니다.


6. JVM을 효율적으로 다루기 위한 실무 포인트

지금까지 살펴본 내부 동작 원리를 바탕으로, 실무에서 JVM 성능에 영향을 주는 대표적인 요소들을 정리합니다.

짧게 사는 객체는 의도적으로 많이 만들어도 괜찮은 경우가 많습니다. Young 영역 GC는 세대별 가설에 기반해 설계되어 있고, TLAB 덕분에 객체 할당 자체는 락 경합 없이 매우 빠릅니다. 무조건 객체 생성을 피하기보다, 정말 오래 살아남아 Old 영역으로 승격되는 객체(대형 캐시, 정적 컬렉션 등)의 수와 크기를 관리하는 쪽이 GC 튜닝에서 더 중요한 경우가 많습니다.

GC는 애플리케이션 특성에 맞게 선택합니다. 처리량이 중요하고 정지 시간에 다소 관대하다면 Parallel GC나 G1 GC, 응답 지연에 극도로 민감한 서비스(대규모 힙에서 짧은 정지 시간이 필수인 경우)라면 ZGC나 Shenandoah를 검토할 수 있습니다. G1은 리전 단위로 회수 효율이 좋은 영역을 우선 회수하는 방식이라, 힙 크기가 크고 일반적인 서버 워크로드에 기본값으로 적합하게 설계되어 있습니다.

클래스를 과도하게 동적 생성하는 프레임워크를 쓴다면 메타스페이스도 함께 살펴야 합니다. 런타임에 프록시 클래스를 대량으로 생성하는 프레임워크를 사용할 경우, 힙이 아니라 메타스페이스 사용량이 늘어납니다. 메타스페이스는 기본적으로 크기 제한이 없어 시스템 네이티브 메모리를 잠식할 수 있으므로, 필요하다면 상한을 설정해 관찰하는 것이 안전합니다.

재귀 깊이와 스레드 수는 함께 고려해야 합니다. JVM 스택은 스레드마다 독립적으로 할당됩니다. 스레드를 과도하게 많이 생성하면 스레드별 스택이 차지하는 메모리가 누적되어 전체 메모리 압박으로 이어질 수 있고, 반대로 재귀 호출이 지나치게 깊으면 개별 스레드의 스택이 넘쳐 StackOverflowError가 발생합니다.

애플리케이션 시작 직후의 성능과 장시간 실행 후의 성능은 다릅니다. 계층형 컴파일 구조상, 메서드가 C2 수준까지 최적화되려면 일정 횟수 이상 실행되어야 합니다(수천 회 단위). 벤치마크를 짧게 한 번 실행해 얻은 결과와, 충분히 예열(warm-up)된 이후의 실제 운영 성능은 다를 수 있다는 점을 감안해야 합니다. 컨테이너 환경처럼 프로세스가 자주 재시작되는 경우 이 예열 비용이 반복적으로 발생한다는 점도 고려 대상입니다. 시작 시간 자체가 느리다면 대량의 클래스를 로딩하고 검증하는 비용을 의심해볼 수 있습니다. 불필요한 라이브러리를 정리해 로드할 클래스 수를 줄이거나, 클래스 데이터 공유(CDS, Class Data Sharing) 기능으로 클래스 파싱·링크 비용을 줄이는 접근도 가능합니다.

참조 유형도 목적에 맞게 사용합니다. 단순히 메모리 누수를 피하려고 무분별하게 WeakReference를 사용하기보다, 캐시처럼 메모리가 부족할 때만 비워도 되는 경우에는 SoftReference를, 리스너나 콜백 등록처럼 생명주기를 원본 객체에 종속시키고 싶은 경우에는 WeakReference를 목적에 맞게 구분해서 사용하는 것이 의도를 코드로 명확히 드러내는 방법입니다.

힙 크기가 32GB를 넘지 않도록 관리하면 압축 오프셋(Compressed Oops)의 이점을 유지할 수 있습니다. 객체 헤더에 들어가는 클래스 포인터와 참조 필드는 원래 8바이트지만, 힙 시작 주소를 기준으로 한 상대 오프셋을 4바이트로 압축해서 저장하는 최적화가 기본적으로 켜져 있습니다. 이 압축은 힙이 32GB 미만일 때 기준 주소를 0으로 두고 동작하는데, 힙을 32GB 이상으로 키우면 이 최적화가 꺼지면서 객체당 메모리 사용량과 캐시 효율이 함께 나빠질 수 있습니다. 힙을 키울 때는 이 경계값을 넘는지부터 확인하는 것이 좋습니다.

GC를 아무리 돌려도 회수되는 메모리가 적으면 JVM이 스스로 포기하고 예외를 던지도록 설계되어 있습니다. 전체 실행 시간 중 GC에 쓰는 시간 비율이 98%를 넘으면서, 그렇게 GC를 했는데도 회수되는 힙 공간이 2% 미만인 상태가 반복되면 OutOfMemoryError: GC overhead limit exceeded가 발생합니다. 힙 크기를 늘려도 이 오류가 계속 난다면 메모리가 부족한 것이 아니라 애플리케이션이 계속 살아있는 객체(메모리 누수)를 만들어내고 있다는 신호로 봐야 합니다.

GC를 비롯해 스레드 덤프, 특정 최적화 적용처럼 모든 스레드가 동시에 멈춰야 안전한 작업이 있을 때, JVM은 각 스레드를 안전점(Safepoint)이라는 지점까지만 진행시키고 멈춰 세웁니다. 실행 중인 스레드가 안전점에 도달하기까지 시간이 걸리면 그만큼 GC를 포함한 전체 작업이 지연되므로, 안전점 도달이 유난히 오래 걸리는 코드 패턴(예: JIT로 컴파일되지 않은 매우 긴 반복문)이 있는지도 지연 시간 튜닝의 대상이 됩니다.


참고 개념 요약

개념 핵심 요약
javac 파이프라인 파싱 → 진입 → 애노테이션 처리 → 속성 부여 → 흐름 분석 → 문법 풀어내기 → 바이트코드 생성
클래스 로더 계층 Bootstrap → Platform → Application, 부모 위임 모델로 상위 로더 우선
클래스 로딩 단계 로딩 → 링크(검증·준비·해석) → 초기화(<clinit> 실행)
힙 구조 Young(Eden + Survivor) / Old, MaxTenuringThreshold 기본 15
메타스페이스 클래스 메타데이터 저장, 네이티브 메모리 기반, 기본 크기 무제한
스레드별 메모리 JVM 스택(프레임 단위), PC 레지스터, 네이티브 메서드 스택
GC 종류 Serial, Parallel, G1(기본값), ZGC, Shenandoah, Epsilon
GC 공통 메커니즘 카드 테이블 + 배리어로 세대·리전 간 참조를 추적
계층형 컴파일 Tier 0(인터프리터) → Tier 1~3(C1) → Tier 4(C2), 호출 수천 회 기준 전환
실무 임계값 압축 오프 32GB 경계, GC overhead limit(98%/2%), Safepoint