Mettez la main à la pâte avec Scala JVM Bytecode

Publié: 2022-03-11

Le langage Scala n'a cessé de gagner en popularité au cours des dernières années, grâce à son excellente combinaison de principes de développement logiciel fonctionnels et orientés objet, et à son implémentation au-dessus de la machine virtuelle Java (JVM) éprouvée.

Bien que Scala compile en bytecode Java, il est conçu pour améliorer de nombreuses lacunes perçues du langage Java. Offrant une prise en charge complète de la programmation fonctionnelle, la syntaxe de base de Scala contient de nombreuses structures implicites qui doivent être construites explicitement par les programmeurs Java, certaines impliquant une complexité considérable.

La création d'un langage qui se compile en bytecode Java nécessite une compréhension approfondie du fonctionnement interne de la machine virtuelle Java. Pour apprécier ce que les développeurs de Scala ont accompli, il est nécessaire d'aller sous le capot et d'explorer comment le code source de Scala est interprété par le compilateur pour produire un bytecode JVM efficace et efficace.

Voyons comment tout cela est implémenté.

Conditions préalables

La lecture de cet article nécessite une compréhension de base du bytecode Java Virtual Machine. La spécification complète de la machine virtuelle peut être obtenue à partir de la documentation officielle d'Oracle. Lire toute la spécification n'est pas critique pour comprendre cet article, donc, pour une introduction rapide aux bases, j'ai préparé un petit guide au bas de l'article.

Cliquez ici pour lire un cours accéléré sur les bases de la JVM.

Un utilitaire est nécessaire pour désassembler le bytecode Java afin de reproduire les exemples fournis ci-dessous et de procéder à une enquête plus approfondie. Le kit de développement Java fournit son propre utilitaire de ligne de commande, javap , que nous utiliserons ici. Une démonstration rapide du fonctionnement de javap est incluse dans le guide en bas.

Et bien sûr, une installation fonctionnelle du compilateur Scala est nécessaire pour les lecteurs qui souhaitent suivre les exemples. Cet article a été écrit en utilisant Scala 2.11.7. Différentes versions de Scala peuvent produire un bytecode légèrement différent.

Getters et Setters par défaut

Bien que la convention Java fournisse toujours des méthodes getter et setter pour les attributs publics, les programmeurs Java sont tenus de les écrire eux-mêmes, malgré le fait que le modèle pour chacun n'a pas changé depuis des décennies. Scala, en revanche, fournit des getters et des setters par défaut.

Regardons l'exemple suivant :

 class Person(val name:String) { }

Jetons un coup d'œil à l'intérieur de la classe Person . Si nous compilons ce fichier avec scalac , alors exécuter $ javap -p Person.class nous donne :

 Compiled from "Person.scala" public class Person { private final java.lang.String name; // field public java.lang.String name(); // getter method public Person(java.lang.String); // constructor }

Nous pouvons voir que pour chaque champ de la classe Scala, un champ et sa méthode getter sont générés. Le champ est privé et final, tandis que la méthode est publique.

Si nous remplaçons val par var dans la source Person et recompilons, le modificateur final du champ est supprimé et la méthode setter est également ajoutée :

 Compiled from "Person.scala" public class Person { private java.lang.String name; // field public java.lang.String name(); // getter method public void name_$eq(java.lang.String); // setter method public Person(java.lang.String); // constructor }

Si une val ou une var est définie dans le corps de la classe, le champ privé correspondant et les méthodes d'accès sont créés et initialisés de manière appropriée lors de la création de l'instance.

Notez qu'une telle implémentation des champs val et var au niveau de la classe signifie que si certaines variables sont utilisées au niveau de la classe pour stocker des valeurs intermédiaires, et ne sont jamais accédées directement par le programmeur, l'initialisation de chacun de ces champs ajoutera une à deux méthodes au empreinte de classe. L'ajout d'un modificateur private pour de tels champs ne signifie pas que les accesseurs correspondants seront supprimés. Ils deviendront simplement privés.

Définitions des variables et des fonctions

Supposons que nous ayons une méthode, m() , et créons trois références différentes de style Scala à cette fonction :

 class Person(val name:String) { def m(): Int = { // ... return 0 } val m1 = m var m2 = m def m3 = m }

Comment chacune de ces références à m est-elle construite ? Quand m est-il exécuté dans chaque cas ? Examinons le bytecode résultant. La sortie suivante montre les résultats de javap -v Person.class (en omettant beaucoup de sortie superflue) :

 Constant pool: #22 = Fieldref #2.#21 // Person.m1:I #24 = Fieldref #2.#23 // Person.m2:I #30 = Methodref #2.#29 // Person.m:()I #35 = Methodref #4.#34 // java/lang/Object."<init>":()V // ... public int m(); Code: // other methods refer to this method // ... public int m1(); Code: // get the value of field m1 and return it 0: aload_0 1: getfield #22 // Field m1:I 4: ireturn public int m2(); Code: // get the value of field m2 and return it 0: aload_0 1: getfield #24 // Field m2:I 4: ireturn public void m2_$eq(int); Code: // get the value of this method's input argument 0: aload_0 1: iload_1 // write it to the field m2 and return 2: putfield #24 // Field m2:I 5: return public int m3(); Code: // execute the instance method m(), and return 0: aload_0 1: invokevirtual #30 // Method m:()I 4: ireturn public Person(java.lang.String); Code: // instance constructor ... // execute the instance method m(), and write the result to field m1 9: aload_0 10: aload_0 11: invokevirtual #30 // Method m:()I 14: putfield #22 // Field m1:I // execute the instance method m(), and write the result to field m2 17: aload_0 18: aload_0 19: invokevirtual #30 // Method m:()I 22: putfield #24 // Field m2:I 25: return

Dans le pool constant, nous voyons que la référence à la méthode m() est stockée à l'index #30 . Dans le code constructeur, on voit que cette méthode est invoquée deux fois lors de l'initialisation, avec l'instruction invokevirtual #30 apparaissant d'abord à l'offset 11, puis à l'offset 19. La première invocation est suivie de l'instruction putfield #22 qui assigne le résultat de cette méthode au champ m1 , référencé par l'index #22 dans le pool constant. La deuxième invocation est suivie du même modèle, cette fois en attribuant la valeur au champ m2 , indexé à #24 dans le pool de constantes.

En d'autres termes, l'affectation d'une méthode à une variable définie avec val ou var n'affecte que le résultat de la méthode à cette variable. Nous pouvons voir que les méthodes m1() et m2() qui sont créées sont simplement des getters pour ces variables. Dans le cas de var m2 , nous voyons également que le setter m2_$eq(int) est créé, qui se comporte comme n'importe quel autre setter, écrasant la valeur dans le champ.

Cependant, l'utilisation du mot-clé def donne un résultat différent. Plutôt que de récupérer une valeur de champ à renvoyer, la méthode m3() inclut également l'instruction invokevirtual #30 . Autrement dit, chaque fois que cette méthode est appelée, elle appelle ensuite m() et renvoie le résultat de cette méthode.

Ainsi, comme nous pouvons le voir, Scala propose trois façons de travailler avec les champs de classe, et ceux-ci sont facilement spécifiés via les mots-clés val , var et def . En Java, nous aurions à implémenter explicitement les setters et getters nécessaires, et un tel code passe-partout écrit manuellement serait beaucoup moins expressif et plus sujet aux erreurs.

Valeurs paresseuses

Un code plus compliqué est produit lors de la déclaration d'une valeur paresseuse. Supposons que nous ayons ajouté le champ suivant à la classe précédemment définie :

 lazy val m4 = m

L'exécution javap -p -v Person.class révélera désormais ce qui suit :

 Constant pool: #20 = Fieldref #2.#19 // Person.bitmap$0:Z #23 = Methodref #2.#22 // Person.m:()I #25 = Fieldref #2.#24 // Person.m4:I #31 = Fieldref #27.#30 // scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; #48 = Methodref #2.#47 // Person.m4$lzycompute:()I // ... private volatile boolean bitmap$0; private int m4$lzycompute(); Code: // lock the thread 0: aload_0 1: dup 2: astore_1 3: monitorenter // check the flag for whether this field has already been set 4: aload_0 5: getfield #20 // Field bitmap$0:Z // if it has, skip to position 24 (unlock the thread and return) 8: ifne 24 // if it hasn't, execute the method m() 11: aload_0 12: aload_0 13: invokevirtual #23 // Method m:()I // write the method to the field m4 16: putfield #25 // Field m4:I // set the flag indicating the field has been set 19: aload_0 20: iconst_1 21: putfield #20 // Field bitmap$0:Z // unlock the thread 24: getstatic #31 // Field scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; 27: pop 28: aload_1 29: monitorexit // get the value of field m4 and return it 30: aload_0 31: getfield #25 // Field m4:I 34: ireturn // ... public int m4(); Code: // check the flag for whether this field has already been set 0: aload_0 1: getfield #20 // Field bitmap$0:Z // if it hasn't, skip to position 14 (invoke lazy method and return) 4: ifeq 14 // if it has, get the value of field m4, then skip to position 18 (return) 7: aload_0 8: getfield #25 // Field m4:I 11: goto 18 // execute the method m4$lzycompute() to set the field 14: aload_0 15: invokespecial #48 // Method m4$lzycompute:()I // return 18: ireturn

Dans ce cas, la valeur du champ m4 n'est calculée que lorsque cela est nécessaire. La méthode privée spéciale m4$lzycompute() est produite pour calculer la valeur paresseuse, et le champ bitmap$0 pour suivre son état. La méthode m4() vérifie si la valeur de ce champ est 0, indiquant que m4 n'a pas encore été initialisé, auquel cas m4$lzycompute() est invoqué, remplissant m4 et retournant sa valeur. Cette méthode privée définit également la valeur de bitmap$0 sur 1, de sorte que la prochaine fois que m4() est appelé, il ignorera l'appel de la méthode d'initialisation et renverra simplement la valeur de m4 .

Les résultats du premier appel à une valeur paresseuse Scala.

Le bytecode que Scala produit ici est conçu pour être à la fois sûr et efficace. Pour être thread-safe, la méthode de calcul paresseux utilise la paire d'instructions monitorenter / monitorexit . La méthode reste efficace car la surcharge de performances de cette synchronisation ne se produit que lors de la première lecture de la valeur paresseuse.

Un seul bit est nécessaire pour indiquer l'état de la valeur paresseuse. Donc, s'il n'y a pas plus de 32 valeurs paresseuses, un seul champ int peut toutes les suivre. Si plus d'une valeur paresseuse est définie dans le code source, le bytecode ci-dessus sera modifié par le compilateur pour implémenter un masque de bits à cet effet.

Encore une fois, Scala nous permet de tirer facilement parti d'un type de comportement spécifique qui devrait être implémenté explicitement en Java, ce qui permet d'économiser des efforts et de réduire le risque de fautes de frappe.

Fonction comme valeur

Examinons maintenant le code source Scala suivant :

 class Printer(val output: String => Unit) { } object Hello { def main(arg: Array[String]) { val printer = new Printer( s => println(s) ); printer.output("Hello"); } }

La classe Printer a un champ, output , avec le type String => Unit : une fonction qui prend une String et renvoie un objet de type Unit (similaire à void en Java). Dans la méthode principale, nous créons l'un de ces objets et attribuons à ce champ une fonction anonyme qui imprime une chaîne donnée.

La compilation de ce code génère quatre fichiers de classe :

Le code source est compilé en quatre fichiers de classe.

Hello.class est une classe wrapper dont la méthode principale appelle simplement Hello$.main() :

 public final class Hello // ... public static void main(java.lang.String[]); Code: 0: getstatic #16 // Field Hello$.MODULE$:LHello$; 3: aload_0 4: invokevirtual #18 // Method Hello$.main:([Ljava/lang/String;)V 7: return

La classe cachée Hello$.class contient la véritable implémentation de la méthode principale. Pour jeter un œil à son bytecode, assurez-vous que vous échappez correctement $ selon les règles de votre shell de commande, pour éviter son interprétation comme caractère spécial :

 public final class Hello$ // ... public void main(java.lang.String[]); Code: // initialize Printer and anonymous function 0: new #16 // class Printer 3: dup 4: new #18 // class Hello$$anonfun$1 7: dup 8: invokespecial #19 // Method Hello$$anonfun$1."<init>":()V 11: invokespecial #22 // Method Printer."<init>":(Lscala/Function1;)V 14: astore_2 // load the anonymous function onto the stack 15: aload_2 16: invokevirtual #26 // Method Printer.output:()Lscala/Function1; // execute the anonymous function, passing the string "Hello" 19: ldc #28 // String Hello 21: invokeinterface #34, 2 // InterfaceMethod scala/Function1.apply:(Ljava/lang/Object;)Ljava/lang/Object; // return 26: pop 27: return

La méthode crée un Printer . Il crée ensuite un Hello$$anonfun$1 , qui contient notre fonction anonyme s => println(s) . L' Printer est initialisée avec cet objet comme champ de output . Ce champ est ensuite chargé sur la pile, et exécuté avec l'opérande "Hello" .

Jetons un coup d'œil à la classe de fonction anonyme, Hello$$anonfun$1.class , ci-dessous. Nous pouvons voir qu'il étend la Function1 de Scala (comme AbstractFunction1 ) en implémentant la méthode apply() . En fait, il crée deux méthodes apply() , l'une enveloppant l'autre, qui effectuent ensemble une vérification de type (dans ce cas, que l'entrée est une String ) et exécutent la fonction anonyme (impression de l'entrée avec println() ).

 public final class Hello$$anonfun$1 extends scala.runtime.AbstractFunction1<java.lang.String, scala.runtime.BoxedUnit> implements scala.Serializable // ... // Takes an argument of type String. Invoked second. public final void apply(java.lang.String); Code: // execute Scala's built-in method println(), passing the input argument 0: getstatic #25 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: aload_1 4: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 7: return // Takes an argument of type Object. Invoked first. public final java.lang.Object apply(java.lang.Object); Code: 0: aload_0 // check that the input argument is a String (throws exception if not) 1: aload_1 2: checkcast #36 // class java/lang/String // invoke the method apply( String ), passing the input argument 5: invokevirtual #38 // Method apply:(Ljava/lang/String;)V // return the void type 8: getstatic #44 // Field scala/runtime/BoxedUnit.UNIT:Lscala/runtime/BoxedUnit; 11: areturn

En regardant la méthode Hello$.main() ci-dessus, nous pouvons voir qu'au décalage 21, l'exécution de la fonction anonyme est déclenchée par un appel à sa méthode apply( Object ) .

Enfin, pour être complet, regardons le bytecode pour Printer.class :

 public class Printer // ... // field private final scala.Function1<java.lang.String, scala.runtime.BoxedUnit> output; // field getter public scala.Function1<java.lang.String, scala.runtime.BoxedUnit> output(); Code: 0: aload_0 1: getfield #14 // Field output:Lscala/Function1; 4: areturn // constructor public Printer(scala.Function1<java.lang.String, scala.runtime.BoxedUnit>); Code: 0: aload_0 1: aload_1 2: putfield #14 // Field output:Lscala/Function1; 5: aload_0 6: invokespecial #21 // Method java/lang/Object."<init>":()V 9: return

Nous pouvons voir que la fonction anonyme ici est traitée comme n'importe quelle variable val . Il est stocké dans le champ de classe output et le getter output() est créé. La seule différence est que cette variable doit maintenant implémenter l'interface Scala scala.Function1 (ce que fait AbstractFunction1 ).

Ainsi, le coût de cette élégante fonctionnalité Scala correspond aux classes utilitaires sous-jacentes, créées pour représenter et exécuter une seule fonction anonyme pouvant être utilisée comme valeur. Vous devez prendre en compte le nombre de ces fonctions, ainsi que les détails de l'implémentation de votre machine virtuelle, pour comprendre ce que cela signifie pour votre application particulière.

Passer sous le capot avec Scala : découvrez comment ce langage puissant est implémenté dans le bytecode JVM.
Tweeter

Traits de Scala

Les caractéristiques de Scala sont similaires aux interfaces de Java. Le trait suivant définit deux signatures de méthode et fournit une implémentation par défaut de la seconde. Voyons comment il est implémenté :

 trait Similarity { def isSimilar(x: Any): Boolean def isNotSimilar(x: Any): Boolean = !isSimilar(x) } 

Le code source est compilé dans deux fichiers de classe.

Deux entités sont produites : Similarity.class , l'interface déclarant les deux méthodes, et la classe synthétique, Similarity$class.class , fournissant l'implémentation par défaut :

 public interface Similarity { public abstract boolean isSimilar(java.lang.Object); public abstract boolean isNotSimilar(java.lang.Object); }
 public abstract class Similarity$class public static boolean isNotSimilar(Similarity, java.lang.Object); Code: 0: aload_0 // execute the instance method isSimilar() 1: aload_1 2: invokeinterface #13, 2 // InterfaceMethod Similarity.isSimilar:(Ljava/lang/Object;)Z // if the returned value is 0, skip to position 14 (return with value 1) 7: ifeq 14 // otherwise, return with value 0 10: iconst_0 11: goto 15 // return the value 1 14: iconst_1 15: ireturn public static void $init$(Similarity); Code: 0: return

Lorsqu'une classe implémente ce trait et appelle la méthode isNotSimilar , le compilateur Scala génère l'instruction de bytecode invokestatic pour appeler la méthode statique fournie par la classe qui l'accompagne.

Un polymorphisme complexe et des structures d'héritage peuvent être créés à partir de traits. Par exemple, plusieurs traits, ainsi que la classe d'implémentation, peuvent tous remplacer une méthode avec la même signature, en appelant super.methodName() pour passer le contrôle au trait suivant. Lorsque le compilateur Scala rencontre de tels appels, il :

  • Détermine quel trait exact est supposé par cet appel.
  • Détermine le nom de la classe d'accompagnement qui fournit le bytecode de méthode statique défini pour le trait.
  • Produit l'instruction invokestatic nécessaire.

Ainsi, nous pouvons voir que le puissant concept de traits est implémenté au niveau JVM d'une manière qui n'entraîne pas de surcharge importante, et les programmeurs Scala peuvent profiter de cette fonctionnalité sans craindre qu'elle soit trop coûteuse à l'exécution.

Célibataires

Scala fournit la définition explicite des classes singleton à l'aide du mot-clé object . Considérons la classe singleton suivante :

 object Config { val home_dir = "/home/user" }

Le compilateur produit deux fichiers de classe :

Le code source est compilé dans deux fichiers de classe.

Config.class est assez simple :

 public final class Config public static java.lang.String home_dir(); Code: // execute the method Config$.home_dir() 0: getstatic #16 // Field Config$.MODULE$:LConfig$; 3: invokevirtual #18 // Method Config$.home_dir:()Ljava/lang/String; 6: areturn

Il s'agit simplement d'un décorateur pour la classe synthétique Config$ qui intègre la fonctionnalité du singleton. L'examen de cette classe avec javap -p -c produit le bytecode suivant :

 public final class Config$ public static final Config$ MODULE$; // a public reference to the singleton object private final java.lang.String home_dir; // static initializer public static {}; Code: 0: new #2 // class Config$ 3: invokespecial #12 // Method "<init>":()V 6: return public java.lang.String home_dir(); Code: // get the value of field home_dir and return it 0: aload_0 1: getfield #17 // Field home_dir:Ljava/lang/String; 4: areturn private Config$(); Code: // initialize the object 0: aload_0 1: invokespecial #19 // Method java/lang/Object."<init>":()V // expose a public reference to this object in the synthetic variable MODULE$ 4: aload_0 5: putstatic #21 // Field MODULE$:LConfig$; // load the value "/home/user" and write it to the field home_dir 8: aload_0 9: ldc #23 // String /home/user 11: putfield #17 // Field home_dir:Ljava/lang/String; 14: return

Il consiste à suivre :

  • La variable synthétique MODULE$ , par laquelle d'autres objets accèdent à cet objet singleton.
  • L'initialiseur statique {} (également connu sous le nom de <clinit> , l'initialiseur de classe) et la méthode privée Config$ , utilisée pour initialiser MODULE$ et définir ses champs aux valeurs par défaut
  • Une méthode getter pour le champ statique home_dir . Dans ce cas, il ne s'agit que d'une méthode. Si le singleton a plus de champs, il aura plus de getters, ainsi que des setters pour les champs mutables.

Le singleton est un modèle de conception populaire et utile. Le langage Java ne fournit pas de moyen direct de le spécifier au niveau du langage ; il incombe plutôt au développeur de l'implémenter dans la source Java. Scala, d'autre part, fournit un moyen clair et pratique de déclarer explicitement un singleton à l'aide du mot-clé object . Comme nous pouvons le voir en regardant sous le capot, il est mis en œuvre de manière abordable et naturelle.

Conclusion

Nous avons maintenant vu comment Scala compile plusieurs fonctionnalités de programmation implicites et fonctionnelles dans des structures de bytecode Java sophistiquées. Avec cet aperçu du fonctionnement interne de Scala, nous pouvons acquérir une appréciation plus profonde de la puissance de Scala, nous aidant à tirer le meilleur parti de ce langage puissant.

Nous avons aussi maintenant les outils pour explorer la langue nous-mêmes. Il existe de nombreuses fonctionnalités utiles de la syntaxe Scala qui ne sont pas couvertes dans cet article, telles que les classes de cas, la mise au curry et les compréhensions de liste. Je vous encourage à étudier vous-même la mise en œuvre de ces structures par Scala, afin que vous puissiez apprendre à devenir un ninja Scala de niveau supérieur !


La machine virtuelle Java : un cours accéléré

Tout comme le compilateur Java, le compilateur Scala convertit le code source en fichiers .class , contenant le bytecode Java à exécuter par la machine virtuelle Java. Afin de comprendre en quoi les deux langues diffèrent sous le capot, il est nécessaire de comprendre le système qu'elles ciblent toutes les deux. Ici, nous présentons un bref aperçu de certains éléments majeurs de l'architecture de la machine virtuelle Java, de la structure des fichiers de classe et des bases de l'assembleur.

Notez que ce guide ne couvrira que le minimum pour permettre de suivre l'article ci-dessus. Bien que de nombreux composants majeurs de la JVM ne soient pas abordés ici, des détails complets peuvent être trouvés dans les documents officiels, ici.

Décompiler des fichiers de classe avec javap
Piscine constante
Tableaux de champs et de méthodes
Code d'octet JVM
Appels de méthode et pile d'appels
Exécution sur la pile d'opérandes
Variables locales
retourner en haut

Décompiler des fichiers de classe avec javap

Java est livré avec l'utilitaire de ligne de commande javap , qui décompile les fichiers .class sous une forme lisible par l'homme. Étant donné que les fichiers de classe Scala et Java ciblent tous deux la même JVM, javap peut être utilisé pour examiner les fichiers de classe compilés par Scala.

Compilons le code source suivant :

 // RegularPolygon.scala class RegularPolygon( val numSides: Int ) { def getPerimeter( sideLength: Double ): Double = { println( "Calculating perimeter..." ) return sideLength * this.numSides } }

Compiler ceci avec scalac RegularPolygon.scala produira RegularPolygon.class . Si nous exécutons ensuite javap RegularPolygon.class nous verrons ce qui suit :

 $ javap RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { public int numSides(); public double getPerimeter(double); public RegularPolygon(int); }

Il s'agit d'une ventilation très simple du fichier de classe qui montre simplement les noms et les types des membres publics de la classe. L'ajout de l'option -p inclura les membres privés :

 $ javap -p RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { private final int numSides; public int numSides(); public double getPerimeter(double); public RegularPolygon(int); }

Ce n'est pas encore beaucoup d'informations. Pour voir comment les méthodes sont implémentées dans le bytecode Java, ajoutons l'option -c :

 $ javap -p -c RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { private final int numSides; public int numSides(); Code: 0: aload_0 1: getfield #13 // Field numSides:I 4: ireturn public double getPerimeter(double); Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... 5: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul 15: dreturn public RegularPolygon(int); Code: 0: aload_0 1: iload_1 2: putfield #13 // Field numSides:I 5: aload_0 6: invokespecial #38 // Method java/lang/Object."<init>":()V 9: return }

C'est un peu plus intéressant. Cependant, pour vraiment avoir toute l'histoire, nous devrions utiliser l'option -v ou -verbose , comme dans javap -p -v RegularPolygon.class :

Le contenu complet d'un fichier de classe Java.

Ici, nous voyons enfin ce qu'il y a vraiment dans le fichier de classe. Qu'est-ce que tout cela signifie? Jetons un coup d'œil à certaines des parties les plus importantes.

Piscine constante

Le cycle de développement des applications C++ comprend des étapes de compilation et de liaison. Le cycle de développement de Java saute une étape de liaison explicite car la liaison se produit au moment de l'exécution. Le fichier de classe doit prendre en charge cette liaison d'exécution. Cela signifie que lorsque le code source fait référence à un champ ou à une méthode, le bytecode résultant doit conserver les références pertinentes sous forme symbolique, prêt à être déréférencé une fois que l'application a été chargée en mémoire et que les adresses réelles peuvent être résolues par l'éditeur de liens d'exécution. Cette forme symbolique doit contenir :

  • nom du cours
  • nom du champ ou de la méthode
  • saisir les informations

La spécification du format de fichier de classe comprend une section du fichier appelée constant pool , un tableau de toutes les références nécessaires à l'éditeur de liens. Il contient des entrées de différents types.

 // ... Constant pool: #1 = Utf8 RegularPolygon #2 = Class #1 // RegularPolygon #3 = Utf8 java/lang/Object #4 = Class #3 // java/lang/Object // ...

Le premier octet de chaque entrée est une balise numérique indiquant le type d'entrée. Les octets restants fournissent des informations sur la valeur de l'entrée. Le nombre d'octets et les règles d'interprétation dépendent du type indiqué par le premier octet.

Par exemple, une classe Java qui utilise un entier constant 365 peut avoir une entrée de pool constante avec le bytecode suivant :

 x03 00 00 01 6D

Le premier octet, x03 , identifie le type d'entrée, CONSTANT_Integer . Cela informe l'éditeur de liens que les quatre octets suivants contiennent la valeur de l'entier. (Notez que 365 en hexadécimal est x16D ). S'il s'agit de la 14e entrée dans le pool de constantes, javap -v la rendra comme ceci :

 #14 = Integer 365

De nombreux types de constantes sont composés de références à des types de constantes plus "primitifs" ailleurs dans le pool de constantes. Par exemple, notre exemple de code contient l'instruction :

 println( "Calculating perimeter..." )

L'utilisation d'une constante de chaîne produira deux entrées dans le pool de constantes : une entrée de type CONSTANT_String et une autre entrée de type CONSTANT_Utf8 . L'entrée de type Constant_UTF8 contient la représentation UTF8 réelle de la valeur de chaîne. L'entrée de type CONSTANT_String contient une référence à l'entrée CONSTANT_Utf8 :

 #24 = Utf8 Calculating perimeter... #25 = String #24 // Calculating perimeter...

Une telle complication est nécessaire car il existe d'autres types d'entrées de pool constant qui font référence à des entrées de type Utf8 et qui ne sont pas des entrées de type String . Par exemple, toute référence à un attribut de classe produira un type CONSTANT_Fieldref , qui contient une série de références au nom de classe, au nom d'attribut et au type d'attribut :

 #1 = Utf8 RegularPolygon #2 = Class #1 // RegularPolygon #9 = Utf8 numSides #10 = Utf8 I #12 = NameAndType #9:#10 // numSides:I #13 = Fieldref #2.#12 // RegularPolygon.numSides:I

Pour plus de détails sur le pool de constantes, consultez la documentation JVM.

Tableaux de champs et de méthodes

Un fichier de classe contient une table de champs qui contient des informations sur chaque champ (c'est-à-dire, attribut) défini dans la classe. Il s'agit de références à des entrées de pool constantes qui décrivent le nom et le type du champ ainsi que des indicateurs de contrôle d'accès et d'autres données pertinentes.

Une table de méthode similaire est présente dans le fichier de classe. Cependant, en plus des informations de nom et de type, pour chaque méthode non abstraite, il contient les instructions de bytecode réelles à exécuter par la JVM, ainsi que les structures de données utilisées par le cadre de pile de la méthode, décrites ci-dessous.

Code d'octet JVM

La JVM utilise son propre jeu d'instructions interne pour exécuter le code compilé. L'exécution de javap avec l'option -c inclut les implémentations de méthodes compilées dans la sortie. Si nous examinons notre fichier RegularPolygon.class de cette manière, nous verrons la sortie suivante pour notre méthode getPerimeter() :

 public double getPerimeter(double); Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... 5: invokevirtual #29 // Method scala/Predef$.println:(Ljava/lang/Object;)V 8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul 15: dreturn

Le bytecode réel pourrait ressembler à ceci :

 xB2 00 17 x12 19 xB6 00 1D x27 ...

Chaque instruction commence par un code opération d'un octet identifiant l'instruction JVM, suivi de zéro ou plusieurs opérandes d'instruction à opérer, selon le format de l'instruction spécifique. Il s'agit généralement de valeurs constantes ou de références dans le pool de constantes. javap traduit utilement le bytecode en une forme lisible par l'homme affichant :

  • Le décalage ou la position du premier octet de l'instruction dans le code.
  • Le nom lisible par l'homme, ou mnémonique , de l'instruction.
  • La valeur de l'opérande, le cas échéant.

Les opérandes affichés avec un signe dièse, tels que #23 , sont des références aux entrées du pool de constantes. Comme nous pouvons le voir, javap produit également des commentaires utiles dans la sortie, identifiant exactement ce qui est référencé à partir du pool.

Nous allons discuter de quelques-unes des instructions courantes ci-dessous. Pour des informations détaillées sur le jeu d'instructions JVM complet, consultez la documentation.

Appels de méthode et pile d'appels

Chaque appel de méthode doit pouvoir s'exécuter avec son propre contexte, qui inclut des éléments tels que des variables déclarées localement ou des arguments transmis à la méthode. Ensemble, ils forment un cadre de pile . Lors de l'invocation d'une méthode, un nouveau cadre est créé et placé au-dessus de la pile d'appels . Lorsque la méthode revient, l'image actuelle est supprimée de la pile des appels et supprimée, et l'image qui était en vigueur avant l'appel de la méthode est restaurée.

Un cadre de pile comprend quelques structures distinctes. Deux importantes sont la pile d'opérandes et la table de variables locales , discutées ensuite.

La pile d'appels JVM.

Exécution sur la pile d'opérandes

De nombreuses instructions JVM fonctionnent sur la pile d'opérandes de leur trame. Plutôt que de spécifier explicitement un opérande constant dans le bytecode, ces instructions prennent à la place les valeurs en haut de la pile des opérandes comme entrée. En règle générale, ces valeurs sont supprimées de la pile au cours du processus. Certaines instructions placent également de nouvelles valeurs en haut de la pile. De cette manière, les instructions JVM peuvent être combinées pour effectuer des opérations complexes. Par exemple, l'expression :

 sideLength * this.numSides

est compilé comme suit dans notre méthode getPerimeter() :

 8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul 

Les instructions JVM peuvent fonctionner sur la pile d'opérandes pour exécuter des fonctions complexes.

  • La première instruction, dload_1 , pousse la référence d'objet de l'emplacement 1 de la table des variables locales (voir ci-dessous) sur la pile des opérandes. Dans ce cas, il s'agit de l'argument de la méthode sideLength .- L'instruction suivante, aload_0 , pousse la référence d'objet à l'emplacement 0 de la table des variables locales sur la pile des opérandes. En pratique, il s'agit presque toujours de la référence à this , la classe actuelle.
  • Cela configure la pile pour le prochain appel, invokevirtual #31 , qui exécute la méthode d'instance numSides() . invokevirtual fait apparaître l'opérande supérieur (la référence à this ) de la pile pour identifier à partir de quelle classe il doit appeler la méthode. Une fois la méthode retournée, son résultat est poussé sur la pile.
  • Dans ce cas, la valeur renvoyée ( numSides ) est au format entier. Il doit être converti dans un format à virgule flottante double afin de le multiplier par une autre valeur double. L'instruction i2d la valeur entière de la pile, la convertit au format à virgule flottante et la repousse sur la pile.
  • À ce stade, la pile contient le résultat en virgule flottante de this.numSides en haut, suivi de la valeur de l'argument sideLength qui a été passé à la méthode. dmul ces deux premières valeurs de la pile, effectue une multiplication en virgule flottante sur elles et pousse le résultat sur la pile.

Lorsqu'une méthode est appelée, une nouvelle pile d'opérandes est créée dans le cadre de son cadre de pile, où les opérations seront effectuées. We must be careful with terminology here: the word “stack” may refer to the call stack , the stack of frames providing context for method execution, or to a particular frame's operand stack , upon which JVM instructions operate.

Local Variables

Each stack frame keeps a table of local variables . This typically includes a reference to this object, any arguments that were passed when the method was called, and any local variables declared within the method body. Running javap with the -v option will include information about how each method's stack frame should be set up, including its local variable table:

 public double getPerimeter(double); // ... Code: 0: getstatic #23 // Field scala/Predef$.MODULE$:Lscala/Predef$; 3: ldc #25 // String Calculating perimeter... // ... LocalVariableTable: Start Length Slot Name Signature 0 16 0 this LRegularPolygon; 0 16 1 sideLength D

In this example, there are two local variables. The variable in slot 0 is named this , with the type RegularPolygon . This is the reference to the method's own class. The variable in slot 1 is named sideLength , with the type D (indicating a double). This is the argument that is passed to our getPerimeter() method.

Instructions such as iload_1 , fstore_2 , or aload [n] , transfer different types of local variables between the operand stack and the local variable table. Since the first item in the table is usually the reference to this , the instruction aload_0 is commonly seen in any method that operates on its own class.

This concludes our walkthrough of JVM basics.

En relation : Réduire le code standard avec les macros Scala et les quasi-quotes