Object header, MethodTable pointer and fields; MethodTable versus EEClass versus RuntimeType; minimum object size; inspecting with SOS.
Every heap object starts with a pointer to its MethodTable (the runtime's type descriptor), preceded by an 8-byte object header on 64-bit, followed by the fields. The MethodTable holds the base size, flags, parent type, the interface map and the vtable slots used for virtual dispatch; rarely used data lives in a separate EEClass. The type object is the managed System.RuntimeType instance you get from typeof(T) or GetType(); it is a normal heap object that points to the MethodTable (RuntimeTypeHandle.Value). On 64-bit the smallest object is 24 bytes. You can inspect all of this with dotnet-dump and SOS (dumpobj, dumpmt).
Every .NET object on the heap starts with a pointer to its type's MethodTable — the CLR's runtime representation of a type (the "type object" in some books). The MethodTable holds what the runtime needs at high speed: the type's size, parent, interfaces, vtable of virtual methods, GC layout and flags. An object's layout is: object header, MethodTable pointer, then fields. I'd explain both, how they're used, and how to inspect them.
-8 0 8
[ header ][ MethodTable* ][ field 1 ][ field 2 ] ... [padding]
^ an object reference points HERE
Examples:
object → 24 bytes.class Point { int X; int Y; } → 8 + 8 + 8 = 24 bytes.string → header + MT + int Length + chars (UTF-16) + null terminator, padded.Length (+ padding) + elements (int[10] = 8 + 8 + 8 + 40 = 64 bytes).int → 24 bytes (header + MT + 4-byte value + padding).| Part | Purpose |
|---|---|
| Base size, component size | Allocation size (component size for arrays/strings) |
| Flags | Has finaliser, contains GC pointers, is value type, is interface, generic info, etc. |
| Parent MethodTable pointer | Inheritance, casting (is/as) |
| Interface map | Implemented interfaces (for casts and interface dispatch) |
| Vtable slots | Pointers to virtual method code (inherited slots first, then new virtuals) and non-virtual slots |
| Pointer to EEClass | Less frequently used data (field descriptors, method descriptors) |
| Module, generic dictionary pointers | Generics and metadata |
| GC descriptor (stored before the MT) | Which offsets in the object contain references, so the GC can scan |
| Static data pointers | Where statics live |
EEClass holds colder data shared between generic instantiations (field layout, method descs), keeping MethodTables compact and hot.
GetType() → reads the MT pointer and returns the RuntimeType for it.is, as, castclass) → compare MT pointers, walk the parent chain or interface map (with a cast cache).new uses the MT base size and writes the MT pointer.Unboxed value types (struct locals, struct fields, array elements of struct type) have no header and no MT pointer — just the fields. The MT exists for the type, but individual values don't point to it; that's why structs are compact and why boxing allocates (it adds header + MT).
dotnet-dump analyze:
dumpheap -type Order // find instances
dumpobj <address> // fields, size, MT
dumpmt -md <MT address> // MethodTable, vtable, method descs
dumpclass <EEClass>
TypeLayout.PrintLayout<Order>() prints field offsets, padding, sizes.Unsafe.SizeOf<T>() for struct sizes; RuntimeHelpers.GetRawObjectDataSize (internal) style tools in diagnostics.An in-memory catalogue of 20 million PriceEntry objects (class with int ProductId, int RegionId, decimal Price) used ~1 GB. Each object was 8 (header) + 8 (MT) + 4 + 4 + 16 = 40 bytes plus an 8-byte reference in the list — 48 bytes per entry. Converting to a readonly record struct stored in arrays cut each entry to 24 bytes with no per-object header or reference, halving memory and reducing GC scanning time because the struct contained no references.
The interviewer checks low-level runtime understanding relevant to memory analysis.
Green flags: header at negative offset, MT pointer at offset 0, contents of a MethodTable, EEClass split, RuntimeType as the type object, 24-byte minimum, SOS commands.
Red flags: thinks objects contain only their fields; confuses the type object with the MethodTable; no idea how to inspect a heap.
MethodTable
m_dwFlags / m_BaseSize
m_wNumVirtuals / m_wNumInterfaces
m_pParentMethodTable
m_pModule
m_pAuxiliaryData (EEClass, statics, generic info)
vtable indirection chunks -> [slot0: ToString] [slot1: Equals] [slot2: GetHashCode] [slot3: Finalize] [new virtuals...]
interface map
Vtable slots are stored in chunks that can be shared between parent and child types when not overridden, saving memory.
[StructLayout] can force sequential/explicit for interop.typeof(T) returns a RuntimeType object that wraps a TypeHandle (pointer to the MethodTable). Type.TypeHandle.Value exposes the MT address. RuntimeHelpers.GetMethodTable(obj) (internal) is used by the BCL.
Int32[]), component size, length; multi-dimensional arrays add bounds.String literals and some static readonly objects can be allocated in frozen segments (non-GC heap) in newer .NET versions, reducing GC work. NativeAOT precomputes MethodTables in the binary's data section.
ObjectLayoutInspector to print the layout of a class and a struct with mixed fields.dotnet-dump analyze and dumpobj, then dumpmt -md.Unsafe.SizeOf<T>().using System.Runtime.CompilerServices;
object order = new Order { Id = 42 };
IntPtr fromType = typeof(Order).TypeHandle.Value; // MethodTable pointer via the type object
IntPtr fromObject = ReadMethodTable(order); // first word of the object
Console.WriteLine($"MT from typeof: 0x{fromType:X}");
Console.WriteLine($"MT from object: 0x{fromObject:X}");
Console.WriteLine($"Same type object: {ReferenceEquals(typeof(Order), order.GetType())}");
long before = GC.GetAllocatedBytesForCurrentThread();
object empty = new object();
long after = GC.GetAllocatedBytesForCurrentThread();
Console.WriteLine($"new object() allocated {after - before} bytes"); // 24 on 64-bit
GC.KeepAlive(empty);
// Demonstration only: reads the object's first word. The object is not moved
// between taking the address and reading it in this tiny window, but do not use in production.
static unsafe IntPtr ReadMethodTable(object o) => **(IntPtr**)Unsafe.AsPointer(ref o);
sealed class Order { public int Id; }
What happens: estimating memory as sum of fields. Why it is wrong: each object adds header + MT + padding (~16+ bytes). Do instead: account for overhead; consider structs/arrays for large volumes.
What happens: misdescribing layout. Why it is wrong: references point to the MT pointer; the header is at a negative offset. Do instead: describe the layout precisely.
What happens: expecting class fields in source order. Why it is wrong: auto layout reorders. Do instead: use explicit layout only when needed (interop).
What happens: counting headers for structs in arrays. Why it is wrong: unboxed structs have no header/MT pointer. Do instead: remember boxing adds them.
What happens: object-per-record designs for huge in-memory datasets. Why it is wrong: memory and GC scanning costs. Do instead: struct arrays, columnar storage.
What happens: treating them as the same. Why it is wrong: MT is hot data per type instantiation; EEClass holds colder shared data. Do instead: explain the split.
What happens: guessing sizes. Why it is wrong: padding and alignment surprises. Do instead: ObjectLayoutInspector, SOS.
What happens: adding reference fields to hot data types unnecessarily. Why it is wrong: GC must scan them. Do instead: keep bulk data reference-free where possible.
Object layout knowledge drives memory-dense designs.
| Design | Per-item overhead (64-bit) |
|---|---|
`List<PointClass>` |
8-byte reference + 24-byte object |
PointStruct[] |
Just the fields (8 bytes for two ints) |
Boxed int |
24 bytes |
| Empty class instance | 24 bytes |
Seniors read dumpheap -stat output fluently: the MethodTable column is the type, the size column shows where the memory goes.
Each object has a pointer-sized header before its MethodTable pointer. The header can hold a thin lock (owning thread id and recursion count), the object's identity hash code (from RuntimeHelpers.GetHashCode or the default object.GetHashCode), or an index into the sync block table. When an object needs more than the header can hold (contention on a lock, a hash code and a lock at the same time, or other per-object data), the runtime inflates it to a sync block that holds a full monitor. lock (obj) uses Monitor, which uses this mechanism; .NET 9's System.Threading.Lock is a separate type that does not use the object header.
Each MethodTable has vtable slots for its virtual methods. A derived class copies its parent's slots and replaces the ones it overrides; new methods get a new slot instead. A virtual call loads the object's MethodTable pointer, loads the slot (through a chunk indirection) and makes an indirect call: a few dependent loads and an indirect branch. That is cheap, but it prevents inlining, which is usually the bigger cost. The JIT turns virtual calls into direct calls when it knows the exact type: sealed classes or methods, a just-created object, or guarded devirtualisation from Dynamic PGO.
The return type is the contract, so I would make it honest. Not-found becomes Task<Order?> with nullable reference types on, so callers are forced to handle null, or a Result<Order> if there are several expected failure reasons. The two out parameters must go because async methods cannot have out parameters; their information moves into the result object. The internal List<Order> is replaced with IReadOnlyList<Order> (or a copy) so callers cannot mutate our state. C# gives us void, values, tuples, records, out/Try pattern, ref returns, IEnumerable<T>/IAsyncEnumerable<T>, and Task/ValueTask.
How to tell it (STAR)
Our [geospatial cache] used [1.2 GB]. dumpheap showed each point cost 24 bytes plus a reference. I switched to a struct array, which cut memory to [about 400 MB] and made Gen 2 collections much faster.
Last updated 31 Oct 2026.