ABONAMENTE VIDEO REDACȚIA
RO
EN
NOU
Numărul 147
Numărul 146 Numărul 145 Numărul 144 Numărul 143 Numărul 142 Numărul 141 Numărul 140 Numărul 139 Numărul 138 Numărul 137 Numărul 136 Numărul 135 Numărul 134 Numărul 133 Numărul 132 Numărul 131 Numărul 130 Numărul 129 Numărul 128 Numărul 127 Numărul 126 Numărul 125 Numărul 124 Numărul 123 Numărul 122 Numărul 121 Numărul 120 Numărul 119 Numărul 118 Numărul 117 Numărul 116 Numărul 115 Numărul 114 Numărul 113 Numărul 112 Numărul 111 Numărul 110 Numărul 109 Numărul 108 Numărul 107 Numărul 106 Numărul 105 Numărul 104 Numărul 103 Numărul 102 Numărul 101 Numărul 100 Numărul 99 Numărul 98 Numărul 97 Numărul 96 Numărul 95 Numărul 94 Numărul 93 Numărul 92 Numărul 91 Numărul 90 Numărul 89 Numărul 88 Numărul 87 Numărul 86 Numărul 85 Numărul 84 Numărul 83 Numărul 82 Numărul 81 Numărul 80 Numărul 79 Numărul 78 Numărul 77 Numărul 76 Numărul 75 Numărul 74 Numărul 73 Numărul 72 Numărul 71 Numărul 70 Numărul 69 Numărul 68 Numărul 67 Numărul 66 Numărul 65 Numărul 64 Numărul 63 Numărul 62 Numărul 61 Numărul 60 Numărul 59 Numărul 58 Numărul 57 Numărul 56 Numărul 55 Numărul 54 Numărul 53 Numărul 52 Numărul 51 Numărul 50 Numărul 49 Numărul 48 Numărul 47 Numărul 46 Numărul 45 Numărul 44 Numărul 43 Numărul 42 Numărul 41 Numărul 40 Numărul 39 Numărul 38 Numărul 37 Numărul 36 Numărul 35 Numărul 34 Numărul 33 Numărul 32 Numărul 31 Numărul 30 Numărul 29 Numărul 28 Numărul 27 Numărul 26 Numărul 25 Numărul 24 Numărul 23 Numărul 22 Numărul 21 Numărul 20 Numărul 19 Numărul 18 Numărul 17 Numărul 16 Numărul 15 Numărul 14 Numărul 13 Numărul 12 Numărul 11 Numărul 10 Numărul 9 Numărul 8 Numărul 7 Numărul 6 Numărul 5 Numărul 4 Numărul 3 Numărul 2 Numărul 1
×
▼ LISTĂ EDIȚII ▼
Numărul 60
Abonament PDF

Chiar merge? Nu pot să cred!

József Bartók
Software Engineer @ Hazelcast



PROGRAMARE

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!

Conferință TSM

NUMĂRUL 147 - Automotive

Sponsori

  • Accenture
  • BT Code Crafters
  • Accesa
  • Bosch
  • Betfair
  • MHP
  • BoatyardX
  • .msg systems
  • P3 group
  • Ing Hubs
  • Cognizant Softvision
  • Colors in projects