사전 JIT 컴파일
개념

JIT 컴파일

gabury1고친 사람 github-actions[bot]

JIT 컴파일은 프로그램이 도는 도중에 코드를 그 기계의 명령으로 번역합니다. 실행 전에 전부 번역해 두는 대신, 자주 지나가는 구간만 골라 번역하고 다음부터는 번역해 둔 것을 부릅니다. 그래서 프로그램은 켜자마자 돌기 시작하고, 오래 돌수록 빨라집니다.

쉽고 빠른 이해

JIT 컴파일은 실행 도중에 코드를 기계의 명령으로 번역해 두는 일입니다. 같은 반복문을 백만 번 도는 프로그램이 처음 몇 바퀴보다 나중 바퀴를 빨리 도는 것이 그 결과입니다.

미리 전부 번역해 두면 아직 한 번도 안 돌려 본 코드까지 짐작으로 번역해야 합니다. 거꾸로 번역을 아예 안 하면 같은 곳을 만 번 지날 때 만 번 다 해석해야 합니다.

  1. 처음에는 명령을 하나씩 읽어 가며 돌립니다
  2. 어느 구간을 몇 번 지났는지 세어 두고 유난히 잦은 것을 고릅니다
  3. 그 구간만 기계 명령으로 번역해 두고 다음부터는 번역본을 부릅니다

대가는 번역하는 일이 프로그램이 도는 도중에 끼어든다는 것입니다. 그 몫의 시간과 메모리를 쓰므로, 짧게 돌다 끝나는 프로그램은 번역에 들인 값을 회수하지 못합니다.

상세

외국 손님이 오는 가게를 떠올려 봅시다. 메뉴판을 통째로 번역해 두고 장사를 시작하는 방법이 있고, 손님이 물을 때마다 옆에서 한 마디씩 옮겨 주는 방법이 있습니다. 세 번째 방법은 장사를 하면서 자주 나오는 질문만 골라 그때그때 적어 붙여 두는 것입니다.

JIT 컴파일이 세 번째입니다. 프로그램을 돌리면서 자주 지나가는 구간을 골라, 그 기계가 바로 알아듣는 기계어로 번역해 둡니다. 다음부터는 번역해 둔 것을 실행합니다.

이름은 실행 전에 미리가 아니라 필요해진 때에 맞춰 번역한다는 뜻입니다. 영어로는 Just In Time compilation 이고, 줄여서 JIT 컴파일이라고 씁니다.

번역하는 때

코드를 기계 명령으로 옮기는 일을 언제 하느냐로 실행 방식이 갈립니다. 한쪽 끝은 실행 전에 번역을 다 끝내는 AOT(Ahead Of Time, 미리) 컴파일입니다.

반대쪽 끝은 번역을 아예 하지 않는 방식입니다. 명령을 하나씩 읽어 가며 그때그때 돌리는 일을 해석이라고 하고, 해석해서 프로그램을 돌리는 것을 인터프리터라고 부릅니다. JIT 컴파일은 그 사이에 섭니다. 셋을 나란히 놓으면 어느 칸이 비어 있었는지 보입니다.

방식 번역하는 때 켜자마자 오래 도는 프로그램
AOT 컴파일 실행 전에 전부 바로 제 속도가 난다 돌려 본 결과를 못 쓴다
인터프리터 번역하지 않는다 바로 돌기 시작한다 지날 때마다 다시 해석한다
JIT 컴파일 실행 도중에 골라서 해석으로 시작한다 자주 도는 구간이 기계어가 된다

마지막 줄이 JIT 컴파일입니다. 켜자마자 도는 것은 인터프리터에서 빌려 왔습니다. 오래 도는 구간이 기계어가 되는 것은 AOT 컴파일에서 빌려 왔습니다.

바이트코드에서 기계어로

번역거리로 들어오는 것은 대개 바이트코드입니다. 사람이 쓴 소스를 미리 한 번 옮겨 둔 중간 형태입니다. 어느 기계에서나 같은 모양입니다.

들어오는 것이 바이트코드만은 아닙니다. 소스를 바로 받기도 합니다. 코드를 받아 실제로 돌리는 프로그램(실행기)이 자기만 쓰는 중간 표현으로 한 번 더 옮겨 놓고 그것을 받기도 합니다.

번역해서 나온 기계 명령은 파일로 안 나갑니다. 프로그램이 쓰는 메모리 안의 한 구역에 쌓입니다. 그 구역을 코드 캐시라고 부르고, 프로그램이 끝나면 코드 캐시도 같이 사라집니다.

번역을 맡는 부분은 JIT 컴파일러라고 부릅니다. 무엇이 들어와 무엇이 나오고, 나온 것이 어디에 사는지를 한 장에 놓으면 이렇습니다.

flowchart TD
    A["바이트코드"] --> J["JIT 컴파일러"]
    B["소스 코드"] --> J
    C["중간 표현"] --> J
    J --> M
    subgraph P["프로그램이 쓰는 메모리 · 프로그램이 끝나면 같이 사라진다"]
        subgraph K["코드 캐시"]
            M["번역된 기계 명령"]
        end
    end

번역거리는 세 갈래로 들어오고 나가는 곳은 하나입니다. 번역본은 디스크의 파일이 아니라 돌고 있는 프로그램의 메모리 안에 얹혀 삽니다.

같은 프로그램을 다시 켜면 번역을 처음부터 합니다. 앞 실행에서 무엇이 자주 돌았는지는 남지 않습니다.

자주 도는 구간 고르기

들어온 코드를 전부 번역하면 번역하느라 쓴 시간이 아껴 준 시간을 넘깁니다. 대부분의 코드는 몇 번 돌고 맙니다. 그것까지 번역하면 남는 것이 없습니다.

그래서 세어 봅니다. 메서드가 몇 번 불렸는지, 반복문이 몇 번 뒤로 돌아갔는지를 실행기가 기록해 둡니다. 그 수가 정해 둔 선을 넘은 것만 번역 대상으로 올립니다. 이렇게 뽑힌 구간을 핫스팟이라고 부릅니다.

Java
for (int i = 0; i < 1_000_000; i++) {
    total += price(order);   // 백만 번 불린다
}

위에서 price 는 한 번 실행에 백만 번 불립니다. 이런 메서드가 먼저 번역됩니다. 옆에 있어도 한 번만 불리는 메서드는 끝까지 해석으로 남습니다.

부를 때마다 세다가 선을 넘으면 번역으로 넘어가는 흐름은 이렇습니다. 아직 선을 못 넘은 메서드가 해석으로 돌아가 계속 세어지는 고리가 아래쪽에 있습니다.

flowchart TD
    A["메서드를 부른다"] --> B["몇 번 불렸는지 센다"]
    B --> C{"정해 둔 선을 넘었나"}
    C -->|아직| D["명령을 하나씩 해석한다"]
    C -->|넘었다| E["그 메서드를 기계어로 번역한다"]
    E --> F["다음 호출부터 번역본을 실행한다"]
    D --> A

돌려 보고 나서 얻는 정보

AOT 컴파일은 코드만 보고 판단합니다. 어느 분기로 자주 갈지, 어떤 값이 들어올지를 짐작해야 합니다. JIT 컴파일은 이미 돌려 본 뒤라서, 짐작해야 했던 것을 실제로 보고 정합니다.

실행하면서 이런 것을 모아 두는 일을 프로파일링이라고 합니다. 어느 분기가 거의 안 타는지, 한 호출 지점에 늘 같은 종류의 값만 오는지 같은 것이 모입니다.

모은 정보는 번역할 때 쓰입니다. 작은 메서드가 아주 자주 불리면 그 몸통을 부르는 쪽 코드에 그대로 옮겨 붙여 호출 자체를 없앨 수 있는데, 이것을 인라이닝이라고 합니다. 거의 안 타는 분기는 번역본에서 빼 둡니다.

핵심은 이 판단들이 전부 가정 위에 서 있다는 것입니다. 「이 호출에는 늘 같은 종류의 값이 온다」를 참으로 놓고 번역한 코드는, 그 가정이 유지되는 동안만 맞습니다.

짐작이 빗나갔을 때의 되돌림

가정은 깨질 수 있습니다. 한 시간 동안 같은 종류만 오던 호출에 다른 종류가 들어오는 일이 생깁니다.

그래서 번역본 앞에 가정을 확인하는 짧은 검사를 붙여 둡니다. 검사를 통과하면 번역본을 그대로 실행합니다. 통과하지 못하면 그 번역본을 버리고 해석으로 돌아갑니다. 이 되돌림을 탈최적화라고 부릅니다.

되돌아간 구간은 다시 잦아지면 번역 대상으로 또 올라갑니다. 이번에는 깨진 가정을 빼고 번역하므로, 같은 곳에서 되돌림이 되풀이되지는 않습니다.

해석으로 돌기, 가정을 건 번역본으로 돌기, 가정이 깨져 해석으로 돌아오기 — 이 셋을 오가는 것이 전부입니다. 되돌아온 구간이 다시 잦아지면 「정해 둔 선을 넘었다」 화살표를 한 번 더 탑니다.

stateDiagram-v2
    state "명령을 해석하며 돈다" as A
    state "가정을 걸고 번역한다" as B
    state "번역본으로 돈다" as C
    A --> B: 정해 둔 선을 넘었다
    B --> C: 번역을 마쳤다
    C --> A: 걸어 둔 가정이 깨졌다

워밍업과 그 대가

프로그램을 켠 직후에는 아직 아무것도 번역되지 않았습니다. 얼마간 돌아 봐야 자주 도는 구간이 드러납니다. 거기부터 번역이 붙습니다. 제 속도가 날 때까지를 워밍업이라고 합니다.

벤치마크로 성능을 잴 때 앞 구간을 버리고 재는 까닭이 여기 있습니다. 워밍업이 섞인 수치는 그 프로그램의 속도가 아니라 번역이 덜 붙은 동안의 속도입니다.

대가는 셋입니다. 번역하는 일이 프로그램과 같은 프로세스 안에서 돌아 프로세서 시간을 나눠 씁니다. 번역해 둔 기계 명령과 모아 둔 실행 정보는 메모리를 차지합니다. 같은 코드를 켤 때마다 번역을 다시 합니다.

그래서 몇 초 만에 끝나는 명령줄 도구는 번역에 들인 값을 돌려받지 못합니다. 새로 뜬 프로세스가 곧바로 일을 받아야 하는 콜드 스타트가 잦은 환경도 마찬가지입니다. 이 방식이 가장 유리한 쪽은 같은 코드를 오래 되풀이해 도는 서버입니다.

미리 번역해 두는 방식과의 경계

AOT 컴파일과 가르는 선은 번역 시점 하나입니다. 하는 일은 같습니다. 다른 것은 무엇을 알고 번역하는지, 그리고 그 값을 누가 치르는지입니다. AOT 컴파일은 실행하기 전에 그 값을 치르고, JIT 컴파일은 돌고 있는 프로그램이 도는 동안 치릅니다.

둘을 한 실행기 안에서 같이 쓰기도 합니다. 시작 구간은 미리 번역해 둔 코드로 넘깁니다. 오래 도는 구간만 실행 중에 다시 번역해 더 공들인 코드로 바꿉니다. 번역 단계를 여럿 두고 자주 돌수록 공들인 단계로 올리는 방식을 계층형 컴파일이라고 부릅니다.

인터프리터와도 갈라서지 않습니다. 번역본이 붙은 뒤에도 아직 안 뽑힌 구간과 되돌아온 구간은 계속 해석으로 돕니다. 한 프로그램 안에서 두 방식이 나란히 도는 것이 보통입니다.

이 방식은 한 언어의 것이 아닙니다. 가상 머신 위에서 도는 언어, 브라우저가 스크립트를 돌리는 엔진, 데이터베이스의 질의 실행기, 정규식 엔진이 모두 같은 수를 씁니다. 프로그램이 도는 동안, 곧 런타임에 같은 코드를 되풀이해 실행하는 환경이면 어디서나 값이 납니다.

관련 항목

JIT 컴파일과 맞세워지는 실행 방식

인터프리터 · AOT 컴파일 · 컴파일러 · 트랜스파일러 · 어셈블러 · 계층형 컴파일 · 인터프리트 후 컴파일

JIT 컴파일이 입력으로 받는 코드 표현

바이트코드 · 중간 표현 · 기계어 · 소스 코드 · 추상 구문 트리 · 클래스 파일

JIT 컴파일을 품는 실행 환경

JVM · 가상 머신 · 런타임 · 자바스크립트 엔진 · V8 · HotSpot · PyPy · CLR · GraalVM

JIT 컴파일이 실행 중에 적용하는 최적화 기법

인라이닝 · 탈출 분석 · 루프 언롤링 · 상수 접기 · 죽은 코드 제거 · 레지스터 할당 · 컴파일러 최적화

JIT 컴파일이 번역 대상을 고르려고 모으는 정보

프로파일링 · 프로파일러 · 핫스팟 · 호출 카운터 · 타입 프로파일 · 분기 정보

JIT 컴파일이 치르는 대가와 거기서 생기는 문제

워밍업 · 코드 캐시 · 탈최적화 · 콜드 스타트 · 벤치마크 · 메모리 사용량

JIT 컴파일과 나란히 도는 런타임 기능

가비지 컬렉션 · 클래스 로더 · 동적 바인딩 · 리플렉션 · 스택 머신 · 힙

다른 이름: JIT · JIT 컴파일러 · just-in-time compilation · 적시 컴파일 · 동적 컴파일