funkcionálně.cz

Přední český blog o funkcionálním programování, kde se o funkcionálním programování nepíše
««« »»»

Velikost objektů v Javě

21. 1. 2013 — k47

Na root.cz mi dneska vyšel článek Velikost objektů v Javě, kde se dozvíte, jak Java alokuje paměť pro objekty a pole, jak odhadnout kolik paměti ta, která datová struktura potřebuje (a většinou je to překvapivě hodně) a jak bojovat s reputací Javy jako paměťově nenažraného jazyka.

Dál plánuji pár dalších článků, kde budu přímo měřit spotřebu paměti některých datových struktur a jejich implementací. Ale ty nejspíš publikuji jenom tady.

####### Velikost objektů v Javě

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 OpenJDK2) začíná hlavičkou, které obsahuje 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) je velká 12 bajtů a na 64 bitových platformách 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 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, 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í 64bitové JVM s komprimovanými pointery):

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ý 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 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 typů 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 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á: 12B hlaviček, 4B hashcode, 4B délka stringu, 4B offset, 4B reference a 4B padding.

Vnitřní pole má: 12B hlavičky, 4B délku pole + (počet znaků) * 2B

Stringová část zabírá 32 bajtů, vnitřní pole má režii 16 bajtů + 0 až 6 bajtů, které se ztratí zarovnáním pole. Dohromady to dělá 48 - 52 extra bajtů na jeden String nebo 62 - 68 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 (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í 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á: 12B hlavičky, 2 reference po 4B a 4B, které padly na oltář zarovnání paměti, tedy 24 bajtů na jednu Cons buňku (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á: 12B hlaviček a 4B dat, což představuje dalších 12 extra bajtů režie. Dohromady tedy potřebujeme 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, Vector ze Scaly 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 má 12B hlavičky + 12B reference. 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:

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.System.identityHashCode
@kaja47, kaja47@k47.cz, deadbeef.k47.cz, starší články