Dart 91 🎯 Internal Architecture: Class Table Layout, Object Headers, and Smi Mechanics
The Dart VM’s internal architecture is a study in trade-offs between memory efficiency and execution speed. Every object in the heap begins with a header that encodes metadata for garbage collection, type identification, and canonicalization. The class table provides a level of indirection that allows objects to reference their type with a small integer instead of a full pointer. And Smis—small integers—are not heap objects at all; they are encoded directly in the pointer bits, which means arithmetic on them never allocates and garbage collection never touches them. Understanding these three mechanisms explains why Dart’s object model behaves the way it does under the hood.
This chapter covers the object header’s tag bits, the class table’s role in method dispatch and type identification, and the Smi tagging scheme that keeps small integers off the heap. It examines the bit-level layout that makes each mechanism work and the constraints that force objects out of the fast path and into heap allocation.
Key point: A Smi is not an object. It is an integer encoded in the pointer itself, with the lowest bit set to 0 to distinguish it from a heap object. This means the VM can perform arithmetic without allocating memory, but it also means the range of values that fit in a Smi is limited by the pointer width and the number of bits reserved for tagging.
Why these internals matter
The dispatch problem. When a Dart program calls a method on an object, the VM must determine which implementation to execute. In a language with single inheritance and interfaces, this is not a simple pointer lookup. The class table exists to make dispatch fast: every object carries a class ID in its header, and the class table maps that ID to the class’s metadata, including its vtable. The indirection is what allows the VM to change a class’s implementation—through JIT compilation or hot reload—without rewriting every object that references it .
The allocation problem. Heap allocation is expensive. Every allocation requires finding free space, initializing the header, and potentially triggering a garbage collection. Smis exist to avoid allocation for the most common numeric values. An integer that fits in the tagged pointer representation never touches the heap, never appears in a GC root set, and never requires a write barrier. The performance difference between Smi arithmetic and heap-number arithmetic is the difference between a few CPU instructions and a memory allocation .
The type identification problem. Dart is dynamically typed at runtime, even when the source is statically typed. The VM must be able to answer “what type is this object?” at any point. The object header’s class ID provides the answer in a fixed number of bits. The class table maps that ID to the full type information. Without this indirection, every object would need to carry a full type pointer, increasing object size and complicating GC .
a. Object Headers and Tag Bits
Every heap-allocated object in the Dart VM begins with a tags_ field. This field is not a single value but a bit field that packs multiple pieces of metadata into one word. The exact layout is internal and can change between VM versions, but the categories are stable: garbage collection marking bits, a canonicalization bit, and the class ID .
The class ID occupies a fixed-width portion of the header. Historically, this was 16 bits, which limited the VM to 65,536 classes. A later change moved top-level and abstract classes to a separate ID space outside the 16-bit range, freeing space for concrete classes that can have instances in the heap . The constraint exists because the header must fit in a single word, and the class ID must be extractable with a bitmask and shift operation.
// Conceptual header layout (not exact VM implementation)
┌─────────────────────────────────────────────────────────────┐
│ tags_ field (64 bits on 64-bit VM) │
├─────────────────────────────────────────────────────────────┤
│ [GC bits] [canonical bit] [hash bits] [class ID] [unused] │
└─────────────────────────────────────────────────────────────┘
│ │ │ │
│ │ │ └── Class table index
│ │ └── Identity hash (lazy)
│ └── Canonicalization state
└── Marking bits for concurrent GC
The header is accessed atomically because the garbage collector runs concurrently with the mutator. The tag bits are read and written with relaxed ordering, and the GC uses separate mechanisms—write barriers, safepoints, and acquire-release operations—to ensure correctness without requiring every header access to be synchronized .
b. The Class Table
The class table is a contiguous array indexed by class ID. Each entry points to a Class object containing metadata: the class name, its superclass, its instance size, its vtable, and the methods it implements. The table is per-isolate, meaning each Dart isolate has its own class table, though the VM isolate holds a shared table for predefined classes .
The indirection exists for two reasons. First, it allows objects to be compact: a 16-bit class ID is smaller than a 64-bit pointer. Second, it allows the VM to modify class metadata without touching objects. When the JIT compiler optimizes a method, it can patch the vtable entry in the class table, and every instance of that class immediately uses the new code. When hot reload adds a method to a class, the VM updates the class object and the dispatch tables, and existing instances see the new method without being reallocated .
// Conceptual class table lookup
// The VM extracts the class ID from the object header
int cid = object.tags_.classId;
// The class table maps the ID to the Class metadata
Class cls = isolate.classTable[cid];
// The Class object contains the vtable for dispatch
Function target = cls.vtable[methodIndex];
The class table is also the basis for type checks. An instanceof test against a concrete class can be reduced to a class ID comparison, which is a single integer comparison. Type checks against interfaces or superclasses require walking the class hierarchy, but the class table provides the starting point .
c. Smi Mechanics
A Smi (small integer) is a Dart int whose value fits in the pointer representation without heap allocation. On a 64-bit VM, the pointer is 64 bits, and the lowest bit is used as a tag: 0 for Smi, 1 for heap object. The integer value is stored in the upper 63 bits, shifted left by one. This gives a range of approximately −2⁶² to 2⁶²−1 for Smis on a 64-bit VM .
// Smi encoding on a 64-bit VM
Value: 42
Binary: 0b101010
Tagged: 0b1010100 (shifted left by 1, tag bit 0)
Value: -1
Binary (two's complement): ...11111111
Tagged: ...11111110 (shifted left by 1, tag bit 0)
The tag bit is the mechanism that distinguishes a Smi from a heap pointer. A heap pointer has the lowest bit set to 1 (kHeapObjectTag = 0x01). When the VM receives a value, it checks the lowest bit: if 0, the value is a Smi and the integer is value >> 1; if 1, the value is a pointer to a heap object, and the actual address is value - 1 .
This encoding has consequences. Arithmetic on Smis does not allocate, but it can overflow the Smi range. When that happens, the VM allocates a heap number (Mint or Bigint) to hold the result. The transition from Smi to heap number is invisible to the Dart programmer but visible in the VM’s performance profile: every heap allocation is a potential GC trigger, and every heap number must be traced by the collector .
The GC also benefits from Smis. A field that is known to contain a Smi does not need a write barrier. Write barriers exist to track cross-generational pointers, and a Smi is not a pointer to a heap object. The compiler can eliminate the barrier when it can prove the value is a Smi, which is one of the optimizations that makes Smi-heavy code fast .
Complete Example Session
// ============================================
// PART 1: SMI RANGE ON 64-BIT VM
// ============================================
// The maximum Smi value is 2^62 - 1 on a 64-bit VM
// because one bit is used for the tag
void main() {
// This value fits in a Smi
int small = 4611686018427387903; // 2^62 - 1
print(small); // Smi, no heap allocation
// This value exceeds the Smi range and becomes a heap number
int big = 4611686018427387904; // 2^62
print(big); // Mint or Bigint, heap allocation
}
// ============================================
// PART 2: SMI ARITHMETIC DOES NOT ALLOCATE
// ============================================
void smiArithmetic() {
int a = 100;
int b = 200;
// The result 300 fits in a Smi
// No heap allocation, no GC pressure
int sum = a + b;
}
// ============================================
// PART 3: OVERFLOW ALLOCATES
// ============================================
void overflow() {
int maxSmi = 4611686018427387903; // 2^62 - 1
// Adding 1 exceeds the Smi range
// The VM allocates a heap number for the result
int overflowed = maxSmi + 1; // Heap allocation
}
// ============================================
// PART 4: CLASS ID EXTRACTION
// ============================================
// The VM extracts the class ID from the object header
// This is conceptual; the actual implementation is in C++
void conceptualClassId(Object obj) {
// Pseudocode for VM operation:
// int cid = (obj.tags_ >> kClassIdShift) & kClassIdMask;
// Class cls = isolate.classTable[cid];
}
// ============================================
// PART 5: CLASS TABLE LOOKUP FOR DISPATCH
// ============================================
// When a method is called, the VM uses the class ID
// to find the vtable entry in the class table
void conceptualDispatch() {
// Pseudocode:
// int cid = object.classId();
// Function target = isolate.classTable[cid].vtable[methodIndex];
// target(object, arguments);
}
// ============================================
// PART 6: TYPE CHECK VIA CLASS ID
// ============================================
// A concrete class check compares class IDs
void typeCheck(Object obj) {
if (obj is String) {
// The VM compares the object's class ID
// against the String class ID
// Single integer comparison for concrete class
}
}
// ============================================
// PART 7: INTERFACE CHECK REQUIRES HIERARCHY WALK
// ============================================
// An interface check cannot be a single ID comparison
// The VM walks the class hierarchy or uses a subtype cache
void interfaceCheck(Object obj) {
if (obj is Comparable) {
// The VM checks whether the class implements Comparable
// This may involve walking the class's interface list
}
}
// ============================================
// PART 8: WRITE BARRIER ELIMINATION FOR SMI
// ============================================
// The GC does not need to track Smi stores
// because Smis are not heap objects
class Container {
int value = 0; // Smi field
}
void storeSmi(Container c) {
// This store does not need a write barrier
// The compiler knows value is a Smi
c.value = 42;
}
// ============================================
// PART 9: HEAP NUMBER ALLOCATION
// ============================================
// When a value exceeds the Smi range
// the VM allocates a Mint (64-bit) or Bigint
void heapNumber() {
int big = 4611686018427387904; // 2^62
// The VM allocates a Mint object
// The object has a header and a 64-bit payload
}
// ============================================
// PART 10: CONCEPTUAL OBJECT HEADER
// ============================================
// Every heap object begins with a tags_ field
// The field encodes GC state, class ID, and flags
class ConceptualHeader {
// This is not real Dart code
// It represents the VM's internal layout
int tags; // [GC bits][canonical][hash][class ID]
// Followed by the object's fields
}
The ten parts trace the Smi encoding, the class table’s role in dispatch and type checking, and the GC implications of the tag bit. Part 1 and Part 3 show the boundary between Smi and heap number. Part 8 shows why the GC can ignore Smi stores.
Quick Reference
Object Header Components
| Component | Purpose | Width |
|---|---|---|
| GC marking bits | Concurrent GC state | Variable |
| Canonicalization bit | Type canonicalization | 1 bit |
| Identity hash | hashCode value | Variable |
| Class ID | Class table index | 16 bits (concrete) |
Smi Range
| VM Word Size | Smi Bits | Range |
|---|---|---|
| 32-bit | 31 | −2³⁰ to 2³⁰−1 |
| 64-bit | 63 | −2⁶² to 2⁶²−1 |
Tag Bits
| Tag | Value | Meaning |
|---|---|---|
kSmiTag | 0x00 | Small integer (value shifted left) |
kHeapObjectTag | 0x01 | Heap object (pointer + 1) |
Class Table Lookup
| Step | Operation |
|---|---|
| 1 | Extract class ID from tags_ |
| 2 | Index into class table |
| 3 | Retrieve Class metadata |
| 4 | Access vtable or type info |
Best Practices
✅ Do This:
// Use int values that fit in Smi range for hot paths
int counter = 0; // ✅
// Prefer int over double for integer arithmetic
// Smis are faster than heap doubles in tight loops
for (int i = 0; i < 1000; i++) { } // ✅
// Understand that large integer values allocate
// If a value exceeds 2^62-1, it becomes a heap number
❌ Don’t Do This:
// Assume all integers are Smis
// Values outside the range allocate on the heap // ❌
// Use dynamic typing in hot loops
// The VM cannot eliminate write barriers or type checks // ❌
// Rely on exact Smi boundaries
// The range is an implementation detail that may change // ❌
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Unexpected heap allocation | Integer exceeds Smi range | Use smaller values or accept allocation |
| Slow arithmetic in loops | Values cross Smi boundary | Keep values within Smi range |
| Write barrier not eliminated | Compiler cannot prove Smi | Use static typing to help compiler |
| Class ID exhaustion | Too many concrete classes | Split libraries or reduce class count |
Real-World Examples
1. Smi Arithmetic
int a = 10;
int b = 20;
int sum = a + b; // Smi, no allocation
2. Smi Overflow
int max = 4611686018427387903; // 2^62 - 1
int overflow = max + 1; // Heap number
3. Class ID Check
if (obj is String) { } // Compares class ID
4. Interface Check
if (obj is Comparable) { } // Walks hierarchy
5. Write Barrier Elimination
class C { int x = 0; }
c.x = 42; // No write barrier for Smi
6. Heap Number Allocation
int big = 4611686018427387904; // 2^62
7. Smi Tag Check
// Conceptual: if ((value & 1) == 0) it is a Smi
8. Class Table Index
// Conceptual: isolate.classTable[cid]
9. Vtable Dispatch
// Conceptual: cls.vtable[methodIndex](receiver)
10. Object Header Access
// Conceptual: obj.tags_ & kClassIdMask
Visual
Smi Encoding
┌──────────────────────────────────────────────────────────────┐
│ 64-BIT WORD │
│ │
│ ┌─────────────────────────────────────────────────────────┐│
│ │ 63 bits: integer value (shifted left by 1) │ 0 │ ││
│ └─────────────────────────────────────────────────────────┘│
│ │ │
│ └── Smi tag │
│ │
│ Value: 42 → binary 101010 → tagged 1010100 │
│ Value: -1 → tagged ...11111110 │
│ │
│ Heap object pointer: address | 1 (kHeapObjectTag) │
│ │
│ The lowest bit distinguishes Smi from heap pointer. │
│ │
└──────────────────────────────────────────────────────────────┘
Class Table Indirection
┌─────────────────────────────────────────────────────────────┐
│ OBJECT CLASS TABLE │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ tags_ │ │ [0] Class │ │
│ │ class ID = 5 │───────►│ [1] Class │ │
│ └─────────────────┘ │ [2] Class │ │
│ │ [3] Class │ │
│ ┌─────────────────┐ │ [4] Class │ │
│ │ fields... │ │ [5] Class ◄─────┼──┐ │
│ └─────────────────┘ │ [6] Class │ │ │
│ └─────────────────┘ │ │
│ │ │
│ ┌────────────────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Class metadata │ │
│ │ - vtable │ │
│ │ - superclass │ │
│ │ - instance size │ │
│ └─────────────────┘ │
│ │
│ Object carries ID, not pointer. │
│ Table maps ID to metadata. │
│ │
└─────────────────────────────────────────────────────────────┘
Smi vs Heap Number
┌──────────────────────────────────────────────────────────────┐
│ SMI HEAP NUMBER │
│ │
│ ┌─────────────────┐ ┌─────────────────┐ │
│ │ Encoded in │ │ Heap object │ │
│ │ pointer bits │ │ with header │ │
│ │ │ │ │ │
│ │ No allocation │ │ Allocation │ │
│ │ No GC tracking │ │ GC tracked │ │
│ │ No write barrier│ │ Write barrier │ │
│ └─────────────────┘ └─────────────────┘ │
│ │
│ Range: -2^62 to 2^62-1 Range: full 64-bit │
│ (64-bit VM) (Mint/Bigint) │
│ │
│ Fast path for common values. Slow path for overflow. │
│ │
└──────────────────────────────────────────────────────────────┘
Dispatch via Class ID
┌──────────────────────────────────────────────────────────────┐
│ METHOD CALL: obj.method() │
│ │
│ ┌─────────────────┐ │
│ │ Extract class │ cid = obj.tags_ >> shift & mask │
│ │ ID from header │ │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Look up class │ cls = classTable[cid] │
│ │ in class table │ │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Access vtable │ target = cls.vtable[methodIndex] │
│ │ entry │ │
│ └────────┬────────┘ │
│ ▼ │
│ ┌─────────────────┐ │
│ │ Call target │ target(obj, args) │
│ └─────────────────┘ │
│ │
│ The class table indirection allows the VM to patch │
│ vtables without rewriting objects. │
│ │
└──────────────────────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Object header | tags_ field with GC bits, canonical bit, class ID |
| Class ID width | 16 bits for concrete classes (heap objects) |
| Class table | Per-isolate array mapping ID to Class metadata |
| Smi tag | Lowest bit 0 |
| Heap object tag | Lowest bit 1 (kHeapObjectTag) |
| Smi range (64-bit) | −2⁶² to 2⁶²−1 |
| Smi range (32-bit) | −2³⁰ to 2³⁰−1 |
| Write barrier | Eliminated for Smi stores |
| Dispatch | Class ID → class table → vtable |
Key takeaways:
- The object header packs metadata into a single word. GC marking bits, canonicalization state, identity hash, and class ID share the
tags_field. The class ID is limited to 16 bits for concrete classes because the header must fit in a word and the ID must be extractable with a mask and shift . - The class table provides indirection for dispatch and type checks. Objects carry an ID, not a pointer. The table maps the ID to class metadata, including the vtable. This indirection allows the VM to patch method implementations without touching objects, which is essential for JIT compilation and hot reload .
- A Smi is not a heap object. It is an integer encoded in the pointer bits with the lowest bit set to 0. Arithmetic on Smis does not allocate, does not trigger GC, and does not require a write barrier. This is the fast path for the most common numeric values .
- The Smi range is a consequence of the tag bit. On a 64-bit VM, one bit is reserved for the tag, leaving 63 bits for the integer value. The range is approximately −2⁶² to 2⁶²−1. Values outside this range become heap numbers (
MintorBigint) . - The distinction between Smi and heap number is invisible in Dart source. The VM transitions automatically when a value exceeds the range. The performance implication—allocation and GC pressure—is real but not something the programmer manages explicitly.
Remember: The Dart VM’s internal architecture is a set of bit-level decisions that trade memory for speed and indirection for flexibility. The object header uses bits for metadata because every object pays for that header, and a few bits saved per object becomes megabytes across a heap. The class table uses an ID instead of a pointer because the indirection allows the VM to modify class behavior without rewriting instances. Smis encode integers in the pointer because the most common values should not pay the cost of heap allocation. None of these mechanisms is visible in Dart source code, but they explain why some operations are fast, why some values allocate, and why the VM can optimize method calls without changing the objects themselves.
Stop using slow, ad-bloated tool sites! 🤮
🔎 Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)
⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free
🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!