전통적으로 컴파일이라고 하면 어떤 언어로 된 소스 코드를 기계가 인식할 수 있는 네이티브 코드로 변환하는 과정을 의미하지만, 자바에서의 컴파일은 자바 언어로 된 코드를 JVM이 인식할 수 있는 JVM 명령어 코드(바이트코드)로 변환하는 것을 의미한다. 드물지만 자바에서의 컴파일도 일반적인 의미의 컴파일처럼 기계가 인식할 수 있는 코드로 변환하는 과정을 의미할 때도 있다. 대표적으로 JIT 컴파일러가 하는 컴파일은 바이트코드로 변환하는 것이 아니라 바이트코드를 네이티브 코드로 변환하는 것을 의미한다.
실행 파일 생성 과정
자바 소스 코드를 컴파일하는 과정이 몇 단계로 구성되는지 구체적으로 스펙에 규정되어 있지는 않다. 참고로 C로 작성된 코드로부터 실행 파일을 만드는 과정은 보통 다음과 같이 4 단계로 구분한다.
참고로 중요하진 않지만 자바는 전처리 과정에서 주석이 있던 행 자체가 제거되지는 않는다. 바이트코드 내용 중에 자바 소스 코드의 행 번호와 바이트코드 명령어의 위치를 매핑해주는 부분이 있는데 이 때 표시되는 자바 소스 코드 행 번호는 주석이 있던 행이 제거되지 않은 상태 기준의 행 번호가 표시된다.
자바는 컴파일 결과로 나온 바이트코드가 JVM에 의해 실행되면서 네이티브 기계어 코드로 변환되므로, 프로그램 실행 전에 네이티브 기계어 코드를 만들어내는 어셈블리 단계가 없다고 볼 수 있다. 마찬가지로 링크 단계도 프로그램 실행 전에 수행되지 않고 JVM에 의해 프로그램이 실행될 때 동적으로 수행된다.
따라서 자바의 컴파일 절차는 아주 단순하다. 그림조차도 그릴 필요 없고 다음과 같이 표현할 수 있다.
1
자바 소스 코드 파일(.java) -> javac 컴파일러 -> JVM 바이트코드(.class)
앞에서 나온 C 컴파일 과정 그림에서 살펴본 것처럼 컴파일과 어셈블리 과정을 하나로 뭉쳐서 컴파일이라고 하기도 한다. 아래에서 살펴볼 일반적인 컴파일 세부 단계는 컴파일과 어셈블리 과정을 하나로 뭉친 개념이다.
컴파일 세부 단계
1. Lexical Analysis(어휘 분석)
Lexical Analyzer(Lexer 또는 Tokenizer라고도 한다)가 소스 코드에서 문자 단위로 읽어서 어휘소(lexeme)를 식별하고 어휘소를 설명하는 토큰 스트림(Token Stream)을 생성한다.
어휘소는 식별가능한 문자 시퀀스인데 다음과 같은 것들을 통칭한다.
키워드(keywords): public, class, main, for 등
리터럴(literals): 1L, 2.3f, "Hello" 등
식별자(identifiers): 변수 이름, 상수 이름, 함수 이름 등
연산자(operators): +, - 등
구분 문자(punctuation characters): ,, [], {}, () 등
토큰(Token)은 타입(키워드, 리터럴, 식별자 등)과 값(public, 1L, main 등)으로 구성되며 어휘소를 설명하는 객체로 볼 수 있다.
식별자 토큰은 어휘 분석 단계에서 심볼 테이블에 저장되고 이후 단계에서 계속 사용된다.
2. Syntax Analysis(구문 분석)
Syntax Analyzer(구문 분석기, 파서(Parser)라고도 한다)가 어휘 분석 결과로 나온 토큰 스트림이 언어의 스펙으로 정해진 문법 형식에 맞는지 검사해서, 맞지 않으면 컴파일 에러를 내고, 맞으면 파스 트리(Parse Tree)를 생성한다(구문 분석 단계의 결과로 나오는 파스 트리를 추상 구문 트리(Abstract Syntax Tree)라고 부르는 자료도 있다).
자바의 컴파일 과정은 여기까지다. 자바의 컴파일 과정을 한 마디로 요약하면 자바 코드를 자바 언어 스펙에 따라 분석/검증하고, JVM 스펙의 class 파일 구조에 맞는 바이트코드를 만들어내는 과정 이라고 할 수 있다.
바이트코드는 로딩, 링크 과정을 거쳐야 하지만 분명히 JVM에서 실행될 수 있는 코드다. 따라서 꼭 자바 언어 스펙을 따르는 자바가 아니라도, JVM 스펙의 class 파일 구조에 맞는 바이트코드를 만들어 낼 수 있다면 어떤 언어든 JVM에서 실행될 수 있다. 클로저(Clojure)나 스칼라, 코틀린 등이 JVM에서 실행될 수 있는 이유가 바로 여기에 있다.
자바 코드의 변수, 상수, 제어문, 연산, 인자, 메서드 호출, 배열, switch문, 예외 처리, finally, synchronization, 애너테이션, 모듈(Java 9 이후) 등이 바이트코드로 어떻게 변환되는지는 JVM 스펙의 3장에 나오는 예시를 통해 확인할 수 있다.
그냥 지나치면 허전하니 간단한 자바 파일과 컴파일 된 바이트코드를 한 번 살펴보자.
바이트코드 구경하기
그냥 헬로월드는 너무 단순하니까 인터페이스를 사용하는 코드 예제를 살펴보자. main 메서드를 가진 GreetingMain 클래스가 Greeting 인터페이스를 구현하는 KoreanGreeting 클래스를 사용하는 예제다.
먼저 인터페이스인 Greeting부터 살펴보자.
Greeting
1 2 3 4 5 6
package homo.efficio.jvm.sample;
publicinterfaceGreeting{
String sayHello(String name); }
메서드 하나를 가지고 있는 아주 단순한 인터페이스다. 컴파일 한 후에 다음과 같이 javap 명령으로 바이트코드를 확인할 수 있다. javap는 바이너리인 바이트코드 .class 파일을 텍스트로 보여주는 일종의 역어셈블러 프로그램이다.
인터페이스의 바이트코드는 Classfile, public interface …, Constant pool, { 바이트코드 }, SourceFile 이렇게 크게 5가지 항목으로 구분되어 표시된다.
컴파일과 실행 관점에서 주목해야할 항목은 상수 풀(Constant pool)과 실제 소스 코드로부터 변환된 바이트코드 내용이다.
상수풀에는 Class와 Utf8로 분류되는 값들이 표시되어 있다. 상수 풀에 포함된 정보는 #N의 형식으로 인덱스되어 있다. Class는 말그대로 클래스임을 나타내고 Utf8은 클래스나 메서드 등의 이름을 나타내는 식별자를 UTF-8로 인코딩 된 값으로 나타내고 있다. Class로 분류된 항목의 값은 #7 같이 다른 항목을 가리키는 일종의 참조로 되어 있고, 참조를 통해 가리키는 항목의 값은 주석으로 병기(// homo/efficio/jvm/sample/Greeting)되어 있다.
바이트코드에는 원래 자바 소스에는 없던 abstract라는 키워드가 추가되어 표시되어 있다. sayHello 메서드의 파라미터 정보((Ljava/lang/String;)) 와 반환 타입 정보(Ljava/lang/String;)가 descriptor 항목에 표시되고, 접근 지정자(ACC_PUBLIC, ACC_ABSTRACT)가 flags 항목에 표시된다.
아주 간단해서 바이트코드의 상수풀과 바이트코드가 어떤 식으로 기술되는지 비교적 쉽게 감을 잡을 수 있다. 너무 간단해서 바이트코드 내용이 별로 없기 때문에, 바이트코드에 대한 설명은 구현 클래스인 KoreanGreeting에서 실제 코드와 함께 다시 살펴볼 것이다.
KoreanGreeting
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15
package homo.efficio.jvm.sample;
publicclassKoreanGreetingimplementsGreeting{
private String hello = "안녕 ";
@Override public String sayHello(String name){ return getHello() + name; }
private String getHello(){ returnthis.hello; } }
Greeting 인터페이스를 구현하고 있고, hello라는 필드를 하나 가지고 있는 단순한 클래스다. getHello()는 메서드가 2개일 때는 어떻게 표시되는지, 내부 private 메서드 호출은 어떻게 표시되는지 보기 위해 일부러 추가했다.
private java.lang.String getHello(); descriptor: ()Ljava/lang/String; flags: (0x0002) ACC_PRIVATE Code: stack=1, locals=1, args_size=1 0: aload_0 1: getfield #3 // Field hello:Ljava/lang/String; 4: areturn LineNumberTable: line 13: 0 LocalVariableTable: Start Length Slot Name Signature 0 5 0 this Lhomo/efficio/jvm/sample/KoreanGreeting; } SourceFile: "KoreanGreeting.java" InnerClasses: public static final #44= #43 of #47; // Lookup=class java/lang/invoke/MethodHandles$Lookup of class java/lang/invoke/MethodHandles BootstrapMethods: 0: #30 REF_invokeStatic java/lang/invoke/StringConcatFactory.makeConcatWithConstants:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite; Method arguments: #31 \u0001\u0001
자바 소스 코드 상으로는 Greeting 인터페이스와 몇 줄 차이 안 나는데 바이트코드의 양은 큰 차이가 난다. 바이트코드를 분석하는 것이 글의 목적이 아니라 컴파일이라는 큰 과정을 살펴보면서 결과물인 바이트코드도 눈으로 구경해보자는 취지이므로 개략적인 생김새와 기본적인 내용만 훑어보자.
상수 풀
상수 풀에는 Methodref, String, Fieldref, Methodref, InvokeDynamic, NameAndType, MethodHandle 등 새로운 종류의 상수 항목이 나오는데, 이름과 값을 조금 살펴보면 어떻게 사용되는지 대략 감을 잡을 수 있다. 소스 코드 수준에서 정적으로 파악할 수 있는 변수, 상수, 메서드 등의 일람표라고 생각하면 된다.
상수 풀에 저장되는 상수 항목의 종류는 총 17개이며, 자세한 내용은 JVM 스펙을 참고한다.
바이트코드
{} 로 묶여서 표시되는 바이트코드는 대략 다음과 같은 구조로 되어 있다.
필드나 메서드 선언부
descriptor: 필드의 타입이나 메서드의 파라미터 및 반환 타입
flags: 접근 지정자
Code
stack, locals, args_size: 스택 높이, 로컬 변수 갯수, 인자 갯수
실제 구현 코드: 코드 위치, 바이트코드 명령어(instruction), 오퍼랜드(operand, 피연산자)
LineNumberTable: 자바 코드의 행 번호와 바이트코드의 위치 매핑 테이블
LocalVariableTable: 로컬 변수 테이블
어셈블리어 프로그래밍 경험이 있는 개발자에게는 바이트코드가 그리 낯설지 않을 것이다. 바이트코드의 대부분은 오퍼랜드 스택에 값을 넣고, 빼고, 읽고, 복사하고, 스왑하거나 메서드를 호출하는 내용을 담고 있다.
바이트코드 명령어에 대한 자세한 내용은 JVM 스펙을 참고하고 여기에서는 메서드 호출과 관계있는 invoke* 명령어만 짧게 알아보자.
명령어 이름
하는 일
invokeinterface
인터페이스에 정의된 메서드 호출
invokespecial
생성자, 현재 클래스의 메서드, 수퍼클래스의 메서드 호출
invokestatic
정적 메서드 호출
invokevirtual
자바 메서드 호출의 기본 방식이며, 객체 참조(obj.)를 붙여서 호출되는 일반적인 메서드 호출
invokedynamic
JVM에서 실행되는 동적 타입 언어를 위해 Java 7에 추가된 명령어. 람다식도 invokedynamic을 이용해서 구현되었다. 자세한 내용은 오라클 문서나 네이버 문서 또는 DZone 문서를 참고하자.
한 가지 눈여겨 볼 것은 실제 자바 소스 코드에는 없던 디폴트 생성자가 추가되어 있다는 점이다. 컴파일러가 자동으로 추가해준다는 사실을 실제로 확인한 셈이다. 디폴트 생성자는 자바 언어 스펙을 참고하자.
GreetingMain
Greeting 인터페이스와 이를 구현한 KoreanGreeting 클래스를 사용해서 인사말을 찍는 클래스다.
이 글은 컴파일 과정을 훑어보는 게 목적이었으므로 바이트코드 구경은 여기서 줄인다. 바이트코드에 대한 내용은 더 궁금하다면 알고 싶은 부분을 직접 코딩/컴파일하고 javap와 JVM 스펙으로 확인해보는 것이 가장 좋고, 2탄을 참고해도 좋다.
마무리
여기까지 자바 소스 코드가 바이트코드로 어떻게 컴파일되는지 알아봤다. 짧게 정리해보면 다음과 같다.
자바도 전처리, 컴파일, 링크 과정을 통해 최종 실행 파일이 만들어진다.
컴파일의 세부 단계는 어휘 분석, 구문 분석, 의미 분석, 중간 코드 생성, 중간 코드 최적화로 구성된다.
자바 컴파일은 자바 코드를 자바 언어 스펙에 따라 분석/검증하고, JVM 스펙의 class 파일 구조에 맞는 바이트코드를 만들어내는 과정이다.
자바 소스 코드를 컴파일한 결과로 나오는 class 파일은 크게 보면 클래스 메타 정보, 상수 풀, 코드 구현부(JVM 명령어+오퍼랜드)로 구성된다.
소스 코드에서 정적으로 파악할 수 있는 변수, 상수, 메서드 등의 정보가 클래스 파일 단위의 상수 풀(Constant Pool)에 저장되고, 연산, 제어, 메서드 호출 등은 JVM 명령어와 상수 풀에 저장된 항목을 오퍼랜드로 사용하는 바이트코드로 변환되어 코드 구현부에 저장된다.
javap 명령으로 바이너리 바이트코드를 눈으로 읽을 수 있는 텍스트로 역어셈블해서 확인할 수 있다.