Ubrudź sobie ręce dzięki kodowi bajtowemu Scala JVM
Opublikowany: 2022-03-11Język Scala w ciągu ostatnich kilku lat nadal zdobywał popularność dzięki doskonałemu połączeniu funkcjonalnych i obiektowych zasad tworzenia oprogramowania oraz jego implementacji na sprawdzonej wirtualnej maszynie Java (JVM).
Chociaż Scala kompiluje się do kodu bajtowego Java, ma na celu poprawę wielu dostrzeganych niedociągnięć języka Java. Oferując pełną obsługę programowania funkcjonalnego, podstawowa składnia Scali zawiera wiele niejawnych struktur, które muszą być zbudowane w sposób jawny przez programistów Java, a niektóre z nich wymagają znacznej złożoności.
Stworzenie języka, który kompiluje się do kodu bajtowego Javy, wymaga głębokiego zrozumienia wewnętrznego działania wirtualnej maszyny Javy. Aby docenić osiągnięcia programistów Scali, należy zajrzeć pod maskę i zbadać, w jaki sposób kod źródłowy Scali jest interpretowany przez kompilator w celu uzyskania wydajnego i efektywnego kodu bajtowego JVM.
Przyjrzyjmy się, jak to wszystko jest zaimplementowane.
Warunki wstępne
Przeczytanie tego artykułu wymaga podstawowej wiedzy na temat kodu bajtowego wirtualnej maszyny Java. Pełną specyfikację maszyny wirtualnej można znaleźć w oficjalnej dokumentacji Oracle. Przeczytanie całej specyfikacji nie jest kluczowe dla zrozumienia tego artykułu, więc dla szybkiego wprowadzenia do podstaw przygotowałem krótki przewodnik na dole artykułu.
Potrzebne jest narzędzie do demontażu kodu bajtowego Java w celu odtworzenia przykładów podanych poniżej i kontynuowania dalszych badań. Java Development Kit udostępnia własne narzędzie wiersza poleceń, javap
, którego będziemy używać tutaj. Szybka demonstracja działania javap
znajduje się w przewodniku na dole.
I oczywiście działająca instalacja kompilatora Scala jest niezbędna dla czytelników, którzy chcą śledzić przykłady. Ten artykuł został napisany przy użyciu Scali 2.11.7. Różne wersje Scali mogą generować nieco inny kod bajtowy.
Domyślne gettery i settery
Chociaż konwencja Java zawsze zapewnia metody pobierające i ustawiające dla atrybutów publicznych, programiści Java są zobowiązani do napisania ich samodzielnie, pomimo faktu, że wzorzec dla każdego z nich nie zmienił się od dziesięcioleci. Scala natomiast dostarcza domyślne gettery i settery.
Spójrzmy na następujący przykład:
class Person(val name:String) { }
Zajrzyjmy do klasy Person
. Jeśli skompilujemy ten plik za pomocą scalac
, to uruchomienie $ javap -p Person.class
daje nam:
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 }
Widzimy, że dla każdego pola w klasie Scala generowane jest pole i jego metoda pobierająca. Pole jest prywatne i ostateczne, natomiast metoda jest publiczna.
Jeśli zamienimy val
na var
w źródle Person
i dokonamy ponownej kompilacji, to final
modyfikator pola zostanie usunięty, a także dodana zostanie metoda ustawiająca:
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 }
Jeśli jakakolwiek wartość val
lub var
jest zdefiniowana w treści klasy, to odpowiednie pole prywatne i metody akcesorów są tworzone i odpowiednio inicjowane podczas tworzenia instancji.
Należy zauważyć, że taka implementacja pól val
i var
na poziomie klasy oznacza, że jeśli jakieś zmienne są używane na poziomie klasy do przechowywania wartości pośrednich, a programista nigdy nie ma do nich bezpośredniego dostępu, inicjalizacja każdego takiego pola spowoduje dodanie jednej do dwóch metod do ślad klasy. Dodanie private
modyfikatora dla takich pól nie oznacza, że odpowiednie akcesory zostaną usunięte. Po prostu staną się prywatne.
Definicje zmiennych i funkcji
Załóżmy, że mamy metodę m()
i tworzymy trzy różne odwołania w stylu Scala do tej funkcji:
class Person(val name:String) { def m(): Int = { // ... return 0 } val m1 = m var m2 = m def m3 = m }
Jak skonstruowane jest każde z tych odniesień do m
? Kiedy w każdym przypadku m
stracony? Rzućmy okiem na wynikowy kod bajtowy. Poniższe dane wyjściowe pokazują wyniki javap -v Person.class
(pomijając wiele zbędnych danych wyjściowych):
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
W puli stałej widzimy, że odwołanie do metody m()
jest przechowywane pod indeksem #30
. W kodzie konstruktora widzimy, że ta metoda jest wywoływana dwukrotnie podczas inicjalizacji, przy czym instrukcja invokevirtual #30
pojawia się najpierw pod offsetem 11 bajtu, a następnie pod offsetem 19. Po pierwszym wywołaniu następuje putfield #22
instrukcji, które przypisuje wynik tę metodę do pola m1
, do którego odwołuje się indeks #22
w puli stałej. Drugie wywołanie następuje według tego samego wzorca, tym razem przypisując wartość do pola m2
indeksowanego na #24
w puli stałej.
Innymi słowy, przypisanie metody do zmiennej zdefiniowanej za pomocą val
lub var
przypisuje wynik metody tylko do tej zmiennej. Widzimy, że tworzone metody m1()
i m2()
są po prostu pobierającymi te zmienne. W przypadku var m2
widzimy również, że tworzony jest setter m2_$eq(int)
, który zachowuje się jak każdy inny setter, nadpisując wartość w polu.
Jednak użycie słowa kluczowego def
daje inny wynik. Zamiast pobierać wartość pola do zwrócenia, metoda m3()
zawiera również instrukcję invokevirtual #30
. Oznacza to, że za każdym razem, gdy ta metoda jest wywoływana, wywołuje ona m()
i zwraca wynik tej metody.
Jak widać, Scala udostępnia trzy sposoby pracy z polami klas, które można łatwo określić za pomocą słów kluczowych val
, var
, i def
. W Javie musielibyśmy jawnie zaimplementować niezbędne settery i gettery, a taki ręcznie napisany szablonowy kod byłby znacznie mniej wyrazisty i bardziej podatny na błędy.
leniwe wartości
Bardziej skomplikowany kod jest tworzony podczas deklarowania leniwej wartości. Załóżmy, że dodaliśmy następujące pole do wcześniej zdefiniowanej klasy:
lazy val m4 = m
Uruchomienie javap -p -v Person.class
pokaże teraz następujące informacje:
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
W takim przypadku wartość pola m4
nie jest obliczana, dopóki nie jest potrzebna. Specjalna, prywatna metoda m4$lzycompute()
jest tworzona do obliczania wartości leniwej, a pole bitmap$0
do śledzenia jej stanu. Metoda m4()
sprawdza, czy wartość tego pola wynosi 0, co wskazuje, że m4
nie zostało jeszcze zainicjowane, w którym to przypadku wywoływana jest m4$lzycompute()
, wypełniająca m4
i zwracająca jego wartość. Ta prywatna metoda również ustawia wartość bitmap$0
na 1, więc przy następnym wywołaniu m4()
pominie wywołanie metody inicjującej i zamiast tego po prostu zwróci wartość m4
.
Kod bajtowy, który Scala tworzy tutaj, został zaprojektowany tak, aby był zarówno bezpieczny dla wątków, jak i skuteczny. Aby zapewnić bezpieczeństwo wątków, leniwa metoda obliczeniowa wykorzystuje parę instrukcji monitorenter
/ monitorexit
. Metoda pozostaje skuteczna, ponieważ obciążenie wydajnościowe tej synchronizacji występuje tylko przy pierwszym odczycie wartości opóźnionej.
Do wskazania stanu leniwej wartości potrzebny jest tylko jeden bit. Jeśli więc nie ma więcej niż 32 leniwych wartości, jedno pole int może je wszystkie śledzić. Jeśli w kodzie źródłowym zdefiniowano więcej niż jedną wartość opóźnioną, powyższy kod bajtowy zostanie zmodyfikowany przez kompilator w celu zaimplementowania maski bitowej w tym celu.
Ponownie, Scala pozwala nam łatwo wykorzystać określony rodzaj zachowania, który musiałby zostać zaimplementowany bezpośrednio w Javie, oszczędzając wysiłek i zmniejszając ryzyko literówek.
Funkcja jako wartość
Przyjrzyjmy się teraz następującemu kodowi źródłowemu Scali:
class Printer(val output: String => Unit) { } object Hello { def main(arg: Array[String]) { val printer = new Printer( s => println(s) ); printer.output("Hello"); } }
Klasa Printer
ma jedno pole, output
, o typie String => Unit
: funkcja, która pobiera String
i zwraca obiekt typu Unit
(podobnie jak void
w Javie). W metodzie main tworzymy jeden z tych obiektów i przypisujemy temu polu funkcję anonimową, która wypisuje dany ciąg.
Kompilacja tego kodu generuje cztery pliki klas:
Hello.class
to klasa opakowująca, której główna metoda po prostu wywołuje 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
Ukryta Hello$.class
. zawiera rzeczywistą implementację głównej metody. Aby przyjrzeć się jego kodowi bajtowemu, upewnij się, że poprawnie eskortujesz $
zgodnie z zasadami twojej powłoki poleceń, aby uniknąć jego interpretacji jako znaku specjalnego:
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 tworzy Printer
. Następnie tworzy Hello$$anonfun$1
, który zawiera naszą anonimową funkcję s => println(s)
. Printer
jest inicjowana z tym obiektem jako polem output
. To pole jest następnie ładowane na stos i wykonywane z operandem "Hello"
.
Rzućmy okiem na anonimową klasę funkcji Hello$$anonfun$1.class
poniżej. Widzimy, że rozszerza Function1
Scali (jako AbstractFunction1
) poprzez implementację metody apply()
. W rzeczywistości tworzy dwie metody apply()
, z których jedna otacza drugą, które razem wykonują sprawdzanie typu (w tym przypadku, czy dane wejściowe to String
) i wykonują funkcję anonimową (wypisując dane wejściowe za pomocą 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
Patrząc wstecz na powyższą metodę Hello$.main()
, widzimy, że przy przesunięciu 21 wykonanie funkcji anonimowej jest wyzwalane przez wywołanie jej metody apply( Object )
.
Na koniec, dla kompletności, spójrzmy na kod bajtowy dla 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
Widzimy, że funkcja anonimowa jest tutaj traktowana jak każda zmienna val
. Jest on przechowywany w danych output
pola klasy i tworzony jest getter output()
. Jedyną różnicą jest to, że ta zmienna musi teraz implementować interfejs Scala scala.Function1
(co robi AbstractFunction1
).
Tak więc kosztem tej eleganckiej funkcji Scali są podstawowe klasy narzędzi, stworzone do reprezentowania i wykonywania pojedynczej anonimowej funkcji, która może być użyta jako wartość. Należy wziąć pod uwagę liczbę takich funkcji, a także szczegóły implementacji maszyny wirtualnej, aby dowiedzieć się, co to oznacza dla konkretnej aplikacji.
Cechy Scali
Cechy Scali są podobne do interfejsów w Javie. Następująca cecha definiuje dwie sygnatury metod i zapewnia domyślną implementację drugiej. Zobaczmy, jak to jest realizowane:
trait Similarity { def isSimilar(x: Any): Boolean def isNotSimilar(x: Any): Boolean = !isSimilar(x) }
Tworzone są dwie encje: Similarity.class
, interfejs deklarujący obie metody, oraz klasa syntetyczna, Similarity$class.class
, zapewniająca domyślną implementację:
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
Gdy klasa implementuje tę cechę i wywołuje metodę isNotSimilar
, kompilator Scala generuje instrukcję kodu bajtowego invokestatic
, aby wywołać metodę statyczną dostarczoną przez towarzyszącą klasę.
Z cech można tworzyć złożone struktury polimorfizmu i dziedziczenia. Na przykład wiele cech, jak również klasa implementująca, mogą wszystkie nadpisać metodę o tej samej sygnaturze, wywołując super.methodName()
w celu przekazania kontroli do następnej cechy. Kiedy kompilator Scala napotka takie wywołania, to:

- Określa, jaką konkretną cechę przyjmuje to wywołanie.
- Określa nazwę towarzyszącej klasy, która zapewnia kod bajtowy metody statycznej zdefiniowany dla cechy.
- Tworzy niezbędną instrukcję
invokestatic
.
Widzimy zatem, że potężna koncepcja cech jest implementowana na poziomie JVM w sposób, który nie prowadzi do znacznych kosztów ogólnych, a programiści Scali mogą cieszyć się tą funkcją bez obawy, że będzie ona zbyt kosztowna w czasie wykonywania.
Singletony
Scala zapewnia jawną definicję klas singleton przy użyciu słowa kluczowego object
. Rozważmy następującą klasę singletona:
object Config { val home_dir = "/home/user" }
Kompilator tworzy dwa pliki klas:
Config.class
jest dość prosty:
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
To jest tylko dekorator dla syntetycznej klasy Config$
, która zawiera funkcjonalność singletona. Sprawdzanie tej klasy za pomocą javap -p -c
daje następujący kod bajtowy:
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
Składa się z następujących elementów:
- Syntetyczna zmienna
MODULE$
, przez którą inne obiekty uzyskują dostęp do tego obiektu singletona. - Statyczny inicjator
{}
(znany również jako<clinit>
, inicjator klasy) i prywatna metodaConfig$
, używane do inicjalizacjiMODULE$
i ustawiania jego pól na wartości domyślne - Metoda pobierająca dla pola statycznego
home_dir
. W tym przypadku jest to tylko jedna metoda. Jeśli singleton ma więcej pól, będzie miał więcej getterów, a także seterów dla pól zmiennych.
Singleton to popularny i użyteczny wzorzec projektowy. Język Java nie zapewnia bezpośredniego sposobu określenia go na poziomie języka; raczej to programista jest odpowiedzialny za zaimplementowanie go w źródle Java. Z drugiej strony Scala zapewnia jasny i wygodny sposób deklarowania singletona jawnie przy użyciu słowa kluczowego object
. Jak widać zaglądając pod maskę, jest to realizowane w przystępny i naturalny sposób.
Wniosek
Widzieliśmy teraz, jak Scala kompiluje kilka niejawnych i funkcjonalnych funkcji programowania w wyrafinowane struktury kodu bajtowego Java. Dzięki temu wglądowi w wewnętrzne działanie Scali możemy lepiej docenić moc Scali, pomagając nam w pełni wykorzystać ten potężny język.
Mamy teraz również narzędzia do samodzielnego poznawania języka. Istnieje wiele przydatnych funkcji składni Scali, które nie zostały omówione w tym artykule, takich jak klasy przypadków, currying i listy składane. Zachęcam cię do samodzielnego zbadania implementacji tych struktur przez Scala, abyś mógł nauczyć się, jak zostać Scala ninja na wyższym poziomie!
Wirtualna maszyna Java: szybki kurs
Podobnie jak kompilator Java, kompilator Scala konwertuje kod źródłowy do plików .class
, zawierających kod bajtowy Java do wykonania przez wirtualną maszynę Java. Aby zrozumieć, czym te dwa języki różnią się pod maską, konieczne jest zrozumienie systemu, na który oba języki są skierowane. Tutaj przedstawiamy krótki przegląd niektórych głównych elementów architektury Java Virtual Machine, struktury plików klas i podstaw asemblera.
Pamiętaj, że ten przewodnik obejmuje tylko minimum, aby umożliwić śledzenie wraz z powyższym artykułem. Chociaż wiele głównych komponentów JVM nie jest tutaj omawianych, pełne szczegóły można znaleźć w oficjalnych dokumentach, tutaj.
Dekompilacja plików klas za pomocą
javap
Stała pula
Tabele pól i metod
Kod bajtowy JVM
Wywołania metod i stos wywołań
Wykonanie na stosie argumentów
Zmienne lokalne
Wróć do góry
Dekompilacja plików klas za pomocą javap
Java jest dostarczana z narzędziem wiersza poleceń javap
, które dekompiluje pliki .class
do postaci czytelnej dla człowieka. Ponieważ pliki klas Scala i Java są przeznaczone dla tej samej maszyny JVM, javap
może być używany do sprawdzania plików klas skompilowanych przez Scala.
Skompilujmy następujący kod źródłowy:
// RegularPolygon.scala class RegularPolygon( val numSides: Int ) { def getPerimeter( sideLength: Double ): Double = { println( "Calculating perimeter..." ) return sideLength * this.numSides } }
Kompilacja tego za pomocą scalac RegularPolygon.scala
wygeneruje RegularPolygon.class
. Jeśli następnie uruchomimy javap RegularPolygon.class
, zobaczymy co następuje:
$ javap RegularPolygon.class Compiled from "RegularPolygon.scala" public class RegularPolygon { public int numSides(); public double getPerimeter(double); public RegularPolygon(int); }
Jest to bardzo prosty podział pliku klasy, który po prostu pokazuje nazwy i typy publicznych członków klasy. Dodanie opcji -p
obejmie członków prywatnych:
$ 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); }
To wciąż niewiele informacji. Aby zobaczyć, jak metody są zaimplementowane w kodzie bajtowym Javy, dodajmy opcję -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 }
To trochę bardziej interesujące. Jednak, aby naprawdę poznać całą historię, powinniśmy użyć opcji -v
lub -verbose
, jak w javap -p -v RegularPolygon.class
:
Tutaj wreszcie widzimy, co tak naprawdę jest w pliku klasy. Co to wszystko znaczy? Przyjrzyjmy się niektórym z najważniejszych części.
Stała pula
Cykl rozwoju aplikacji C++ obejmuje etapy kompilacji i łączenia. Cykl rozwojowy dla Javy pomija etap jawnego łączenia, ponieważ łączenie ma miejsce w czasie wykonywania. Plik klasy musi obsługiwać to łączenie środowiska wykonawczego. Oznacza to, że gdy kod źródłowy odwołuje się do dowolnego pola lub metody, wynikowy kod bajtowy musi przechowywać odpowiednie odniesienia w formie symbolicznej, gotowe do usunięcia po załadowaniu aplikacji do pamięci, a rzeczywiste adresy mogą zostać rozwiązane przez linker środowiska wykonawczego. Ta symboliczna forma musi zawierać:
- Nazwa klasy
- nazwa pola lub metody
- wpisz informacje
Specyfikacja formatu pliku klasy zawiera sekcję pliku zwaną stałą pulą , tabelę wszystkich odniesień potrzebnych linkerowi. Zawiera wpisy różnego typu.
// ... Constant pool: #1 = Utf8 RegularPolygon #2 = Class #1 // RegularPolygon #3 = Utf8 java/lang/Object #4 = Class #3 // java/lang/Object // ...
Pierwszy bajt każdego wpisu to znacznik numeryczny wskazujący typ wpisu. Pozostałe bajty dostarczają informacji o wartości wpisu. Liczba bajtów i zasady ich interpretacji zależą od typu wskazanego przez pierwszy bajt.
Na przykład klasa Java, która używa stałej liczby całkowitej 365
, może mieć wpis stałej puli z następującym kodem bajtowym:
x03 00 00 01 6D
Pierwszy bajt x03
identyfikuje typ wpisu CONSTANT_Integer
. Informuje to konsolidator, że następne cztery bajty zawierają wartość liczby całkowitej. (Zauważ, że 365 w systemie szesnastkowym to x16D
). Jeśli jest to czternasty wpis w puli stałej, javap -v
wyrenderuje go tak:
#14 = Integer 365
Wiele typów stałych składa się z odwołań do bardziej „prymitywnych” typów stałych w innych miejscach puli stałych. Na przykład nasz przykładowy kod zawiera instrukcję:
println( "Calculating perimeter..." )
Użycie stałej ciągu spowoduje utworzenie dwóch wpisów w puli stałych: jednego wpisu typu CONSTANT_String
i drugiego wpisu typu CONSTANT_Utf8
. Wpis typu Constant_UTF8
zawiera rzeczywistą reprezentację UTF8 wartości ciągu. Wpis typu CONSTANT_String
zawiera odniesienie do wpisu CONSTANT_Utf8
:
#24 = Utf8 Calculating perimeter... #25 = String #24 // Calculating perimeter...
Taka komplikacja jest konieczna, ponieważ istnieją inne typy wpisów puli stałych, które odnoszą się do wpisów typu Utf8
i które nie są wpisami typu String
. Na przykład każde odwołanie do atrybutu klasy spowoduje powstanie typu CONSTANT_Fieldref
, który zawiera serię odniesień do nazwy klasy, nazwy atrybutu i typu atrybutu:
#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
Więcej informacji na temat puli stałej można znaleźć w dokumentacji JVM.
Tabele pól i metod
Plik klasy zawiera tabelę pól , która zawiera informacje o każdym polu (tj. atrybucie) zdefiniowanym w klasie. Są to odwołania do stałych wpisów puli, które opisują nazwę i typ pola, a także flagi kontroli dostępu i inne istotne dane.
Podobna tabela metod znajduje się w pliku klasy. Jednak oprócz informacji o nazwie i typie dla każdej metody nieabstrakcyjnej zawiera ona rzeczywiste instrukcje kodu bajtowego do wykonania przez maszynę JVM, a także struktury danych używane przez ramkę stosu metody, opisane poniżej.
Kod bajtowy JVM
JVM używa własnego zestawu instrukcji wewnętrznych do wykonywania skompilowanego kodu. Uruchamianie javap
z opcją -c
obejmuje implementacje skompilowanych metod w danych wyjściowych. Jeśli w ten sposób zbadamy nasz plik RegularPolygon.class
, zobaczymy następujące dane wyjściowe dla naszej metody 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
Rzeczywisty kod bajtowy może wyglądać mniej więcej tak:
xB2 00 17 x12 19 xB6 00 1D x27 ...
Każda instrukcja rozpoczyna się jednobajtowym kodem operacji identyfikującym instrukcję maszyny JVM, po którym następuje zero lub więcej operandów instrukcji, na których ma być wykonana operacja, w zależności od formatu konkretnej instrukcji. Są to zazwyczaj albo wartości stałe, albo odwołania do puli stałej. javap
w tłumaczeniu kodu bajtowego na postać czytelną dla człowieka, wyświetlając:
- Offset lub pozycja pierwszego bajtu instrukcji w kodzie.
- Czytelna dla człowieka nazwa lub mnemonik instrukcji.
- Wartość operandu, jeśli istnieje.
Operandy wyświetlane ze znakiem krzyżyka, takie jak #23
, są odwołaniami do wpisów w puli stałej. Jak widać, javap
tworzy również pomocne komentarze w danych wyjściowych, określając, do czego dokładnie odwołuje się pula.
Poniżej omówimy kilka typowych instrukcji. Szczegółowe informacje na temat pełnego zestawu instrukcji JVM można znaleźć w dokumentacji.
Wywołania metod i stos wywołań
Każde wywołanie metody musi być w stanie działać z własnym kontekstem, który obejmuje takie elementy, jak zmienne deklarowane lokalnie lub argumenty, które zostały przekazane do metody. Razem tworzą one ramkę stosu . Po wywołaniu metody nowa ramka jest tworzona i umieszczana na szczycie stosu wywołań . Po zwróceniu metody bieżąca ramka jest usuwana ze stosu wywołań i odrzucana, a ramka, która obowiązywała przed wywołaniem metody, zostaje przywrócona.
Ramka stosu zawiera kilka odrębnych struktur. Dwa ważne z nich to stos operandów i tabela zmiennych lokalnych , omówione dalej.
Wykonanie na stosie argumentów
Wiele instrukcji JVM działa na stosie operandów swojej ramki. Zamiast jawnego określania stałego operandu w kodzie bajtowym, te instrukcje zamiast tego przyjmują jako dane wejściowe wartości ze szczytu stosu operandów. Zazwyczaj te wartości są usuwane ze stosu w trakcie tego procesu. Niektóre instrukcje również umieszczają nowe wartości na szczycie stosu. W ten sposób instrukcje JVM można łączyć w celu wykonywania złożonych operacji. Na przykład wyrażenie:
sideLength * this.numSides
jest kompilowany do następującej metody w naszej getPerimeter()
:
8: dload_1 9: aload_0 10: invokevirtual #31 // Method numSides:()I 13: i2d 14: dmul
- Pierwsza instrukcja,
dload_1
, odkłada odwołanie do obiektu ze slotu 1 tabeli zmiennych lokalnych (omówione dalej) na stos operandów. W tym przypadku jest to argument metodysideLength
.- Następna instrukcja,aload_0
, odkłada odwołanie do obiektu w slocie 0 lokalnej tablicy zmiennych na stos argumentów. W praktyce jest to prawie zawsze odniesienie dothis
, aktualnej klasy. - Spowoduje to ustawienie stosu dla następnego wywołania,
invokevirtual #31
, które wykonuje metodę instancjinumSides()
.invokevirtual
górny operand (odwołanie dothis
) ze stosu, aby określić, z jakiej klasy musi wywołać metodę. Gdy metoda powróci, jej wynik jest odkładany na stos. - W takim przypadku zwracana wartość (
numSides
) jest w formacie liczby całkowitej. Musi zostać przekonwertowany na format podwójnej liczby zmiennoprzecinkowej, aby pomnożyć go przez inną podwójną wartość. Instrukcjai2d
wartość całkowitą ze stosu, konwertuje ją na format zmiennoprzecinkowy i odkłada z powrotem na stos. - W tym momencie stos zawiera wynik zmiennoprzecinkowy
this.numSides
u góry, po którym następuje wartość argumentusideLength
, który został przekazany do metody.dmul
te dwie najwyższe wartości ze stosu, wykonuje na nich mnożenie zmiennoprzecinkowe i odkłada wynik na stos.
Gdy wywoływana jest metoda, nowy stos operandów jest tworzony jako część jej ramki stosu, na której będą wykonywane operacje. 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.