0. Intro
Java와 Kotlin은 compile 방식으로 해석되고 있다.
compiler로 해석된 자바 바이트 코드는 JVM의 interpreter로 기계어로 번역되어 실행된다.
Java나 Kotlin은 compile 방식을 택했고 JVM은 왜 interpreter 방식을 택했을까?
이 둘의 차이점은 무엇일까?
1. Compiler
compiler는 소스코드를 기계어로 해석한다.
- 소스 코드 읽기: 컴파일러는 소스 코드 파일을 읽는다.
- 어휘 분석: 소스 코드를 토큰이라는 작은 요소로 분해한다.
- 이 단계에서 컴파일러는 주석, 공백, 탭 문자 등을 제거하고, 키워드, 연산자, 식별자, 리터럴 등의 토큰으로 분류한다.
- 구문 분석: 토큰들을 구조화 하여 추상 구문 트리를 생성한다.
- 이 과정에서 문법에 따라 토큰들을 조합하여 프로그램의 구조를 나타내는 트리 형태의 데이터 구조를 생성한다.
- 의미 분석: 추상 구문 트리를 검사하여 프로그램의 의미를 검증한다.
- 타입 검사 변수의 정의 및 사용 검사, 함 수 호출의 유효성 검사 등이 수행된다. 오류가 발견되면 컴파일러는 에러 메시지를 생성하고 컴파일 과정을 중단한다.
- 중간 코드 생성: 의미 분석이 완료되면 추상 구문 트리를 중간 표현으로 변환한다.
- 중간 코드는 소스 코드와 기계어 사이에 있는 중간 단계로 컴파일러에 의해 추가적인 최적화가 수행될 수 있다.
- 최적화: 중간 코드에 대한 다양한 최적화 기법을 적용하여 프로그램의 실행 효율을 향상 시킨다.
- 이 과정에서 불필요한 코드 제거, 상수 폴딩, 루프 최적화 등의 작업이 수행된다.
- 기계어 생성: 최적화된 중간 코드를 기계어 (바이너리 코드)로 변환한다.
- 이 단계에서 기계어 명령어를 생성하고 메모리 할당, 레지스터 할당 등과 같은 저수준 작업을 수행한다.
- 링킹: 기계어로 변환된 코드에 필요한 외부 라이브러리, 함수, 모듈 등을 연결한다.
Compiler의 가장 큰 특징은 코드 전체를 통째로 해석한다.
실행 파일을 생성하기 때문에 보안적으로 소스 코드가 직접 외부에 노출 되지 않아 안전하다.
JIT Compiler 같은 경우는 캐싱을 하기 때문에 자주 호출 된다면 Interpreter 보다 좋은 perfomance를 보여줄 수 있다.
2. Interpreter
- 소스 코드 읽기: 소스 코드를 한 줄씩 읽는다.
- 어휘 분석 : 소스 코드를 토큰이라는 작은 요소로 분해한다.
- 이 단계에서 컴파일러는 주석, 공백, 탭 문자 등을 제거하고, 키워드, 연산자, 식별자, 리터럴 등의 토큰으로 분류한다.
- 구분 분석: 토큰들을 구조화 하여 추상 구문 트리를 생성한다.
- 이 과정에서 문법에 따라 토큰들을 조합하여 프로그램의 구조를 나타내는 트리 형태의 데이터 구조를 생성한다.
- 의미 분석: 추상 구문 트리를 검사하여 프로그램의 의미를 검증한다.
- 타입 검사 변수의 정의 및 사용 검사, 함 수 호출의 유효성 검사 등이 수행된다. 오류가 발견되면 컴파일러는 에러 메시지를 생성하고 컴파일 과정을 중단한다.
- 실행: 의미가 분석 되면 추상 구문 트리를 순차적으로 실행한다.
- 이 과정에서 프로그램의 각 문장이 실행되고 변수 값이 계산되며, 함수가 호출된다. 인터프리터는 소스 코드를 한 줄씩 실행하므로, 실행 시점에서 소스 코드에 대한 변경사항이 즉시 반영된다.
Interpreter는 소스 코드를 한 줄씩 읽어들이기 때문에 전체적인 속도는 compiler에 비해 느릴 수 있지만 소스 코드의 수정이 빈번하게 발생하는 경우 유리할 수 있다.
3. Compiler vs Interpreter
컴파일러
- 장점
- 컴파일이 완료된 실행 파일은 컴퓨터에서 빠르게 실행할 수 있기 때문에 효율적이다.
- 0과 1로 된 기계어로 번역되기 때문에 프로그램의 코드가 유출되지 않는다.
- 컴파일 에러(문법적 에러)와 관련된 에러를 초기에 발견할 수 있다.
- 단점
- 코드를 수정하면 컴파일을 다시 해야 한다.
- 소스 파일 전체를 컴파일해야 하므로 용량이 크다. 따라서 수정 사항이 빈번할 경우 문제가 발생할 수 있다.
- 모든 소스 파일을 한꺼번에 번역하기 때문에 컴파일 시간이 비교적 느리다.
- 목적 파일 생성을 위해 메모리를 사용한다.
- 특정 시스템에서 만들어진 실행 파일이 다른 시스템에서는 실행되지 않는 경우가 많다.
인터프리터
- 장점
- 메모리를 사용하지 않는다.
- 시스템 간의 이식성이 뛰어나다.
- 전체 코드를 다시 컴파일할 필요가 없기 때문에 코드 수정에 용이하다.
- 단점
- 매번 번역 과정을 거쳐야 하기 때문에 실행 속도가 컴파일러에 비해 느리다.
- 중간 코드로 해석되기 때문에 프로그램의 코드가 유출될 수 있다.
4. JIT Compile
자바는 코드를 실행하기 위해서 바이트코드로 컴파일하는 과정과 바이트코드를 인터프리트하는 과정을 거쳐야 하기 때문에 컴파일 과정만 필요한 다른 프로그래밍 언어보다 느리다. 거기에 더하여 인터프리터는 컴파일러보다 느리기 때문에 성능 문제기 발생할 수밖에 없었다.
이러한 문제를 개선하기 위해 나온 것이 JIT 컴파일러이다. 원래 자바의 JVM에서는 인터프리터 방식만 사용했다.
하지만 성능 문제가 발생했고 JIT 컴파일러를 추가해서 성능을 올리게 되었다.
JVM 이란?
JVM은 Just-In-Time 이라는 compiler를 이용하여 자바 바이트 코드로 먼저 컴파일 한 뒤 Interpreter를 이용하여 읽어 들이기 때문에 실행 속도를 개선하고, 기계어 번역을 하기 때문에 디버깅이 용이하다.
JIT Compiler는 한 번 컴파일 된 실행파일을 캐싱하여 저장하기 때문에 자주 호출되는 경우 캐시를 가져와 유리하게 성능을 가져갈 수 있다.
JIT 컴파일러 동작 방식
JIT 컴파일러는 실행 시점에서는 인터프리터와 같이 기계어 코드를 생성하면서 해당 코드가 컴파일 대상이 되면 컴파일하고 그 코드를 캐싱한다. JIT 컴파일은 코드가 실행되는 과정에 실시간으로 일어나며(그래서 Just-In-Time이다), 전체 코드의 필요한 부분만 변환한다. 기계어로 변환된 코드는 캐시에 저장되기 때문에 재사용 시 컴파일을 다시 할 필요가 없다.
- JIT 컴파일러가 컴파일하는 조건은 얼마나 자주 코드가 실행됐는가 이다. 일정한 횟수만큼 실행되고 나면 컴파일 임계치에 도달하고 컴파일러는 컴파일하기에 충분한 정보가 쌓였다고 생각한다.
- 임계치는 메서드가 호출된 횟수, 메서드의 루프를 빠져나오기까지 돈 횟수 두 개를 기반으로 한다. 이 두 수의 합계를 확인하고 메서드가 컴파일될 자격이 있는지 여부를 결정한다. 자격이 있다면 메서드는 컴파일되기 위해 큐에서 대기한다. 이후 메서드들은 컴파일 스레드에 의해 컴파일된다.
- 아주 오랫동안 돌아가는 루프 문의 카운터가 임계치를 넘어가면 해당 루프는 컴파일 대상이 된다. JVM은 루프를 위한 코드의 컴파일이 끝나면 루프가 다시 반복될 때는 코드를 컴파일된 코드로 교체하고 더 빠르게 실행된다. 이 교체 과정을 "스택 상의 교체(on-stack replacement, ORS)"라고 부른다.
그림으로 보자면, 위와 같이 반복되는 코드들을 컴파일하여 캐싱해둠으로써 인터프리터는 반복되는 코드를 읽지 않고 컴파일된 코드를 바로 사용할 수 있는 것이다.
JIT 컴파일러의 이점
일반적인 인터프러터 언어는 바이트코드나 소스코드를 최적화 과정이 없이 번역하기 때문에 성능이 낮다. 반면 정적으로 컴파일하는 언어는 실행 전에 무조건 컴파일을 해야 하기 때문에 다양한 플랫폼에 맞게 컴파일을 하려면 시간이 오래 걸린다.
JIT 컴파일러는 실행 과정에서 컴파일을 할 수 있기 위해 만들어졌다. JIT 컴파일러는 정적 컴파일러만큼 빠르면서 인터프러터 언어의 빠른 응답속도를 추구하기 위해 사용한다. 또한 바이트코드 컴파일러가 시간이 많이 소요되는 최적화를 미리 해주기 때문에 바이트코드에서 기계어 번역은 훨씬 빠르게 진행될 수 있어 성능상의 이점이 있다.