javap와 바이트코드
제네릭 브리지 예외를 재현하고 javap로 피연산자 스택·invokedynamic·예외 테이블을 추적합니다.
소스 코드는 컴파일러가 클래스 파일로 바꾸는 입력입니다.
런타임 예외와 프로파일러 프레임을 이해하려면 메서드 디스크립터, 바이트코드 명령어, 합성 멤버, 줄 번호 표를 확인할 때가 있습니다.
javap는 디컴파일러가 아니라 클래스 파일 역어셈블러이므로 소스를 완벽히 복원하지 않고 JVM이 실행할 구조를 보여 줍니다.
원시 호출과 브리지 형변환
Mapper<String> 구현은 소스에서 String만 받지만 타입 소거 뒤 인터페이스 메서드 디스크립터는 Object입니다.
컴파일러가 Object를 String으로 형변환하는 합성 브리지를 만듭니다.
원시 타입 호출은 소스 단계의 매개변수 타입 검사를 우회하므로 Integer도 브리지까지 전달할 수 있습니다.
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));
}
}GenericBridgeCastFailure$StringMapper의 실제 javap 출력에서 확인한 두 메서드입니다.
| 클래스 파일 메서드 | 디스크립터 | 동작 |
|---|---|---|
| length(String) | (Ljava/lang/String;)I | String.length()를 호출해 int 반환 |
| length(Object) | (Ljava/lang/Object;)I | checkcast 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을 실행해 관측한 예외는 아닙니다.
컴파일 뒤 다음 명령을 실행합니다.
javac --release 25 GenericBridgeCastFailure.java
javap -c -v -p 'GenericBridgeCastFailure$StringMapper'원칙은 원시 타입과 비검사 형변환 경고를 제거하는 것입니다.
브리지 자체는 컴파일러가 타입 다형성을 보존하기 위해 정상 생성한 코드입니다.
디스크립터와 피연산자 스택
디스크립터 (II)I는 int 두 개를 받아 int를 반환한다는 뜻입니다.
지역 변수 슬롯에 인수가 놓이고 명령어가 피연산자 스택에서 값을 꺼내 계산합니다.
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));
}
}javac --release 25 -g ArithmeticBytecodeProbe.java
javap -c -s -l ArithmeticBytecodeProbe관찰할 명령어는 iload, iconst_2, imul, invokestatic Math.min, iadd, ireturn입니다.
-l은 줄 번호·지역 변수 표를 보여 주며 -g:none으로 컴파일하면 디버그 정보가 줄어 디버거 경험이 달라집니다.
try-finally의 예외 테이블
소스의 finally는 정상 return과 예외 경로 모두에서 실행돼야 합니다.
javap로 예외 테이블과 모니터·자원 정리 코드를 확인할 수 있습니다.
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);
}
}javap -c -v -p FinallyBytecodeProbe에서 return 경로의 감소 연산과 예외 처리기 경로의 감소 연산·athrow를 비교합니다.
바이트코드를 직접 수정하지 말고 소스의 생명 주기 코드가 모든 경로를 덮는지 확인하는 근거로 사용합니다.
invokedynamic의 두 용도
현대 javac는 람다 객체 생성과 문자열 연결에 invokedynamic을 사용할 수 있습니다.
같은 명령어라도 부트스트랩 메서드가 다르므로 상수 풀과 BootstrapMethods를 봅니다.
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);
}
}javac --release 25 InvokeDynamicInventory.java
javap -c -v -p InvokeDynamicInventory이 원문을 JDK 25.0.4.1로 컴파일한 javap 결과에서 람다 생성 지점은 LambdaMetafactory, 문자열 결합은 StringConcatFactory 부트스트랩을 참조했습니다. 이는 클래스 파일 검사이며 main 실행이나 JIT 관측은 아닙니다.
컴파일러 버전과 대상 릴리스에 따라 하위 변환 전략이 달라질 수 있으므로 명령어 개수를 성능 결론으로 바로 사용하지 않습니다.
리플렉션·javap 교차 확인
런타임 리플렉션에서도 브리지·합성 플래그를 확인할 수 있습니다.
소스에 없는 스택 프레임을 발견했다면 클래스 파일과 리플렉션을 함께 살펴봅니다.
import java.lang.reflect.Method;
import java.util.Arrays;
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) {
Arrays.stream(TextBox.class.getDeclaredMethods())
.sorted(java.util.Comparator.comparing(Method::toString))
.forEach(
method ->
System.out.println(
method
+ " bridge="
+ method.isBridge()
+ " synthetic="
+ method.isSynthetic()));
}
}javap 선택 기준
- 스택 추적에 합성·브리지·람다 프레임이 보일 때 사용합니다.
NoSuchMethodError에는 호출자의 디스크립터와 런타임 클래스의 디스크립터를 비교합니다.- 접근 오류에는 클래스·메서드 플래그와 모듈·패키지 접근 규칙을 봅니다.
- 디버거의 소스 줄 대응 관계는
LineNumberTable에서 확인합니다. - 성능은 바이트코드만으로 결론 내지 않고 JIT 컴파일과 머신 코드 프로필을 봅니다.
javap 산출물이 같으면 런타임 성능도 같나요?
아닙니다.
JIT 프로필, 인라이닝, 탈출 분석, CPU와 입력 분포가 머신 코드를 바꿉니다.
javap는 클래스 파일 규칙과 컴파일러 하위 변환을 확인하는 도구입니다.
성능은 JMH, JFR, 컴파일러 로그와 함께 측정합니다.
연습 문제
synchronized 인스턴스 메서드를 만들고 리플렉션과 javap로 플래그를 확인하세요.
해설 보기
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()));
}
}javap -c -v SynchronizedMethodFlagSolution에서 ACC_SYNCHRONIZED를 확인합니다.
synchronized 블록은 메서드 플래그가 아니라 monitorenter·monitorexit 명령어로 나타납니다.