Než můžeme začít mluvit o velikosti celých objektů a polí, musíme vědět, kolik paměti zabírají jednotlivé primitivní typy. To ukazuje následující tabulka. Za povšimnutí stojí fakt, že boolean vyžaduje 1 bajt, i když reprezentuje jenom jeden bit informací. To stejné platí i o poli, kde si každý bool vyžádá celý jeden bajt1.
typ | velikost |
---|---|
byte, boolean | 1 B |
short, char | 2 B |
int, float | 4 B |
long, double | 8 B |
Objekty
Každý objekt (aspoň na nejrozšířenějším JVM od Oracle2) začíná hlavičkou o délce dvou procesorových slov, která obsahují hashCode identity objektu3, ukazatel na třídu objektu a nějaké další příznaky. Na 32 bitových platformách (nebo 64 bitových s komprimovanými pointery) to představuje celkem 8 bajtů, na 64 bitových pak 16 bajtů. Za hlavičkou následují všechny 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ů.
- Atributy objektů jsou řazeny podle velikosti: nejdřív long/double, pak int/float, char/shorts, byte/boolean a jako poslední reference na jiné 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 a předek není zarovnán na 8 bajtů, long/double se může přesunout až na konec potomkových atributů, 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, 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.
Ukážeme si několik příkladů, jak vypadá paměť alokovaná hypotetickými objekty (všechny příklady uvažují 32bitové JVM):
class X { byte b; int i; long l; }
| header | long | int |b|xxxxx| | 8B | 8B | 4B |1| 3B | |---------------|---------------|-------|-|-----|
class Parent { int pi; short ps; }
class Child { int ci; short cs; } extends Parent
| header | pi |ps |xxx| ci |cs |xxx| | 8B | 4B |2B |2B | 4B |2B |2B | |---------------|-------|---|---|-------|---|---|
class Parent { short s; byte b; }
class Child { long l; int i; } extends Parent
| header |s |b|x| int | long | | 8B |2B |1|1| 4B | 8B | |---------------|---|-|-|-------|---------------|
class Cons { Object head; Object tail; }
| header | head | tail | | 8B | 4B | 4B | |---------------|-------|-------|
Pole
Pole jsou na tom podobně jako objekty, ale jejich hlavička kromě dvou procesorových slov obsahuje ještě jeden čtyřbajtový integer 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 musejí být zarovnány na 8 bajtů, takže za hlavičkou je čtyřbajtová mezera a teprve pak následují data.
new byte[] { 0, 0 }
| header |length |b|b|xxx| | 8B | 4B |1|1|2B | |---------------|-------|-|-|---|
new long[] {0}
| header |length |xxxxxxx| long | | 8B | 4B | 4B | 8B | |---------------|-------|-------|---------------|
Situace se dá shrnout do několika vzorců:
32bitové platformy
- Objekty
- 8B hlavičky + velikost atributů předků zarovnaných na 4 bajty + součet velikostí všech typů zarovnaný nahoru na 8B
- Pole typů byte, bool, short, char, int, float
- 12B hlavičky + length * velikost typu to celé zarovnané nahoru na 8 bajtů
- Pole referencí
- 12B hlavičky + length * 4B to celé zarovnané nahoru na 8 bajtů
- Pole typů long nebo double
- 12B hlavičky + 4B padding + length * 8B
64bitové 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 typů byte, bool, short, char, int, float
- 20B hlavičky + length * velikost typu to celé zarovnané nahoru na 8 bajtů
- Pole typů long, double nebo referencí
- 20B hlavičky + 4B padding + length * 8B
Příklady
Nakonec si ukážeme několik příkladů ukazujících, kolik paměti spotřebují některé běžně používané typy.
String
Řetězec je reprezentován jako objekt, který odkazuje na vnitřní pole znaků.
Samotný objekt String má: 8B hlavičky, 4B hashcode, 4B délka stringu, 4B offset, 4B reference
Vnitřní pole má: 8B hlavičky, 4B délku pole + (počet znaků) * 2B
Stringová část zabírá 24 bajtů, vnitřní pole má režii 12 bajtů + 0 až 6 bajtů, které se ztratí zarovnáním pole. Dohromady to dělá 36 – 42 extra bajtů na jeden String nebo 60 – 66 bajtů pro 64 bitové platformy.
(pozn: V nejnovější verzi Javy se změnila implementace řetězců a už neobsahují atributy pro délku a offset. Režie je tedy o 8 bajtů menší).
List
Neměnný spojový seznam (jako například 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í nemusíme zabývat. Cons
obsahuje dva atributy: vlastní hodnotu head
a referenci tail
na následující buňku v řadě. 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.
Cons
tedy zabírá: 8B hlavičky + 2 reference po 4B, tedy 16 bajtů na jednu Cons buňku (nebo 32 na 64 bitových platformách). To je celkem přijatelná daň za to, že můžeme v konstantním čase číst a manipulovat začátek seznamu.
Ale teď si představme, že v této datové struktuře budeme chtít ukládat celá čísla a ty musejí projít autoboxingem.
Objekt Integer
má: 8B hlaviček, 4B dat a 4B, které padly na oltář zarovnávání paměti, což představuje dalších 12 extra bajtů režie. Dohromady tedy potřebujeme 28 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 12B, 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, Vector nebo pro extrémní případy zvolit specializované kolekce jako Trove nebo Colt.
HashMap
Existuje mnoho způsobů, jak implementovat hashmapu, ale my budeme uvažovat způsob, jak je implementována ve standardní knihovně Javy – tedy polem, které ukazuje na spojový seznam (tzv. closed addressing):
class Map<K, V> {
Entry<K, V>[] buckets;
}
class Entry<K, V> {
Bucket<K, V> next;
K key;
V value;
}
Potřebujeme pole referencí, které je velké přibližně jako počet elementů v mapě. Každá reference, která obsahuje nějakou hodnotu, vede na Entry, která má tři reference: další Entry v řadě, klíč mapy a hodnotu.
Entry zabere 8B hlavičky + 12B reference + 4B zarovnání. Dohromady to dává 24B (nebo 40B na 64 bitových platformách) + 4B reference na jeden pár klíč/hodnota v poli, a to nepočítáme 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 chceme kompaktní pole, můžeme použít BitSet nebo BitMap
- Jednotlivé virtuální stroje se od sebe mohou lišit tím, jak v paměti reprezentují objekty.
- Vrací ho standardní implementace metody hashCode, nebo se k němu můžeme dostat voláním java.lang.Sys.identityHashCo