> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Java Object Header: Mark Word, Class Pointer and Layout
- URL: https://www.ggorantala.dev/java-object-header-mark-word/
- Published: 2026-11-05T04:03:00.000Z
- Updated: 2026-11-05T04:03:39.000Z
- Description: What's in a Java object header? Mark word, class pointer and padding, measured with Unsafe on JDK 21, plus what compact headers change in JDK 25.
- Author: Gopi Gorantala
- Tags: jvm, jvm-internals, jvm-memory

## Start with the evidence

You've probably already searched "how many bytes is a Java object" and landed on a post that says an empty object is 16 bytes, an `Integer` is 24, and the header is 12 or 16 depending on who's counting. Here's what a JDK 21 JVM printed for me when I asked it directly:

```log
$ java -XX:+UseSerialGC -Xmx64m Header.java       # OpenJDK 21.0.12.1, Linux x86_64
Object          16 bytes
Mixed           32 bytes
byte[0]         16 bytes
byte[1]         24 bytes
Integer         16 bytes
offset 12  i        <- an int field living at byte 12
int[5] length, read at offset 12: 5
fresh              0000000000000001
after hashCode()   0000004943826901   (hashCode was 49438269)
lock held          00007ffa3c1fe470
lock released      0000000000000001
two threads fight  00007ff9a4000fe2

```

An `Integer` is 16 bytes, not 24\. An `int` field sits at offset 12, which is inside what most diagrams call the header. And that last number is not an object's state at all. It's a pointer. The Java object header is two words that every instruction on your hot path eventually touches, and I'd been carrying a slightly wrong picture of it for years.

This took me more than a day to get straight, mostly because the one tool everybody uses for this (JOL) wasn't available where I ran the lab. I've written down the workaround so you don't repeat the detour.

## What's going on, roughly

Every object on a HotSpot heap starts with a header. On a 64-bit JVM with default flags on JDK 21 it's 12 bytes: an 8-byte **mark word** and a 4-byte **class word**.

The mark word is a little state machine in one `long`. It holds the identity hash code, the GC age, and the lock state, and it changes shape depending on what's been done to the object. The class word is a compressed pointer to the object's metadata (its `Klass`, the JVM's internal description of the class), so the JVM can find methods and field layout from any object reference.

Arrays add a 4-byte length right after that, which is why `byte[0]` costs 16 and `byte[1]` costs 24\. Then everything gets rounded up to a multiple of 8.

That's most of it. The rest of this post is about why the mark word is a state machine, what the 4-byte gap means for your fields, and what JDK 24 through 27 do to all of it.

## Read the header yourself, without JOL

JOL (the OpenJDK Java Object Layout tool) is the usual answer here. I couldn't use it: the sandbox I ran this in blocks Maven Central, so there was no jar to fetch. Two Maven hosts both refused the connection, and after that I admitted the JDK already ships what I need. `sun.misc.Unsafe` is in the `jdk.unsupported` module, still there on 21, and it can read raw memory at any offset of an object. It's not something to ship. For a lab on a laptop it's perfect, because you read the bytes instead of trusting a formatter.

**What you need.** OpenJDK 21.0.12.1 (I used the Ubuntu build; any 21 will do), no dependencies, Linux or macOS. I did not run this on JDK 25; that part of the post comes from the JEPs and is marked as such. Get the JDK with SDKMAN if you don't have one (`sdk list java` shows the exact identifier for your platform), then check `java -version`.

```java
// Header.java
import java.lang.management.ManagementFactory;
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;

public class Header {
    static final sun.misc.Unsafe U;
    static {
        try {
            Field f = sun.misc.Unsafe.class.getDeclaredField("theUnsafe");
            f.setAccessible(true);
            U = (sun.misc.Unsafe) f.get(null);
        } catch (Exception e) { throw new RuntimeException(e); }
    }
    static class Mixed { byte b; long l; int i; Object ref; short s; boolean z; }
    static final com.sun.management.ThreadMXBean TM =
        (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();
    static volatile Object sink;

    static long allocated() { return TM.getThreadAllocatedBytes(Thread.currentThread().threadId()); }
    static String mark(Object o) { return String.format("%016x", U.getLong(o, 0L)); } // the first 8 bytes of every object

    static void size(String name, java.util.function.Supplier<Object> s) {
        for (int i = 0; i < 5000; i++) sink = s.get();          // warm up so the JIT isn't allocating on our behalf
        long before = allocated();
        for (int i = 0; i < 1000; i++) sink = s.get();
        System.out.printf("%-14s %3d bytes%n", name, (allocated() - before) / 1000);
    }

    public static void main(String[] args) throws Exception {
        size("Object", Object::new);
        size("Mixed", Mixed::new);
        size("byte[0]", () -> new byte[0]);
        size("byte[1]", () -> new byte[1]);
        size("Integer", () -> Integer.valueOf(1000 + (int) (allocated() & 1)));
        for (Field f : Mixed.class.getDeclaredFields())
            if (!Modifier.isStatic(f.getModifiers()))
                System.out.printf("offset %2d  %s%n", U.objectFieldOffset(f), f.getName());
        System.out.println("int[5] length, read at offset 12: " + U.getInt(new int[5], 12L));

        Object o = new Object();
        System.out.println("fresh              " + mark(o));
        int h = o.hashCode();
        System.out.printf("after hashCode()   %s   (hashCode was %08x)%n", mark(o), h);

        Object l = new Object();
        synchronized (l) { System.out.println("lock held          " + mark(l)); }
        System.out.println("lock released      " + mark(l));

        Object c = new Object();
        Thread owner = new Thread(() -> { synchronized (c) { sleep(400); } });
        Thread waiter = new Thread(() -> { synchronized (c) { } });
        owner.start(); sleep(100); waiter.start(); sleep(100);
        System.out.println("two threads fight  " + mark(c));
        owner.join(); waiter.join();
        System.out.println("fight is over      " + mark(c));
        sleep(Integer.getInteger("wait.ms", 0));
        System.out.println("after " + Integer.getInteger("wait.ms", 0) + " ms     " + mark(c));
    }
    static void sleep(long ms) { try { Thread.sleep(ms); } catch (InterruptedException e) { } }
}

```

```log
Step 1 - sizes and offsets
$ java -XX:+UseSerialGC -Xmx64m Header.java
Expected: Object 16, Mixed 32, byte[0] 16, byte[1] 24, Integer 16; field offsets 12, 16, 24, 26, 27, 28
          (a deprecation warning for objectFieldOffset is normal; the source launcher prints it on stderr)

Step 2 - mark word states
Same run. Expected: fresh = ...0001, after hashCode() has the hash shifted left 8 bits, lock held = a 0x7f... pointer
          ending in 0, lock released = back to ...0001, contended = a pointer ending in 2.

Step 3 - what happens after the fight
$ java -XX:+UnlockDiagnosticVMOptions -XX:GuaranteedAsyncDeflationInterval=1000 -Dwait.ms=3000 Header.java
Expected: last line goes back to 0000000000000001

Step 4 - turn off class pointer compression
$ java -XX:-UseCompressedClassPointers Header.java
Expected: byte[0] 24, Mixed 40, Integer 24, and the "int[5] length" line prints garbage

```

Two things I forced. `-Xmx64m` keeps the heap small enough that compressed oops stay on and the numbers match mine (see the [compressed oops post](https://ggorantala.dev/compressed-oops-32gb-heap/?ref=ggorantala.dev) for why that matters). And the 1,000 ms deflation interval in step 3 is a lab-only setting; I'll get to why.

If step 1 gives you `Mixed` as 40 instead of 32, you're on a heap big enough to turn compressed oops off. If you see nothing at all for `Integer`, the allocation counter was off by a JIT-inlined allocation; run it again.

Cleanup is nothing: no logs, no dumps.

## 16 bytes for an Integer, and the 4-byte gap

Before the mechanism, the number that confused me. The mark word is 8 bytes and the class word is 4, so the header is 12\. But objects are aligned to 8 bytes (`-XX:ObjectAlignmentInBytes=8` by default), and fields want natural alignment too. A `long` needs to start on a multiple of 8\. At offset 12 it can't.

So HotSpot puts the biggest-fitting field it has into the hole. In `Mixed`, the `int i` landed at offset 12, then `long l` at 16, `short s` at 24, `byte b` at 26, `boolean z` at 27, and the reference at 28\. Total is 32 and every byte is used. Fields get packed by size, not declared in the order you wrote them, so reordering your source does nothing.

That's also why an `Integer` is 16: 12 bytes of header, 4 bytes of `value`, no padding. Plenty of posts say 24\. My guess is they measured a 16-byte header, which is what you get with `-XX:-UseCompressedClassPointers` (step 4 above; I measured `Integer` at 24 there). Same JVM, one flag, and the answer on page 1 may depend on which flag the author had.

An empty `Object` is 16 for a different reason. 12 bytes of header can't be the whole object, and the next 8-byte boundary is 16\. Four bytes of padding.

## What the mark word holds, and when

The mark word isn't a fixed set of fields. It's a union, and the bottom two bits say which interpretation you're looking at. On 64-bit HotSpot in JDK 21, the normal (unlocked) shape is:

```log
 63                    39 38              8   7   6      3  2   1 0
+------------------------+------------------+---+---------+---+-----+
|        unused: 25      |   hash: 31       | - | age: 4  | - | 01  |
+------------------------+------------------+---+---------+---+-----+

lock bits   what the other 62 bits mean
  01        normal object: hash, age as above
  00        locked: the word is a pointer to a lock record on the owner's stack
  10        inflated: the word is a pointer to an ObjectMonitor (a C++ struct)
  11        marked: GC is using the word, typically a forwarding pointer

```

That diagram is my rendering of the layout comment at the top of `markWord.hpp`, which I read through the fetch tool rather than a local checkout, so treat the exact bit positions as "matches what I measured" and not as "I read the file". What I did measure: a fresh object printed `...0001` (unlocked, age 0, no hash). After `hashCode()` the value was `0000004943826901`, and the hash `0x49438269` sits at bits 8 to 38, shifted left by 8\. The identity hash is generated lazily, the first time anyone asks, and then it's stored in the mark word for good.

Three consequences are worth knowing.

First, the hash is 31 bits wide, per the layout comment. Don't expect `Object.hashCode()` to be unique across a large heap.

Second, age is 4 bits, so it caps at 15\. I ran an object through young collections on G1 and printed the age field after each one: it went 1, 2, 3 and so on up to 15 and stayed there. If you want the full story of who decides when an object moves up, that's the [object aging post](https://ggorantala.dev/object-aging-promotion-java/?ref=ggorantala.dev); the mark word is only where the counter lives. One thing I haven't explained: JDK 21 happily accepts `-XX:MaxTenuringThreshold=16` without a warning. I didn't test what it does.

Third, the lock state is overlaid on the same word. Look at what my lab printed:

```log
lock held          00007ffa3c1fe470    low bits 00: pointer into this thread's stack
lock released      0000000000000001    back to normal, hash/age intact
two threads fight  00007ff9a4000fe2    low bits 10: pointer to an ObjectMonitor

```

When one thread holds the lock uncontended, HotSpot on JDK 21 copies the original mark word into a record on that thread's stack and swaps in a pointer to the record. That's why a hash or age can't live in the object while it's locked this way: the real header is on the stack. Release restores it. When a second thread shows up, the lock **inflates**: a full `ObjectMonitor` is allocated, and the mark word now points to it. If you're picking between `synchronized` and other primitives, the [volatile vs synchronized post](https://ggorantala.dev/volatile-vs-synchronized/?ref=ggorantala.dev) covers the language side; this is the memory side.

### The part I got wrong

I assumed an inflated monitor goes away the moment contention ends. It doesn't. In my lab the word still read `...0fe2` after both threads finished, and it was unchanged when I read it again with no wait. For a while I was sure my reader had a bug.

What fixed it was reading the flags: `GuaranteedAsyncDeflationInterval` is a **diagnostic** flag, default 60,000 ms. Idle monitors get deflated by a background thread, and with no pressure that happens about once a minute. When I set it to 1,000 ms, the word went back to `...0001` within the three-second window (step 3). So the object is carrying a pointer to a monitor for up to a minute after the last thread left. I read this off `-XX:+PrintFlagsFinal` and the behaviour I saw; I haven't read the deflation code.

The same inflation happens if you call `hashCode()` on a locked object, because the stack lock leaves no room for the hash. I saw this too: locked, then hashed, and the word became an inflated monitor pointer that also stayed after release.

And the age counter has a dead end of its own. My first age test called `System.gc()` six times in a loop and printed age 0 every time. My guess is that a full collection doesn't age objects the way a young one does; I didn't read the Serial code to confirm. I got real ageing only after switching to allocating garbage to force young GCs. If you're testing this yourself, don't use `System.gc()`.

## Inside HotSpot, and then a smaller header

The mark word lives in `oops/markWord.hpp`, and its comments are the best prose on this topic anywhere. The class word is an `narrowKlass`, a 32-bit offset from a base address into the compressed class space, decoded with a shift. If that sounds like compressed oops, it's the same trick applied to metadata instead of objects. That metadata lives in Metaspace; I'll cover where and how big in [metaspace vs PermGen](https://ggorantala.dev/metaspace-vs-permgen/?ref=ggorantala.dev).

Here's how the header has moved, as far as I could verify:

```log
JDK 8     12-byte header with compressed class pointers; 16 without. Biased locking in the mark word. (from memory)
JDK 15    Biased locking disabled by default (JEP 374).
JDK 18    Biased locking code removed. (JEP 374 page I fetched says "eventually remove"; the release is from memory.)
JDK 21    What I measured above. No UseCompactObjectHeaders flag at all: "Unrecognized VM option".
JDK 24    Compact object headers, experimental (JEP 450). Needs -XX:+UnlockExperimentalVMOptions.
JDK 25    Compact object headers are a product feature (JEP 519). Off by default.
JDK 27    Reported as on by default via JEP 534 (second-hand, see below).

```

JEP 450 merges the two words into one. Per the JEP, the header goes from 96 or 128 bits to 64, the class pointer shrinks to 22 bits (down from 32), and the identity hash stays 31 bits. Compressed class pointers become mandatory. The JEP says Project Lilliput experiments saw headers eat "more than 20% of live data", and the numbers it reports for SPECjbb2015 are 22% less heap and 8% less CPU in one configuration, 15% fewer GCs in another, and about 10% less run time on a highly parallel JSON parser. Those are benchmark numbers; yours will differ, and I haven't run any of this on JDK 25.

The class limit matters if you generate lots of classes: with 22 bits, the encoding is changed so the JVM can address a comparable number of classes, according to the JEP. I couldn't verify the new ceiling and I'm not going to quote one.

There's one thing I couldn't pin down from the JEP text: the exact bit positions of hash, age and lock in the compact layout. The page shows a diagram without numbers. Don't write tooling that reads those bits until you've read `markWord.hpp` for your exact JDK build.

On JDK 27: a roundup post says "In Java 27, they are enabled by default through JDK Enhancement Proposal 534." The openjdk.org page for JEP 534 wouldn't load from my environment, so I'm treating that as unconfirmed. An earlier draft, JEP 8361187, proposed exactly this (default on, with `-XX:-UseCompactObjectHeaders` to opt out, and the old layout deprecated). Check the JEP page before you act on either.

## Header myths worth retiring

**"The header is 16 bytes."** It's 12 with compressed class pointers (the default on 21), and 16 without. If your Docker image sets `-XX:-UseCompressedClassPointers` because someone copied it from a blog, your arrays and small objects grew. In my step 4, `byte[0]` went from 16 to 24, `Integer` from 16 to 24, and my `Mixed` class from 32 to 40\. Also, the length field of arrays moved to offset 16, which is why my reader printed garbage there.

**"Compressed oops and compressed class pointers are the same flag."** They aren't. Compressed oops shrink the *references inside* your objects (`-XX:+UseCompressedOops`); compressed class pointers shrink the *class word in the header* (`-XX:+UseCompressedClassPointers`). I measured both: with `-XX:-UseCompressedOops` only, the `Object[3]` went from 32 to 40 and `Integer` stayed at 16\. Turn off class pointers and `Integer` goes to 24\. You can disable one without the other.

**"The mark word has a spare bit I can use."** There are gaps, yes. They are reserved. Don't.

**"`System.gc()` ages my objects."** Not in my Serial test, as I found out.

**"`-XX:+UseCompactObjectHeaders` will work on my JDK."** Not on 21\. JVM refuses to start with `Unrecognized VM option 'UseCompactObjectHeaders'`, and this is also what you get if your CI image is an LTS older than 24\. If you copy the flag into a shared JVM-options file, anything on an older JDK dies at startup.

## What you can actually change

There isn't much, and that's honest. The header is not tunable. What you can influence:

| Setting                               | JDK 21                                           | What it does                         | Cost                                                                                               |
| ------------------------------------- | ------------------------------------------------ | ------------------------------------ | -------------------------------------------------------------------------------------------------- |
| \-XX:+UseCompressedClassPointers      | product, on by default                           | 4-byte class word                    | If you turn it off: 16-byte header, bigger arrays                                                  |
| \-XX:ObjectAlignmentInBytes=8         | product, default 8                               | Object size rounding                 | Raising to 16 pads objects (my byte\[1\] went from 24 to 32) but extends the compressed oops range |
| \-XX:+UseCompactObjectHeaders         | not present; experimental in 24, product from 25 | One 8-byte header                    | Needs the right JDK; class count limit; I haven't benchmarked it                                   |
| \-XX:GuaranteedAsyncDeflationInterval | diagnostic, default 60,000                       | How often idle monitors are deflated | Lab-only; don't tune it in production                                                              |

The place where you actually get savings is the object model: fewer small objects. Every object you avoid saves a header. A `Long` boxed in a `Map<Long, Long>` carries 12 bytes of overhead for 8 bytes of data. A primitive-specialised map or an array does not. I'd do that before touching any flag, and if you're on JDK 25 or later and are curious about the saving, test with your real heap histogram first.

## Checking your own heap, and upgrade notes

To see where your headers go without any of my Unsafe code, take a class histogram of the live process: `jcmd <pid> GC.class_histogram` lists instances and bytes per class. Divide the bytes by the instance count for small classes and you can read the header cost directly. Then ask: how much of my live set is objects under 32 bytes? Those are the ones where 12 of every 24 or 32 bytes is header.

For JDK-to-JDK moves:

- **17 to 21:** nothing changes in the header. What I measured on 21 should also hold on 17, but I didn't run 17.
- **21 to 25:** the compact header is available, off by default. Test it on a staging copy: run the same load with and without `-XX:+UseCompactObjectHeaders`, compare heap-after-GC and pause times.
- **To 27 or later:** if JEP 534 lands as reported, you'll get the compact header unless you opt out. Anything that reads raw object bytes (agents, native libraries) must be rechecked.
- If your process uses `Unsafe` to read headers, or a native agent that inspects the mark word, assume it breaks on compact headers.

I'd graph two things: heap used after full GC (trending flat after a header change means you saved something), and GC count per hour. I haven't checked the Micrometer meter names for this post, so look them up before you wire alerts.

## Short list for the wall

- The default header on JDK 21 is 12 bytes: 8-byte mark word, 4-byte class word. Arrays add 4 for length. Everything rounds up to 8.
- Fields can sit inside the header's 4-byte gap. That's why `Integer` is 16 and why the page you read said 24.
- The mark word is a union. It holds identity hash, a 4-bit age (max 15), and a lock state that can turn it into a pointer.
- An inflated monitor can outlive the contention. By default it can be a minute before it's deflated.
- Compact headers cut the header to 8 bytes: experimental in 24, product in 25\. Off by default there. Check JEP 534 for 27.

## Questions people ask about the Java object header

**How big is a Java object header?** On a 64-bit HotSpot JVM with default flags (JDK 21): 12 bytes. It's 16 bytes with `-XX:-UseCompressedClassPointers`, and 8 bytes with compact object headers (JDK 24 experimental, JDK 25 product). Arrays add 4 bytes for length.

**What is the mark word in Java?** It's the first word of every object header. It stores the identity hash code, the GC age, and the lock state. The bottom two bits say which: 01 for a normal object, 00 for a thread-owned lock, 10 for an inflated monitor and 11 when GC is using the word.

**Does an empty Java object really take 16 bytes?** On JDK 21 with default flags, yes. 12 bytes of header plus 4 bytes of alignment padding. I measured it with `ThreadMXBean.getThreadAllocatedBytes`. See the [compressed oops post](https://ggorantala.dev/compressed-oops-32gb-heap/?ref=ggorantala.dev) for what changes above roughly 32 GB.

**What are compact object headers?** A JDK feature (JEP 450 experimental in 24, JEP 519 product in 25) that merges the mark and class words into one 8-byte word. It needs compressed class pointers and cuts the class pointer to 22 bits. The JEP reports 22% less heap on one SPECjbb2015 setting; measure your own workload.