질문의 역사
Java 7에서 JSR 292를 통해 invokedynamic이 도입되면서 JVM에서 동적 언어 구현을 지원하기 위한 MethodHandle API가 제공되었습니다. 문제는 MethodHandle.invoke가 수천 개의 오버로드를 선언하지 않고도 모든 조합의 인자 유형과 반환 유형을 수용해야 한다는 것이었습니다. JVM 설계자들은 @PolymorphicSignature 주석을 사용하여 내부적으로 표시된 다형 서명 메소드의 개념을 도입함으로써 이 문제를 해결했습니다.
문제
표준 Java 메소드 호출은 컴파일러가 메소드의 선언된 서명과 정확히 일치하는 특정 메소드 설명자를 참조하는 invokevirtual (또는 유사한) 명령어를 방출해야 합니다. 만약 MethodHandle.invoke가 Object... 인자를 받도록 선언된 경우, 모든 호출 사이트에서 박싱 및 배열 할당이 필요하게 되어 성능 목표를 달성할 수 없게 됩니다. 반대로, 모든 가능한 서명 조합에 대해 오버로드를 선언하는 것은 불가능하며 Class 파일이 무한히 부풀어 오르게 됩니다.
해결책
JVM은 @PolymorphicSignature로 주석이 달린 메소드를 특별하게 처리합니다. 컴파일러가 그러한 메소드에 대한 호출을 만났을 때, 선언된 서명을 무시하고 호출 사이트의 인자 및 반환 유형의 지워진 유형과 정확히 일치하는 메소드 설명자를 갖는 invokevirtual 명령어를 생성합니다. 이를 통해 MethodHandle.invokeExact는 소스 코드에서 (Object)Object로 수용되는 것처럼 보이지만 특정 호출 사이트에서 (String)int로 컴파일됩니다. 그런 다음 JVM은 이 호출을 대체자 오버헤드 없이 타겟 메소드의 진입점에 직접 연결합니다.
import java.lang.invoke.MethodHandle; import java.lang.invoke.MethodHandles; import java.lang.invoke.MethodType; public class PolymorphicExample { public static void main(String[] args) throws Throwable { MethodHandle handle = MethodHandles.lookup() .findVirtual(String.class, "length", MethodType.methodType(int.class)); // 컴파일러는 설명자 (String)int로invokevirtual을 생성합니다. // bytecode에서 invokeExact가 (Object)Object로 선언되어 있음에도 불구하고 int result = (int) handle.invokeExact("hello"); System.out.println(result); // 출력: 5 } }
문제 설명
금융 틱 데이터에 대한 고처리량 이벤트 처리 프레임워크를 구축하는 동안, 우리는 반사와 같은 유연성을 사용하여 등록된 핸들러에 들어오는 메시지를 디스패치해야 했지만 0 할당 오버헤드로 수행해야 했습니다. 각 핸들러 메소드는 서로 다른 서명을 가졌습니다. 일부는 long 타임스탬프를 수용하고, 다른 일부는 BigDecimal 가격을 수용하여 일반적인 디스패치가 어려워졌습니다.
고려된 다양한 해결책
동적 바이트코드 생성은 ASM 또는 ByteBuddy를 사용하여 각 핸들러 서명에 대한 프록시 클래스를 등록 시간에 생성하는 것을 포함했습니다. 이 접근 방식은 워밍업 후 거의 네이티브 성능을 제공했지만 상당한 Metaspace를 소비하고 클래스 로딩 및 JIT 컴파일 중 몇 초 동안 응용 프로그램 시작 대기 시간을 증가시켰습니다. 또한 생성된 코드를 디버깅하는 데에 유지 관리 복잡성을 더했습니다.
메소드 핸들을 사용하는 반사는 표준 Method.invoke를 사용하고 unreflect를 통해 MethodHandle를 얻는 것을 포함했습니다. 구현하기는 더 간단했지만, 이는 원시 인자에 대해 박싱 비용을 부과하고 HotSpot이 반사 레이어를 통해 인라인하는 것을 방지했습니다. 성능 테스트에서는 직접 호출과 비교하여 10-15배 느린 디스패치가 이루어져 지연 시간 요구 사항을 위반했습니다.
다형 서명 활용은 인자를 호출 전 예기치 않은 정확한 기대 유형으로 신중하게 캐스팅해야 했습니다. 이것은 컴파일러가 각 호출 사이트에 서명별 invokevirtual 명령어를 생성할 수 있게 해 주었으며, 효과적으로 MethodHandle을 유형화된 함수 포인터로 취급하게 합니다. 트레이드오프는 컴파일 시간 유형 엄격성으로, 우리는 등록 기간 동안 핸들러 서명을 검증해야 하였으며, 서명이 일치하지 않으면 코드가 컴파일되지 않았습니다.
선택된 해결책과 이유
우리는 다형 서명 접근 방식을 선택하고 등록 시간 검증 레이어를 결합하였습니다. LambdaMetafactory와 invokedynamic를 사용해 적절한 MethodHandle 서명에 맞는 경량 어댑터 람다를 생성함으로써 우리는 직접 호출 성능을 달성하면서 타입 안전성을 유지했습니다. JVM은 실제 핸들러 메소드로의 MethodHandle을 통해 인라인할 수 있었으며, 완전히 디스패치 오버헤드를 제거했습니다.
결과
시스템은 초당 250만 이벤트를 처리하고 서브 마이크로초 대기 시간을 맞추어 수동으로 작성된 디스패치 코드 성능과 일치했습니다. GC 압력은 반사 기반 프로토타입과 비교하여 98% 감소하였으며, 원시 인자가 더 이상 호출 경로에서 박싱을 요구하지 않았습니다. 이 솔루션은 타입 오류가 런타임이 아니라 컴파일 타임에 포착되므로 유지 관리가 용이하게 유지되었습니다.
왜 MethodHandle.invoke()는 타입 변환을 허용하는 반면 invokeExact()는 정밀한 서명 일치를 요구하는가, 두 메소드 모두 다형 서명을 가지고 있음에도 불구하고?
두 메소드는 @PolymorphicSignature 주석을 보유하고 있지만, invokeExact는 JVM 수준에서 엄격한 서명 체크를 수행합니다. 컴파일러가 invokeExact를 위한 invokevirtual 명령어를 생성할 때, 호출 사이트에서 정확한 지워진 유형을 사용합니다. 그런 다음 JVM은 이러한 유형이 목표 MethodType과 정밀하게 일치하는지 확인합니다. 반면, invoke(Exact 없이)는 호출 사이트 유형을 목표 유형에 맞게 조정하는 로직을 포함하며, 이는 박싱, 언박싱 및 원시 변환을 수행합니다. 이 조정은 호출 사이트에서가 아니라 MethodHandle 구현 내에서 발생하므로 invoke는 더 유연하지만 어댑터 체인 오버헤드로 인해 느려질 수 있습니다.
JVM은 다형 서명 메소드가 임의의 메소드 설명자를 허용하더라도 어떻게 타입 안전성 위반을 방지하는가?
JVM은 소스 수준에서 타입 안전성을 강제하기 위해 Java 컴파일러에 의존합니다. @PolymorphicSignature는 MethodHandle 및 VarHandle과 같은 java.base 모듈 클래스에 제한되므로 사용자 코드는 새로운 다형 메소드를 선언할 수 없습니다. 컴파일러는 호출 사이트에서 기대되는 서명에 대해 인자 유형을 검증할 수 있을 때만 다형 호출을 허용합니다. invokeExact의 경우 컴파일러는 생성된 설명자가 프로그래머가 의도한 것과 일치하도록 보장하기 위해 암시적 캐스트를 삽입합니다. JVM은 컴파일러가 이 검증을 수행했다고 믿으며, 이를 통해 호출 시 런타임 설명자 검사를 건너뛰어 오버헤드가 없는 제로를 달성하면서 컴파일 타임 제약을 통해 안전성을 유지합니다.
왜 다형 서명 메소드는 스택 추적 및 디버깅에서 Object 유형으로 지워지는 것처럼 보이지만 특정 원시 유형으로 실행됩니까?
javac 컴파일러는 이러한 메소드에 대해 class 파일에 @PolymorphicSignature 속성을 사용하여 출력합니다. JVM이 이러한 메소드에 대한 호출을 해결할 때, 호출 사이트의 상수 풀 항목에서 설명자를 선언된 설명자로 대체합니다. 이는 실제 바이트코드 실행이 특정 유형(int, long 등)을 사용하는 반면 메소드의 메타데이터는 반사 목적으로 선언된 서명을 유지하게 함을 의미합니다(일반적으로 (Object...)Object). 따라서 스택 추적은 지워진 형태를 보여주며, 이는 Throwable.fillInStackTrace가 메소드의 메타데이터에서 기호적 설명자를 사용하기 때문이며, 실제 호출 중 동적 설명자는 사용되지 않습니다. 이러한 차이점은 개발자들이 디버거에서 정확한 매개변수 유형을 볼 것으로 기대하게 만들어 혼란을 야기합니다.