본문 바로가기

javap와 바이트코드

제네릭 브리지 예외를 재현하고 javap로 피연산자 스택·invokedynamic·예외 테이블을 추적합니다.

소스 코드는 컴파일러가 클래스 파일로 바꾸는 입력입니다.

런타임 예외와 프로파일러 프레임을 이해하려면 메서드 디스크립터, 바이트코드 명령어, 합성 멤버, 줄 번호 표를 확인할 때가 있습니다.

javap는 디컴파일러가 아니라 클래스 파일 역어셈블러이므로 소스를 완벽히 복원하지 않고 JVM이 실행할 구조를 보여 줍니다.


원시 호출과 브리지 형변환

Mapper<String> 구현은 소스에서 String만 받지만 타입 소거 뒤 인터페이스 메서드 디스크립터는 Object입니다.

컴파일러가 Object를 String으로 형변환하는 합성 브리지를 만듭니다.

원시 타입 호출은 소스 단계의 매개변수 타입 검사를 우회하므로 Integer도 브리지까지 전달할 수 있습니다.

lab/GenericBridgeCastFailure.java
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));
    }
}

소거된 Object 입력은 브리지의 형변환을 거쳐 String 구현으로 간다

브리지 length(Object)의 checkcast를 거쳐 Integer 25는 구현 전 실패하고 별도 board 문자열은 length(String)으로 5를 반환합니다. 두 독립 입력의 호출 게이트와 미실행 구현을 추적할 수 있습니다.

소거된 Object 입력은 브리지의 형변환을 거쳐 String 구현으로 간다

GenericBridgeCastFailure$StringMapper의 실제 javap 출력에서 확인한 두 메서드입니다.

제네릭 소거 · 브리지의 checkcast 경계

소거된 Object 입력은 브리지의 형변환을 거쳐 String 구현으로 간다

원시 타입으로 넘긴 Integer 25는 length(Object) 브리지까지 도달합니다. String 형변환에서 실패하므로 이 입력은 length(String) 구현에 도달하지 못합니다.

브리지의 변환 실패는 String 구현 앞에서 발생한다입력Object→checkcast String으로연결하고Integer25는실패해ClassCastException으로간다.별도String board 성공입력만length(String)과String.length로가서5를얻는다.실패에서구현으로연결하는간선없음.이는원규칙+추가정상입력의설명이며 preservedhistoricalJDK25javap/Main未実行範囲와freshJDK21execution을분리한다.실패성공length(Object)원 main: Integer 25String 형변환checkcastClassCastExceptionString 구현에 도달 못함length(String)별도 입력: boardString.length()별도 문자열의 길이 5원 main은 실패 분기 · 성공 분기는 별도 정상 입력
브리지의 변환 실패는 String 구현 앞에서 발생한다입력Object→checkcast String으로연결하고Integer25는실패해ClassCastException으로간다.별도String board 성공입력만length(String)과String.length로가서5를얻는다.실패에서구현으로연결하는간선없음.이는원규칙+추가정상입력의설명이며 preservedhistoricalJDK25javap/Main未実行範囲와freshJDK21execution을분리한다.실패length(Object)원 main: Integer 25String 형변환checkcastClassCastException구현에 도달 못함length(String)별도 입력: boardString.length()길이 5오른쪽 선: 성공 분기원 main은 Integer 실패

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)가 입력을 받습니다.

  • 원 입력: Integer 25
  • 정상 대안: String board
  • 브리지가 String 구현 앞에 변환을 수행합니다.
checkcast

입력을 String으로 변환합니다.

  • Integer 25: ClassCastException
  • board: 통과
  • 실패 입력은 length(String)에 도달하지 않습니다.
정상 구현

별도 입력인 board에 대해서만 String.length를 실행합니다.

  • 정상 값: 5
  • 원 main: 이 분기 미도달
  • 별도 정상 입력을 실패 원 main의 후속 동작으로 연결하지 않습니다.

소거된 Object 입력은 브리지의 형변환을 거쳐 String 구현으로 간다

브리지 length(Object)의 checkcast를 거쳐 Integer 25는 구현 전 실패하고 별도 board 문자열은 length(String)으로 5를 반환합니다. 두 독립 입력의 호출 게이트와 미실행 구현을 추적할 수 있습니다.

소거된 Object 입력은 브리지의 형변환을 거쳐 String 구현으로 간다

GenericBridgeCastFailure$StringMapper의 실제 javap 출력에서 확인한 두 메서드입니다.

제네릭 소거 · 브리지의 checkcast 경계

소거된 Object 입력은 브리지의 형변환을 거쳐 String 구현으로 간다

원시 타입으로 넘긴 Integer 25는 length(Object) 브리지까지 도달합니다. String 형변환에서 실패하므로 이 입력은 length(String) 구현에 도달하지 못합니다.

브리지의 변환 실패는 String 구현 앞에서 발생한다입력Object→checkcast String으로연결하고Integer25는실패해ClassCastException으로간다.별도String board 성공입력만length(String)과String.length로가서5를얻는다.실패에서구현으로연결하는간선없음.이는원규칙+추가정상입력의설명이며 preservedhistoricalJDK25javap/Main未実行範囲와freshJDK21execution을분리한다.실패성공length(Object)원 main: Integer 25String 형변환checkcastClassCastExceptionString 구현에 도달 못함length(String)별도 입력: boardString.length()별도 문자열의 길이 5원 main은 실패 분기 · 성공 분기는 별도 정상 입력
브리지의 변환 실패는 String 구현 앞에서 발생한다입력Object→checkcast String으로연결하고Integer25는실패해ClassCastException으로간다.별도String board 성공입력만length(String)과String.length로가서5를얻는다.실패에서구현으로연결하는간선없음.이는원규칙+추가정상입력의설명이며 preservedhistoricalJDK25javap/Main未実行範囲와freshJDK21execution을분리한다.실패length(Object)원 main: Integer 25String 형변환checkcastClassCastException구현에 도달 못함length(String)별도 입력: boardString.length()길이 5오른쪽 선: 성공 분기원 main은 Integer 실패

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)가 입력을 받습니다.

  • 원 입력: Integer 25
  • 정상 대안: String board
  • 브리지가 String 구현 앞에 변환을 수행합니다.
checkcast

입력을 String으로 변환합니다.

  • Integer 25: ClassCastException
  • board: 통과
  • 실패 입력은 length(String)에 도달하지 않습니다.
정상 구현

별도 입력인 board에 대해서만 String.length를 실행합니다.

  • 정상 값: 5
  • 원 main: 이 분기 미도달
  • 별도 정상 입력을 실패 원 main의 후속 동작으로 연결하지 않습니다.

컴파일 뒤 다음 명령을 실행합니다.

javac --release 25 GenericBridgeCastFailure.java
javap -c -v -p 'GenericBridgeCastFailure$StringMapper'

원칙은 원시 타입과 비검사 형변환 경고를 제거하는 것입니다.

브리지 자체는 컴파일러가 타입 다형성을 보존하기 위해 정상 생성한 코드입니다.

원시 호출의 25는 Integer 객체로 전달됩니다. length(Object)의 checkcast가 이 객체를 String으로 바꾸려는 위치에서 실패하므로, 뒤의 String.length()가 null이나 길이를 검사한 실패가 아닙니다. 정상 문자열 "board"를 전달한 별도 경우라면 같은 브리지를 거쳐 길이 5를 얻습니다. 입력을 바꿀 때 원시 타입의 비검사 경고가 해결된 것은 아니라는 점도 함께 확인합니다.


디스크립터와 피연산자 스택

디스크립터 (II)I는 int 두 개를 받아 int를 반환한다는 뜻입니다.

지역 변수 슬롯에 인수가 놓이고 명령어가 피연산자 스택에서 값을 꺼내 계산합니다.

src/ArithmeticBytecodeProbe.java
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입니다.

원래 입력 30과 4에서 조회 기여는 30 × 2 = 60이고 댓글 기여는 작은 쪽인 4 × 3 = 12이므로 합은 72입니다. 별도로 댓글 수를 40으로 바꾸면 댓글 수의 상한 7을 먼저 적용한 뒤 3을 곱해 21을 더하므로 결과는 81입니다. 이 숫자 추론을 먼저 확인한 뒤 스택에서 상한 계산과 곱셈이 어떤 순서로 값을 소비하는지 읽습니다.

-l은 줄 번호·지역 변수 표를 보여 주며 -g:none으로 컴파일하면 디버그 정보가 줄어 디버거 경험이 달라집니다.


try-finally의 예외 테이블

소스의 finally는 정상 return과 예외 경로 모두에서 실행돼야 합니다.

javap로 예외 테이블과 모니터·자원 정리 코드를 확인할 수 있습니다.

src/FinallyBytecodeProbe.java
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를 봅니다.

src/InvokeDynamicInventory.java
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 교차 확인

런타임 리플렉션에서도 브리지·합성 플래그를 확인할 수 있습니다.

소스에 없는 스택 프레임을 발견했다면 클래스 파일과 리플렉션을 함께 살펴봅니다.

src/BridgeMethodInventory.java
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로 플래그를 확인하세요.

메서드 이름은 increment이고 인수는 없습니다. 먼저 메서드 선언에 동기화가 붙은 경우와 본문 안에 동기화 블록이 있는 경우의 확인 위치를 구별합니다. 리플렉션으로 확인할 값과 클래스 파일에서 찾을 표식을 각각 적고, 플래그 하나가 처리량이나 락 대기 시간을 알려 주는지도 설명합니다.

해설 보기
exercise/SynchronizedMethodFlagSolution.java
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 명령어로 나타납니다.