JVM 밑바닥까지 파헤치기 - 컴파일과 최적화

유승선 ·2025년 3월 9일
1

자바 독학

목록 보기
15/16
post-thumbnail

자바를 이해하는데 필요한 지식인 컴파일러와 관련된 공부를 하였습니다.


Javac 컴파일러

javac 컴파일러는 순수하게 자바로 작성된 코드다. 기본적인 지식에 포함되지만 javac 는 지금까지 배운 JVM 과 내부 메모리 구조, 단계를 거치기 전에 자바 파일을 .class 형태의 클래스 파일로 컴파일 해주는 역할을 가지고 있다.

크게 3가지의 단계를 거치게 된다.

  1. 어휘 및 구문 분석
  • 소스 코드의 문자 스트림을 토큰 집합으로 변환하는 일을 말한다.

  • int a= b + 2 는 총 6개의 토큰 구성을 의미하며 (int, a, = , b, +, 2) 어휘 분석을 담당하는 코드는 com.tools.javac.parser.Scanner 클래스다.

  • 추상 구문 트리는 프로그램 코드의 문법 구조를 트리형태로 기술하는 기법이다.

  1. 플러그인 애너테이션 처리기들로 애너테이션 처리
  • 대표적인 애너테이션은 롬복이 존재한다
  1. 의미 분석 및 바이트코드 생성
  • 앞선 결과로 추상 구문 트리를 얻었다. 즉, 구조화 된 프로그램 코드라고 볼 수 있다.

  • 의미 분석의 주된 목적은 구조적으로 올바른 소스가 '맥락적으로도' 올바른지 확인하는것이다. 예를 들어 타입 검사, 제어 흐름 검사, 데이터 흐름 검사 같은 작업이 대표적이다.

코드에 빨간 밑줄로 오류를 표시해주는 경우가 대부분 의미 분석 단계에서 발견된 문제를 경고하는 것이다.

바이트 코드 생성은 javac 컴파일 과정의 마지막 단계며 이때 컴파일러가 소량의 코드를 추가하거나 변경할 수 있다.

  • 프로그램 로직 일부를 최적화된 코드로 대체하기도 한다. 예를 들어, + 연산자를 사용한 문자열 합치기를 StringBuilder 나 StringBuffer 의 append() 를 이용하여 코드로 대체할 수 있다.

자바 편의 문법

제네릭

  • 특수한 매게 변수를 사용하여 작업 대상의 데이터 타입을 지정할 수 있게 하는 것

  • 데이터 타입에 구애받지 않는 알고리즘을 작성할 수 있다. (추상화 능력 향상)

  • 자바가 선택한 제네릭 구현 방식은 "타입 소거 제네릭" 이라고 한다. 즉, 컴파일 타임에서 자바는 타입 정보가 사라진다는 뜻이다.

  • 자바의 제네릭 구현은 온전히 javac 컴파일러가 담당한다.

프런트엔드 컴파일러가 수행하는 최적화의 목적은 주로 코딩 효율 개선이고, 단순하게 바이트 파일로 변환하는 외에도 일부 최적화에 기여할 수 있다. 그리고 네이티브 기계어 코드 생성은 JIT 컴파일러 또는 AOT 컴파일러의 역할이라고 볼 수 있다.


JIT 컴파일러

책에서는 백엔드 컴파일러로 JIT 컴파일러를 소개해주었다. 백엔드 컴파일러의 역할은, 앞서 javac 가 바이트코드로 변환을 해주었다면 실제로 실행될 수 있는 기계어로 변환해주는 작업을 맡고 있다고 볼 수 있다.

JIT 컴파일러는 인터프리터 방법으로 코드를 해석해 실행한다. 그런 다음, 아주 자주 실행되는 메서드나 코드 블록이 발견된다면 다양한 최적화를 통해서 실행 효율을 높인다.

이러한 코드 블록을 핫스팟 코드라고 하며, 런타임에 이 작업을 수행하는 백엔드 컴파일러를 JIT 컴파일러라고 한다.

인터프리터와 컴파일러

인터프리터와 컴파일러는 서로의 효율을 높여 사용할 수 있다. 컴파일러의 잘못된 최적화를 잡아서 무너지는 경우 최적화를 취소하고 인터프리터에 실행을 맡길 수도 있다.

JVM 에는 JIT 컴파일러가 2개 또는 3개 내장되어 있다. 그중 2개는 각각 클라이언트 컴파일러 (C1 컴파일러) 그리고 서버 컴파일러 (C2 컴파일러) 로 불린다.

세번째 JIT 컴파일러는 JDK 10과 함께 등장한 그랄 컴파일러다. 하지만 JDK 16부터 표준 JDK에서 배제된 채 그랄 VM 이라는 오라클의 별도 프로젝트에서 개발 중이다.

기존에는 JVM 에서 인터프리터가 단 하나의 컴파일러와 협력해 동작했지만 이제는 실행 모드를 자동으로 선택한다는 특징이 있다.

프로그램 시작 응답 속도와 운영 효율에 균형을 맞추는 목적으로 핫스팟 가상 머신은 계층형 컴파일 기능을 추가했다.

컴파일 대상과 촉발 조건

핫코드의 대표적인 유형은

  1. 여러번 호출되는 메서드
  2. 여러번 실행되는 순환문의 본문

여기서 '여러번' 뜻의 의미는 특정 코드가 핫 코드인지를 판단하는 요소인데 주로 쓰이는 방식은 아래와 같다.

  1. 샘플 기반 핫스팟 코드 탐지
  2. 카운터 기반 핫스팟 코드 탐지

JVM 은 메서드 각각에 대해 메서드 호출 카운터와 백 에지 카운터를 준비한다.

그 외 AOT 컴파일러, 그랄 VM 영역이있지만 이 부분은 추가로 학습해보고 정리하는 것으로 하겠다.

실제 사례

JVM 웜업 영상을 보면서 JIT 컴파일러의 C1 컴파일러 C2 컴파일러에 대한 내용이 등장했다.

이슈 :
배포 이후 서버가 시작 될 때 Latency 이슈가 발생. 이슈가 되었던 서버는 계정 서버였고 많은 API요청이 오기 때문에 초기 응답 지연 현상이 크리티컬했다.

1차 접근 :
JVM 은 기본적으로 실제 메서드가 호출 될 때 연관된 클래스를 Lazy Loading 하는 특징이 있는데 이를 캐싱하기 위해서 실제 트래픽이 발생하기 전에 로컬호스트에 실제 사용하는 API를 호출해서 지연 시간을 줄일 수 있었다.

2차 접근 :

TPS가 더 높아지면서 같은 이슈가 발생했고 JIT 컴파일러 최적화 단계에 도입. 이때, JIT 컴파일러에 단계별 최적화 (Tiered Compilation) C1, C2 컴파일 사용.

C1 : 간략한 최적화
C2 : 최대 최적화

각 컴파일러에는 정해진 임계치가 정해져있고 특정 임계치를 넘어가면 다음 컴파일 단계로 넘어간다는 특징을 활용

level 0 : 최적화 없이 바이트 코드를 기계어로 변환

level 1 ~ 3 (C1) : 3부터 프로파일링 모드가 실행되어 정보를 수집하고 최적화 진행

level 4 (C2) : 최대 최적화를 진행함으로서 성능을 보장한다.

C1과 C2는 각각 쓰레드를 가지고 있고 별도로 동작하도록 되어있다. 각 메서드가 설정된 기준을 넘게 실행되면 각 레벨에 맞게 최적화를 진행하게 된다.

그리고 아래 보이는 것처럼 레벨 0에서는 바이트코드가 기계어로 변경 되고 C1 임계치보다 많이 실행되면 C1 컴파일러를 위한 큐로 전달된다. C1에서는 레벨 3단계로 최적화 후 코드 캐시로 전달.

C2임계치보다 많아지게 되면 다시 한번 큐로 넘어가서 C2에서 최대 최적화를 진행한다.

이런 최적화 과정을 통해서 반복적으로 warm up 메서드를 실행해서 최적화를 진행 하고 웜업이 끝난 후에 트래픽이 유입될 수 있도록 하여 초기 지연 문제를 해결했다고 한다.

profile
성장하는 사람

0개의 댓글