10 ručnih crteža i 8000 reči za duboko razumevanje JIT-a (Just-In-Time kompilatora)
Kao što smo već objasnili, da bi se poboljšale performanse Java programa tokom izvršavanja, JVM je uveo JIT, odnosno Just-In-Time kompilaciju.

Java kod se prvo kompajlira u bajtkod, koji JVM tokom izvršavanja interpretira. Kada se neki deo koda često izvršava, JIT će te „vruće" kodove (hotspot) kompajlirati u mašinski kod, čime se povećava efikasnost izvršavanja programa.

Zašto JIT zapravo povećava efikasnost izvršavanja? Zar i interpreter ne prevodi bajtkod u mašinski kod koji operativni sistem izvršava?
Kada interpreter izvršava program, za svaku instrukciju bajtkoda mora da obavi proces interpretacije, a zatim izvrši odgovarajuću mašinsku instrukciju. Taj proces se ponavlja pri svakom izvršavanju, jer interpreter ne pamti prethodne rezultate interpretacije.
Nasuprot tome, JIT kompajlira često izvršavani bajtkod u mašinski kod. Taj proces se dešava samo jednom. Kada jednom bajtkod bude kompajliran u mašinski kod, pri svakom sledećem izvršavanju tog dela koda se direktno izvršava odgovarajući mašinski kod, bez ponovne interpretacije.
Pored toga, mašinski kod koji JIT generiše je bliži hardverskom nivou, pa efikasnije koristi resurse poput CPU-a i memorije. JIT takođe može tokom izvršavanja da optimizuje kod na osnovu stvarnih okolnosti (npr. inlining, odmotavanje petlji, optimizacija predviđanja grana); te optimizacije se vrše na nivou mašinskog koda i mogu značajno da poboljšaju efikasnost izvršavanja.
Drugim rečima, interpreter je neko ko se strogo drži pravila i svaki put postupa po propisu, dok je JIT neko ko „traži prečice" i na osnovu stvarne situacije bira optimalno rešenje.
Dobro, da još jednom sumiramo.
Proces izvršavanja Jave se deli na dva koraka. U prvom koraku, javac kompajlira izvorni kod u bajtkod; tokom tog procesa se obavlja leksička, sintaksna i semantička analiza.
U drugom koraku, interpreter red po red interpretira i izvršava bajtkod. Tokom interpretacije, JVM prikuplja informacije o radu programa, a na osnovu tih informacija JIT postepeno počinje da deluje — kompajlira bajtkod u mašinski kod. Međutim, ne kompajlira se sav kod, već samo onaj koji JVM prepozna kao vrući kod (hotspot).
Kada se kod smatra vrućim?
U JVM-u postoji prag: kada broj poziva metode ili bloka koda u određenom vremenskom periodu premaši taj prag, taj kod se proglašava vrućim, zatim se kompajlira i pohranjuje u codeCache. Pri sledećem izvršavanju, kada se ponovo naiđe na taj kod, mašinski kod se direktno čita iz codeCache-a i izvršava, čime se poboljšavaju performanse programa.
Celokupan proces izvršavanja grubo je prikazan na sledećoj slici:

Ovaj codeCache me podseća na Redis — i Redis čuva vruće podatke u memoriji kako bi ubrzao pristup.
OK, sada kada smo objasnili princip JIT-a, pogledajmo njegovu implementaciju.
JVM kompilatori
U JVM su integrisana dva kompilatora: jedan je Client Compiler, a drugi je Server Compiler.
Client Compiler se fokusira na brzinu pokretanja i lokalnu optimizaciju, dok Server Compiler više pažnje posvećuje globalnoj optimizaciji i ima bolje performanse; pošto obavlja opsežniju globalnu analizu, vreme pokretanja je nešto duže.
Ta dva kompilatora se međusobno dopunjuju i zajedno podižu performanse JVM-a na novi nivo.
Client Compiler
Uzmimo za primer HotSpot, „krunskog naslednika" virtuelnih mašina — on dolazi sa Client Compiler-om poznatim kao C1 kompilator, čije je vreme pokretanja izuzetno kratko.
C1 obično radi ove tri stvari:
①,Lokalna, jednostavna i pouzdana optimizacija, na primer osnovne optimizacije na nivou bajtkoda — inlining metoda, propagacija konstanti itd.
Hajde da na primeru pokažemo šta je inlining metoda. Pretpostavimo da imamo dve jednostavne metode:
public class Example {
public int add(int a, int b) {
return a + b;
}
public void run() {
int result = add(5, 3);
System.out.println(result);
}
}Prilikom izvršavanja metode run poziva se metoda add. Nakon optimizacije inlining-om, bajtkod metode add se direktno ubacuje u metodu run, tako da više nije potrebno pozivati add — dovoljno je izvršiti samo metodu run.
public class Example {
public void run() {
int a = 5;
int b = 3;
int result = a + b; // ovo je telo add metode nakon inlining-a
System.out.println(result);
}
}②,Kompajliranje bajtkoda u HIR (High-level Intermediate Representation). Ne zamaramo se prevodom imena na srpski; umesto doslovnog prevoda, jednostavno zapamtite da se zove HIR — oblik koji je prilično blizak izvornom kodu.
Pomoću HIR-a možemo ostvariti optimizacije poput eliminacije redundantnog koda, uklanjanja mrtvog koda itd. Pogledajmo i to na primeru koda.
public class OptimizationExample {
public int calculate(int x, int y) {
int a = x + y;
int b = x + y; // redundantna računica
return a * b;
}
}Očigledno, vrednost b se može direktno izračunati iz vrednosti a, pa je računanje b redundantno. Pomoću HIR-a možemo eliminisati tu redundantnu računicu.
public class OptimizationExample {
public int calculate(int x, int y) {
int a = x + y;
return a * a; // koristi se jedan rezultat računanja
}
}U fazi HIR optimizacije, kompilator prepoznaje da je računanje x + y redundantno, pa drugi rezultat zamenjuje prvim.
③,Na kraju se HIR prevodi u LIR (Low-level Intermediate Representation), koji je već prilično blizak mašinskom kodu. Tokom tog procesa obavljaju se dodela registara, peephole optimizacija i sl.
Dodela registara znači da se prilikom kompajliranja promenljive iz programa dodeljuju registrima CPU-a. Pošto je pristup registrima znatno brži od pristupa memoriji, razumna dodela registara može značajno da poboljša efikasnost izvršavanja programa.
Pogledajmo ovaj kod:
int a = 5;
int b = 10;
int c = a + b;
System.out.println(c);Bez optimizacije registara, kompilator bi promenljive a, b i c dodelio memoriji, a prilikom izvršavanja bi njihove vrednosti čitao iz memorije. Šta se dešava sa optimizacijom dodele registara?
R1 = 5 // dodeljuje 5 registru R1
R2 = 10 // dodeljuje 10 registru R2
R3 = R1 + R2 // zbir R1 i R2 dodeljuje registru R3Tako se promenljive a, b i c nalaze u registrima R1, R2 i R3 umesto u memoriji. Pošto je pristup registrima znatno brži od pristupa memoriji, ova optimizacija povećava efikasnost izvršavanja.
Peephole optimizacija (Peephole Optimization) je tehnika lokalne optimizacije koja se primenjuje u fazi generisanja mašinskog koda. Kompilator „zaviri" u mali isečak generisanog mašinskog koda i pokušava da pronađe i zameni efikasnije sekvence instrukcija.
Pretpostavimo da imamo sledeći jednostavan mašinski kod:
MOV R1, 0
ADD R1, 5Ovaj kod prvo postavlja registar R1 na nulu, a zatim dodaje 5 na R1. Peephole optimizacija će ove dve instrukcije spojiti u jednu:
MOV R1, 5Tako se ista operacija obavlja samo jednom instrukcijom, što očigledno povećava efikasnost izvršavanja koda.
Server Compiler
Server Compiler se fokusira na globalne optimizacije koje zahtevaju duže vreme kompajliranja, pa čak i na osnovu informacija o radu programa sprovodi neke agresivnije optimizacije. Vreme pokretanja ovakvog kompilatora je duže, te je pogodan za pozadinske programe koji dugo rade; njegove performanse su obično za preko 30% bolje od Client Compiler-a. Trenutno se u HotSpot virtuelnoj mašini koriste dva Server Compiler-a: C2 i Graal.
C2 Compiler
U HotSpot-u je podrazumevani Server Compiler upravo C2 kompilator.
Prilikom optimizacije kompajliranja, C2 kompilator koristi grafovnu strukturu koja kombinuje tok kontrole i tok podataka, poznatu kao Ideal Graph — mogli bismo je nazvati „grafom ideala".
Ideal Graph predstavlja tok podataka trenutnog programa i zavisnosti između instrukcija. Zahvaljujući toj strukturi grafa, neki koraci optimizacije (posebno oni koji obuhvataju „plutajuće" blokove koda) postaju manje složeni.

Prilikom raščlanjivanja bajtkoda, C2 dodaje čvorove u prazan Graph. Čvorovi u Graph-u obično odgovaraju bloku instrukcija, a svaki blok sadrži više međusobno povezanih instrukcija. JVM primenjuje tehnike optimizacije kao što su Global Value Numbering, sažimanje konstanti itd., a nakon raščlanjivanja obavlja se i uklanjanje mrtvog koda.
Nakon generisanja Ideal Graph-a, na toj osnovi se, u kombinaciji sa prikupljenim informacijama o radu programa, sprovode neke globalne optimizacije.
Bilo da se radi globalna optimizacija ili ne, Ideal Graph se na kraju prevodi u MachNode Graph, koji je još bliži mašinskom nivou, i iz njega se dobija konačni mašinski kod.
Graal Compiler
Počevši od JDK-a 9, u HotSpot je integrisan novi Server Compiler — Graal kompilator. U poređenju sa C2, Graal ima nekoliko ključnih odlika:
①,JVM tokom interpretacije prikuplja razne informacije o radu programa, a zatim na osnovu njih sprovodi neke predviđanjem vođene agresivne optimizacije, poput predviđanja grana: na osnovu verovatnoće izvršavanja različitih grana programa, selektivno se kompajliraju grane veće verovatnoće. Graal je naklonjeniji takvim optimizacijama od C2, pa je njegova vršna performansa obično bolja od C2.
②,Za razliku od C2 (koji je uglavnom pisan u C++-u), Graal je napisan u Javi. Prednost toga je što Graal može direktno da koristi mehanizam upravljanja memorijom JVM-a i ne mora sam da implementira upravljanje memorijom kao C2, čime se izbegaju neki problemi u tom pogledu.
③,Graal uvodi mnoge savremene tehnike optimizacije kompajliranja, kao što su složenije strategije inlining-a, optimizacije petlji itd., koje u određenim situacijama mogu proizvesti bolje optimizovan kod od C2.
④,Poboljšana analiza bekstva (escape analysis) pomaže u boljoj dodeli na steku (stack allocation) i eliminaciji brava, čime se poboljšavaju performanse.
⑤,Graal se ne može koristiti samo kao JIT kompilator, već podržava i Ahead-of-Time (AOT) kompajliranje, što pomaže u smanjenju vremena pokretanja i zauzeća memorije Java aplikacija.
Graal kompilator se može omogućiti JVM parametrima -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler. Kada je omogućen, zamenjuje C2 u HotSpot-u i odgovara na zahteve za kompajliranje koje je inače obavljao C2.
Tiered kompilacija JVM-a
Java 7 je uvela koncept tiered kompilacije (slojevitog kompajliranja), koja kombinuje prednosti C1 i C2 i teži ravnoteži između brzine pokretanja i vršnih performansi. Tiered kompilacija deli stanje izvršavanja JVM-a na pet nivoa. Tih pet nivoa je:
Tiered kompilacija uvedena u Javi 7 zaista je tehnika koja kombinuje prednosti C1 kompilatora (Client Compiler) i C2 kompilatora (Server Compiler). Ona ima za cilj da optimizuje brzinu pokretanja Java programa i performanse pri dugotrajnom radu. Ovaj mehanizam primenjuje različite strategije kompajliranja na različitim nivoima kako bi postigao ravnotežu između brzog pokretanja i najboljih performansi. U HotSpot JVM-u, tiered kompilacija deli stanje izvršavanja programa na pet nivoa:
Nivo 0 — Interpreter: ovo je početna faza izvršavanja programa, u kojoj se kod red po red interpretira. Cilj ove faze je da se što pre započne izvršavanje, bez čekanja na kraj kompajliranja.
Nivo 1 — C1 kompilator sa laganim optimizacijama (C1 with Simple Optimizations): na ovom nivou kod prvi put kompajlira C1 kompilator, uz primenu osnovnih optimizacija poput inlining-a metoda. Kompajliranje u ovoj fazi je relativno brzo i brzo pruža performanse bolje od interpretacije.
Nivo 2 — C1 kompilator sa punim optimizacijama (C1 with Full Optimizations): ovim nivoom i dalje upravlja C1 kompilator, ali se primenjuje više tehnika optimizacije, poput analize bekstva. Iako te optimizacije zahtevaju duže vreme kompajliranja, mogu dodatno da poboljšaju performanse pri radu.
Nivo 3 — C1 kompilator sa prikupljanjem profiling podataka (C1 with Profiling): na ovom nivou C1 kompilator, pored izvršavanja optimizacija, prikuplja i detaljne analitičke podatke o izvršavanju metoda (poput učestalosti grana, vrućih kodova itd.). Ti podaci će se koristiti za naknadne optimizacije C2 kompilatora.
Nivo 4 — optimizacije C2 kompilatora (C2 Optimizations): u završnoj fazi radi C2 kompilator, koji primenjuje prikupljene analitičke podatke za dubinske optimizacije. Optimizacije C2 kompilatora su temeljnije i složenije, pogodne za kod koji dugo radi, i pružaju najbolje performanse izvršavanja.
Na sledećoj slici su prikazane neke uobičajene putanje kompajliranja:

Putanja ① na slici predstavlja uobičajenu situaciju: vruća metoda od interpretacije prelazi na C1 kompajliranje na nivou 3, a zatim na C2 kompajliranje na nivou 4.
Ako je metoda mala (npr. getter/setter), nivo 3 ne prikupi vredne profiling podatke, pa će JVM oceniti da je efikasnost izvršavanja iste za C1 i C2 kod — tada se prolazi putanjom ②.
U tom slučaju, nakon kompajliranja na nivou 3, JVM odustaje od prelaska na C2 i direktno bira C1 kompajliranje na nivou 1.
Ako je C1 zauzet, prolazi se putanjom ③: tokom interpretacije se obavlja profiling programa, pa se na osnovu tih informacija direktno prelazi na C2 kompajliranje na nivou 4.
Efikasnost unutar C1 opada redom: nivo 1 > nivo 2 > nivo 3, pri čemu je nivo 3 obično za preko 35% sporiji od nivoa 2. Zato se, kada je C2 zauzet, prolazi putanjom ④. Tada se metoda prvo kompajlira na nivou 2, a zatim na nivou 3, kako bi se smanjilo vreme izvršavanja metode na nivou 3.
Ako kompilator sprovede neke agresivnije optimizacije, poput predviđanja grana, a tokom stvarnog izvršavanja predviđanje se pokaže pogrešnim, tada se vrši deoptimizacija i ponovno se prelazi na interpretaciju. Putanja ⑤ na slici predstavlja baš tu deoptimizaciju.
Tiered kompilacija primenjuje različite stepene optimizacije u različitim fazama, čime obezbeđuje i brže vreme pokretanja aplikacije i vršne performanse pri dugotrajnom radu. Ova dinamički prilagodljiva strategija kompajliranja jedan je od ključnih mehanizama kojima Java platforma kontinuirano poboljšava performanse.
Od JDK-a 8, JVM podrazumevano uključuje tiered kompilaciju.
Okidači za JIT
JVM pokreće JIT na osnovu broja poziva metode i broja izvršavanja povratnih grana petlje (loop back-edge).
Povratna grana petlje je pojam iz grafa toka kontrole; u programu se jednostavno može shvatiti kao instrukcija koja skače unazad, na primer u sledećem kodu:
public void nlp(Object obj) {
int sum = 0;
for (int i = 0; i < 200; i++) {
sum += i;
}
}Nakon kompajliranja, gornji kod generiše sledeći bajtkod.
public void nlp(java.lang.Object);
Code:
0: iconst_0
1: istore_1
2: iconst_0
3: istore_2
4: iload_2
5: sipush 200
8: if_icmpge 21
11: iload_1
12: iload_2
13: iadd
14: istore_1
15: iinc 2, 1
18: goto 4
21: returnBajtkod na ofsetu 18 skače unazad do bajtkoda na ofsetu 4. Tokom interpretacije, svaki put kada se izvrši ta instrukcija, JVM uvećava brojač povratnih grana petlje te metode za 1.
Tokom Just-In-Time kompajliranja, kompilator prepoznaje početak i kraj petlje. U gornjem bajtkodu, početak i kraj tela petlje su bajtkodovi na ofsetima 11 odnosno 15. Kompilator će na kraju tela petlje dodati kod za uvećanje brojača povratnih grana petlje, čime se broje prolasci kroz petlju.
Kada zbir broja poziva metode i broja povratnih grana petlje premaši prag zadat parametrom -XX:CompileThreshold, pokreće se Just-In-Time kompajliranje.
Podrazumevana vrednost za C1 je 1500; za C2 je 10000.
Kada je tiered kompilacija uključena, prag koji postavlja parametar -XX:CompileThreshold prestaje da važi; pokretanje JIT kompajliranja tada određuju sledeći uslovi:
- Broj poziva metode veći od praga zadatog parametrom
-XX:TierXInvocationThreshold, pomnoženog koeficijentom. - Broj poziva metode veći od praga zadatog parametrom
-XX:TierXMINInvocationThreshold, pomnoženog koeficijentom, i zbir broja poziva metode i broja povratnih grana petlje veći od praga zadatog parametrom-XX:TierXCompileThreshold, pomnoženog koeficijentom.
Formula uslova za tiered kompilaciju (i je broj poziva, b je broj povratnih grana petlje, s je koeficijent):
i > TierXInvocationThreshold * s || (i > TierXMinInvocationThreshold * s && i + b > TierXCompileThreshold * s)Dovoljno je da je ispunjen jedan od uslova da bi se pokrenulo Just-In-Time kompajliranje, pri čemu JVM dinamički podešava koeficijent s na osnovu trenutnog broja metoda koje se kompajliraju i broja niti za kompajliranje.
Optimizacije JIT kompilacije
Just-In-Time kompilator sprovodi niz optimizacija nad programom koji se izvršava, uključujući:
- analizu tokom raščlanjivanja bajtkoda
- lokalne optimizacije na osnovu nekih međuoblika koda tokom kompajliranja
- globalne optimizacije na osnovu grafa zavisnosti programa
Tek nakon toga se generiše mašinski kod.
Međuoblik (Intermediate Representation)
U teoriji kompajliranja, kompilator se obično deli na front-end i back-end. Front-end, nakon leksičke, sintaksne i semantičke analize, generiše međuoblik IR (Intermediate Representation), dok back-end optimizuje IR i generiše ciljni kod.
Bajtkod Jave je jedna vrsta IR-a, ali je struktura bajtkoda složena i nije pogodna za globalnu analizu i optimizaciju.
Savremeni kompilatori uglavnom koriste IR u obliku grafa, tzv. Static Single Assignment (statičko jednostruko dodeljivanje) — SSA je trenutno jedan od najčešće korišćenih oblika IR-a. Odlika ovog IR-a je da se svaka promenljiva može dodeliti samo jednom, i da se promenljiva može koristiti tek nakon što joj se dodeli vrednost.
Na primer (već smo pomenuli, ali ovde ponavljamo radi naglaska):
{
a = 1;
a = 2;
b = a;
}Lako primećujemo da je dodela a = 1 redundantna. Tradicionalni kompilator mora da se posluži analizom toka podataka i sleva nadesno proverava koje su vrednosti promenljivih preklopljene. Međutim, uz pomoć SSA IR-a, kompilator lako prepoznaje redundantne dodele.
Pseudokod prethodnog koda u SSA IR obliku može se prikazati kao:
{
a_1 = 1;
a_2 = 2;
b_1 = a_2;
}Pošto se u SSA IR-u svaka promenljiva dodeljuje samo jednom, promenljiva a iz koda se u SSA IR-u deli u dve promenljive, a_1 i a_2. Tako kompilator lako može, skeniranjem tih promenljivih, da uoči da se a_1 nakon dodele nigde ne koristi, pa zaključuje da je ta dodela redundantna.
Pored toga, SSA IR znatno pomaže i drugim vrstama optimizacija, kao u sledećem primeru uklanjanja mrtvog koda (Dead Code Elimination):
public void DeadCodeElimination{
int a = 2;
int b = 0
if(2 > 1){
a = 1;
} else{
b = 2;
}
add(a,b)
}Dobija se SSA IR pseudokod:
a_1 = 2;
b_1 = 0
if true:
a_2 = 1;
else
b_2 = 2;
add(a,b)Kompilator, izvršavajući bajtkod, može da uoči da se else grana neće izvršiti. Nakon uklanjanja mrtvog koda dobija se sledeći kod:
public void DeadCodeElimination{
int a = 1;
int b = 0;
add(a,b)
}Svaku optimizaciju kompilatora možemo posmatrati kao algoritam za optimizaciju grafa: on prima jedan IR graf i izdaje transformisani IR graf. Proces optimizacije kompilatora čine takva optimizacija čvorova grafa, povezana u niz.
C1-ov HIR
Ranije je pomenuto da C1 kompilator interno koristi viši međuoblik HIR i niži međuoblik LIR za razne optimizacije, i da su oba IR-a u SSA obliku.
HIR je struktura grafa toka kontrole sastavljena iz više osnovnih blokova (Basic Block), gde svaki blok sadrži mnogo instrukcija u SSA obliku. Struktura osnovnog bloka prikazana je na sledećoj slici:

U njoj predecessors označava prethodne osnovne blokove; pošto ih može biti više, to je BlockList struktura — proširivi niz sastavljen iz više BlockBegin čvorova.
Slično tome, successors označava više narednih osnovnih blokova, BlockEnd.
Pored ta dva dela, preostaje glavni blok, koji sadrži instrukcije koje program izvršava i jedan next pokazivač koji ukazuje na sledeći glavni blok za izvršavanje.
Konstrukcija HIR-a iz bajtkoda se na kraju obavlja pozivima GraphBuilder-a. GraphBuilder obilazi bajtkod i sve osnovne blokove koda skladišti kao strukturu povezane liste, ali u tom trenutku osnovni blokovi imaju samo BlockBegin, bez konkretnih instrukcija.
U drugom koraku GraphBuilder koristi ValueStack kao stek operanada i tablicu lokalnih promenljivih, simulira izvršavanje bajtkoda, konstruiše odgovarajući HIR i popunjava prethodno prazne osnovne blokove. Evo primera procesa konstrukcije HIR-a za jednostavan blok bajtkoda:
bajtkod Local Value operand stack HIR
5: iload_1 [i1,i2] [i1]
6: iload_2 [i1,i2] [i1,i2]
................................................ i3: i1 * i2
7: imul
8: istore_3 [i1,i2,i3] [i3]Vidi se da se pri izvršenju iload_1 na stek operanada potiskuje promenljiva i1; pri iload_2 potiskuje se i2; pri instrukciji množenja imul se skidaju dve vršne vrednosti sa steka i konstruiše se HIR i3 : i1 * i2, a zatim se generisani i3 potiskuje na stek.
Većina posla optimizacije u C1 kompilatoru obavlja se na HIR-u. Kada se optimizacija završi, HIR se prevodi u LIR. LIR je, slično kao HIR, takođe interna IR koje koristi kompilator; HIR se, uz optimizaciju i uklanjanje nekih međučvorova, pretvara u LIR, koji je po obliku jednostavniji.
C2-ov Sea-of-Nodes IR
Ideal Graph u C2 kompilatoru koristi međuoblik poznat kao Sea-of-Nodes, takođe u SSA obliku.
Njegova najveća odlika je da ukida pojam promenljivih i direktno radi operacije nad vrednostima. Radi lakšeg razumevanja, može se iskoristiti alat za vizualizaciju IR-a Ideal Graph Visualizer (IGV) koji prikazuje konkretan IR graf. Na primer, za sledeći kod:
public static int foo(int count) {
int sum = 0;
for (int i = 0; i < count; i++) {
sum += i;
}
return sum;
}Odgovarajući IR graf je prikazan ispod:

U osnovnom bloku B0, čvor broj 0 (Start) jeste ulaz u metodu, dok je u B3 čvor broj 21 (Return) izlaz iz metode.
Crvene, podebljane linije predstavljaju tok kontrole, plave linije tok podataka, a linije drugih boja su posebni tokovi kontrole ili podataka.
Čvorovi koje povezuje tok kontrole su fiksni čvorovi, dok su ostali plutajući čvorovi.
Zahvaljujući toj strukturi grafa, prikupljajući informacije o radu programa, JVM može da rasporedi (Schedule) te plutajuće čvorove i tako postigne najbolji efekat kompajliranja.
Inlining metoda
Pogledajmo sledeći kod:
public static boolean flag = true;
public static int value0 = 0;
public static int value1 = 1;
public static int foo(int value) {
int result = bar(flag);
if (result != 0) {
return result;
} else {
return value;
}
}
public static int bar(boolean flag) {
return flag ? value0 : value1;
}Pogledajmo IR graf metode bar:

IR graf nakon inlining-a:

Inlining kopira čvorove IR grafa pozvane metode u IR graf metode koja poziva. U ovom primeru, Start čvor broj 0 iz IR grafa metode bar se kopira u IR graf metode foo, čime se izbegava trošak poziva metode.
Analiza bekstva (Escape Analysis)
Analiza bekstva je važna tehnika koju JIT koristi za optimizaciju upravljanja memorijom i sinhronizacije. Analizirajući da li objekat „bektne" izvan metode ili niti, kompilator može da donese pametnije odluke o skladištenju i sinhronizaciji.
Analiza bekstva se obično obavlja na osnovu inlining-a metoda; JIT može, na osnovu rezultata analize bekstva, sprovesti optimizacije poput eliminacije brava, dodele na steku i zamene skalarima.
Sledeći kod je primer objekta koji ne bekti:
public class Example {
public static void main(String[] args) {
example();
}
public static void example() {
Foo foo = new Foo();
Bar bar = new Bar();
bar.setFoo(foo);
}
}
class Foo {}
class Bar {
private Foo foo;
public void setFoo(Foo foo) {
this.foo = foo;
}
}U ovom primeru, metoda example kreira dva objekta: Foo i Bar. Zatim objekat Bar, putem metode setFoo, referencira objekat Foo.
Bekstvo objekta Foo:
- Objekat
Foose kreira i prosleđuje metodisetFooobjektaBar. - Kada se
setFoopozove, referenca na objekatFoose skladišti u promenljivoj instancefooobjektaBar. - Međutim, sam objekat
Barse nakon završetka metodeexampleviše ne koristi. - To znači da, iako je referenca na
Fooskladištena u drugom objektu, pošto ni sam objekatBarne bekti iz metodeexample, objekatFoozapravo takođe ne bekti.
- Objekat
Bekstvo objekta Bar:
- Objekat
Barse kreira i koristi u metodiexample, ali se nakon toga ne prosleđuje drugim metodama niti se vraća kao rezultat. - Dakle, ni objekat
Barne bekti iz metodeexample.
- Objekat
Na osnovu rezultata analize bekstva, JIT može doneti sledeće odluke o optimizaciji:
①,Eliminacija brava: ako u klasi Foo ili Bar postoje sinhronizovani blokovi (koji koriste synchronized), pošto objekti ne bektne, kompilator može bezbedno da ukloni te operacije zaključavanja.
②,Dodela na steku: pošto ni objekat Foo ni objekat Bar ne bektne izvan metode, kompilator može da izabere da oba objekta dodeli na steku umesto na hipu. Time se povećava efikasnost dodele memorije i smanjuje opterećenje sakupljača smeća.
Razliku između hipa i steka možete pogledati u: Memorijske oblasti podataka JVM-a
Znamo da se Java objekti dodeljuju na hipu, koji je vidljiv svim objektima. Istovremeno, JVM mora da upravlja dodeljenom memorijom na hipu i da, kada objekat više nije referenciran, oslobodi memoriju koju zauzima.
Ako analiza bekstva može da dokaže da neki novo kreirani objekat ne bekti, JVM ga sasvim može dodeliti na stek, a kada metoda u kojoj se nalazi naredba new izađe, oslobađanje te memorije se obavlja automatski, skidanjem okvira steka trenutne metode.
Tako nam ne treba sakupljač smeća da bi obradio objekte koji više nisu referencirani.
Međutim, HotSpot ne vrši stvarnu dodelu na steku, već koristi tehniku zamene skalarima.
③,Zamena skalarima (Scalar Replacement)
Zamena skalarima je tehnika optimizacije u kojoj kompilator razlaže agregatni objekat na njegova pojedinačna polja. Ako taj objekat ne bekti izvan metode, njegova polja se mogu posmatrati kao nezavisne lokalne promenljive.
Ova tehnika omogućava kompilatoru finije optimizacije, poput bolje dodele registara i smanjenja nepotrebnih dodela memorije.
Razmotrimo sledeći kod:
public class Example {
@AllArgsConstructor
static class Cat {
int age;
int weight;
}
public static void example() {
Cat cat = new Cat(1, 10);
addAgeAndWeight(cat.age, cat.weight);
}
public static void addAgeAndWeight(int age, int weight) {
// neke operacije nad godinama i težinom
}
public static void main(String[] args) {
example();
}
}Obim korišćenja objekta:
- U metodi
examplekreira se objekatCat, čija se poljaageiweightprosleđuju metodiaddAgeAndWeight. - Objekat
Catse kreira u metodiexamplei koristi se samo u njoj — ne prosleđuje se izvan metode niti se dodeljuje spoljnoj referenci.
- U metodi
Analiza bekstva:
- Pošto objekat
Catnema referenci izvan metode, on ne bekti iz opsega metodeexample. - To znači da je
Catlokalni objekat, pogodan za zamenu skalarima.
- Pošto objekat
Primenost zamene skalarima:
- JIT kompilator JVM-a analizira način korišćenja objekta
Cat. Na osnovu analize bekstva, kompilator može da odluči daCatne dodeljuje na hipu, već da ga razloži u dve nezavisne lokalne promenljiveageiweight. - Time se memorijski prostor na hipu koji bi zauzimao objekat
Catoslobođa, a smanjuje se i opterećenje sakupljanja smeća.
- JIT kompilator JVM-a analizira način korišćenja objekta
Izvršavanje nakon optimizacije:
- Prilikom izvršavanja metode
example, poljaageiweightobjektaCatse direktno tretiraju kao lokalne promenljive na steku, čime se izbegava dodela na hipu.
- Prilikom izvršavanja metode
Pseudokod nakon zamene skalarima izgleda ovako:
public class Example {
public static void example() {
int catAge = 1;
int catWeight = 10;
addAgeAndWeight(catAge, catWeight);
}
public static void addAgeAndWeight(int age, int weight) {
// implementacija metode
}
}Vidi se da je objekat Cat razložen u dve lokalne promenljive catAge i catWeight, koje se zatim direktno prosleđuju metodi addAgeAndWeight kao argumenti.
Peephole optimizacija i dodela registara
Ranije smo ukratko analizirali peephole optimizaciju i dodelu registara, pa pretpostavljam da ste sa ta dva pojma već upoznati. Ovde ih ukratko sumiramo.
Peephole optimizacija se svodi na to da se određene kombinacije u intermedijarnom kodu koji kompilator generiše zamenjuju efikasnijim grupama instrukcija, na primer smanjenje jačine operacija (strength reduction), spajanje konstanti itd. Sledeći primer je primer smanjenja jačine:
y1=x1*3
nakon smanjenja jačine dobijamo
y1=(x1<<1)+x1Kompilator pomoću pomeranja i sabiranja smanjuje jačinu množenja, koristeći efikasniju grupu instrukcija.
Dodela registara je takođe sredstvo optimizacije kompajliranja i široko se koristi u C2 kompilatoru. Sastoji se u tome da se često korišćene promenljive čuvaju u registrima; pošto CPU pristupa registrima znatno brže nego memoriji, time se povećava brzina rada programa.
Nakon dodele registara i peephole optimizacije, program se prevodi u mašinski kod i skladišti u codeCache.
Rezime
Ovaj članak je uglavnom predstavio princip Just-In-Time kompajliranja i proces optimizacije kompajliranja, uključujući:
- uslove za pokretanje JIT-a
- optimizacije JIT kompajliranja
- kompilatore JIT-a
JIT je važan sastavni deo JVM-a; on može, na osnovu stanja rada programa, da vrši optimizaciju kompajliranja vrućeg koda i time poboljša efikasnost izvršavanja programa.
Referentni link: Meituan tehnički članak
