Haldy nejsou tak velké, jak se se zdají být

Nedávno jsem narazil na zajímavý test, který se snažil nahrubo určit kolik paměti potřebují různé datové struktury na JVM: kolekce z Javy, Scaly, Googlí Guava a kolekce Trove specializované pro primitivní typy. Test probíhal tak, že nastartoval JVM s 1GB heap, vytvořil danou kolekci, začal přidávat jeden element za druhým až do okamžiku, kdy došla paměť a virtuální stroj vyhodil OutOfMemoryError. Potom autoři prohlásili, že daná kolekce s výsledným počtem elementů zabere plus/mínus jeden gigabajt paměti.

To všechno vypadalo celkem rozumně až do okamžiku, kdy jsem si prošel výsledky. Z nich vyplývalo, že do jednoho gigabajtu paměti se vejde Trove TIntArrayList (který ukládá neboxované integery) o délce nejvýše 10485760 elementů. Neboli 40 megabajtů dat.

Tady něco nehraje. Čekal jsem, že to bude desetkrát až dvacetkrát více.

Hned jsem začal vrtat v implementaci Trove kolekcí, zdrojácích testu a měřit velikosti objektů a za chvíli jsem zjistil, že Trove TIntArrayList je implementován polem, které má výchozí kapacitu 10 a když se naplní, alokuje větší pole, překopíruje do něj starý obsah a přidá nová data. Velikost nového pole se rovná staré velikost posunuté o jeden bit doleva. A 10 << 20 je právě 10485760, což znamená, že test narazil na OOM, když TIntArrayList prováděl jednadvacáté zvětšení. V ten okamžik se na haldě nacházelo staré pole délky 10M a snaha alokovat nové o délce 20M selhala. Ale i když sečteme velikosti těchto dvou polí, dostaneme pouhých 120MB dat na 1GB haldě než JVM začne kapitulovat.

Tady pořád něco nehraje.

Nastartoval jsem tedy VisualVM, abych se podíval, co se ve virtuálním stroji doopravdy děje a hned mě to udeřilo do očí: generační garbage collector.

Pokud JVM používá tento GC, rozdělí haldu do několika oblastí: eden, survivor a old a žádná z nich není dost velká na to, aby pojala gigantické pole. A protože data nemůžou přečnívat z jedné oblasti do druhé, není možné alokovat pole větší než nejmenší z těchto regionů, což bude nejspíš survivor, který je tvořen dvěma nezávislými polovinami mezi kterými se kopírují data.

Tato teorie také vysvětluje, proč „fragmentované“ kolekce, které nepotřebují velká souvislá pole, mají v testu naměřenou poměrně velkou kapacitu, která je rozhodně větší než by odpovídalo rozdílu mezi boxovanými a primitivními typy.

Nejsnadnější způsob, jak tento problém řešit, když na něj narazíte, je nastavit větší heap a dál se o nic nestarat.

Flattr this!

This entry was posted in JVM, Paměť. Bookmark the permalink.

5 Responses to Haldy nejsou tak velké, jak se se zdají být

  1. peter says:

    tie 3 oblasti sa na zaciatku nastavia na 3 rovnako velke casti? tie velkosti su neni dynamicke? resp. da sa nastavit aby boli dynamicke?
    alebo sa da nastavit ina strategia gc? narazam teraz na to, ze pokial mi aplikacia bezi na starsom JVM a ja ju upgradnem na vyssiu verziu, ktora uz pouziva iny GC, tak samotne GC mi moze robit problemy, ktore som predtym nemal

    • Mám dojem, že JVM používá generační GC od doby, kdy Java byla ve verzi 1.3 – tedy už celých 14 let. V tomhle případě bych se nebál, že se něco změní.

      Pokud se nemýlím, velikosti jednotlivých generací se mění dynamicky podle potřeby. Ale když to nebude stačit, JVM má asi 850 přepínačů a některý z nich určitě změní požadované chování nebo nastaví, který GC použít (JVM jich má v sobě několik). Ale to všechno jsou extrémní případy. Většinou stačí nastavit dost paměti přes -Xmx a všechno funguje tak, jak má.

    • Zdenek Henek says:

      Java (zatim) neodstranila zadny GC, takze muzete pouzivat stejnou implementatci GC i na nejnovejsi verzi. Jedine, co by se mohlo zmenit jsou parametry XX, ktere nejsou garantovane. Nepamatuji se, ze bysme meli nejaky problem pri prechodu z JDK 1.6ky na 1.7 v gc parametrech. Pokud mate problem s pauzama zkuste Concurrent mark sweep. My pouzivame pouze Concurrent mark sweep.

  2. kobul says:

    Pokud se nepletu, tak u „Mark and Sweep“ garbage collectoru jsou ty generace nastavené napevno a to poměrem „old“ a „young“ generace. V OLD jsou déle žijící objekty, nové objekty se vytváří v „young“. Young je ještě rozdělená na dvě stejně velké části, mezi kterými se objekty kopírují sem-tam. To v důsledku znamená, že nově vytvořený objekt může být max. velký jako polovina young generace. Velikost této generace se dá nastavit parametrem -XX:NewRatio=?, který udává poměr young generace ku old gen.
    Příklad:
    Xmx=1G -XX:NewRatio=4
    Poměr young:old je 1:4 tedy young = 200MB, 1/2 young = 100MB takže max. objekt, který je možno vytvořit, bude mít 100MB – proto 120MB narazilo na OOME…

Leave a Reply

Your email address will not be published. Required fields are marked *