Historique de la question
L'introduction de invokedynamic dans Java 7 via JSR 292 a apporté l'API MethodHandle pour soutenir les implémentations de langages dynamiques sur la JVM. Le défi était que MethodHandle.invoke devait accepter n'importe quelle combinaison de types d'arguments et de types de retour sans déclarer des milliers de surcharges. Les architectes de la JVM ont résolu cela en introduisant le concept de méthodes avec signatures polymorphiques, marquées en interne par l'annotation @PolymorphicSignature au sein du package java.lang.invoke.
Le problème
L'invocation standard d'une méthode Java nécessite que le compilateur émette une instruction invokevirtual (ou similaire) référencant un descripteur de méthode spécifique dans le pool constant qui correspond exactement à la signature déclarée de la méthode. Si MethodHandle.invoke était déclaré pour prendre Object... arguments, chaque site d'appel nécessiterait un boxing et une allocation de tableau, ce qui compromettrait les objectifs de performance. À l'inverse, déclarer des surcharges pour chaque combinaison possible de signatures est impossible et ferait gonfler le fichier Class à l'infini.
La solution
La JVM traite les méthodes annotées avec @PolymorphicSignature de manière spéciale. Lorsque le compilateur rencontre un appel à une telle méthode, il ignore la signature déclarée et génère à la place une instruction invokevirtual dont le descripteur de méthode correspond exactement aux types effacés des arguments et du type de retour au site d'appel. Cela permet à MethodHandle.invokeExact d'apparaître comme acceptant (Object)Object dans le code source mais de se compiler en (String)int à un site d'appel spécifique. La JVM lie alors cet appel directement au point d'entrée de la méthode cible sans overhead d'adaptateur.
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)); // Le compilateur génère invokevirtual avec le descripteur (String)int // malgré invokeExact étant déclaré comme (Object)Object dans le bytecode int result = (int) handle.invokeExact("hello"); System.out.println(result); // Sortie : 5 } }
Description du problème
Lors de la construction d'un cadre de traitement d'événements à fort débit pour des données de ticks financières, nous devions dispatcher des messages entrants à des gestionnaires enregistrés en utilisant une flexibilité de type réflexion mais avec un overhead d'allocation nul. Chaque méthode de gestion avait des signatures différentes : certaines acceptaient des timestamps long, d'autres des prix BigDecimal—rendant le dispatch générique difficile sans boxing des primitives.
Différentes solutions envisagées
La génération dynamique de bytecode impliquait l'utilisation de ASM ou de ByteBuddy pour générer des classes proxy pour chaque signature de gestionnaire au moment de l'enregistrement. Cette approche offrait une performance proche du natif après montée en température mais consommait un Metaspace significatif et augmentait la latence de démarrage de l'application de plusieurs secondes pendant le chargement des classes et la compilation JIT. Elle ajoutait également une complexité de maintenance pour le débogage du code généré.
La réflexion avec les poignées de méthode utilisait Method.invoke standard suivi de unreflect pour obtenir des MethodHandle. Bien que plus simple à mettre en œuvre, cela imposait des coûts de boxing pour les arguments primitifs et empêchait HotSpot de faire de l'inlining à travers la couche réflexive. Les tests de performance montraient que le dispatch était 10 à 15 fois plus lent par rapport aux appels directs, violant nos exigences de latence.
L'exploitation des signatures polymorphiques nécessitait de caster soigneusement les arguments aux types exacts attendus avant d'appeler invokeExact. Cela permettait au compilateur de générer des instructions invokevirtual spécifiques à la signature pour chaque site d'appel, traitant effectivement le MethodHandle comme un pointeur de fonction typé. Le compromis était la rigueur de type à la compilation : nous devions valider les signatures des gestionnaires lors de l'enregistrement pour garantir la sécurité des types, et le code ne se compilerait pas si les signatures étaient mismatched.
Solution choisie et pourquoi
Nous avons sélectionné l'approche de signature polymorphique combinée à une couche de validation au moment de l'enregistrement. En générant des lambdas adaptateurs légers (en utilisant LambdaMetafactory et invokedynamic) qui correspondaient aux signatures exactes de MethodHandle, nous avons obtenu des performances d'appel direct tout en maintenant la sécurité des types. La JVM pouvait inliner à travers le MethodHandle jusqu'à la véritable méthode gestionnaire, éliminant entièrement l'overhead de dispatch.
Résultat
Le système traitait 2,5 millions d'événements par seconde avec une latence sub-microseconde, égalant les performances du code de dispatch écrit à la main. La pression sur le GC a chuté de 98 % par rapport au prototype basé sur la réflexion, car les arguments primitifs n'avaient plus besoin de boxing pendant le chemin d'invocation. La solution est restée maintenable car les erreurs de type étaient détectées à la compilation plutôt qu'à l'exécution.
Pourquoi MethodHandle.invoke() permet-il la conversion de type tandis qu'invokeExact() exige une correspondance précise de la signature bien que les deux aient des signatures polymorphiques ?
Les deux méthodes portent l'annotation @PolymorphicSignature, mais invokeExact effectue une vérification stricte de la signature au niveau de la JVM. Lorsque le compilateur génère l'instruction invokevirtual pour invokeExact, il utilise les types effacés exacts au site d'appel. La JVM vérifie alors que ces types correspondent précisément au MethodType cible. En revanche, invoke (sans Exact) inclut une logique pour adapter les types du site d'appel au type cible en utilisant des adaptateurs MethodHandle.asType, qui effectuent le boxing, le unboxing et les conversions primitives. Cette adaptation se produit au sein de l'implémentation MethodHandle plutôt qu'au site d'appel, rendant invoke plus flexible mais potentiellement plus lent en raison de l'overhead de la chaîne d'adaptateurs.
Comment la JVM empêche-t-elle les violations de sécurité des types si les méthodes à signatures polymorphiques permettent des descripteurs de méthodes arbitraires ?
La JVM s'appuie sur le compilateur Java pour faire respecter la sécurité des types au niveau source. Comme @PolymorphicSignature est restreint aux classes du module java.base (comme MethodHandle et VarHandle), le code utilisateur ne peut pas déclarer de nouvelles méthodes polymorphiques. Le compilateur ne permet que les appels polymorphiques où il peut vérifier les types d'arguments par rapport à la signature attendue au site d'appel. Pour invokeExact, le compilateur insère des casts implicites pour garantir que le descripteur généré correspond à ce que le programmeur avait l'intention. La JVM fait confiance au compilateur pour avoir effectué cette vérification, lui permettant de sauter les vérifications de descripteur à l'exécution lors de l'invocation, atteignant ainsi un zéro-overhead tout en maintenant la sécurité à travers des contraintes au moment de la compilation.
Pourquoi les méthodes à signatures polymorphiques semblent-elles s'effacer en types Object dans les traces de pile et le débogage, tout en s'exécutant avec des types primitifs spécifiques ?
Le compilateur javac émet l'attribut @PolymorphicSignature dans le fichier class pour ces méthodes. Lorsque la JVM résout une invocation à une telle méthode, elle substitue le descripteur de l'entrée du pool constant du site d'appel pour le descripteur déclaré. Cela signifie que l'exécution réelle du bytecode utilise les types spécifiques (int, long, etc.), mais les métadonnées de la méthode dans l'objet Class conservent la signature déclarée (typiquement (Object...)Object) à des fins de réflexion. Par conséquent, les traces de pile montrent la forme effacée parce que Throwable.fillInStackTrace utilise le descripteur symbolique des métadonnées de la méthode, et non le descripteur dynamique utilisé durant l'invocation réelle. Cette distinction confond les développeurs qui s'attendent à voir les types de paramètres exacts dans les débogueurs.