본문으로 건너뛰기

안동민 개발노트

본문 시작

javap와 바이트코드

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

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

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

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

Java source는 classfile 구조를 거쳐 verifier·interpreter·JIT가 실행한다

javap 출력은 source 복원이 아니라 JVM이 보는 descriptor· flag· instruction· attribute를 보여 줍니다.

  1. .java source

    generic· lambda· finally

  2. javac

    lowering· type erasure

  3. .class

    descriptor· flags· Code· attributes

  4. JVM verify/ load

    binary contract 검사

  5. execute

    interpreter + JIT


원시 호출과 브리지 형변환

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

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

원시 타입 MapperInteger를 넣으면 실제 ClassCastException이 브리지에서 발생합니다.

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));
    }
}

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

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

관찰 결과 length(Object) 메서드에 ACC_BRIDGE, ACC_SYNTHETIC 플래그가 있고 바이트코드가 checkcast java/lang/Stringlength(String)을 호출합니다.

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

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


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

디스크립터 (II)Iint 두 개를 받아 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입니다.

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

Generic source method는 type erasure 뒤 synthetic bridge로 Object 호출을 실제 method에 연결한다

Mapper<String>의 source method만 보면 runtime interface descriptor가 Object인 이유와 cast frame을 설명할 수 없습니다.

  1. source

    map(String)

  2. erased interface

    map(Object):Object

  3. ACC_BRIDGE · SYNTHETIC

    compiler-generated method

  4. checkcast String

    runtime type guard

  5. invoke map(String)

    actual implementation


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

관찰 결과 람다 호출 지점은 LambdaMetafactory, 문자열 결합은 StringConcatFactory 부트스트랩을 참조합니다.

컴파일러 버전과 대상 릴리스에 따라 하위 변환 전략이 달라질 수 있으므로 명령어 개수를 성능 결론으로 바로 사용하지 않습니다.

javap option은 확인하려는 classfile 층에 맞춰 고른다

모든 경우에 -verbose 출력을 펼치면 필요한 descriptor나 line table이 방대한 constant pool에 묻힙니다.

option관찰 대상대표 질문
-cbytecode·exception table실제 control flow
-smethod/field descriptorNoSuchMethodError signature
-lline·local variable tablestack line·debug info
-pprivate 포함 모든 member숨은 compiler member
-vflags·constant pool·bootstrapbridge·synthetic·lambda

리플렉션·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()));
    }
}
Bytecode 진단은 증상에서 확인할 classfile 계약을 역으로 고른다

instruction 개수만 세어 성능 결론을 내리면 JIT 최적화와 compiler lowering 차이를 실제 비용으로 오해합니다.

질문비교도구
NoSuchMethodErrorcaller descriptor ↔ runtime classjavap -s -c
bridge frameACC_BRIDGE/SYNTHETIC·checkcastjavap -v·reflection
finally 실행normal/exception path·exception tablejavap -c
lambda/concatinvokedynamic bootstrapjavap -v
성능runtime profile·compiled codeJFR/JIT log 우선

javap 선택 기준

  • 스택 추적에 합성·브리지·람다 프레임이 보일 때 사용합니다.
  • NoSuchMethodError에는 호출자의 디스크립터와 런타임 클래스의 디스크립터를 비교합니다.
  • 접근 오류에는 클래스·메서드 플래그와 모듈·패키지 접근 규칙을 봅니다.
  • 디버거의 소스 줄 대응 관계는 LineNumberTable에서 확인합니다.
  • 성능은 바이트코드만으로 결론 내지 않고 JIT 컴파일과 머신 코드 프로필을 봅니다.
javap 산출물이 같으면 런타임 성능도 같나요?

아닙니다.

JIT 프로필, 인라이닝, 탈출 분석, CPU와 입력 분포가 머신 코드를 바꿉니다.

javap는 클래스 파일 규칙과 컴파일러 하위 변환을 확인하는 도구입니다.

성능은 JMH, JFR, 컴파일러 로그와 함께 측정합니다.


연습 문제

synchronized 인스턴스 메서드를 만들고 리플렉션과 javap로 플래그를 확인하세요.

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