자바가 느리다는 건 20년 전 이야기예요. HotSpot JVM은 실행 중에 "뜨거운(hot)" 코드를 관찰하고, 실제 프로파일에 맞춰 기계어로 재컴파일합니다. 이번 장은 계층 컴파일 · 인라이닝 · 이스케이프 분석 · 역최적화의 내부 동작과, 그것을 제대로 측정(JMH)하는 법을 파고듭니다. "왜 빠른가"를 알아야 "왜 안 빠른가"도 진단할 수 있어요.
JVM은 켜자마자 최고 성능으로 달리지 않아요. 처음엔 인터프리터로 바이트코드를 한 줄씩 실행하면서 어떤 메서드가 자주 불리는지(hot)를 관찰합니다. 충분히 뜨거워지면 그때서야 JIT 컴파일러가 기계어로 바꿔요. 이렇게 실행하면서 컴파일하기에 Just-In-Time이라 부릅니다.
hot 판단은 두 종류의 카운터로 해요. 메서드가 불린 횟수(호출 카운터, invocation counter)와 루프가 뒤로 점프한 횟수(백엣지 카운터, back-edge counter). 이 값들이 임계치(-XX:CompileThreshold 등)를 넘으면 컴파일 큐에 올라갑니다.
| 레벨 | 실행 주체 | 성격 |
|---|---|---|
| 0 | 인터프리터 | 즉시 실행, 프로파일 카운터 수집 |
| 1 | C1 (프로파일 없음) | 단순·안정 코드용 빠른 컴파일(더 이상 프로파일 안 함) |
| 2 | C1 (제한적 프로파일) | 가벼운 프로파일 포함 |
| 3 | C1 (풀 프로파일) | C2로 넘기기 위한 풍부한 프로파일 수집 |
| 4 | C2 | 수집된 프로파일로 공격적 최적화(최종 목표) |
-client/-server 이분법은 옛말이에요.
main에서 시작한 거대한 루프가 하나 있고 그 안에서 프로그램 대부분을 보낸다고 해봐요. 메서드는 딱 한 번 호출됐으니 호출 카운터로는 영영 hot이 안 됩니다. 그래서 JVM은 백엣지 카운터로 루프가 뜨거워진 걸 감지하면, 실행 중인 프레임 한가운데서 인터프리터 코드를 컴파일된 코드로 갈아끼워요. 이게 OSR — "이미 스택에서 돌고 있는 메서드를 통째로 교체"하는 기술입니다.JIT의 최적화는 수십 가지지만, 성능에 가장 크게 기여하는 것부터 봅시다. 그중 인라이닝이 "최적화의 어머니"라 불려요 — 다른 최적화들이 인라이닝으로 열린 넓은 시야 위에서 이뤄지기 때문입니다.
| 최적화 | 하는 일 | 효과 |
|---|---|---|
| 인라이닝(Inlining) | 메서드 호출을 호출 지점에 본문으로 펼침 | 호출 오버헤드 제거 + 더 넓은 최적화 범위 확보 |
| 역가상화(Devirtualization) | 가상 호출(virtual call)을 프로파일로 구체 타입으로 특정 | 인라이닝 가능해짐(monomorphic/bimorphic) |
| 루프 언롤링 | 반복 본문을 여러 번 펼쳐 분기·카운터 감소 | 파이프라이닝·벡터화 여지 확대 |
| 죽은 코드 제거(DCE) | 결과가 안 쓰이는 계산·분기 삭제 | 불필요 연산 제거(벤치마크의 함정 원인!) |
| 상수 폴딩·전파 | 컴파일 타임에 결정되는 식을 미리 계산 | 런타임 연산 제거 |
-XX:MaxInlineSize(작은 메서드 기준, 기본 ~35바이트코드)·-XX:FreqInlineSize(자주 불리는 hot 메서드 기준, 기본 ~325바이트코드) 같은 크기 한도로 통제해요.
// 호출 지점에 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/필드에 저장 → 밖에서 접근 가능 | 최적화 불가(정상 힙 할당) |
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);
}
}
-prof gc(할당률 측정)나 async-profiler의 alloc 프로파일로 "이 경로에서 진짜 할당이 없는가"를 봐야 해요. 필드가 커지거나 조건이 복잡해지면 스칼라 치환이 조용히 실패할 수 있습니다.C2의 강력함은 투기적 최적화(speculative optimization)에서 나와요. "지금까지 이 call site엔 Dog만 왔으니 앞으로도 그럴 것"이라 가정하고 공격적으로 인라이닝·역가상화합니다. 그런데 가정은 틀릴 수 있죠. 그래서 컴파일된 코드 곳곳에 가드(guard)를 심어두고, 가정이 깨지면 역최적화로 되돌립니다.
| 역최적화 유발 원인 | 무슨 일 |
|---|---|
| 새 클래스 로딩 | monomorphic 가정이 깨짐(새 구현체 등장) → 인라이닝 무효화 |
| 프로파일 변화 | 안 타던 분기가 갑자기 실행됨 → 죽은 줄 알았던 코드 부활 |
| 타입 가드 실패 | 기대한 타입과 다른 객체 도착(예: Cat 대신 Dog만 오다가...) |
| 널/범위 가정 위반 | 제거했던 null 체크·배열 경계 조건이 실제로 발생 |
-XX:+PrintCompilation·JFR의 compilation/deopt 이벤트로 원인 지점을 찾습니다.여기까지 온 최적화들 때문에 순진한 벤치마크는 거의 항상 틀립니다. System.nanoTime()으로 루프를 감싸 재는 방식은 워밍업, DCE, 상수 폴딩을 하나도 통제하지 못해요. 그래서 JVM 진영은 JMH(Java Microbenchmark Harness)라는 전용 도구를 씁니다.
| JMH 요소 | 역할 |
|---|---|
@Warmup | 측정 전 반복 실행으로 JIT 컴파일을 미리 유도(피크 상태에서 측정) |
@Fork | 별도 JVM 프로세스로 실행 → 프로파일 오염·간섭 격리(여러 fork로 분산 확인) |
Blackhole | 결과를 "소비"해 죽은 코드 제거(DCE)를 막음 |
@State | 입력을 JVM이 상수 폴딩 못하게 상태 객체로 보관 |
@BenchmarkMode | throughput/avg time 등 측정 관점 선택 |
Blackhole.consume()이나 반환값으로 소비.final 상수면 결과를 컴파일 타임에 미리 계산 → 실제 연산 안 함. → @State 필드로 "예측 불가"하게.@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 방지
}
-XX:+FlightRecorder, jcmd JFR.start), async-profiler는 안전점(safepoint) 편향 없는 hot method와 alloc 프로파일을 플레임그래프로 뽑아줘요. 원칙 하나: 측정은 프로덕션과 유사한 조건(같은 힙·GC·JDK·데이터 규모)에서 — 랩톱 벤치는 운영을 대변하지 못합니다.JIT의 약점은 워밍업이에요. 켜자마자 최고 성능이 아니고, 짧게 살다 죽는 프로세스(서버리스·CLI)에선 이 워밍업 비용을 회수하지 못합니다. 그래서 나온 대안이 AOT(Ahead-Of-Time) 컴파일 — GraalVM native image는 빌드 시점에 전체 프로그램을 미리 기계어로 컴파일해 독립 실행파일로 만들어요.
MaxInlineSize/FreqInlineSize), 역가상화(mono/bi-morphic), 루프 언롤링, DCE·상수 폴딩.@Warmup·@Fork·Blackhole·@State로 DCE·상수 폴딩·워밍업 함정 차단. hot·할당은 JFR/async-profiler, 프로덕션 유사 조건에서.