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, aktualizováno: 30. 9. 2021

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 .

typvelikost
byte, boolean1 B
short, char2 B
int, float4 B
long, double8 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:

  1. Každý objekt je zarovnán na násobek 8 bajtů. (Dá se změnit přes -XX:ObjectAlignmentInBytes)
  2. 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.
  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, 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:

64 bitové platformy:

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:

Pozn:

  1. Pokud chci kompaktní pole, musím použít BitSet nebo BitMap
  2. Jednotlivé virtuální stroje se mohou lišit, jak v paměti reprezentují objekty.
  3. Vrací ho standardní implementace metody hashCode, nebo se k němu můžu dostat voláním java.lang.System.identityHashCode
@kaja47, kaja47@k47.cz, deadbeef.k47.cz, starší články