Kako Java virtuelna mašina izvršava bytecode instrukcije?
Izvršni engine jedan je od najjezgrovitijih sastavnih delova Java virtuelne mašine. "Virtuelna mašina" je pojam koji stoji naspram pojma "fizička mašina"; obe mašine imaju sposobnost izvršavanja koda, s tom razlikom što je izvršni engine fizičke mašine direktno zasnovan na procesoru, hardveru, skupu instrukcija i operativnom sistemu, dok izvršni engine virtuelne mašine implementira sam, pa može samostalno da definiše skup instrukcija i arhitekturu izvršnog engine-a, kao i da izvršava one formate skupa instrukcija koje hardver ne podržava direktno.
U specifikaciji Java virtuelne mašine definisan je konceptualni model izvršnog engine-a za bytecode virtuelne mašine; taj konceptualni model predstavlja jedinstveni spoljašnji izgled (Facade) za različite izvršne engine-e virtuelnih mašina. U različitim implementacijama virtuelne mašine, izvršni engine prilikom izvršavanja Java koda može koristiti interpretiranu obradu (preko interpretatora) i kompajliranu obradu (preko just-in-time kompajlera koji stvara nativni kod), a može imati i oba, čak i izvršne engine-e sa više nivoa kompajlera. Ali glede spoljašnjeg izgleda, svi izvršni engine-i Java virtuelnih mašina su istovetni: ulaz je bytecode fajl, proces je ekvivalentan procesu raščlanjivanja bytecoda, a izlaz je rezultat izvršavanja.
I. Struktura okvira steka u toku rada
II. Poziv metode
Poziv metode nije isto što i izvršenje metode; jedini zadatak faze poziva metode jeste da se utvrdi verzija metode koja se poziva (odnosno koja se metoda poziva), a da se privremeno ne ulazi u konkretan proces unutrašnjeg izvršavanja metode.
Tokom rada programa, poziv metode je najučestalija i najčešća operacija. Ranije je rečeno da proces kompajliranja Class fajla ne sadrži korak povezivanja iz tradicionalnog kompajliranja; svi pozivi metoda u Class fajlu uskladišteni su samo kao simboličke reference, a ne kao ulazne adrese u memorijskom rasporedu metode u toku rada (što odgovara ranije pomenutoj direktnoj referenci). Ova osobina Javi daje snažniju sposobnost dinamičkog proširivanja, ali ujedno čini i proces poziva Java metode relativno složenim — direktne reference ciljne metode mogu biti utvrđene tek tokom učitavanja klase, pa čak i u toku rada.
Razrešavanje
Ciljne metode svih poziva metoda u Class fajlu su simboličke reference u bazenu konstanti. U fazi razrešavanja tokom učitavanja klase, deo tih simboličkih referenci biće pretvoren u direktne reference. Preduslov da ovo razrešavanje može da važi jeste da metoda, pre stvarnog izvršavanja programa, ima odredivu verziju poziva i da ta verzija poziva metode ne može biti promenjena u toku rada. Drugim rečima, cilj poziva mora biti utvrđen već u trenutku pisanja koda i kompajliranja od strane kompajlera. Poziv ovakvih metoda naziva se razrešavanje (Resolution).
Metode u Java jeziku koje ispunjavaju zahtev "kompajler može da ih odredi, nepromenljive u toku rada" uglavnom obuhvataju dve velike kategorije: statičke metode i privatne metode — prve su direktno povezane sa tipom, a druge spolja ne mogu biti pristupane; karakteristike obeju metoda onemogućavaju im da drugačija verzija bude zameni putem nasleđivanja ili na drugi način, pa su obe pogodne za razrešavanje u fazi učitavanja klase.
U skladu s tim, u Java virtuelnoj mašini postoji 5 bytecode instrukcija za poziv metoda, i to:
- invokestatic: poziva statičke metode;
- invokespecial: poziva metod konstruktora instance
<init>, privatne metode i metode roditeljske klase; - invokevirtual: poziva sve virtuelne metode;
- invokeinterface: poziva metode interfejsa; u toku rada će se tek utvrditi objekat koji implementira taj interfejs;
- invokedynamic: prvo se u toku rada dinamički razreši metoda na koju ukazuje kvalifikator pozivne tačke, a zatim izvrši ta metoda.
Sve metode koje mogu biti pozvane instrukcijama invokestatic i invokespecial mogu imati jedinstvenu verziju poziva utvrđenu u fazi razrešavanja; u taj uslov spadaju 4 kategorije: statičke metode, privatne metode, konstruktori instanci i metode roditeljske klase — one će pri učitavanju razrešiti simboličku referencu u direktnu. Ove metode se mogu nazvati nevirtuelnim metodama; nasuprot njima, ostale metode se nazivaju virtuelnim metodama (osim final metoda).
Nevirtuelne metode u Javi, pored onih pozvanih preko invokestatic i invokespecial, obuhvataju i metode modifikovane sa final. Iako se final metode pozivaju instrukcijom invokevirtual, pošto ne mogu biti preklopljene i nemaju drugih verzija, nema potrebe za polimorfnim odabirom primaoca metode, odnosno rezultat polimorfnog izbora sigurno je jedinstven. U specifikaciji Java jezika izričito je navedeno da je final metoda nevirtuelna metoda.
Poziv putem razrešavanja uvek je statičan proces, potpuno odrediv tokom kompajliranja; u fazi razrešavanja pri učitavanju klase sve zahvaćene simboličke reference pretvoriće se u odredivu direktnu referencu, bez odlaganja na vreme rada. Poziv putem dispačiranja (Dispatch) može biti statički ili dinamički; prema broju varijabli na kojima se dispačiranje zasniva, može se podeliti na jednostruki i višestruki dispač. Ove dve vrste dispačiranja, u međusobnim kombinacijama, čine četiri situacije: statički jednostruki dispač, statički višestruki dispač, dinamički jednostruki dispač i dinamički višestruki dispač. U nastavku ćemo videti kako se u virtuelnoj mašini odvija dispačiranje metoda.
Dispačiranje
Objektno-orijentisano programiranje ima tri osnovne karakteristike: enkapsulaciju, nasleđivanje i polimorfizam. Dispačiranje o kojem će ovde biti reči otkriva neke od najosnovnijih iskaza polimorfizma, kao što su "preopterećenje" i "preklapanje" — kako su implementirani u Java virtuelnoj mašini? Kako virtuelna mašina utvrđuje ispravnu ciljnu metodu?
Statitčki dispač
Pre nego što počnemo da predstavljamo statički dispač, hajde da pogledamo jedan kod.
/**
* Demo statičkog dispača metoda
*
* @author baronzhang
*/
public class StaticDispatch {
private static abstract class Human { }
private static class Man extends Human { }
private static class Woman extends Human { }
private void sayHello(Human guy) {
System.out.println("Hello, guy!");
}
private void sayHello(Man man) {
System.out.println("Hello, man!");
}
private void sayHello(Woman woman) {
System.out.println("Hello, woman!");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch dispatch = new StaticDispatch();
dispatch.sayHello(man);
dispatch.sayHello(woman);
}
}Nakon pokretanja, izlaz ovog programa je sledeći:
Hello, guy!
Hello, guy!Svaki Java programer sa malo iskustva može doći do gorenavedenog zaključka, ali zašto — kada su stvarni tipovi argumenata koje prosleđujemo metodi sayHello() Man i Woman — virtuelna mašina prilikom izvršavanja programa bira preopterećenu verziju Human? Da bismo razumeli to pitanje, prvo moramo razjasniti dva pojma.
Human man = new Man();"Human" u gorenavedenom kodu naziva se statički tip (Static Type) promenljive, odnosno prividni tip (Apparent Type), dok se "Man" naziva stvarni tip (Actual Type) promenljive. I statički i stvarni tip mogu se u programu donekle menjati, s razlikom što se promena statičkog tipa dešava samo u trenutku upotrebe, da statički tip same promenljive ne mora biti promenjen i da konačni statički tip može biti poznat u vreme kompajliranja; dok se rezultat promene stvarnog tipa može utvrditi tek u toku rada — prilikom kompajliranja kompajler ne zna koji je stvarni tip nekog objekta.
Kada smo razjasnili ova dva pojma, ponovo hajde da pogledamo dva poziva sayHello() u metodi main() klase StaticDispatch: pod pretpostavkom da je primalac metode već utvrđen kao objekat "dispatch", koja će preopterećena verzija biti korišćena u potpunosti zavisi od broja i tipa podataka prosleđenih argumenata. U kodu su definisane dve promenljive istog statičkog tipa, ali različitog stvarnog tipa; međutim, virtuelna mašina (tačnije, kompajler) pri preopterećenju kao kriterijum koristi statički tip argumenata, a ne stvarni tip. Pošto je statički tip poznat u vreme kompajliranja, u fazi kompajliranja Javac kompajler će, na osnovu statičkog tipa argumenata, odlučiti koja preopterećena verzija će biti korišćena — pa bira sayHello(Human) kao cilj poziva i u parametre dve invokevirtual instrukcije u metodi main() upisuje simboličku referencu te metode.
Sve akcije dispačiranja koje se oslanjaju na statički tip da bi se locirala verzija izvršavanja metode nazivaju se statistički dispač. Tipična primena statičkog dispača jeste preopterećenje metoda. Statistički dispač dešava se u fazi kompajliranja, pa samim tim akcija utvrđivanja statičkog dispača zapravo ne izvršava virtuelna mašina.
Pored toga, iako kompajler može utvrditi preopterećenu verziju metode, u mnogim slučajevima ta preopterećena verzija nije "jedinstvena", pa se često može utvrditi samo "pogodnija" verzija. Glavni razlog za ovu situaciju jeste što literali ne zahtevaju definiciju, pa literali nemaju izričiti statički tip — njihov statički tip može se razumeti i zaključiti samo pravilima jezika. Sledeći kod prikazuje šta znači "pogodnija" verzija.
/**
* @author baronzhang
*/
public class Overlaod {
static void sayHello(Object arg) {
System.out.println("Hello, Object!");
}
static void sayHello(int arg) {
System.out.println("Hello, int!");
}
static void sayHello(long arg) {
System.out.println("Hello, long!");
}
static void sayHello(Character arg) {
System.out.println("Hello, Character!");
}
static void sayHello(char arg) {
System.out.println("Hello, char!");
}
static void sayHello(char... arg) {
System.out.println("Hello, char...!");
}
static void sayHello(Serializable arg) {
System.out.println("Hello, Serializable!");
}
public static void main(String[] args) {
sayHello('a');
}
}Rezultat rada gorenavedenog koda je:
Hello, char!To je lako razumeti — 'a' je podatak tipa char, pa će se prirodno tražiti preopterećena metoda čiji je tip parametra char. Ako se zakomentariše metoda sayHello(char arg), izlaz će se promeniti u:
Hello, int!Sada se dogodila jedna konverzija tipova — 'a' pored toga što može predstavljati znak, može predstavljati i broj 97, jer Unicode vrednost znaka 'a' iznosi decimalno 97, pa je i preopterećena metoda čiji je tip parametra int prikladna. Ako nastavimo da komentarišemo metodu sayHello(int arg), izlaz postaje:
Hello, long!Sada su se dogodile dve konverzije tipova — 'a' se, nakon što je pretvoren u ceo broj 97, dalje pretvara u tip long 97L, što odgovara preopterećenoj metodi čiji je tip parametra long. Ako nastavimo da komentarišemo metodu sayHello(long arg), izlaz postaje:
Hello, Character!Sada se dogodilo jedno automatsko boksovanje — 'a' je pakovan u svoj omotački tip java.lang.Character, pa je pronađena preopterećena metoda tipa Character. Ako nastavimo da komentarišemo metodu sayHello(Character arg), izlaz postaje:
Hello, Serializable!Izlaz je ovde "Hello, Serializable!" zato što je java.lang.Serializable interfejs koji implementira klasa java.lang.Character; kada se nakon automatskog boksovanja utvrdi da klasa boksovanja i dalje ne može biti pronađena, ali je pronađen tip interfejsa koji ona implementira, neposredno zatim dešava se još jedna automatska konverzija. Tip char može se pretvoriti u int, ali Character se nikada neće pretvoriti u Integer — može se bezbedno pretvoriti samo u interfejs ili roditeljsku klasu koju implementira. Character implementira i još jedan interfejs — java.lang.Comparable; ako bi istovremeno postojale dve preopterećene metode sa parametrima Serializable i Comparable, njihov prioritet u tom trenutku bio bi isti. Kompajler ne može utvrditi u koji tip treba izvršiti automatsku konverziju, pa će prijaviti dvosmislenost tipa i odbiti kompajliranje. Program u trenutku poziva mora izričito navesti statički tip literala, na primer: sayHello((Comparable) 'a'), da bi se kompajliranje uspelo. Ako nastavimo da komentarišemo metodu sayHello(Serializable arg), izlaz postaje:
Hello, Object!Sada se char, nakon boksovanja, pretvara u roditeljsku klasu; ako postoji više roditeljskih klasa, pretraga će krenuti uzlanicu nasleđivanja od dna, pri čemu je prioritet niži što je klasa bliža vrhu. Čak i kada je vrednost argumenta poziva metode null, ovo pravilo i dalje važi. Ako nastavimo da komentarišemo metodu sayHello(Serializable arg), izlaz postaje:
Hello, char...!Od 7 preopterećenih metoda ostala je samo jedna; očigledno je prioritet preopterećenja sa promenljivim brojem argumenata najniži, a u tom trenutku se znak 'a' tretira kao element niza.
Niz gore opisanih procesa demonstrira proces biranja cilja statičkog dispača tokom kompajliranja; taj proces je ujedno i suština na kojoj Java jezik ostvaruje preopterećenje metoda.
Dinamički dispač
Dinamički dispač je usko povezan sa "preklapanjem (Override)", još jednim važnim iskazom polimorfizma. I dalje ćemo kroz kod razumeti šta je dinamički dispač.
/**
* Demo dinamičkog dispača metoda
*
* @author baronzhang
*/
public class DynamicDispatch {
static abstract class Human {
abstract void sayHello();
}
static class Man extends Human {
@Override
void sayHello() {
System.out.println("Man say hello!");
}
}
static class Woman extends Human {
@Override
void sayHello() {
System.out.println("Woman say hello!");
}
}
public static void main(String[] args){
Human man = new Man();
Human woman = new Woman();
man.sayHello();
woman.sayHello();
man = new Woman();
man.sayHello();
}
}Rezultat izvršavanja koda:
Man say hello!
Woman say hello!
Woman say hello!Za gorenavedeni kod — kako virtuelna mašina utvrđuje koju metodu treba pozvati? Očigledno se to ovde više ne odlučuje na osnovu statičkog tipa, jer dve promenljive istog statičkog tipa Human, man i woman, pri pozivu metode sayHello() izvršavaju različito ponašanje, a promenljiva man u dva poziva izvršava različite metode. Razlog za ovaj rezultat jeste njihov različit stvarni tip. Kako tačno virtuelna mašina na osnovu stvarnog tipa dispačira verziju izvršavanja metode — o tome ovde nećemo govoriti; zainteresovani mogu pogledati original.
Dispač koji u toku rada, na osnovu stvarnog tipa, utvrđuje verziju izvršavanja metode nazivamo dinamički dispač.
Jednostruki i višestruki dispač
Primalac metode i argumenti metode zajednički se nazivaju varijablama metode; ta definicija potiče iz knjige "Java i obrasci". Na osnovu toga koliko varijabli dispač koristi, dispač se može podeliti na jednostruki dispač i višestruki dispač.
Jednostruki dispač određuje verziju izvršavanja metode na osnovu jedne varijable; višestruki dispač određuje verziju izvršavanja metode na osnovu više od jedne varijable.
I dalje ćemo kroz kod razumeti (kod je zasnovan na čuvenom 3Q ratu kao pozadini):
/**
* Demo jednostrukog i višestrukog dispača
*
* @author baronzhang
*/
public class Dispatch {
static class QQ { }
static class QiHu360 { }
static class Father {
public void hardChoice(QQ qq) {
System.out.println("Father choice QQ!");
}
public void hardChoice(QiHu360 qiHu360) {
System.out.println("Father choice 360!");
}
}
static class Son extends Father {
@Override
public void hardChoice(QQ qq) {
System.out.println("Son choice QQ!");
}
@Override
public void hardChoice(QiHu360 qiHu360) {
System.out.println("Son choice 360!");
}
}
public static void main(String[] args) {
Father father = new Father();
Father son = new Son();
father.hardChoice(new QQ());
son.hardChoice(new QiHu360());
}
}Rezultat izlaza koda:
Father choice QQ!
Son choice 360!Hajde prvo da pogledamo proces izbora kompajlera u fazi kompajliranja, odnosno proces statičkog dispača. U tom trenutku izbor ciljne metode zasniva se na dve tačke: prvo, da li je statički tip Father ili Son; drugo, da li je argument metode QQ ili QiHu360. Pošto se izbor zasniva na dve varijable, statički dispač Java jezika spada u višestruki dispač.
A sada da pogledamo proces izbora virtuelne mašine u fazi rada, odnosno proces dinamičkog dispača. Pri izvršavanju son.hardChoice(new QiHu360()), pošto je već u vreme kompajliranja utvrđeno da potpis ciljne metode mora biti hardChoice(QiHu360), u ovom trenutku statički i stvarni tip argumenta neće imati nikakav uticaj na izbor metode; jedini faktor koji može uticati na izbor virtuelne mašine jeste da li je stvarni tip primaoca te metode Father ili Son. Pošto se izbor zasniva na samo jednoj varijabli, dinamički dispač Java jezika spada u jednostruki dispač.
Iz gore navedenog, Java jezik je jezik statičkog višestrukog dispača i dinamičkog jednostrukog dispača.
III. Izvršni engine za bytecode zasnovan na steku, koji radi interpretirano
Već je objašnjeno kako virtuelna mašina poziva metode; u nastavku ćemo videti kako virtuelna mašina izvršava bytecode instrukcije unutar metode.
Interpretirano izvršavanje
Java se često definise kao jezik "interpretiranog izvršavanja", ali sa pojavom JIT-a i kompajlera koji Java kod mogu direktno prevesti u nativni kod, ta tvrdnja više nije tačna. Tek kada se utvrdi o kojoj se konkretnoj verziji Java implementacije i režimu rada izvršnog engine-a radi, razgovor o interpretiranom naspram kompajliranog izvršavanja postaje precizniji.
Bilo da je u pitanju interpretirano ili kompajlirano izvršavanje, bilo da se radi o fizičkoj ili virtuelnoj mašini, mašina — za aplikaciju — ne može kao čovek da čita, razume i zatim stekne sposobnost izvršavanja. Većina programskog koda, pre nego što se pretvori u ciljni kod fizičke mašine ili instrukcije koje izvršava virtuelna mašina, mora proći kroz korake prikazane na dijagramu. Donja grana na dijagramu jeste proces generisanja ciljnog mašinskog koda iz programskog koda u okviru tradicionalne teorije kompajliranja; srednja grana je proces interpretiranog izvršavanja.
Danas se jezici zasnovani na fizičkim mašinama, Java virtuelnim mašinama ili drugim virtuelnim mašinama višeg nivoa koji nisu Java, uglavnom drže ovog pristupa zasnovanog na savremenoj teoriji kompajliranja: pre izvršavanja se nad izvornim kodom vrše leksička i sintaksna analiza i izvorni kod se pretvara u apstraktno sintaksno stablo. Za implementaciju jednog konkretnog jezika, leksička analiza, sintaksna analiza, pa čak i kasniji optimizator i generator ciljnog koda mogu se izabrati da budu nezavisni od izvršnog engine-a, čineći kompajler u punom smislu te reči — takav predstavnik je C/C++. Takođe može biti polunezavisni kompajler — predstavnik je Java. Ili se svi ti koraci zajedno sa izvršavanjem mogu zatvoriti u jednu zatvorenu crnu kutiju, kao kod većine JavaScript izvršnih engine-a.
U Java jeziku, Javac kompajler obavlja proces od programskog koda, preko leksičke i sintaksne analize do apstraktnog sintaksnog stabla, a zatim obilaskom sintaksnog stabla generisanja toka bytecode instrukcija. Pošto se ovaj deo odvija izvan Java virtuelne mašine, dok je interpretator unutar virtuelne mašine, kompajliranje Java programa jeste polunezavisna implementacija.
Mnogi izvršni engine-i Java virtuelnih mašina, prilikom izvršavanja Java koda, imaju dve mogućnosti — interpretirano (preko interpretatora) i kompajlirano (preko just-in-time kompajlera koji stvara nativni kod) izvršavanje. Režim izvršavanja u najnovijim Android verzijama jeste AOT + JIT + interpretirano izvršavanje; o tome ćemo, bude li prilike, razgovarati kasnije.
Skup instrukcija zasnovan na steku i skup instrukcija zasnovan na registrima
Tok instrukcija koje isporučuje Java kompajler u osnovi je arhitektura skupa instrukcija zasnovanog na steku. Glavna prednost skupa instrukcija zasnovanog na steku jeste prenosivost — registre obezbeđuje direktno hardver, pa ako program direktno zavisi od tih hardverskih registara, neizbežno će biti podložan hardverskim ograničenjima. Skup instrukcija zasnovan na steku ima i druge prednosti, kao što je relativno kompaktniji (u bytecode-u svaki bajt odgovara jednoj instrukciji, dok u višeadresnim skupovima instrukcija treba čuvati i argumente), da je implementacija kompajliranja jednostavnija (ne razmatra se problem dodele prostora — sav rad se obavlja na steku) itd.
Glavni nedostatak skupa instrukcija zasnovanog na steku jeste što je brzina izvršavanja relativno nešto sporija. I činjenica da su skupovi instrukcija svih glavnih fizičkih mašina zasnovani na registrima to indirektno potvrđuje.
Iako je kod skupa instrukcija zasnovanog na steku veoma kompaktan, broj instrukcija potrebnih za istu funkciju obično je veći nego kod arhitekture zasnovane na registrima, jer operacije pop-a i push-a same po sebi stvaraju prilično mnogo instrukcija. Još važnije, stek je implementiran u memoriji, pa učestali pristup steku znači i učestali pristup memoriji — a u odnosu na procesor, memorija je uvek usko grlo brzine izvršavanja. Zbog broja instrukcija i pristupa memoriji, brzina izvršavanja skupa instrukcija zasnovanog na steku je relativno sporija.
Baš iz gorenavedenih razloga, u Android virtuelnoj mašini usvojen je skup instrukcija zasnovan na registrima. Ipak, postoji jedna razlika: gore je reč o registrima na fizičkoj mašini, dok se na Androidu misli na registre virtuelne mašine.
Referentni link: https://juejin.cn/post/6844903871010045960
