Skip to main content

29 Tekniikkaa

29Tekniikka logo

Java-palvelin ei yleensä kaadu ilman syytä

Monessa ohjelmistoyrityksessä tulee ennemmin tai myöhemmin vastaan tilanne, jossa muuten hyvin toiminut Java-palvelu alkaa käyttäytyä oudosti. Muistia kuluu päivä päivältä enemmän. Roskienkeruu käy yhä useammin. Vastaukset hidastuvat. Lopulta palvelu käynnistyy uudelleen tai pahimmassa tapauksessa pysähtyy kokonaan.

Ensimmäinen reaktio on usein epäillä JVM:n asetuksia tai palvelimen resursseja. Muistia lisätään, prosessoritehoa kasvatetaan ja toivotaan parasta.

Usein ongelma ei kuitenkaan ratkea.

Syy löytyy monesti sovelluksesta itsestään. Jokin tieto jää muistiin pidemmäksi aikaa kuin oli tarkoitus. Yksittäinen objekti ei vielä aiheuta ongelmaa, mutta kuukausien aikana niitä kertyy tuhansia tai miljoonia.

Tätä kutsutaan muistivuodoksi.

Java-kehittäjille aihe voi tuntua hämmentävältä, koska kielessä on automaattinen roskienkeruu. Moni ajattelee, että muistivuodot kuuluvat C- tai C++-maailmaan, eivät Javaan.

Todellisuudessa Java-sovellukset kärsivät muistivuodoista yllättävän usein. Ero on vain siinä, että vuoto ei synny varaamattoman muistin vapauttamatta jättämisestä, vaan siitä, että ohjelma säilyttää viittauksia objekteihin, joita ei enää tarvittaisi.

Miten muistivuoto näkyy tuotannossa?

Harvoin kukaan huomaa muistivuotoa ensimmäisenä koodista.

Yleensä huomio kiinnittyy oireisiin.

Palvelu, joka aiemmin vastasi nopeasti, alkaa ajoittain hidastua. Käyttökatkojen määrä kasvaa. Kontit käynnistyvät uudelleen. Muistinkulutus näyttää kasvavan päivä päivältä ilman selvää syytä.

Monessa tapauksessa ongelma havaitaan vasta silloin, kun valvontajärjestelmä lähettää hälytyksen.

Tyypillinen kehitysympäristö ei paljasta tällaisia ongelmia. Testipalvelu voi olla käynnissä muutaman tunnin tai päivän. Tuotannossa sama sovellus voi pyöriä kuukausia yhtäjaksoisesti ja käsitellä miljoonia tapahtumia.

Juuri silloin pienet virheet alkavat näkyä.

Muistan erään projektin, jossa palvelu toimi moitteettomasti testauksen aikana. Tuotannossa muistinkulutus kuitenkin nousi tasaisesti viikkojen ajan. Aluksi ero oli niin pieni, ettei siihen kiinnitetty huomiota. Kuukauden kuluttua JVM käytti lähes kaiken sille annetun muistin.

Lopulta syylliseksi paljastui välimuisti, josta tietoja ei koskaan poistettu.

Koodi vaikutti ensi silmäyksellä täysin järkevältä.

Yksi yleisimmistä syistä: rajaton välimuisti

➠ Välimuisti on erinomainen työkalu suorituskyvyn parantamiseen.
➠ Ongelma syntyy silloin, kun välimuistin kasvulle ei aseteta rajoja.
➠ Kehittäjä lisää HashMapin tai muun rakenteen nopeuttamaan hakuja. Aluksi käyttäjiä on vähän ja kaikki toimii hyvin. Myöhemmin käyttäjämäärät kasvavat, tietoa kertyy enemmän ja muisti alkaa täyttyä.
➠ Jos vanhoja tietoja ei poisteta koskaan, järjestelmä kerää niitä loputtomasti.
➠ Tällainen virhe ei yleensä näy heti. Juuri siksi sitä on vaikea havaita.
➠ Nykyisin on harvinaista rakentaa välimuistia täysin itse. Useimmissa projekteissa käytetään esimerkiksi Caffeinea tai Redis-pohjaisia ratkaisuja, joissa vanhentuminen ja kokorajat voidaan määritellä selkeästi.
➠ Silti ongelmia syntyy edelleen, etenkin sisäisissä apurakenteissa ja vanhemmissa järjestelmissä.

Staattiset rakenteet voivat jäädä elämään vuosiksi

Toinen toistuva ongelma liittyy staattisiin muuttujiin.

Kun tieto tallennetaan static-kokoelmaan, sen elinkaari muuttuu. Data pysyy muistissa niin kauan kuin sovellus on käynnissä.

Tämä ei ole ongelma, jos tallennettava tieto on aidosti pysyvää.

Ongelma syntyy silloin, kun rakenteeseen alkaa kertyä käyttäjäkohtaisia tai tapahtumakohtaisia tietoja.

Moni kehittäjä on joskus lisännyt väliaikaisen ratkaisun tuotanto-ongelman ympärille ja ajatellut palaavansa asiaan myöhemmin. Sitten ratkaisu jää käyttöön vuosiksi.

Kun muistivuoto lopulta havaitaan, kukaan ei enää muista miksi kyseinen rakenne alun perin lisättiin.

Tapahtumakuuntelijat ja rekisteröinnit unohtuvat helposti

Muistivuoto ei aina liity suuriin tietomääriin.

Joskus riittää, että jokin komponentti rekisteröi itsensä kuuntelijaksi mutta ei koskaan poista rekisteröintiä.

Tällöin alkuperäinen objekti pysyy muistissa, vaikka sitä ei enää käytettäisi.

Tämän tyyppiset vuodot voivat olla hankalia, koska ne eivät välttämättä näy heti muistinkulutuksessa. Sen sijaan järjestelmään kertyy vähitellen suuri määrä vanhoja instansseja.

Kun niitä alkaa olla tarpeeksi paljon, vaikutukset näkyvät suorituskyvyssä.

Kun muistia alkaa kadota, mistä tutkiminen kannattaa aloittaa?

➠ Moni lähtee etsimään ongelmaa lähdekoodista.
➠ Käytännössä tutkiminen kannattaa aloittaa mittareista.
➠ Ensimmäinen kysymys ei ole “missä muistivuoto on”, vaan “kasvaako muistinkäyttö oikeasti jatkuvasti”.
➠ Jos muistinkäyttö nousee ja laskee normaalisti, kyse voi olla täysin terveestä JVM:n käyttäytymisestä.
➠ Jos taas käytössä oleva muistimäärä nousee jokaisen roskienkeruun jälkeen hieman korkeammalle tasolle kuin aiemmin, kannattaa kiinnostua asiasta.
➠ Silloin on syytä ottaa talteen heap dump.
➠ Heap dump on käytännössä kuva JVM:n muistista tietyllä hetkellä. Sen avulla voidaan nähdä, mitä objekteja muistissa on ja miksi ne ovat siellä edelleen.
➠ Juuri tästä syystä heap dump on usein tärkein yksittäinen työkalu muistivuotojen tutkimisessa.

Mitä heap dumpista kannattaa etsiä?

Ensimmäisellä kerralla heap dump voi näyttää pelottavalta.

Objekteja on valtava määrä ja näkymä täynnä teknisiä yksityiskohtia.

Kokeneet kehittäjät eivät yleensä etsi ensimmäisenä yksittäistä virhettä. He etsivät poikkeavaa käyttäytymistä.

Jos jokin luokka vie huomattavan paljon muistia, se herättää kysymyksiä.

Miksi näitä objekteja on näin paljon?

Miksi ne ovat edelleen olemassa?

Mikä pitää niihin viittausta yllä?

Usein vastaus löytyy viittausketjusta.

Yksi väärässä paikassa oleva kokoelma voi pitää muistissa tuhansia objekteja, joiden olisi pitänyt poistua jo kauan sitten.

Työkalut auttavat, mutta eivät ratkaise ongelmaa puolestasi

VisualVM, Eclipse MAT, Java Flight Recorder ja monet muut työkalut ovat erittäin hyödyllisiä.

Silti ne ovat vain apuvälineitä.

Työkalu voi kertoa, että tiettyä objektia on miljoona kappaletta muistissa. Se ei kuitenkaan kerro, miksi sovellus on suunniteltu pitämään ne siellä.

Siksi muistivuotojen korjaaminen vaatii lähes aina myös sovelluksen liiketoimintalogiikan ymmärtämistä.

Pelkkä tekninen analyysi ei aina riitä.

Miksi ongelma löytyy usein vasta tuotannossa?

➠ Tämä on kysymys, jonka kuulen jatkuvasti.
➠ Vastaus on yleensä yksinkertainen.
➠ Tuotannossa on enemmän käyttäjiä, enemmän dataa ja enemmän aikaa.
➠ Moni virhe tarvitsee viikkoja tai kuukausia ennen kuin sen vaikutukset näkyvät.
➠ Jos sovellus käsittelee kehitysympäristössä tuhat tapahtumaa päivässä mutta tuotannossa miljoona, käyttäytyminen voi muuttua täysin.
➠ Sama koodi, sama infrastruktuuri ja sama JVM voivat näyttää aivan erilaisilta eri kuormitustasoilla.

Muistivuodon estäminen on helpompaa kuin sen korjaaminen

Paras muistivuoto on sellainen, jota ei koskaan synny.

Käytännössä tämä tarkoittaa muutamia melko yksinkertaisia asioita.

Välimuisteille asetetaan kokorajat.

Tietojen vanhentuminen määritellään etukäteen.

Pitkäikäisiin kokoelmiin tallennetaan vain sellaista dataa, joka todella kuuluu sinne.

Kuormitustestit tehdään riittävän realistisilla datamäärillä.

Lisäksi muistinkulutusta seurataan jatkuvasti eikä vasta silloin, kun järjestelmä alkaa oireilla.

Moni kallis tuotanto-ongelma olisi havaittu paljon aikaisemmin, jos muistinkäytölle olisi ollut selkeät mittarit ja hälytysrajat.

Lopuksi

Java-muistivuotojen hankalin puoli on se, että ne näyttävät usein aluksi täysin tavallisilta suorituskykyongelmilta. Palvelu hidastuu vähän kerrallaan, muistia kuluu hieman enemmän kuin ennen ja roskienkeruu tekee yhä enemmän töitä.

Siksi ongelmaa ei kannata lähestyä ensimmäisenä JVM-asetusten tai palvelinresurssien kautta.

Parempi lähtökohta on kysyä yksi yksinkertainen kysymys:

"Mikä tieto jää muistissa elämään pidempään kuin sen pitäisi?"

Kun siihen löytyy vastaus, myös varsinainen muistivuoto löytyy yleensä paljon nopeammin.