JIT 컴파일
더 많은 작업
- 제조·물류의 JIT는 저스트 인 타임 문서를 참고.
- Just-in-time compilation; JIT 컴파일, 적시 컴파일
- 프로그램을 실행하는 도중에 필요한 부분을 기계어로 번역하는 컴파일 방식
JIT 컴파일(Just-in-time compilation)은 프로그램을 실행하기 전에 전부 기계어로 바꿔 두는 대신, 실행하는 동안 자주 쓰이는 부분을 그때그때 기계어로 번역해 실행하는 방식이다. 동적 번역(dynamic translation)이라고도 한다. 번역 대상은 소스 코드일 수도 있지만 대개는 바이트코드 같은 중간 표현이다. 이 기능을 맡는 프로그램을 JIT 컴파일러라고 한다.
JIT는 미리 컴파일하는 AOT(ahead-of-time) 방식과 한 줄씩 해석하는 인터프리터 방식을 섞은 것이다. 처음에는 해석하거나 가볍게 번역해 빨리 시작하고, 실행 중에 모은 정보(어떤 코드가 자주 도는지, 변수에 실제로 어떤 타입이 들어오는지, CPU가 어떤 명령어를 지원하는지)를 써서 뜨거운 코드만 공들여 최적화한다. Java 가상 머신, .NET, 브라우저의 JavaScript 엔진, PyPy, LuaJIT, PHP 8 등 오늘날 널리 쓰이는 런타임 대부분이 JIT를 쓴다. 정규 표현식 엔진, CPU 에뮬레이터, 리눅스 커널의 eBPF, 데이터베이스 질의 실행기에도 쓰인다.
JIT와 AOT의 "time"은 실행 시점(runtime)을 기준으로 한 말이다. 실행 시점에 맞춰 번역하면 JIT, 실행보다 앞서 번역하면 AOT다. 용어 자체는 제조업의 적시 생산(just in time)에서 빌려 왔으며, 제임스 고슬링이 1993년 무렵부터 이 말을 썼다고 밝힌 바 있다.[1]
실행 방식은 언어가 아니라 언어 구현체의 성질이다. 같은 언어라도 구현에 따라 방식이 다르다. Java는 보통 JIT로 돌지만 안드로이드 ART나 GraalVM Native Image로 AOT 컴파일할 수 있고, C도 Emscripten으로 WebAssembly로 옮겨 브라우저에서 JIT로 돌릴 수 있다. OCaml은 바이트코드 컴파일러(ocamlc)와 네이티브 컴파일러(ocamlopt)를 함께 배포한다.
| 항목 | AOT 컴파일 | 인터프리터 | JIT 컴파일 |
|---|---|---|---|
| 번역 시점 | 실행 전(빌드 때) 전체 | 번역하지 않고 실행하며 해석 | 실행 중, 필요한 부분만 |
| 시작 속도 | 빠름 | 빠름 | 느린 편(워밍업 필요) |
| 충분히 돈 뒤 성능 | 높음 | 낮음 | 높음, 경우에 따라 AOT 이상 |
| 실행 중 정보 활용 | 없음(PGO를 쓰면 빌드 때 수집한 프로파일만) | 해당 없음 | 실제 타입, 분기 빈도, CPU 기능을 반영 |
| 메모리 | 적음 | 적음 | 컴파일러와 코드 캐시만큼 더 씀 |
| 배포물의 이식성 | 플랫폼마다 따로 빌드 | 높음 | 높음(바이트코드 하나로 배포) |
| 실행 중 코드 생성 | 필요 없음 | 필요 없음 | 필요(쓰기 가능한 메모리를 실행 가능하게 바꿔야 함) |
| 대표 예 | C, C++, Rust, Go, Swift | CPython 기본 동작, Bash, 초기 BASIC | HotSpot JVM, .NET, V8, PyPy, LuaJIT |
AOT 컴파일도 CPU별 최적화를 할 수 있지만, 배포 대상 CPU를 미리 정해야 한다. 여러 CPU에서 돌리려면 공통 명령어만 쓰거나 CPU별로 따로 빌드해야 한다. JIT는 실행하는 기계의 CPU를 보고 예를 들어 SSE2나 AVX 벡터 명령어를 골라 쓸 수 있다. 반대로 AOT는 시작이 빠르고 메모리를 적게 쓰며 실행 시간이 예측 가능해서, 시작 속도가 중요한 명령줄 도구나 실시간 제어 분야에서 선호된다.
바이트코드 기반 JIT 런타임의 일반적인 흐름은 다음과 같다.
- 소스 코드를 미리(AOT) 바이트코드로 컴파일한다. 구문 분석과 기본 최적화 같은 무거운 일은 이 단계에서 끝난다.
- 런타임은 바이트코드를 인터프리터로 실행하면서 메서드 호출 횟수, 루프 반복 횟수, 타입 정보를 센다(프로파일링).
- 횟수가 문턱값을 넘은 뜨거운(hot) 코드를 JIT 컴파일러가 기계어로 번역해 코드 캐시에 넣는다. 컴파일은 대개 별도 스레드에서 돌고, 끝날 때까지는 기존 방식으로 계속 실행한다.
- 이후 호출은 캐시된 기계어로 곧장 간다. 더 뜨거워지면 더 강하게 최적화해 다시 컴파일한다.
- 최적화의 전제가 깨지면 탈최적화(deoptimization)로 느린 단계로 돌아간다.
프로그램은 실행 시간의 대부분을 코드의 일부분에서 보낸다. 그래서 전체를 번역하지 않고 뜨거운 부분만 번역해도 효과가 크고, 번역 비용도 줄어든다.
| 구분 | 메서드 JIT | 트레이싱 JIT |
|---|---|---|
| 컴파일 단위 | 함수(메서드) 하나 전체 | 실제로 실행된 경로 한 가닥(트레이스), 주로 뜨거운 루프 |
| 뜨거움 판단 | 메서드 호출 횟수, 루프 뒤로 가는 분기 횟수 | 루프 반복 횟수 |
| 분기 처리 | 제어 흐름 그래프 전체를 최적화 | 기록한 경로만 직선 코드로 만들고, 다른 경로로 가는 곳에 가드(guard)를 넣음 |
| 장점 | 예측 가능, 큰 함수·복잡한 분기에 강함 | 최적화가 쉽고, 처음 도는 함수도 루프가 뜨거우면 곧바로 빨라짐 |
| 약점 | 함수가 여러 번 불려야 최적화가 들어감 | 경로가 자주 갈리는 코드에서 트레이스가 폭증하거나 가드 실패가 잦음 |
| 예 | HotSpot, .NET RyuJIT, V8, JavaScriptCore | LuaJIT, PyPy, TraceMonkey(Firefox 3.5), PHP 8의 트레이싱 JIT |
트레이싱 JIT는 루프를 한 번 실제로 돌면서 실행한 연산을 순서대로 기록한다. 호출한 함수는 트레이스 안에 그대로 펼쳐지고(인라이닝), if 문은 "이번에도 같은 쪽으로 가는가"를 확인하는 가드로 바뀐다. 가드가 실패하면 트레이스를 빠져나와 인터프리터로 돌아간다. 아래는 개념을 보여 주는 예다.
def sq(x):
return x * x
total = 0
for i in range(1000000):
total += sq(i)
if total > 10**12:
break
#트레이스 한 가닥(개념 표기)
loop(i, total):
guard_int(i); guard_int(total) # 타입이 기록 때와 같은지 확인
t1 = int_mul(i, i) # sq(i)가 인라이닝됨
total2 = int_add(total, t1)
guard_false(total2 > 10**12) # if 문이 가드로 바뀜
i2 = int_add(i, 1)
guard(i2 < 1000000)
jump loop(i2, total2)
트레이싱의 뿌리는 1970년 제임스 G. 미첼의 관찰(인터프리터가 수행한 동작을 저장하면 컴파일된 코드를 얻을 수 있다)로 거슬러 올라간다.[1] 기계어 수준 트레이싱을 처음 구현한 것은 HP의 Dynamo(2000)로, PA-RISC 기계어를 실행 중에 다시 최적화해 일부 프로그램을 더 빠르게 만들었다.[2] 고급 언어용으로는 2006년의 HotpathVM(자원이 적은 기기용 JVM)이 초기 사례이고, LuaJIT 2.0도 트레이스 컴파일러를 채택했다. Mozilla는 트레이싱 JIT인 TraceMonkey를 Firefox 3.5(2009)에 넣었다.[3] TraceMonkey는 이후 메서드 JIT로 대체되었다.
컴파일은 비싸다. 강하게 최적화할수록 좋은 코드가 나오지만 번역 시간과 메모리가 늘어난다. 그래서 현대 런타임은 여러 단계(tier)를 두고, 코드가 뜨거워질수록 위 단계로 올린다. 이를 계층형 컴파일(tiered compilation)이라 한다.
| 런타임 | 단계(아래에서 위로) |
|---|---|
| HotSpot JVM | 인터프리터 → C1(클라이언트 컴파일러, 빠른 번역, 프로파일 수집 코드 삽입) → C2(서버 컴파일러, 강한 최적화). Java SE 7에서 도입, 서버 VM의 기본값[4] |
| .NET(CoreCLR) | 티어 0(최적화 없는 quick JIT 또는 미리 컴파일된 ReadyToRun 코드) → 티어 1(백그라운드에서 최적화 JIT). .NET Core 3.0부터 기본, .NET 6부터 동적 PGO[5] |
| V8(Chrome, Node.js) | Ignition(바이트코드 인터프리터) → Sparkplug(비최적화 기준 JIT, 2021) → Maglev(빠른 최적화 JIT, 2023) → TurboFan(최고 수준 최적화)[6] |
| SpiderMonkey(Firefox) | 인터프리터 → Baseline Interpreter → Baseline JIT → Warp(Ion 기반 최적화 JIT)[7] |
| JavaScriptCore(Safari) | LLInt(저수준 인터프리터) → Baseline JIT → DFG JIT → FTL JIT[8] |
HotSpot에서 C1은 원래 시작이 빠른 클라이언트 VM용, C2는 오래 도는 서버 VM용 컴파일러였다. 계층형 컴파일은 둘을 한 VM 안에서 이어 붙인 것이다. C1이 만든 코드가 프로파일을 수집하므로 인터프리터만으로 프로파일을 모을 때보다 워밍업 구간이 빨라진다.
JIT가 AOT보다 유리한 점은 실제 실행 정보를 보고 "앞으로도 그럴 것"이라고 추측(speculation)해 최적화할 수 있다는 것이다.
- 타입 피드백(type feedback): 동적 타입 언어에서
a + b가 지금까지 늘 정수끼리 더했다면 정수 덧셈 기계어 하나로 줄이고, 다른 타입이 오면 가드가 잡는다. Self 언어 연구에서 체계화되었다.[1] - 인라인 캐시(inline cache): 메서드 호출 지점마다 지난번에 찾은 대상 메서드를 기억해, 다음 호출 때 탐색을 건너뛴다. 한 지점에 여러 타입이 오면 다형 인라인 캐시를 쓴다.
- 인라이닝과 탈가상화: 가상 호출이라도 실제로는 한 가지 클래스만 불렸다면 그 메서드 본문을 호출 지점에 펼친다. 정적 컴파일러는 나중에 로드될 클래스를 알 수 없어서 이렇게 과감하게 하기 어렵다.
- 경계 검사 제거, 루프 불변 코드 이동, 탈출 분석(escape analysis)으로 힙 할당을 없애거나 스택으로 옮기는 최적화.
- CPU 특화: 실행 중인 CPU가 지원하는 벡터 명령어나 내장 함수(intrinsic)를 골라 쓴다.
- 동적 링크를 유지한 채 라이브러리 함수를 인라이닝할 수 있다. AOT에서는 링크 경계 때문에 막히는 최적화다.
추측이 틀리면(예: 정수만 오던 곳에 문자열이 오거나, 한 가지뿐이던 클래스에 하위 클래스가 새로 로드되면) 최적화된 코드를 계속 쓸 수 없다. 이때 런타임은 최적화된 코드의 실행 상태(레지스터와 스택에 흩어진 값)를 인터프리터나 하위 단계가 이해하는 모양으로 되돌린 뒤 그쪽에서 실행을 이어 간다. 이를 탈최적화(deoptimization)라 한다. 1992년 Self의 디버거 연구에서 최적화된 코드를 소스 수준에서 디버깅하려고 제안되었고,[9] 이후 추측 최적화를 가능하게 하는 핵심 장치가 되었다. JavaScriptCore는 이를 OSR exit이라고 부른다.[8] 탈최적화와 재최적화가 반복되면(deopt loop) 성능이 크게 떨어지므로 런타임은 실패가 잦은 추측을 포기한다.
OSR(on-stack replacement)은 이미 실행 중인 함수의 스택 프레임을 다른 버전의 코드로 바꿔 끼우는 기법이다. 함수가 한 번만 호출되었는데 그 안의 루프가 수백만 번 도는 경우, 호출 횟수만 세면 영원히 컴파일되지 않는다. OSR을 쓰면 루프 도중에 인터프리터 프레임을 최적화된 코드의 프레임으로 갈아타고 계속 돌 수 있다. 탈최적화는 그 반대 방향의 OSR이다.
JIT가 만든 기계어는 코드 캐시라는 별도 메모리 영역에 둔다. HotSpot은 코드 캐시 크기를 -XX:ReservedCodeCacheSize로 조정하며, 이 영역이 가득 차면 새 메서드를 컴파일하지 못해 성능이 떨어진다. 1980년대 Smalltalk-80 구현(Deutsch와 Schiffman)은 이미 메서드를 처음 실행할 때 기계어로 번역해 캐시해 두고, 메모리가 모자라면 기계어를 버렸다가 필요할 때 다시 만들었다.[1]
| 연도 | 사건 |
|---|---|
| 1960 | 존 매카시의 LISP 논문이 실행 중에 함수를 기계어로 번역하는 방법을 언급했다. 번역이 충분히 빨라 결과를 저장할 필요가 없다고 했다. 공개된 JIT 연구 가운데 가장 이른 것으로 흔히 꼽힌다[1] |
| 1968 | 켄 톰프슨이 QED 편집기에서 정규 표현식을 IBM 7094 기계어로 컴파일해 검색했다[10] |
| 1970 | 제임스 G. 미첼이 LC² 구현에서 인터프리터가 수행한 동작을 저장해 컴파일된 코드를 얻는 기법을 제시했다[1] |
| 1984 | Deutsch와 Schiffman의 Smalltalk-80 구현이 메서드를 처음 실행할 때 기계어로 번역하고 캐시했다. 인라인 캐시도 이 구현에서 나왔다[11] |
| 1987~1994 | Self 언어 연구진(Ungar, Chambers, Hölzle)이 적응형 최적화, 탈최적화, 타입 피드백을 발전시켰다[1]. 한때 최적화된 C의 절반 정도 속도를 냈다고 알려져 있다 |
| 1993년 무렵 | 제임스 고슬링이 Java 쪽에서 "just-in-time"이라는 말을 쓰기 시작했다[1] |
| 1999 | Sun이 Java HotSpot 성능 엔진을 공개했다(4월 27일). Self와 Strongtalk 연구진의 기술을 이었다[12] |
| 2000 | J2SE 1.3부터 HotSpot이 Sun JVM의 기본 가상 머신이 되었다[13]. HP Dynamo 발표 |
| 2002 | .NET Framework 1.0이 CIL을 JIT로 실행하는 CLR을 내놓았다 |
| 2005 | 마이크 폴이 LuaJIT를 공개했다[14] |
| 2008 | Google Chrome과 함께 V8이 나왔다. 처음에는 바이트코드 없이 소스에서 곧장 기계어를 만들었다 |
| 2009 | Firefox 3.5에 트레이싱 JIT TraceMonkey 탑재 |
| 2010 | V8 Crankshaft(런타임 프로파일러, 최적화 컴파일러, 탈최적화)[15]. 안드로이드 2.2 Dalvik VM에 JIT 추가[16] |
| 2011 | Java SE 7에 계층형 컴파일 도입 |
| 2015 | .NET Framework 4.6에 64비트 RyuJIT 탑재[17] |
| 2016 | 안드로이드 7.0이 ART에 JIT와 프로파일 기반 컴파일을 더했다[18] |
| 2017 | V8 5.9(Chrome 59)가 Ignition과 TurboFan으로 완전히 넘어가 Full-codegen과 Crankshaft를 폐기했다[19] |
| 2018 | JDK 10에서 Java로 작성한 Graal을 실험적 JIT로 쓸 수 있게 되었다(JEP 317)[20] |
| 2019 | .NET Core 3.0에서 계층형 컴파일이 기본값이 되었다 |
| 2020 | PHP 8.0에 JIT(트레이싱 JIT와 함수 JIT) 도입[21]. Firefox 83에 Warp 탑재[7] |
| 2021 | V8 Sparkplug(Chrome 91)[22], Erlang/OTP 24의 BeamAsm JIT[23], Ruby 3.1의 YJIT[24], copy-and-patch 컴파일 논문 발표[25] |
| 2023 | V8 Maglev(Chrome 117)[6] |
| 2024 | CPython 3.13에 copy-and-patch 기반 실험적 JIT(PEP 744, 기본 비활성)[26][27] |
| 구현 | 대상 | 방식과 특징 |
|---|---|---|
| HotSpot | Java, Kotlin, Scala 등 JVM 언어 | 메서드 JIT, 인터프리터 + C1 + C2 계층형 컴파일, OSR, 탈최적화. OpenJDK의 기본 VM |
| Graal | JVM, GraalVM | Java로 작성한 최적화 컴파일러. JDK 9의 JVMCI(JEP 243)로 HotSpot에 붙고, JDK 10에서 실험적 JIT로 제공되었다가 JDK 17에서 JDK 본체에서 빠졌다(JEP 410)[28]. 지금은 GraalVM이 JIT와 Native Image(AOT)를 제공한다. Truffle 프레임워크는 인터프리터를 부분 평가해 다른 언어용 JIT를 만든다 |
| OpenJ9 | JVM | IBM에서 나온 JVM. Testarossa JIT와 AOT 코드 공유 캐시 |
| RyuJIT | .NET(C#, F#, VB.NET) | CIL을 기계어로 번역. 티어 0/티어 1 계층형 컴파일, OSR, 동적 PGO. 미리 컴파일하는 ReadyToRun, 완전한 AOT인 Native AOT와 함께 쓸 수 있다. 예전 .NET Framework에는 설치 때 미리 컴파일하는 NGen이 있었다 |
| V8 | JavaScript, WebAssembly | Chrome, Node.js, Deno. JS는 Ignition → Sparkplug → Maglev → TurboFan. WebAssembly는 Liftoff(기준 컴파일러)와 TurboFan |
| SpiderMonkey | JavaScript, WebAssembly | Firefox. Baseline Interpreter, Baseline JIT, Warp. 공통 중간 표현 CacheIR로 모든 단계가 인라인 캐시 정보를 공유한다[7] |
| JavaScriptCore | JavaScript | Safari와 iOS의 모든 WebKit 기반 앱. LLInt, Baseline, DFG, FTL 네 단계[8] |
| PyPy | Python | RPython으로 쓴 인터프리터 자체를 추적하는 메타 트레이싱 JIT. 프로그램이 아니라 인터프리터를 트레이싱하므로 RPython으로 만든 다른 언어 인터프리터도 JIT를 얻는다 |
| CPython 3.13 이상 | Python | 특수화된 바이트코드 → 마이크로 연산(Tier 2 IR) → copy-and-patch로 기계어. 빌드할 때 --enable-experimental-jit로 켜며 기본은 꺼져 있다[26]. 3.14부터 Windows·macOS 공식 바이너리에 실험적 JIT가 포함된다[29]
|
| Numba | Python(NumPy 코드) | 데코레이터를 붙인 함수를 LLVM으로 기계어 번역 |
| LuaJIT | Lua 5.1 | 마이크 폴이 만든 트레이싱 JIT. SSA 기반 최적화, FFI 내장. Lua 5.1의 API와 ABI를 기준으로 하며 그 뒤 Lua 버전의 문법은 일부만 받아들였다[14] |
| PHP 8 | PHP | OPcache 확장에 들어 있는 JIT. 트레이싱 JIT와 함수 JIT 두 가지. 공식 발표로는 합성 벤치마크에서 약 3배, 일반 웹 애플리케이션은 7.4와 비슷하다[21] |
| YJIT | Ruby | Shopify가 만든 기본 블록 버전 관리(basic block versioning) 방식 JIT. Ruby 3.1에 실험적으로 들어갔다 |
| ART | 안드로이드 앱(DEX 바이트코드) | 안드로이드 4.4에서 미리보기로 나와 5.0~6.0은 설치 때 전부 AOT 컴파일했고, 7.0부터 JIT + 프로파일 기반 AOT 혼합. 앱을 JIT로 돌리며 뜨거운 메서드 목록을 모았다가, 기기가 충전 중이고 쉬고 있을 때 그 부분만 AOT 컴파일한다 |
| BeamAsm | Erlang, Elixir | Erlang/OTP 24부터 기본. BEAM 바이트코드를 로드할 때 기계어로 번역 |
| Julia | Julia | 함수를 처음 호출할 때 인자 타입별로 특수화해 LLVM으로 컴파일. 첫 실행 지연(time to first plot)이 오래 문제로 꼽혔다 |
| LLVM ORC, libgccjit | 범용 | 다른 언어 구현이 JIT 백엔드로 쓰는 라이브러리. Clang과 묶으면 C·C++도 JIT로 돌릴 수 있다(CERN의 Cling) |
| PostgreSQL | SQL | 버전 11부터 LLVM으로 식 평가와 튜플 변환(tuple deforming)을 JIT 컴파일[30] |
| 리눅스 eBPF | BPF 프로그램 | 커널 안에서 검증기를 통과한 BPF 바이트코드를 기계어로 JIT 번역 |
| 정규 표현식 | 다양함 | PCRE2의 JIT, .NET의 RegexOptions.Compiled 등. 패턴은 실행 중에야 주어지므로 AOT로 미리 번역할 수 없다
|
| 에뮬레이터 | 다른 CPU의 기계어 | QEMU의 TCG, 게임기 에뮬레이터의 동적 재컴파일러(dynarec). 원본 기계어를 바이트코드처럼 취급해 번역한다 |
이 밖에 MATLAB의 실행 엔진, Adobe Flash의 ActionScript 3 가상 머신(AVM2), Mojo(AOT와 JIT 모두 지원) 등도 JIT를 쓴다.
JIT의 대가는 시작 지연이다. 처음에는 인터프리터나 최적화가 약한 코드로 돌고, 번역하는 데 CPU 시간과 메모리를 쓴다. 이 구간을 워밍업(warm-up)이라 한다. 실행 시간이 아주 짧은 프로그램은 번역한 코드를 제대로 써 보기도 전에 끝나 버려, 같은 코드를 AOT로 컴파일한 것보다 느리게 나온다. 그래서 JIT 런타임을 벤치마크할 때는 워밍업 반복을 충분히 돌리고 측정해야 하며, Java에는 이를 대신해 주는 JMH 같은 도구가 있다. 온라인 저지처럼 짧은 실행 시간을 재는 환경에서는 트레이싱 JIT를 쓰는 PyPy가 메서드 JIT를 쓰는 JVM보다 빨라 보이는 식의 왜곡이 생기기도 한다.
워밍업이 늘 약속대로 끝나는 것도 아니다. 2017년 연구는 여러 VM에서 널리 쓰이는 마이크로벤치마크를 한 프로세스 안에서 반복 실행해 보고, VM과 벤치마크 쌍 가운데 여러 번 실행에서 일관되게 최고 성능의 정상 상태에 도달한 것이 많아야 43.5%라고 보고했다. 워밍업 뒤 오히려 느려지거나 정상 상태에 이르지 못하는 경우도 있었다.[31]
비용과 완화책은 다음과 같다.
- 메모리: 컴파일러 자체, 프로파일 데이터, 코드 캐시가 메모리를 차지한다. 메모리가 작은 기기에서는 JIT 단계를 줄이거나 끈다.
- 컴파일 스레드의 CPU 사용: 백그라운드 컴파일이 코어를 차지한다. 컨테이너처럼 CPU가 제한된 환경에서는 시작 직후 지연이 커진다.
- 시작 속도 개선: HotSpot의 클래스 데이터 공유(CDS)와 JDK 24의 AOT 클래스 로딩·링킹(JEP 483), JDK 25의 AOT 메서드 프로파일링(JEP 515), .NET의 ReadyToRun과 Native AOT, GraalVM Native Image, 안드로이드의 클라우드 프로파일, V8의 코드 캐시와 스냅숏이 있다.
- AOT로 바꾸면 잃는 것: 실행 중 클래스 로딩, 리플렉션, 동적 프록시 같은 기능이 제한되거나 설정 파일로 미리 알려 줘야 한다. 또한 실행 중 프로파일을 쓰지 못해 오래 도는 서버에서는 최고 성능이 JIT보다 낮을 수 있다. .NET의 ReadyToRun처럼 미리 컴파일한 코드로 시작하고 뜨거운 메서드만 다시 JIT로 최적화하는 혼합 방식이 절충안이다.
JIT는 실행 중에 기계어를 만들어 실행하므로, "데이터를 코드로 실행한다"는 점에서 보안 약점이 생긴다.
현대 운영체제는 메모리 페이지를 쓰기 가능과 실행 가능 중 하나만 허용하는 W^X(write xor execute) 정책과 데이터 실행 방지(DEP, NX 비트)로 주입된 코드의 실행을 막는다. JIT는 기계어를 메모리에 쓴 다음 실행해야 하므로, 쓰기와 실행이 동시에 가능한 RWX 페이지를 쓰면 공격자에게 좋은 표적이 된다. 그래서 JIT는 코드를 쓴 뒤 페이지 권한을 읽기·실행으로 바꾸고, 인라인 캐시를 고치는 등 다시 써야 할 때만 잠깐 쓰기 권한을 준다. Firefox는 이 방식을 Firefox 46(2016)에 적용했고, 현대 벤치마크에서 성능 손실은 1% 미만이었다고 밝혔다.[32] macOS는 강화된 런타임(hardened runtime)을 켠 앱이 JIT를 쓰려면 com.apple.security.cs.allow-jit 권한을 받아 MAP_JIT 메모리를 써야 한다.
코드 영역을 읽기 전용으로만 두거나 실행 중 코드 생성을 금지하는 환경(일부 게임기, 보안 정책이 강한 운영체제, 코드와 데이터 메모리가 분리된 하버드 구조 기계)에서는 기계어를 만드는 JIT를 쓸 수 없다. 이런 곳에서는 인터프리터로만 돌리거나, 기계어 대신 더 빠른 바이트코드로 번역하는 식으로 대신한다.
JIT 스프레잉(JIT spraying)은 공격자가 스크립트의 상수 값을 조작해 JIT가 만든 실행 가능 메모리에 원하는 명령어 조각을 대량으로 심는 공격이다. 명령어 중간으로 점프하면 상수가 셸코드로 해석되는 점을 노린다. 디온 블라자키스가 2010년 Black Hat DC에서 Adobe Flash의 ActionScript JIT로 DEP와 ASLR을 우회하는 방법을 발표하면서 알려졌다.[33] 대응책으로 상수 블라인딩(상수를 무작위 값과 XOR해 넣기), 코드 배치 무작위화, 명령어 사이 NOP 삽입 등이 쓰인다.
더 큰 문제는 JIT 컴파일러 자체의 버그다. 최적화기가 경계 검사를 잘못 없애거나 타입 추측을 잘못하면 샌드박스 안의 스크립트가 임의 메모리를 읽고 쓰는 코드가 만들어진다. Microsoft Edge 보안 연구팀은 2019년 이후 V8 취약점의 약 45%가 JIT와 관련되어 있었고, 실제 공격에 쓰인 Chrome 취약점의 절반 이상이 JIT 버그였다는 분석을 들어, 2021년 JIT를 끄는 실험 기능 "Super Duper Secure Mode"를 내놓았다. JIT를 끄면 RWX 메모리가 필요 없어져 렌더러 프로세스에 CET와 ACG 같은 보호 기법을 적용할 수 있다는 것도 이유였다.[34] 이 기능은 뒤에 Edge의 보안 강화 모드(Enhanced security mode)로 이어졌다. HotSpot JVM에서도 JIT 컴파일러 버그로 인한 보안 취약점이 여러 번 보고되었다.
스펙터 취약점 논문은 브라우저에서 JIT로 컴파일되는 JavaScript만으로 같은 프로세스의 메모리를 읽어 내는 공격을 시연했다.[35] 이에 브라우저들은 고해상도 타이머의 정밀도를 낮추고, 사이트별 프로세스 격리를 강화하고, JIT가 만드는 코드에 투기 실행 차단 명령을 넣는 조치를 했다.
- iOS·iPadOS: App Store 심사 지침은 앱이 기능을 바꾸는 코드를 내려받아 실행하는 것을 금지하고, 웹을 탐색하는 앱은 WebKit을 쓰도록 요구한다.[36] 일반 앱은 실행 중에 기계어 페이지를 만들 권한이 없어 JIT를 쓸 수 없고, 애플이 서명한 WebKit 프로세스만 JavaScript JIT를 쓴다. 그래서 iOS용 Chrome, Firefox도 오랫동안 자체 엔진 대신 WebKit을 썼다. 다만 JIT만이 이유는 아니며, 애플은 EU와 일본에서만 별도 권한을 받은 브라우저에 다른 엔진을 허용한다.[36] 같은 이유로 JIT에 기대는 게임기 에뮬레이터나 가상 머신 앱은 iOS에서 인터프리터 방식으로 느리게 돌거나 아예 나오지 않는 것으로 알려져 있다.
- 잠금 모드(Lockdown Mode): iOS 16, macOS Ventura부터 있는 잠금 모드를 켜면 사용자가 예외로 둔 사이트를 빼고 WebKit의 JavaScript JIT가 꺼진다.[37]
- macOS: 권한만 받으면 서드파티 앱도 JIT를 쓸 수 있어 Chromium, Gecko 엔진 브라우저가 자유롭게 돈다.
- ↑ 1.0 1.1 1.2 1.3 1.4 1.5 1.6 1.7 John Aycock, "A Brief History of Just-In-Time", ACM Computing Surveys 35(2), 2003
- ↑ Bala, Duesterwald, Banerjia, "Dynamo: A Transparent Dynamic Optimization System", PLDI 2000
- ↑ Mozilla Hacks, "an overview of TraceMonkey", 2009
- ↑ Oracle, Java HotSpot Virtual Machine Performance Enhancements (Java SE 7)
- ↑ Microsoft Learn, Compilation config settings
- ↑ 6.0 6.1 V8 blog, "Maglev: V8's Fastest Optimizing JIT"
- ↑ 7.0 7.1 7.2 Mozilla Hacks, "Warp: Improved JS performance in Firefox 83", 2020
- ↑ 8.0 8.1 8.2 WebKit blog, "Speculation in JavaScriptCore", 2020
- ↑ Hölzle, Chambers, Ungar, "Debugging optimized code with dynamic deoptimization", PLDI 1992
- ↑ Ken Thompson, "Regular expression search algorithm", CACM 11(6), 1968
- ↑ L. Peter Deutsch, Allan M. Schiffman, "Efficient implementation of the Smalltalk-80 system", POPL 1984
- ↑ HPCwire, "Sun Anncs Availability of the Java HotSpot Performance Engine", 1999-04-30
- ↑ Oracle, Java HotSpot Client VM 및 Server VM (J2SE 1.3 문서)
- ↑ 14.0 14.1 LuaJIT 공식 사이트
- ↑ Chromium Blog, "A New Crankshaft for V8", 2010-12-07
- ↑ Android Developers Blog, "Dalvik JIT", 2010
- ↑ .NET Blog, "The RyuJIT transition is complete!"
- ↑ Android Developers, Android 7.0 for Developers
- ↑ V8 blog, "Launching Ignition and TurboFan", 2017-05-15
- ↑ JEP 317: Experimental Java-Based JIT Compiler
- ↑ 21.0 21.1 PHP 8.0 Release Announcement
- ↑ V8 blog, "Sparkplug: a non-optimizing JavaScript compiler", 2021
- ↑ Erlang/OTP 24 Highlights
- ↑ Ruby 3.1.0 Released, 2021-12-25
- ↑ Haoran Xu, Fredrik Kjolstad, "Copy-and-patch compilation", OOPSLA 2021
- ↑ 26.0 26.1 What's New In Python 3.13
- ↑ PEP 744: JIT Compilation
- ↑ JEP 410: Remove the Experimental AOT and JIT Compiler
- ↑ What's New In Python 3.14
- ↑ PostgreSQL Documentation, What Is JIT compilation?
- ↑ Barrett, Bolz-Tereick, Killick, Mount, Tratt, "Virtual machine warmup blows hot and cold", OOPSLA 2017
- ↑ Jan de Mooij, "W^X JIT-code enabled in Firefox", 2015
- ↑ Piotr Bania, "JIT spraying and mitigations", 2010
- ↑ Microsoft Browser Vulnerability Research, "Super Duper Secure Mode", 2021-08-04
- ↑ Kocher et al., "Spectre Attacks: Exploiting Speculative Execution", IEEE S&P 2019
- ↑ 36.0 36.1 Apple, App Store Review Guidelines (2.5.2, 2.5.6)
- ↑ Apple 개인 안전 사용 설명서, Lockdown Mode로 기기 강화하기