계층 컴파일 주요 최적화 이스케이프 분석 역최적화 성능 측정 AOT · Native 📝 문제풀이
◀ 심화 CH 20 📋 목차 심화 CH 22 ▶
심화 CHAPTER 21 · JIT와 성능 공학 · 딥다이브

인터프리터로 시작한 코드가 어떻게 C보다 빨라질까?

자바가 느리다는 건 20년 전 이야기예요. HotSpot JVM은 실행 중에 "뜨거운(hot)" 코드를 관찰하고, 실제 프로파일에 맞춰 기계어로 재컴파일합니다. 이번 장은 계층 컴파일 · 인라이닝 · 이스케이프 분석 · 역최적화의 내부 동작과, 그것을 제대로 측정(JMH)하는 법을 파고듭니다. "왜 빠른가"를 알아야 "왜 안 빠른가"도 진단할 수 있어요.

🎯 이 장을 끝내면
🪜
계층 컴파일
인터프리터 → C1 → C2, 그리고 OSR
"모든 코드를 다 컴파일하지 않는다"가 핵심이에요.

JVM은 켜자마자 최고 성능으로 달리지 않아요. 처음엔 인터프리터로 바이트코드를 한 줄씩 실행하면서 어떤 메서드가 자주 불리는지(hot)를 관찰합니다. 충분히 뜨거워지면 그때서야 JIT 컴파일러가 기계어로 바꿔요. 이렇게 실행하면서 컴파일하기에 Just-In-Time이라 부릅니다.

📖
레벨0 · 인터프리터
즉시 실행 + 카운터 프로파일
🏃
C1 (레벨1~3)
빠른 컴파일 + 프로파일 수집
🚀
C2 (레벨4)
프로파일 기반 고도 최적화

hot 판단은 두 종류의 카운터로 해요. 메서드가 불린 횟수(호출 카운터, invocation counter)와 루프가 뒤로 점프한 횟수(백엣지 카운터, back-edge counter). 이 값들이 임계치(-XX:CompileThreshold 등)를 넘으면 컴파일 큐에 올라갑니다.

레벨실행 주체성격
0인터프리터즉시 실행, 프로파일 카운터 수집
1C1 (프로파일 없음)단순·안정 코드용 빠른 컴파일(더 이상 프로파일 안 함)
2C1 (제한적 프로파일)가벼운 프로파일 포함
3C1 (풀 프로파일)C2로 넘기기 위한 풍부한 프로파일 수집
4C2수집된 프로파일로 공격적 최적화(최종 목표)
⚖️ C1 vs C2 — 역할이 다르다 C1 (Client 컴파일러) — 컴파일이 빠르지만 최적화 강도는 낮아요. 워밍업 초반에 인터프리터보다 빨리 돌게 해주는 징검다리이자, C2에 줄 프로파일 수집기 역할.
C2 (Server 컴파일러) — 컴파일은 느리고 비싸지만, 인라이닝·이스케이프 분석·루프 최적화 등을 총동원해 피크 성능 코드를 만들어요. 기본값인 계층 컴파일은 둘을 조합해 "초반 응답성(C1)"과 "장기 최고성능(C2)"을 동시에 노립니다. 그래서 대부분의 서버 워크로드에서 -client/-server 이분법은 옛말이에요.
💡
OSR(On-Stack Replacement). main에서 시작한 거대한 루프가 하나 있고 그 안에서 프로그램 대부분을 보낸다고 해봐요. 메서드는 딱 한 번 호출됐으니 호출 카운터로는 영영 hot이 안 됩니다. 그래서 JVM은 백엣지 카운터로 루프가 뜨거워진 걸 감지하면, 실행 중인 프레임 한가운데서 인터프리터 코드를 컴파일된 코드로 갈아끼워요. 이게 OSR — "이미 스택에서 돌고 있는 메서드를 통째로 교체"하는 기술입니다.
🔧
주요 최적화
인라이닝 · 루프 · 역가상화
C2가 코드를 다시 쓰는 대표 기법들

JIT의 최적화는 수십 가지지만, 성능에 가장 크게 기여하는 것부터 봅시다. 그중 인라이닝이 "최적화의 어머니"라 불려요 — 다른 최적화들이 인라이닝으로 열린 넓은 시야 위에서 이뤄지기 때문입니다.

최적화하는 일효과
인라이닝(Inlining)메서드 호출을 호출 지점에 본문으로 펼침호출 오버헤드 제거 + 더 넓은 최적화 범위 확보
역가상화(Devirtualization)가상 호출(virtual call)을 프로파일로 구체 타입으로 특정인라이닝 가능해짐(monomorphic/bimorphic)
루프 언롤링반복 본문을 여러 번 펼쳐 분기·카운터 감소파이프라이닝·벡터화 여지 확대
죽은 코드 제거(DCE)결과가 안 쓰이는 계산·분기 삭제불필요 연산 제거(벤치마크의 함정 원인!)
상수 폴딩·전파컴파일 타임에 결정되는 식을 미리 계산런타임 연산 제거
📥 인라이닝 — 왜 이렇게 중요할까 메서드 호출은 그 자체로도 비용(스택 프레임, 분기)이지만, 더 큰 문제는 최적화의 벽이라는 점이에요. 호출 너머의 코드를 컴파일러가 함께 볼 수 없으니 최적화가 거기서 끊깁니다. 인라이닝으로 본문을 펼치면 상수 전파·이스케이프 분석·널 체크 제거가 호출 경계를 넘어 이어져요. 단, 무한정 펼치면 코드가 비대해져(code bloat) I-cache를 망칩니다. 그래서 -XX:MaxInlineSize(작은 메서드 기준, 기본 ~35바이트코드)·-XX:FreqInlineSize(자주 불리는 hot 메서드 기준, 기본 ~325바이트코드) 같은 크기 한도로 통제해요.
🧭
역가상화와 call site. 인터페이스·오버라이드 호출은 원래 어떤 구현이 불릴지 런타임에 정해지는 가상 호출이라 그냥은 인라이닝이 안 돼요. 하지만 JIT는 호출 지점(call site)의 프로파일을 봅니다. 한 종류 타입만 왔으면 monomorphic(단형), 두 종류면 bimorphic(이형)이라 부르고, 이 경우 타입 가드 + 직접 인라이닝으로 최적화해요. 세 종류 이상 섞이는 megamorphic 호출 지점은 최적화가 어려워집니다 — "핫 루프에서는 구현 타입을 몇 개로 좁혀라"가 실전 교훈이에요.
// 호출 지점에 List 구현이 ArrayList 하나만 온다면 (monomorphic)
for (int i = 0; i < list.size(); i++) {   // list.size() 가상호출처럼 보이지만
    sum += list.get(i);                    // 프로파일로 ArrayList로 특정 →
}                                          // 역가상화 → 인라이닝 → 경계 제거
// 반대로 여기에 LinkedList, CopyOnWriteArrayList... 여럿이 섞이면
// megamorphic 이 되어 인라이닝이 막히고 느려짐
🏃‍♂️
이스케이프 분석
"안 새는 객체"는 힙에 안 만든다
스칼라 치환 · 스택 할당 · 락 제거

이스케이프 분석(Escape Analysis)은 "이 객체가 자기를 만든 메서드(또는 스레드) 밖으로 새어 나가는가?"를 컴파일러가 추적하는 기법이에요. 새지 않는다는 게 증명되면, JVM은 힙 할당이라는 값비싼 일을 아예 건너뛸 수 있습니다.

결과조건최적화
NoEscape객체가 메서드 밖으로 전혀 안 나감스칼라 치환 / 스택 할당
ArgEscape인자로만 넘어가고 반환·필드 저장은 안 됨제한적 최적화(스택 할당 가능성)
GlobalEscape반환되거나 static/필드에 저장 → 밖에서 접근 가능최적화 불가(정상 힙 할당)
🎯 세 가지 후속 최적화 ① 스칼라 치환(Scalar Replacement) — 객체를 아예 만들지 않고, 그 필드들을 낱개의 지역변수(스칼라)로 분해해 레지스터/스택에 둬요. HotSpot에서 실제로 가장 자주 일어나는 형태 — "할당이 사라진다".
② 스택 할당(Stack Allocation) — 힙 대신 스택 프레임에 객체를 두어 메서드가 끝나면 자동 소멸(GC 부담 0). 개념적 설명으로 자주 언급돼요.
③ 락 제거(Lock Elision) — 객체가 한 스레드에만 갇혀 있으면(공유 안 됨) 그 객체 모니터의 synchronized무의미하므로 락 자체를 삭제합니다. 그래서 "임시 StringBuilder나 작은 래퍼 객체를 루프에서 만들어도 괜찮다"는 조언이 성립해요 — 이스케이프 안 하면 실제 할당·락이 없어질 수 있으니까. 물론 보장은 아니고, JIT가 최적화하기 전(워밍업)엔 진짜 할당됩니다.
int distanceSq(int x, int y) {
    Point p = new Point(x, y);   // p 는 이 메서드 밖으로 안 샘(NoEscape)
    return p.x * p.x + p.y * p.y; // → 스칼라 치환: new Point 사라지고
}                                 //   x*x + y*y 로 축약 (힙 할당 0)

void log(Object msg) {
    StringBuilder sb = new StringBuilder(); // 지역 · 공유 안 됨
    synchronized (sb) {          // 락 제거(elision) 대상 → 사실상 삭제
        sb.append(msg);
    }
}
⚠️
이스케이프 분석은 "안 하기로 결정"하는 최적화라 눈에 안 보여요. 힙 할당이 사라졌는지 확인하려면 추측하지 말고 측정해야 합니다. JMH의 -prof gc(할당률 측정)나 async-profiler의 alloc 프로파일로 "이 경로에서 진짜 할당이 없는가"를 봐야 해요. 필드가 커지거나 조건이 복잡해지면 스칼라 치환이 조용히 실패할 수 있습니다.
↩️
역최적화
"투기"가 틀리면 되돌린다
Deoptimization · uncommon trap

C2의 강력함은 투기적 최적화(speculative optimization)에서 나와요. "지금까지 이 call site엔 Dog만 왔으니 앞으로도 그럴 것"이라 가정하고 공격적으로 인라이닝·역가상화합니다. 그런데 가정은 틀릴 수 있죠. 그래서 컴파일된 코드 곳곳에 가드(guard)를 심어두고, 가정이 깨지면 역최적화로 되돌립니다.

🪤 uncommon trap — 안전한 탈출구 최적화 코드가 "예상 밖 상황"(가정 위반)을 만나면 uncommon trap이 발동해요. 그러면 실행이 안전한 인터프리터로 되돌아가고(deopt), 문제의 컴파일된 코드는 폐기됩니다. 실행은 절대 틀린 결과를 내지 않고 잠깐 느려질 뿐이에요. 즉 JIT는 "빠르지만 틀릴 수도 있는 코드"를 만들고, 틀리는 순간 안전하게 후퇴하는 안전망을 항상 함께 깔아둡니다.
역최적화 유발 원인무슨 일
새 클래스 로딩monomorphic 가정이 깨짐(새 구현체 등장) → 인라이닝 무효화
프로파일 변화안 타던 분기가 갑자기 실행됨 → 죽은 줄 알았던 코드 부활
타입 가드 실패기대한 타입과 다른 객체 도착(예: Cat 대신 Dog만 오다가...)
널/범위 가정 위반제거했던 null 체크·배열 경계 조건이 실제로 발생
💡
재컴파일과의 관계. 역최적화 후 그 코드가 여전히 hot이면, JVM은 새 프로파일을 반영해 다시 컴파일해요(이번엔 두 타입 다 고려하는 등). 즉 deopt는 실패가 아니라 적응 루프의 일부입니다. 다만 같은 지점에서 deopt가 반복되면(끊임없이 새 타입이 오거나 프로파일이 요동) 성능이 출렁여요 — 이때 -XX:+PrintCompilation·JFR의 compilation/deopt 이벤트로 원인 지점을 찾습니다.
📏
성능 측정
추측하지 말고 JMH로 재라
마이크로벤치의 함정과 프로파일링

여기까지 온 최적화들 때문에 순진한 벤치마크는 거의 항상 틀립니다. System.nanoTime()으로 루프를 감싸 재는 방식은 워밍업, DCE, 상수 폴딩을 하나도 통제하지 못해요. 그래서 JVM 진영은 JMH(Java Microbenchmark Harness)라는 전용 도구를 씁니다.

JMH 요소역할
@Warmup측정 전 반복 실행으로 JIT 컴파일을 미리 유도(피크 상태에서 측정)
@Fork별도 JVM 프로세스로 실행 → 프로파일 오염·간섭 격리(여러 fork로 분산 확인)
Blackhole결과를 "소비"해 죽은 코드 제거(DCE)를 막음
@State입력을 JVM이 상수 폴딩 못하게 상태 객체로 보관
@BenchmarkModethroughput/avg time 등 측정 관점 선택
🕳️ 마이크로벤치의 3대 함정 ① 죽은 코드 제거(DCE) — 결과를 아무도 안 쓰면 JIT가 계산 자체를 삭제 → "0ns"라는 거짓 수치. → Blackhole.consume()이나 반환값으로 소비.
② 상수 폴딩(Constant Folding) — 입력이 final 상수면 결과를 컴파일 타임에 미리 계산 → 실제 연산 안 함. → @State 필드로 "예측 불가"하게.
③ 워밍업 미흡 — 인터프리터/C1 단계 수치를 피크로 착각. 첫 실행은 JIT 워밍업 때문에 느린 게 정상. → @Warmup으로 정상상태(steady state) 진입 후 측정. 이 셋이 "내 벤치마크는 왜 말도 안 되게 빠른가/느린가"의 90%예요. JMH는 이 함정들을 구조적으로 막아주는 게 존재 이유입니다.
@State(Scope.Thread)          // 입력을 상태로 보관 → 상수 폴딩 방지
public class HashBench {
    int x = 42;               // final 아님 · JVM이 상수로 못 봄

    @Benchmark
    @Warmup(iterations = 5)   // JIT 워밍업 유도
    @Fork(2)                  // 별도 JVM 2회
    public int hash() {
        return Integer.hashCode(x * 31 + 7);
    }                          // 반환값 → JMH가 Blackhole로 소비 → DCE 방지
}
🔬
벤치마크 다음은 프로파일링. "느리다"를 확인했으면 어디가 느린지 찾아야죠. JFR(Java Flight Recorder)은 낮은 오버헤드로 CPU 표본·할당·GC·컴파일 이벤트를 녹화하고(-XX:+FlightRecorder, jcmd JFR.start), async-profiler는 안전점(safepoint) 편향 없는 hot methodalloc 프로파일을 플레임그래프로 뽑아줘요. 원칙 하나: 측정은 프로덕션과 유사한 조건(같은 힙·GC·JDK·데이터 규모)에서 — 랩톱 벤치는 운영을 대변하지 못합니다.
📦
AOT · Native
JIT를 버리면 얻는 것과 잃는 것
GraalVM native image의 트레이드오프

JIT의 약점은 워밍업이에요. 켜자마자 최고 성능이 아니고, 짧게 살다 죽는 프로세스(서버리스·CLI)에선 이 워밍업 비용을 회수하지 못합니다. 그래서 나온 대안이 AOT(Ahead-Of-Time) 컴파일GraalVM native image는 빌드 시점에 전체 프로그램을 미리 기계어로 컴파일해 독립 실행파일로 만들어요.

⚖️ Native image 트레이드오프 ✅ 얻는 것즉시 최고 속도로 시작(워밍업 없음), 밀리초급 시동, 낮은 메모리 사용량, JVM 미포함 단일 바이너리. → 서버리스·CLI·스케일 인/아웃 잦은 컨테이너에 유리.
❌ 잃는 것 — 런타임 프로파일 기반 피크 최적화(C2)를 못 받아 장기 실행 처리량은 대개 JIT보다 낮음. 폐쇄형 세계 가정(closed-world)이라 리플렉션·동적 프록시·JNI는 별도 설정 필요, 빌드가 느리고 무거움. 한 줄 요약: 오래 사는 처리량 워크로드 → JIT, 자주 뜨고 지는 지연/시동 민감 워크로드 → AOT native. 정답은 워크로드 수명에 달렸어요.
🧠 이 장 핵심 요약
📝
문제풀이 · 점검
시니어 관점으로 점검
면접·성능 튜닝에서 실제로 묻는 결의 문제예요. 틀려도 바로 해설이 나옵니다.
🧪
개념을 "안다"와 "설명할 수 있다"는 달라요. 네 유형(객관식 · O/X · 빈칸 · 코드결과)으로 점검해보세요.
심화 CH 22 🧵 동시성 내부 — 메모리 모델, happens-before, 락 구현, false sharing (제작 예정)