JavaprogramowanieStarszy programista Java

Przez jaki konkretny kontrakt na poziomie JVM kompilator rozpoznaje metody o polimorficznych sygnaturach, co umożliwia emisję specyficznych dla miejsca wywołania deskryptorów metod, które nadpisują zadeklarowaną sygnaturę?

Zdaj rozmowy kwalifikacyjne z asystentem AI Hintsage

Odpowiedź na pytanie

Historia pytania

Wprowadzenie invokedynamic w Java 7 poprzez JSR 292 przyniosło API MethodHandle, aby wspierać implementacje języków dynamicznych na JVM. Wyzwanie polegało na tym, że MethodHandle.invoke musiał przyjmować dowolne kombinacje typów argumentów i typów zwracanych bez zadeklarowania tysiąca przeciążeń. Architekci JVM rozwiązali to, wprowadzając pojęcie metod o polimorficznych sygnaturach, wewnętrznie oznaczonych adnotacją @PolymorphicSignature w pakiecie java.lang.invoke.

Problem

Standardowe wywołanie metod w Java wymaga od kompilatora emisji instrukcji invokevirtual (lub podobnej), odnoszącej się do konkretnego deskryptora metody w puli stałych, który dokładnie odpowiada zadeklarowanej sygnaturze metody. Jeśli MethodHandle.invoke byłby zadeklarowany jako przyjmujący Object... args, każde miejsce wywołania wymagałoby opakowywania i alokacji tablicy, co podważyłoby cele wydajnościowe. Z drugiej strony, zadeklarowanie przeciążeń dla każdej możliwej kombinacji sygnatury jest niemożliwe i spowodowałoby nieskończony wzrost rozmiaru pliku Class.

Rozwiązanie

JVM traktuje metody oznaczone adnotacją @PolymorphicSignature w sposób specjalny. Gdy kompilator napotyka wywołanie takiej metody, ignoruje zadeklarowaną sygnaturę i zamiast tego generuje instrukcję invokevirtual, której deskryptor metody dokładnie odpowiada usuniętym typom argumentów i typowi zwracającemu w miejscu wywołania. Dzięki temu MethodHandle.invokeExact może pojawiać się jako akceptujący (Object)Object w kodzie źródłowym, ale kompilować się do (String)int w konkretnym miejscu wywołania. JVM następnie łączy to wywołanie bezpośrednio z punktem wejścia docelowej metody bez narzutu adaptora.

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)); // Kompilator generuje invokevirtual z deskryptorem (String)int // mimo że invokeExact jest zadeklarowane jako (Object)Object w bajtkodzie int result = (int) handle.invokeExact("hello"); System.out.println(result); // Wypisuje: 5 } }

Sytuacja z życia

Opis problemu

Podczas budowania frameworka do przetwarzania zdarzeń o wysokiej wydajności dla danych tickowych z rynku finansowego, musieliśmy przesyłać przychodzące komunikaty do zarejestrowanych obsługujących, używając elastyczności podobnej do refleksji, ale z zerowym narzutem alokacyjnym. Każda metoda obsługująca miała różne sygnatury — niektóre przyjmowały long znaczniki czasu, inne BigDecimal ceny — co utrudniało ogólne przesyłanie bez opakowywania typów prymitywnych.

Różne rozważane rozwiązania

Dynamiczna generacja bajtkodu wiązała się z używaniem ASM lub ByteBuddy do generowania klas proxy dla każdej sygnatury obsługi w czasie rejestracji. To podejście oferowało wydajność zbliżoną do natywnej po rozgrzaniu, ale zużywało znaczną ilość Metaspace i zwiększało czas uruchamiania aplikacji o kilka sekund podczas ładowania klas i kompilacji JIT. Dodało to również złożoności utrzymania przy debugowaniu wygenerowanego kodu.

Refleksja z uchwytami metod wykorzystywała standardowe Method.invoke, a następnie unreflect, aby uzyskać MethodHandle. Choć łatwiejsze do zaimplementowania, to generowało koszty opakowywania dla argumentów prymitywnych i uniemożliwiało HotSpot w inlining przez warstwę refleksyjną. Testy wydajności wykazały, że przesyłanie było 10-15 razy wolniejsze w porównaniu do wywołań bezpośrednich, co naruszało nasze wymagania dotyczące opóźnień.

Wykorzystanie polimorficznych sygnatur wymagało starannego rzutowania argumentów na dokładne oczekiwane typy przed wywołaniem invokeExact. To pozwalało kompilatorowi generować specyficzne dla sygnatury instrukcje invokevirtual dla każdego miejsca wywołania, skutecznie traktując MethodHandle jako typowany wskaźnik do funkcji. Kompromis polegał na rygorze typów w czasie kompilacji — musieliśmy walidować sygnatury obsługi podczas rejestracji, aby zapewnić bezpieczeństwo typów, a kod nie kompilowałby się, jeśli sygnatury byłyby niezgodne.

Wybrane rozwiązanie i dlaczego

Wybraliśmy podejście z polimorficznymi sygnaturami w połączeniu z warstwą walidacyjną w czasie rejestracji. Dzięki generowaniu lekkich adapterów (przy użyciu LambdaMetafactory i invokedynamic), które pasowały do dokładnych sygnatur MethodHandle, osiągnęliśmy wydajność wywołań bezpośrednich przy zachowaniu bezpieczeństwa typów. JVM mogło linii przez MethodHandle do rzeczywistej metody obsługującej, całkowicie eliminując narzut wysyłania.

Wynik

System przetwarzał 2,5 miliona zdarzeń na sekundę z opóźnieniem na poziomie sub-mikrosekundy, co odpowiadało wydajności kodu do przesyłania napisanego ręcznie. Nacisk GC spadł o 98% w porównaniu do prototypu opartego na refleksji, ponieważ argumenty prymitywne nie wymagały już opakowywania podczas ścieżki wywołania. Rozwiązanie pozostawało zdatne do utrzymania, ponieważ błędy typów były wykrywane w czasie kompilacji, a nie w czasie wykonania.

Co kandydaci często pomijają

Dlaczego MethodHandle.invoke() pozwala na konwersję typów, podczas gdy invokeExact() wymaga dokładnego dopasowania sygnatury, mimo że oba mają polimorficzne sygnatury?

Obie metody mają adnotację @PolymorphicSignature, ale invokeExact przeprowadza ścisłe sprawdzanie sygnatur na poziomie JVM. Kiedy kompilator generuje instrukcję invokevirtual dla invokeExact, używa dokładnych usuniętych typów w miejscu wywołania. JVM następnie weryfikuje, że te typy dokładnie odpowiadają docelowemu MethodType. W przeciwieństwie do tego, invoke (bez Exact) zawiera logikę dostosowującą typy miejsc wywołania do docelowego typu za pomocą adapterów MethodHandle.asType, które wykonują opakowywanie, rozpakowywanie i konwersje prymitywne. To dostosowanie dzieje się w implementacji MethodHandle, a nie w miejscu wywołania, co sprawia, że invoke jest bardziej elastyczne, ale potencjalnie wolniejsze z powodu narzutu łańcucha adapterów.

Jak JVM zapobiega naruszeniom bezpieczeństwa typów, jeśli metody o polimorficznych sygnaturach pozwalają na dowolne deskryptory metod?

JVM polega na kompilatorze Java, aby egzekwował bezpieczeństwo typów na poziomie źródłowym. Ponieważ @PolymorphicSignature jest ograniczone do klas modułu java.base (takich jak MethodHandle i VarHandle), użytkownik nie może zadeklarować nowych metod polimorficznych. Kompilator pozwala tylko na wywołania polimorficzne, gdzie może zweryfikować typy argumentów w porównaniu do oczekiwanej sygnatury w miejscu wywołania. W przypadku invokeExact kompilator wstawia ukryte rzutowania, aby zapewnić, że generowany deskryptor odpowiada temu, co programista zamierzał. JVM ufa, że kompilator przeprowadził tę weryfikację, co pozwala mu pominąć sprawdzenia deskryptora w czasie wykonania podczas wywołania, osiągając tym samym zerowy narzut przy zachowaniu bezpieczeństwa dzięki ograniczeniom w czasie kompilacji.

Dlaczego metody o polimorficznych sygnaturach wydają się być zmazane do typów Object w śladach stosu i podczas debugowania, lecz wykonywane są ze specyficznymi typami prymitywnymi?

Kompilator javac emituje atrybut @PolymorphicSignature w pliku class dla tych metod. Kiedy JVM rozwiązuje wywołanie do takiej metody, zastępuje deskryptor z wpisu w puli stałych miejsca wywołania dla zadeklarowanego deskryptora. To oznacza, że rzeczywiste wykonanie bajtkodu używa specyficznych typów (int, long, itd.), ale metadane metody w obiekcie Class zachowują zadeklarowaną sygnaturę (zwykle (Object...)Object) dla celów refleksyjnych. W rezultacie ślady stosu pokazują zmazaną formę, ponieważ Throwable.fillInStackTrace używa symbolicznego deskryptora z metadanych metody, a nie dynamicznego deskryptora używanego podczas rzeczywistego wywołania. Ta różnica myli deweloperów, którzy oczekują zobaczyć dokładne typy parametrów w debuggerach.