2주차 Unit 2.3 — 인스턴스 메서드 호출의 전 과정 (Case Study)

Psj·2026년 5월 15일

F-lab

목록 보기
58/240

Unit 2.3 — 인스턴스 메서드 호출의 전 과정 (Case Study)

F-LAB JAVA · 2주차 · Phase 2 · JVM 메서드 실행 메커니즘


📌 학습 목표

이 Unit을 끝내면 다음을 답할 수 있어야 한다.

  • 두 줄의 코드가 JVM에서 정확히 몇 단계를 거치는가?
  • 5단계 중 가장 비싼 단계는 무엇이며 왜인가?
  • 같은 메서드를 100만 번 호출하면 매번 같은 비용인가?
  • JIT 컴파일러가 어느 시점에 개입하나?
  • 클래스 로딩은 정확히 언제 일어나나?
  • 운영에서 메서드 호출 hot path를 어떻게 분석하나?

🎯 핵심 한 문장

두 줄의 자바 코드가 JVM에서는 5개의 큰 단계로 펼쳐진다.
Phase 1 ~ Unit 2.2에서 본 모든 메커니즘이 시간순으로 한 자리에 모이는 순간이다.
그리고 두 번째 호출부터는 JIT가 이 비용을 거의 0으로 만든다.

추적할 코드

public class App {
    public static void main(String[] args) {
        ShipmentCalculator calc = new ShipmentCalculator();   // ← 한 줄
        BigDecimal fare = calc.calculate(100, 50);            // ← 또 한 줄
    }
}

public class ShipmentCalculator {
    private int callCount;
    
    public BigDecimal calculate(int weight, int ratePerKg) {
        callCount++;
        return BigDecimal.valueOf((long) weight * ratePerKg);
    }
}

이 두 줄의 코드가 JVM에서 펼쳐지는 모습 전체.


🧭 9개 섹션 로드맵

1. 5단계 큰 그림 — 한 페이지 요약
2. Stage 1 — JVM 시작과 클래스 로딩
3. Stage 2 — main() 실행 시작
4. Stage 3 — new로 객체 생성
5. Stage 4 — 참조 저장
6. Stage 5 — 인스턴스 메서드 호출
7. JIT의 마법 — 두 번째 호출부터의 변화
8. ILIC 실무 — Hot path 분석과 워밍업
9. 면접 질문 + 자기 점검

1️⃣ 5단계 큰 그림

1.1 시간 흐름 다이어그램

시간 →
┌─────────────────────────────────────────────────────────────┐
│ Stage 1: JVM 시작 + 클래스 로딩                              │
│   App.class, ShipmentCalculator.class 읽기                  │
│   Method Area에 적재                                        │
├─────────────────────────────────────────────────────────────┤
│ Stage 2: main() 실행 시작                                    │
│   App의 Static Zone에서 main 바이트코드 발견                │
│   첫 스택 프레임 생성                                        │
├─────────────────────────────────────────────────────────────┤
│ Stage 3: new ShipmentCalculator()                          │
│   Heap에 객체 할당                                          │
│   Object Header 설정 (Class Pointer)                       │
│   생성자 호출 (invokespecial)                               │
├─────────────────────────────────────────────────────────────┤
│ Stage 4: 객체 참조를 calc 변수에 저장                        │
│   astore 명령으로 LVA에 저장                                │
├─────────────────────────────────────────────────────────────┤
│ Stage 5: calc.calculate(100, 50)                           │
│   Method Resolution (1단계)                                │
│   Method Invocation (2단계)                                │
│   바이트코드 실행                                            │
└─────────────────────────────────────────────────────────────┘

1.2 단계별 비용

Stage비용빈도
1. 클래스 로딩매우 큼 (수 ms ~ 수십 ms)클래스당 1번
2. main 시작작음앱 시작 1번
3. 객체 생성보통 (수 μs)new마다
4. 참조 저장매우 작음 (ns)매번
5. 메서드 호출보통 → JIT 후 매우 작음매번

가장 비싼 단계 = Stage 1 (클래스 로딩).
하지만 클래스당 단 1번이라 amortize 됨.

→ 실시간 비용 관점에선 Stage 3 (객체 생성) 가 가장 자주 신경 써야 할 부분.

1.3 두 호출의 비용 비교

첫 번째 호출 시 비용:
  Stage 1: 클래스 로딩  ▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓▓
  Stage 2: main 시작   ▓
  Stage 3: new         ▓▓▓
  Stage 4: 저장        ·
  Stage 5: 메서드 호출  ▓▓

두 번째 호출 시 비용:
  Stage 1: (이미 로딩됨)  ·
  Stage 2: (이미 시작)    ·
  Stage 3: new           ▓▓▓
  Stage 4: 저장          ·
  Stage 5: 메서드 호출    ▓▓ → JIT 후 ·

→ 첫 호출은 매우 비싸지만, 이후 호출은 거의 무료. JIT의 효과.


2️⃣ Stage 1 — JVM 시작과 클래스 로딩

2.1 JVM 시작 시점

java App

이 한 줄로 일어나는 일:

1. java 명령 → JVM 프로세스 시작
2. JVM이 자기 자신 초기화
   - 메모리 영역 할당 (Heap, Method Area, Stack 등)
   - 기본 클래스 로더 준비
3. 시스템 클래스 로딩 (java.lang.Object, String, System 등)
4. App.class 찾기 (-classpath의 경로 검색)
5. App 클래스 로딩 시작

java App 실행 후 main()이 실제로 호출되기까지 100~200ms.
→ Spring Boot 앱은 클래스 수천 개 로딩으로 수 초 걸림.

2.2 클래스 로딩의 3단계

JVM이 App.class를 만나면:

┌─────────────────────────────────────────────┐
│ 1. Loading                                   │
│    - 파일 시스템에서 .class 바이트 읽기      │
│    - 바이트를 Method Area에 적재             │
│    - Class 객체를 Heap에 생성                │
├─────────────────────────────────────────────┤
│ 2. Linking                                   │
│    - Verification: 바이트코드 검증           │
│      (스택 오버플로, 잘못된 타입 변환 등)    │
│    - Preparation: static 변수 메모리 할당   │
│      (기본값으로 초기화)                     │
│    - Resolution: 심볼 참조 해소 (lazy)       │
├─────────────────────────────────────────────┤
│ 3. Initialization                            │
│    - static 변수 초기값 할당                 │
│    - static {} 블록 실행                     │
└─────────────────────────────────────────────┘

2.3 App.class와 ShipmentCalculator.class 모두 로딩?

코드:

public static void main(String[] args) {
    ShipmentCalculator calc = new ShipmentCalculator();
    ...
}

JVM의 lazy loading:

  • App 클래스: java App 시작 시 즉시 로딩
  • ShipmentCalculator 클래스: new ShipmentCalculator() 만나기 직전에 로딩
Stage 1 (App 시작):
  Method Area: App 클래스만

Stage 3 (new 만나기 직전):
  Method Area: App + ShipmentCalculator

호출되지 않는 클래스는 절대 로딩 안 됨. JVM의 메모리 절약 전략.

2.4 메모리 상태 — Stage 1 종료 시점

Method Area:
  ┌──────────────────────────────────────┐
  │ App 클래스                            │
  │   Class Metadata Zone                │
  │     • 부모: Object                    │
  │     • 메서드 시그니처: main(String[])V │
  │     • main → static                  │
  │   Static Zone                        │
  │     • main() 바이트코드                │
  │   Non-Static Zone                    │
  │     • <init>() 바이트코드 (기본 생성자) │
  └──────────────────────────────────────┘

Heap:
  Class 객체들 (App.class, Object.class, String.class, ...)

Stack: (비어있음)

→ ShipmentCalculator는 아직 메모리에 없음.

2.5 클래스 로딩 옵션

# 클래스 로딩 추적
java -verbose:class App

# 출력 예:
# [Loaded java.lang.Object from ...]
# [Loaded App from file:./]
# [Loaded ShipmentCalculator from file:./]   ← new 만나기 직전

운영에서 어떤 클래스가 언제 로딩되는지 확인 가능.


3️⃣ Stage 2 — main() 실행 시작

3.1 main을 찾는 과정

JVM:

1. App 클래스에서 main 메서드 검색
   조건:
   - public
   - static
   - void (또는 int — 일부 OS)
   - String[] 매개변수
   
2. App의 Method Table에서 일치 시그니처 발견:
   "main([Ljava/lang/String;)V"
   
3. Static Zone에서 main 바이트코드 위치 확정

3.2 첫 스택 프레임 생성

1. main 메서드의 Method 구조체 조회:
   - LVA 크기: 2 (args + 지역변수 calc 정도)
   - Operand Stack 최대 크기: 2

2. JVM이 메인 스레드 생성:
   - Stack Area 할당 (기본 512KB ~ 1MB)
   - PC Register 준비

3. 첫 프레임 push:
   main() 프레임
     LVA[0] args = String[] (커맨드라인 인자)
     LVA[1] (calc 자리, 아직 비어있음)

4. PC를 main의 첫 명령으로

3.3 메모리 상태 — Stage 2

Method Area:
  App 클래스 (그대로)

Heap:
  Class 객체들 + String[] 인자 (args)

Stack (메인 스레드):
  ┌──────────────────────────┐
  │ main() 프레임              │  ← 첫 프레임
  │   LVA[0] args = ref       │
  │   LVA[1] calc = ?         │
  │   Operand Stack: []       │
  └──────────────────────────┘

PC: main의 첫 명령

3.4 main의 바이트코드 미리 보기

public static void main(String[] args) {
    ShipmentCalculator calc = new ShipmentCalculator();
    BigDecimal fare = calc.calculate(100, 50);
}

바이트코드 (대략):

 0: new           #2   // ShipmentCalculator
 3: dup
 4: invokespecial #3   // <init>
 7: astore_1           // calc에 저장
 8: aload_1            // calc 로드
 9: bipush       100
11: bipush       50
13: invokevirtual #4   // calculate(II)Ljava/math/BigDecimal;
16: astore_2           // fare에 저장
17: return

PC=0부터 시작. 이제 명령 하나씩 따라가며 Stage 3~5 진행.


4️⃣ Stage 3 — new로 객체 생성

4.1 한 줄의 코드 = 4개 바이트코드

ShipmentCalculator calc = new ShipmentCalculator();
 0: new           #2   // ShipmentCalculator
 3: dup
 4: invokespecial #3   // <init>
 7: astore_1           // calc에 저장

→ Unit 1.5에서 본 패턴. 이번엔 시간 흐름에 따라 정밀 추적.

4.2 단계 3-1: new #2 — 메모리 할당

JVM 동작:
  1. 상수 풀의 #2 항목 조회
     → ShipmentCalculator 클래스 심볼
  
  2. ShipmentCalculator 클래스 로딩 확인
     → 아직 로딩 안 됨!
     → Stage 1 클래스 로딩 절차 실행 (Loading → Linking → Initialization)
     → Method Area에 ShipmentCalculator 클래스 적재
  
  3. 객체 크기 계산
     - Header: 16 bytes
     - Instance field (callCount: int): 4 bytes
     - Padding: 4 bytes
     - 총: 24 bytes
  
  4. Heap의 Eden Space에서 메모리 확보
     - TLAB 활용 (메인 스레드의 TLAB)
  
  5. 0으로 초기화 (callCount = 0)
  
  6. Object Header 설정
     - Mark Word: 초기 상태
     - Class Pointer: ShipmentCalculator Klass 주소
  
  7. 객체 참조를 Operand Stack에 push

이 순간의 메모리:

Method Area:
  ┌──────────────────────────────────┐
  │ ShipmentCalculator 클래스          │  ← 방금 로딩됨
  │   Method Table:                   │
  │     <init>()V                     │
  │     calculate(II)BigDecimal       │
  │   Non-Static Zone:                │
  │     <init> 바이트코드               │
  │     calculate 바이트코드            │
  └──────────────────────────────────┘

Heap:
  ┌──────────────────────────┐
  │ Calculator @0x7f4a2c01    │  ← 방금 할당됨
  │   Mark Word               │
  │   Class ptr → Calc Klass  │
  │   callCount = 0           │  ← 미초기화 상태 (생성자 호출 전)
  └──────────────────────────┘

Stack:
  main() 프레임
    LVA[0] args
    LVA[1] calc = ?
    Operand Stack: [0x7f4a2c01]   ← new의 결과

4.3 단계 3-2: dup — Stack top 복제

0  : new        # Operand Stack: [0x7f4a2c01]
3  : dup        # Operand Stack: [0x7f4a2c01, 0x7f4a2c01]

왜 복제?

  • 다음 invokespecial이 객체 참조를 소비함 (this로 사용)
  • 그 다음 astore_1에서 calc에 저장해야 하니까 또 하나 필요
  • → 미리 1개 더 만들어둠

→ Unit 1.5 6장에서 본 패턴. 객체 생성의 표준.

4.4 단계 3-3: invokespecial #3 — 생성자 호출

4: invokespecial #3   // <init>()V

ShipmentCalculator에 명시적 생성자 없음 → JVM이 기본 생성자 자동 생성.

public class ShipmentCalculator {
    private int callCount;
    
    // 컴파일러가 추가하는 기본 생성자
    public ShipmentCalculator() {
        super();   // Object 생성자 호출
    }
}

호출 흐름:

1. Operand Stack top의 객체 참조를 인자로
   Stack: [0x7f4a2c01, 0x7f4a2c01] → 1개 소비
   
2. 새 스택 프레임 생성:
   <init>() 프레임
     LVA[0] this = 0x7f4a2c01
   
3. 생성자 실행:
   - super() 호출 → Object의 <init>() 호출 → 또 새 프레임 (재귀)
   - callCount는 이미 0이라 생략
   
4. 생성자 종료:
   - <init> 프레임 pop
   - return (void)

이 순간 메모리:

Stack:
  ┌──────────────────────────┐
  │ main() 프레임              │
  │   Operand Stack:          │
  │     [0x7f4a2c01]          │   ← invokespecial 후 1개 남음
  └──────────────────────────┘

Heap:
  Calculator @0x7f4a2c01
    callCount = 0    ← 생성자 끝, 정상 객체

4.5 단계 3-4: astore_1 — 참조 저장 (Stage 4로 연결)

7: astore_1

Operand Stack의 top을 LVA[1]에 저장.

Stack:
  main() 프레임
    LVA[0] args
    LVA[1] calc = 0x7f4a2c01   ← 저장됨
    Operand Stack: []

이제 calc 변수가 객체를 가리킴 → Stage 4 완료.

4.6 Stage 3의 비용 분석

  • ShipmentCalculator 클래스가 처음 로딩되는 경우: 1ms ~ 10ms
  • 이미 로딩된 경우: 100ns ~ 1μs
  • TLAB 할당 실패 시: 더 비쌈 (GC 발동 가능)

첫 객체 생성이 가장 비싸다.


5️⃣ Stage 4 — 참조 저장 (이미 Stage 3-4에서 완료)

이미 Stage 3 마지막에서 astore_1로 calc에 저장 완료.

이 단계는 단순하지만 변수의 역할 변화를 명확히 하기 위해 분리.

5.1 calc의 역할

Heap:
  Calculator @0x7f4a2c01

Stack:
  main() 프레임
    LVA[1] calc = 0x7f4a2c01   ← 객체에 대한 "이름"
  • calcStack에 있는 참조 변수
  • 값은 객체의 주소(0x7f4a2c01)
  • 객체 자체는 Heap에

5.2 다음 명령 — calc 사용

BigDecimal fare = calc.calculate(100, 50);

바이트코드:

 8: aload_1            // calc 로드 (LVA[1] → Operand Stack)
 9: bipush       100   // 100 push
11: bipush       50    // 50 push
13: invokevirtual #4   // calculate(II)BigDecimal
16: astore_2           // fare에 저장

→ Stage 5에서 본격 추적.


6️⃣ Stage 5 — 인스턴스 메서드 호출

6.1 인자 준비 — aload_1, bipush

8 : aload_1     // calc 로드
9 : bipush 100  // 100
11: bipush 50   // 50

이 명령 후의 Operand Stack:

[0x7f4a2c01, 100, 50]
 ↑          ↑    ↑
 this 인자  arg  arg

→ 첫 번째 인자가 객체 참조(this).
→ Unit 2.2 5장에서 본 this 전달의 메커니즘.

6.2 단계 5-1: Method Resolution (Unit 2.1 1단계)

13: invokevirtual #4

JVM 동작:

1. 상수 풀의 #4 항목 조회
   → "ShipmentCalculator.calculate(II)Ljava/math/BigDecimal;"
   
2. Operand Stack top에서 N개 인자 + 1개 this 위치 파악
   - 시그니처 (II) → 2 슬롯 (int 2개)
   - this까지 합해서 3 슬롯
   - 즉, Stack의 top 3개
   
3. Stack top - 2 위치의 객체(this) 의 Class Pointer 확인
   → ShipmentCalculator Klass
   
4. ShipmentCalculator Klass의 VMT에서 calculate(II) 슬롯 찾기
   → 해당 슬롯이 가리키는 Method 구조체 획득
   
5. 접근 권한 검증 (public)
   
6. 메서드 실행 준비 완료

첫 호출은 위 1~6 모두 수행 (느림).
두 번째 호출은 인라인 캐시 활용 (7장에서).

6.3 단계 5-2: Method Invocation (Unit 2.1 2단계)

1. Method 구조체에서 메서드 정보:
   - LVA 크기: 3 슬롯 (this, weight, ratePerKg)
   - Operand Stack 최대: 4
   
2. 새 스택 프레임 생성:
   calculate() 프레임
     LVA[0] this = 0x7f4a2c01    ← Operand Stack에서 가져옴
     LVA[1] weight = 100
     LVA[2] ratePerKg = 50
     Operand Stack: []
   
3. main 프레임의 Operand Stack에서 3개 인자 제거
   main의 Stack: []
   
4. Return Address 저장:
   calculate 프레임의 Frame Data에 main의 다음 PC(=16) 기록
   
5. PC를 calculate의 첫 명령으로 점프

메모리:

Stack:
  ┌──────────────────────────────────┐
  │ calculate() 프레임                  │  ← 새 프레임
  │   LVA[0] this = 0x7f4a2c01        │
  │   LVA[1] weight = 100             │
  │   LVA[2] ratePerKg = 50           │
  │   Operand Stack: []               │
  │   Return Address → main PC=16     │
  ├──────────────────────────────────┤
  │ main() 프레임                       │
  │   LVA[0] args                     │
  │   LVA[1] calc = 0x7f4a2c01        │
  │   LVA[2] fare = ?                 │
  │   Operand Stack: []               │
  └──────────────────────────────────┘

Heap:
  Calculator @0x7f4a2c01
    callCount = 0

6.4 단계 5-3: calculate 바이트코드 실행

public BigDecimal calculate(int weight, int ratePerKg) {
    callCount++;
    return BigDecimal.valueOf((long) weight * ratePerKg);
}

바이트코드:

 0: aload_0           // this 로드
 1: dup               // 복제
 2: getfield #5       // callCount 읽기
 5: iconst_1          // 1 push
 6: iadd              // callCount + 1
 7: putfield #5       // this.callCount = 결과
10: iload_1           // weight
11: i2l               // int → long 변환
12: iload_2           // ratePerKg
13: i2l               
14: lmul              // 곱셈
15: invokestatic #6   // BigDecimal.valueOf(long)
18: areturn           // 결과 반환

단계 5-3a: callCount++ 처리 (PC=0~7)

PC=0: aload_0
  Stack: [this]

PC=1: dup
  Stack: [this, this]

PC=2: getfield #5    // callCount
  Stack: [this, 0]   ← top의 this에서 callCount 읽음

PC=5: iconst_1
  Stack: [this, 0, 1]

PC=6: iadd
  Stack: [this, 1]   ← 0 + 1 = 1

PC=7: putfield #5    // this.callCount = 1
  Stack: []
  Heap의 this 객체:
    callCount = 1   ← 변경됨!

단계 5-3b: 곱셈 처리 (PC=10~14)

PC=10: iload_1     // weight
  Stack: [100]

PC=11: i2l         // int → long
  Stack: [100L]

PC=12: iload_2     // ratePerKg
  Stack: [100L, 50]

PC=13: i2l
  Stack: [100L, 50L]

PC=14: lmul        // 곱셈
  Stack: [5000L]

단계 5-3c: BigDecimal.valueOf 호출 (PC=15)

PC=15: invokestatic BigDecimal.valueOf(long)

또 다른 메서드 호출. 새 프레임 push, BigDecimal 클래스의 valueOf 실행.
→ 이 호출도 내부적으로 Stage 5 전체를 다시 거침 (재귀적!).

BigDecimal.valueOf 종료 후:

Stack: [BigDecimal_ref]    ← 5000을 표현하는 BigDecimal 객체 참조

단계 5-3d: 반환 (PC=18)

PC=18: areturn
  - Stack top의 참조를 호출자에게 반환
  - calculate 프레임 pop
  - PC를 Return Address(=16)로 복원

6.5 main으로 복귀

main() 프레임 (복귀 후):
  LVA[0] args
  LVA[1] calc = 0x7f4a2c01
  LVA[2] fare = ?
  Operand Stack: [BigDecimal_ref]   ← calculate가 반환한 값
  PC = 16
PC=16: astore_2    // fare에 저장
  Stack: []
  LVA[2] fare = BigDecimal_ref

이제 fare 변수가 BigDecimal 객체를 가리킴.

6.6 Stage 5 전체 요약

시간 →
[1] Resolution: 클래스/메서드 찾기                    (≈ 1μs)
[2] Invocation: 새 프레임 생성, this+인자 전달        (≈ 100ns)
[3] 바이트코드 실행 (callCount++, 곱셈, BigDecimal)   (≈ 1μs)
[4] 메서드 반환                                       (≈ 50ns)

약 2-3 마이크로초. 그러나 이는 첫 호출만.


7️⃣ JIT의 마법 — 두 번째 호출부터의 변화

7.1 자기 점검 질문의 답

같은 객체의 메서드를 100번 호출하면 매번 metadata 조회를 하는가?

: 처음에만. 이후엔 거의 안 함. JIT 덕분.

7.2 JIT 컴파일러 — 단계적 최적화

JVM의 메서드 실행 흐름:

첫 호출 ~ 10번째 호출:
  Interpreter 모드
  바이트코드를 한 줄씩 해석 (위 Stage 5 그대로)

10번째 ~ 10000번째:
  C1 (Client) 컴파일러 작동
  바이트코드 → 네이티브 코드 (간단한 최적화)
  실행 속도 ~10배 향상

10000번째 이후:
  C2 (Server) 컴파일러 작동
  공격적 최적화 (인라이닝, 디버추얼라이제이션)
  실행 속도 ~100배 향상

7.3 인라인 캐시 — Resolution 비용 제거

for (int i = 0; i < 1_000_000; i++) {
    calc.calculate(100, 50);   // 100만 번 호출
}

첫 호출:

- Stack의 객체 → Class Pointer → ShipmentCalculator Klass
- VMT에서 calculate 슬롯 찾기
- Method 구조체 획득

두 번째 호출부터 (인라인 캐시):

호출 사이트(call site)에 캐시:
  "여기서 호출된 Klass = ShipmentCalculator"
  "그때의 Method 구조체 = 캐시된 포인터"

다음 호출:
  - 현재 객체의 Klass == 캐시된 Klass? (포인터 비교)
  - 같으면 → 캐시된 Method 구조체 바로 사용
  - 다르면 → 일반 Resolution (다형성 케이스)

→ Resolution 비용 거의 0.

7.4 인라이닝 — Invocation 비용도 제거

C2 컴파일러가 보고 판단:

"이 호출 사이트는 99% ShipmentCalculator를 호출한다"
"calculate 메서드는 작다 (인라이닝하기 적합)"
→ 호출 자체를 없애고 calculate 코드를 호출자에 펼침

결과:

// 원본 코드
for (int i = 0; i < 1_000_000; i++) {
    calc.calculate(100, 50);
}

// JIT 최적화 후 (개념적, 실제는 네이티브 코드)
for (int i = 0; i < 1_000_000; i++) {
    calc.callCount++;
    BigDecimal.valueOf(100L * 50L);
    //                          ↑ 이것도 인라이닝되거나 캐시
}

// 더 적극적 최적화:
calc.callCount = 1_000_000;   // 루프 자체 제거
// 결과 사용 안 되면 BigDecimal.valueOf 호출도 제거

메서드 호출이 사라지고, 단순 산술로 변환.

7.5 측정 — 실제 비용 차이

마이크로벤치마크:

@Benchmark
public BigDecimal callOnce() {
    return calc.calculate(100, 50);
}

JMH 측정 결과 (가상):

Mode: AverageTime
Cold (첫 호출):         ≈ 2 - 3 μs
Warm (인터프리터):      ≈ 200 - 500 ns
Hot (C1 컴파일):        ≈ 20 - 50 ns
Burning (C2 컴파일):    ≈ 1 - 5 ns

첫 호출과 최적화 후 호출은 1000배 차이.

7.6 워밍업의 중요성

@Benchmark
public BigDecimal benchmark() {
    return calc.calculate(100, 50);
}

JMH 같은 벤치마크 도구가 워밍업 라운드를 두는 이유:

  • 첫 수만 회는 측정 안 함
  • JIT가 충분히 최적화한 후 측정 시작

→ "이 코드 느린데?" 의심 전에 워밍업 후 측정.

7.7 JIT 확인 방법

# 어떤 메서드가 JIT 컴파일됐는지 보기
java -XX:+PrintCompilation App

# 출력 예:
#     1     n 0       java.lang.System::arraycopy (native)
#     2     b 3       java.lang.String::hashCode (44 bytes)
#     ...
#   123     b 4       ShipmentCalculator::calculate (28 bytes)
#                          ↑ 이 메서드가 C2 컴파일됨

# 인라이닝 추적
java -XX:+PrintInlining -XX:+UnlockDiagnosticVMOptions App

운영에서 hot path 확인 시 위 옵션 사용.


8️⃣ ILIC 실무 — Hot path 분석과 워밍업

8.1 Spring 부트 앱의 워밍업

@RestController
public class ShipmentController {
    private final ShipmentService service;
    
    @GetMapping("/{id}")
    public ShipmentResponse get(@PathVariable Long id) {
        return service.findById(id);
    }
}
배포 직후:
  첫 요청: 200~500ms
    - 클래스 로딩 다수
    - 메서드 인터프리터 모드
    - JPA Proxy 생성
  
  100번째 요청: 50~100ms
    - 클래스 로딩 완료
    - C1 컴파일 시작
  
  1000번째 요청: 10~20ms
    - C2 컴파일 적용
    - 인라이닝 효과

배포 직후 성능이 안 좋다고 놀라지 말 것. 자연스러운 워밍업 구간.

8.2 운영 패턴 — 점진적 트래픽 라우팅

Blue-Green / Canary 배포:

1. 새 인스턴스(Green) 배포
2. 트래픽 5% → 모니터링 → 워밍업
3. 100ms 미만 안정화 후 트래픽 50% → 100%
4. 구 인스턴스(Blue) 종료

→ JIT 워밍업을 고려한 표준 배포 전략.

8.3 AOT 컴파일 (Native Image)

GraalVM Native Image:

native-image -jar app.jar
./app

특징:

  • 컴파일 타임에 모든 코드를 네이티브로 변환
  • 클래스 로딩 시간 0
  • JIT 워밍업 없음
  • 시작 시간: 수십 ms (JVM은 수 초)
  • 메모리 사용량: 1/5

단점:

  • Reflection, 동적 프록시 제약 (메타데이터 미리 등록 필요)
  • 빌드 시간 매우 길어짐 (수 분)

→ Spring Boot 3.x + Spring Native로 도입 시도 가능.
→ 서버리스, CLI 도구에 적합.

8.4 메서드 호출 hot path 분석

Async-profiler 사용:

# 30초간 CPU 프로파일링
async-profiler -d 30 -f profile.html <PID>

출력 (Flame Graph):

ShipmentController.get()           ████████████████████░  85%
  ShipmentService.findById()        ████████████░         50%
    ShipmentRepository.findById()    ███████░            35%
      Hibernate query                ███████             32%
        JDBC                         ██████              25%

→ 어느 메서드에서 CPU 시간이 소비되는지 시각화.
→ 운영 최적화의 출발점.

8.5 의외의 hot path — 함정

ILIC 시나리오:

  • 운임 계산 메서드가 느림 → 의심
  • 프로파일 결과: 실제로는 로깅(log.info) 이 시간 잡아먹음
  • 원인: log.info("..." + bigData) 의 String concat
  • 해결: log.info("...{}", bigData) 로 변경

추측 대신 측정. 의외의 곳에서 비용 발생.

8.6 메서드 호출 비용 절약 — 자연스러운 패턴

// 잘못된 패턴
for (Cargo c : cargoes) {
    if (c.getStatus().equals(ACTIVE)) {       // ← getStatus() 호출 매번
        for (Item i : c.getItems()) {          // ← getItems() 호출 매번
            // ...
        }
    }
}

// 개선된 패턴
for (Cargo c : cargoes) {
    Status status = c.getStatus();
    List<Item> items = c.getItems();
    if (status.equals(ACTIVE)) {
        for (Item i : items) {
            // ...
        }
    }
}

→ JIT가 어차피 최적화하지만, 명시적으로 변수에 저장하면 더 안전.


9️⃣ 면접 질문 + 자기 점검

9.1 면접 단골 질문 매핑

Q핵심 답변
new Calc()의 4가지 작업?클래스 확인 → 메모리 할당 → Header 설정 → 생성자 호출
dup 명령의 필요성?new 후 객체 참조를 invokespecial이 소비. astore도 써야 해서 미리 복제
클래스 로딩은 언제?처음 참조될 때 (lazy). new, 정적 호출, Class.forName 등이 트리거
5단계 중 가장 비싼 단계?클래스 로딩. 하지만 클래스당 1번이라 amortize
같은 메서드 100번 호출 시 매번 Resolution?첫 번째만. 이후 인라인 캐시 활용
JIT 컴파일러 단계?Interpreter → C1 (≥10 호출) → C2 (≥10K 호출)
첫 호출과 hot 호출 성능 차이?1000배까지 가능. JIT의 인라이닝 효과
워밍업이 필요한 이유?JIT가 최적화하기까지 시간 필요. 배포 직후 느림
Native Image의 장점?시작 시간 수십ms, 메모리 1/5. 단 빌드 길고 제약 있음
Hot path 어떻게 찾나?프로파일러 (Async-profiler, JFR). Flame graph 시각화

9.2 자기 점검 체크리스트

기본 이해

  • new 한 줄의 4가지 바이트코드를 그릴 수 있다
  • 클래스 로딩의 3단계(Loading/Linking/Initialization)를 안다
  • dup이 필요한 이유를 설명할 수 있다
  • 메서드 호출 시 Resolution과 Invocation 분리를 안다
  • this가 어떻게 LVA[0]에 들어가는지 추적할 수 있다

실전 적용

  • JIT 워밍업의 효과를 측정할 수 있다
  • 인터프리터/C1/C2 단계를 안다
  • 인라인 캐시가 다형성 비용을 줄이는 메커니즘을 설명할 수 있다
  • Native Image의 trade-off를 안다
  • Hot path를 프로파일러로 찾을 수 있다

면접 대비 — 5분 답변

  • Model2 m2 = new Model2(); m2.hap(1, 2); 의 전 과정
  • 가장 비싼 단계와 그 이유
  • JIT의 단계적 최적화
  • 인라인 캐시와 devirtualization
  • 운영 환경의 워밍업 전략

🎯 핵심 요약 — 3줄 정리

1. 두 줄의 코드 = 5단계의 거대한 작업

  • Stage 1 클래스 로딩 (가장 비쌈, 1번만)
  • Stage 2 main 시작
  • Stage 3 객체 생성 (new + dup + invokespecial + astore)
  • Stage 4 참조 저장
  • Stage 5 메서드 호출 (Resolution + Invocation + 바이트코드 실행)

2. JIT가 비용을 1000배 줄인다

  • 첫 호출: 인터프리터, ≈ 2μs
  • 1만번 후: C2 컴파일, ≈ 1-5ns
  • 인라인 캐시 → Resolution 비용 0
  • 인라이닝 → Invocation 비용 0

3. ILIC 실무 직결

  • 배포 직후 느림 = JIT 워밍업 → 정상
  • Blue-Green 배포로 워밍업 흡수
  • Native Image로 시작 시간 100배 단축 가능
  • 의외의 hot path는 측정으로 발견 (추측 X)

📚 다음으로...

Unit 2.4 — new 연산자가 실제로 하는 일

이번 Unit에서 메서드 호출의 전 과정을 봤다면, 다음은 new에 집중해서 4가지 작업을 정밀 추적.

  • new 없이 객체를 만드는 방법들 (Reflection, clone, deserialization, Unsafe)
  • 생성자가 호출되기 전 객체의 상태
  • 생성자 체인 (super → this)
  • 객체 생성 비용 최적화 (Object Pool, Flyweight)

→ Phase 2의 마지막 Unit.
→ 그 다음 Phase 3 (정점).

2주차 진행 상황

✅ Phase 1 — 자바 변수 ↔ 메모리 매핑 (1.1 ~ 1.6 완주)
🚀 Phase 2 — JVM 메서드 실행 메커니즘
  ✅ Unit 2.1 메서드 호출의 2단계 처리
  ✅ Unit 2.2 Static vs 인스턴스 호출 경로
  ✅ Unit 2.3 인스턴스 메서드 호출의 전 과정 ← 여기
  ⏭ Unit 2.4 new 연산자의 실제 동작
⏭ Phase 3 — 바이트코드와 상수 풀 ★ 2주차의 정점
profile
Software Developer

0개의 댓글