JVM은 자바 프로그래밍 언어를 지원하기 위해 설계되었다. 오라클의 JDK 소프트웨어에는 자바 프로그래밍 언어로 작성된 소스 코드를 JVM의 명령어 세트로 변환하는 컴파일러와 자바 가상 머신 자체를 구현하는 실행 시스템(run-time system)있다. 컴파일러가 JVM을 어떻게 활용하는지 이해하는 것은 컴파일러 작성자와 자바 가상 머신 자체를 이해하려는 사람에게 유용하다.
"컴파일러"라는 용어는 때로 JVM의 명령어 세트를 특정 CPU의 명령어 세트로 변환하는 번역기를 가리킬 때 사용된다. 이러한 번역기의 한 예로는 Java 가상 머신 코드가 로드된 후에만 플랫폼별 명령어를 생성하는 JIT(Just-In-Time) 코드 생성기가 있다. 이 장은 코드 생성과 관련된 문제를 다루지 않고 자바 프로그래밍 언어로 작성된 소스 코드를 자바 가상 머신 명령어로 컴파일하는 것만 다룬다.
3장은 대부분의 예제 소스 코드와 해당 예제에 대한 오라클의 JDK 릴리스 1.0.2의 javac 컴파일러가 생성한 JVM 코드의 주석이 달린 목록으로 구성되어 있다. JVM 코드는 JDK 릴리스와 함께 제공되는 javap 유틸리티에 의해 출력되는 비공식적인 "가상 머신 어셈블리 언어"로 작성된다. javap를 사용하여 컴파일된 메서드의 추가적인 예제를 생성할 수 있다.
어셈블리어의 형식은 다음과 같다.
<index> <opcode> [ <operand1> [ <operand2>... ]] [<comment>]
여기서 index는 해당 명령어의 opcode의 인덱스로, 해당 메서드의 바이트 배열에 있는 위치를 가리킨다. 또는 index는 메서드의 시작부터의 바이트 오프셋으로 생각할 수도 있다. opcode는 명령어의 opcode에 대한 mnemonic 이며, 영이거나 그 이상의 operandN은 명령어의 피연산자이다. 선택적인 comment는 줄 끝 주석 구문으로 제공된다.
주석 내의 일부 내용은 javap에 의해 생성되고, 나머지는 사용자가 작성한다. 각 명령어 앞에 붙은 index는 제어 전송 명령어의 대상으로 사용될 수 있다. 예를 들어, goto 8 명령어는 제어를 인덱스 8의 명령어로 이동시킨다. JVM의 제어 전송 명령어의 실제 피연산자는 해당 명령어의 오프셋이며, javap에 의해 표시되며, 해당 메서드로의 보다 쉽게 읽을 수 있는 오프셋으로 표시된다.
run-time constant pool의 인덱스를 나타내는 피연산자 앞에는 해시 기호를 붙이고, 해당 명령어 뒤에는 참조되는 런타임 상수 풀 항목을 식별하는 주석이 있다.
JVM의 코드를 예를 들어서 설명해보자
void spin() {
int i;
for (i = 0; i < 100; i++) {;}// Loop body is empty
}
컴파일러가 이걸 컴파일하는 과정은 다음과 같다.
0 iconst_0 // 정수 상수 0을 푸시한다.
1 istore_1 // 로컬 변수 1에 저장한다. (i=0)
2 goto 8 // 처음에는 증가하지 않는다.
5 iinc 1 1 // 로컬 변수 1을 1씩 증가시킨다. (i++)
8 iload_1 // 로컬 변수 1을 푸시한다. (i)
9 bipush 100 // 정수 상수 100을 푸시한다.
11 if_icmplt 5 // 비교하고, 만약 작으면 루프한다. (i < 100)
14 return // 완료되면 void를 반환한다.
JVM은 스택 지향적이며, 대부분의 작업은 JVM의 현재 프레임의 피연산자 스택에서 하나 이상의 피연산자를 가져오거나 결과를 다시 피연산자 스택에 푸시한다. 메서드가 호출될 때마다 새로운 프레임이 생성되며, 해당 메서드에서 사용하기 위한 새로운 피연산자 스택과 로컬 변수 집합도 생성된다. 따라서 계산의 어느 한 지점에는 많은 프레임과 해당하는 많은 스레드 제어당 피연산자 스택이 있을 수 있다. 현재 프레임의 피연산자 스택만이 활성화된다.
JVM의 명령어 세트는 다양한 데이터 type에 대한 작업에 대해 서로 다른 바이트코드를 사용하여 피연산자 유형을 구별한다. spin 메서드는 int 유형의 값에만 작동한다. 해당 컴파일된 코드의 명령어들 (iconst_0, istore_1, iinc, iload_1, if_icmplt)은 모두 int 유형에 특화되어 있다. (이게 우리가 자바 코드를 쓸 때 타입에 신경써야 되는 이유!)
예제에서 spin 메서드에서 사용되는 두 개의 상수 0과 100은 서로 다른 명령어를 사용하여 피연산자 스택에 푸시된다. 0은 iconst_0 명령어 중 하나인 iconst_0 명령어를 사용하여 푸시된다. 100은 bipush 명령어를 사용하여 푸시된다. bipush 명령어는 푸시하는 값을 즉시 피연산자로 가져온다.
JVM은 특정 피연산자(예: iconst_i 명령어의 경우 int 상수 -1, 0, 1, 2, 3, 4 및 5)가 자주 발생할 것으로 예상되는 경우 이러한 피연산자를 암시적(implicit)으로 사용하여 명령어를 최적화 한다. 예를 들어, iconst_0 명령어는 int 0을 푸시할 것을 알고 있으므로 어떤 값을 푸시할지 알려주는 피연산자를 저장할 필요가 없으며, 피연산자를 가져오거나 디코딩할 필요도 없다. 0을 bipush 0으로 컴파일하는 해도 좋지만, spin의 컴파일된 코드를 한 바이트 더 길게 만들었을 것이고 JVM은 루프마다 명시적인 피연산자를 가져와 디코딩하는 추가적인 시간을 소비했을 것이다. 암시적 피연산자의 사용은 컴파일된 코드를 더 컴팩트하고 효율적으로 만든다.
spin 메서드에서 int 변수 i는 JVM의 로컬 변수 1(인덱스)에 저장된다. 대부분의 JVM 명령어가 직접 로컬 변수가 아닌 피연산자 스택에서 꺼낸 값에 작용하기 때문에, 로컬 변수와 피연산자 스택 간의 값을 전송하는 명령어는 JVM에 대한 컴파일된 코드에서 흔하다. 이러한 작업들은 명령어 세트에서도 특별한 지원을 받는다. spin 메서드에서 값은 istore_1 및 iload_1 명령어를 사용하여 로컬 변수와 피연산자 스택 간에 전송된다. 각각의 명령어는 암시적으로 로컬 변수 1(인덱스)에서 작동한다. istore_1 명령어는 피연산자 스택에서 int를 pop하여 이를 로컬 변수 1에 저장한다. iload_1 명령어는 로컬 변수 1(인덱스)에 있는 값을 피연산자 스택으로 푸시한다.
로컬 변수:
로컬 변수는 각 메서드 호출에 대한 고유한 메모리 영역이다.
각 메서드가 호출될 때마다 새로운 프레임이 생성되며, 해당 프레임 내에 로컬 변수 공간이 할당된다.
로컬 변수는 해당 메서드 내에서 선언된 변수들을 저장하는데 사용된다.
메서드 내부에서 변수에 직접 접근할 때 사용된다.
메서드의 매개변수 및 메서드 내부에서 선언된 변수들이 여기에 저장된다.
피연산자 스택:
피연산자 스택은 JVM이 계산을 수행하는 데 사용되는 메모리 영역이다.
각 프레임마다 하나의 피연산자 스택이 생성된다.
연산에 필요한 값들을 저장하고, 연산 결과를 저장하기 위해 사용된다.
메서드 호출 시 매개변수나 반환값을 전달하고, 연산 결과를 저장하는 데 사용된다.
대부분의 연산은 피연산자 스택을 사용하여 수행된다. 예를 들어, 산술 연산이나 메서드 호출 시 인자 전달 등이 여기에 해당된다.
로컬 변수의 사용 및 재사용은 컴파일러 사용자의 맘이다. 특수화된 로드 및 저장 명령어는 컴파일러 작성자가 가능한 한 많이 로컬 변수를 재사용하도록 권장한다. 이로 인해 생성된 코드는 더 빠르고, 더 콤팩트하며, 프레임 내 공간을 덜 차지하니까 코드 짤 때 재활용 잘 하자.
JVM은 로컬 변수에 대한 개 많이 쓰는 명령의 명령어를 만들어 놨다.
예제의 for loop에서 i의 값을 1씩 증가시키는 건 정말 많이 활용해서 iinc이란 명령어를 만들어놨다.
iinc 명령어는 로컬 변수의 내용을 한 바이트로 지정된 값만큼 증가시킨다. spin 메서드의 iinc 명령어는 첫 번째 로컬 변수(첫 번째 피연산자)를 1씩 증가시킨다(두 번째 피연산자). iinc 명령어는 루프 구조를 구현할 때 매우 편리하게 사용된다.
예시의 spin의 for 루프는 주로 다음 명령어들에 의해 실행된다:
5 iinc 1 1 // 로컬 변수 1을 1씩 증가시킴 (i++)
8 iload_1 // 로컬 변수 1 (i)를 푸시함
9 bipush 100 // 정수 상수 100을 푸시함
11 if_icmplt 5 // 비교하고 만약 작으면 루프함 (i < 100)
bipush 명령어는 int로 값을 100으로 피연산자 스택에 푸시한 후, if_icmplt 명령어가 피연산자 스택에서 해당 값을 pop하여 i와 비교합니다. 비교가 성공하면 (변수 i가 100보다 작을 경우), 제어는 인덱스 5로 전달되어 for 루프의 다음 반복이 시작된다. 그렇지 않으면, 제어는 if_icmplt 다음의 명령어로 전달된다.
또 다른 예시로 type만 바꾼 dspin() 매서드를 살펴보자.
void dspin() {
double i;
for (i = 0.0; i < 100.0; i++) {
; // Loop body is empty
컴파일된 코드는 다음과 같다.
Method void dspin()
0 dconst_0 // double 상수 0.0을 푸시
1 dstore_1 // 로컬 변수 1과 2에 저장
2 goto 9 // 처음에는 증가하지 않는다
5 dload_1 // 로컬 변수 1과 2를 푸시
6 dconst_1 // double 상수 1.0을 푸시
7 dadd // 더하기; dinc 명령어는 없다
8 dstore_1 // 결과를 로컬 변수 1과 2에 저장한다
9 dload_1 // 로컬 변수 1과 2를 푸시한다
10 ldc2_w #4 // double 상수 100.0을 푸시한다
13 dcmpg // if_dcmplt 명령어가 없다
14 iflt 5 // 비교하고 작으면 루프한다 (i < 100.0)
17 return // 완료되면 void를 반환한다
double 값은 두 개의 로컬 변수를 차지하는 점을 명심하자, 이 값은 두 로컬 변수 중 작은 인덱스를 사용하여 액세스된다. long 타입 값도 마찬가지다.
double doubleLocals(double d1, double d2) {
return d1 + d2;
}
Method double doubleLocals(double,double)
0 dload_1 // 첫 번째 인수는 로컬 변수 1과 2에 있다.
1 dload_3 // 두 번째 인수는 로컬 변수 3과 4에 있다.
2 dadd // 더하기
3 dreturn // double 값을 반환한다.
doubleLocals에서 double 값을 저장하는 데 사용되는 로컬 변수 쌍의 로컬 변수는 절대로 개별적으로 사용되어선 안된다.
JVM의 1바이트 크기의 명령어는 컴파일된 코드가 매우 컴팩트하게 되도록 한다. 그러나 1바이트 크기의 명령어는 JVM 명령어 세트가 작아야 한다는 것을 의미한다. 이런 점을 고려하여, JVM은 모든 데이터 유형에 대해 동등한 지원을 제공하지 않는다. 즉, 완전히 독립적이지 않다. (Table 2.11.1-A)
-> 일부 타입의 명령어가 없는 경우가 있다. 이런 경우 int의 명령어를 재탕해서 사용한다. 자세한 내용은 내가 올린 챕터 2를 참고하도록
예를 들어, 예제 spin의 for 문에서 int 유형의 값 비교는 단일 if_icmplt 명령어를 사용하여 구현할 수 있다. 그러나 JVM 명령어 세트에는 double 유형의 값에 대한 조건 분기를 수행하는 단일 명령어가 없다. 따라서 dspin은 double 유형의 값 비교를 dcmpg 명령어로 수행한 다음 iflt 명령어로 변환해서 사용한다.
JVM은 int 형식의 데이터를 가장 직접적으로 지원한다. 이는 JVM의 효율적인 구현을 위함이다. 이는 일반적인 프로그램에서 int 데이터를 많이 씀으로 인해 JVM의 피연산자 스택 및 로컬 변수 배열의 효율을 위함이다. 그 외의 정수형 타입(integral types)은 보다 간접적인 지원을 받는다. 예를 들어 byte, char, 또는 short 버전의 저장, 로드, 또는 덧셈 명령어가 없다. short type를 이용한 예제를 살펴보자.
void sspin() {
short i;
for (i = 0; i < 100; i++) {
; // Loop body is empty
}
}
앞 예제와 같이 JVM을 위해 컴파일되어야 한다. 다른 타입(int와 같은)을 사용하여 동작하는 명령어를 사용하여 short와 int 값 사이를 변환한다. short 데이터에 대한 연산 결과가 적절한 범위 내에 유지되도록 필요한 경우 int와 short 값 사이를 변환한다.
Method void sspin()
0 iconst_0
1 istore_1
2 goto 10
5 iload_1 // short 타입이 int 타입의 연산자로 계산됨
6 iconst_1
7 iadd
8 i2s // Truncate int to short
9 istore_1
10 iload_1
11 bipush 100
13 if_icmplt 5
16 return
자바 가상 머신에서 byte, char 및 short 타입에 대한 직접적인 지원이 부족한 것에 큰 지장은 없다. 왜냐하면 이러한 타입의 값들은 내부적으로 int로 promote 된다.(byte와 short는 int로 부호 확장되며, char는 0으로 확장된다). 따라서 byte, char 및 short 데이터에 대한 연산은 int 명령어를 사용하여 수행될 수 있다. 유일한 추가 비용은 int 연산의 값을 유효한 범위로 바꾸는것.
JVM에서 long 및 부동 소수점 타입은 중간 수준의 지원을 갖추고 있다. 완전한 조건부 제어 전송 명령어가 없는 것만 빠져 있다. 자세한 건 2장을 참고하자.
JVM은 일반적으로 피연산자 스택에서 산술 연산을 수행한다. (예외는 iinc 명령어로, 이 명령어는 지역 변수의 값을 직접 증가시킨다.) 예를 들어, align2grain 메서드는 int 값을 주어진 2의 거듭제곱에 대입한다.
int align2grain(int i, int grain) {
return ((i + grain-1) & ~(grain-1));
}
산술 연산의 피연산자는 피연산자 스택에서 pop되고, 연산의 결과는 다시 피연산자 스택에 push 된다. 따라서 산술 하위 계산의 결과는 그 계산의 포함 계산의 피연산자로 사용될 수 있다. 예를 들어, ~(grain-1)의 계산은 다음과 같은 명령어로 처리된다:
5 iload_2 // Push grain
6 iconst_1 // Push int constant 1
7 isub // Subtract; push result
8 iconst_m1 // Push int constant -1
9 ixor // Do XOR; push result
먼저, 지역 변수 2(index)와 명령어에 내포어 직접적으로 사용되는 int 값 1을 사용하여 grain-1이 계산된다. 이러한 피연산자들은 피연산자 스택에서 pop되어 그들의 차이가 다시 피연산자 스택에 push된다. 따라서 차이는 즉시 ixor 명령어의 하나의 피연산자로 사용할 수 있다. (참고로, ~x == -1^x ) 비슷하게, ixor 명령어의 결과는 다음 iand 명령어의 피연산가 된다.
컴파일 되는 건 다음과 같다.
Method int align2grain(int,int)
0 iload_1
1 iload_2
2 iadd
3 iconst_1
4 isub
5 iload_2
6 iconst_1
7 isub
8 iconst_m1
9 ixor
10 iand
11 ireturn
여기서 잠깐!
대략적인 그림으로 살펴보자
┌───────────────────────┐ ┌───────────────────────┐
│ Method Frame │ │ Method Frame │
│-----------------------│ │-----------------------│
│ Local Variables: │ │ Local Variables: │
│ var0 │ │ var0 │
│ var1 │ │ var1 │
│ var2 │ │ var2 │
│ │ │ │
│ Operand Stack: │ │ Operand Stack: │
│ ... │ │ ... │
│ │ │ result of │
│ │ │ previous operation │
└───────────────────────┘ └───────────────────────┘
위 다이어그램에서, 각 메서드 호출은 별도의 호출 프레임을 가지고 있다. 각 프레임에는 지역 변수와 피연산자 스택이 있다. 메서드가 실행될 때, 지역 변수는 메서드에 선언된 변수들을 저장하는 데 사용되며, 피연산자 스택은 산술 연산에 사용되는 값을 저장하는 데 사용됩니다.
예를 들어, 위의 과정에서는 각 명령어가 실행될 때마다 피연산자 스택에서 값들이 pop되고 연산 후 결과가 다시 푸시된다. 이러한 과정은 메서드가 호출될 때마다 해당 메서드의 호출 프레임에서 발생하며, 메서드가 종료될 때 해당 프레임은 제거된다.
현재 클래스의 런타임 상수 풀(run-time constant pool)을 통해 많은 숫자 상수뿐만 아니라 객체, 필드 및 메서드에 접근된다. 객체 액세스는 나중에 고려하자. int, long, float 및 double 타입의 데이터뿐만 아니라 String 클래스의 인스턴스에 대한 참조는 ldc, ldc_w 및 ldc2_w 명령어를 사용하여 관리된다.
ldc 및 ldc_w 명령어는 double 및 long을 제외한 다른 유형의 런타임 상수 풀에 있는 값을 접근시키 데 사용된다. ldc_w 명령어는 런타임 상수 풀 아이템이 많고 아이템에 접근하기 위해 더 큰 인덱스가 필요한 경우에만 ldc 대신 사용된다. ldc2_w 명령어는 double 및 long 유형의 모든 값을 접근하는 데 사용된다. 여기에는 넓은(wide) 변형이 없다. 다시 말해, ldc2_w 명령어는 항상 double 및 long 값에 대해서만 사용되며, 다른 유형의 값에 대한 변형이 존재하지 않는다.
byte, char 또는 short 유형의 정수 상수 및 작은 int 값은 bipush, sipush 또는 iconst_i 명령어를 사용하여 컴파일될 수 있다. 일부 작은 부동 소수점 상수는 fconst_f 및 dconst_d 명령어를 사용하여 컴파일될 수 있다.
이러한 모든 경우에 컴파일은 간단하다. 예를 들어, 다음과 같은 상수의 경우:
void useManyNumeric() {
int i = 100;
int j = 1000000;
long l1 = 1;
long l2 = 0xffffffff;
double d = 2.2;
...do some calculations...
}
다음과 같이 됨:
Method void useManyNumeric()
0 bipush 100 // Push small int constant with bipush
2 istore_1
3 ldc #1 // Push large int constant (1000000) with ldc
5 istore_2
6 lconst_1 // A tiny long value uses small fast lconst_1
7 lstore_3
8 ldc2_w #6 // Push long 0xffffffff (that is, an int -1)
// Any long constant value can be pushed with ldc2_w
11 lstore 5
13 ldc2_w #8 // Push double constant 2.200000
// Uncommon double values are also pushed with ldc2_w
16 dstore 7
...do those calculations...