Sporcati le mani con Scala JVM Bytecode

Pubblicato: 2022-03-11

Il linguaggio Scala ha continuato a guadagnare popolarità negli ultimi anni, grazie alla sua eccellente combinazione di principi di sviluppo software funzionale e orientato agli oggetti e alla sua implementazione sulla collaudata Java Virtual Machine (JVM).

Sebbene Scala compili in bytecode Java, è progettato per migliorare molte delle carenze percepite del linguaggio Java. Offrendo un supporto completo alla programmazione funzionale, la sintassi principale di Scala contiene molte strutture implicite che devono essere costruite esplicitamente dai programmatori Java, alcune delle quali implicano una notevole complessità.

La creazione di un linguaggio che compila in bytecode Java richiede una profonda comprensione del funzionamento interno della Java Virtual Machine. Per apprezzare ciò che gli sviluppatori di Scala hanno realizzato, è necessario andare sotto il cofano ed esplorare come il codice sorgente di Scala viene interpretato dal compilatore per produrre un bytecode JVM efficiente ed efficace.

Diamo un'occhiata a come viene implementata tutta questa roba.

Prerequisiti

La lettura di questo articolo richiede una conoscenza di base del bytecode di Java Virtual Machine. Le specifiche complete della macchina virtuale possono essere ottenute dalla documentazione ufficiale di Oracle. Leggere l'intera specifica non è fondamentale per comprendere questo articolo, quindi, per una rapida introduzione alle nozioni di base, ho preparato una breve guida in fondo all'articolo.

Fare clic qui per leggere un corso accelerato sulle basi di JVM.

È necessaria un'utilità per disassemblare il bytecode Java per riprodurre gli esempi forniti di seguito e per procedere con ulteriori indagini. Il Java Development Kit fornisce la propria utilità della riga di comando, javap , che useremo qui. Una rapida dimostrazione di come funziona javap è inclusa nella guida in fondo.

E, naturalmente, un'installazione funzionante del compilatore Scala è necessaria per i lettori che vogliono seguire gli esempi. Questo articolo è stato scritto utilizzando Scala 2.11.7. Versioni diverse di Scala possono produrre bytecode leggermente diversi.

Getter e setter predefiniti

Sebbene la convenzione Java fornisca sempre metodi getter e setter per gli attributi pubblici, i programmatori Java sono tenuti a scriverli da soli, nonostante il fatto che il modello per ciascuno non sia cambiato da decenni. Scala, al contrario, fornisce getter e setter predefiniti.

Diamo un'occhiata al seguente esempio:

 class Person(val name:String) { }

Diamo un'occhiata all'interno della classe Person . Se compiliamo questo file con scalac , l'esecuzione $ javap -p Person.class ci dà:

 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 }

Possiamo vedere che per ogni campo della classe Scala viene generato un campo e il suo metodo getter. Il campo è privato e definitivo, mentre il metodo è pubblico.

Se sostituiamo val con var nell'origine Person e ricompiliamo, il modificatore final del campo viene eliminato e viene aggiunto anche il metodo setter:

 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 }

Se qualsiasi val o var è definito all'interno del corpo della classe, vengono creati il ​​campo privato e i metodi di accesso corrispondenti e inizializzati in modo appropriato al momento della creazione dell'istanza.

Si noti che una tale implementazione dei campi val e var a livello di classe significa che se alcune variabili vengono utilizzate a livello di classe per memorizzare valori intermedi e non sono mai accessibili direttamente dal programmatore, l'inizializzazione di ciascuno di questi campi aggiungerà uno o due metodi al impronta di classe. L'aggiunta di un modificatore private per tali campi non significa che le funzioni di accesso corrispondenti verranno eliminate. Diventeranno semplicemente privati.

Definizioni di variabili e funzioni

Assumiamo di avere un metodo, m() , e creiamo tre diversi riferimenti in stile Scala a questa funzione:

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

Come vengono costruiti ciascuno di questi riferimenti a m ? Quando viene m in ogni caso? Diamo un'occhiata al bytecode risultante. Il seguente output mostra i risultati di javap -v Person.class (omettendo molto output superfluo):

 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

Nel pool costante, vediamo che il riferimento al metodo m() è memorizzato nell'indice #30 . Nel codice del costruttore, vediamo che questo metodo viene invocato due volte durante l'inizializzazione, con l'istruzione invokevirtual #30 che appare prima all'offset del byte 11, poi all'offset 19. La prima invocazione è seguita dall'istruzione putfield #22 che assegna il risultato di questo metodo al campo m1 , a cui fa riferimento l'indice #22 nel pool di costanti. La seconda invocazione è seguita dallo stesso schema, questa volta assegnando il valore al campo m2 , indicizzato a #24 nel pool di costanti.

In altre parole, l'assegnazione di un metodo a una variabile definita con val o var assegna solo il risultato del metodo a quella variabile. Possiamo vedere che i metodi m1() e m2() creati sono semplicemente getter per queste variabili. Nel caso di var m2 , vediamo anche che viene creato il setter m2_$eq(int) , che si comporta come qualsiasi altro setter, sovrascrivendo il valore nel campo.

Tuttavia, l'utilizzo della parola chiave def dà un risultato diverso. Anziché recuperare un valore di campo da restituire, il metodo m3() include anche l'istruzione invokevirtual #30 . Cioè, ogni volta che questo metodo viene chiamato, chiama m() e restituisce il risultato di questo metodo.

Quindi, come possiamo vedere, Scala fornisce tre modi per lavorare con i campi di classe, e questi sono facilmente specificabili tramite le parole chiave val , var e def . In Java, dovremmo implementare esplicitamente i setter e i getter necessari e tale codice boilerplate scritto manualmente sarebbe molto meno espressivo e più soggetto a errori.

Valori pigri

Viene prodotto codice più complicato quando si dichiara un valore pigro. Supponiamo di aver aggiunto il seguente campo alla classe precedentemente definita:

 lazy val m4 = m

L'esecuzione javap -p -v Person.class ora rivelerà quanto segue:

 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

In questo caso, il valore del campo m4 non viene calcolato finché non è necessario. Viene prodotto lo speciale metodo privato m4$lzycompute() per calcolare il valore pigro e il campo bitmap$0 per tracciarne lo stato. Il metodo m4() controlla se il valore di questo campo è 0, indicando che m4 non è stato ancora inizializzato, nel qual caso viene invocato m4$lzycompute() , popolando m4 e restituendo il suo valore. Questo metodo privato imposta anche il valore di bitmap$0 su 1, in modo che la prossima volta che viene chiamato m4() salterà il richiamo del metodo di inizializzazione e restituirà semplicemente il valore di m4 .

I risultati della prima chiamata a un valore lazy Scala.

Il bytecode prodotto da Scala qui è progettato per essere thread-safe ed efficace. Per essere thread-safe, il metodo di calcolo pigro utilizza la coppia di istruzioni monitorenter / monitorexit . Il metodo rimane efficace poiché l'overhead delle prestazioni di questa sincronizzazione si verifica solo alla prima lettura del valore pigro.

È necessario un solo bit per indicare lo stato del valore pigro. Quindi, se non ci sono più di 32 valori pigri, un singolo campo int può tenerne traccia tutti. Se più di un valore pigro è definito nel codice sorgente, il bytecode sopra verrà modificato dal compilatore per implementare una maschera di bit per questo scopo.

Anche in questo caso, Scala ci consente di sfruttare facilmente un tipo specifico di comportamento che dovrebbe essere implementato esplicitamente in Java, risparmiando fatica e riducendo il rischio di errori di battitura.

Funzione come valore

Ora diamo un'occhiata al seguente codice sorgente Scala:

 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 ha un campo, output , con il tipo String => Unit : una funzione che prende una String e restituisce un oggetto di tipo Unit (simile a void in Java). Nel metodo principale, creiamo uno di questi oggetti e assegniamo a questo campo una funzione anonima che stampa una determinata stringa.

La compilazione di questo codice genera quattro file di classe:

Il codice sorgente viene compilato in quattro file di classe.

Hello.class è una classe wrapper il cui metodo principale chiama semplicemente 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 nascosta Hello$.class contiene l'implementazione reale del metodo main. Per dare un'occhiata al suo bytecode, assicurati di eseguire correttamente l'escape $ secondo le regole della tua shell dei comandi, per evitare la sua interpretazione come carattere speciale:

 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

Il metodo crea una Printer . Quindi crea un Hello$$anonfun$1 , che contiene la nostra funzione anonima s => println(s) . La Printer viene inizializzata con questo oggetto come campo di output . Questo campo viene quindi caricato nello stack ed eseguito con l'operando "Hello" .

Diamo un'occhiata alla classe di funzione anonima, Hello$$anonfun$1.class , di seguito. Possiamo vedere che estende Function1 di Scala (come AbstractFunction1 ) implementando il metodo apply() . In realtà, crea due metodi apply() , uno che avvolge l'altro, che insieme eseguono il controllo del tipo (in questo caso, che l'input sia una String ) ed eseguono la funzione anonima (stampando l'input con 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

Guardando indietro al metodo Hello$.main() sopra, possiamo vedere che, all'offset 21, l'esecuzione della funzione anonima viene attivata da una chiamata al suo metodo apply( Object ) .

Infine, per completezza, diamo un'occhiata al bytecode per 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

Possiamo vedere che la funzione anonima qui viene trattata come qualsiasi variabile val . Viene memorizzato nel campo classe output e viene creato getter output() . L'unica differenza è che questa variabile ora deve implementare l'interfaccia scala.Function1 (cosa che fa AbstractFunction1 ).

Quindi, il costo di questa elegante funzionalità di Scala sono le classi di utilità sottostanti, create per rappresentare ed eseguire una singola funzione anonima che può essere utilizzata come valore. Dovresti prendere in considerazione il numero di tali funzioni, nonché i dettagli dell'implementazione della tua VM, per capire cosa significa per la tua particolare applicazione.

Andare sotto il cofano con Scala: esplora come questo potente linguaggio è implementato nel bytecode JVM.
Twitta

Tratti Scala

I tratti di Scala sono simili alle interfacce in Java. La caratteristica seguente definisce due firme di metodo e fornisce un'implementazione predefinita della seconda. Vediamo come viene implementato:

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

Il codice sorgente viene compilato in due file di classe.

Vengono prodotte due entità: Similarity.class , l'interfaccia che dichiara entrambi i metodi, e la classe sintetica, Similarity$class.class , che fornisce l'implementazione predefinita:

 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

Quando una classe implementa questo tratto e chiama il metodo isNotSimilar , il compilatore Scala genera l'istruzione bytecode invokestatic per chiamare il metodo statico fornito dalla classe di accompagnamento.

Il polimorfismo complesso e le strutture ereditarie possono essere create dai tratti. Ad esempio, più tratti, così come la classe di implementazione, possono tutti sovrascrivere un metodo con la stessa firma, chiamando super.methodName() per passare il controllo al tratto successivo. Quando il compilatore Scala incontra tali chiamate, esso:

  • Determina quale tratto esatto viene assunto da questa chiamata.
  • Determina il nome della classe associata che fornisce il bytecode del metodo statico definito per il tratto.
  • Produce l'istruzione invokestatic necessaria.

Quindi possiamo vedere che il potente concetto di tratti è implementato a livello di JVM in un modo che non porta a un sovraccarico significativo e i programmatori Scala possono godere di questa funzionalità senza preoccuparsi che sarà troppo costoso in fase di esecuzione.

Singleton

Scala prevede la definizione esplicita di classi singleton utilizzando la parola chiave object . Consideriamo la seguente classe singleton:

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

Il compilatore produce due file di classe:

Il codice sorgente viene compilato in due file di classe.

Config.class è piuttosto semplice:

 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

Questo è solo un decoratore per la classe sintetica Config$ che incorpora la funzionalità del singleton. L'esame di quella classe con javap -p -c produce il seguente bytecode:

 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

Si compone di quanto segue:

  • La variabile sintetica MODULE$ , attraverso la quale altri oggetti accedono a questo oggetto singleton.
  • L'inizializzatore statico {} (noto anche come <clinit> , l'inizializzatore di classe) e il metodo privato Config$ , utilizzati per inizializzare MODULE$ e impostare i suoi campi sui valori predefiniti
  • Un metodo getter per il campo statico home_dir . In questo caso, è solo un metodo. Se il singleton ha più campi, avrà più getter, così come setter per campi mutabili.

Il singleton è un modello di progettazione popolare e utile. Il linguaggio Java non fornisce un modo diretto per specificarlo a livello di lingua; piuttosto, è responsabilità dello sviluppatore implementarlo nel sorgente Java. Scala, d'altra parte, fornisce un modo chiaro e conveniente per dichiarare un singleton in modo esplicito usando la parola chiave object . Come possiamo vedere guardando sotto il cofano, è implementato in modo conveniente e naturale.

Conclusione

Abbiamo ora visto come Scala compila diverse caratteristiche di programmazione implicita e funzionale in sofisticate strutture di bytecode Java. Con questo sguardo al funzionamento interno di Scala, possiamo ottenere un apprezzamento più profondo del potere di Scala, aiutandoci a ottenere il massimo da questo potente linguaggio.

Ora abbiamo anche gli strumenti per esplorare noi stessi la lingua. Ci sono molte funzioni utili della sintassi di Scala che non sono trattate in questo articolo, come le classi case, il currying e la comprensione degli elenchi. Ti incoraggio a indagare tu stesso sull'implementazione di queste strutture da parte di Scala, così puoi imparare come essere un ninja di Scala di livello successivo!


La Java Virtual Machine: un corso accelerato

Proprio come il compilatore Java, il compilatore Scala converte il codice sorgente in file .class , contenenti il ​​bytecode Java che deve essere eseguito dalla Java Virtual Machine. Per capire come le due lingue differiscono sotto il cofano, è necessario capire il sistema a cui entrambe prendono di mira. Qui, presentiamo una breve panoramica di alcuni elementi principali dell'architettura Java Virtual Machine, della struttura dei file di classe e delle basi dell'assembler.

Nota che questa guida coprirà solo il minimo per consentire di seguire insieme all'articolo sopra. Sebbene molti dei componenti principali della JVM non siano discussi qui, i dettagli completi possono essere trovati nei documenti ufficiali, qui.

Decompilare i file di classe con javap
Piscina costante
Tabelle dei campi e dei metodi
Bytecode JVM
Chiamate di metodo e stack di chiamate
Esecuzione sullo stack di operandi
Variabili locali
Ritorna su

Decompilare i file di classe con javap

Java viene fornito con l'utilità della riga di comando javap , che decompila i file .class in un formato leggibile dall'uomo. Poiché i file di classe Scala e Java hanno entrambi come target la stessa JVM, è possibile utilizzare javap per esaminare i file di classe compilati da Scala.

Compiliamo il seguente codice sorgente:

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

La compilazione con scalac RegularPolygon.scala produrrà RegularPolygon.class . Se poi javap RegularPolygon.class vedremo quanto segue:

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

Questa è una suddivisione molto semplice del file di classe che mostra semplicemente i nomi e i tipi dei membri pubblici della classe. L'aggiunta dell'opzione -p includerà i membri privati:

 $ 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); }

Non sono ancora molte informazioni. Per vedere come vengono implementati i metodi nel bytecode Java, aggiungiamo l'opzione -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 }

Questo è un po' più interessante. Tuttavia, per ottenere davvero l'intera storia, dovremmo usare l'opzione -v o -verbose , come in javap -p -v RegularPolygon.class :

Il contenuto completo di un file di classe Java.

Qui vediamo finalmente cosa c'è veramente nel file di classe. Cosa significa tutto questo? Diamo un'occhiata ad alcune delle parti più importanti.

Piscina costante

Il ciclo di sviluppo per le applicazioni C++ include fasi di compilazione e collegamento. Il ciclo di sviluppo per Java salta una fase di collegamento esplicito perché il collegamento avviene in fase di esecuzione. Il file di classe deve supportare questo collegamento di runtime. Ciò significa che quando il codice sorgente fa riferimento a qualsiasi campo o metodo, il bytecode risultante deve mantenere i riferimenti rilevanti in forma simbolica, pronti per essere dereferenziati una volta che l'applicazione è stata caricata in memoria e gli indirizzi effettivi possono essere risolti dal linker di runtime. Questa forma simbolica deve contenere:

  • nome della classe
  • nome del campo o del metodo
  • digitare informazioni

La specifica del formato del file di classe include una sezione del file chiamata constant pool , una tabella di tutti i riferimenti necessari al linker. Contiene voci di diverso tipo.

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

Il primo byte di ogni voce è un tag numerico che indica il tipo di voce. I restanti byte forniscono informazioni sul valore della voce. Il numero di byte e le regole per la loro interpretazione dipendono dal tipo indicato dal primo byte.

Ad esempio, una classe Java che utilizza un numero intero costante 365 può avere una voce di pool costante con il seguente bytecode:

 x03 00 00 01 6D

Il primo byte, x03 , identifica il tipo di voce, CONSTANT_Integer . Questo informa il linker che i successivi quattro byte contengono il valore dell'intero. (Nota che 365 in esadecimale è x16D ). Se questa è la quattordicesima voce nel pool costante, javap -v lo renderà in questo modo:

 #14 = Integer 365

Molti tipi di costanti sono composti da riferimenti a tipi di costanti più "primitivi" in altre parti del pool di costanti. Ad esempio, il nostro codice di esempio contiene l'istruzione:

 println( "Calculating perimeter..." )

L'utilizzo di una costante stringa produrrà due voci nel pool di costanti: una voce con tipo CONSTANT_String e un'altra voce di tipo CONSTANT_Utf8 . La voce di tipo Constant_UTF8 contiene la rappresentazione UTF8 effettiva del valore della stringa. La voce di tipo CONSTANT_String contiene un riferimento alla voce CONSTANT_Utf8 :

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

Tale complicazione è necessaria perché esistono altri tipi di voci di pool costanti che fanno riferimento a voci di tipo Utf8 e che non sono voci di tipo String . Ad esempio, qualsiasi riferimento a un attributo di classe produrrà un tipo CONSTANT_Fieldref , che contiene una serie di riferimenti al nome della classe, al nome dell'attributo e al tipo di attributo:

 #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

Per maggiori dettagli sul pool costante, vedere la documentazione JVM.

Tabelle dei campi e dei metodi

Un file di classe contiene una tabella di campi che contiene informazioni su ciascun campo (ad esempio, attributo) definito nella classe. Questi sono riferimenti a voci di pool costanti che descrivono il nome e il tipo del campo, nonché i flag di controllo dell'accesso e altri dati rilevanti.

Una tabella di metodi simile è presente nel file di classe. Tuttavia, oltre alle informazioni sul nome e sul tipo, per ciascun metodo non astratto, contiene le istruzioni di bytecode effettive che devono essere eseguite dalla JVM, nonché le strutture di dati utilizzate dallo stack frame del metodo, descritte di seguito.

Bytecode JVM

La JVM utilizza il proprio set di istruzioni interno per eseguire il codice compilato. L'esecuzione di javap con l'opzione -c include le implementazioni del metodo compilato nell'output. Se esaminiamo il nostro file RegularPolygon.class in questo modo, vedremo il seguente output per il nostro 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

Il bytecode effettivo potrebbe assomigliare a questo:

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

Ogni istruzione inizia con un codice operativo di un byte che identifica l'istruzione JVM, seguito da zero o più operandi di istruzione su cui operare, a seconda del formato dell'istruzione specifica. Questi sono in genere valori costanti o riferimenti nel pool di costanti. javap traduce in modo utile il bytecode in un modulo leggibile dall'uomo che mostra:

  • L' offset o la posizione del primo byte dell'istruzione all'interno del codice.
  • Il nome leggibile dall'uomo, o mnemonico , dell'istruzione.
  • Il valore dell'operando, se presente.

Gli operandi visualizzati con un cancelletto, ad esempio #23 , sono riferimenti a voci nel pool di costanti. Come possiamo vedere, javap produce anche commenti utili nell'output, identificando ciò a cui si fa riferimento esattamente dal pool.

Discuteremo alcune delle istruzioni comuni di seguito. Per informazioni dettagliate sul set di istruzioni JVM completo, consultare la documentazione.

Chiamate di metodo e stack di chiamate

Ogni chiamata al metodo deve essere in grado di essere eseguita con il proprio contesto, che include cose come variabili dichiarate localmente o argomenti passati al metodo. Insieme, questi costituiscono uno stack frame . Al richiamo di un metodo, viene creato un nuovo frame e posizionato in cima allo stack di chiamate . Quando il metodo ritorna, il frame corrente viene rimosso dallo stack di chiamate e scartato e il frame che era in vigore prima della chiamata del metodo viene ripristinato.

Uno stack frame include alcune strutture distinte. Due importanti sono lo stack degli operandi e la tabella delle variabili locali , discussi di seguito.

Lo stack di chiamate JVM.

Esecuzione sullo stack di operandi

Molte istruzioni JVM operano sullo stack di operandi del loro frame. Invece di specificare un operando costante in modo esplicito nel bytecode, queste istruzioni prendono invece come input i valori nella parte superiore dello stack degli operandi. In genere, questi valori vengono rimossi dallo stack nel processo. Alcune istruzioni mettono anche nuovi valori in cima alla pila. In questo modo, le istruzioni JVM possono essere combinate per eseguire operazioni complesse. Ad esempio, l'espressione:

 sideLength * this.numSides

è compilato nel modo seguente nel nostro getPerimeter() :

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

Le istruzioni JVM possono operare sullo stack degli operandi per eseguire funzioni complesse.

  • La prima istruzione, dload_1 , inserisce il riferimento all'oggetto dallo slot 1 della tabella delle variabili locali (discussa in seguito) nello stack degli operandi. In questo caso, questo è l'argomento del metodo sideLength .- L'istruzione successiva, aload_0 , inserisce il riferimento all'oggetto nello slot 0 della tabella delle variabili locali nello stack degli operandi. In pratica, questo è quasi sempre il riferimento a this , la classe attuale.
  • Questo imposta lo stack per la chiamata successiva, invokevirtual #31 , che esegue il metodo di istanza numSides() . invokevirtual estrae dallo stack l'operando superiore (il riferimento a this ) per identificare da quale classe deve chiamare il metodo. Una volta che il metodo ritorna, il suo risultato viene inserito nello stack.
  • In questo caso, il valore restituito ( numSides ) è in formato intero. Deve essere convertito in un formato a virgola mobile doppia per moltiplicarlo con un altro valore doppio. L'istruzione i2d estrae il valore intero dallo stack, lo converte in formato a virgola mobile e lo reinserisce nello stack.
  • A questo punto, lo stack contiene il risultato in virgola mobile di this.numSides in alto, seguito dal valore dell'argomento sideLength passato al metodo. dmul questi primi due valori dallo stack, esegue la moltiplicazione in virgola mobile su di essi e inserisce il risultato nello stack.

Quando viene chiamato un metodo, viene creato un nuovo stack di operandi come parte del relativo stack frame, in cui verranno eseguite le operazioni. 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.

Correlati: Riduci il codice Boilerplate con Macro Scala e Quasiquotes