javap와 바이트코드
제네릭 브리지의 실제 ClassCastException을 재현하고 `javap` -c·-v·-p·-s로 피연산자 스택, invokedynamic, 예외 테이블, 접근 플래그를 추적합니다.
소스 코드는 컴파일러가 클래스 파일로 바꾸는 입력입니다.
런타임 예외와 프로파일러 프레임을 이해하려면 메서드 디스크립터, 바이트코드 명령어, 합성 멤버, 줄 번호 표를 확인할 때가 있습니다.
javap는 디컴파일러가 아니라 클래스 파일 역어셈블러이므로 소스를 완벽히 복원하지 않고 JVM이 실행할 구조를 보여 줍니다.
원시 호출과 브리지 형변환
Mapper<String> 구현은 소스에서 String만 받지만 타입 소거 뒤 인터페이스 메서드 디스크립터는 Object입니다.
컴파일러가 Object를 String으로 형변환하는 합성 브리지를 만듭니다.
원시 타입 Mapper에 Integer를 넣으면 실제 ClassCastException이 브리지에서 발생합니다.
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));
}
}컴파일 뒤 다음 명령을 실행합니다.
javac --release 25 GenericBridgeCastFailure.java
javap -c -v -p 'GenericBridgeCastFailure$StringMapper'관찰 결과 length(Object) 메서드에 ACC_BRIDGE, ACC_SYNTHETIC 플래그가 있고 바이트코드가 checkcast java/lang/String 뒤 length(String)을 호출합니다.
원칙은 원시 타입과 비검사 형변환 경고를 제거하는 것입니다.
브리지 자체는 컴파일러가 타입 다형성을 보존하기 위해 정상 생성한 코드입니다.
디스크립터와 피연산자 스택
디스크립터 (II)I는 int 두 개를 받아 int를 반환한다는 뜻입니다.
지역 변수 슬롯에 인수가 놓이고 명령어가 피연산자 스택에서 값을 꺼내 계산합니다.
public final class ArithmeticBytecodeProbe {
static int weighted(int minutes, int streak) {
int base = minutes * 2;
int bonus = Math.min(streak, 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관찰 결과 람다 호출 지점은 LambdaMetafactory, 문자열 결합은 StringConcatFactory 부트스트랩을 참조합니다.
컴파일러 버전과 대상 릴리스에 따라 하위 변환 전략이 달라질 수 있으므로 명령어 개수를 성능 결론으로 바로 사용하지 않습니다.
리플렉션·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 명령어로 나타납니다.