Velikost objektů v Javě

Karel Čížek 21. 1. 2013

O Javě se říká, že je paměťově příliš náročná. Z části je to pravda, ale často jde jen o nešetrné programy, které nerespektují jak Java alokuje paměť. V článku si ukážeme jak spočítat velikost objektů a polí. To obvykle stačí k tomu, abychom psali aplikace, které si místo gigabajtů vystačí se stovkami megabajtů.

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:

  1. Každý objekt je zarovnán na násobek 8 bajtů.
  2. 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.
  3. 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ů.
  4. 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.
  5. 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.

widgety


Odkazy:

Pozn:

  1. Pokud chceme kompaktní pole, můžeme použít BitSet nebo BitMap
  2. Jednotlivé virtuální stroje se od sebe mohou lišit tím, jak v paměti reprezentují objekty.
  3. Vrací ho standardní implementace metody hashCode, nebo se k němu můžeme dostat voláním java.lang.Sys.identityHashCo
Našli jste v článku chybu?
Podnikatel.cz: Udělali jsme velkou chybu, napsal Čupr

Udělali jsme velkou chybu, napsal Čupr

DigiZone.cz: Test LG 55UH750V aneb Cena/výkon

Test LG 55UH750V aneb Cena/výkon

DigiZone.cz: Nova opět stahuje „milionáře“

Nova opět stahuje „milionáře“

Vitalia.cz: Voda z Vltavy před a po úpravě na pitnou

Voda z Vltavy před a po úpravě na pitnou

Vitalia.cz: Inspekce našla nelegální sklad v SAPĚ. Zase

Inspekce našla nelegální sklad v SAPĚ. Zase

Podnikatel.cz: Znáte už 5 novinek k #EET

Znáte už 5 novinek k #EET

DigiZone.cz: Světový pohár v přímém přenosu na ČT

Světový pohár v přímém přenosu na ČT

DigiZone.cz: Ginx TV: pořad o počítačových hráčích

Ginx TV: pořad o počítačových hráčích

Podnikatel.cz: Dva měsíce na EET. Budou stačit?

Dva měsíce na EET. Budou stačit?

Vitalia.cz: Muž, který miluje příliš. Ženám neimponuje

Muž, který miluje příliš. Ženám neimponuje

DigiZone.cz: DVB-T2 ověřeno: seznam TV zveřejněn

DVB-T2 ověřeno: seznam TV zveřejněn

DigiZone.cz: Parlamentní listy: kde končí PR...

Parlamentní listy: kde končí PR...

DigiZone.cz: Funbox 4K v DVB-T2 má ostrý provoz

Funbox 4K v DVB-T2 má ostrý provoz

Vitalia.cz: dTest odhalil ten nejlepší kečup

dTest odhalil ten nejlepší kečup

DigiZone.cz: Mordparta: trochu podchlazený 87. revír

Mordparta: trochu podchlazený 87. revír

Vitalia.cz: Tohle jsou nejlepší česká piva podle odborníků

Tohle jsou nejlepší česká piva podle odborníků

Podnikatel.cz: Babišovi se nedá věřit, stěžovali si hospodští

Babišovi se nedá věřit, stěžovali si hospodští

DigiZone.cz: Numan Two: rozhlasový přijímač s CD

Numan Two: rozhlasový přijímač s CD

Vitalia.cz: Jaký je rozdíl mezi brambůrky a chipsy?

Jaký je rozdíl mezi brambůrky a chipsy?

DigiZone.cz: Rapl: seriál, který vás smíří s ČT

Rapl: seriál, který vás smíří s ČT