Guide contents

Lesson 39 of 71

Object Layout and Allocation

Every Java object carries a header and padding that the source code does not show. These bytes decide how many objects fit in the CPU cache and how much heap a data structure uses. This lesson shows the layout HotSpot uses on 64-bit platforms, how to measure it, and how allocation itself works.

Topic Key fact on 64-bit HotSpot
Header 8-byte mark word plus a class pointer (4 bytes when compressed)
Compressed oops References stored as 32-bit values; on by default for heaps below about 32 GB
Alignment Objects start and end on 8-byte boundaries by default
JOL The Java Object Layout tool prints real offsets
TLAB Each thread allocates by bumping a pointer in its own buffer
Compact headers 8-byte header; experimental in Java 24, product option in Java 25

Examples run on JDK 25. The JOL examples pin the layout flags on the command line (compressed oops on, compact headers off), so their output is fixed. JOL prints one # WARNING line about the Serviceability Agent, a debugging interface it does not need for these tables.

Example 1: the header and JOL

An object header has two parts. The mark word (8 bytes) holds the identity hash code once it is computed, the GC age (how many young collections the object survived), and lock state bits. The class pointer (klass pointer) points to the JVM's internal class metadata. With compressed class pointers, the default, it takes 4 bytes. JOL (org.openjdk.jol:jol-core, version 0.17 here) prints the layout of a class. JOL cannot read field offsets of a record class through Unsafe, so the example uses a plain class.

Java
import org.openjdk.jol.info.ClassLayout;

public class Main {
    static final class Point { int x; int y; }

    public static void main(String[] args) {
        System.out.println(ClassLayout.parseClass(Point.class).toPrintable());
    }
}
Output
# WARNING: Unable to attach Serviceability Agent. HotSpot Serviceability Agent attach skipped due to jol.skipHotspotSAAttach flag.
Main$Point object internals:
OFF  SZ   TYPE DESCRIPTION               VALUE
  0   8        (object header: mark)     N/A
  8   4        (object header: class)    N/A
 12   4    int Point.x                   N/A
 16   4    int Point.y                   N/A
 20   4        (object alignment gap)    
Instance size: 24 bytes
Space losses: 0 bytes internal + 4 bytes external = 4 bytes total

Eight bytes of data cost 24 bytes of heap: 12 for the header, 8 for the fields, 4 for alignment. ClassLayout.parseInstance(obj) prints the same table with the current header values; after System.identityHashCode(obj) the mark word shows the stored hash. Biased locking, which also used the mark word, was disabled by default in Java 15 (JEP 374) and removed in Java 18.

Example 2: field order and padding

HotSpot does not keep source order. It groups fields by size (8-byte, then 4-byte, then 2-byte, then 1-byte, with references counted as 4 bytes when compressed) and may place a small field in the 4-byte gap after the header. Superclass fields come before subclass fields.

Java
import org.openjdk.jol.info.ClassLayout;

public class Main {
    static class Order {
        byte side;
        long price;
        int quantity;
        boolean active;
        String symbol;
    }

    public static void main(String[] args) {
        System.out.println(ClassLayout.parseClass(Order.class).toPrintable());
    }
}
Output
# WARNING: Unable to attach Serviceability Agent. HotSpot Serviceability Agent attach skipped due to jol.skipHotspotSAAttach flag.
Main$Order object internals:
OFF  SZ               TYPE DESCRIPTION               VALUE
  0   8                    (object header: mark)     N/A
  8   4                    (object header: class)    N/A
 12   4                int Order.quantity            N/A
 16   8               long Order.price               N/A
 24   1               byte Order.side                N/A
 25   1            boolean Order.active              N/A
 26   2                    (alignment/padding gap)   
 28   4   java.lang.String Order.symbol              N/A
Instance size: 32 bytes
Space losses: 2 bytes internal + 0 bytes external = 2 bytes total

The exact order of the small fields differs between JDK releases, but the size stays 32 bytes. Because the layout is the JVM's choice, padding against false sharing must use class hierarchies or @jdk.internal.vm.annotation.Contended (internal, needs -XX:-RestrictContended for application classes), as in The LMAX Disruptor Pattern.

Example 3: estimating sizes under three header modes

The rules above are simple enough to compute. This program estimates instance sizes with the default layout, with compressed pointers turned off, and with compact object headers. The model places the largest fields first, aligns each field to its size, lets small fields fill a gap after the header, and rounds up to 8 bytes.

Java
import java.util.ArrayList;
import java.util.Comparator;
import java.util.List;

public class Main {
    static long align(long v, int a) { return (v + a - 1) / a * a; }

    static long size(int header, int... fieldSizes) {
        List<Integer> fields = new ArrayList<>();
        for (int f : fieldSizes) fields.add(f);
        fields.sort(Comparator.reverseOrder());
        long offset = header;
        if (!fields.isEmpty() && fields.get(0) == 8 && offset % 8 != 0) {
            long gap = align(offset, 8) - offset;
            for (int i = 0; i < fields.size() && gap > 0; ) {
                int f = fields.get(i);
                if (f < 8 && f <= gap) { offset += f; gap -= f; fields.remove(i); }
                else i++;
            }
        }
        for (int f : fields) offset = align(offset, f) + f;
        return align(offset, 8);
    }

    public static void main(String[] args) {
        // header bytes and reference size per mode
        int[][] modes = { {12, 4}, {16, 8}, {8, 4} };
        String[] names = { "compressed (default)", "uncompressed", "compact headers" };
        for (int m = 0; m < modes.length; m++) {
            int h = modes[m][0], ref = modes[m][1];
            System.out.printf("%-21s Point=%d Integer=%d Long=%d Order=%d%n", names[m],
                    size(h, 4, 4), size(h, 4), size(h, 8), size(h, 1, 8, 4, 1, ref));
        }
    }
}
Output
compressed (default)  Point=24 Integer=16 Long=24 Order=32
uncompressed          Point=24 Integer=24 Long=24 Order=40
compact headers       Point=16 Integer=16 Long=16 Order=32

Compressed oops store a reference as a 32-bit offset that the JVM shifts left by 3 (the 8-byte alignment) and adds to the heap base, so 32 bits address 32 GB. Above that heap size (-Xmx of about 32 GB or more) the JVM turns compressed oops off and every reference doubles to 8 bytes. A heap just over the limit can therefore hold fewer objects than a heap just under it. -XX:ObjectAlignmentInBytes=16 raises the limit to 64 GB at the cost of more padding.

Compact object headers (JEP 450, experimental in Java 24 behind -XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders; JEP 519 made it a product option in Java 25, -XX:+UseCompactObjectHeaders, off by default) merge the class pointer into an 8-byte mark word. Small objects such as Point and Long shrink by a third. JOL on JDK 25 with -XX:+UseCompactObjectHeaders reports the same sizes as the model: Point 16 bytes, Order 32 bytes.

Example 4: arrays and the cost of boxing

An array header has the mark word, the class pointer, and a 4-byte length: 16 bytes with compressed pointers. Elements follow at their natural size. A boxed collection pays for an array of references plus one object per element.

Java
public class Main {
    static long align8(long v) { return (v + 7) / 8 * 8; }

    public static void main(String[] args) {
        int n = 1_000_000;
        long header = 16;
        long ints = align8(header + 4L * n);
        long refs = align8(header + 4L * n);
        long integerObjects = 16L * n;
        long longs = align8(header + 8L * n);
        long longObjects = 24L * n;

        System.out.println("int[3]:             " + align8(header + 4 * 3) + " bytes");
        System.out.println("int[1M]:            " + ints + " bytes");
        System.out.println("Integer[1M] filled: " + (refs + integerObjects) + " bytes");
        System.out.println("long[1M]:           " + longs + " bytes");
        System.out.println("Long[1M] filled:    " + (refs + longObjects) + " bytes");
    }
}
Output
int[3]:             32 bytes
int[1M]:            4000016 bytes
Integer[1M] filled: 20000016 bytes
long[1M]:           8000016 bytes
Long[1M] filled:    28000016 bytes

Boxing costs five times the memory for int and three and a half times for long, and every element access follows a pointer to a different place in the heap. ArrayList<Integer> and HashMap<Long, Long> carry this cost; primitive collections (Eclipse Collections, fastutil, Agrona's Int2IntHashMap) and plain arrays avoid it.

Example 5: the Integer cache

Integer.valueOf, which autoboxing calls, returns cached instances for values from -128 to 127. Outside that range each call allocates a new object, so == compares two different references.

Java
public class Main {
    public static void main(String[] args) {
        Integer a = 127, b = 127;
        Integer c = 128, d = 128;
        Integer e = -128, f = -128;
        System.out.println("127 == 127: " + (a == b));
        System.out.println("128 == 128: " + (c == d));
        System.out.println("-128 == -128: " + (e == f));
        System.out.println("128 equals 128: " + c.equals(d));
        Long x = 127L, y = 127L;
        System.out.println("Long 127 == 127: " + (x == y));
    }
}
Output
127 == 127: true
128 == 128: false
-128 == -128: true
128 equals 128: true
Long 127 == 127: true

The upper bound of the Integer cache can be raised with -XX:AutoBoxCacheMax=<n> (which sets the system property java.lang.Integer.IntegerCache.high); the lower bound stays -128. Long, Short, Byte, and Character (0 to 127) have fixed caches. Comparing boxed values with == is a common bug that passes tests with small numbers and fails in production with large ones.

Example 6: reading the layout flags at run time

HotSpotDiagnosticMXBean.getVMOption returns the current value of a VM flag and throws IllegalArgumentException for a flag the JVM does not have. That makes the program portable across releases.

Java
import com.sun.management.HotSpotDiagnosticMXBean;
import java.lang.management.ManagementFactory;

public class Main {
    public static void main(String[] args) {
        HotSpotDiagnosticMXBean bean = ManagementFactory.getPlatformMXBean(HotSpotDiagnosticMXBean.class);
        String[] flags = {
            "UseCompressedOops", "UseCompressedClassPointers",
            "ObjectAlignmentInBytes", "UseTLAB", "UseCompactObjectHeaders"
        };
        for (String flag : flags) {
            String value;
            try {
                value = bean.getVMOption(flag).getValue();
            } catch (IllegalArgumentException e) {
                value = "not available in this JDK";
            }
            System.out.println(flag + " = " + value);
        }
    }
}
Output
UseCompressedOops = true
UseCompressedClassPointers = true
ObjectAlignmentInBytes = 8
UseTLAB = true
UseCompactObjectHeaders = false

The run above uses -Xmx256m. With -Xmx40g, the first line prints false. The last line prints true with -XX:+UseCompactObjectHeaders; on JDK 21, which does not have the flag, it prints not available in this JDK. java -XX:+PrintFlagsFinal -version lists every flag with its final value.

Example 7: TLABs and bump-pointer allocation

The young generation is one large region, and threads would contend if they all allocated from its single free pointer. Each thread therefore gets a thread-local allocation buffer (TLAB), a chunk of eden it owns. Allocation inside a TLAB is a bump of a pointer: check that top + size <= end, return top, add size. No lock and no CAS. When the TLAB is full, the thread takes a new one from eden with a CAS on the shared pointer. This program models that path.

Java
public class Main {
    static final class Eden {
        long top = 0;
        final long end;
        Eden(long end) { this.end = end; }
        long take(long size) {               // shared: a CAS in a real JVM
            if (top + size > end) return -1;
            long start = top;
            top += size;
            return start;
        }
    }

    static final class Tlab {
        final Eden eden;
        final long tlabSize;
        long top, end;
        int refills;
        Tlab(Eden eden, long tlabSize) { this.eden = eden; this.tlabSize = tlabSize; }

        long allocate(long size) {
            if (top + size > end) {           // slow path: new TLAB
                long start = eden.take(tlabSize);
                if (start < 0) return -1;     // eden full: a young GC would run here
                top = start;
                end = start + tlabSize;
                refills++;
            }
            long address = top;              // fast path: bump the pointer
            top += size;
            return address;
        }
    }

    public static void main(String[] args) {
        Eden eden = new Eden(1024);
        Tlab tlab = new Tlab(eden, 256);
        long[] sizes = {24, 16, 32, 200, 24, 16};
        for (long s : sizes) {
            System.out.println("alloc " + s + " -> " + tlab.allocate(s));
        }
        System.out.println("refills: " + tlab.refills + ", eden used: " + eden.top);
    }
}
Output
alloc 24 -> 0
alloc 16 -> 24
alloc 32 -> 40
alloc 200 -> 256
alloc 24 -> 456
alloc 16 -> 480
refills: 2, eden used: 512

The 200-byte request did not fit in the 184 bytes left, so the thread retired the first TLAB (the JVM fills the tail with a dummy object so the heap stays parseable) and took a new one. Objects larger than a TLAB are allocated directly in eden or, for G1 humongous objects (half a region or more), in dedicated regions. Bump allocation makes new cost a few instructions, and objects allocated together sit next to each other in memory.

Example 8: measuring allocation per thread

com.sun.management.ThreadMXBean.getCurrentThreadAllocatedBytes (Java 14) returns how many bytes the current thread has allocated. It counts TLAB use, so it is a cheap way to assert an allocation budget in a test. Exact numbers vary with the JIT and with escape analysis, which can remove allocations, so the program compares with a lower bound.

Java
import java.lang.management.ManagementFactory;

public class Main {
    static Integer[] box(int n) {
        Integer[] out = new Integer[n];
        for (int i = 0; i < n; i++) {
            out[i] = 1000 + i;               // outside the cache: allocates
        }
        return out;
    }

    public static void main(String[] args) {
        com.sun.management.ThreadMXBean threads =
                (com.sun.management.ThreadMXBean) ManagementFactory.getThreadMXBean();
        long before = threads.getCurrentThreadAllocatedBytes();
        Integer[] values = box(1000);
        long after = threads.getCurrentThreadAllocatedBytes();
        long expectedMinimum = (16 + 4L * 1000) + 16L * 1000;
        System.out.println("expected minimum: " + expectedMinimum);
        System.out.println("allocated at least that: " + (after - before >= expectedMinimum));
        System.out.println("last value: " + values[999]);
    }
}
Output
expected minimum: 20016
allocated at least that: true
last value: 1999

The array escapes to main, so escape analysis cannot remove the boxes. The same counter is exposed by JFR (jdk.ObjectAllocationSample) and by allocation profilers such as async-profiler in allocation mode.

Interview points

  • Header on 64-bit HotSpot: 8-byte mark word plus 4-byte compressed class pointer, 12 bytes; arrays add a 4-byte length for 16.
  • new Object() takes 16 bytes; an Integer takes 16; a Long takes 24 by default.
  • Compressed oops are on below about 32 GB of heap; above it every reference becomes 8 bytes.
  • Fields are reordered by size; padding must use inheritance or @Contended.
  • Integer.valueOf caches -128 to 127; == on boxed values compares references.
  • A TLAB makes allocation a pointer bump; refill and large objects take the slower shared path.
  • Compact object headers: JEP 450 experimental in Java 24, JEP 519 product in Java 25, off by default, 8-byte header.

Summary

  • Object size = header + fields packed by size + padding to 8 bytes.
  • JOL prints real offsets; -XX:+PrintFlagsFinal and HotSpotDiagnosticMXBean print the flags that change them.
  • Boxed collections cost several times the memory of primitive arrays.
  • Each thread allocates in its own TLAB; getCurrentThreadAllocatedBytes measures allocation per thread.