Murdărește-ți mâinile cu Scala JVM Bytecode

Publicat: 2022-03-11

Limbajul Scala a continuat să câștige popularitate în ultimii ani, datorită combinației sale excelente de principii de dezvoltare a software-ului funcțional și orientat pe obiecte și implementării sale pe lângă mașina virtuală Java (JVM) dovedită.

Deși Scala se compilează în bytecode Java, este conceput pentru a îmbunătăți multe dintre deficiențele percepute ale limbajului Java. Oferind suport complet de programare funcțională, sintaxa de bază a Scala conține multe structuri implicite care trebuie construite în mod explicit de către programatorii Java, unele implicând o complexitate considerabilă.

Crearea unui limbaj care se compilează în bytecode Java necesită o înțelegere profundă a funcționării interioare a mașinii virtuale Java. Pentru a aprecia ceea ce dezvoltatorii Scala au realizat, este necesar să mergem sub capotă și să explorezi modul în care codul sursă al Scala este interpretat de către compilator pentru a produce un cod de octet JVM eficient și eficient.

Să aruncăm o privire la cum sunt implementate toate aceste lucruri.

Cerințe preliminare

Citirea acestui articol necesită o înțelegere de bază a codului de octeți Java Virtual Machine. Specificațiile complete ale mașinii virtuale pot fi obținute din documentația oficială Oracle. Citirea întregii specificații nu este critică pentru înțelegerea acestui articol, așa că, pentru o introducere rapidă în elementele de bază, am pregătit un scurt ghid în partea de jos a articolului.

Faceți clic aici pentru a citi un curs intensiv despre elementele de bază ale JVM.

Este necesar un utilitar pentru a dezasambla codul de octet Java pentru a reproduce exemplele furnizate mai jos și pentru a continua investigațiile suplimentare. Kitul de dezvoltare Java oferă propriul utilitar de linie de comandă, javap , pe care îl vom folosi aici. O demonstrație rapidă a modului în care funcționează javap este inclusă în ghidul din partea de jos.

Și, desigur, o instalare funcțională a compilatorului Scala este necesară pentru cititorii care doresc să urmeze exemplele. Acest articol a fost scris folosind Scala 2.11.7. Diferite versiuni de Scala pot produce bytecode ușor diferit.

Getters și Setters implicite

Deși convenția Java oferă întotdeauna metode getter și setter pentru atributele publice, programatorii Java sunt obligați să le scrie ei înșiși, în ciuda faptului că modelul pentru fiecare nu s-a schimbat în decenii. Scala, în schimb, furnizează getters și setters implicite.

Să ne uităm la următorul exemplu:

 class Person(val name:String) { }

Să aruncăm o privire în interiorul clasei Person . Dacă compilam acest fișier cu scalac , atunci rulând $ javap -p Person.class ne oferă:

 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 }

Putem vedea că pentru fiecare câmp din clasa Scala sunt generate un câmp și metoda lui getter. Domeniul este privat și final, în timp ce metoda este publică.

Dacă înlocuim val cu var în sursa Person și recompilăm, atunci modificatorul final al câmpului este abandonat și se adaugă și metoda 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 }

Dacă orice val sau var este definit în interiorul corpului clasei, atunci câmpul privat corespunzător și metodele accesorii sunt create și inițializate corespunzător la crearea instanței.

Rețineți că o astfel de implementare a câmpurilor val și var la nivel de clasă înseamnă că, dacă unele variabile sunt utilizate la nivel de clasă pentru a stoca valori intermediare și nu sunt niciodată accesate direct de către programator, inițializarea fiecărui astfel de câmp va adăuga una sau două metode la amprenta clasei. Adăugarea unui modificator private pentru astfel de câmpuri nu înseamnă că accesorii corespunzători vor fi abandonați. Pur și simplu vor deveni private.

Definiții de variabile și funcții

Să presupunem că avem o metodă, m() și să creăm trei referințe diferite în stil Scala la această funcție:

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

Cum sunt construite fiecare dintre aceste referințe la m ? Când m executat în fiecare caz? Să aruncăm o privire la bytecode rezultat. Următoarea ieșire arată rezultatele javap -v Person.class (omițând o mulțime de rezultate 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

În pool-ul constant, vedem că referința la metoda m() este stocată la indexul #30 . În codul constructorului, vedem că această metodă este invocată de două ori în timpul inițializării, instrucțiunea invokevirtual #30 apărând mai întâi la offset-ul octet 11, apoi la offset-ul 19. Prima invocare este urmată de instrucțiunea putfield #22 care atribuie rezultatul această metodă la câmpul m1 , referit de indexul #22 în pool-ul constant. A doua invocare este urmată de același model, de data aceasta atribuind valoarea câmpului m2 , indexat la #24 în pool-ul constant.

Cu alte cuvinte, atribuirea unei metode unei variabile definite cu val sau var atribuie doar rezultatul metodei acelei variabile. Putem vedea că metodele m1() și m2() care sunt create sunt pur și simplu getters pentru aceste variabile. În cazul var m2 , vedem și că se creează setter- m2_$eq(int) , care se comportă la fel ca orice alt setter, suprascriind valoarea din câmp.

Cu toate acestea, utilizarea cuvântului cheie def dă un rezultat diferit. În loc să preia o valoare de câmp de returnat, metoda m3() include și instrucțiunea invokevirtual #30 . Adică, de fiecare dată când această metodă este apelată, apoi apelează m() și returnează rezultatul acestei metode.

Deci, după cum putem vedea, Scala oferă trei moduri de a lucra cu câmpurile de clasă, iar acestea sunt ușor de specificate prin cuvintele cheie val , var și def . În Java, ar trebui să implementăm în mod explicit seterii și geterii necesari, iar un astfel de cod boilerplate scris manual ar fi mult mai puțin expresiv și mai predispus la erori.

Valori leneșe

Un cod mai complicat este produs atunci când se declară o valoare leneșă. Să presupunem că am adăugat următorul câmp la clasa definită anterior:

 lazy val m4 = m

Rularea javap -p -v Person.class va dezvălui acum următoarele:

 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

În acest caz, valoarea câmpului m4 nu este calculată până când este necesar. Metoda specială, privată m4$lzycompute() este produsă pentru a calcula valoarea leneșă, iar câmpul bitmap$0 pentru a urmări starea acesteia. Metoda m4() verifică dacă valoarea acestui câmp este 0, indicând că m4 nu a fost încă inițializat, caz în care este invocat m4$lzycompute() , populând m4 și returnând valoarea acestuia. Această metodă privată setează, de asemenea, valoarea bitmap$0 la 1, astfel încât data viitoare când se apelează m4() va sări peste invocarea metodei de inițializare și va returna pur și simplu valoarea lui m4 .

Rezultatele primului apel la o valoare leneșă Scala.

Codul de octeți pe care Scala îl produce aici este conceput pentru a fi atât sigur, cât și eficient. Pentru a fi sigură pentru fire, metoda de calcul leneș folosește perechea de instrucțiuni monitorenter / monitorexit . Metoda rămâne eficientă, deoarece suprasarcina de performanță a acestei sincronizări apare doar la prima citire a valorii leneșe.

Este necesar doar un bit pentru a indica starea valorii leneșe. Deci, dacă nu există mai mult de 32 de valori lazy, un singur câmp int le poate urmări pe toate. Dacă în codul sursă sunt definite mai multe valori lazy, bytecode-ul de mai sus va fi modificat de către compilator pentru a implementa o mască de biți în acest scop.

Din nou, Scala ne permite să profităm cu ușurință de un anumit tip de comportament care ar trebui implementat explicit în Java, economisind efort și reducând riscul greșelilor de scriere.

Funcționează ca valoare

Acum să aruncăm o privire la următorul cod sursă Scala:

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

Clasa Printer are un câmp, output , cu tipul String => Unit : o funcție care ia un String și returnează un obiect de tip Unit (similar cu void în Java). În metoda principală, creăm unul dintre aceste obiecte și atribuim acestui câmp să fie o funcție anonimă care tipărește un șir dat.

Compilarea acestui cod generează patru fișiere de clasă:

Codul sursă este compilat în patru fișiere de clasă.

Hello.class este o clasă wrapper a cărei metodă principală apelează pur și simplu 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

Clasa ascunsă Hello$.class implementarea reală a metodei principale. Pentru a arunca o privire la bytecode-ul său, asigurați-vă că scăpați corect $ conform regulilor shell-ului de comandă, pentru a evita interpretarea lui ca caracter special:

 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

Metoda creează o Printer . Apoi creează un Hello$$anonfun$1 , care conține funcția noastră anonimă s => println(s) . Printer este inițializată cu acest obiect ca câmp de output . Acest câmp este apoi încărcat în stivă și executat cu operandul "Hello" .

Să aruncăm o privire la clasa de funcții anonime, Hello$$anonfun$1.class , de mai jos. Putem vedea că extinde Function1 Scala (ca AbstractFunction1 ) prin implementarea metodei apply() . De fapt, creează două metode apply() , una împachetând-o pe cealaltă, care împreună realizează verificarea tipului (în acest caz, că intrarea este un String ) și execută funcția anonimă (imprimarea intrării cu 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

Privind înapoi la metoda Hello$.main() de mai sus, putem vedea că, la offset-ul 21, execuția funcției anonime este declanșată de un apel la metoda sa apply( Object ) .

În cele din urmă, pentru a fi complet, să ne uităm la bytecode pentru 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

Putem vedea că funcția anonimă de aici este tratată la fel ca orice variabilă val . Este stocat în câmpul de clasă output și este creat getter output() . Singura diferență este că această variabilă trebuie să implementeze acum interfața Scala scala.Function1 (ceea ce AbstractFunction1 face).

Deci, costul acestei caracteristici elegante Scala este clasele de utilitate care stau la baza, create pentru a reprezenta și executa o singură funcție anonimă care poate fi folosită ca valoare. Ar trebui să țineți cont de numărul de astfel de funcții, precum și de detaliile implementării dvs. VM, pentru a vă da seama ce înseamnă aceasta pentru aplicația dvs. particulară.

Mergeți sub capotă cu Scala: explorați modul în care acest limbaj puternic este implementat în bytecode JVM.
Tweet

Trăsături Scala

Trăsăturile lui Scala sunt similare cu interfețele din Java. Următoarea trăsătură definește două semnături de metodă și oferă o implementare implicită a celei de-a doua. Să vedem cum este implementat:

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

Codul sursă este compilat în două fișiere de clasă.

Sunt produse două entități: Similarity.class , interfața care declară ambele metode și clasa sintetică, Similarity$class.class , care oferă implementarea implicită:

 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

Când o clasă implementează această trăsătură și apelează metoda isNotSimilar , compilatorul Scala generează instrucțiunea bytecode invokestatic pentru a apela metoda statică furnizată de clasa însoțitoare.

Polimorfismul complex și structurile de moștenire pot fi create din trăsături. De exemplu, trăsăturile multiple, precum și clasa de implementare, pot suprascrie o metodă cu aceeași semnătură, apelând super.methodName() pentru a trece controlul următoarei trăsături. Când compilatorul Scala întâlnește astfel de apeluri, acesta:

  • Determină ce trăsătură exactă este asumată de acest apel.
  • Determină numele clasei însoțitoare care oferă codul octet al metodei statice definit pentru trăsătură.
  • Produce instrucțiunea de invokestatic necesară.

Astfel, putem vedea că conceptul puternic de trăsături este implementat la nivel de JVM într-un mod care nu duce la o supraîncărcare semnificativă, iar programatorii Scala se pot bucura de această caracteristică fără a-și face griji că va fi prea costisitoare în timpul execuției.

Singletons

Scala prevede definirea explicită a claselor singleton folosind cuvântul cheie object . Să luăm în considerare următoarea clasă singleton:

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

Compilatorul produce două fișiere de clasă:

Codul sursă este compilat în două fișiere de clasă.

Config.class este unul destul de simplu:

 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

Acesta este doar un decorator pentru clasa sintetică Config$ care încorporează funcționalitatea singleton-ului. Examinarea acelei clase cu javap -p -c produce următorul 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

Acesta constă în următoarele:

  • Variabila sintetică MODULE$ , prin care alte obiecte accesează acest obiect singleton.
  • Inițializatorul static {} (cunoscut și ca <clinit> , inițializatorul de clasă) și metoda privată Config$ , utilizate pentru a inițializa MODULE$ și a seta câmpurile sale la valorile implicite
  • O metodă getter pentru câmpul static home_dir . În acest caz, este doar o metodă. Dacă singleton-ul are mai multe câmpuri, va avea mai multe getters, precum și setters pentru câmpuri mutabile.

Singletonul este un model de design popular și util. Limbajul Java nu oferă o modalitate directă de a-l specifica la nivel de limbaj; mai degrabă, este responsabilitatea dezvoltatorului să o implementeze în sursa Java. Scala, pe de altă parte, oferă o modalitate clară și convenabilă de a declara un singleton în mod explicit folosind cuvântul cheie object . După cum putem vedea uitându-ne sub capotă, este implementat într-un mod accesibil și natural.

Concluzie

Am văzut acum cum Scala compilează câteva caracteristici de programare implicite și funcționale în structuri sofisticate de coduri de octet Java. Cu această privire asupra funcționării interioare a lui Scala, putem obține o apreciere mai profundă a puterii lui Scala, ajutându-ne să obținem la maximum acest limbaj puternic.

De asemenea, avem acum instrumentele pentru a explora singuri limba. Există multe caracteristici utile ale sintaxei Scala care nu sunt acoperite în acest articol, cum ar fi clasele de cazuri, curry și listele de înțelegere. Vă încurajez să investigați singuri implementarea de către Scala a acestor structuri, astfel încât să puteți învăța cum să fiți un ninja Scala de nivelul următor!


Mașina virtuală Java: un curs intensiv

La fel ca compilatorul Java, compilatorul Scala convertește codul sursă în fișiere .class , care conțin bytecode Java pentru a fi executat de mașina virtuală Java. Pentru a înțelege modul în care cele două limbi diferă sub capotă, este necesar să înțelegeți sistemul pe care îl vizează ambele. Aici, prezentăm o scurtă prezentare a unor elemente majore ale arhitecturii Java Virtual Machine, structurii fișierelor de clasă și elementele de bază ale asamblatorului.

Rețineți că acest ghid va acoperi doar minimul pentru a permite urmărirea împreună cu articolul de mai sus. Deși multe componente majore ale JVM-ului nu sunt discutate aici, detalii complete pot fi găsite în documentele oficiale, aici.

Decompilarea fișierelor de clasă cu javap
Piscina constantă
Tabele de câmpuri și metode
Cod octet JVM
Apeluri de metodă și stiva de apeluri
Execuție pe stiva de operanzi
Variabile locale
Înapoi la început

Decompilarea fișierelor de clasă cu javap

Java este livrat cu utilitarul de linie de comandă javap , care decompilează fișierele .class într-o formă care poate fi citită de om. Deoarece fișierele de clasă Scala și Java vizează ambele același JVM, javap poate fi folosit pentru a examina fișierele de clasă compilate de Scala.

Să compilam următorul cod sursă:

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

Compilând acest lucru cu scalac RegularPolygon.scala va produce RegularPolygon.class . Dacă apoi rulăm javap RegularPolygon.class , vom vedea următoarele:

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

Aceasta este o defalcare foarte simplă a fișierului de clasă care arată pur și simplu numele și tipurile membrilor publici ai clasei. Adăugarea opțiunii -p va include membri privați:

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

Aceasta încă nu este o mulțime de informații. Pentru a vedea cum sunt implementate metodele în bytecode Java, să adăugăm opțiunea -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 }

Asta e un pic mai interesant. Cu toate acestea, pentru a înțelege cu adevărat întreaga poveste, ar trebui să folosim opțiunea -v sau -verbose , ca în javap -p -v RegularPolygon.class :

Conținutul complet al unui fișier de clasă Java.

Aici vedem în sfârșit ce este cu adevărat în fișierul clasei. Ce înseamnă toate acestea? Să aruncăm o privire la unele dintre cele mai importante părți.

Piscina constantă

Ciclul de dezvoltare pentru aplicațiile C++ include etape de compilare și de conectare. Ciclul de dezvoltare pentru Java omite o etapă explicită de conectare, deoarece legătura are loc în timpul execuției. Fișierul de clasă trebuie să accepte această legătură de rulare. Aceasta înseamnă că atunci când codul sursă se referă la orice câmp sau metodă, bytecode rezultat trebuie să păstreze referințele relevante în formă simbolică, gata să fie dereferențiate odată ce aplicația s-a încărcat în memorie și adresele reale pot fi rezolvate de linker-ul runtime. Această formă simbolică trebuie să conțină:

  • numele clasei
  • numele câmpului sau metodei
  • informații de tip

Specificația formatului de fișier de clasă include o secțiune a fișierului numită pool constant , un tabel cu toate referințele necesare linkerului. Conține intrări de diferite tipuri.

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

Primul octet al fiecărei intrări este o etichetă numerică care indică tipul de intrare. Octeții rămași oferă informații despre valoarea intrării. Numărul de octeți și regulile de interpretare a acestora depind de tipul indicat de primul octet.

De exemplu, o clasă Java care utilizează un număr întreg constant 365 poate avea o intrare constantă în pool cu ​​următorul bytecode:

 x03 00 00 01 6D

Primul octet, x03 , identifică tipul de intrare, CONSTANT_Integer . Acest lucru informează linkerul că următorii patru octeți conțin valoarea întregului. (Rețineți că 365 în hexazecimal este x16D ). Dacă aceasta este a 14-a intrare din pool-ul constant, javap -v o va reda astfel:

 #14 = Integer 365

Multe tipuri de constante sunt compuse din referințe la tipuri de constante mai „primitive” în altă parte în pool-ul de constante. De exemplu, exemplul nostru de cod conține declarația:

 println( "Calculating perimeter..." )

Utilizarea unei constante șir va produce două intrări în pool-ul de constante: o intrare cu tipul CONSTANT_String și o altă intrare de tip CONSTANT_Utf8 . Intrarea de tip Constant_UTF8 conține reprezentarea reală UTF8 a valorii șirului. Intrarea de tip CONSTANT_String conține o referință la intrarea CONSTANT_Utf8 :

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

O astfel de complicație este necesară deoarece există alte tipuri de intrări de grup constant care se referă la intrări de tip Utf8 și care nu sunt intrări de tip String . De exemplu, orice referință la un atribut de clasă va produce un tip CONSTANT_Fieldref , care conține o serie de referințe la numele clasei, numele atributului și tipul atributului:

 #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

Pentru mai multe detalii despre pool-ul constant, consultați documentația JVM.

Tabele de câmpuri și metode

Un fișier de clasă conține un tabel de câmpuri care conține informații despre fiecare câmp (adică, atribut) definit în clasă. Acestea sunt referiri la intrări constante din pool care descriu numele și tipul câmpului, precum și semnalizatoarele de control al accesului și alte date relevante.

Un tabel de metode similar este prezent în fișierul de clasă. Cu toate acestea, pe lângă informațiile de nume și tip, pentru fiecare metodă non-abstractă, aceasta conține instrucțiunile de bytecode reale care urmează să fie executate de JVM, precum și structurile de date utilizate de cadrul stivă al metodei, descrise mai jos.

Cod octet JVM

JVM-ul folosește propriul set de instrucțiuni interne pentru a executa codul compilat. Rularea javap cu opțiunea -c include implementările metodei compilate în rezultat. Dacă examinăm fișierul nostru RegularPolygon.class în acest fel, vom vedea următoarea ieșire pentru metoda noastră 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

Bytecode real ar putea arăta cam așa:

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

Fiecare instrucțiune începe cu un cod operațional de un octet care identifică instrucțiunea JVM, urmat de zero sau mai mulți operanzi de instrucțiune pentru a fi operați, în funcție de formatul instrucțiunii specifice. Acestea sunt de obicei fie valori constante, fie referințe în pool-ul constant. javap traduce util bytecode-ul într-o formă care poate fi citită de om care afișează:

  • Decalajul sau poziția primului octet al instrucțiunii din cod.
  • Numele instrucțiunii care poate fi citit de om sau mnemonic .
  • Valoarea operandului, dacă există.

Operanzii care sunt afișați cu semnul lire sterline, cum ar fi #23 , sunt referințe la intrările din grupul constant. După cum putem vedea, javap produce, de asemenea, comentarii utile în rezultat, identificând exact ce este referit din pool.

Vom discuta mai jos câteva dintre instrucțiunile comune. Pentru informații detaliate despre setul complet de instrucțiuni JVM, consultați documentația.

Apeluri de metodă și stiva de apeluri

Fiecare apel de metodă trebuie să poată rula cu propriul context, care include lucruri precum variabilele declarate local sau argumentele care au fost transmise metodei. Împreună, acestea formează un cadru de stivă . La invocarea unei metode, un nou cadru este creat și plasat deasupra stivei de apeluri . Când metoda revine, cadrul curent este eliminat din stiva de apeluri și eliminat, iar cadrul care era în vigoare înainte de apelarea metodei este restaurat.

Un cadru de stivă include câteva structuri distincte. Două importante sunt stiva de operanzi și tabelul de variabile locale , discutate în continuare.

Stiva de apeluri JVM.

Execuție pe stiva de operanzi

Multe instrucțiuni JVM operează pe stiva de operanzi a cadrului lor. În loc să specifice un operand constant în mod explicit în bytecode, aceste instrucțiuni preiau valorile din partea de sus a stivei de operanzi ca intrare. De obicei, aceste valori sunt eliminate din stivă în timpul procesului. Unele instrucțiuni plasează, de asemenea, valori noi deasupra stivei. În acest fel, instrucțiunile JVM pot fi combinate pentru a efectua operațiuni complexe. De exemplu, expresia:

 sideLength * this.numSides

este compilat după cum urmează în metoda noastră getPerimeter() :

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

Instrucțiunile JVM pot funcționa pe stiva de operanzi pentru a îndeplini funcții complexe.

  • Prima instrucțiune, dload_1 , împinge referința obiectului din slotul 1 al tabelului de variabile locale (discutat în continuare) în stiva de operanzi. În acest caz, acesta este argumentul metodei sideLength .- Următoarea instrucțiune, aload_0 , împinge referința obiectului din slotul 0 al tabelului de variabile locale în stiva de operanzi. În practică, aceasta este aproape întotdeauna referința la this , clasa actuală.
  • Aceasta setează stiva pentru următorul apel, invokevirtual #31 , care execută metoda instanței numSides() . invokevirtual scoate operandul de sus (referința la this ) din stivă pentru a identifica din ce clasă trebuie să numească metoda. Odată ce metoda revine, rezultatul ei este împins în stivă.
  • În acest caz, valoarea returnată ( numSides ) este în format întreg. Trebuie convertit într-un format dublu cu virgulă mobilă pentru a-l înmulți cu o altă valoare dublă. Instrucțiunea i2d scoate valoarea întreagă din stivă, o convertește în format în virgulă mobilă și o împinge înapoi în stivă.
  • În acest moment, stiva conține rezultatul în virgulă mobilă a this.numSides deasupra, urmat de valoarea argumentului sideLength care a fost transmis metodei. dmul scoate aceste două valori de top din stivă, efectuează multiplicarea în virgulă mobilă pe ele și împinge rezultatul în stivă.

Când o metodă este apelată, o nouă stivă de operanzi este creată ca parte a cadrului său de stivă, unde vor fi efectuate operațiuni. 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.

Înrudit : Reduceți codul standard cu macrocomenzi Scala și cvasiquote