TIL·

자바는 하이브리드 언어다!

학교를 다닐 때 교수님께서는 JAVA는 컴파일 언어라고 하셨다.
하지만, 조금만 깊게 파보면 Java라는 언어는 인터프리터도 된다.
이 이야기를 처음 들었을 때 잘 이해되지 않았는데, 컴퓨터의 언어라는 이야기를 듣고 조금 생각이 바뀌었다.

CPU가 이해할 수 있는 말

이를 이해하기 위해서는 CPU에 대해서 이해할 필요가 있다.

우리가 흔히 알고있는 Intel, AMD 프로세서부터 근래 맥북에 들어가는 M1, M2와같은 Apple Silicon 까지 다양한 프로세서가 존재한다.

이 프로세서들은 우리가 작성한 소스코드를 직접 읽을 수 없고, 일반적으로 컴파일이라는 과정을 통해 CPU가 실행할 수 있는, 0과 1로 이루어진 기계어로 변환된다.

스쳐가며 x86이나 arm 이라는 단어를 접해본 적 있을것이다. 추가로, x86 아키텍처 혹은 arm 아키텍처라는 말도 함께 들어본 적 있을텐데 이러한 CPU가 어떤 명령어를 제공하고, 그 명령어를 어떻게 표현하는지 정의한 것을 ISA 즉, Instruction Set Architecture 라고 한다.

여기서 한 가지 의문이 생길 수 있다. Intel과 AMD는 제조사가 다르니 ISA도 다를까?
아니다, ISA는 제조사와는 무관하다. Intel 프로세서와 AMD 프로세서는 모두 동일한 x86-64 ISA를 사용하며, Apple Silicon 프로세서는 ARM64 ISA를 사용한다.

이렇게 제조사는 달라도 ISA는 같을 수 있고, x86-64 ISA를 대상으로 컴파일된 기계어는 ARM64 ISA를 사용하는 CPU에서는 그대로 실행이 불가능하다.

JAVA와 JVM

"어, JAVA는 그냥 빌드하면 다 돌아가던데?"
맞다. 앞서 설명한대로면 ARM64 ISA를 대상으로 빌드된 파일은 x86-64 ISA를 사용하는 CPU에서는 동작할 수 없다. 그런데 Java로 작성하고 빌드한 프로그램은 CPU 아키텍처가 달라도 실행하는데 문제가 없다.

왜 그런것일까?

Java는 컴파일 시 CPU가 실행할 수 있는 기계어로 바로 컴파일 하는것이 아닌, 바이트코드(bytecode)로 먼저 컴파일이 되고, JVM은 이 바이트코드를 런타임에 읽으며 실행하게 된다.

그럼 JVM은 이 바이트코드를 어떻게 읽는것일까?

인터프리터와 JIT

JVM은 런타임에 인터프리터를 통해 바이트코드 명령어를 하나씩 해석하며 처리한다. 여기서 한 가지만 생각해보면 좋겠다.

똑같은 코드를 계속 읽어야 할 경우에는?

java 3 lines
int sum(int a, int b) {
  return a + b;
}

이와같은 코드가 있다고 가정해보고, sum 메서드를 수백 번, 수천 번 반복 호출하면 어떻게 될까? 이미 여러 번 실행한 코드인데, 매번 해석할 필요가 있을까?

JVM은 런타임에 코드의 실행 정보를 수집하고, 이 과정에서 자주 실행되어 컴파일할 가치가 있다고 판단되는 코드를 Hot Code로 식별한다. 그리고 이렇게 식별된 코드를 JIT 컴파일러가 해당 코드를 CPU가 직접 실행할 수 있는 네이티브 코드 즉, 기계어로 컴파일한다.

JIT Compiler가 생성한 네이티브 코드는 어디에?

앞서 Hot Code로 식별된 코드는 네이티브로 컴파일해둔다 했는데, 이 네이티브 코드는 어디에 저장될까?
이 네이티브 코드는 JVM이 관리하는 메모리 영역인 Code Cache 라는 영역에 저장이 된다. 이 후 이미 컴파일된 코드가 다시 실행될 때 바이트코드의 명령어를 다시 인터프리터로 해석하는게 아니라, Code Cache에 저장된 네이티브 코드를 실행할 수 있다.

Hot Code는 어떻게 판단할까?

여기서 자연스럽게 이런 의문이 들었다.

단순히 많이 호출되고, 많이 사용되면 Hot Code인가?

이런 의문은 반은 맞고 반은 틀렸다.
JVM은 하나의 명세 이며, 이를 구현하는 여러 구현체가 존재한다. 이 글에서는 대표적인 HotSpot JVM을 기준으로 살펴본다.

HotSpot JVM은 런타임에 코드가 얼마나 자주 실행되는지 관찰한다. 이를 위해 실행과 관련된 여러 정보를 수집하며, 이러한 정보를 바탕으로 어떤 코드를 JIT 컴파일할지 결정한다.

여기서 코드의 실행 빈도를 이해하기 위해 먼저 Invocation CounterBackedge Counter라는 개념을 살펴보자.

Invocation Counter

Invocation Counter는 메서드가 얼마나 자주 호출되는지를 추적하기 위한 카운터다.

java 7 lines
int sum(int a, int b) {
    return a + b;
}

for (int i = 0; i < 10_000; i++) {
    sum(1, 2);
}

위 코드에서는 sum() 메서드가 반복적으로 호출된다. HotSpot JVM은 이러한 메서드의 실행 빈도를 추적하고, JIT 컴파일 여부를 판단하는 정보로 활용한다.
그런데 메서드가 자주 호출되지 않더라도 내부의 특정 코드가 굉장히 많이 실행될 수도 있다.

java 5 lines
void calculate() {
    for (int i = 0; i < 10_000_000; i++) {
        // ...
    }
}

이 메서드를 한번 만 호출해도 내부 코드는 천만 번 반복된다. 만약 메서드의 호출 횟수만 추적한다면 calculator 메서드는 자주 실행되지 않는 코드라 판단할 수 있지만, 실제로 내부에서는 천만 번 실행되는 로직이 있다.
이러한 코드를 인터프리터가 계속해서 해석한다면 반복적인 인터프리팅 오버헤드가 발생한다. 따라서 JVM은 메서드 호출 횟수뿐만 아니라 반복적으로 실행되는 흐름도 추적할 필요가 있으며, 이를 위해 Backedge Counter를 사용한다.

Backedge Counter

반복문은 코드를 실행한 뒤 다시 이전 위치로 돌아가 다음 반복을 수행한다.

text 1 lines
조건 확인 -> 코드 실행 -> 다시 조건 확인 -> 코드실행 ....

이처럼 실행 흐름이 코드 뒤쪽에서 앞쪽으로 돌아가는 분기를 Backedge 라고 한다.
Backedge Counter는 이러한 반복 실행 빈도를 추적하며, Invocation Counter와 마찬가지로 HotSpot JVM이 JIT 컴파일 여부를 결정하는 데 활용된다.

즉, HotSpot JVM은 메서드의 호출 횟수만 보는것이 아니라, 메서드 내부의 코드가 얼마나 반복적으로 실행되고 있는가도 함께 관찰한다.

반복 횟수에 따라 Hot Code로 식별된다면, 몇번이나 반복해야 하는것일까?

HotSpot JVM은 코드의 실행 횟수 등이 일정한 임계 조건(Threshold)에 도달했는지를 JIT 컴파일 판단에 활용한다. 하지만 이 임계값은 단순히 "10,000번 실행"과 같은 하나의 고정된 실행 횟수만을 의미하지는 않는다.

또한 JIT 컴파일 자체에도 CPU와 메모리 비용이 발생하기 때문에, 처음부터 모든 코드를 높은 수준으로 최적화하는 것은 오히려 비효율적일 수 있다.

그래서 HotSpot JVM은 코드의 실행 상태에 따라 여러 단계에 걸쳐 컴파일과 최적화를 수행하며, 이러한 방식을 Tiered Compilation이라고 한다.

Tiered Compilation

Tiered Compilation은 이름 그대로 JIT 컴파일을 여러 단계(Tier)로 나누어 수행하는 방식이다.

HotSpot JVM은 인터프리터와 C1, C2 컴파일러를 사용하며, 코드의 실행 상태에 따라 적절한 수준의 컴파일과 최적화를 수행한다.

text 3 lines
Level 0 : Interpreter
Level 1~3 : C1 Compiler
Level 4 : C2 Compiler

C1은 빠른 컴파일에 초점을 맞추고, C2는 더 많은 실행 정보를 바탕으로 높은 수준의 최적화를 수행한다.

물론 실제로는 각 Level의 역할과 전환 조건, OSR, Deoptimization 등 더 복잡한 과정이 존재한다. 하지만 여기서는 JVM이 모든 바이트코드를 처음부터 기계어로 컴파일하는 것이 아니라, 실행하면서 필요한 코드를 단계적으로 컴파일한다는 정도만 이해하고 넘어가자.

다시, JAVA는 왜 하이브리드 언어인가?

이 글의 제목은 "자바는 하이브리드 언어다!" 이다.
지금까지 살펴본 내용을 보면 컴파일인지, 인터프리터 언어인지 한 쪽으로 구분하기 어렵다.

JAVA는 빌드 단계에서 소스코드를 바이트코드로 컴파일하고, 실행 단계에서 JVM은 인터프리터를 통해 명령문을 실행하며, 실행 과정에서 자주 실행되어 컴파일할 가치가 있다고 판단된 코드는 JIT Compiler를 통해 네이티브 코드로 컴파일되며, JVM의 메모리 영역인 Code Cache에 저장한다.

text 1 lines
Java Source Code - (Compiler) -> Bytecode -> JVM - (Interpreter) -> Hot Code - (JIT Compiler) -> Native Code

결국 이 모든 과정을 보면 JAVA의 전체 실행 과정에는 컴파일과정과 인터프리터 과정이 섞여있다.
이것이 Java를 하이브리드 방식의 언어라고 부르는 이유다.

마무리

이번 글에서는 JAVA가 소스코드를 바이트코드로 컴파일하고, JVM이 인터프리터와 JIT Compiler를 통해 어떻게 최적화를 하고 실행하는지에 대해서 알아보았다.
조금 더 깊게 파보면 좋겠지만, 이 글은 자바가 왜 하이브리드 언어인지에 대해서 설명한 글이라 여기서 마침표를 찍고자 한다.

여기서 끝내기보다 이 내용에 이어서 아래 내용도 조금 고민해보면 좋겠다.

  • JIT Compiler가 만든 네이티브 코드는 Code Cache에 영구적으로 남아 있을까?
  • C1과 C2 Compiler는 각각 어떤 최적화를 수행할까?
  • JIT Compiler가 수행한 최적화가 더 이상 유효하지 않다면 어떻게 될까?
  • JVM을 처음 실행했을 때보다 요청이 반복될수록 빨라지는 이유는 무엇일까?

이러한 고민은 Code Cache, JIT Optimization, OSR(On-Stack Replacement), Deoptimization, JVM Warm-up 과 연결된다.
이번 글에서는 Java가 왜 컴파일과 인터프리팅을 함께 사용하는지 이해하는 데까지만 다루고, 더 깊은 JVM의 동작은 다음 주제로 남겨두려 한다.