📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

📚 Interview question

What is a method table (and the type object) in the CLR, and what is the in-memory layout of a .NET object?

Object header, MethodTable pointer and fields; MethodTable versus EEClass versus RuntimeType; minimum object size; inspecting with SOS.

Senior Theory Expert clr method-table object-layout runtime-internals sos

🎯 Key points

  • A heap object is an 8-byte header (at a negative offset), a MethodTable pointer, then fields
  • The MethodTable holds size, flags, parent, interface map and vtable slots
  • Cold, shared type data lives in the EEClass
  • The type object is a managed RuntimeType that points to the MethodTable
  • The smallest object on 64-bit is 24 bytes; unboxed structs have no header
  • SOS commands dumpheap, dumpobj and dumpmt inspect objects and method tables

⏱ 30-second answer

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).

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. Object layout (64-bit)

          -8         0               8
          [ header ][ MethodTable*  ][ field 1 ][ field 2 ] ... [padding]
                     ^ an object reference points HERE
  • Object header (8 bytes on 64-bit; 4 bytes + padding on 32-bit) sits at negative offset from the reference: holds the sync block index, thin lock info or the identity hash code, plus a few GC bits.
  • MethodTable pointer (8 bytes): the object's exact type.
  • Fields: instance fields of the class and all base classes (base first), laid out by the runtime (auto layout: references grouped, then fields ordered by size for alignment).
  • Minimum object size: 24 bytes on 64-bit (header + MT + one pointer-sized slot), even for an empty object.

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.
  • Arrays → header + MT + Length (+ padding) + elements (int[10] = 8 + 8 + 8 + 40 = 64 bytes).
  • A boxed int → 24 bytes (header + MT + 4-byte value + padding).

2. What the MethodTable contains

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.

3. How the runtime uses it

  • GetType() → reads the MT pointer and returns the RuntimeType for it.
  • Casts and type checks (is, as, castclass) → compare MT pointers, walk the parent chain or interface map (with a cast cache).
  • Virtual calls → load MT, index the vtable slot, call.
  • Interface calls → virtual stub dispatch uses the MT to resolve/cache targets.
  • GC → uses MT size and GC descriptor to know object size and where references are.
  • Allocation → new uses the MT base size and writes the MT pointer.
  • Boxing → creates an object with the value type's MT and copies the value.

4. Value types

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).

5. Inspecting it

  • SOS in WinDbg/dotnet-dump analyze:
    dumpheap -type Order        // find instances
    dumpobj <address>           // fields, size, MT
    dumpmt -md <MT address>     // MethodTable, vtable, method descs
    dumpclass <EEClass>
    
  • ObjectLayoutInspector NuGet: TypeLayout.PrintLayout<Order>() prints field offsets, padding, sizes.
  • Unsafe.SizeOf<T>() for struct sizes; RuntimeHelpers.GetRawObjectDataSize (internal) style tools in diagnostics.
  • Visual Studio memory tools show object sizes (shallow/retained).

6. Why it matters

  • Memory estimates: millions of small objects cost far more than their fields (header + MT + padding ≈ 16 bytes overhead each).
  • Struct vs class decisions.
  • Field ordering in structs affects padding.
  • Understanding virtual dispatch, casting cost, boxing, and GC scanning (objects with no references are cheaper for the GC).

7. A production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. MethodTable in memory (simplified)

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.

2. Field layout

  • Classes use auto layout: the runtime reorders fields (references first, then 8-byte, 4-byte, 2-byte, 1-byte fields) to minimise padding and group GC references.
  • Structs default to sequential layout unless they contain references (then auto) — [StructLayout] can force sequential/explicit for interop.
  • Base class fields come before derived fields, so a derived object can be treated as a base object.

3. RuntimeType and type handles

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.

4. Arrays and strings

  • Arrays: MT for the array type (Int32[]), component size, length; multi-dimensional arrays add bounds.
  • Strings: special MT with component size 2; length field; data inline.

5. Frozen objects and NativeAOT

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.

Try it yourself

  1. Use ObjectLayoutInspector to print the layout of a class and a struct with mixed fields.
  2. Inspect an object with dotnet-dump analyze and dumpobj, then dumpmt -md.
  3. Compare memory of 1 million class instances vs structs in an array.
  4. Reorder struct fields and observe size changes with Unsafe.SizeOf<T>().

💻 Code

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; }

⚠ Common mistakes

1. Ignoring per-object overhead

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.

2. Saying the header is at the reference address

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.

3. Assuming field order matches declaration

What happens: expecting class fields in source order. Why it is wrong: auto layout reorders. Do instead: use explicit layout only when needed (interop).

4. Thinking value types have headers

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.

5. Storing millions of tiny objects

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.

6. Confusing MethodTable and EEClass

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.

7. Not using tools to inspect layout

What happens: guessing sizes. Why it is wrong: padding and alignment surprises. Do instead: ObjectLayoutInspector, SOS.

8. Overlooking reference-free types for GC

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.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate mentions the object header Why: Checks how locks and hash codes use the header
    How to answer

    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.

  2. When: Candidate mentions vtable slots in the MethodTable Why: Checks virtual dispatch mechanics
    How to answer

    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.

  3. When: Candidate suggests structs to remove per-object overhead Why: Probes value type trade-offs
    How to answer

    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.

🛠 Real-world problems: how you handle them

In-memory cache shrank by two thirds after switching small classes to structs
Situation
A geospatial service cached [about 50 million] points as small classes.
Signals
The process used [about 1.2 GB] for the cache; Gen 2 GCs were slow.
Investigation
`dotnet-dump` `dumpheap -stat` showed millions of 24-byte `Point` objects plus the reference array.
Resolution
Changed `Point` to a `readonly struct` stored in a single array, removing per-object header and MT pointer.
Prevention
Added memory budget tests for the cache size.

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.

↩ Usually asked after

More in Runtime Execution

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me