Introduction à Invoke Dynamic dans la JVM

1. Vue d'ensemble

Invoke Dynamic (également connu sous le nom d'Indy) faisait partie de JSR 292 destiné à améliorer la prise en charge de la JVM pour les langages à typage dynamique. Après sa première version en Java 7, l' opcode invokedynamic est largement utilisé par les langages dynamiques basés sur JVM comme JRuby et même les langages statiquement typés comme Java.

Dans ce tutoriel, nous allons démystifier invokedynamic et voir comment il peutaider les concepteurs de bibliothèques et de langage à mettre en œuvre de nombreuses formes de dynamicité

2. Découvrez Invoke Dynamic

Commençons par une simple chaîne d'appels d'API Stream:

public class Main { public static void main(String[] args) { long lengthyColors = List.of("Red", "Green", "Blue") .stream().filter(c -> c.length() > 3).count(); } }

Au début, nous pourrions penser que Java crée une classe interne anonyme dérivant de Predicate , puis transmet cette instance à la méthode de filtrage . Mais nous aurions tort.

2.1. Le Bytecode

Pour vérifier cette hypothèse, nous pouvons jeter un coup d'œil au bytecode généré:

javap -c -p Main // truncated // class names are simplified for the sake of brevity // for instance, Stream is actually java/util/stream/Stream 0: ldc #7 // String Red 2: ldc #9 // String Green 4: ldc #11 // String Blue 6: invokestatic #13 // InterfaceMethod List.of:(LObject;LObject;)LList; 9: invokeinterface #19, 1 // InterfaceMethod List.stream:()LStream; 14: invokedynamic #23, 0 // InvokeDynamic #0:test:()LPredicate; 19: invokeinterface #27, 2 // InterfaceMethod Stream.filter:(LPredicate;)LStream; 24: invokeinterface #33, 1 // InterfaceMethod Stream.count:()J 29: lstore_1 30: return

Malgré ce que nous pensions, il n'y a pas de classe interne anonyme et personne ne transmet une instance d'une telle classe à la méthode de filtrage .

Étonnamment, l' instruction invokedynamic est en quelque sorte responsable de la création de l' instance Predicate .

2.2. Méthodes spécifiques Lambda

De plus, le compilateur Java a également généré la méthode statique amusante suivante:

private static boolean lambda$main$0(java.lang.String); Code: 0: aload_0 1: invokevirtual #37 // Method java/lang/String.length:()I 4: iconst_3 5: if_icmple 12 8: iconst_1 9: goto 13 12: iconst_0 13: ireturn

Cette méthode prend une chaîne comme entrée, puis effectue les étapes suivantes:

  • Calcul de la longueur d'entrée (invokevirtual sur la longueur )
  • Comparaison de la longueur avec la constante 3 ( if_icmple et iconst_3 )
  • Renvoie false si la longueur est inférieure ou égale à 3

Fait intéressant, c'est en fait l'équivalent du lambda que nous avons passé à la méthode de filtrage :

c -> c.length() > 3

Ainsi, au lieu d'une classe interne anonyme, Java crée une méthode statique spéciale et appelle d'une manière ou d'une autre cette méthode via invokedynamic.

Au cours de cet article, nous allons voir comment cette invocation fonctionne en interne. Mais, d'abord, définissons le problème que invokedynamic tente de résoudre.

2.3. Le problème

Avant Java 7, la JVM n'avait que quatre types d'invocation de méthode: invokevirtual pour appeler des méthodes de classe normales, invokestatic pour appeler des méthodes statiques, invokeinterface pour appeler des méthodes d'interface et invokespecial pour appeler des constructeurs ou des méthodes privées.

Malgré leurs différences, toutes ces invocations partagent un trait simple: elles ont quelques étapes prédéfinies pour terminer chaque appel de méthode, et nous ne pouvons pas enrichir ces étapes avec nos comportements personnalisés.

Il existe deux solutions principales pour cette limitation: une au moment de la compilation et l'autre au moment de l'exécution. Le premier est généralement utilisé par des langages comme Scala ou Koltin et le second est la solution de choix pour les langages dynamiques basés sur JVM comme JRuby.

L'approche d'exécution est généralement basée sur la réflexion et, par conséquent, inefficace.

D'autre part, la solution au moment de la compilation repose généralement sur la génération de code au moment de la compilation. Cette approche est plus efficace lors de l'exécution. Cependant, il est quelque peu fragile et peut également ralentir le temps de démarrage car il y a plus de bytecode à traiter.

Maintenant que nous avons une meilleure compréhension du problème, voyons comment la solution fonctionne en interne.

3. Sous le capot

invokedynamic nous permet d'amorcer le processus d'appel de méthode comme nous le souhaitons . Autrement dit, lorsque la JVM voit unopcode dynamique invoqué pour la première fois, elle appelle une méthode spéciale connue sous le nom de méthode d'amorçage pour initialiser le processus d'appel:

La méthode bootstrap est un morceau normal de code Java que nous avons écrit pour configurer le processus d'appel. Par conséquent, il peut contenir n'importe quelle logique.

Une fois que la méthode d'amorçage se termine normalement, elle doit renvoyer une instance de CallSite. Ce CallSite contient les informations suivantes:

  • Un pointeur vers la logique réelle que JVM doit exécuter. Cela doit être représenté comme un MethodHandle.
  • Une condition représentant la validité du CallSite retourné .

À partir de maintenant, chaque fois que JVM verra à nouveau cet opcode particulier, il ignorera le chemin lent et appellera directement l'exécutable sous-jacent . De plus, la JVM continuera à ignorer le chemin lent jusqu'à ce que la condition du CallSite change.

Contrairement à l'API Reflection, la JVM peut complètement voir les MethodHandle et essaiera de les optimiser, d'où les meilleures performances.

3.1. Tableau des méthodes Bootstrap

Jetons un autre regard sur le bytecode invokedynamic généré :

14: invokedynamic #23, 0 // InvokeDynamic #0:test:()Ljava/util/function/Predicate;

Cela signifie que cette instruction particulière doit appeler la première méthode d'amorçage (partie # 0) à partir de la table des méthodes d'amorçage. En outre, il mentionne certains des arguments à passer à la méthode bootstrap:

  • The test is the only abstract method in the Predicate
  • The ()Ljava/util/function/Predicate represents a method signature in the JVM – the method takes nothing as input and returns an instance of the Predicate interface

In order to see the bootstrap method table for the lambda example, we should pass -v option to javap:

javap -c -p -v Main // truncated // added new lines for brevity BootstrapMethods: 0: #55 REF_invokeStatic java/lang/invoke/LambdaMetafactory.metafactory: (Ljava/lang/invoke/MethodHandles$Lookup; Ljava/lang/String; Ljava/lang/invoke/MethodType; Ljava/lang/invoke/MethodType; Ljava/lang/invoke/MethodHandle; Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite; Method arguments: #62 (Ljava/lang/Object;)Z #64 REF_invokeStatic Main.lambda$main$0:(Ljava/lang/String;)Z #67 (Ljava/lang/String;)Z

The bootstrap method for all lambdas is the metafactory static method in the LambdaMetafactory class.

Similar to all other bootstrap methods, this one takes at least three arguments as follows:

  • The Ljava/lang/invoke/MethodHandles$Lookup argument represents the lookup context for the invokedynamic
  • The Ljava/lang/String represents the method name in the call site – in this example, the method name is test
  • The Ljava/lang/invoke/MethodType is the dynamic method signature of the call site – in this case, it's ()Ljava/util/function/Predicate

In addition to these three arguments, bootstrap methods also can optionally accept one or more extra parameters. In this example, these are the extra ones:

  • The (Ljava/lang/Object;)Z is an erased method signature accepting an instance of Object and returning a boolean.
  • The REF_invokeStatic Main.lambda$main$0:(Ljava/lang/String;)Z is the MethodHandle pointing to the actual lambda logic.
  • The (Ljava/lang/String;)Z is a non-erased method signature accepting one String and returning a boolean.

Put simply, the JVM will pass all the required information to the bootstrap method. Bootstrap method will, in turn, use that information to create an appropriate instance of Predicate. Then, the JVM will pass that instance to the filter method.

3.2. Different Types of CallSites

Once the JVM sees invokedynamic in this example for the first time, it calls the bootstrap method. As of writing this article, the lambda bootstrap method will use the InnerClassLambdaMetafactoryto generate an inner class for the lambda at runtime.

Then the bootstrap method encapsulates the generated inner class inside a special type of CallSite known as ConstantCallSite. This type of CallSite would never change after setup. Therefore, after the first setup for each lambda, the JVM will always use the fast path to directly call the lambda logic.

Although this is the most efficient type of invokedynamic, it's certainly not the only available option. As a matter of fact, Java provides MutableCallSite and VolatileCallSite to accommodate for more dynamic requirements.

3.3. Advantages

So, in order to implement lambda expressions, instead of creating anonymous inner classes at compile-time, Java creates them at runtime via invokedynamic.

One might argue against deferring inner class generation until runtime. However, the invokedynamic approach has a few advantages over the simple compile-time solution.

First, the JVM does not generate the inner class until the first use of lambda. Hence, we won't pay for the extra footprint associated with the inner class before the first lambda execution.

Additionally, much of the linkage logic is moved out from the bytecode to the bootstrap method. Therefore, the invokedynamic bytecode is usually much smaller than alternative solutions. The smaller bytecode can boost startup speed.

Suppose a newer version of Java comes with a more efficient bootstrap method implementation. Then our invokedynamic bytecode can take advantage of this improvement without recompiling. This way we can achieve some sort of forwarding binary compatibility. Basically, we can switch between different strategies without recompilation.

Finally, writing the bootstrap and linkage logic in Java is usually easier than traversing an AST to generate a complex piece of bytecode. So, invokedynamic can be (subjectively) less brittle.

4. More Examples

Lambda expressions are not the only feature, and Java is not certainly the only language using invokedynamic. In this section, we're going to get familiar with a few other examples of dynamic invocation.

4.1. Java 14: Records

Records are a new preview feature in Java 14 providing a nice concise syntax to declare classes that are supposed to be dumb data holders.

Here's a simple record example:

public record Color(String name, int code) {}

Given this simple one-liner, Java compiler generates appropriate implementations for accessor methods, toString, equals, and hashcode.

In order to implement toString, equals, or hashcode, Java is using invokedynamic. For instance, the bytecode for equals is as follows:

public final boolean equals(java.lang.Object); Code: 0: aload_0 1: aload_1 2: invokedynamic #27, 0 // InvokeDynamic #0:equals:(LColor;Ljava/lang/Object;)Z 7: ireturn

The alternative solution is to find all record fields and generate the equals logic based on those fields at compile-time. The more we have fields, the lengthier the bytecode.

On the contrary, Java calls a bootstrap method to link the appropriate implementation at runtime. Therefore, the bytecode length would remain constant regardless of the number of fields.

Looking more closely at the bytecode shows that the bootstrap method is ObjectMethods#bootstrap:

BootstrapMethods: 0: #42 REF_invokeStatic java/lang/runtime/ObjectMethods.bootstrap: (Ljava/lang/invoke/MethodHandles$Lookup; Ljava/lang/String; Ljava/lang/invoke/TypeDescriptor; Ljava/lang/Class; Ljava/lang/String; [Ljava/lang/invoke/MethodHandle;)Ljava/lang/Object; Method arguments: #8 Color #49 name;code #51 REF_getField Color.name:Ljava/lang/String; #52 REF_getField Color.code:I

4.2. Java 9: String Concatenation

Avant Java 9, les concaténations de chaînes non triviales étaient implémentées à l'aide de StringBuilder. Dans le cadre de JEP 280, la concaténation de chaînes utilise désormais invokedynamic. Par exemple, concaténons une chaîne constante avec une variable aléatoire:

"random-" + ThreadLocalRandom.current().nextInt();

Voici à quoi ressemble le bytecode pour cet exemple:

0: invokestatic #7 // Method ThreadLocalRandom.current:()LThreadLocalRandom; 3: invokevirtual #13 // Method ThreadLocalRandom.nextInt:()I 6: invokedynamic #17, 0 // InvokeDynamic #0:makeConcatWithConstants:(I)LString;

De plus, les méthodes d'amorçage pour les concaténations de chaînes résident dans la classe StringConcatFactory :

BootstrapMethods: 0: #30 REF_invokeStatic java/lang/invoke/StringConcatFactory.makeConcatWithConstants: (Ljava/lang/invoke/MethodHandles$Lookup; Ljava/lang/String; Ljava/lang/invoke/MethodType; Ljava/lang/String; [Ljava/lang/Object;)Ljava/lang/invoke/CallSite; Method arguments: #36 random-\u0001

5. Conclusion

Dans cet article, nous nous sommes d'abord familiarisés avec les problèmes que l'indy tente de résoudre.

Ensuite, en parcourant un exemple d'expression lambda simple, nous avons vu comment invokedynamic fonctionne en interne.

Enfin, nous avons énuméré quelques autres exemples d'indy dans les versions récentes de Java.