JVM은 컴파일러일까, 인터프리터일까?

/ 7 min read /
0 views

컴파일러 방식

컴파일러 방식은 고급언어로 작성된 프로그램을 목적 프로그램으로 번환후 링킹작업을 통해 실행파일을 생성한다. 기계어 번역과정에서 많은 메모리를 사용한다. 초기 스캔시간은 오래 걸리지만 한번 실행 파일이 만들어지고 나면 실행속도가 빠르다.

인터프리터 방식

인터프리터 방식은 고급언어로 작성된 프로그램을 실행 시 한 번에 한 문장씩 기계어로 번역한다. 컴파일러와 같은 오브젝트 코드 생성 과정이 없기 때문에 메모리 효율이 좋다. 변환속도는 빠르지만 한 번에 한 문장씩 번역 후 실행 시키기 때문에 실행 시간이 느리다.

그럼 자바는?

자바는 컴파일러와 인터프리터 방식을 혼합한 형태로 동작한다.

  1. 자바 컴파일러(Java Compiler)가 자바 소스파일을 컴파일한다. 이때 나오는 파일은 자바 바이트 코드(.class)파일로 아직 컴퓨터가 읽을 수 없는 자바 가상 머신이 이해할 수 있는 코드다.
  2. 컴파일된 바이트 코드를 JVM의 클래스로더(Class Loader)에게 전달한다.
  3. 클래스 로더는 동적로딩(Dynamic Loading)을 통해 필요한 클래스들을 로딩 및 링크하여 런타임 데이터 영역(Runtime Data area), 즉 JVM의 메모리에 올린다.
    • 클래스 로더 세부 동작은 다음과 같다.
    • 로드 : 클래스 파일을 가져와서 JVM의 메모리에 로드한다.
    • 검증 : 자바 언어 명세(Java Language Specification) 및 JVM 명세에 명시된 대로 구성되어 있는지 검사한다.
    • 준비 : 클래스가 필요로 하는 메모리를 할당한다. (필드, 메서드, 인터페이스 등등)
    • 해석 : 클래스의 상수 풀 내 모든 심볼릭 레퍼런스를 다이렉트 레퍼런스로 변경한다.
    • 초기화 : 클래스 변수들을 적절한 값으로 초기화한다. (static 필드)
  4. 실행엔진(Execution Engine)은 JVM 메모리에 올라온 바이트 코드들을 인터프리터 방식으로 명령어 단위로 하나씩 가져와서 실행한다.

결국에 인터프리터면 느려지 않을까?

JVM에서는 인터프리터의 단점을 보완하기 위해 JIT 컴파일러(Just-In-Time Compiler)을 사용한다. 컴파일러의 대표적인 언어인 C/C++의 경우 정적으로 컴파일을 하여 컴파일 시점에 코드를 최적화하여 기계어로 변환한다. 이렇게 정적 컴파일을 해주는 컴파일러 방식을 AOT 컴파일러(Ahead-of-Time)라고 한다.

하지만 JIT 컴파일러는 동적으로 코드를 컴파일하는 컴파일러를 의미한다. AOT 컴파일러의 경우 한 번에 최적화하여 컴파일하여 JIT 컴파일러에 비해 상대적으로 빠르게 시작할 수 있다. JIT 컴파일러의 경우에는 런타임 상황에서 인터프리터로 실행되는 과정에서 자주 반복되는 코드(HotSpot)를 발견하면, 해당 부분의 바이트코드를 JIT 컴파일러를 통해 네이티브 코드로 변환하고 최적화를 하여 JVM 내에 위치한 코드 캐시(Code Cache) 영역에 캐싱한다.

하지만 모든 바이트코드를 무작정 런타임에 컴파일 하는 것이 결코 효율적이지 않다는 것이다. 파레토 법칙에 따르면 전형적인 프로그램은 전체 실행 시간의 대부분을 20%의 핵셈 메서드나 루프에서 소비한다. JVM은 이러한 휴리스틱에 기반하여 프로그램이 실행될 때 실행 시간, 메모리 사용량, 함수 호출 횟수 등과 같은 정보를 수집하고 분석하여 프로그램의 동작을 분석하는 과정인 프로파일링 기반 최적화, PGO(Profile-Guided Optimization) 과정을 수행한다. JVM은 내부적으로 두 가지 형태의 카운터 기반 프로파일링 기법을 운용한다.

  1. 메서드 호출 카운터(Method Invocation Counter): 특정 메서드가 다른 코드에 의해 호추뢷ㄹ 떄마다 1씩 증가하는 카운터로, 프로그램 전반에 걸쳐 가장 빈번하게 사용되는 유틸리티 함수나 핵심 비즈니스 로직을 탐지하는 기준이 된다.
  2. 백엣지 카운터(Loop Back-edge Counter): 메서드 호출 횟수 자체는 적으나 해당 메서드 내부에 거대한 for loop 도는 while loop가 존재하여 제어 흐름이 루프의 시작점을 지속적으로 분기할 때마다 1씩 증가하는 카운터이다. 이는 실질적으로 CPU 자원을 독점하는 롱 러닝(Long-running) 루프를 탐지하는 기준이 된다.

인터프리터가 코드를 실행하는 동안 백그라운드 스레드는 이 두 카운터의 합계를 지속적으로 모니터링한다. 카운터 합계가 사전에 정의된 특정 임계치(Threshold)를 초과하는 순간, 해당 메서드 또는 특정 루프 블록은 즉각 핫스팟으로 분류되어 JVM의 컴파일 작업 대기열(Compile Queue)에 적재된다. 컴파일러 스레드는 큐에서 이 작업을 꺼내어 원본 바이트코드를 내부적인 중간 표현(Intermediate Representation, IR)으로 구문 변환하고, 레지스터 할당, 데이터 흐름 분석, 불필요한 연산 제거 등의 공격적인 최적화를 적용한 뒤, 최종 타겟 기계어를 생성해 메모리 내의 통제된 영역인 코드 캐시(Code Cache)에 저장한다. 이후 인터프리터는 해당 메서드에 대한 호출 요청이 들어오면, 더 이상 바이트코드를 한 줄씩 해석하지 않고 코드 캐시에 보관된 네이티브 기계어의 주소로 실행 흐름을 전환(Context Switch)한다. 이를 통해 CPU는 인터프리터의 개입 없이 메모리에서 직접 명령어를 패치하여 연산을 수행하게 되고, 이 시점부터 자바 애플리케이션의 성능은 C/C++과 같은 순수 네이티브 컴파일 언어와 동등하거나, 문맥 인식 동적 최적화를 통해 오히려 능가하는 비약적인 성능 향상을 할 수 있게 된다. 그럼에도 JIT 컴파일러의 매력은 충분하지만, 런타임에 바이트코드를 네이티브 코드로 변환하는 작업은 여전히 CPU 자원을 소모하는 고비용의 작업이다.

컴파일 비용의 최적화: 단계별 컴파일(Tiered Compilation)

모든 메서드에 대해 최고 수준의 최적화를 적용한다면, 컴파일 자체에 너무 많은 시간이 소요되어 애플리케이션의 응답성이 저하될 것이다. JVM은 이러한 문제를 해결하기 위해 단계별 컴파일(Tiered Compilation)을 지원한다.

단계별 컴파일은 코드의 실행 빈도와 부하에 따라 컴파일 단계를 5단계로 나누어 관리합니다.

  • Level 0(Interpreted): 코드가 처음 실행될 때의 단계이다. JIT 컴파일 없이 인터프리터가 바이트코드를 한 줄씩 실행하며 프로파일링 정보를 수집한다.
  • Level 1(Simple C1): C1 컴파일러가 최적화 없이 네이티브 코드로 컴파일한다. 프로파일링 정보를 수집하지 않으며, 가장 빠르게 네이티브 코드를 생성하는 것이 목적이다.
  • Level 2 (Limited C1): C1 컴파일러가 가벼운 수준의 최적화를 적용하며, 일부 프로파일링 정보를 수집한다.
  • Level 3 (Full C1): C1 컴파일러가 모든 최적화 기법을 동원하며, 완전한 프로파일링 정보를 수집한다.
  • Level 4 (C2): 수집된 프로파일링 정보를 바탕으로 C2 컴파일러가 고도의 최적화를 수행한다. 이 단계의 코드는 서버급 성능을 보장하지만 컴파일 시간이 가장 오래 걸린다.

일반적으로 프로그램이 시작되면 인터프리터(Level 0)가 동작하다가, 메서드가 빈번하게 수행되어 호출 임계치에 도달하면 C1 컴파일러(Level 3)가 빠르게 네이티브 코드를 생성한다. 이후 해당 메서드가 진정한 Hotspot으로 판명되면 비로소 C2 컴파일러(Level 4)가 개입하여 극한의 최적화를 수행하는 구조다.

JIT 최적화: 메서드 인라이닝(Method Inlining)

JIT 컴파일러가 수행하는 수많은 최적화 기법 중 성능 향상에 가장 크게 기여하는 핵심 기술은 메서드 인라이닝(Method Inlining)이다. 이는 메서드 호출부를 호출될 메서드의 실제 본문 코드로 치환하는 기법을 의미한다.

자바는 객체지향 언어의 특성상 메서드가 하나의 역할만을 하기 위해서, 메서드를 분리하는 경우가 많고, 작은 단위의 메서드 호출이 빈번하게 발생한다. 메서드 호출 시마다 스택 프레임을 생성하고, 매개변수를 전달하며, 실행 후 리턴 어드레스로 복귀하는 과정은 필연적으로 CPU 오버헤드를 발생시킨다. 인라이닝은 이러한 호출 비용을 원천적으로 제거할 뿐만 아니라, 분산되어 있던 코드를 하나의 거대한 코드 블록으로 합침으로써 다른 최적화 기법(Dead Code Elimination, Loop Optimization 등)이 적용될 수 있는 분석 범위를 극대화하는 촉매제 역할을 한다. JVM은 모든 메서드를 무분별하게 인라이닝하지 않는다. 인라이닝 여부를 결정하는 가장 중요한 척도는 메서드의 바이트코드 크기와 호출 빈도다.

  • 크기 제약: -XX:MaxInlineSize는 자주 호출되지 않는 메서드의 인라이닝 한계를 결정하며(기본값 35 bytes), -XX:FreqInlineSize는 Hotspot 메서드의 인라이닝 한계를 결정한다(기본값 325 bytes).
  • 다형성 제약: 상속과 인터페이스를 통한 다형성은 인라이닝의 최대 장애물이다. 실행 시점에 어떤 구현체가 호출될지 불분명한 가상 호출(Virtual Call)의 경우, JIT는 인라이닝을 주저하게 된다.

여기서 final, private, static 키워드는 중요한 역할을 수행한다. 이 키워드들은 해당 메서드가 재정의(Override)되지 않음을 보장하므로, JIT 컴파일러가 런타임에 복잡한 검사 없이 즉시 최적화된 기계어를 생성할 수 있는 근거가 된다. 결국 개발자가 클린 코드를 지향하며 메서드를 작고 응집도 있게 쪼개는 행위는, 역설적으로 JIT 컴파일러가 인라이닝을 수행하기 가장 좋은 최적의 환경을 제공하는 셈이다. 하지만 메서드가 아무리 효율적으로 합쳐지더라도, 객체를 힙(Heap) 영역에 생성하고 관리하며 발생하는 가비지 컬렉션(GC)의 비용 자체를 완전히 제거하지는 못한다.

힙 메모리 할당의 효율성 재정의: 탈출 분석(Escape Analysis)

메서드 인라이닝을 통해 코드 블록이 하나로 합쳐지면, JIT 컴파일러는 객체의 생명주기를 단일 메서드보다 훨씬 넓은 범위에서 조망할 수 있게 된다. 이때 핵심적으로 작동하는 기법이 탈출 분석(Escape Analysis)이다. 탈출 분석은 특정 객체가 해당 메서드나 스레드 범위 밖으로 ‘탈출’하여 참조될 가능성이 있는지 여부를 판별하는 과정이다.

객체가 메서드 내에서 생성되어 외부로 반환되지 않고, 전역 변수나 다른 객체의 필드에 저장되지도 않는다면 해당 객체는 메서드 실행 종료와 함께 소멸될 수 있다고 판단한다. JIT 컴파일러는 이러한 프로파일링 정보를 바탕으로 다음과 같은 고도의 최적화를 단행한다.

스택 할당(Stack Allocation): 자바의 모든 객체는 원칙적으로 힙(Heap)에 할당되어 가비지 컬렉션(GC)의 관리 대상이 된다. 하지만 탈출하지 않는 객체로 판명되면, JVM은 이를 힙이 아닌 스택 프레임에 직접 할당한다. 이는 메서드 종료 시 별도의 GC 과정 없이 즉시 메모리가 회수됨을 의미하며, 결과적으로 가비지 컬렉터의 부하를 직접적으로 경감시킨다.

스칼라 교체(Scalar Replacement): 객체 전체를 생성하는 대신, 객체를 구성하는 원시 타입 필드(Primitive fields)들로 쪼개어 개별 로컬 변수로 대체한다. 이 기법이 적용되면 실제 객체 구조는 메모리에 생성되지 않고 각 필드 값이 CPU 레지스터나 스택에 상주하게 되어, 객체 헤더 접근 등에 소요되는 메모리 오버헤드가 완전히 제거된다.

록 제거(Lock Elision): 특정 객체가 단일 스레드 내에서만 사용됨이 탈출 분석을 통해 증명되면, 해당 객체에 선언된 synchronized 블록이나 내부적인 락(Lock) 메커니즘을 컴파일 단계에서 삭제한다. 스레드 간 경합이 발생할 여지가 없으므로 불필요한 동기화 비용을 지불하지 않는 것이다.

탈출 분석은 JVM이 메모리 구조를 런타임에 재설계하는 최적화의 정점이다. 하지만 이러한 고도의 최적화 결과물인 네이티브 코드는 결국 메모리 어딘가에 안전하게 저장되어야 하며, 그 공간은 물리적으로 한정되어 있다.

만약 JVM이 객체의 이동 경로를 완벽히 예측하여 메모리 할당 위치 자체를 최적화할 수 있다면 성능은 한 단계 더 진화할 수 있지 않을까?

Loading Comments...
Failed to load data