
[Inside Java] 1~5편 까지는 JVM이 메모리를 어떻게 관리하는지를 중심으로 살펴보았습니다.
그런데 지금까지 살펴본 것처럼 JVM이 런타임마다 바이트코드를 하나씩 해석하고, Operand Stack을 이용해 연산을 수행하며, 메서드 호출 대상까지 계속 찾아야 한다면 Java는 계속 느린 것 아닐까?.. 하는 의문증이 생길수도..
왜냐면은 실제로 Java는 AOT(Ahead-Of-Time) 방식처럼 실행 전에 모든 코드를 기계어로 컴파일하지 않고,
우선 Interpreter를 통해 바이트코드를 실행합니다.
그렇다면 같은 코드가 반복 실행될 때도 매번 바이트코드를 해석해야 하는 걸까요?
Java는 처음부터 모든 코드를 기계어로 바꾸지 않습니다.
우선 빠르게 실행을 시작하고, 실제 실행 정보를 관찰한 뒤 자주 사용되는 코드만 더 빠른 형태로 바꿉니다.
이번 글에서는 이 과정을 Interpreter → Runtime Profiling → JIT Compilation → Code Cache → Inlining → Deoptimization 흐름으로 살펴보겠습니다.
먼저 전체 흐름부터 보기

핵심은 단순합니다.
처음에는 Interpreter로 실행
→ 실행 정보를 수집
→ 자주 실행되는 코드 발견
→ JIT가 기계어로 컴파일
→ 이후에는 컴파일된 코드를 실행
Java는 왜 처음부터 전부 컴파일하지 않을까?
Java 소스는 javac을 통해 바이트코드로 변환됩니다.
.java
↓ javac
.class Bytecode
[Java Inside] 1편에서 소개했듯, JVM은 이 바이트코드를 처음부터 전부 기계어로 바꾸지 않고 우선 Interpreter로 실행할 수 있습니다.
바이트코드 명령 읽기
→ 명령 실행
→ 다음 명령 읽기
이 방식은 프로그램 시작이 빠르다는 장점이 있습니다.
실제로 한 번도 실행되지 않을 코드까지 미리 컴파일할 필요가 없기 때문입니다.
하지만 같은 코드가 반복되면 매번 바이트코드를 해석하는 비용이 쌓입니다.
for (int i = 0; i < 1_000_000; i++) {
service.execute();
}
그래서 JVM은 실행하면서 어떤 코드가 자주 사용되는지 관찰합니다.
Runtime Profiling — 실행 정보 수집!
JVM은 프로그램을 실행하면서 여러 정보를 수집할 수 있습니다.
- 메서드 호출 횟수
- 반복문 실행 횟수
- 분기문의 선택 비율
- 메서드 호출 지점에 실제로 들어온 객체 타입
예를 들어 다음 코드가 있다고 해보겠습니다.
Parent p = new Child();
p.introduce();
바이트코드에는 여전히 가상 호출이 남습니다.
invokevirtual Parent.introduce
하지만 런타임에서 이 호출 지점에 계속 Child만 들어온다면 JVM은 다음과 같은 정보를 얻을 수 있습니다.
실제 receiver 타입
Child
Child
Child
Child
이런 실행 정보가 이후 JIT 최적화의 근거가 됩니다.
Hot Code와 JIT Compilation
JVM은 자주 실행되는 메서드나 반복 구간을 Hot Code로 판단할 수 있습니다.

JIT Compiler는 바이트코드를 현재 CPU가 직접 실행할 수 있는 기계어로 바꿉니다.
JVM Bytecode
→ JIT Compilation
→ ARM64 또는 x86-64 Machine Code
실행 전에 모든 코드를 컴파일하는 AOT 방식과 달리, JIT는 프로그램 실행 중에 수집한 정보를 이용할 수 있습니다.
JIT의 핵심은 단순히 실행 중 컴파일한다는 점이 아니라,
실제 사용 패턴을 알고 최적화한다는 점입니다.
#cf.
Hot Code
JVM이 실행 중 매우 자주 호출되거나 반복적으로 실행된다고 판단한 코드.
JVM은 메서드 호출 횟수나 반복문 실행 횟수 등을 관찰하다가 일정 기준을 넘으면 해당 코드를 Hot Code로 간주하여 더 빠르게 실행할 대상으로 선택함
JIT Compiler
JIT(Just-In-Time) Compiler는 “Hot Code”를 현재 CPU가 직접 실행할 수 있는 네이티브 기계어로 변환하는 컴파일러!
실행 중 수집한 정보를 바탕으로 최적화를 적용하므로, 단순히 기계어로 바꾸는 것을 넘어 더 빠르게 실행되도록 최적화할 수 있음
그러면..?
컴파일된 코드는 어디에 저장될까?
JIT가 만든 기계어는 Java Heap에 저장되지 않습니다.
HotSpot JVM은 컴파일된 기계어를 주로 Code Cache라는 네이티브 메모리 영역에서 관리합니다.

여기서 3편의 Method Metadata와 구분해야 합니다.
- Method Metadata: 메서드 이름, descriptor, 바이트코드 등 클래스 단위 정보
- Code Cache: JIT가 실제로 생성한 네이티브 기계어
즉, 메서드에 대한 정보와 최적화된 실행 코드는 같은 것이 아닙니다.
Devirtualization — 가상 호출을 더 직접적인 호출로 바꾸기
3편에서 살펴본 Dynamic Dispatch는 실제 객체를 기준으로 호출할 메서드를 결정합니다.
receiver 객체 확인
→ 실제 타입 확인
→ 오버라이딩 메서드 선택
하지만 특정 호출 지점에 계속 Child만 들어온다면 JVM은 이 호출을 더 단순하게 만들 수 있습니다.
invokevirtual Parent.introduce
↓ 실행 정보 관찰
Child.introduce 호출 경로로 최적화
이처럼 가상 호출을 직접 호출에 가까운 형태로 바꾸는 최적화를 Devirtualization이라고 합니다.
물론! 이 최적화로 인해 언어 차원에서는 Dynamic Dispatch가 Static Dispatch로 바뀌는 것이 아닙니다.
JVM은 실행 중 수집한 정보를 바탕으로 기계어 수준에서 직접 호출에 가까운 형태로 최적화할 뿐, 이러한 가정이 깨지면 Deoptimization을 통해 원래의 Dynamic Dispatch 경로로 되돌아갈 수 있기 때문입니다!
그리고 이거로 Java 소스나 바이트코드가 수정되는 것은 아닙니다. JIT가 생성한 기계어 내부에서 빠른 경로를 만드는 것입니다.
Inlining — 메서드 호출 자체를 없애기
호출 대상이 충분히 명확해지면 JVM은 메서드 본문을 호출 위치에 펼칠 수 있습니다.
class Child extends Parent {
@Override
int calculate() {
return age + 10;
}
}
원래 호출은 다음과 같습니다.
int result = p.calculate();
Inlining이 적용되면 개념적으로 다음과 같이 바뀔 수 있습니다.
int result = p.age + 10;
실제 Java 소스가 바뀌는 것은 아니지만, JIT가 만든 기계어에서는 메서드 호출과 복귀 비용을 줄일 수 있습니다.

Inlining의 장점은 호출 비용 감소만이 아닙니다. 메서드 본문이 호출자 안으로 들어오면 JVM은 더 넓은 범위에서 다음 최적화를 수행할 수 있습니다.
- 상수 전파
- 불필요한 분기 제거
- 중복 연산 제거
- 추가적인 타입 추론
그래서 Inlining은 JIT 최적화의 중요한 출발점입니다.
모든 메서드를 인라이닝하지는 않음!
메서드가 크거나 호출 경로가 복잡한데도 모두 인라이닝하면 생성되는 기계어가 지나치게 커질 수 있습니다.
JIT는 대략 다음 요소들을 고려합니다.
- 메서드 크기
- 호출 빈도
- 실제 타입의 안정성
- 인라이닝 이후 얻을 수 있는 이점
따라서 작고 자주 호출되는 메서드는 인라이닝되기 쉽지만, 크고 복잡한 메서드는 그대로 호출될 수 있습니다.
참고로 Inlining은 Java만 사용하는 기술이 아닙니다.
C++에서는 inline 키워드를 직접 사용할 수도 있지만, 최근 컴파일러는 inline이 없어도 필요하다고 판단하면 자체적으로 인라이닝을 수행하는 경우가 많습니다. Swift 역시 LLVM 최적화를 통해 비슷한 방식의 인라이닝을 수행합니다.
즉, ‘메서드 호출을 줄여 성능을 높인다’는 Inilning 아이디어는
대부분의 현대 컴파일러가 사용하는 대표적인 최적화 기법입니다.
이해가 안 간다면,
단순히 함수 호출 하나를 줄인다고 생각하기 전에, [Inside Java] 2~5편에서 살펴봤던 메서드 호출 과정을 떠올려 보면 됩니다.
메서드 하나를 호출할 때도 JVM은 새로운 Stack Frame을 준비하고, 인자를 전달하고, Local Variable Table과 Operand Stack을 사용해 실행을 이어가며, 실행이 끝나면 기존 Stack Frame으로 복귀하는 과정을 거칩니다.
또한 invokevirtual과 같은 바이트코드 명령을 통해 실제 호출 대상을 찾고(Dynamic Dispatch), 적절한 메서드의 실행 흐름으로 이동해야 합니다.
Inlining이 적용되면 JIT는 이러한 메서드 호출 경로 자체를 기계어 수준에서 상당 부분 생략할 수 있습니다.
그래서, 새로운 Stack Frame을 만들고 복귀하는 과정이나 메서드 호출에 필요한 여러 단계를 줄이고, 호출 위치에서 곧바로 메서드 본문의 기계어를 이어서 실행하는 형태로 최적화합니다.
물론 Java 소스나 .class 바이트코드가 바뀌는 것은 아닙니다.
이러한 변화는 JIT가 생성한 네이티브 기계어(Code Cache) 에서만 이루어집니다.
그래서 Inlining은 단순히 “함수 호출 하나를 없애는 최적화”가 아니라, 우리가 지금까지 살펴본 JVM의 메서드 호출 비용 자체를 줄여 주는 핵심 최적화 기법이라고 이해하면 됩니다.
예전에 Swift 특정 오픈소스가 어떻게 만들어졌는지 들여다보며 로직을 분석했을 때, 함수마다 왜 inilne이 붙어있는지 궁금했는데, 이번에 좀 그 사용 이유와 의도를 이해할 수 있게 되었네요. 인정!
Deoptimization — JVM의 추측이 틀리면 어떻게 될까?
JIT는 지금까지 수집한 실행 정보를 바탕으로 최적화합니다.
지금까지 이 호출 지점에는 Child만 들어왔다.
→ 앞으로도 Child가 들어올 가능성이 높다.
하지만 나중에 다른 타입이 들어올 수도 있습니다.
Parent p = condition
? new Child()
: new Student();
이때 기존 최적화의 가정이 깨질 수 있습니다.
JVM은 잘못된 결과를 계속 실행하지 않고, 최적화된 코드를 폐기하거나 일반 실행 경로로 되돌아갈 수 있습니다.

이를 Deoptimization이라고 합니다.
JVM의 최적화는 절대 변하지 않는 확정이 아니라,
필요하면 되돌릴 수 있는 실행 중 가정에 가깝습니다.
Java는 처음부터 빠른 것이 아니라, 실행하면서 빨라진다
지금까지의 흐름을 다시 연결해 보겠습니다.

처음 실행되는 코드는 Interpreter로 빠르게 시작합니다.
반복해서 실행되는 코드는 JIT가 기계어로 바꾸고, 실제 타입과 호출 패턴에 따라 더 공격적인 최적화를 적용할 수 있습니다.
그래서 Java 애플리케이션은 시작 직후와 충분히 실행된 이후의 성능 특성이 다를 수 있습니다. 흔히 이를 Warm-up과 연결해서 설명합니다.
핵심 정리
- JVM은 처음부터 모든 바이트코드를 기계어로 바꾸지 않습니다.
- 처음에는 Interpreter가 바이트코드를 실행할 수 있습니다.
- JVM은 메서드 호출 횟수, 반복 횟수, 실제 객체 타입 같은 실행 정보를 수집합니다.
- 자주 실행되는 코드는 Hot Code로 판단될 수 있습니다.
- JIT Compiler는 Hot Code를 CPU가 실행할 수 있는 네이티브 기계어로 변환합니다.
- JIT가 생성한 코드는 주로 Code Cache에서 관리됩니다.
- Devirtualization은 실제 호출 타입이 안정적일 때 가상 호출을 더 직접적인 경로로 최적화하는 방식입니다.
- Inlining은 메서드 본문을 호출 위치에 펼쳐 호출 비용과 최적화 경계를 줄입니다.
- 모든 메서드가 JIT 컴파일되거나 인라이닝되는 것은 아닙니다.
- JVM은 실행 정보를 바탕으로 추측하여 최적화할 수 있습니다.
- 기존 가정이 깨지면 Deoptimization을 통해 일반 실행 경로로 돌아갈 수 있습니다.
Java의 실행 성능은 단순히 Interpreter와 JIT 중 하나로 결정되지 않습니다.
JVM은 빠르게 시작하기 위해 Interpreter를 사용하고, 실제 실행 정보를 바탕으로 필요한 코드만 JIT 컴파일하며, 상황이 달라지면 최적화를 되돌릴 수 있습니다.
다음 편
지금까지는 class를 중심으로 JVM 실행 과정을 살펴봤습니다.
그런데 Java 프로젝트의 빌드 결과를 보면 interface, enum, annotation, record도 모두 .class 파일로 만들어집니다.
다음 편에서는 클래스가 아닌 문법 구조도 왜 .class가 되는지, 그리고 JVM이 이 타입들을 어떻게 바라보는지 살펴보겠습니다.
[Inside Java] 몰아보기
[Inside Java] 01. Java는 왜 바로 기계어로 컴파일하지 않을까? — 바이트코드부터 JVM 메모리까지...
Swift로 iOS 앱을 만들 때는 보통 컴파일 → 빌드 → 실행 파일 생성 → CPU 실행이라는 흐름이 비교적 자연스럽게 느껴졌습니다.C++도 비슷합니다. 소스 코드를 컴파일하면 특정 운영체제와 CPU가 이
dev-with-precious-dreams.tistory.com
[Inside Java] 02. 업캐스팅?! Java 컴파일러는 무엇을 알고, JVM은 무엇을 결정할까? — Dynamic Dispatch로
Parent p = new Child(); Java를 공부하다 보면 이 한 줄을 아주 자주 만나게 됩니다.보통은 이렇게 설명합니다.부모 타입 변수에 자식 객체를 담는 업캐스팅이다. 문법적으로는 맞는 설명입니다.그런데
dev-with-precious-dreams.tistory.com
[Inside Java] 03. Heap 객체에서 method Area까지! - Klass Pointer와 Dynamic vs Static Dispatch 살펴보기
이전 포스트(누르면 바로가져요) 에서는 다음 코드가 왜 문법적으로 가능한지 살펴봤습니다.Parent p = new Child(); 컴파일러는 표현식 p의 정적 타입을 Parent로 보고, 실제 생성되는 객체는 Child입니
dev-with-precious-dreams.tistory.com
[Inside Java] 04. JVM은 main()을 바로 실행하지 않는다?! #1 -ClassLoader, 클래스 생명주기 #Loading #Linking #In
1편에서는 Java 코드가 바이트코드로 변환되고 JVM 위에서 실행되는 전체 흐름을 살펴봤습니다. 2편에서는 컴파일러가 정적 타입을 기준으로 무엇을 검사하고, 런타임의 JVM이 실제 객체를 기준으
dev-with-precious-dreams.tistory.com
[Inside Java] 05. JVM은 main()을 바로 실행하지 않는다 #2 — class metadata는 언제 초기화 될까? #Active Use,
앞선 4장에서는 .class 파일이 JVM 내부에서 Loading, Linking, Initialization을 거쳐 실행 가능한 타입이 되는 과정을 살펴봤습니다.하지만 여기서 한 가지 궁금증이 생깁니다. “클래스가 메모리에 로드
dev-with-precious-dreams.tistory.com
[Inside Java] 07. interface는 JVM 메모리에서 어떻게 관리될까? — default 메서드와 enum 객체까지!!
이전 [Inside Java] 시리즈에서는 class로 만든 객체와 메서드 호출이 JVM 안에서 어떻게 연결되는지 살펴봤습니다.그런데 Java에는 class만 있는 것이 아니죠.....interface Animal { void speak();}enum Direction { LEF
dev-with-precious-dreams.tistory.com
[Inside Java] 08. 마무리.. Java 실행 구조 핵심 위키!
앞선 1장부터 7장까지 다룬 내용을 다시 찾기 쉽도록 핵심 개념만 두줄로 압축했습니다.자세한 동작 원리와 예시는 각 챕터에서 확인하고, 이 글에서는 개념의 연결 관계만 빠르게 정리합니다.
dev-with-precious-dreams.tistory.com
Refs
- Java Virtual Machine Specification, Chapter 2. The Structure of the Java Virtual Machine
https://docs.oracle.com/javase/specs/jvms/se21/html/jvms-2.html
- Java Virtual Machine Guide