module 01 · memory models · Part I · the machine

Chapter 06 — Object layout: what a "value" really looks like in RAM

The 30-second version. Every language answers "what are the bytes of a value?" with one of three moves: unboxed (the bytes are the value — a C struct, a Rust struct, a C# struct), boxed (a heap block with a header plus payload, reached by pointer — every Java object, every Python object), or tagged (bits of the word itself say what it is — V8's Smis, NaN-boxing). Padding, vtables, headers, fat pointers, and the length-of-array problem all live on this map. Learn the three moves once, and every language in Part II is a local dialect.

The three representations

Take the humble value 42. Depending on the language, it may be:

unboxed (C, C++, Rust, C# struct)      boxed (Java, C#, Python, JS object)
 ┌──────────────┐                       ┌─────────┐     ┌──────────────────┐
 │  42          │ 4 bytes on stack/     │ ref ────┼────▶│ header │ 42      │ heap
 └──────────────┘ anywhere              └─────────┘     └──────────────────┘
 no header, no pointer, no metadata                     header = identity+GC bits
                                                 
tagged (V8 Smi, NaN-boxing, classic Lisp)
 ┌───────────────────────┐
 │ 42 << 1  │ tag=0      │  one word: value+type-in-bits (bit 0 clear = Smi)
 └───────────────────────┘  31-bit payload on 32-bit V8 (see Chapter 15)

Unboxed is the machine's native language: zero overhead, but no identity (you cannot ask "is this the same 42"), no runtime type, no GC. Boxed buys identity, dynamic type, and collector integration, at the price of one heap block, one header, and one pointer dereference per access (a Chapter 02 locality event). Tagged is the compromise dialects invent when they want unboxed speed and dynamic typing at once: steal bits from the pointer or the double (the pointer tagging and spare-bits material from Chapter 01).

Padding, computed by hand

Natural alignment (Chapter 01) plus one extra rule — a struct's own alignment is the largest alignment of its members, so arrays of structs stay aligned — gives you the complete layout algorithm. Compute it once by hand and you own it forever:

struct A { char c; int i; char c2; };   // offsets: c@0, pad 1-3, i@4, c2@8, pad 9-11
struct B { int i; char c; char c2; };   // offsets: i@0, c@4, c2@5, pad 6-7
// sizeof(A) = 12,  sizeof(B) = 8 — same fields, 33% waste from ordering alone

The compiler is your ally here in C (it may not reorder — the standard fixes member order for compatibility — but it inserts the padding); C++ adds the famous complication that members are laid out in declaration order per access-control group, and Rust simply fixes order too. Chapter 01's lab proved the numbers on real hardware; the practice takeaway is Chapter 02's: a struct that fits in one 64-byte line is a different animal from one that straddles two. Sorting members largest-alignment-first is the classic 20-30% layout win.

The header: the price of identity

What does a boxed object carry before your first field? Per language, drawn to scale (64-bit unless noted):

Java (HotSpot, compressed oops):   [ mark word 8B ][ klass ptr 4B ][ fields... ]
                                     hash/lock bits    type          12B header, 8B-align
Python (CPython 3.11):             [ refcnt 8B ][ type ptr 8B ][ payload... ]
                                     → a plain int is 28 bytes total
V8 (Node, no ptr-compression):     [ map word 8B ][ properties ][ elements ][ in-object fields ]
                                     the map IS the hidden class (Chapter 15)
Objective-C/Swift class instance:  [ isa 8B (arm64: packed with refcount!) ][ fields ]
                                     Chapter 16's non-pointer-isa
C++ class with virtuals:           [ vptr 8B ][ fields... ]     no other header!
C# class:                          [ obj header 8B (sync+method table) ][ fields ]

Same idea, different contents — the header carries whichever of these the runtime needs: type identity (klass/map/isa/vtable), GC bookkeeping (mark bits, age — the JVM's 4-bit GC age lives in the mark word, Chapter 12), lock/hash state (the same JVM mark word moonlights as a thin lock), and reference counts (Python, Objective-C, Swift). Note the one honest exception: C++ has no per-object GC or type header — a polymorphic object carries exactly one pointer, the vptr. That is the zero-overhead principle (Chapter 09) drawn at byte scale.

Vtables: virtual dispatch at byte level

struct Shape { virtual double area(); int id; };
// object:      [ vptr (8B) ][ id (4B) ]
//                    │
//                    ▼  (one indirect load)
//              vtable: [ &Shape::area ][ typeinfo* ]   (layout: ABI-defined, Itanium ABI
//                                                       puts typeinfo at offset −2 words)

A virtual call: load vptr, load slot, call indirect — two dependent loads (Chapter 02 says: two potential misses) and no inlining across the call site, which is the real cost (the missed specialization, not the instructions). Multiple inheritance adds offset thunks; the details are Module 04 material (decompilers reconstruct classes from exactly these vtable shapes — and Chapter 21's attackers target them: overwrite a vptr, own the call). Rust and Go compile the same idea into their tagged enums and fat interfaces (below).

Fat pointers: one pointer is not always one word

Some values need more than an address to be useful:

Go slice            [ data ptr ][ len ][ cap ]          3 words — the length rides WITH
Rust &[T] slice     [ data ptr ][ len ]                 2 words
Go interface        [ itab (type+methods) ][ data ptr ] 2 words — the type rides WITH
std::string_view    [ data ptr ][ size ]                2 words
Swift String        (small-string: inline bytes, 16B total — Chapter 16)

The pattern: the pair (pointer, length/type) travels together in registers/stack slots — "fat pointer." This solves the C plague at its root: a Go slice cannot forget its length; every indexing compiles to a bounds check against the len riding along. Chapter 21's entire "CWE-787: out-of-bounds write" industry exists because C arrays are naked pointers with the length forgotten at decay — the single most consequential layout decision in this book.

The length-of-array problem, the master example

Who knows how long the array is?

Four layouts, four safety regimes — one problem. When you meet a new language in Part II, ask this question first; it predicts the language's whole error personality.

Tagging and NaN-boxing: stealing spare bits

Chapter 01 left us spare bits: a 48-bit canonical address leaves the top 16 bits of every pointer unused; IEEE-754 quiet NaNs leave 51 bits of payload unused.

The trade is always the same: you buy "no allocation for half the values" with "a mask/shift on every operation" — and the tagging IS the type system at runtime.

Boxing, when it leaks into your perf profile

Value-language code that quietly heap-allocates: C# struct → interface cast (Chapter 13's 24-byte box), Java int → Integer autoboxing into collections (Chapter 12's classic p99 killer), OCaml's polymorphic compare... The detector is a heap profile that shows small, identical-size allocations at a hot call site. The fixes form a family: generics (parametric types make the box unnecessary — this is why Java generics and C# generics exist at the memory level), spans/slices over raw arrays, and unboxed fields on the containing object.

What insiders know

See it yourself

One C program prints the whole chapter; run it on x86-64:

#include <stdio.h>
#include <stddef.h>
struct A { char c; int i; char c2; };
struct B { int i; char c; char c2; };
struct V { int (*fp)(void); int id; };       /* hand-rolled "vptr" first member */
int main(void) {
    printf("A: size=%zu c@%zu i@%zu c2@%zu\n", sizeof(struct A),
           offsetof(struct A, c), offsetof(struct A, i), offsetof(struct A, c2));
    printf("B: size=%zu i@%zu c@%zu c2@%zu\n", sizeof(struct B),
           offsetof(struct B, i), offsetof(struct B, c), offsetof(struct B, c2));
    printf("ptr=%zu  V: size=%zu\n", sizeof(void*), sizeof(struct V));
    struct V obj = { NULL, 7 };
    printf("V obj bytes: fp=%p id=%d — the 'vptr' is just member 0\n", obj.fp, obj.id);
    return 0;
}

Then the boxed-size ladder (Python): python3 -c "import sys; print(sys.getsizeof(0), sys.getsizeof(1), sys.getsizeof(10**30), sys.getsizeof(''), sys.getsizeof('a'))" — 28, 28, (grows), 49, 50 on CPython 3.11: the header is the floor (Chapter 14 dissects it). And Rust: println!("{}", std::mem::size_of::<Option<&u8>>()) → 8 — the niche optimization, live.

How to use this knowledge

Connections

Forward links — where the rest of the program picks this chapter up:

Quiz

  1. Concept. Why does a boxed value cost a pointer dereference even when the payload is already in cache? Trace the two-step access.
  2. Concept. Why must a struct's total size be padded to its largest member's alignment? What breaks in arrays of that struct otherwise?
  3. Predict the number. x86-64: struct {char a; double b; char c;} — size and every offset?
  4. Predict the number. A C# class holding an int and a char (auto layout): minimum object size on x64? And the same fields as a struct (unboxed)?
  5. Predict the behavior. Java: Integer a = 127, b = 127; Integer c = 200, d = 200; — what do a == b and c == d print, and what does it have to do with boxing?
  6. Bug diagnosis. A Go function's for _, v := range bigSlice is fast, but the same loop over []interface{} is 10x slower with heavy GC. Explain with representations.
  7. Bug diagnosis. A C++ profiler shows a hot virtual call missing constantly at L1. Two objects of the same class are being called alternately. What is the layout-level mechanism?
  8. Cross-chapter. Chapter 21 says "CWE-787 is the #1 CWE." Which table in this chapter explains why that bug class cannot exist in Java, and by what mechanism (two words)?
Answers
  1. The variable holds a reference: first access loads the pointer itself (usually register/stack — cheap), then dereferences to the heap block where the payload sits. Even with the block cached, you pay the dependent load latency and — for scattered objects — the line/row locality costs of Chapter 01/02. Unboxed reads the bytes directly from the variable's own slot.
  2. Arrays compute element addresses as base + i × sizeof(struct): if sizeof weren't a multiple of the largest alignment, element 1's double would land misaligned — every element after the first breaks the Chapter 01 alignment contract, and atomics (Chapter 20) could tear.
  3. size = 24: a@0, pad 1-7, b@8 (8B), c@16, pad 17-23 (trailing pad to multiple of 8).
  4. Class: 8B object header + 4B int + 2B char + pad to 8 → 16 bytes minimum. Unboxed struct: 4 + 2 → 8 bytes with packing (or 8 with default sequential layout — the point: no header, no pointer; the class costs the header forever, the struct costs nothing beyond its fields).
  5. a == b → true (reference equality, but Integer caches −128..127 — both 127s are the same object); c == d → false (two distinct boxed 200s). This is boxing + a small cache — Chapter 12 and 14's small-int singletons, and the reason the rule "never == wrappers" exists.
  6. []T holds unboxed records contiguous in memory — range iteration streams lines (Chapter 02). []interface{} boxes every element: each is a separate heap object (map/itab + payload — fat pointer to a boxed value), so iteration pointer-chases, allocation rate explodes, and GC pressure (Chapter 11/05's allocation tax) dominates. Representation, not the loop, is the cost.
  7. Two objects → likely two vtables, and worse, two vptr values alternating at one call site: the vptr load and the vtable slot load (two dependent loads, Chapter 02/09) can't stay predicted in the L1 (branch target + data caches both thrash); inlining is impossible (unknown target). Megamorphism at machine level — the Chapter 15 lesson, visible in C++ clothes.
  8. The length-of-array table: Java arrays carry their length in the header, and the runtime bounds-checks every indexing — "length field + enforced check," the two-word answer to why CWE-787 is a C-family disease.