Step 03 · Object layout
Every heap object starts with the same header:
struct Object {
ObjKind kind; // 1 byte: String, Array, ...
uint8_t marked; // 1 byte: GC bookkeeping
uint16_t _pad; // 2 bytes
uint32_t size; // 4 bytes: total size including header
Object* next; // 8 bytes: intrusive linked list
};
16 bytes of overhead per object. We trade a few bytes for:
- A uniform header the GC can inspect blind.
- An intrusive object table — no separate metadata structure to
keep in sync. The sweep phase just walks
head_ → next → next → …. - Inline
sizeso sweep knows how much memory each object holds.
StringObj
struct StringObj : Object {
uint32_t len;
char data[1]; // flexible array
};
Allocation: sizeof(StringObj) + len. We declare data[1] so the
struct is well-formed even at length 0; the real size is computed in
newString. We always NUL-terminate for cheap interop with C
printers.
ArrayObj
struct ArrayObj : Object {
uint32_t len;
Value elems[1];
};
Inline storage of Values (boxed pointers). The GC iterates elems[0..len)
during mark, no separate "type descriptor" needed because the kind
field tells it the layout.
Forwarding the design
For closures we'd add:
struct ClosureObj : Object {
uint32_t numUpvalues;
uint64_t funcId; // index into the JIT'd function table
Value upvalues[1]; // captured environment
};
The mark routine grows a switch:
switch (o->kind) {
case ObjKind::Array: for each elem mark(elem); break;
case ObjKind::Closure: for each upvalue mark(upval); break;
case ObjKind::String: /* no pointers */ break;
}
This is the canonical "GC tracing per kind" pattern. Variations:
embed pointer offsets directly in the header, or rely on a
type-descriptor pointer to call a virtual trace method.