javap로 바이트코드 읽기
javap로 클래스 파일을 열어 피연산자 스택, 제네릭 브리지 메서드, finally의 예외 테이블, invokedynamic을 읽고 스택 트레이스에 나타난 낯선 메서드의 정체를 확인합니다.
스택 트레이스를 읽다 보면 소스와 맞지 않는 줄을 만날 때가 있습니다. 예외가 난 위치가 메서드 본문이 아니라 클래스 선언 줄로 찍히거나, lambda$main$0처럼 직접 만든 적 없는 메서드 이름이 보이는 경우입니다. 이런 줄은 javac가 소스를 클래스 파일로 바꾸면서 만들어 넣은 코드에서 나옵니다. 그러니 원인을 알려면 소스가 아니라 클래스 파일에 실제로 무엇이 들어 있는지 봐야 합니다.
JDK에 들어 있는 javap는 클래스 파일을 사람이 읽을 수 있는 형태로 풀어 보여 주는 역어셈블러(disassembler)입니다. 바이트코드를 다시 자바 소스로 되돌리는 디컴파일러가 아니므로 소스를 복원해 주지는 않지만, JVM이 실제로 실행할 메서드와 명령어, 각종 표를 그대로 보여 줍니다. 이 글에서는 작은 예제 네 개를 javap로 열어 보며 바이트코드를 읽는 법을 익힙니다. 본문의 출력은 JDK 21의 javac와 javap로 얻은 것입니다. 다른 버전의 JDK로 컴파일하면 클래스 파일 버전이나 상수 풀 번호(#7 같은 숫자) 같은 세부가 다를 수 있습니다.
바이트코드 첫 읽기: 지역 변수와 피연산자 스택
먼저 정수 계산 한 줄이 어떤 명령어가 되는지 보겠습니다. weighted는 조회 수에 2를 곱한 값과, 댓글 수를 최대 7로 자른 뒤 3을 곱한 값을 더합니다.
public final class ArithmeticBytecodeProbe {
static int weighted(int views, int comments) {
int base = views * 2;
int bonus = Math.min(comments, 7) * 3;
return base + bonus;
}
public static void main(String[] args) {
System.out.println(weighted(30, 4));
}
}weighted(30, 4)는 30 × 2 = 60에 min(4, 7) × 3 = 12를 더하므로 실행하면 72가 출력됩니다. 이제 컴파일한 클래스 파일을 javap로 엽니다. -c는 메서드의 바이트코드를, -s는 메서드의 디스크립터를 보여 달라는 옵션입니다. -g로 컴파일한 것은 잠시 뒤에 볼 지역 변수 이름 표를 클래스 파일에 넣기 위해서입니다.
javac -g -d out ArithmeticBytecodeProbe.java
javap -c -s -cp out ArithmeticBytecodeProbe출력에는 컴파일러가 만든 기본 생성자와 main도 나오지만, 여기서는 weighted 부분만 보겠습니다. 각 줄 맨 앞의 숫자는 그 명령어가 메서드 안에서 시작하는 바이트 위치라서, 명령어 길이에 따라 1씩 늘지 않고 건너뛰기도 합니다.
static int weighted(int, int);
descriptor: (II)I
Code:
0: iload_0
1: iconst_2
2: imul
3: istore_2
4: iload_1
5: bipush 7
7: invokestatic #7 // Method java/lang/Math.min:(II)I
10: iconst_3
11: imul
12: istore_3
13: iload_2
14: iload_3
15: iadd
16: ireturndescriptor: (II)I는 JVM이 메서드의 매개변수와 반환 타입을 적는 방식입니다. 괄호 안의 II는 int 매개변수 두 개를, 괄호 뒤의 I는 int 반환을 뜻합니다. 참조 타입은 Ljava/lang/String;처럼 L과 ; 사이에 클래스 이름을 슬래시로 구분해 적습니다. JVM은 메서드를 이름과 이 디스크립터로 찾으므로, 이름이 같아도 디스크립터가 다르면 다른 메서드입니다.
명령어를 읽으려면 JVM이 스택 기반 기계라는 점을 알아야 합니다. 메서드가 실행될 때 JVM은 지역 변수를 번호가 매겨진 칸(slot)에 두고, 계산은 피연산자 스택(operand stack)이라는 작업용 스택에서 합니다. 이 메서드는 static이라 0번 칸에 views, 1번 칸에 comments가 들어 있고, 2번과 3번 칸은 base와 bonus가 씁니다. iload_0은 0번 칸의 int를 스택에 올리고, imul은 스택 맨 위의 두 값을 꺼내 곱한 결과를 다시 올리며, istore_2는 맨 위 값을 꺼내 2번 칸에 저장합니다. 명령어 이름 앞의 i는 int를 다룬다는 뜻입니다. weighted(30, 4)를 실행할 때 스택이 어떻게 변하는지 따라가 보겠습니다.
| 위치 | 명령어 | 실행 후 피연산자 스택(오른쪽이 맨 위) | 지역 변수 변화 |
|---|---|---|---|
| 0 | iload_0 | 30 | |
| 1 | iconst_2 | 30, 2 | |
| 2 | imul | 60 | |
| 3 | istore_2 | 비어 있음 | base = 60 |
| 4 | iload_1 | 4 | |
| 5 | bipush 7 | 4, 7 | |
| 7 | invokestatic Math.min | 4 | |
| 10 | iconst_3 | 4, 3 | |
| 11 | imul | 12 | |
| 12 | istore_3 | 비어 있음 | bonus = 12 |
| 13 | iload_2 | 60 | |
| 14 | iload_3 | 60, 12 | |
| 15 | iadd | 72 | |
| 16 | ireturn | 비어 있음 | 72를 반환 |
invokestatic은 스택에서 인수 두 개(4와 7)를 꺼내 Math.min을 호출하고, 반환값 4를 다시 올립니다. 왼쪽의 위치 번호가 5에서 7로, 7에서 10으로 건너뛰는 것은 명령어마다 길이가 달라서입니다. 위치 번호는 메서드 코드 안의 바이트 위치인데, bipush 7은 명령어 1바이트와 값 1바이트로 2바이트를 차지하고, invokestatic은 명령어 뒤에 상수 풀 번호 2바이트가 붙어 3바이트를 차지합니다. 같은 상수라도 2와 3은 iconst_2, iconst_3처럼 값이 명령어에 들어 있는 1바이트 명령어를 쓰고, 7은 bipush를 씁니다. iconst_ 계열은 -1부터 5까지만 있기 때문입니다. #7은 클래스 파일의 상수 풀에서 7번 항목을 가리키는 번호이고, 오른쪽 주석이 그 항목이 Math.min:(II)I임을 알려 줍니다.
-l 옵션을 주면 줄 번호 표와 지역 변수 표도 볼 수 있습니다. 앞에서 -g로 컴파일했으므로 지역 변수의 이름까지 들어 있습니다.
javap -l -cp out ArithmeticBytecodeProbe static int weighted(int, int);
LineNumberTable:
line 3: 0
line 4: 4
line 5: 13
LocalVariableTable:
Start Length Slot Name Signature
0 17 0 views I
0 17 1 comments I
4 13 2 base I
13 4 3 bonus ILineNumberTable은 바이트코드 위치와 소스 줄을 이어 주는 표입니다. 위치 0부터는 3번째 줄, 4부터는 4번째 줄, 13부터는 5번째 줄의 코드라는 뜻이고, 스택 트레이스의 줄 번호가 바로 이 표에서 나옵니다. LocalVariableTable은 각 칸에 어떤 이름의 변수가 어느 구간에서 살아 있는지 알려 주며, 디버거가 변수 이름을 보여 줄 때 씁니다. javac는 옵션을 주지 않으면 줄 번호와 소스 파일 이름만 넣고 지역 변수 표는 넣지 않습니다. 반대로 -g:none으로 컴파일하면 줄 번호까지 빠져서 스택 트레이스에 줄 번호 대신 Unknown Source가 찍힙니다.
소스에 없는 메서드 1: 제네릭 브리지 메서드
이제 스택 트레이스가 소스와 맞지 않는 실제 예를 보겠습니다. StringMapper는 Mapper<String>을 구현하므로 소스에서는 String만 받습니다. 그런데 main은 Mapper를 타입 인수 없이 raw 타입으로 쓰고, Integer로 박싱될 25를 넘깁니다. 컴파일러 경고는 @SuppressWarnings로 일부러 숨겼습니다.
public final class GenericBridgeCastFailure {
private interface Mapper<T> {
int length(T value);
}
private static final class StringMapper implements Mapper<String> {
@Override
public int length(String value) {
return value.length();
}
}
@SuppressWarnings({"rawtypes", "unchecked"})
public static void main(String[] args) {
Mapper raw = new StringMapper();
System.out.println(raw.length(25));
}
}raw는 raw 타입이라서 length의 매개변수가 Object로 취급되고, 컴파일러는 raw.length(25)처럼 Integer를 넘기는 것을 막지 않습니다. 스택 트레이스의 줄 번호가 소스의 어느 줄을 가리키는지 함께 보세요.
Exception in thread "main" java.lang.ClassCastException: class java.lang.Integer cannot be cast to class java.lang.String (java.lang.Integer and java.lang.String are in module java.base of loader 'bootstrap')
at GenericBridgeCastFailure$StringMapper.length(GenericBridgeCastFailure.java:6)
at GenericBridgeCastFailure.main(GenericBridgeCastFailure.java:16)예외가 난 위치를 보면 StringMapper.length의 6번째 줄입니다. 그런데 6번째 줄은 메서드 본문이 아니라 StringMapper 클래스를 선언한 줄이고, 실제 length(String)은 8번째 줄부터 시작합니다. 이 프레임은 우리가 작성한 length가 아니라는 뜻입니다. javap로 StringMapper의 메서드를 모두 열어 보겠습니다. -p는 private 멤버까지 보여 달라는 옵션입니다. 셸이 $를 변수로 해석하지 않도록 클래스 이름을 작은따옴표로 감쌌습니다.
javac -d out GenericBridgeCastFailure.java
javap -c -p -cp out 'GenericBridgeCastFailure$StringMapper'Compiled from "GenericBridgeCastFailure.java"
final class GenericBridgeCastFailure$StringMapper implements GenericBridgeCastFailure$Mapper<java.lang.String> {
private GenericBridgeCastFailure$StringMapper();
Code:
0: aload_0
1: invokespecial #1 // Method java/lang/Object."<init>":()V
4: return
public int length(java.lang.String);
Code:
0: aload_1
1: invokevirtual #7 // Method java/lang/String.length:()I
4: ireturn
public int length(java.lang.Object);
Code:
0: aload_0
1: aload_1
2: checkcast #8 // class java/lang/String
5: invokevirtual #13 // Method length:(Ljava/lang/String;)I
8: ireturn
}소스에는 length가 하나뿐인데 클래스 파일에는 length(java.lang.Object)가 하나 더 있습니다. 이것이 브리지 메서드(bridge method)입니다. 제네릭 타입 정보는 컴파일 뒤에 지워지므로(타입 소거와 런타임 한계 참고) 인터페이스의 length(T)는 클래스 파일에서 디스크립터가 (Ljava/lang/Object;)I인 length(Object)가 됩니다. 반면 StringMapper의 length(String)은 디스크립터가 (Ljava/lang/String;)I라서, JVM이 보기에는 인터페이스 메서드를 재정의한 것이 아닙니다. 그래서 javac는 length(Object)를 하나 만들어 받은 인수를 checkcast로 String으로 바꾼 뒤 진짜 length(String)을 호출하게 합니다. javap -v로 보면 이 메서드에는 flags: (0x1041) ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC이 붙어 있어, 컴파일러가 만든 브리지라는 표시까지 확인할 수 있습니다.
이제 예외가 난 과정을 설명할 수 있습니다. raw 타입 Mapper의 length는 Object를 받으므로 컴파일러는 Integer 25를 막지 않습니다. 실행하면 호출은 브리지 length(Object)로 들어가고, 위치 2의 checkcast가 Integer를 String으로 바꾸려다 ClassCastException을 던집니다. 그러니 우리가 작성한 length(String)은 한 번도 실행되지 않았습니다. 브리지에는 대응하는 소스 줄이 없어서 javac가 클래스 선언 줄을 줄 번호로 붙였고, 그래서 스택 트레이스에 6번째 줄이 찍힌 것입니다. 만약 정상적인 문자열 "board"를 넘겼다면 checkcast를 통과해 length(String)이 5를 돌려줍니다.
소거된 Object 입력은 브리지의 형변환을 거쳐 String 구현으로 간다
브리지 length(Object)의 checkcast를 거쳐 Integer 25는 구현 전 실패하고 별도 board 문자열은 length(String)으로 5를 반환합니다. 두 독립 입력의 호출 게이트와 미실행 구현을 추적할 수 있습니다.
GenericBridgeCastFailure$StringMapper의 실제 javap 출력에서 확인한 두 메서드입니다.
제네릭 소거 · 브리지의 checkcast 경계
소거된 Object 입력은 브리지의 형변환을 거쳐 String 구현으로 간다
원시 타입으로 넘긴 Integer 25는 length(Object) 브리지까지 도달합니다. String 형변환에서 실패하므로 이 입력은 length(String) 구현에 도달하지 못합니다.
length(String)
- 디스크립터
- (Ljava/lang/String;)I
- 동작
- String.length()를 호출해 int 반환
length(Object)
- 디스크립터
- (Ljava/lang/Object;)I
- 동작
- checkcast String 뒤 length(String) 호출
JDK 25.0.4.1의 javap에서 두 번째 메서드의 ACC_BRIDGE·ACC_SYNTHETIC을 확인했습니다. 원문 main의 Integer 입력은 ClassCastException을 일으키는 경로지만, main을 실행해 관측한 예외는 아닙니다.
이 예의 조건
- 원 main=Integer 25 실패이며 board는 별도 정상 입력입니다. JDK 실행 기록을 추가하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 입력 Object→checkcast String으로 연결하고 Integer 25는 실패해 ClassCastException으로 간다. 별도 String board 성공 입력만 length(String)과 String.length로 가서 5를 얻는다. 실패에서 구현으로 연결하는 간선은 없다. 이는 원 규칙과 추가 정상 입력의 설명이며 보존된 과거 JDK 25의 javap 자료 및 main 미실행 범위와 새 JDK 21 실행을 구분한다. |
| 구분 | 내용 |
|---|---|
| 설명 | 입력 Object→checkcast String으로 연결하고 Integer 25는 실패해 ClassCastException으로 간다. 별도 String board 성공 입력만 length(String)과 String.length로 가서 5를 얻는다. 실패에서 구현으로 연결하는 간선은 없다. 이는 원 규칙과 추가 정상 입력의 설명이며 보존된 과거 JDK 25의 javap 자료 및 main 미실행 범위와 새 JDK 21 실행을 구분한다. |
| 전체 읽기 정보 | 소거된 Object 입력은 브리지의 형변환을 거쳐 String 구현으로 간다 소거된 Object 입력은 브리지의 형변환을 거쳐 String 구현으로 간다 GenericBridgeCastFailure$StringMapper의 실제 javap 출력에서 확인한 두 메서드입니다. 제네릭 소거 · 브리지의 checkcast 경계 소거된 Object 입력은 브리지의 형변환을 거쳐 String 구현으로 간다 원시 타입으로 넘긴 Integer 25는 length(Object) 브리지까지 도달합니다. String 형변환에서 실패하므로 이 입력은 length(String) 구현에 도달하지 못합니다. length(String) 디스크립터 (Ljava/lang/String;)I 동작 String.length()를 호출해 int 반환 length(Object) 디스크립터 (Ljava/lang/Object;)I 동작 checkcast String 뒤 length(String) 호출 JDK 25.0.4.1의 javap에서 두 번째 메서드의 ACC_BRIDGE·ACC_SYNTHETIC을 확인했습니다. 원문 main의 Integer 입력은 ClassCastException을 일으키는 경로지만, main을 실행해 관측한 예외는 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 브리지 입력 length(Object)가 입력을 받습니다. |
|
|
| checkcast 입력을 String으로 변환합니다. |
|
|
| 정상 구현 별도 입력인 board에 대해서만 String.length를 실행합니다. |
|
|
소거된 Object 입력은 브리지의 형변환을 거쳐 String 구현으로 간다
브리지 length(Object)의 checkcast를 거쳐 Integer 25는 구현 전 실패하고 별도 board 문자열은 length(String)으로 5를 반환합니다. 두 독립 입력의 호출 게이트와 미실행 구현을 추적할 수 있습니다.
GenericBridgeCastFailure$StringMapper의 실제 javap 출력에서 확인한 두 메서드입니다.
제네릭 소거 · 브리지의 checkcast 경계
소거된 Object 입력은 브리지의 형변환을 거쳐 String 구현으로 간다
원시 타입으로 넘긴 Integer 25는 length(Object) 브리지까지 도달합니다. String 형변환에서 실패하므로 이 입력은 length(String) 구현에 도달하지 못합니다.
length(String)
- 디스크립터
- (Ljava/lang/String;)I
- 동작
- String.length()를 호출해 int 반환
length(Object)
- 디스크립터
- (Ljava/lang/Object;)I
- 동작
- checkcast String 뒤 length(String) 호출
JDK 25.0.4.1의 javap에서 두 번째 메서드의 ACC_BRIDGE·ACC_SYNTHETIC을 확인했습니다. 원문 main의 Integer 입력은 ClassCastException을 일으키는 경로지만, main을 실행해 관측한 예외는 아닙니다.
이 예의 조건
- 원 main=Integer 25 실패이며 board는 별도 정상 입력입니다. JDK 실행 기록을 추가하지 않습니다.
관계도의 보충 설명
| 구분 | 내용 |
|---|---|
| 2 | 입력 Object→checkcast String으로 연결하고 Integer 25는 실패해 ClassCastException으로 간다. 별도 String board 성공 입력만 length(String)과 String.length로 가서 5를 얻는다. 실패에서 구현으로 연결하는 간선은 없다. 이는 원 규칙과 추가 정상 입력의 설명이며 보존된 과거 JDK 25의 javap 자료 및 main 미실행 범위와 새 JDK 21 실행을 구분한다. |
| 구분 | 내용 |
|---|---|
| 설명 | 입력 Object→checkcast String으로 연결하고 Integer 25는 실패해 ClassCastException으로 간다. 별도 String board 성공 입력만 length(String)과 String.length로 가서 5를 얻는다. 실패에서 구현으로 연결하는 간선은 없다. 이는 원 규칙과 추가 정상 입력의 설명이며 보존된 과거 JDK 25의 javap 자료 및 main 미실행 범위와 새 JDK 21 실행을 구분한다. |
| 전체 읽기 정보 | 소거된 Object 입력은 브리지의 형변환을 거쳐 String 구현으로 간다 소거된 Object 입력은 브리지의 형변환을 거쳐 String 구현으로 간다 GenericBridgeCastFailure$StringMapper의 실제 javap 출력에서 확인한 두 메서드입니다. 제네릭 소거 · 브리지의 checkcast 경계 소거된 Object 입력은 브리지의 형변환을 거쳐 String 구현으로 간다 원시 타입으로 넘긴 Integer 25는 length(Object) 브리지까지 도달합니다. String 형변환에서 실패하므로 이 입력은 length(String) 구현에 도달하지 못합니다. length(String) 디스크립터 (Ljava/lang/String;)I 동작 String.length()를 호출해 int 반환 length(Object) 디스크립터 (Ljava/lang/Object;)I 동작 checkcast String 뒤 length(String) 호출 JDK 25.0.4.1의 javap에서 두 번째 메서드의 ACC_BRIDGE·ACC_SYNTHETIC을 확인했습니다. 원문 main의 Integer 입력은 ClassCastException을 일으키는 경로지만, main을 실행해 관측한 예외는 아닙니다. |
| 관찰·상황 | 값·보충 정보 | 변화·이유 |
|---|---|---|
| 브리지 입력 length(Object)가 입력을 받습니다. |
|
|
| checkcast 입력을 String으로 변환합니다. |
|
|
| 정상 구현 별도 입력인 board에 대해서만 String.length를 실행합니다. |
|
|
브리지 메서드 자체는 잘못이 아닙니다. 소거된 뒤에도 다형성이 유지되도록 컴파일러가 정상적으로 만드는 코드입니다. 문제는 raw 타입과 그 경고를 숨긴 데 있습니다. 경고를 지우고 Mapper<String>으로 선언했다면 raw.length(25)는 컴파일 오류가 되어 실행 전에 잡혔을 것입니다.
브리지 메서드는 반환 타입 때문에 생기기도 합니다. 아래 TextBox는 Box<String>의 value()를 String을 반환하도록 구현합니다. 리플렉션의 Method.isBridge()와 isSynthetic()으로 클래스에 선언된 메서드를 모두 확인해 보겠습니다.
import java.lang.reflect.Method;
import java.util.Arrays;
import java.util.Comparator;
public final class BridgeMethodInventory {
private interface Box<T> {
T value();
}
private static final class TextBox implements Box<String> {
@Override
public String value() {
return "java";
}
}
public static void main(String[] args) {
Method[] methods = TextBox.class.getDeclaredMethods();
Arrays.sort(methods, Comparator.comparing(Method::toString));
for (Method method : methods) {
System.out.println(method + " bridge=" + method.isBridge() + " synthetic=" + method.isSynthetic());
}
}
}Method의 toString()은 접근 제한자, 반환 타입, 클래스 이름, 메서드 이름을 이어 붙인 문자열입니다. 각 줄 끝에는 그 메서드가 브리지인지와 컴파일러가 만든 것인지가 붙습니다.
public java.lang.Object BridgeMethodInventory$TextBox.value() bridge=true synthetic=true
public java.lang.String BridgeMethodInventory$TextBox.value() bridge=false synthetic=falsegetDeclaredMethods()가 돌려주는 배열의 순서는 정해져 있지 않으므로 문자열로 정렬해서 출력했습니다. 소스에는 value()가 하나지만 리플렉션에는 두 개가 보입니다. 소거된 인터페이스 메서드는 Object를 반환하는 value()인데, 자바 소스에서는 같은 이름과 매개변수의 메서드가 반환 타입만 다르게 둘 있을 수 없어도 JVM은 디스크립터 전체로 메서드를 구별하므로 가능합니다. 이처럼 브리지는 javap로도, 실행 중인 리플렉션으로도 확인할 수 있습니다. 스택 트레이스에서 소스에 없는 프레임을 만나면 두 방법을 함께 써서 정체를 밝히면 됩니다.
finally는 모든 출구에 복사된다
finally 블록은 try가 정상으로 끝나든 예외로 끝나든 실행되어야 합니다. 바이트코드에는 finally에 해당하는 명령어가 따로 없는데, 그렇다면 javac는 이 규칙을 어떻게 지킬까요? 아래 work는 자원 수를 하나 늘린 뒤 finally에서 다시 줄입니다.
public final class FinallyBytecodeProbe {
private static int openResources;
static int work(boolean fail) {
openResources++;
try {
if (fail) {
throw new IllegalStateException("failed");
}
return 25;
} finally {
openResources--;
}
}
public static void main(String[] args) {
System.out.println("result=" + work(false));
try {
work(true);
} catch (IllegalStateException exception) {
System.out.println(exception.getMessage());
}
System.out.println("open=" + openResources);
}
}main은 work를 한 번은 정상으로, 한 번은 예외로 끝낸 뒤 openResources를 찍습니다. 두 번 늘리고 두 번 줄였다면 마지막 값은 0이어야 합니다.
result=25
failed
open=0정상 반환과 예외, 두 경우 모두 finally가 실행되어 마지막에 open=0이 나왔습니다. javap -c -p -cp out FinallyBytecodeProbe로 work를 열어 보면 그 방법이 보입니다.
static int work(boolean);
Code:
0: getstatic #7 // Field openResources:I
3: iconst_1
4: iadd
5: putstatic #7 // Field openResources:I
8: iload_0
9: ifeq 22
12: new #13 // class java/lang/IllegalStateException
15: dup
16: ldc #15 // String failed
18: invokespecial #17 // Method java/lang/IllegalStateException."<init>":(Ljava/lang/String;)V
21: athrow
22: bipush 25
24: istore_1
25: getstatic #7 // Field openResources:I
28: iconst_1
29: isub
30: putstatic #7 // Field openResources:I
33: iload_1
34: ireturn
35: astore_2
36: getstatic #7 // Field openResources:I
39: iconst_1
40: isub
41: putstatic #7 // Field openResources:I
44: aload_2
45: athrow
Exception table:
from to target type
8 25 35 anyopenResources--에 해당하는 네 명령어(getstatic, iconst_1, isub, putstatic)가 위치 25와 36에 두 번 나옵니다. javac는 finally의 내용을 try를 빠져나가는 출구마다 복사해 넣습니다. 정상 경로에서는 반환할 값 25를 먼저 1번 칸에 저장해 두고(위치 24), finally 코드를 실행한 다음 저장해 둔 값을 반환합니다(위치 33과 34).
예외 경로는 맨 아래의 예외 테이블(exception table)이 맡습니다. 8 25 35 any는 위치 8 이상 25 미만에서 어떤 예외(any)든 던져지면 위치 35로 가라는 뜻입니다. 위치 35에서는 예외 객체를 2번 칸에 보관하고(astore_2), 복사된 finally 코드를 실행한 뒤, 보관한 예외를 다시 던집니다(aload_2, athrow). 범위가 25에서 끝나는 것도 의미가 있습니다. 위치 25부터는 이미 finally 코드이므로, 거기서 난 예외까지 다시 finally로 보내면 같은 정리 코드가 두 번 실행되기 때문입니다.
이렇게 보면 finally의 정리 코드가 모든 경로를 덮는지 클래스 파일 수준에서 확인할 수 있습니다. 다만 바이트코드를 직접 고치는 것이 목적은 아닙니다. 소스의 try/finally나 try-with-resources가 의도대로 컴파일됐는지 확인하는 용도로 씁니다.
소스에 없는 메서드 2: 람다와 invokedynamic
람다와 문자열 연결은 invokedynamic이라는 명령어로 컴파일됩니다. 다음 프로그램은 지역 변수 factor를 캡처하는 람다로 문자열 목록을 만듭니다.
import java.util.List;
import java.util.function.Function;
public final class InvokeDynamicInventory {
public static void main(String[] args) {
int factor = 2;
Function<Integer, String> label = value -> "score=" + (value * factor);
List<String> labels = List.of(10, 20, 30).stream().map(label).toList();
System.out.println(labels);
}
}실행 결과 자체는 평범합니다. 이 예제에서 볼 것은 람다와 문자열 연결이 바이트코드로 어떻게 바뀌었는지이고, 그것은 뒤의 javap 출력에 있습니다.
[score=20, score=40, score=60]javap -c -p -cp out InvokeDynamicInventory의 출력에서 람다를 만드는 부분과, 소스에 없던 메서드 하나를 보겠습니다. 이 메서드는 private이라서 -p 없이는 보이지 않습니다.
public static void main(java.lang.String[]);
Code:
0: iconst_2
1: istore_1
2: iload_1
3: invokedynamic #7, 0 // InvokeDynamic #0:apply:(I)Ljava/util/function/Function;
8: astore_2
...
private static java.lang.String lambda$main$0(int, java.lang.Integer);
Code:
0: aload_1
1: invokevirtual #49 // Method java/lang/Integer.intValue:()I
4: iload_0
5: imul
6: invokedynamic #53, 0 // InvokeDynamic #1:makeConcatWithConstants:(I)Ljava/lang/String;
11: areturn...은 List.of와 스트림 호출이 이어지는 부분을 줄인 것입니다. 람다의 본문은 lambda$main$0이라는 private static 메서드가 됐습니다. 매개변수의 순서도 눈여겨볼 만합니다. 캡처한 factor가 첫 번째(int), 람다의 원래 매개변수 value가 두 번째(Integer)입니다. 람다 안에서 예외가 나면 스택 트레이스에 이 lambda$main$0이 찍히므로, 이름을 보고 'main 메서드 안의 첫 번째 람다'라고 읽으면 됩니다. 다만 이렇게 메서드마다 번호를 0부터 따로 매기는 것은 JDK 25의 javac입니다. 이 글의 출력을 얻은 JDK 21의 javac는 클래스 안의 람다에 번호를 하나로 이어 매기므로, 앞쪽 메서드에 람다가 있으면 main의 첫 람다도 lambda$main$1처럼 0이 아닌 번호를 받습니다.
main의 위치 3에서는 invokedynamic이 Function 객체를 만듭니다. 디스크립터 (I)Ljava/util/function/Function;은 캡처할 int 하나를 받아 Function을 돌려준다는 뜻입니다. invokedynamic은 무엇을 호출할지 클래스 파일에 고정해 두지 않고, 그 명령어가 처음 실행될 때 부트스트랩 메서드(bootstrap method)를 불러 실제 동작을 정합니다. 한 번 정해진 동작은 그 호출 지점에서 계속 재사용됩니다. 어떤 부트스트랩 메서드를 쓰는지는 javap -v의 맨 끝에 있는 BootstrapMethods 표에서 확인합니다.
BootstrapMethods:
0: #93 REF_invokeStatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
Method arguments:
#84 (Ljava/lang/Object;)Ljava/lang/Object;
#86 REF_invokeStatic InvokeDynamicInventory.lambda$main$0:(ILjava/lang/Integer;)Ljava/lang/String;
#89 (Ljava/lang/Integer;)Ljava/lang/String;
1: #100 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:
#91 score=\u0001같은 invokedynamic이라도 0번은 람다를 만드는 LambdaMetafactory를, 1번은 문자열을 연결하는 StringConcatFactory를 부트스트랩으로 씁니다. 0번의 인수에는 구현 메서드 lambda$main$0이 들어 있고, 1번의 인수 score=\u0001은 연결 방법을 적은 틀로 \u0001 자리에 실행 중의 값이 들어갑니다. 문자열 연결을 StringBuilder 호출 대신 invokedynamic으로 컴파일하는 방식은 JDK 9(JEP 280)부터 쓰였습니다. 이처럼 같은 소스도 컴파일러 버전과 대상 릴리스에 따라 다른 바이트코드가 될 수 있으므로, 명령어 개수만 보고 성능을 판단해서는 안 됩니다.
증상별로 javap를 꺼내는 경우
평소에는 바이트코드를 볼 일이 거의 없습니다. javap를 꺼내는 것은 대개 다음과 같이 소스만으로는 설명되지 않는 증상을 만났을 때입니다.
- 스택 트레이스에 소스에 없는 프레임(브리지 메서드,
lambda$…메서드)이나 클래스 선언 줄을 가리키는 줄 번호가 보일 때 NoSuchMethodError가 날 때: 호출하는 쪽 클래스의javap -c에 적힌 디스크립터와, 실행 중에 실제로 쓰인 클래스의javap -s에 나온 디스크립터를 비교합니다. 예를 들어 라이브러리가count()의 반환 타입을int에서long으로 바꾸면 디스크립터가()I에서()J로 달라집니다. 호출하는 쪽을 다시 컴파일하지 않으면 그 클래스 파일에는 예전 디스크립터()I가 남아 있으므로, 실행 중에 그 메서드를 찾지 못합니다.IllegalAccessError가 날 때:javap -v로 클래스와 메서드의 접근 플래그를 확인하고, 모듈과 패키지 접근 규칙을 함께 봅니다.- 디버거가 엉뚱한 줄에 멈출 때:
-l로LineNumberTable을 확인합니다.
반대로 성능 문제는 javap로 결론을 내리기 어렵습니다. 실제로 실행되는 것은 JIT 컴파일러가 바이트코드를 바탕으로 만든 기계어이기 때문입니다. 이 이야기는 다음 글에서 이어집니다.
javap 출력이 같으면 런타임 성능도 같나요?
아닙니다. 같은 바이트코드라도 JIT 컴파일러가 실행 중에 모은 프로필, 인라이닝, 탈출 분석, CPU 종류와 입력 데이터의 분포에 따라 전혀 다른 기계어가 만들어질 수 있습니다. javap는 클래스 파일의 구조와 컴파일러가 소스를 어떻게 바꿨는지 확인하는 도구입니다. 성능은 JMH 같은 벤치마크 도구, JFR, JIT 컴파일 로그로 직접 측정해야 합니다.
정리
javap -c는 메서드의 바이트코드를,-s는 디스크립터를,-l은 줄 번호와 지역 변수 표를,-p는 private 멤버를,-v는 플래그와 상수 풀을 포함한 전체 정보를 보여 줍니다.- JVM은 지역 변수 칸과 피연산자 스택으로 계산합니다.
iload로 값을 올리고imul,iadd같은 명령어가 스택의 값을 꺼내 계산한 뒤 결과를 다시 올립니다. - 제네릭 타입이 소거되면
javac가checkcast를 담은 브리지 메서드를 만들어 다형성을 유지합니다. raw 타입으로 잘못된 값을 넘기면 이 브리지에서ClassCastException이 나고, 스택 트레이스에는 클래스 선언 줄이 찍힙니다. finally는 출구마다 복사되고, 예외 경로는 예외 테이블이 연결합니다.- 람다 본문은
lambda$메서드$번호형태의 private 메서드가 되고, 람다 생성과 문자열 연결은 각기 다른 부트스트랩 메서드를 쓰는invokedynamic으로 컴파일됩니다.
연습 문제
weighted(30, 40)을 호출하면 앞의 추적 표에서 어느 단계의 결과가 달라지고, 최종 반환값은 얼마인지 구하세요.- 인수가 없는
increment메서드를synchronized메서드로 선언한 경우와, 본문 안에서synchronized블록을 쓴 경우를 비교하세요. 각각 리플렉션과javap에서 무엇을 보면 동기화를 확인할 수 있는지 적고, 그 표시만으로 처리량이나 락 대기 시간을 알 수 있는지도 설명하세요. - 스택 트레이스에
at Sorter$ByName.compare(Sorter.java:12)가 찍혔는데, 12번째 줄은private static final class ByName implements Comparator<Item> {입니다.compare(Item, Item)메서드는 14번째 줄부터 있습니다. 무엇을 의심해야 하고, 어떻게 확인할 수 있을까요?
힌트
Math.min이 돌려주는 값을 다시 계산해 보세요.- 메서드 선언의
synchronized는 메서드의 속성이고, 블록은 본문 안의 코드입니다. - 소거된
Comparator<T>의compare는 어떤 디스크립터가 될까요?
해설
-
위치 4부터 달라집니다.
iload_1이 4 대신 40을 올리고,bipush 7뒤에invokestatic Math.min이min(40, 7)의 결과로 7을 남깁니다. 이어서iconst_3과imul을 거쳐bonus는 21이 되고, 마지막iadd는 60 + 21을 계산해 81을 반환합니다. 댓글 수가 7을 넘으면 몇 개든bonus가 21로 같다는 것을 스택에서도 확인할 수 있습니다. -
메서드에
synchronized를 붙이면 바이트코드에는 동기화 명령어가 없고, 메서드의 접근 플래그에 표시가 붙습니다.
import java.lang.reflect.Method;
import java.lang.reflect.Modifier;
public final class SynchronizedMethodFlagSolution {
private int value;
public synchronized int increment() {
return ++value;
}
public static void main(String[] args) throws Exception {
SynchronizedMethodFlagSolution counter = new SynchronizedMethodFlagSolution();
System.out.println(counter.increment());
Method method = SynchronizedMethodFlagSolution.class.getMethod("increment");
System.out.println("synchronized=" + Modifier.isSynchronized(method.getModifiers()));
}
}첫 줄은 increment()를 한 번 호출한 반환값이고, 둘째 줄은 리플렉션으로 그 메서드의 수식어를 읽은 결과입니다. getMethod는 public 메서드만 찾으므로 increment를 public으로 선언해 두었습니다.
1
synchronized=true리플렉션에서는 Modifier.isSynchronized가 true를 돌려주고, javap -v -cp out SynchronizedMethodFlagSolution에서는 increment에 flags: (0x0021) ACC_PUBLIC, ACC_SYNCHRONIZED가 보입니다. JVM은 이 플래그를 보고 메서드를 호출할 때 객체의 모니터를 잡고 끝날 때 놓습니다. 반면 본문 안에서 synchronized (lock) { return ++value; }처럼 블록을 쓰면 메서드 플래그는 ACC_PUBLIC뿐이고 isSynchronized도 false입니다. 대신 javap -c의 바이트코드에 monitorenter와 monitorexit가 나타나고, 예외가 나도 모니터를 놓도록 monitorexit가 두 번 들어가며 예외 테이블에 any 항목이 생깁니다. finally가 복사되던 것과 같은 방식입니다. 어느 쪽이든 이 표시는 동기화를 한다는 사실만 알려 줄 뿐, 처리량이나 락을 얼마나 오래 기다리는지는 알려 주지 않습니다. 그런 정보는 JFR이나 프로파일러로 실행 중에 측정해야 합니다.
- 컴파일러가 만든 브리지 메서드를 의심해야 합니다.
Comparator<T>의compare(T, T)는 소거되면compare(Object, Object)가 되므로,javac는ByName에compare(Object, Object)브리지를 만들어 인수를Item으로checkcast한 뒤 진짜compare(Item, Item)을 호출하게 합니다. 브리지에는 대응하는 소스 줄이 없어 클래스 선언 줄이 줄 번호로 붙으므로, 예외가 그 줄에서 났다면checkcast가 실패했을 가능성이 큽니다. 즉 raw 타입이나 비검사 형변환을 거쳐Item이 아닌 객체가 비교 대상으로 들어온 것입니다.javap -c -p -cp out 'Sorter$ByName'으로compare(java.lang.Object, java.lang.Object)와 그 안의checkcast를 확인하고, 예외 메시지에서 어떤 타입을Item으로 바꾸려 했는지 보면 잘못 들어온 값을 찾을 수 있습니다.
다음 글
이 글에서 본 바이트코드는 실행의 출발점일 뿐입니다. JIT 예열과 벤치마크에서는 바이트코드가 실행 중에 기계어로 컴파일되는 과정이 성능 측정을 어떻게 흔드는지 보고, 믿을 수 있는 벤치마크를 만드는 조건을 정리합니다.