Modul în care construim aplicații software a evoluat din 1995, când a fost introdus pentru prima dată Java, ca limbaj de programare. Am progresat de la utilizarea monoliților, la microservicii și, în ultimii ani, la serverless. Cu toate acestea, aceste schimbări aduc, de asemenea, noi provocări, cum ar fi timpul de pornire rapid și instant peak performance, esențiale pentru dezvoltarea aplicațiilor software. Pentru a aborda aceste provocări, Java a introdus Native Images.
Native Images desemnează o tehnologie care utilizează compilarea ahead-of-time pentru a transforma codul Java în executabile autonome numite native images. Acest lucru duce la timpi de pornire mai mici, o utilizare mai eficientă a resurselor și o securitate îmbunătățită.
În aplicațiile Java tradiționale, metoda de compilare utilizată este just-in-time (JIT). JVM este responsabil de executarea bytecode-ului Java și de compilarea codului utilizat frecvent în native code pe baza informațiilor rezultate în urma profilingului din timpul execuției. Avantajul acestui tip de compilare este că permite ca JVM-ul să livreze cod optimizat pentru îmbunătățirea performanțelor de vârf și pentru așa-numita aplicație platform-independent. Dar aceste avantaje vin și cu anumite dezavantaje: JIT folosește mult JVM în timpul execuției, ceea ce duce la un timp de pornire mai mare și la introducerea unui warm-up time. În plus, JIT necesită un JVM pentru a executa codul, ceea ce implică o utilizare crescută a resurselor și la o amprentă de memorie mai mare. AOT urmărește să îmbunătățească aceste aspecte.
Ahead-of-time compilation se referă la tehnica de traducere a unui limbaj high-level, în cazul nostru Java, într-un limbaj low-level, înainte ca aplicația să fie executată, de regulă, la build time. Rezultatul AOT va fi un binar, specific platformei utilizate pentru compilare, care nu necesită compilare ulterioară.
Compilarea AOT este utilizată în aplicațiile Java pentru a îmbunătăți performanța generală, dar, mai ales, pentru a reduce timpul de pornire. Cel mai cunoscut compilator AOT în Java este GraalVM.
GraalVM a început ca un proiect de cercetare la Oracle, în cadrul ramurii de cercetare și dezvoltare a companiei, Oracle Labs. Oracle Labs este responsabil de cercetarea limbajelor de programare, a mașinilor virtuale, învățarea automată, securitate, procesare grafică și alte domenii.
Principalul produs al GraalVM este compilatorul Graal, un compilator cu optimizare ridicată, creat de la zero. În mod surprinzător, acest compilator este scris în cea mai mare parte în Java și, în multe scenarii, datorită numeroaselor optimizări, este mai performant decât compilatorul C2. Acest lucru demonstrează încă o dată cât de puternic este Java ca limbaj de programare.
GraalVM primește ca input bytecode-ul Java și generează un executabil. Acest executabil este specific platformei utilizate pentru compilare. Cum face GraalVM acest lucru? Ei bine, realizează ceva numit analiză statică, în care compilatorul analizează codul folosit în aplicație și include doar acel cod în rezultatul nativ.
Pentru a face acest lucru, compilatorul utilizează aceste trei tehnici:
Points-to analysis: este o tehnică de compilare care ajută la identificarea relațiilor dintre pointeri și locațiile în memorie la care acestea indică în timpul execuției programului. GraalVM utilizează această tehnică pentru a determina ce clase, metode și câmpuri pot fi accesate în timpul execuției și le include numai pe acestea în executabil. Analiza are, de regulă, ca punct de pornire metoda main și procesează iterativ tot codul accesibil în mod tranzitoriu până când se ajunge la un punct fix. Această analiză se aplică la codul aplicației, dar și la librăriile externe sau clasele din JDK. Doar codul necesar rulării aplicației este adăugat în executabil.
Inițializarea la build time: Inițializarea claselor în timpul execuției adaugă costuri suplimentare, crescând timpul de pornire al aplicației. De exemplu, o simplă aplicație "Hello, World" necesită inițializarea a peste 300 de clase. Pentru a reduce această suprasolicitare, GraalVM suportă inițializarea claselor la momentul compilării, eliminând inițializarea și verificările în timpul execuției. Toată starea statică din clasele inițializate este stocată în executabil. Accesul la câmpurile statice ale unei clase care au fost inițializate la compilare este transparent pentru aplicație și funcționează ca și cum clasa ar fi fost inițializată în timpul execuției. Totuși, acest lucru vine și cu unele constrângeri:
Atunci când o clasă este inițializată, toate superclasele și super interfețele sale cu metode default trebuie, de asemenea, să fie inițializate.
Aceste trei etape se repetă până când se ajunge la un punct fix, unde toate clasele și metodele necesare rulării aplicației au fost incluse.
Pentru a suporta funcționalități cum ar fi gestionarea memoriei și programarea firelor de execuție, Native Images includ un lightweight VM numit SubstrateVM. Acest VM special este, de asemenea, scris în Java.
Fig. 1 Proces de compilare GraalVM
Native images au mai multe avantaje. Câteva dintre principalele avantaje ale utilizării lor sunt:
Timp de pornire instant. Imaginile native au timpi de pornire mai rapizi în comparație cu aplicațiile bazate pe JVM. Compilarea sau interpretarea JIT nu este necesară, ceea ce face ca executabilele să fie pornite imediat. De asemenea, pre-popularea heapului elimină așa-numitul "cold start" de care suferă aplicațiile scrise în Java. Acest lucru creează o experiență de utilizare mai responsivă, în special pentru aplicațiile cu durată de viață scurtă.
Utilizarea optimizată a resurselor. Prin compilarea codului Java special pentru platforma pe care urmează să fie rulat, imaginile native pot profita de optimizările specifice platformei, ceea ce duce la o mai bună utilizare a resurselor sistemului. Această optimizare poate avea ca rezultat o scalabilitate și o performanță îmbunătățite pentru aplicațiile Java.
Dimensiuni reduse ale spațiului de stocare. Deoarece imaginile native conțin doar componentele necesare rulării aplicației, imaginile native au, de obicei, o amprentă de memorie mai mică în comparație cu aplicațiile JVM clasice.
În cele ce urmează, o să analizăm un exemplu simplu ce folosește Spring Native și Maven pentru construirea de native images. Pentru aceasta, voi folosi ediția Community GraalVM, care se bazează pe OpenJDK. Versiunea Java utilizată pentru acest exemplu este 21. GraalVM vine, de asemenea, cu un plugin Maven ce facilitează crearea și testarea executabilelor native. Spring Native este un proiect din cadrul Spring Framework care oferă suport pentru crearea aplicațiilor Spring ca imagini native.
Fig. 2 JIT vs. AOT startup
Am scris o aplicație simplă pentru gestionarea produselor, care expune 4 endpoint-uri pentru crearea, ștergerea și listarea produselor. Vom crea două versiuni ale acestei aplicații, una compilată JIT, care rulează folosind JVM și cealaltă va fi compilată AOT, ca imagine nativă, apoi voi compara performanța acestor două aplicații.
În primul rând, trebuie să adăugăm pluginul graalVM în pom.xml - acest lucru ne va oferi capacitatea de a crea imagini native folosind Maven.
<build>
<plugins>
<plugin>
<groupId>org.graalvm.buildtools</groupId>
<artifactId>native-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
Acum, să compilăm aplicația folosind comanda maven:
./mvnw -Pnative native:compile
După ce compilarea este finalizată, putem observa imaginea nativă sub forma unui executabil.
Timp de pornire: Deoarece codul Java nu trebuie interpretat și compilat, imaginea nativă pornește imediat în comparație cu aplicația JVM. De asemenea, datorită heapului prepopulat și compilării directe în cod mașină, aplicația funcționează la performanțe maxime.
În imaginile de mai jos, putem vedea logurile din timpul pornirii aplicațiilor Spring, timpul de pornire al aplicației compilată ca imagine nativă a avut timpul de pornire de doar 143 ms, în timp ce timpul de pornire al aplicației ce rulează folosind JVM este de 2,834 secunde.
Eficientizarea resurselor. Deoarece codul java este compilat în avans, executabilul nativ nu are nevoie de JVM, de infrastructura de compilare JIT a acestuia sau de memorie pentru codul compilat, ci are nevoie de memorie doar pentru executabil și pentru datele aplicației.
Fig. 3. 1 Native Image startup log
Fig. 3.2 JVM application startup log
Graficele reflectă consumul de CPU și al memoriei celor două aplicații. Linia albastră reprezintă utilizarea memoriei, aproximativ 390 MB pentru aplicația JVM față de 120 MB pentru imaginea nativă. Linia roșie reprezintă activitatea procesorului. Putem observa că aplicația JVM consumă mult mai mult CPU în timpul perioadei de warm-up, din cauza tuturor operațiunilor JIT care trebuie efectuate; pe de altă parte, imaginea nativă utilizează mult mai puțin CPU, toate operațiunile ce necesită putere de procesare intensivă efectuându-se în timpul compilării.
Probabil că acum putem spune că este prea frumos pentru a fi adevărat, dar imaginile native vin și cu unele dezavantaje și lucruri de care trebuie să ținem cont atunci când lucrăm cu acestea în Java.
Iată câteva dezavantaje ale imaginilor native:
Build time-ul crește exponențial. Deoarece toate operațiunile din timpul de execuție sunt mutate în momentul compilării, din cauza compilării AOT, build time-ul crește exponențial, iar crearea de imagini native necesită mai multe resurse. De asemenea, executabilul nativ rezultat este specific platformei și va fi compatibil cu arhitectura sistemului pe care a fost construit.
Posibile incompatibilități folosind reflection. Deoarece imaginea nativă efectuează o analiză statică pe baza unei presupuneri de tip "lume închisă", caracteristicile dinamice, cum ar fi reflection, necesită configurații suplimentare pentru ca compilatorul să fie conștient de utilizarea acestora la build time. Atunci când efectuează o analiză statică, Native Image va încerca să detecteze și să gestioneze apelurile către Reflection API, cu toate acestea, această analiză automată nu este întotdeauna suficientă și este posibil să fie necesar să oferim compilatorului câteva indicații. Acest tip de configurare poate fi necesar și atunci când se utilizează Java Native Interface (JNI), dynamic proxy și resurse classpath.
Fig. 4 Native Image vs. JVM, consum cpu și memorie
În concluzie, introducerea imaginilor native în Java marchează un progres semnificativ în abordarea provocărilor aplicațiilor moderne, cum ar fi timpii de pornire rapizi și utilizarea eficientă a resurselor. Prin utilizarea compilării ahead-of-time și a tehnologiilor precum GraalVM, dezvoltatorii Java pot produce acum executabile independente cu performanțe ridicate și o amprentă de memorie redusă.
Beneficiile imaginilor native, inclusiv timpul de pornire instant, utilizarea optimizată a resurselor și vulnerabilitatea redusă fac din acestea o opțiune pentru aplicațiile cu durată de viață scurtă și pentru cele care necesită măsuri de securitate mai bune. Cu toate acestea, este esențial să recunoaștem dezavantajele asociate cu imaginile native, cum ar fi creșterea build time-ului, suportul limitat pentru reflection și problemele de compatibilitate cu anumite funcționalități și librării Java. Abordarea acestor provocări poate necesita un efort suplimentar și o analiză atentă în timpul dezvoltării.
În timp ce imaginile native oferă avantaje promițătoare, dezvoltatorii ar trebui să evalueze adecvarea lor pentru proiecte specifice pe baza cerințelor de performanță, a considerentelor de compatibilitate și a constrângerilor de resurse. Cu o înțelegere adecvată a provocărilor potențiale, imaginile native pot fi un instrument valoros în dezvoltarea aplicațiilor Java moderne, permițând o performanță și o scalabilitate îmbunătățite în diverse medii de implementare.
Alina Yurenko, Revolutionizing Java with GraalVM Native Image
Points to Analysis