Velikost objektů v Javě
O Javě se říká, že je paměťově náročná. Z části to může být pravda, ale často jde jen o nešetrné programy, které nerespektují jak Java alokuje paměť. V článku ukážu jak spočítat velikost objektů a polí. To může pomoct ke zredukování kolik paměti si program vyžádá. V extrémních případech to může seříznout velikost haldy z jednotek gigabajtů na stovky megabajtů.
Důležité: Pro průzkum, jak jsou objekty rozloženy v paměti, se hodí nástroj JOL (Java Object Layout), který ukáže všechny detaily a tajemství na právě běžícím JVM. Používejte ho pro ověření všeho, co tu píšu.
Než můžu začít mluvit o velikosti celých objektů a polí, musím vědět, kolik paměti zabírají jednotlivé primitivní typy. To ukazuje následující tabulka. Jediná zajímanost je to, že boolean vyžaduje 1 bajt i když reprezentuje jenom jeden bit informace. To platí i v případě polí, kde si každý bool vyžádá celý bajt1 .
| typ | velikost |
|---|---|
| byte, boolean | 1 B |
| short, char | 2 B |
| int, float | 4 B |
| long, double | 8 B |
Objekty
Každý objekt (aspoň v případě OpenJDK2 ) začíná hlavičkou, která obsahuje identity hash code3 , ukazatel na třídu objektu a nějaké další bity pro zamykání a garbage collector. Na 32 bitových platformách (nebo 64 bitových s komprimovanými referencemi) je velká 12 bajtů a na 64 bitových platformách 16 bajtů. Za hlavičkou následují atributy objektu (fields, instanční proměnné). Jejich pořadí se řídí pěti pravidly:
- Každý objekt je zarovnán na násobek 8 bajtů. (Dá se změnit přes
-XX:ObjectAlignmentInBytes) - Atributy objektů jsou řazeny podle velikosti: nejdřív long/double, pak int/float, char/shorts, byte/boolean a jako poslední reference na objekty. Atributy jsou vždy zarovnány na násobek vlastní velikosti.
- Atributy patřící různým třídám hierarchie dědičnosti se nikdy nemíchají dohromady. Atributy předka se v paměti nacházejí před atributy potomků.
- První atribut potomka musí být zarovnán na 4 bajty, takže za posledním atributem předka může být až tříbajtová mezera.
- Pokud je první atribut potomka long/double před kterým by zarovnáním vznikla čtyřbajtová mezera (předek není zarovnán na 8 bajtů, nebo následují po 12 bajtové hlavičce), long/double se může přesunout tak, aby menší typy vyplnily čtyřbajtovou mezeru.
Atributy jsou zarovnány na násobek vlastní velikosti proto, že pro procesor je obvykle rychlejší načíst například 4 bajty paměti do čtyřbajtového registru pokud se nachází na adrese zarovnané právě na 4 bajty. Kdyby JVM zachovávalo pořadí atributů a zároveň je zarovnávalo, jak to dělávají C kompilátory, objekty by byly plné nevyužitých děr. Tím, že atributy seřadí od největších (long/double) po nejmenší (byte/bool) dosáhne minimální velikosti objektu, ve kterém jsou všechny atributy přirozeně zarovnány.
Následující příklady ukážou, jak vypadá paměť alokovaná hypotetickými objekty (všechny příklady uvažují 64bitové JVM s komprimovanými referencemi):
class X { byte b; int i; long l; }
| header | int | long |b|xxxxxxxxxxxxxxxxxxx| | 12B | 4B | 8B |1| 7B padding | |-----------------------|-------|---------------|-|-------------------|
class Parent { int pi; short ps; } class Child extends Parent { int ci; short cs; }
| header | pi |ps |xxx| ci |cs |xxx| | 12B | 4B |2B |2B | 4B |2B |2B | |-----------------------|-------|---|---|-------|---|---|
class Parent { int i; short s; byte b; } class Child extends Parent { long l; int ii; }
| header | i |s |b|x| int | long | | 12B | 4B |2B |1|1| 4B | 8B | |-----------------------|-------|---|-|-|-------|---------------|
class Cons { Object head; Object tail; }
| header | head | tail |xxxxxxx| | 12B | 4B | 4B | 4B | |-----------------------|-------|-------|-------|
Pole
Pole jsou na tom podobně jako objekty, ale jejich hlavička obsahuje navíc jeden čtyřbajtový int udávající délku pole. Pak následuje samotný obsah pole, zase zarovnán na násobek 8 bajtů.
Pokud pole obsahuje osmibajtové primitivní typy (long nebo double), hodnoty stále musí být zarovnány na 8 bajtů, takže na 64bit platformách je za hlavičkou čtyřbajtová mezera a teprve po ní následují data.
new byte[] { 0, 0 }
| header |length |b|b|xxxxxxxxxxx| | 12B | 4B |1|1|6B | |-----------------------|-------|-|-|-----------|
new long[] {0}
| header |length | long | | 12B | 4B | 8B | |-----------------------|-------|---------------|
Situace se dá shrnout do několika vzorců:
32 bitové platformy:
- Objekty: 12B hlavičky + velikost atributů předků zarovnaných na 4 bajty + součet velikostí všech typů zarovnaný nahoru na 8B
- Pole byte, bool, short, char, int, float, long, double: 16B hlavičky + length * velikost typu to celé zarovnané nahoru na 8 bajtů
- Pole referencí: 16B hlavičky + length * 4B to celé zarovnané nahoru na 8 bajtů
64 bitové platformy:
- Objekty: 16B hlavičky + velikost atributů předků zarovnaných na 4 bajty + součet velikostí všech typů zarovnaný nahoru na 8B
- Pole byte, bool, short, char, int, float: 20B hlavičky + length * velikost typu to celé zarovnané nahoru na 8 bajtů
- Pole long, double nebo referencí: 20B hlavičky + 4B padding + length * 8B
Příklady
String: Řetězec je reprezentován jako objekt, který odkazuje na
vnitřní pole bajtů. Samotný objekt String má 12B hlaviček, 4B
hashcode, 4B reference na byte[], 1B info jestli jde o ASCII nebo UTF string,
bool hashIsZero a 2B padding. Vnitřní pole má: 12B hlavičky, 4B délku pole +
(počet znaků) × 1 nebo 2 bajty. Stringová část zabírá 24 bajtů, vnitřní pole má
režii 16 bajtů + 0 až 7 bajtů pro zarovnání pole. Dohromady to dělá 40 - 47
extra bajtů na jeden String s komprimovanými referencemi.
List
Neměnný spojový seznam (jaký se používá ve Scale) je složený z řetězu
Cons buněk ukončených buňkou Nil. Nil má jenom jednu instanci v celém
virtuálním stroji a tak se jí není třeba zabývat. Cons obsahuje dva atributy:
vlastní hodnotu head a referenci tail na následující buňku. Protože je
List generický a JVM provádí type erasure, head je vždy reference. Pokud
odkazuje na primitivní typ, ten je autoboxingem zabalen do objektu (aspoň dokud
nebude projekt Valhalla začleněn).
Cons tedy zabírá: 12B hlavičky, 2 reference po 4B a 4B obětované na oltář
zarovnání paměti -- tedy 24 bajtů na jednu Cons buňku (32 na 64 bitových
platformách). Taková je daň za O(1) čtení a manipulaci začátku seznamu.
Ale teď si představte, že v této datové struktuře budete chtít ukládat celá čísla a ty musejí projít autoboxingem.
Objekt Integer má: 12B hlaviček a 4B dat, tedy dalších 12 bajtů režie.
Dohromady je potřeba 40 bajtů (nebo 56 bajtů na 64 bitových platformách),
abychom mohli uložit 4 bajty dat do spojového seznamu. Naproti tomu pole
primitivních integerů má pouze konstantní režii 16B, která je velice rychle
amortizována. V takových případech stojí za to zvážit, jestli nebude vhodnější
použít nějakou kompaktnější datovou strukturu jako třeba pole,
scala.collection.immutable.Vector nebo pro extrémní případy
specializované kolekce v duchu Trove a Colt.
HashMap
Existuje mnoho způsobů, jak implementovat hashmapu, ale teď budu uvažovat způsob, jak je implementována ve standardní knihovně Javy -- polem, které ukazuje na spojový seznam (closed addressing):
class Map<K, V> { Entry<K, V>[] buckets; } class Entry<K, V> { Bucket<K, V> next; K key; V value; }
Je potřeba pole referencí, přibližně velké jako počet elementů v mapě. Každá
reference, která obsahuje nějakou hodnotu, vede na Entry a ta má tři
reference: další Entry v řadě, klíč mapy a hodnotu.
Entry má 12B hlavičky + 12B referencí, dohromady 24B (nebo 40B na 64 bitových platformách) + 4B reference na jeden pár klíč/hodnota v poli a to nepočítám data, která zaberou samotné objekty klíčů a hodnot a případné paměťové náklady spojené s autoboxingem.
Odkazy:
- How much memory is used by my Java object?
- Java Objects Memory Structure
- Jak efektivně jsou uložena pole a řetězce na haldě?
Pozn:
- Pokud chci kompaktní pole, musím použít BitSet nebo BitMap
- Jednotlivé virtuální stroje se mohou lišit, jak v paměti reprezentují objekty.
- Vrací ho standardní implementace metody
hashCode, nebo se k němu můžu dostat voláním java.lang.System.identityHashCode