object layout
mark word, class pointer, fields, padding. and compact headers.
- chapter
- chapter 13.09
- reading time
- 10 min read
- java 25+
every object starts with a header: a 64-bit mark word (identity hash, GC age, lock bits) and a compressed class pointer. then come the fields, packed largest first with small ones filling the gaps, and the whole thing is rounded up to a multiple of 8 bytes.
on jdk 25 that header is 12 bytes. with compact object headers (a product feature since 25, opt-in, on by default in 27) the class pointer moves into the mark word and the header is 8.
the picture
you know how a new Object() with no fields still costs memory? it's not nothing: on a default jdk 25 it's 16 bytes, all of it header and padding.
think of a parcel in a warehouse. before you get to what's inside, there's a label: who it's for and which shelf it belongs on (the class pointer), plus a few stickers the warehouse adds as it goes: a tracking number (the hash), how many inventory sweeps it survived (GC age), and whether someone has it checked out right now (the lock bits). the contents are packed with the heavy items first and the small things stuffed into the gaps, and the box comes in standard sizes, so there's usually some air at the end.
the mark word, for real
these are the actual 64 bits at the start of one new Object(), read with Unsafe.getLong(o, 0) on jdk 25 (aarch64), in four moments of its life.
after System.identityHashCode(o): 31 bits of hash appear at bit 11.
a few things the real bits show that diagrams usually don't:
- the hash is lazy. a fresh object has no hash at all.
System.identityHashCode(or an un-overriddenhashCode()) computes one the first time it's asked, writes it into the header, and from then on it never changes. the GC moves objects around, so the hash can't be the address: it has to be stored. - the age is 4 bits. that's why
-XX:MaxTenuringThresholdtops out at 15: an object can only count to 15 young collections before it has to be promoted. - locking barely touches the header. on jdk 25 the default is lightweight locking: the lock bits go
01→00and the owner is tracked on the thread's own lock stack, not in the object. more in how locks work inside.
in default mode the header continues with 32 more bits: the compressed class pointer, an offset into the compressed class space where the class's metadata lives. with compact headers those 22 bits of class pointer live at the top of the mark word instead, and the header ends after 8 bytes.
fields: packed, then padded
after the header come the instance fields. HotSpot (since java 15) lays them out like this:
- primitives first, biggest first:
long/double, thenint/float, thenshort/char, thenbyte/boolean. - references last.
- every field goes into the first free gap that fits and is aligned to its own size, so small fields slide into holes left by alignment.
- the total is rounded up to a multiple of 8 (
ObjectAlignmentInBytes).
your declaration order mostly doesn't matter. here's a builder that applies those rules. the presets and all four modes match what jcmd <pid> VM.classes -verbose printed on jdk 25 (it lists the offset of every field, which makes it a JOL you already have):
count in Mixed: declared fourth, placed first, at byte 12, because it's the only field that fits the hole between the 12-byte header and the first 8-aligned slot.L$Mixed {0x00007ffc01040630}
- instance size: 6
...
- ---- non-static fields (8 words):
- 'count' 'I' @12
- 'id' 'J' @16
- 'd' 'D' @24
- 's' 'S' @32
- 'c' 'C' @34
- 'flag' 'Z' @36
- 'b' 'B' @37
- 'ref' 'Ljava/lang/Object;' @40instance size: 6 is in 8-byte words: 48 bytes. 44 bytes of header and fields, 4 bytes of padding.
what things actually cost
the JVM will also just tell you how much it allocated. this measures it with ThreadMXBean.getCurrentThreadAllocatedBytes around 100,000 allocations of each:
import java.lang.management.ManagementFactory;
import java.util.function.IntFunction;
record Point(int x, int y) {}
static final com.sun.management.ThreadMXBean MX =
(com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();
static void measure(String what, IntFunction<Object> make) {
Object[] keep = new Object[100_000];
long before = MX.getCurrentThreadAllocatedBytes();
for (int i = 0; i < keep.length; i++) keep[i] = make.apply(i);
long after = MX.getCurrentThreadAllocatedBytes();
IO.println((after - before) / keep.length + " bytes " + what);
}
void main() {
measure("new Object()", i -> new Object());
measure("Long.valueOf(1000 + i)", i -> Long.valueOf(1000L + i));
measure("new Point(i, i)", i -> new Point(i, i));
measure("new int[3]", i -> new int[3]);
}16 bytes new Object() 24 bytes Long.valueOf(1000 + i) 24 bytes new Point(i, i) 32 bytes new int[3]
this one needs a real JVM (JVM internals), so it doesn't run in the browser. copy it into java Main.java to try it.
run the same program with -XX:+UseCompactObjectHeaders and every line shrinks by exactly 8 bytes:
a Long holds 8 bytes of data and costs 24. that ratio is why an ArrayList<Long> of a million elements is about 24 MB of Long objects plus 4 MB of references, while a long[] of a million is 8 MB, and why Valhalla exists.
arrays
an array's header has one extra field: the length, a 4-byte int right after the class pointer. so the elements of an int[] start at byte 16 by default, and at byte 12 with compact headers (long[] elements still start at 16, because they need 8-byte alignment). read with Unsafe.arrayBaseOffset on jdk 25:
| array | default | compact headers | no compressed class pointers |
|---|---|---|---|
int[], byte[], Object[] data starts at | 16 | 12 | 20 |
long[] data starts at | 16 | 16 | 24 |
size of int[3] | 32 | 24 | 32 |
compressed pointers, and the 32 GB cliff
a reference is 64 bits of address, but HotSpot stores it in 32 bits when it can: compressed oops. objects are 8-byte aligned, so the low 3 bits of every address are zero; store the address shifted right by 3 and 32 bits can reach 2³² × 8 = 32 GB of heap. the class pointer gets the same trick (compressed class pointers), which is why the default header is 8 + 4 = 12 bytes.
compact object headers
the 12-byte header wastes space in two ways: 22 bits at the top of the mark word are unused, and the 4-byte class pointer often drags 4 bytes of padding along with it. Project Lilliput's fix is to put a 22-bit class pointer into those unused bits and drop the second word entirely.
hover a stage.
on jdk 25 you have to ask for it. it costs nothing to try: add -XX:+UseCompactObjectHeaders to a test run and compare heap usage after a GC. the main limit is the 22-bit class pointer: about 4 million classes, which only matters if something generates classes like there's no tomorrow.
class Node {
long value;
Node next;
}how big is a Node on a default jdk 25, and with -XX:+UseCompactObjectHeaders?
what happens to the hash when a lock inflates+
the mark word can't hold everything at once. when a lock is contended (or someone calls wait()), HotSpot inflates it into a full ObjectMonitor. in the default mode the mark word then becomes a pointer to that monitor with lock bits 10, and the hash and age that used to live there move into the monitor (the "displaced header") until it's deflated again.
compact headers can't allow that: the class pointer lives in the mark word now, and the JVM needs it on every virtual call. so on jdk 25, -XX:+UseCompactObjectHeaders also switches on UseObjectMonitorTable (a diagnostic flag; -XX:+PrintFlagsFinal shows it flip to true): monitors are kept in a side table keyed by the object, and the header is never overwritten. it's also why compact headers require lightweight locking.
- header + fields + padding to 8. the header is 12 bytes today, 8 with compact headers.
- the mark word holds the identity hash (lazily), the GC age (4 bits), and the lock bits.
- fields are packed biggest first, and small ones fill alignment holes. declaration order barely matters.
- references are 4 bytes until the heap passes ~32 GB. then they're 8, and everything grows.
- compact headers: experimental in 24, product in 25, default in 27.