Programmering

JVM-ydeevneoptimering, del 2: Compilers

Java-compilere er i centrum i denne anden artikel i JVM-præstationsoptimeringsserien. Eva Andreasson introducerer de forskellige racer af compiler og sammenligner ydeevne resultater fra klient, server og tiered kompilering. Hun slutter med en oversigt over almindelige JVM-optimeringer, såsom eliminering af dead-code, inlining og loop-optimering.

En Java-compiler er kilden til Java's berømte platformuafhængighed. En softwareudvikler skriver det bedste Java-program, som han eller hun kan, og derefter arbejder kompilatoren bag kulisserne for at producere effektiv og veludførelig eksekveringskode til den tilsigtede målplatform. Forskellige slags kompilatorer opfylder forskellige applikationsbehov, hvilket giver specifikke ønskede resultater. Jo mere du forstår om kompilatorer med hensyn til, hvordan de fungerer, og hvilke slags der er tilgængelige, jo mere vil du være i stand til at optimere Java-applikationsydelsen.

Denne anden artikel i JVM-optimering serien fremhæver og forklarer forskellene mellem forskellige kompilatorer til virtuel Java-maskine. Jeg vil også diskutere nogle almindelige optimeringer, der bruges af Just-In-Time (JIT) compilers til Java. (Se "JVM-ydeevneoptimering, del 1" for en JVM-oversigt og introduktion til serien.)

Hvad er en kompilator?

Simpelthen en kompilator tager et programmeringssprog som input og producerer et eksekverbart sprog som output. En almindeligt kendt kompilator er javac, som er inkluderet i alle standard Java-udviklingssæt (JDK'er). javac tager Java-kode som input og oversætter den til bytecode - det eksekverbare sprog for en JVM. Bytekoden gemmes i .class-filer, der indlæses i Java-runtime, når Java-processen startes.

Bytecode kan ikke læses af standard-CPU'er og skal oversættes til et instruktionssprog, som den underliggende udførelsesplatform kan forstå. Komponenten i JVM, der er ansvarlig for at oversætte bytecode til eksekverbare platforminstruktioner, er endnu en compiler. Nogle JVM-kompilatorer håndterer flere niveauer af oversættelse; for eksempel kan en kompilator oprette forskellige niveauer af mellemrepræsentation af bytekoden, før den bliver til faktisk maskininstruktion, det sidste trin i oversættelsen.

Bytecode og JVM

Hvis du vil lære mere om bytecode og JVM, se "Grundlæggende om Bytecode" (Bill Venners, JavaWorld).

Fra et platform-agnostisk perspektiv ønsker vi at holde kodeplatformuafhængig så langt som muligt, så det sidste oversættelsesniveau - fra den laveste repræsentation til den faktiske maskinkode - er det trin, der låser udførelsen til en bestemt platforms processorarkitektur . Det højeste niveau for adskillelse er mellem statiske og dynamiske compilere. Derfra har vi valgmuligheder afhængigt af, hvilket udførelsesmiljø vi målretter mod, hvilke præstationsresultater, vi ønsker, og hvilke ressourcebegrænsninger vi skal opfylde. Jeg diskuterede kort statiske og dynamiske compilers i del 1 i denne serie. I de følgende afsnit forklarer jeg lidt mere.

Statisk vs dynamisk kompilering

Et eksempel på en statisk kompilator er det tidligere nævnte javac. Med statiske compilere fortolkes inputkoden en gang, og output-eksekverbarheden er i den form, der vil blive brugt, når programmet udføres. Medmindre du foretager ændringer i din oprindelige kilde og kompilerer koden igen (ved hjælp af compileren), vil output altid resultere i det samme resultat; dette skyldes, at input er en statisk input, og compileren er en statisk compiler.

I en statisk kompilering følgende Java-kode

statisk int add7 (int x) {return x + 7; }

ville resultere i noget svarende til denne bytecode:

iload0 bipush 7 iadd ireturn

En dynamisk compiler oversættes dynamisk fra et sprog til et andet, hvilket betyder, at det sker, når koden udføres - under kørsel! Dynamisk kompilering og optimering giver driftstider fordelen ved at kunne tilpasse sig ændringer i applikationsbelastningen. Dynamiske compilere er meget velegnede til Java-driftstider, der ofte udføres i uforudsigelige og stadigt skiftende miljøer. De fleste JVM'er bruger en dynamisk compiler såsom en JIT-compiler (Just-In-Time). Fangsten er, at dynamiske compilere og kodeoptimering undertiden har brug for ekstra datastrukturer, tråd og CPU-ressourcer. Jo mere avanceret optimering eller analyse af bytekode-kontekst er, jo flere ressourcer forbruges af kompilering. I de fleste miljøer er omkostningerne stadig meget små sammenlignet med outputkodens betydelige præstationsgevinst.

JVM-sorter og Java-platformuafhængighed

Alle JVM-implementeringer har en ting til fælles, hvilket er deres forsøg på at få applikationsbytekode oversat til maskininstruktioner. Nogle JVM'er fortolker applikationskode ved belastning og bruger præstationstællere til at fokusere på "hot" kode. Nogle JVM'er springer fortolkning over og er afhængige af kompilering alene. Kompileringens ressourceintensitet kan være et større hit (især for applikationer på klientsiden), men det muliggør også mere avancerede optimeringer. Se Ressourcer for mere information.

Hvis du er en nybegynder til Java, vil indviklingen af ​​JVM'er være meget at pakke dit hoved rundt. Den gode nyhed er, at du ikke rigtig har brug for det! JVM administrerer kodekompilering og optimering, så du ikke behøver at bekymre dig om maskininstruktioner og den optimale måde at skrive applikationskode til en underliggende platformarkitektur.

Fra Java bytecode til udførelse

Når du har samlet din Java-kode i bytecode, er de næste trin at oversætte bytecode-instruktionerne til maskinkode. Dette kan gøres af enten en tolk eller en kompilator.

Fortolkning

Den enkleste form for bytecode-kompilering kaldes fortolkning. En tolk kigger simpelthen op på hardwareinstruktionerne til hver bytecode-instruktion og sender den ud for at blive udført af CPU'en.

Du kunne tænke på fortolkning svarende til at bruge en ordbog: for et bestemt ord (bytecode-instruktion) er der en nøjagtig oversættelse (maskinkodeinstruktion). Da tolken læser og straks udfører en bytecode-instruktion ad gangen, er der ingen mulighed for at optimere over et instruktionssæt. En tolk skal også udføre fortolkningen hver gang en bytecode påberåbes, hvilket gør den ret langsom. Fortolkning er en nøjagtig måde at udføre kode på, men det ikke-optimerede outputinstruktions sæt vil sandsynligvis ikke være den mest effektive sekvens for målplatformens processor.

Samling

EN kompilator på den anden side indlæser hele koden, der skal udføres, i løbetiden. Da det oversætter bytecode, har det evnen til at se på hele eller delvise runtime-kontekster og træffe beslutninger om, hvordan man faktisk oversætter koden. Dens beslutninger er baseret på analyse af kodegrafer, såsom forskellige eksekveringsgrene for instruktioner og runtime-kontekstdata.

Når en bytecode-sekvens oversættes til et maskinkode-instruktions-sæt, og optimeringer kan udføres til dette instruktions-sæt, lagres det udskiftende instruktions-sæt (f.eks. Den optimerede sekvens) i en struktur kaldet kode cache. Næste gang denne bytecode udføres, kan den tidligere optimerede kode straks placeres i kodecachen og bruges til udførelse. I nogle tilfælde kan en præstationstæller muligvis sparke ind og tilsidesætte den tidligere optimering, i hvilket tilfælde compileren kører en ny optimeringssekvens. Fordelen ved en kode cache er, at det resulterende instruktions sæt kan udføres på én gang - intet behov for fortolkende opslag eller kompilering! Dette fremskynder udførelsestiden, især for Java-applikationer, hvor de samme metoder kaldes flere gange.

Optimering

Sammen med dynamisk kompilering kommer muligheden for at indsætte performance tællere. Compileren kan f.eks. Indsætte en præstationstæller at tælle hver gang en bytekodeblok (f.eks. svarende til en bestemt metode) blev kaldt. Compilere bruger data om, hvor "hot" en given bytecode er for at bestemme, hvor i kodeoptimeringer bedst påvirker den kørende applikation. Runtime-profileringsdata gør det muligt for kompilatoren at tage et rigt sæt af kodeoptimeringsbeslutninger på farten, hvilket yderligere forbedrer ydeevnen til kodeudførelse. Efterhånden som mere raffinerede kodeprofileringsdata bliver tilgængelige, kan de bruges til at træffe yderligere og bedre optimeringsbeslutninger, såsom: hvordan man bedre sekvensinstruktioner i det kompilerede sprog, om man skal erstatte et sæt instruktioner med mere effektive sæt eller endda om overflødige operationer skal fjernes.

Eksempel

Overvej Java-koden:

statisk int add7 (int x) {return x + 7; }

Dette kunne statisk udarbejdes af javac til bytecode:

iload0 bipush 7 iadd ireturn

Når metoden kaldes, bliver bytekodeblokken dynamisk kompileret til maskininstruktioner. Når en præstationstæller (hvis den findes til kodeblokken) rammer en tærskel, bliver den muligvis også optimeret. Slutresultatet kan se ud som følgende maskininstruktions sæt til en given udførelsesplatform:

lea rax, [rdx + 7] ret

Forskellige kompilatorer til forskellige applikationer

Forskellige Java-applikationer har forskellige behov. Langvarige applikationer på serversiden på virksomheden kan muliggøre flere optimeringer, mens mindre klientapplikationer muligvis har brug for hurtig udførelse med minimalt ressourceforbrug. Lad os overveje tre forskellige kompilatorindstillinger og deres respektive fordele og ulemper.

Kompilatorer på klientsiden

En velkendt optimeringscompiler er C1, compileren, der er aktiveret via -klient JVM startmulighed. Som opstartsnavnet antyder, er C1 en kompilator på klientsiden. Det er designet til applikationer på klientsiden, der har færre ressourcer til rådighed, og som i mange tilfælde er følsomme over for starttid for applikationer. C1 bruger præstationstællere til kodeprofilering for at muliggøre enkle, relativt intrusive optimeringer.

Compilere på serversiden

For langvarige applikationer som f.eks. Java-applikationer på serversiden er det muligvis ikke nok med en kompilator på klientsiden. En server-kompilator som C2 kunne bruges i stedet. C2 er normalt aktiveret ved at tilføje JVM-startindstillingen -server til din startkommandolinje. Da de fleste server-side-programmer forventes at køre i lang tid, betyder det at aktivere C2, at du vil være i stand til at indsamle mere profildata, end du ville med en kortvarig letvægtsklientapplikation. Så du er i stand til at anvende mere avancerede optimeringsteknikker og algoritmer.

Tip: Varm din kompilator på serversiden op

For implementeringer på serversiden kan det tage et stykke tid, før compileren har optimeret de indledende "varme" dele af koden, så implementeringer på serversiden kræver ofte en "opvarmningsfase". Inden du foretager nogen form for ydelsesmåling på en server-implementering, skal du sørge for, at din applikation har nået stabil tilstand! At give kompilatoren nok tid til at kompilere korrekt fungerer til din fordel! (Se JavaWorld-artiklen "Se din HotSpot-compiler gå" for mere om opvarmning af din compiler og mekanikken i profilering.)

En serverkompilator tegner sig for mere profileringsdata end en kompilator på klientsiden gør og tillader mere kompleks filialanalyse, hvilket betyder, at den vil overveje, hvilken optimeringssti der ville være mere fordelagtig. At have flere tilgængelige profildata giver bedre applikationsresultater. Selvfølgelig kræver udførelse af mere omfattende profilering og analyse at bruge flere ressourcer på compileren. En JVM med C2 aktiveret bruger flere tråde og flere CPU-cyklusser, kræver en større kode-cache og så videre.

Niveauet kompilering

Niveauet kompilering kombinerer klientside- og serversidesammensætning. Azul gjorde først en differentieret samling tilgængelig i sin Zing JVM. For nylig (fra og med Java SE 7) er den blevet vedtaget af Oracle Java Hotspot JVM. Niveauet kompilering udnytter fordelene ved både klient- og servercompiler i din JVM. Klientkompileren er mest aktiv under opstart af applikationen og håndterer optimeringer udløst af lavere tærskler for ydeevne. Compileren på klientsiden indsætter også præstationstællere og forbereder instruktionssæt til mere avancerede optimeringer, som senere behandles af server-compileren. Tredelt kompilering er en meget ressourceeffektiv måde at profilere på, fordi compileren er i stand til at indsamle data under kompilatoraktivitet med lav effekt, som senere kan bruges til mere avancerede optimeringer. Denne tilgang giver også mere information, end du får ved at bruge tolkede kodeprofiltællere alene.

Diagramskemaet i figur 1 viser præstationsforskellene mellem ren fortolkning, klientside, serverside og trinvis kompilering. X-aksen viser udførelsestid (tidsenhed) og Y-aksens ydeevne (ops / tidsenhed).

Figur 1. Ydelsesforskelle mellem kompilatorer (klik for at forstørre)

Sammenlignet med rent fortolket kode fører brugen af ​​en kompilator på klientsiden til cirka 5 til 10 gange bedre udførelsesydelse (i ops / s) og forbedrer dermed applikationsydelsen. Variationen i gevinst afhænger naturligvis af, hvor effektiv kompilatoren er, hvilke optimeringer der er aktiveret eller implementeret, og (i mindre grad) hvor veludformet applikationen er med hensyn til målplatformen til udførelse. Sidstnævnte er dog virkelig noget, som en Java-udvikler aldrig skal bekymre sig om.

Sammenlignet med en kompilator på klientsiden øger en kompilator på serversiden normalt kodeydelsen med en målbar 30 procent til 50 procent. I de fleste tilfælde vil forbedring af ydeevnen afbalancere de ekstra ressourceomkostninger.