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 size so 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.