JVM – pohled objektům pod sukně

V poslední době jsem hodně psalpamě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.

Jsem dobrodruh a proto jsem zvolil 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 při nesprávném zacházení 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]
}

def byteToHexString(b: Byte) = "%02x" format b

// převede bajty na little endian
def reverseBytes(hexStr: String) =
  hexStr.grouped(2).toSeq.reverse.mkString

def intToHexString(int: Int) =
  reverseBytes("%08x" format int)

// 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 (COOP).

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 (prázdné místo je vyznačeno jako XXX).

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 a 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 jedné 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:

  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.

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.

Další čtení:

Flattr this!

This entry was posted in Java, JVM, Paměť, Scala. Bookmark the permalink.

Leave a Reply

Your email address will not be published. Required fields are marked *