Ați pățit vreodată să vă uitați la codul pe care l-ați scris cu ani în urmă, să vă dați seama în retrospectivă cât de neperformant e, cât de rău l-ați scris la vremea respectivă și să vă minunați că a mers acceptabil atâta vreme? Noi am pățit și nu de puține ori… După o vreme am început să ne punem problema oare ce ne salvează așa de des de propria prostie? O fi noroc? Improbabil... Și după mulți ani, am înțeles într-un final cine-i îngerul nostru păzitor. E JVM-ul, această mașinărie atât de minunată și deșteaptă, despre care noi, programatorii de Java știm de obicei atât de puțin.
Toți știm că JVM-ul, Mașina Virtuală Java este platforma care ne rulează programele, dar nu toți suntem conștienți că nu e doar un executor orb și că aplică o gamă largă de optimizări asupra codului înainte să-l ruleze. Majoritatea programatorilor Java nu sunt conștienți despre cum codul pe care îl scriu ei, nu este de fapt niciodată codul care rulează efectiv. Câți dintre noi se gândesc des la, compilare JIT, inlining de metode, eliminarea codului mort, escape analysis, loop unrolling, predicția branchurilor, eliminarea checkurilor de null și alte scamatorii care se întâmplă nevăzute în fiecare JVM de fiecare dată când ne rulăm aplicațiile.
Recunoaștem, nu e neapărat o problemă că majoritatea dintre noi nu au nevoie să știe de acești asistenți anonimi din fundal. Până la urmă, un mare atu al mediului Java e că mult mai mulți oameni îl pot folosi mult mai productiv. Dar și după ce afli de ele, nu mai poți să-i ignori și să nu fii recunoscător că există! Așa că haideți să le aducem un omagiu, să le mulțumim un pic că ne ușurează viața zi de zi.
Un pic de istorie. Primele versiuni de JVM au fost simple. Citeau byte-code-ul produs de compilatorul de Java (javac) și îl interpretau direct. Astfel, codul mașină care se rula efectiv era codul interpretorului iar codul "client" era oarecum doar inputul acestuia.
Pentru a îmbunătăți viteza în JDK 1.3, a fost introdusă compilarea Just-In-Time (JIT), care dădea JVM-ului capacitatea de a compila codul "client" (codul scris de programator) direct în cod mașină și nu numai. Fiind efectiv un compilator, are toate capacitățile oricărui compilator static, cum ar fi de exemplu, cel de C++. Deci poate face toate optimizările posibile când compilarea se întâmplă în avansul rulării. Fiind însă un compilator dinamic (adică rulează în timpul execuției) îi dă acces la tehnici de optimizare, care nu sunt posibile în cazul multor altor compilatoare.
Natura dinamică a compilării face în primul rând posibilă transformarea la cod mașină optimizat efectiv pentru arhitectura pe care tocmai rulează JVM-ul (același program Java va fi optimizat diferit în funcție de mediul care îl rulează).
JVM-ul își alocă timp (pe un thread din fundal, ca să nu întrerupă execuția) să urmărească atent care părți din cod sunt executate mai frecvent, ca să decidă ce merită optimizat și ce nu).
De asemenea, urmărește CUM este executat acel cod, ca să vadă cum poate optimiza și cum nu. De exemplu, are capacitatea de a observa că o anumită ramură de cod nu este parcursă niciodată și poate compila cod din care omite complet instrucțiunea de branch (tehnică numită "branch prediction"). Bineînțeles există un safety check (numit "uncommon trap") în codul compilat care trage alarma dacă pică asumpția, iar codul respectiv este imediat recompilat. Până se pregătește noua versiune compilată, codul se rulează prin interpretare. Optimizările, deși pot fi foarte agresive, nu compromit niciodată corectitudinea execuției.
Eliminarea branchurilor poate aduce îmbunătățiri mult mai mari de performanță decât ne-am gândi la o examinare superficială, pentru că branchurile pot sabota semnificativ pre-fetcherele de instrucțiuni ale procesoarelor moderne. Dacă branchul intră pe cealaltă ramură (nu cea "ghicită" de pre-fetcher) atunci tot cache-ul de instrucțiuni, tot pipeline-ul de execuție trebuie golit și repopulat, iar accesarea memoriei poate lăsa procesorul fără instrucțiuni de executat pentru multe sute de cicluri, incapabil să facă treabă utilă.
Faptul că JVM-ul observă meticulos modul în care rulează o anumită bucată de cod este probabil motivul pentru care nu se compilează tot. N-ar fi practic, s-ar consuma mai multe resurse decât se câștigă. Având însă în vedere că majoritatea aplicațiilor reale își petrec 80% din timp în 20% din cod, așa numitul "cod fierbinte" (a se vedea principiu Pareto), optimizarea la sânge a acestui mic procent din cod practic produce îmbunătățiri imense.
Vom analiza pe rând un pic tehnicile mai interesante de optimizare. Inlining-ul de metode ar fi una. Ideea este să scăpăm de costul apelurilor de metode prin inserarea codului din corpul metodei direct în locația unde se afla apelul la metodă. Bineînțeles că n-ar fi practic să se facă inlining la toate metodele. În mod normal se face doar pentru metodele al căror cod compilat e mai scurt de 35 de octeți și pentru metodele "hot" (apelate frecvent) mai mici de 325 de octeți. O metodă se consideră fierbinte, dacă se apelează de mai mult de 10.000 de ori, dar până la urmă toate aceste limite numerice sunt ajustabile prin parametri de JVM.
Un aspect interesant al inliningului este că acesta este influențat și de polimorfism. Când sunt apelate metode JVM-ul verifică câte implementări există pentru metoda respectivă în ierarhia de clase încărcate momentan. Dacă găsește una, atunci vorbim de apel monomorfic, dacă două, atunci de apel bimorfic și dacă mai multe, atunci apel megamorfic. Apelurile mono- și bi-morfice pot fi inline-uite, cele mega-morfice nu.
Legat de implementări, interesant este faptul că numărul celor încărcate și folosite efectiv e ce contează. Chiar dacă noi avem sute de implementări scrise în codebase , dacă în aplicația curentă folosim efectiv doar una sau două din ele, chiar dacă nu i se face inlining (de ex. dacă e o metodă prea mare), JVM-ul observă că nu este nevoie să folosească mecanisme de apel megamorfice (a se citi "costisitoare"), ci unele mai simple, semnificativ mai ieftine ca cele mono- sau bi-morfice.
O optimizare similară inling-ului este "loop unrolling"-ul. Ca și la inlining, se înlocuiește o buclă (complet sau parțial) cu un număr fix de apeluri directe ale codului aflat original în buclă.
Exemplu "loop unrolling" când overheadul buclei e relativ mare:
"Null check elimination". Când scriem cod sursă în multe cazuri merită să verificăm anumite referințe dacă sunt nule sau nu (până la urmă cam 70% din excepțiile care apar în producție sunt _NullPointerException_s). În codul mașină optimizat însă, multe verificări de null pot fi eliminate fără afectarea corectitudinii codului și JVM-ul poate să obțină îmbunătățiri de performanță semnificative din acest lucru. Iar în cazurile rare când greșește și asumpțiile pe care le-a făcut se dovedesc a fi false, "uncommon trap"-ul salvează ziua!
Un alt tip de analiză făcută de JVM este "escape analysis" prin care încearcă să determine, dacă un anumit obiect poate scăpa sau nu din metoda sau threadul curent. Dacă un obiect e folosit de un singur thread, atunci toate lockurile pe acel obiect pot fi de exemplu eliminate complet (mecanism numit "lock elision") și nu mai e necesar costul exorbitant al sincronizării (proces mediat de OS, deci extrem de scump). Dacă un obiect rămâne în contextul unei metode, atunci nu e necesar să se aloce pe heap, deci nu va fi necesar să-l culeagă garbage collectorul ("allocation elimination"). Inliningul menționat anterior ajută mult la cât de efectivă poate fi această tehnică.
Lock coarsening. Când eliminarea lockului nu este o opțiune, câteodată JVM-ul recurge la unirea multiplelor blocuri sincronizate (pe același obiect), apelate la mică distanță una de cealaltă. De exemplu, o metodă sincronizată apelată într-o buclă. Considerând cât de mare e costul intrării și ieșirii dintr-un bloc sincronizat (proces mediat de OS, cum am amintit anterior) și cât de mare e ignoranța programatorului Java obișnuit față de acest lucru, această tehnică poate fi una foarte eficientă în practică.
Exemplu Lock coarsening:
Eliminarea codului mort. În codebase-urile reale există multe bucăți de cod, apărute prin refactorizări repetate, neglijență și alte motive care nu influențează de fapt rezultatul execuției. De exemplu, un contor pe care îl incrementăm într-o buclă pentru fiecare iterație, dar după ce ieșim din buclă nu-l mai folosim la nimic. De multe ori, astfel de cod poate fi și este detectat de JVM și eliminat complet, lucru care de obicei ajută mult la performanță. Aș putea chiar să zic că a nu rula deloc o bucată de cod e îmbunătățirea supremă de performanță care se poate aduce respectivului cod... Există însă și cazuri când această optimizare ne poate cauza bătaie de cap.
Test naiv de performanță distrus la optimizare:
Și credeți-ne! Aceste câteva tehnici amintite sunt doar vârful icebergului (nici n-am amintit de "code motion", "expression hoisting & sinking", "redundant store elimination", "loop unswitching" și câte o mai fi existând, de care nici n-am auzit...). Noi doar am vrut să vă prezentăm un pic câte lucruri se întâmplă în această mașinărie minunată.
Iar JVM-ul nu e doar sofisticat, ci e și în continuă evoluție. Gama de optimizări devin din ce în ce mai complexe pe măsură ce apar noi și noi versiuni. În Java 9 chiar s-a adăugat suport pentru scrierea de compilatoare dinamice în Java, care pot înlocui cele folosite de JVM (Java Virtual Machine Compiler Interface - JVMCI, JEP 243). Devine astfel posibilă apariția de compilatoare de o calitate superioară sau specializate pe anumite domenii. A apărut și posibilitatea compilării Ahead-of-Time pentru tipurile de aplicații care nu își pot permite să aștepte până își culege JIT-ul statisticile ci au nevoie de performanță ridicată din momentul zero (JEP 295).
Așadar, data viitoare când vă mândriți unui coleg de eleganța codului pe care tocmai l-ați scris, să vă gândiți o secundă de cât ajutor nevăzut beneficiați de fapt și dacă v-ați permite eleganța fără suportul JVM-ului!
de Bianca Leuca
de Ovidiu Mățan
de Vlad But
de Bálint Ákos