JVM a pohled objektům pod sukně
V poslední době jsem hodně psal o paměti a struktuře objektů na JVM. Abych skutečně ověřil, že to tak je, mám na výběr ze dvou možností: studovat zdrojáky JVM nebo zahodit bezpečnost a podívat se přímo na nahé bajty v paměti.
Já jsem dobrodruh a zvolil jsem variantu číslo dvě.
V JVM od Sunu/Oracle je k dispozici třída sun.misc.Unsafe
, jejíž jméno říká všechno
potřebné: obsahuje operace, které nejsou bezpečné, protože přistupují přímo k
paměti, mohou číst, alokovat nebo uvolňovat libovolnou paměť spravovanou JVM a
můžou skončit segfaultem.
No a právě Unsafe je ideální nástroj pro cestu do nitra objektů. Pomocí metody
getByte(object, offset)
můžu číst syrové bajty objektů
včetně hlaviček, které jsou před programátorem normálně skryté.
Testovací program je záležitost na pár řádků Scaly:
import sun.misc.Unsafe val unsafe = { val f = classOf[Unsafe].getDeclaredField("theUnsafe") f.setAccessible(true) f.get().asInstanceOf[Unsafe] } val chars = "0123456789abcdef" def byteToHexString(b: Byte) = ""+chars((b & 0xF0) >> 4)+chars(b & 0x0F) // převede bajty na little endian def reverseBytes(hexStr: String) = hexStr.grouped(2).toSeq.reverse.mkString def intToHexString(int: Int) = { val id = Integer.toHexString(int) reverseBytes("0"*(8-id.length) + id) } // identity hash code def idHashCode(x: AnyRef) = intToHexString(System.identityHashCode(x)) def objectBytes(x: AnyRef, bytes: Int) = 0 until bytes map { i => byteToHexString(unsafe.getByte(x, i)) } mkString
Jako první si vytvořím třídu s několika atributy a podívám se jak je reprezentovaná v paměti. To stačí k tomu abych si ověřil pořadí atributů, padding a jak jsou velké hlavičky a co obsahují.
Všechny ukázky předpokládají OpenJDK na 64 bitovém procesoru s komprimovanými referencemi (OOP).
class X { val byte: Byte = 1 val bool: Boolean = true val short: Short = 2 val char: Char = 3 val int: Int = 4 val long: Long = 5 val ref: Class[_] = this.getClass } val x = new X idHashCode(x) // b253ef01 objectBytes(x, 52) grouped 8 mkString "|" // 01b253ef|01000000|800621e8|04000000|05000000|00000000|02000300|01010000|180921e8|00000000|05000000|00000000|9022f8e5 // ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ ^ // hashCode class int long sh ch b b XXX ref XXXXXXXX next object
Z výstupu je krásně vidět několik věcí: identity hash code začíná na 2. bajtu, po něm následují nějaké příznaky, zatím nulové, na 9. bajtu ukazatel na třídu objektu a po dvanáctibajtové hlavičce následují samotné atributy objektu.
Mnoho zdrojů na internetu udává, že hlavička objektu má 2 procesorová slova, tedy 8
nebo 16 bajtů. To platí na JVM bez komprimovaných referencí, ale když jsou COOP zapnuté, hlavička má 12 bajtů.
To mi dlouhou dobu nedávalo smysl. Odpověď jsem našel až ve zdrojových kódech JVM.
Hlavička objektu se skládá ze dvou částí: markOop
(popsáno zde) a
classOop
. markOop
má délku jednoho procesorového slova a classOop
má délku
reference. To dohromady dává 8 bajtů pro 32 bit stroje, 12 bajtů pro 64 bit COOP
stroje a 16 bajtů pro zbytek.
Na vypsaných datech hlavičky jsou ještě zajímavé dvě věci:
- Identity hash code může být nulový dokud si ho nevyžádám funkcí System.identityHashCode
, teprve pak se do hlavičky skutečně zapíše. JVM si tak nejspíš šetří práci, protože pro většinu objektů (nejspíš) nebude nikdy potřeba, protože mají vlastní implementaci hashCode.
- Ukazatel na třídu objektu v hlavičce je jiný než když na stejnou třídu odkazuji ve vlastním atributu. Shodují se jenom poslední dva bajty adresy. Všechny instance stejné třídy mají v hlavičce stejnou adresu, takže první dva bajty nejsou vyčleněny pro něco jiného, ale nějak se vztahují k adrese.
Na atributech jsou patrná pravidla pro jejich řazení a paddingu:
- 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 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.
Dále jsem se podíval na dědičnost:
class Parent { val pi: Int = 1 val pb: Byte = 2 } class Child extends Parent { val ci: Int = 3 val cl: Long = 4 } val c = new Child idHashCode(c) // 25e67300 objectBytes(c, 52) grouped 8 mkString "|" // 0925e673|00000000|98d421e8|01000000|02000000|03000000|04000000|00000000|0d000000|00000000|7894c0e6|9da90200|d87f91fb // ^ ^ ^ ^ ^ ^ ^ ^ // hashCode class pi pb XXXXXX ci cl next object
Zde je vidět, že se atributy předka a potomka nemíchají a jsou zarovnány na 4 bajty.
Můžu si ověřit, jak jsou implementovány něteré základní třídy jako třeba String
.
val s = "0123456789" idHashCode(s) // 948e526c intToHexString(s.hashCode) // 0546775e objectBytes(s, 52) grouped 8 mkString "|" // 01948e52|6c000000|b843a1e5|2026ffe7|00000000|0a000000|0546775e|00000000|01000000|00000000|1007a0e5|0a000000|30003100 // ^ ^ ^ ^ ^ ^ ^ ^ // hashCode class array offset length hashCode XXXXXXXX next object
A pole:
val arr = Array[Long](1,2) idHashCode(arr) // 631a605b objectBytes(arr, 48) grouped 8 mkString "|" // 09631a60|5b000000|5015a0e5|02000000|01000000|00000000|02000000|00000000|0d000000|00000000|e00225e8 // ^ ^ ^ ^ ^ ^ // hashCode class length [1] [2] next object
Úplně nakonec jsem se podíval pod kapotu specializovaných třídy ze Scaly.
val s = (1, 2) // specializovaný tuple s.getClass // scala.Tuple2$mcII$sp idHashCode(s) // 69d5034e objectBytes(s, 60) grouped 8 mkString "|" // 0969d503|4e000000|c04828e6|00000000|00000000|01000000|02000000|00000000|0d000000|00000000|38f729e8|00000000|00000000|50cda5fb|0d000000 // ^ ^ ^ ^ ^ ^ ^ ^ // hashCode class _1 _2 _1 spec _2 spec XXXXXXXX next object
Jak je vidět, tak specializovaná třída obsahuje nejdřív atributy generického předka za kterými následují jejich specializované protějšky. Generické atributy _1 a _2 jsou null reference a nejsou nikdy použity, specializované atributy _1 a _2 mají typ int a můžu přímo vidět jejich hodnoty.