실행 엔진은 클래스 로더를 통해 런타임 데이터 영역에 배치된 바이트코드를 해석하거나 컴파일하여 실행하는 모듈이다.
.class는 JVM이 이해할 수 있는 중간 레벨로 컴파일된 코드다. 그래서 실행 엔진은 인터프리터와 JIT컴파일러 두 가지 방식을 혼합해서 바이트코드를 실행한다.
인터프리터는 바이트코드를 한 줄씩 읽어 네이티브 코드로 변환하고 즉시 실행하는 방식이다.
네이티브코드 : CPU와 운영체제가 직접 이해하고 실행할 수 있는 기계어 수준의 코드
JVM안에서 바이트코드는 기본적으로 인터프리터 방식으로 동작한다.
컴파일 과정 없이 바로 실행되서 초기 실행 속도는 빠르지만, 반복 실행 시에는 매번 해석 과정을 반복해야해서 속도가 느리다. 그리고 중복되는 바이트코드를 매번 컴파일 하기 때문에 비효율적이다.
JIT 컴파일러는 위의 Interpreter의 단점을 보완하기 위해 도입된 모듈이다.
JVM은 실행 중 메서드 호출 횟수와 루프 반복 횟수를 기반으로 Hot Spot을 탐지하고, Hot Spot을 네이티브 코드로 컴파일해서 이후 실행에서는 인터프리터를 거치지 않고 직접 실행한다.
c1 컴파일러는 JIT 컴파일러가 시작되는 초반에 성능을 끌어올리기 위해 만들어진 컴파일러다. (클라이언트 컴파일러라고도 불린다.)
C1은 인터프리터와 C2 사이의 빈틈을 메우기 위해 존재한다고 보면 된다.
C1이 없으면 JVM은 초반이 너무 느리거나, 초반에 너무 비싸다.
C2 컴파일러는 JIT컴파일러의 전반적인 성능을 최적화하기 위해 만들어진 컴파일러다. (서버 컴파일러라고도 불린다.)
C2컴파일러는 C1컴파일러가 수집한 프로파일링 데이터(호출 빈도, 분기 패턴 등)를 바탕으로 최적화를 수행한다. 인라이닝, 루프 최적화, 탈출 분석 등 고급 최적화 기법을 적용해서 C1보다 훨씬 더 최적화된 네이티브 코드를 생성한다.
컴파일 시간은 더 오래 걸리지만, 생성된 코드는 장기적으로 높은 성능을 제공한다고 보면 좋을것 같다.

코드 캐시는 JVM 내부에 존재하는 네이티브 메모리 영역이다.
이 곳에는 JIT컴파일 코드가 저장된다. (JIT 컴파일된 코드, 인터프리터 관련 코드, JVM 자체의 네이티브코드 등)
코드 캐시의 크기와 동작 방식
JVM 시작 시, 코드 캐시는 설정된 값으로 최대 크기가 고정되서 확장할 수 없다. 코드 캐시가 꽉 차면 그 때부터 더 이상 JIT 컴파일이 안 되고, 컴파일되지 않은 코드는 인터프리터로만 실행된다.
이 과정에서 JVM은 Code Cache Sweeper를 통해 사용되지 않는 네이티브 코드를 제거한다.
Code Cache Sweeper
Code Cache Sweeper는 주기적으로 실행되고, 더 이상 사용하지 않는 코드, 역최적화된 코드, 오래된 컴파일 결과를 제거해서 코드 캐시 공간을 확보한다.
네이티브 코드가 코드캐시에서 제거 되는 상황
역최적화될 때, 다른 컴파일 버전으로 교체됐을 때, (단계별 컴파일), 메서드를 지닌 클래스가 언로딩될 때 네이티브 코드가 코드 캐시에서 제거된다.
역최적화란?
JIT 컴파일러가 "이 코드는 이렇게 실행될 것" 이라고 가정하고 최적화한 결과가 실행 중에 깨졌을 때, JVM이 해당 코드를 다시 덜 최적화된 상태로 되돌리는 과정이다.
Non-method: 이 영역에는 JVM 내부 코드, JIT 컴파일러 자체의 코드, 바이트코드 등 메서드가 아닌 코드가 포함되어 있다.
이러한 코드는 JVM이 실행되는 동안 계속 필요하므로 코드 캐시에서 영구적으로 유지된다. (크기는 3MB로 고정되어 있다.)
Profiled: 이 영역에는 C1 컴파일러에 의해 컴파일된 메서드가 저장된다.
이 메서드들은 기본적인 최적화만 적용되었고, 실행 중에 성능 데이터(호출 빈도, 분기 패턴 등)를 수집한다.
수명이 짧은 이유는 자주 사용되지 않으면 캐시에서 제거되고, 핫스팟으로 판단되면 더 높은 수준의 최적화를 거쳐 Non-profiled 영역으로 이동하기 때문이다.
Non-profiled: 이 영역에는 C2 컴파일러에 의해 완전히 최적화된 메서드가 저장된다.
Profiled 영역에서 수집된 성능 데이터를 바탕으로 인라이닝, 루프 최적화, 탈출 분석 등 최적화가 적용된다. 이 메서드들은 더 이상 프로파일링이 없고, 애플리케이션의 핫스팟으로 판단되서 오래 사용될 것으로 예상되서 수명이 길다.
OSR은 실행 중인 메서드를 인터프리터 모드에서 컴파일 모드로 전환하는 기법이다.
JVM의 기본 컴파일 단위는 전체 메서드이기 때문에, 한 메서드의 바이트코드는 한꺼번에 네이티브 코드로 컴파일된다. But 메서드 자체는 자주 호출되지 않지만 내부에 핫 루프가 있는 경우가 존재한다. 이래서 OSR이 필요!
동작 방식은 먼저 인터프리터가 메서드 실행 중 루프를 발견하고, 루프가 핫스팟으로 판단되면 루프를 컴파일한다. 컴파일이 완료되면 실행 중인 스택 프레임을 그대로 유지하면서 컴파일된 코드로 교체하고, 루프는 컴파일 모드로 계속 실행된다.

JVM은 자바 클래스를 내부적으로 C++의 klass 구조체로 표현한다.
klass 안에는 해당 클래스의 메서드 정보를 담은 vtable(virtual method table) 이 있다.
vtable은 메서드 → 실제 실행 코드 주소(네이티브 코드 메모리주소 or 인터프리터 코드)를 가리키는 테이블이다.
즉, JVM은 메서드를 직접 실행하지 않고 vtable이 가리키는 주소를 따라가서 코드를 실행한다.
처음에는 이 주소가 인터프리터 코드를 가리킨다.
메서드가 자주 실행되어 핫 메서드로 판단되면, JIT 컴파일러가 해당 메서드를 네이티브 코드로 컴파일하고 이를 Code Cache에 저장한다.
컴파일이 끝나면 JVM은 klass 안의 vtable에 들어 있는 메서드 실행 주소를 인터프리터 코드 → 네이티브 코드로 교체한다.
이 주소를 바꾸는 동작을 포인터 스위즐링(pointer swizzling) 이라고 한다.
인라인은 메서드 호출을 코드로 직접 삽입해서 실행 속도를 높이는 최적화 기법이다.
메서드 호출 시 스택에 정보를 저장하고 파라미터를 전달하는 등의 과정에서 오버헤드가 발생한다. 객체 지향 설계를 따르면 작은 메서드가 많아져 호출 횟수가 증가하는데, JVM은 이를 자동으로 인라인 처리하여 최적화한다.
ex)
addNumbers(1,2) 메서드 호출이 있고 해당 메서드가 a + b를 반환한다면, 컴파일 후에는 1 + 2로 직접 계산되어 메서드 호출 자체가 사라진다.
인라인 적용 기준은 메서드의 호출 빈도와 크기다. 자주 호출되는 핫스팟 메서드고, 크기가 작을수록 인라인하기 적합하다. 인라인은 디폴트로 활성화되어 있고, -XX:MaxFreqInlineSize=<n>, -XX:MaxInlineSize=<n> 옵션으로 조정할 수 있다.
탈출 분석은 C2 컴파일러가 객체의 스코프를 분석해서, 객체가 메서드나 스레드를 벗어나지 않으면 스택에 할당하거나 동기화를 제거하는 최적화 기법이다.
GlobalEscape: 객체가 메서드 외부에서도 참조되는 경우 (ex: 필드에 저장), 힙에 할당되어 GC 대상이 된다.
ArgEscape: 객체가 메서드 인자로 전달되지만 호출 종료 후 참조가 유지되지 않는 경우, 스택에 할당된다.
NoEscape: 객체가 메서드 내에서만 사용되는 경우, 스택에 할당된다.
예를 들어 루프 내에서 Factorial 객체를 생성하고 사용하는 경우, 해당 객체는 루프 내에서만 존재하므로 NoEscape 상태다. JVM은 이 경우 두 가지 최적화를 수행한다.
synchronized 메서드라도 다른 스레드가 접근할 수 없으므로 동기화 락 제거JVM은 C1과 C2 컴파일러의 장점을 모두 활용하기 위해 계층적 컴파일이라는 전략을 사용한다.
계층적 컴파일은 메서드가 얼마나 자주 호출되는지에 따라 컴파일 수준을 점진적으로 높여가는 방식이다.
Level0: 인터프리터 모드로 실행 (프로파일링 정보 수집)
Level1: C1 컴파일러로 간단하게 컴파일 (최적화 없음)
Level2: C1 컴파일러로 제한적인 최적화와 함께 컴파일
Level3: C1 컴파일러로 모든 최적화를 적용해서 컴파일
Level4: C2 컴파일러로 최대 수준의 최적화를 적용해서 컴파일