Programmering

JVM ytelsesoptimalisering, del 2: kompilatorer

Java-kompilatorer er sentralt i denne andre artikkelen i JVM-ytelsesoptimaliseringsserien. Eva Andreasson introduserer de forskjellige rasene av kompilator og sammenligner ytelsesresultater fra klient-, server- og trinnvis kompilering. Hun avslutter med en oversikt over vanlige JVM-optimaliseringer som eliminering av dead-code, inlining, og loop-optimalisering.

En Java-kompilator er kilden til Javas berømte plattformuavhengighet. En programvareutvikler skriver det beste Java-programmet han eller hun kan, og deretter jobber kompilatoren bak kulissene for å produsere effektiv og godt utførende utførelseskode for den tiltenkte målplattformen. Ulike typer kompilatorer oppfyller forskjellige applikasjonsbehov, og gir dermed spesifikke ønskede ytelsesresultater. Jo mer du forstår om kompilatorer, når det gjelder hvordan de fungerer og hvilke typer som er tilgjengelige, desto mer vil du kunne optimalisere Java-applikasjonsytelsen.

Denne andre artikkelen i JVM-ytelsesoptimalisering serien fremhever og forklarer forskjellene mellom forskjellige Java virtuelle maskin kompilatorer. Jeg vil også diskutere noen vanlige optimaliseringer som brukes av Just-In-Time (JIT) kompilatorer for Java. (Se "JVM-ytelsesoptimalisering, del 1" for en JVM-oversikt og introduksjon til serien.)

Hva er en kompilator?

Rett og slett a kompilator tar et programmeringsspråk som input og produserer et kjørbart språk som output. En allment kjent kompilator er javac, som er inkludert i alle standard Java-utviklingssett (JDK). javac tar Java-kode som inndata og oversetter den til bytekode - det kjørbare språket for en JVM. Bytekoden lagres i .class-filer som lastes inn i Java-kjøretiden når Java-prosessen startes.

Bytecode kan ikke leses av standard CPUer og må oversettes til et instruksjonspråk som den underliggende kjøringsplattformen kan forstå. Komponenten i JVM som er ansvarlig for å oversette bytekode til kjørbare plattforminstruksjoner, er enda en kompilator. Noen JVM-kompilatorer håndterer flere nivåer av oversettelse; for eksempel kan en kompilator opprette forskjellige nivåer av mellomrepresentasjon av bytekoden før den blir til faktisk maskininstruksjon, det siste trinnet i oversettelsen.

Bytecode og JVM

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

Fra et plattform-agnostisk perspektiv ønsker vi å holde kodeplattformuavhengig så langt som mulig, slik at det siste oversettelsesnivået - fra den laveste representasjonen til den faktiske maskinkoden - er trinnet som låser kjøringen til en bestemt plattforms prosessorarkitektur . Det høyeste skillet er mellom statiske og dynamiske kompilatorer. Derfra har vi alternativer avhengig av hvilket utførelsesmiljø vi målretter mot, hvilke ytelsesresultater vi ønsker, og hvilke ressursbegrensninger vi må oppfylle. Jeg diskuterte kort statiske og dynamiske kompilatorer i del 1 av denne serien. I de følgende avsnittene vil jeg forklare litt mer.

Statisk vs dynamisk kompilering

Et eksempel på en statisk kompilator er det tidligere nevnte javac. Med statiske kompilatorer tolkes inngangskoden en gang, og den utførbare kjøreren er i den formen som skal brukes når programmet kjøres. Med mindre du gjør endringer i den opprinnelige kilden og kompilerer koden på nytt (ved hjelp av kompilatoren), vil resultatet alltid resultere i samme utfall; dette er fordi inngangen er en statisk inngang og kompilatoren er en statisk kompilator.

I en statisk samling, følgende Java-kode

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

vil resultere i noe som ligner på denne bytekoden:

iload0 bipush 7 iadd ireturn

En dynamisk kompilator oversettes dynamisk fra ett språk til et annet, noe som betyr at det skjer når koden utføres - i løpet av kjøretiden! Dynamisk kompilering og optimalisering gir kjøretider fordelen av å kunne tilpasse seg endringer i applikasjonsbelastning. Dynamiske kompilatorer er veldig godt egnet for Java-driftstider, som ofte kjøres i uforutsigbare og stadig skiftende miljøer. De fleste JVM-er bruker en dynamisk kompilator som en JIT-kompilator (Just-In-Time). Fangsten er at dynamiske kompilatorer og kodeoptimalisering noen ganger trenger ekstra datastrukturer, tråd og CPU-ressurser. Jo mer avansert optimalisering eller bytekodekontekstanalyse er, desto flere ressurser blir brukt av kompilering. I de fleste miljøer er overhead fortsatt veldig lite sammenlignet med den betydelige ytelsesgevinsten til utgangskoden.

JVM-varianter og Java-plattformuavhengighet

Alle JVM-implementeringer har en ting til felles, som er deres forsøk på å få applikasjonskode oversatt til maskininstruksjoner. Noen JVM-er tolker applikasjonskode ved belastning og bruker ytelsestellere for å fokusere på "hot" -kode. Noen JVM-er hopper over tolkning og stoler på kompilering alene. Ressursintensiteten til kompilering kan være en større hit (spesielt for applikasjoner på klientsiden), men det muliggjør også mer avanserte optimaliseringer. Se Ressurser for mer informasjon.

Hvis du er nybegynner for Java, vil komplikasjonene til JVM-er være mye å pakke hodet rundt. Den gode nyheten er at du ikke trenger det! JVM administrerer kodekompilering og optimalisering, slik at du ikke trenger å bekymre deg for maskininstruksjoner og den optimale måten å skrive applikasjonskode for en underliggende plattformarkitektur.

Fra Java bytecode til utførelse

Når du har samlet Java-koden din i bytecode, er de neste trinnene å oversette bytecode-instruksjonene til maskinkoden. Dette kan gjøres av enten en tolk eller en kompilator.

Tolkning

Den enkleste formen for bytekodekompilering kalles tolkning. An tolk bare ser opp maskinvareinstruksjonene for hver bytecode-instruksjon og sender den av for å bli utført av CPUen.

Du kan tenke deg tolkning ligner på å bruke en ordbok: for et bestemt ord (bytekodeinstruksjon) er det en nøyaktig oversettelse (maskinkodeinstruksjon). Siden tolk leser og umiddelbart utfører en bytekodeinstruksjon om gangen, er det ingen mulighet til å optimalisere over et instruksjonssett. En tolk må også gjøre tolkningen hver gang en bytekode påkalles, noe som gjør den ganske treg. Tolkning er en nøyaktig måte å utføre kode på, men det ikke-optimaliserte utgangsinstruksjonssettet vil sannsynligvis ikke være den best ytende sekvensen for målplattformens prosessor.

Samling

EN kompilator derimot laster hele koden som skal utføres inn i kjøretiden. Når den oversettes bytekode, har den evnen til å se på hele eller delvis kjøretidssammenheng og ta beslutninger om hvordan du faktisk skal oversette koden. Dens beslutninger er basert på analyse av kodegrafer som forskjellige utførelsesgrener av instruksjoner og kjøretid-kontekstdata.

Når en bytekodesekvens blir oversatt til et maskinkodeinstruksjonssett og optimaliseringer kan gjøres til dette instruksjons settet, blir det erstatte instruksjons settet (f.eks. Den optimaliserte sekvensen) lagret i en struktur kodebuffer. Neste gang bytekode kjøres, kan den tidligere optimaliserte koden umiddelbart lokaliseres i kodebufferen og brukes til utføring. I noen tilfeller kan en ytelsesteller sparke inn og overstyre den forrige optimaliseringen, i så fall vil kompilatoren kjøre en ny optimaliseringssekvens. Fordelen med en kodebuffer er at det resulterende instruksjonssettet kan kjøres på en gang - ikke behov for fortolkende oppslag eller kompilering! Dette gir raskere utføringstid, spesielt for Java-applikasjoner der de samme metodene kalles flere ganger.

Optimalisering

Sammen med dynamisk kompilering kommer muligheten til å sette inn performance tellere. Kompilatoren kan for eksempel sette inn en ytelsesteller å telle hver gang en bytekodeblokk (f.eks. tilsvarer en bestemt metode) ble kalt. Kompilatorer bruker data om hvor "hot" en gitt bytecode er for å bestemme hvor i koden optimaliseringer som best vil påvirke applikasjonen som kjører. Runtime-profileringsdata gjør at kompilatoren kan ta et rikt sett med kodeoptimaliseringsbeslutninger på farten, noe som forbedrer ytelsen til kodeutførelse ytterligere. Etter hvert som mer raffinerte kodeprofileringsdata blir tilgjengelige, kan de brukes til å ta flere og bedre optimaliseringsbeslutninger, for eksempel: hvordan bedre sekvensinstruksjoner på det kompilerte språket, om man skal erstatte et sett med instruksjoner med mer effektive sett, eller til og med om man skal eliminere overflødige operasjoner.

Eksempel

Tenk på Java-koden:

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

Dette kan være statisk samlet av javac til bytekoden:

iload0 bipush 7 iadd ireturn

Når metoden kalles, blir bytekodeblokken dynamisk samlet til maskininstruksjoner. Når en ytelsesteller (hvis den er til stede for kodeblokken) treffer en terskel, kan den også bli optimalisert. Sluttresultatet kan se ut som følgende maskininstruksjonssett for en gitt kjøringsplattform:

lea rax, [rdx + 7] ret

Ulike kompilatorer for forskjellige applikasjoner

Ulike Java-applikasjoner har forskjellige behov. Langvarige applikasjoner på serversiden kan gi flere optimaliseringer, mens mindre applikasjoner på klientsiden kan trenge rask kjøring med minimalt ressursforbruk. La oss vurdere tre forskjellige kompilatorinnstillinger og deres respektive fordeler og ulemper.

Kompilatorer på klientsiden

En velkjent optimaliserings kompilator er C1, kompilatoren som er aktivert gjennom -klient JVM oppstartsalternativ. Som oppstartsnavnet antyder, er C1 en kompilator på klientsiden. Den er designet for applikasjoner på klientsiden som har færre ressurser tilgjengelig, og i mange tilfeller er følsomme for oppstartstid for applikasjoner. C1 bruker ytelsestellere for kodeprofilering for å muliggjøre enkle, relativt intrusive optimaliseringer.

Server-kompilatorer

For langvarige applikasjoner som server-Java-Java-applikasjoner, kan det hende at en kompilator på klientsiden ikke er nok. En kompilator på serversiden som C2 kan brukes i stedet. C2 aktiveres vanligvis ved å legge til JVM-oppstartsalternativet -server til oppstartskommandolinjen. Siden det forventes at de fleste programmene på serversiden kjører i lang tid, vil aktivering av C2 bety at du vil kunne samle mer profileringsdata enn du ville gjort med et kortvarig, lett klientprogram. Så du vil kunne bruke mer avanserte optimaliseringsteknikker og algoritmer.

Tips: Varm opp kompilatoren på serversiden

For distribusjon av serversiden kan det ta litt tid før kompilatoren har optimalisert de første "varme" delene av koden, så server-distribusjoner krever ofte en "oppvarmingsfase". Før du gjør noen form for ytelsesmåling på en server-distribusjon, må du sørge for at applikasjonen din har nådd stabil tilstand! Å gi kompilatoren nok tid til å kompilere riktig vil fungere til din fordel! (Se JavaWorld-artikkelen "Se din HotSpot-kompilator gå" for mer om oppvarming av kompilatoren og mekanikken til profilering.)

En serverkompilator står for mer profilering av data enn en kompilator på klientsiden gjør, og tillater mer kompleks grenanalyse, noe som betyr at den vil vurdere hvilken optimaliseringsbane som vil være mer fordelaktig. Å ha mer profildata tilgjengelig gir bedre applikasjonsresultater. Selvfølgelig krever mer omfattende profilering og analyse å bruke mer ressurser på kompilatoren. En JVM med C2 aktivert vil bruke flere tråder og flere CPU-sykluser, krever en større kodebuffer og så videre.

Trinnvis kompilering

Trinnvis kompilering kombinerer klientside og server-side kompilering. Azul gjorde først lagdelt kompilering tilgjengelig i Zing JVM. Mer nylig (fra og med Java SE 7) har den blitt adoptert av Oracle Java Hotspot JVM. Trinnvis kompilering utnytter både klient- og serverkompilatorfordeler i JVM-en din. Klientkompilatoren er mest aktiv under programoppstart og håndterer optimaliseringer utløst av lavere terskler for ytelse. Kompilatoren på klientsiden setter også inn ytelsestellere og forbereder instruksjonssett for mer avanserte optimaliseringer, som vil bli adressert på et senere tidspunkt av kompilatoren på serversiden. Trinnvis kompilering er en veldig ressurseffektiv måte å profilere på, fordi kompilatoren er i stand til å samle inn data under kompilatoraktivitet med lite innvirkning, som kan brukes til mer avanserte optimaliseringer senere. Denne tilnærmingen gir også mer informasjon enn du får ved å bruke tolket kodeprofilteller alene.

Diagramskjemaet i figur 1 viser ytelsesforskjellene mellom ren tolkning, klientside, server-side og trinnvis kompilering. X-aksen viser utførelsestid (tidsenhet) og Y-aksens ytelse (ops / tidsenhet).

Figur 1. Ytelsesforskjeller mellom kompilatorer (klikk for å forstørre)

Sammenlignet med rent tolket kode fører bruk av en kompilator på klientsiden til omtrent 5 til 10 ganger bedre ytelse (i ops / s), og forbedrer dermed applikasjonsytelsen. Variasjonen i gevinst er selvfølgelig avhengig av hvor effektiv kompilatoren er, hvilke optimaliseringer som er aktivert eller implementert, og (i mindre grad) hvor godt utformet applikasjonen er med tanke på målplattformen for utførelse. Sistnevnte er virkelig noe en Java-utvikler aldri trenger å bekymre seg for.

Sammenlignet med en kompilator på klientsiden, øker en kompilator på serversiden vanligvis kodeytelsen med målbare 30 prosent til 50 prosent. I de fleste tilfeller vil ytelsesforbedring balansere de ekstra ressurskostnadene.

$config[zx-auto] not found$config[zx-overlay] not found