Paranna sovellusten suorituskykyä näillä kehittyneillä GC-tekniikoilla

Sovellusten suorituskyky on etusijalla, ja roskienkeräyksen optimointi on hyvä paikka tehdä pieniä, mutta merkityksellisiä edistysaskeleita

Automaattinen roskienkeräys (yhdessä JIT HotSpot-kääntäjän kanssa) on yksi JVM:n edistyneimmistä ja arvostetuimmista komponenteista, mutta monet kehittäjät ja insinöörit tuntevat paljon huonommin roskienkeräystä (GC), sen toimintaa ja vaikutusta sovellusten suorituskykyyn.

Ensin, mitä varten GC edes on? Roskienkeräys on kasassa olevien objektien muistinhallintaprosessi. Kun objekteja allokoidaan kasaan, ne käyvät läpi muutaman keräysvaiheen – yleensä melko nopeasti, koska suurin osa kasassa olevista objekteista on lyhytaikaisia.

Roskienkeräystapahtumat sisältävät kolme vaihetta: merkitseminen, poistaminen ja kopiointi/tiivistäminen. Ensimmäisessä vaiheessa GC käy läpi kasan ja merkitsee kaiken joko eläviksi (viitatuiksi) objekteiksi, viittaamattomiksi objekteiksi tai vapaaksi muistitilaksi. Viittaamattomat objektit poistetaan ja jäljelle jäävät objektit tiivistetään. Sukupolviperiaatteella tapahtuvassa roskienkeruussa objektit ”vanhenevat” ja etenevät elämänsä aikana kolmen tilan kautta – Eden, Survivor space ja Tenured (Old) space. Tämä siirto tapahtuu myös osana tiivistämisvaihetta.

Mutta nyt riittää, siirrytään hauskaan osaan!

Psst! Etsitkö ratkaisua sovellusten suorituskyvyn parantamiseen?OverOps auttaa yrityksiä tunnistamaan, milloin ja missä hidastumisia esiintyy, mutta myös miksi ja miten niitä esiintyy. Katso live-demo nähdäksesi, miten se toimii.

Tutustuminen roskienkeräykseen (GC) Javassa

Yksi automatisoidun GC:n hienoista puolista on se, että kehittäjien ei tarvitse oikeastaan ymmärtää, miten se toimii. Valitettavasti se tarkoittaa, että monet kehittäjät EIVÄT ymmärrä, miten se toimii. Roskienkeräyksen ja monien saatavilla olevien GC:iden ymmärtäminen on vähän kuin Linuxin CLI-komentojen tunteminen. Teknisesti sinun ei tarvitse käyttää niitä, mutta niiden tuntemisella ja niiden käytön omaksumisella voi olla merkittävä vaikutus tuottavuuteesi.

Aivan kuten CLI-komentojenkin kohdalla, on olemassa absoluuttiset perusasiat. ls-komento, jolla voit tarkastella luetteloa vanhemman kansion sisällä olevista kansioista, mv-komento, jolla voit siirtää tiedoston paikasta toiseen, jne. GC:ssä tuollaiset komennot vastaisivat sitä, että tietäisi, että GC:tä on useampi kuin yksi, ja että GC voi aiheuttaa suorituskykyongelmia. Tietysti on paljon muutakin opittavaa (Linux CLI:n käytöstä JA roskienkeruusta).

Javan roskienkeruuprosessin opettelemisen tarkoitus ei ole vain turhia (ja tylsiä) keskustelunaloituksia varten, vaan tarkoitus on oppia, miten toteuttaa ja ylläpitää tehokkaasti oikeanlaista GC:tä, jolla on optimaalinen suorituskyky omaan ympäristöön. Tieto siitä, että roskienkeräys vaikuttaa sovelluksen suorituskykyyn, on perusasia, ja on olemassa monia kehittyneitä tekniikoita, joilla voidaan parantaa GC:n suorituskykyä ja vähentää sen vaikutusta sovelluksen luotettavuuteen.

GC:n suorituskykyyn liittyvät huolenaiheet

Muistivuodot –

Tuntemalla kasan rakenteen ja sen, miten roskienkeräys suoritetaan, tiedämme, että muistin käyttö lisääntyy vähitellen, kunnes roskienkeräystapahtuma sattuu, ja käyttö laskee takaisin alas. Viitattujen objektien kasan käyttö pysyy yleensä tasaisena, joten pudotuksen pitäisi olla suurin piirtein sama määrä.

Muistivuodon yhteydessä jokainen GC-tapahtuma tyhjentää pienemmän osan kasan objekteista (vaikkakin monet jäljelle jäävät objektit eivät ole käytössä), joten kasan käyttö jatkuu, kunnes kasan muisti on täynnä ja OutOfMemoryError-poikkeus heitetään. Syynä tähän on se, että GC merkitsee poistettavaksi vain objektit, joihin ei viitata. Vaikka viitattu objekti ei olisi enää käytössä, sitä ei siis poisteta kasasta. Tämän estämiseksi on olemassa joitakin hyödyllisiä koodaustemppuja, joita käsittelemme hieman myöhemmin.

Jatkuvat ”Pysäytä maailma” -tapahtumat –

Joissain skenaarioissa roskienkeräystä voidaan kutsua ”Pysäytä maailma” -tapahtumaksi, koska sen tapahtuessa kaikki JVM:ssä olevat säikeet (ja näin ollen myös JVM:ssä suoritettava sovellus) pysäytetään, jotta GC:n suorittaminen voidaan suorittaa. Terveissä sovelluksissa GC:n suoritusaika on suhteellisen pieni, eikä sillä ole suurta vaikutusta sovelluksen suorituskykyyn.

Epäoptimaalisissa tilanteissa Stop the World -tapahtumat voivat kuitenkin vaikuttaa suuresti sovelluksen suorituskykyyn ja luotettavuuteen. Jos GC-tapahtuma vaatii Stop the World -tauon ja sen suorittaminen kestää 2 sekuntia, kyseisen sovelluksen loppukäyttäjä kokee 2 sekunnin viiveen, kun sovellusta suorittavat säikeet pysäytetään GC:n mahdollistamiseksi.

Muistivuotojen esiintyessä jatkuvat Stop the World -tapahtumat ovat myös ongelmallisia. Koska jokaisella GC:n suorituksella tyhjennetään vähemmän kasan muistitilaa, jäljelle jäävän muistin täyttymiseen kuluu vähemmän aikaa. Kun muisti on täynnä, JVM käynnistää toisen GC-tapahtuman. Lopulta JVM suorittaa toistuvia Stop the World -tapahtumia, mikä aiheuttaa suuria suorituskykyongelmia.

CPU:n käyttö –

Kaiken taustalla on suorittimen käyttö. Jatkuvien GC / Stop the World -tapahtumien merkittävä oire on CPU-käytön piikki. GC on laskennallisesti raskas operaatio, joten se voi viedä enemmän kuin oman osuutensa suorittimen tehosta. Jos GC:ssä käytetään yhtäaikaisia säikeitä, CPU:n käyttö voi olla vieläkin suurempaa. Oikean GC:n valinnalla sovellukseesi on suurin vaikutus CPU-käyttöön, mutta on myös muita tapoja optimoida suorituskykyä tällä alueella.

Näistä roskienkeruuseen liittyvistä suorituskykyongelmista voimme ymmärtää, että vaikka GC:t olisivat kuinka kehittyneitä (ja niistä on tulossa melko kehittyneitä), niiden akilleenkantapää pysyy samana. Redundantit ja arvaamattomat objektien allokaatiot. Sovelluksen suorituskyvyn parantamiseksi oikean GC:n valinta ei riitä. Meidän on tiedettävä, miten prosessi toimii, ja meidän on optimoitava koodimme niin, että GC:t eivät vedä liikaa resursseja tai aiheuta kohtuuttomia taukoja sovelluksessamme.

Generational GC

Ennen kuin sukellamme erilaisiin Java GC:iin ja niiden suorituskykyyn kohdistuviin vaikutuksiin, on tärkeää ymmärtää geneerisen roskienkeruun perusteet. Generational GC:n peruskonsepti perustuu ajatukseen, että mitä kauemmin viittaus objektiin on olemassa kasassa, sitä epätodennäköisemmin se merkitään poistettavaksi. Merkitsemällä objektit kuvaannollisella ”iällä”, ne voitaisiin erottaa eri tallennustiloihin, jotta GC merkitsisi ne harvemmin.

Kun objekti allokoidaan kasaan, se sijoitetaan niin sanottuun Eden-avaruuteen. Sieltä objektit alkavat, ja useimmissa tapauksissa ne merkitään sinne poistettavaksi. Objektit, jotka selviävät tästä vaiheesta, ”juhlivat syntymäpäivää” ja kopioidaan Survivor-avaruuteen. Tämä prosessi on esitetty alla:

Eden- ja Survivor-avaruudet muodostavat niin sanotun Young Generation -avaruuden. Täällä tapahtuu suurin osa toiminnasta. Kun (Jos) Nuoressa sukupolvessa oleva objekti saavuttaa tietyn iän, se ylennetään Tenured-avaruuteen (jota kutsutaan myös Vanhaksi). Objektien muistien jakamisesta iän perusteella on se hyöty, että GC voi toimia eri tasoilla.

Minor GC on kokoelma, joka keskittyy vain Young Generationiin ja jättää Tenured-avaruuden käytännössä kokonaan huomiotta. Yleensä suurin osa Young Generationin objekteista on merkitty poistettavaksi, eikä Major- tai Full GC (mukaan lukien Old Generation) ole tarpeen muistin vapauttamiseksi kasasta. Toki Major- tai Full GC käynnistetään tarvittaessa.

Yksi nopeaksi niksiksi GC:n toiminnan optimoimiseksi tämän perusteella on säätää heap-alueiden kokoja niin, että ne sopivat parhaiten sovellusten tarpeisiin.

Keräintyypit

Valittavanasi on monia saatavilla olevia GC:itä, ja vaikka G1:stä tuli oletus-GC:stä Java 9:ssä, se oli alunperin tarkoitus korvata CMS-keräin, joka on Low Pause -keräinjärjestelmä, joten sovellusten, jotka toimivat läpimenokerääjiä käyttävillä sovelluksillakin, voi olla parempi pysyä nykyisessä keräimessääsi. Javan roskienkerääjien toiminnallisten erojen ja suorituskykyvaikutusten erojen ymmärtäminen on silti tärkeää.

Tuloskerääjät

Parempi sovelluksille, jotka on optimoitava suureen läpimenotehoon ja jotka voivat tinkiä suuremmasta latenssista sen saavuttamiseksi.

Sarjakeräin –

Sarjakeräin on yksinkertaisin ja se, jota todennäköisesti vähiten käytät, sillä se on suunniteltu lähinnä yksisäikeisiin ympäristöihin (esim. 32-bittiset tai Windows) ja pienille kasoille. Tämä keräilijä voi skaalata muistin käyttöä JVM:ssä vertikaalisesti, mutta se vaatii useita Major/Full GC:tä vapauttaakseen käyttämättömiä kasan resursseja. Tämä aiheuttaa usein Stop the World -taukoja, mikä sulkee sen kaikin tavoin pois käytöstä käyttäjäkohtaisissa ympäristöissä.

Parallel –

Nimensä mukaisesti tämä GC käyttää useita rinnakkain toimivia säikeitä kasan läpikäymiseen ja tiivistämiseen. Vaikka Parallel GC käyttää useita säikeitä roskienkeruuseen, se keskeyttää silti kaikki sovellussäikeet suorituksen aikana. Rinnakkaiskerääjä sopii parhaiten sovelluksiin, jotka on optimoitava parhaan läpimenon saavuttamiseksi ja jotka voivat sietää vastineeksi suurempaa viiveaikaa.

Matalan tauon keräilijät

Useimmat käyttäjälle suunnatut sovellukset vaativat matalan tauon GC:n, jotta pitkät tai usein toistuvat tauot eivät vaikuta käyttäjäkokemukseen. Näissä GC:ssä on kyse reagointikyvyn (aika/tapahtuma) ja vahvan lyhyen aikavälin suorituskyvyn optimoinnista.

Concurrent Mark Sweep (CMS) –

Samankaltainen kuin rinnakkaiskollektori, Concurrent Mark Sweep (CMS) -keräilijä käyttää useita säikeitä merkitsemään ja pyyhkäisemään (poistamaan) viittaamattomia objekteja. Tämä GC käynnistää Stop the World -tapahtumat kuitenkin vain kahdessa erityisessä tapauksessa:

(1) alustettaessa juurien (vanhan sukupolven objektit, jotka ovat tavoitettavissa säikeen tulopisteistä tai staattisista muuttujista) tai kaikkien main()-metodista tulevien viittausten alustavaa merkitsemistä, ja muutama muu

(2), kun sovellus on muuttanut kasan tilaa algoritmin suorittaessa rinnakkain, pakottaa sen palaamaan takaisin ja tekemään viimeisiä korjauksia varmistaakseen, että oikeat objektit on merkitty

G1 –

Garbage first -keräilijä (yleisesti tunnettu nimellä G1) käyttää useita taustasäikeitä skannatakseen kasan läpi, jonka se jakaa alueisiin. Se toimii skannaamalla ensin ne alueet, jotka sisältävät eniten roskaobjekteja, mistä se sai nimensä (Garbage first).

Tämä strategia vähentää mahdollisuutta, että kasa tyhjenee ennen kuin taustasäikeet ovat lopettaneet käyttämättömien objektien skannaamisen, jolloin keräilijä joutuisi pysäyttämään sovelluksen. G1-kerääjän etuna on myös se, että se tiivistää kasan käynnissä, mitä CMS-kerääjä tekee vain täydellisten Stop the World -keräysten aikana.

GC-suorituskyvyn parantaminen

Sovelluksen suorituskykyyn vaikuttaa suoraan roskienkeräysten tiheys ja kesto, joten GC-prosessin optimointi tapahtuu vähentämällä kyseisiä mittareita. Tähän on kaksi pääasiallista tapaa. Ensinnäkin säätämällä nuorten ja vanhojen sukupolvien kasakokoja ja toiseksi vähentämällä objektien allokointi- ja edistämisnopeutta.

Kasakokojen säätäminen ei ole niin suoraviivaista kuin voisi olettaa. Looginen johtopäätös olisi, että kasan koon kasvattaminen vähentäisi GC:n taajuutta samalla kun sen kesto kasvaisi, ja kasan koon pienentäminen vähentäisi GC:n kestoa samalla kun sen taajuus kasvaisi.

Tosiasiassa on kuitenkin niin, että Minor GC:n kesto ei ole riippuvainen kasan koosta vaan keräilystä selviytyvien objektien määrästä. Tämä tarkoittaa, että sovelluksissa, jotka luovat enimmäkseen lyhytikäisiä objekteja, nuoren sukupolven koon kasvattaminen voi itse asiassa vähentää sekä GC:n kestoa että taajuutta. Jos nuoren sukupolven koon kasvattaminen johtaa kuitenkin siihen, että selviytymisalueilla kopioitavien objektien määrä kasvaa merkittävästi, GC-tauot kestävät pidempään, mikä lisää viiveaikaa.

3 vinkkiä GC-tehokkaan koodin kirjoittamiseen

Vinkki #1: Ennusta kokoelmakapasiteetit –

Kaikki Javan standardikokoelmat sekä useimmat mukautetut ja laajennetut toteutukset (kuten Trove ja Googlen Guava) käyttävät taustalla olevia matriiseja (joko primitiivi- tai oliopohjaisia). Koska matriisien koko on muuttumaton sen jälkeen, kun ne on allokoitu, kohteiden lisääminen kokoelmaan voi monissa tapauksissa aiheuttaa sen, että vanhasta taustalla olevasta matriisista luovutaan suuremman, juuri allokoidun matriisin hyväksi.

Useimmat kokoelmatoteutukset pyrkivät optimoimaan tämän uudelleenallokointiprosessin ja pitämään sen amortisoituneena minimissään, vaikka kokoelman odotettua kokoa ei annettaisikaan. Parhaat tulokset saavutetaan kuitenkin antamalla kokoelmalle sen odotettu koko sen rakentamisen yhteydessä.

Vinkki #2: Käsittele tietovirtoja suoraan –

Käsiteltäessä tietovirtoja, kuten esimerkiksi tiedostoista luettua dataa tai verkon kautta ladattua dataa, on hyvin tavallista, että näkee jotakin samansuuntaista kuin:

Tuloksena syntyvä tavujoukko voitaisiin sitten jäsentää XML-dokumentiksi, JSON-olioksi tai Protocol Buffer -sanomaksi, mainitakseni vain muutamia suosittuja vaihtoehtoja.

Käsiteltäessä suuria tai arvaamattoman kokoisia tiedostoja tämä on tietenkin huono ajatus, koska se altistaa meidät OutOfMemoryError-virheille siinä tapauksessa, että JVM ei itse asiassa pysty varaamaan koko tiedoston kokoista puskuria.

Parempi tapa lähestyä tätä asiaa on käyttää sopivaa InputStream-virtaa (tässä tapauksessa FileInputStream-virtaa) ja syöttää se suoraan jäsentäjään lukematta sitä ensin koko tiedostoa tavutietomääräkkeeksi. Kaikki suuret kirjastot paljastavat API:t, joilla voi parsia streameja suoraan, esimerkiksi:

Vinkki #3: Käytä muuttumattomia objekteja –

Muuttumattomuudella on monia etuja. Yksi, johon harvoin kiinnitetään sen ansaitsemaa huomiota, on sen vaikutus roskienkeruuseen.

Muuttumaton objekti on objekti, jonka kenttiä (ja tapauksessamme nimenomaan ei-primitiivisiä kenttiä) ei voi muuttaa sen jälkeen, kun objekti on konstruoitu.

Muuttumattomuus edellyttää, että kaikki muuttumattoman säiliön viittaamat objektit on luotu ennen kuin säiliön konstruointi on valmis. GC:n termein ilmaistuna: Kontti on vähintään yhtä nuori kuin nuorin sen hallussa oleva viittaus. Tämä tarkoittaa, että suorittaessaan roskienkeräysjaksoja nuorille sukupolville GC voi ohittaa vanhemmissa sukupolvissa sijaitsevat muuttumattomat objektit, koska se tietää varmasti, etteivät ne voi viitata mihinkään kerättävässä sukupolvessa olevaan.

Vähemmän skannattavia objekteja tarkoittaa vähemmän skannattavia muistisivuja, ja vähemmän skannattavia muistisivuja tarkoittaa lyhyempiä GC-syklejä, mikä taas tarkoittaa lyhyempiä GC-taukoja ja parempaa kokonaissuorituskykyä.

Lisävinkkejä ja yksityiskohtaisia esimerkkejä löydät tästä postauksesta, jossa käsitellään syvällisiä taktiikoita muistitehokkaamman koodin kirjoittamiseen.

*** Suuret kiitokset Amit Hurvitzille OverOpsin R&D-tiimistä intohimostaan ja näkemyksistään, jotka ovat sisältyneet tähän postaukseen!

Jätä kommentti