Java - JVM에 대한 이해

JIT(Just In Time Compiler)

Java 프로그램의 성능을 향상하기 위해 JIT라는 컴파일러를 사용한다. 이 컴파일러는 프로그램이 실행되는 동안 번역을 수행한다. JVM의 인터프리터가 번역한 코드 segment를 시스템의 native 코드로 번역한다. 자세히 설명하자면 JIT는 자주 사용되는(hot) 메서드를 찾아 해당 메서드를 CPU가 바로 실행할 수 있는 명령어 집합으로 번역한다. 번역된 명령어는 다음 실행을 위해 저장된다. JIT의 최적화(compilation)와 인터프리터의 interpretation의 균형이 발전을 이루면서 Java 프로그램의 interpretation 오버헤드는 점점 줄었다.

요약하자면 JIT는 자주 호출되는 코드를 최적화(미리 native instruction으로 번역)하여 성능을 향상시킨다. JavaScript의 V8엔진에는 TurboFan이라는 JIT가 있다.

java program

Translating and Starting a Program

JVM이 java파일을 어떻게 실행하는 지 알기 전에 C언어로 작성된 코드가 어떻게 실행되는지 알아보자. Java보다 20년 정도 먼저 개발된 C 언어의 개념들이 Java 개발에 영향을 안 주었을 리 없다고 생각하기 때문이다.

디스크에 저장된 C언어로 작성된 파일을 실행 가능한 프로그램으로 만드는 과정은 크게 네 단계로 구성된다.

번역 계층

High-level 언어(C)로 작성된 파일이 assembly language로 번역되고 assembly 파일이 machine language로 이루어진 object 모듈로 조립된다(assembled). 마지막으로 linker가 여러 object 모듈들을 library routine과 조합(combine)하여 모든 참조들을 환원(resolve)한다.

일반적으로 object 파일은 바로 실행할 수 없다.

Compiler

컴파일러는 C언어로 작성된 파일을 assembly language 프로그램으로 번역한다. Assembly 프로그램은 기계가 이해할 수 있는 추상적인 기호로 작성된 형태(symbolic form)이다. Assembly의 symbol들은 나중에 cpu가 인식할 수 있는 형태로 대체된다.

Assembler

Assembler는 assembly 코드의 pseudoinstruction을 cpu가 이해할 수 있는 instruction으로 번역한다. pseudoinstruction은 machine language(instruction)를 추상화 한 것이다. Assembler의 주된 작업은 assembly를 machine 코드로 바꾸는 것이다. Assembler는 assembly code program을 object file로 변환한다. Object 파일은 machine language instruction, data(변수의 값)과 instruction을 메모리에 로드하기 위한 정보의 조합이다. assembly instruction을 binary version(machin code)으로 바꾸려면 assembler는 모든 label(symbol)에 대한 주소값을 결정해야 한다. Assembler는 분기(branch)와 data transfer instruction(mov 등)에 사용된 모든 label에 대해 추적한다. Symbol table에는 symbol과 그에 대응되는 주소값이 저장되어 있다.

linker

이번 단계는 assemble된 routine들(예를 들면 라이브러리 루틴)을 서로 연결하는 것이다. Library routine이란 일반적으로 라이브러리 내에 정의된 함수를 의미한다.

하나의 프로그램을 여러 파일로 나누어 작성한 뒤 개별적으로 컴파일하고 assembler로 object 파일을 만든 뒤 하나의 실행파일로 합치는 이유는 반복을 피하기 위함이다. 예를 들어 내가 정의한 foo()라는 함수를 수정하여 실행파일을 빌드하려는데, 하나의 파일에 모든 코드가 존재한다면 다시 컴파일할 필요가 없는 코드들까지 모두 빌드 과정을 거치게 된다. Standard library는 거의 변할 일이 없는데 항상 다시 빌드되어야 하는 것이다. 이는 컴퓨팅 자원의 심각한 낭비이다.

Linker는 독립적으로 생성된 object file들을 연결하여 하나의 실행 파일을 만든다.
Linking이 진행되는 방식은 두 가지이다.

Static Linking

Linker가 object file들을 연결하는 단계에서 library routine들이 실행 프로그램에 포함된다. 이는 실행 파일의 용량을 증가시키고 실행 시에는 메인 메모리를 낭비하게 된다. 예를 들어 의 `printf` 함수만 사용했더라도 의 모든 routine이 실행 파일에 연결되고 를 사용한 프로그램을 여러 개 실행시킨다면 똑같은 명령어()가 메모리에 계속 로드된다.

Dynamic Linking

Static Linking의 단점을 보완하기 위해 실행 중에 library routine이 메모리에 로드되고 여러 프로세스가 공유한다.

class file symbol table

Class 파일은 javac에 의해 생성되며 세 가지 모드로 실행될 수 있다.

  • standalone mode - main 메서드를 실행한다.
  • 다른 클래스들과 JAR, EAR, WAR 파일로 묶일 수 있다. (C의 .o 파일과 비슷하다.)
  • jlink와 같은 툴을 사용하여 module로 묶일(bundled) 수 있다. (.so 파일과 비슷하다.)

.so 파일과 .o 파일의 차이

Object 파일(.o)은 컴파일러가 생성한 파일이며 전체 프로그램에서의 중간 결과물이다. Linker에 의해 완성된 하나의 실행 파일이 된다.

Shared object 파일(.so)여러 프로그램이 공유해서 사용할 수 있는 동적 라이브러리 파일이다. Windows의 .dll 파일과 유사하다. 프로그램이 실행될 때 필요에 따라 동적으로 메모리에 로드된다. 여러 프로그램에 링크되어있어도 메모리에는 한 번만 로드되어 여러 프로그램들이 같은 메모리 주소를 참조하여 해당 라이브러리의 기능을 사용한다. 코드의 재사용성을 높이고 메모리 사용을 최적화한다.

특정 클래스를 실행할 때(정적 변수/메서드, 인스턴스 변수/메서드 등에 접근)가 되면, JVM은 해당 클래스 파일의 위치를 찾고 메모리에 로드한다. Loading 과정은 파일을 여러 필드로 parsing하고 그 정보들을 적절한 format으로 JVM의 method area에 저장하는 작업을 포함한다. JVM의 method area는 클래스나 인터페이스 정보같이 런타임동안 변하지 않는 정보를 저장하는 영역이다. 이 영역에서 메소드들과 다른 여러 정보(item)를 조회할 수 있으며 복수의 쓰레드들에게 공유된다.

Class 파일의 format은 새로운 Java 버전이 출시될때마다 조금씩 변했다. Object 파일의 형식과 유사하게 다음과 같이 구성된다.

  • file header - 클래스 파일을 생성한 Java version 등
  • constant pool
  • 그 외(methods, data fields 등)

수년동안 JDK의 새로운 버전이 출시되었지만 클래스 파일의 layout은 바꾸지 않았다. 대신 data item과 attribute들을 바꾸었다. 기본 layout이 확장 가능한 구조였기 때문에 이런 변화는 기존 코드에 지장을 주지 않을 수 있었다.

Introducing the constant pool

Constant pool은 클래스 파일에서 중요한 영역(section) 중 하나이다. 이 영역은 해당 클래스를 위한 symbol table 역할을 하는 item들의 컬렉션이다. Constant pool에는 참조되는 클래스들의 이름, 문자열과 numeric constant의 초기값, 그리고 적절한 실행에 필요한 잡다한 값이 저장되어 있다.

package foo;

public class Hello {
    public static void main(String[] args) {
        for ( int i = 0; i < 10; i++ )
            System.out.println("Hello, world!");
    }
}

위의 코드를 javac로 컴파일한 뒤 $ javap -verbose foo.Hello 명령어를 실행하면 아래와 같은 결과가 출력된다. 해당 명령어는 JDK의 javap class file disassembler를 사용하는 것이다.

(constant pool을 제외한 정보는 생략)

Constant pool:
   #1 = Methodref          #2.#3          // java/lang/Object."<init>":()V
   #2 = Class              #4             // java/lang/Object
   #3 = NameAndType        #5:#6          // "<init>":()V
   #4 = Utf8               java/lang/Object
   #5 = Utf8               <init>
   #6 = Utf8               ()V
   #7 = Fieldref           #8.#9          // java/lang/System.out:Ljava/io/PrintStream;
   #8 = Class              #10            // java/lang/System
   #9 = NameAndType        #11:#12        // out:Ljava/io/PrintStream;
  #10 = Utf8               java/lang/System
  #11 = Utf8               out
  #12 = Utf8               Ljava/io/PrintStream;
  #13 = String             #14            // Hello, world!
  #14 = Utf8               Hello, world!
  #15 = Methodref          #16.#17        // java/io/PrintStream.println:(Ljava/lang/String;)V
  #16 = Class              #18            // java/io/PrintStream
  #17 = NameAndType        #19:#20        // println:(Ljava/lang/String;)V
  #18 = Utf8               java/io/PrintStream
  #19 = Utf8               println
  #20 = Utf8               (Ljava/lang/String;)V
  #21 = Class              #22            // foo/Hello
  #22 = Utf8               foo/Hello
  #23 = Utf8               Code
  #24 = Utf8               LineNumberTable
  #25 = Utf8               main
  #26 = Utf8               ([Ljava/lang/String;)V
  #27 = Utf8               StackMapTable
  #28 = Utf8               SourceFile
  #29 = Utf8               Hello.java

첫 번째 열의 숫자는 단순히 데이터 항목(item)을 위한 entry number이다. #0이 아닌 #1부터 시작한다. 사실 0번째 데이터가 있지만 절대 참조되지 않는 dummy entry이다.
두 번째 열은 entry의 type을 표현한다.
세 번째 열은 entry의 값이다. // 뒤의 문자열은 참조하는 값이 무엇인지 나타내는 javap의 기능이다.

Java 클래스에서 문자열 데이터를 표현하기 위해 내부적으로 Utf8이라는 entry type을 사용한다. 실제로는 0x00 바이틀 제거했다는 점에서 UTF-8 표준과 다르지만 나머지는 UTF-8 인코딩과 동일하다.

Constant pool의 Utf8 타입이 아닌 모든 entry들은 결국 Utf8 entries 중 하나로 환원된다.(참조관계를 따라가다 보면 종점은 Utf8 타입 entry이다.)

Using the constant pool

main 함수는 아래와 같은 bytecode로 표현된다. 이것도 $javap -verbose foo.Hello 명령어로 확인 가능하다.

 0: iconst_0
 1: istore_1
 2: iload_1
 3: bipush        10
 5: if_icmpge     22
 8: getstatic     #7                  // Field java/lang/System.out:Ljava/io/PrintStream;
11: ldc           #13                 // String Hello, world!
13: invokevirtual #15                 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
16: iinc          1, 1
19: goto          2
22: return

메서드는 1byte 명령어로 구성되며 각 명령어에는 0개 이상의 argument가 존재한다. 인자들은 값이거나 constant pool의 entry에 대한 참조이다.

Local variable

지역 변수는 stack에서 관리된다. 그리고 stack에 접근하는 instruction은 이미 컴파일 시에 모두 결정되어 assembly로 번역되었기 때문에 symbol table과 같은 meta dat를 사용해서 관리할 필요는 없다.

컴파일러는 함수가 사용하는 지역 변수들이 필요로 하는 메모리 공간(스택 프레임)의 크기를 계산한다. 그리고 해당 정보를 바탕으로 스택 프레임을 생성하고 지역 변수들에 접근하는 assembly를 작성한다.

reference

Categories:

Updated:

Comments