ABOUT ME

Today
Yesterday
Total
  • [JVM] 컴파일과 최적화 (1) - 프론트엔드 컴파일과 최적화
    DevBook/JVM 밑바닥까지 파헤치기 2025. 4. 6. 15:12
    • '컴파일타임'이라고 하면 뜻이 모호할 수 있음
    • 프론트엔드 컴파일러가 *.java 파일을 *.class 파일로 변환하는 과정도 컴파일이고, JVM에서 JIT 컴파일러가 바이트코드를 기계어로 변환하는 과정도 컴파일임. 또한 AOT 컴파일러를 사용하여 특정 하드웨어용 바이너리 코드로 곧바로 컴파일하는 방식도 있음
    • javac와 같은 프론트엔드 컴파일러는 코드 실행 효율 측면의 최적화는 거의 하지 않음
      • 성능 최적화를 런타임 컴파일러에 집중하기로 결정했기 때문
      • javac로 생성하지 않는 클래스 파일(ex. JRuby, 그루비 등으로 작성한 클래스 파일)들도 최적화 효과를 공평하게 누리도록 하기 위해서임
      • 한편 '최적화'의 범위를 개발 단계까지 포함시킨다면 javac는 개발자가 작성하는 코드를 단순화하는 등 코딩 효율을 개선하는 최적화를 상당수 지원한다고 볼 수 있음
        • 최신 자바 문법 중에는 가상 머신 내부나 바이트코드 수준의 변경 없이 컴파일러가 편의 문법을 해석해 구현하는 게 많음
    • 즉, 런타임에는 실행 효율을 높이는 최적화를 JIT 컴파일러가 지속해서 수행하고, 컴파일타임에는 개발자의 코딩 효율을 높이는 최적화를 프론트엔드 컴파일러가 수행한다고 생각하면 됨

     

    javac 컴파일러

    1) javac 소스 코드와 디버깅

    • 컴파일러 API(JSR 199)가 포함된 JDK 6부터는 javac 컴파일러 구현 코드가 표준 자바 클래스 라이브러리로 옮겨짐

    • javac 소스 코드의 메인 클래스는 com.sun.tools.javac.Main임

     

    javac 컴파일 과정

    • javac 코드의 전체 구조를 보면 컴파일은 다음과 같이 1개의 준비 단계와 3개의 처리 단계를 거침
      • 단계 0(준비): 플러그인 애노테이션 처리기들 초기화
      • 단계 1: 구문 분석 및 심벌 테이블 채우기, 예를 들면 다음과 같다.
        • 1.1 어휘 및 구문 분석: 소스 코드를 토큰화하여 추상 구문 트리 구성
        • 1.2 심벌 테이블 채우기: 심벌 주소와 심벌 정보 생성
      • 단계 2: 플러그인 애노테이션 처리기들로 애노테이션 처리
      • 단계 3: 의미 분석 및 바이트코드 생성, 예를 들면 다음과 같다.
        • 3.1 특성 검사: 문법의 정적 정보 확인
        • 3.2 데이터 흐름 및 제어 흐름 분석: 프로그램의 동적 실행 과정 확인
        • 3.3 편의 문법 제거: 코드를 단순화하는 편의 문법을 원래 형식으로 복원
        • 3.4 바이트코드 생성: 지금까지 생성된 정보를 바이트코드로 변환
    • 컴파일하는 중간에 플러그인 애노테이션이 실행되면 새로운 심벌이 생성될 수 있음
    • 새로운 심벌을 다시 처리하기 위해 구문을 분석하고 심벌 테이블을 채우는 앞 단계로 돌아가야 함

    • javac의 컴파일 과정은 com.sun.tools.javac.main.JavaCompiler 클래스가 맡고 있으며, 위의 3가지 처리는 compile() 메서드에 집중되어 있음

     

    2) 구문 분석과 심벌 테이블 채우기

    1. 어휘 및 구문 분석

    • parseFiles() 메서드는 전통적인 컴파일 단계 중 어휘 분석과 구문 분석을 처리함
    • 어휘 분석소스 코드의 문자 스트림을 토큰 집합으로 변환하는 일
      • 프로그램 작성할 때는 가장 작은 단위가 문자이지만, 컴파일 시에는 키워드, 변수 이름, 리터럴, 연산자 같은 토큰이 가장 작은 단위
      • javac 소스 코드에서 어휘 분석을 담당하는 코드는 com.sun.tools.javac.parser.Scanner 클래스
    • 구문 분석일련의 토큰들로부터 추상 구문 트리를 구성하는 과정
      • 추상 구문 트리는 프로그램 코드의 문법 구조를 트리 형태로 기술하는 기법
      • 각 노드는 프로그램 코드의 구문 구조를 나타냄
      • javac 소스 코드에서 com.sun.tools.javac.parser.Parser 클래스가 담당하며, 이 단계에서 생성된 추상 구문 트리는 com.sun.tools.javac.tree.JCTree 클래스로 표현됨
      • 추상 구문 트리가 만들어진 후에는 원래의 소스 코드 문자 스트림은 더 이상 쓰이지 않음
        • 이후 작업은 모두 추상 구문 트리를 사용하기 때문

     

    2. 심벌 테이블 채우기

    • 위에서 enterTrees() 메서드가 담당함
    • 심벌 테이블은 심벌 주소와 심벌 정보의 집합으로 구성된 데이터 구조
      • 심벌 테이블에 등록된 정보는 컴파일 과정 곳곳에 사용됨
    • javac 소스 코드에서 com.sun.tools.javac.comp.Enter 클래스가 담당하고, 이 단계의 결과로 컴파일 단위 각각에 대한 추상 구문 트리의 최상위 노드와 package-info.java의 최상위 노드(존재하는 경우) 목록이 만들어짐

     

    3) 애노테이션 처리

    • JDK 5부터 애노테이션을 지원하기 시작하였고, JDK 6에서는 '플러그인할 수 있는 애노테이션 처리 API'라는 표준이 도입됨
    • 특정 어노테이션은 컴파일타임에 미리 처리될 수 있기 때문에 프론트엔드 컴파일러의 동작에 영향을 줌
      • 플러그인 애노테이션 처리기는 이 과정에서 추상 구문 트리의 임의 요소를 읽고 수정하고 추가할 수 있는 컴파일러용 플러그인
        • 이러한 플러그인이 애노테이션 처리 중에 구문 트리를 수정하면 컴파일러는 '구문 분석 및 심벌 테이블 채우기' 단계로 돌아가야 함
        • 이 일을 모든 플러그인 애노테이션 처리기가 구문 트리를 더는 수정하지 않을 때까지 반복함
    • 컴파일러의 애노테이션 처리 API를 이용하면 개발자의 코드가 컴파일러 동작에 영향을 줄 수 있음
      • 롬복(Lombok)은 이 API를 활용하여 getter/setter 생성, null 확인, equals()와 hashCode() 메서드 생성 등 자바 코드의 장황한 부분을 자동으로 작성하여 개발자의 수고를 덜어줌
    • javac 소스 코드에서 플러그인 애노테이션 처리기는 initProcessAnnotations()에서 초기화하며, 실행은 processAnnotations()가 담당함
      • processAnnotations()는 실행할 새로운 애노테이션 처리기가 있는지 살펴서, 존재한다면 com.sun.tools.javac.processing.JavacProcessingEnvironment 클래스의 doProcessing()을 호출함
      • 이 메서드는 새로운 JavaCompiler 객체를 생성하여 컴파일 이후 단계들을 처리함

     

    4) 의미 분석과 바이트코드 생성

    • 추상 구문 트리는 프로그램 코드를 잘 구조화해 표현하지만 의미 체계가 논리적인지까지는 보장하지 못함
    • 의미 분석의 주된 목적구조적으로 올바른 소스가 '맥락상으로도 올바른지' 확인하는 것

     

    javac가 수행하는 컴파일 과정에서 의미 분석은 '특성 검사'와 '데이터 및 제어 흐름 분석'의 두 단계로 나눌 수 있음

     

    1. 특성 검사

    • 변수를 사용하기 앞서 선언이 되어 있는지, 변수와 할당될 데이터의 타입이 일치하는지 등을 확인함
    • 특성 검사 과정에서 상수 접기(constant folding) 최적화도 수행함 (javac 컴파일러가 소스 코드에 대해 수행하는 몇 안 되는 최적화)
      • ex) int a = 1 + 2;
        • 이 코드로 생성한 추상 구문 트리에는 리터럴 1, 2와 연산자 +가 존재함
        • 상수 접기 최적화가 적용된 후에는 리터럴 3 하나만 남음
        • 그 결과, 런타임에 a = 1 + 2를 처리하는 속도는 애초에 a = 3으로 선언했을 때와 완전히 같아짐
    • javac 소스 코드에서 특성 검사는 Attr 클래스와 Check 클래스가 담당함

     

    2. 데이터 흐름 분석과 제어 흐름 분석

    • 프로그램이 맥락상 논리적으로 올바른지 확인하는 추가 검사
      • ex) 지역 변수가 사용되기 전에 값이 할당되었는지, 메서드의 모든 실행 경로에서 값을 반환하는지, 검사 예외는 모두 올바르게 처리되는지 등
    • javac 소스 코드에서 flow() 메서드에서 처리하고, Flow 클래스에 세세한 작업 코드가 정의되어 있음

     

    3. 편의 문법 제거

    • 자바의 대표적인 편의 문법은 제네릭, 가변 길이 매개 변수, 오토박싱/언방식 등이 속함
    • 이러한 구문은 JVM의 런타임에서 직접 지원하지 않으므로 컴파일 과정 중 편의 문법 제거 단계에서 원래의 기본 구문 구조로 복원
    • javac에서 desugar() 메서드가 촉발하며 TransTypes 클래스와 Lower 클래스가 담당함

     

    4. 바이트코드 생성

    • javac 컴파일 과정의 마지막 단게이며 com.sun.tools.javac.jvm.Gen 클래스에서 담당함
    • 이전 단계에서 생성한 정보(구문 트리, 심벌 테이블)를 바이트코드 명령어로 변환하여 저장소에 기록
    • 이때 컴파일러가 소량의 코드를 추가하거나 변경할 수 있음
      • 인스턴스 생성자 <init>()와 클래스 생성자 <clinit>()가 이 단계에서 구문 트리에 추가
        • 여기서 말하는 인스턴스 생성자는 기본 생성자와는 다름
        • 사용자 코드에서 생성자를 제공하지 않으면 컴파일러는 매개 변수와 접근 제한자가 없는 기본 생성자를 추가하는데, 이 작업은 '심벌 테이블 채우기' 단계에서 진행됨
      • <init>()와 <clinit>()은 다양한 코드가 한데 조합되어 만들어짐
        • 즉, 컴파일러는 명령문 블록(인스턴스 생성자의 경우 {}, 클래스 생성자의 경우 static {})을 만들고, 변수 초기화(인스턴스 변수 및 클래스 변수), 부모 클래스의 인스턴스 생성자 호출 코드를 추가함
          • <clinit>()의 경우 부모 클래스의 <clinit>()을 호출하지 않음 (대신, <clinit>() 메서드에는 java.lang.Object의 <init>()을 호출하는 코드가 생성되는 경우가 많음)
        • 해당 동작들의 순서를 보장함
          • 부모 클래스의 인스턴스 생성자 실행 (super()) --> 변수 초기화 --> 명령문 블록 실행
        • 해당 작업들은 Gen::normalizeDefs() 메서드가 담당함
      • 생성자 생성 외에도 프로그램 로직 일부를 최적화된 코드로 대체하기도 함
        • ex) + 연산자를 사용한 문자열 합치기를 StringBuffer나 StringBuilder의 append()를 이용하는 코드로 대체할 수 있음
    • 구문 트리를 순회하며 필요한 수정을 다 마쳤다면 정보가 다 채워진 심벌 테이블을 com.sun.tools.javac.jvm.ClassWriter 클래스로 전달하고, ClassWriter의 writeClass() 메서드가 최종 클래스 파일에 해당하는 바이트코드를 출력

     

    간단 정리

    • 프론트엔드 컴파일러가 수행하는 최적화의 목적은 주로 코딩 효율 개선이다.
    • 프론트엔드 컴파일러의 역할소스 코드로부터 추상 구문 트리와 중간 바이트코드를 생성하는 것이다.
    • 그 후의 코드 최적화와 최종 바이트코드 생성은 JVM에 내장된 백엔드 컴파일러가 담당한다.
      • 즉, 네이티브 기계어 코드 생성은 JIT 컴파일러 또는 AOT 컴파일러의 역할

    댓글

Designed by Tistory.