The Common Language Runtime (CLR) is the execution engine underneath every .NET application: it loads your assemblies, builds type information on demand, compiles IL to machine code, dispatches calls, allocates objects and cleans them up. This guide is for senior developers who want an accurate mental model of what happens inside the CLR. You will follow a small C# program from IL and metadata through the type loader, precode stubs and the JIT, see how virtual and interface calls are dispatched, measure real object sizes, and learn which tools expose each layer.

What Is the CLR?#

The CLR is Microsoft's implementation of the Common Language Infrastructure, standardized as ECMA-335 in 2001. The standard defines the Common Intermediate Language (IL), the metadata format and the type system that every .NET language targets. Modern .NET ships the runtime as CoreCLR, alongside two other runtime flavors, Mono and Native AOT, covered later in this guide.

The central idea is managed code: code for which the runtime always knows where every object reference lives, what type every object has and how to walk every stack frame. That knowledge enables a precise, compacting garbage collector, type safety, reflection and reliable exception handling. Code is compiled twice: from C# to IL by Roslyn at build time, and from IL to native code by the JIT or an ahead-of-time compiler.

How the CLR Executes Your Code#

Here is the sequence for a framework-dependent app started with dotnet app.dll or its apphost executable:

  1. The host starts. The dotnet muxer or the app's own apphost locates hostfxr, which reads app.runtimeconfig.json and resolves the shared framework version.
  2. Dependencies are resolved. hostpolicy reads app.deps.json, builds the list of trusted platform assemblies (TPA) and loads coreclr.
  3. The runtime initializes. CoreCLR sets up the GC heap, loads System.Private.CoreLib and loads the app assembly into the default AssemblyLoadContext.
  4. Types load on demand. When code first needs a type, the type loader turns its metadata into a MethodTable.
  5. Methods compile on first call. Every method starts with a tiny precode stub that routes to the runtime's prestub, which JIT-compiles the method (or finds precompiled ReadyToRun code) and patches the entry point.
  6. Hot code is recompiled. Tiered compilation later replaces quick tier-0 code with optimized tier-1 code guided by runtime profiles.
  7. Objects live on the GC heap. Each object starts with a pointer to its MethodTable, which is how the GC, casting and virtual dispatch find its type.
LayerWhat it doesHow to observe it
Host (dotnet, hostfxr, hostpolicy)Picks the runtime, builds the TPA listdotnet --info, DOTNET_HOST_TRACE=1
Loader and binderFinds and loads assemblies into load contextsAssemblyLoadContext events, SOS dumpdomain
Type loaderBuilds MethodTable, EEClass and MethodDescSOS name2ee, dumpmt, dumpclass
JIT and code managerCompiles IL, manages entry points and tiersDOTNET_JitDisasm, SOS dumpmd
GC and allocatorAllocates, marks, compactsdotnet-counters, SOS dumpheap

The Type Loader: MethodTable, EEClass and MethodDesc#

The CLR does not materialize types when an assembly loads. The type loader builds them lazily when the JIT, reflection or the runtime first needs them, usually while the JIT compiles a method that references the type. The key data structures, described in the Book of the Runtime, are:

  • TypeHandle: the runtime's identity for a type. It points either to a MethodTable or, for pointers, byrefs, function pointers and generic parameters, to a TypeDesc.
  • MethodTable: the hot data needed at steady state, including the parent type, interface map, virtual method slots, base instance size and GC layout. Every object on the heap points to one.
  • EEClass: cold data needed mainly by the type loader, the JIT and reflection. Splitting hot from cold improves cache use, and generic instantiations can share one EEClass.
  • MethodDesc and FieldDesc: compact descriptors for methods and fields, with MethodDescs allocated in chunks to save space.

Loading is incremental. A type moves through load levels, from CLASS_LOAD_BEGIN to CLASS_LOADED, so mutually recursive definitions such as class A : B<A> can refer to each other with approximate parents first. For generics, instantiations over reference types share one canonical code body (List<__Canon> internally), while each value-type instantiation gets its own specialized code.

Because types resolve at JIT time, some failures surface in surprising places. If the assembly that defines LegacyReport is replaced by a version without that type, the catch below never runs: the TypeLoadException is thrown while the method is being compiled, so it escapes to the caller. Isolating the risky reference in a non-inlined helper restores the expected behavior; both behaviors reproduce on .NET 10.0.12.

C#
using System.Runtime.CompilerServices;

static object? TryCreateReport()
{
    try
    {
        return new LegacyReport();      // resolved when TryCreateReport is JIT-compiled
    }
    catch (TypeLoadException)
    {
        return null;                    // never reached: the exception escapes to the caller
    }
}

static object? TryCreateReportSafely()
{
    try
    {
        return CreateLegacyReport();    // the type is only resolved when the helper compiles
    }
    catch (TypeLoadException)
    {
        return null;
    }
}

[MethodImpl(MethodImplOptions.NoInlining)]
static object CreateLegacyReport() => new LegacyReport();

From IL to Machine Code: Precode, the JIT and ReadyToRun#

Every MethodDesc has a slot holding its current entry point, but the runtime does not generate code eagerly. Callers are given a temporary entry point, a small piece of runtime-generated code called precode that jumps to the prestub. On the first call, the prestub asks the JIT to compile the method (or finds precompiled code), then atomically replaces the temporary entry point with a stable one so later calls go straight to native code. This lazy scheme means only code that actually runs is ever compiled.

Two mechanisms change that default. ReadyToRun (R2R) images, produced by crossgen2 with RyuJIT as the code generator, carry precompiled native code next to the IL; the shared framework is published this way, which is why few framework methods need the JIT at startup. Tiered compilation then replaces hot R2R and tier-0 code with better-optimized tier-1 code, often using Dynamic PGO profiles. The JIT, tiered compilation and Dynamic PGO guide covers that pipeline in depth.

The SOS dumpmt -md output later in this guide shows the laziness directly: only the methods that actually ran are marked JIT. At run time, JitInfo.GetCompiledMethodCount() and JitInfo.GetCompilationTime() in System.Runtime report how much JIT work the process has done.

Method Dispatch: Virtual Calls, Interfaces and Stub Dispatch#

The CLR dispatches calls in three ways:

  • Direct calls to static and non-virtual methods jump to the method's entry point. The C# compiler still emits callvirt for most instance calls because callvirt includes a null check.
  • Virtual calls load the object's MethodTable and call through a fixed slot in its virtual method table.
  • Interface calls use virtual stub dispatch (VSD). Each call site starts with a lookup stub. After the first call, the runtime installs a dispatch stub that compares the object's MethodTable with the last type seen and jumps straight to the cached target. If that check keeps failing, the site is repatched to a resolve stub that looks up the (type, interface slot) pair in a global cache. Polymorphic sites are periodically reset to monomorphic in case the pattern was temporary.

The JIT tries to avoid dispatch altogether. sealed types and exact types let it devirtualize calls and inline them, and Dynamic PGO adds guarded devirtualization for sites that are usually monomorphic. This is the real tier-1 code that .NET 10.0.12 produced for TotalArea on x64 Linux after a warm-up in which every element was a Circle (abbreviated, comments added):

Text
; Tier1 code, optimized using Dynamic PGO
G_M000_IG04:
       mov      rdi, gword ptr [r14]              ; shape = shapes[i]
       cmp      qword ptr [rdi], r15              ; is its MethodTable Circle's?
       jne      SHORT G_M000_IG09                 ; no: take the slow path
       vmovsd   xmm1, qword ptr [rdi+0x08]        ; yes: inlined Circle.Area()
       vmulsd   xmm2, xmm1, qword ptr [reloc @RWD00]
       vmulsd   xmm1, xmm2, xmm1
       ...
G_M000_IG09:
       mov      r11, 0x7F84E7C50038               ; interface dispatch cell
       call     [r11]IShape:Area():double:this    ; stub dispatch fallback

The listing confirms that the MethodTable pointer sits at offset 0 of every object, with the radius field at offset 8, and that the fallback still goes through a stub-dispatch cell. Native AOT uses cached interface dispatch cells instead of VSD stubs.

The Managed Heap and Object Layout#

Every heap object has the same prefix. An object header word sits at a negative offset; it stores the hash code or thin lock state and, when more room is needed, a sync block index. Then comes the MethodTable pointer, which is where an object reference actually points. Fields follow. On 64-bit platforms the header and the MethodTable pointer take 16 bytes and the minimum object size is 24 bytes. Arrays add a length field, padded to 8 bytes on 64-bit, and strings store a length, UTF-16 characters and a null terminator.

For classes, the runtime chooses field order (auto layout). In a dump of record Order(int Id, string Sku, decimal Amount), SOS reported the string reference at offset 8, the int at 0x10 and the decimal at 0x18, for a total of 48 bytes: the reference was moved first even though it was declared second. You can measure sizes without a debugger:

C#
Console.WriteLine($"new object():  {BytesPerAllocation(() => new object())}");
Console.WriteLine($"one int field: {BytesPerAllocation(() => new OneInt(1))}");
Console.WriteLine($"two longs:     {BytesPerAllocation(() => new TwoLongs(1, 2))}");
Console.WriteLine($"boxed int:     {BytesPerAllocation(() => (object)42)}");
Console.WriteLine($"5-char string: {BytesPerAllocation(() => new string('x', 5))}");
Console.WriteLine($"int[4]:        {BytesPerAllocation(() => new int[4])}");
Console.WriteLine($"object[4]:     {BytesPerAllocation(() => new object[4])}");

static long BytesPerAllocation(Func<object> factory, int count = 10_000)
{
    var keep = new object[count];                   // allocate the holder up front
    long before = GC.GetAllocatedBytesForCurrentThread();
    for (var i = 0; i < count; i++)
    {
        keep[i] = factory();
    }
    long after = GC.GetAllocatedBytesForCurrentThread();
    GC.KeepAlive(keep);
    return (after - before) / count;
}

sealed class OneInt(int value) { public int Value = value; }
sealed class TwoLongs(long a, long b) { public long A = a; public long B = b; }
AllocationBytes on x64 (.NET 10.0.12)Where the bytes go
new object()2416 bytes of header and MethodTable pointer plus the minimum payload
Class with one int24The 4-byte field fits in the minimum payload
Class with two long fields3216 bytes of overhead plus 16 bytes of fields
Boxed int24A box is an ordinary object whose payload is the value
string with 5 characters324-byte length, 10 bytes of UTF-16 and a 2-byte terminator
int[4]408-byte length slot plus 16 bytes of elements
object[4]568-byte length slot plus four 8-byte references

A 4-byte payload costs 24 bytes of heap, which is why millions of tiny objects hurt. Objects of 85,000 bytes or more go to the large object heap; the garbage collection guide explains generations and tuning.

AssemblyLoadContext: Loading, Isolation and Unloading#

Every assembly lives in an AssemblyLoadContext (ALC). AssemblyLoadContext.Default holds the framework and the app's static dependencies. An ALC holds one version of an assembly per simple name, and type identity includes the assembly instance, so two ALCs can each load their own Newtonsoft.Json. The flip side is the error "Object of type 'X' cannot be converted to type 'X'" when a type is loaded twice.

A plugin host creates one ALC per plugin, resolves the plugin's private dependencies with AssemblyDependencyResolver and defers shared contracts to the default context so interface types unify:

C#
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.Loader;
using Plugins.Abstractions;

var weak = RunPlugin(Path.GetFullPath("plugins/Reports/Reports.dll"));
for (var i = 0; weak.IsAlive && i < 10; i++)
{
    GC.Collect();
    GC.WaitForPendingFinalizers();
}
Console.WriteLine(weak.IsAlive ? "Still loaded: a reference is leaking" : "Unloaded");

[MethodImpl(MethodImplOptions.NoInlining)]
static WeakReference RunPlugin(string path)
{
    var context = new PluginLoadContext(path);
    Assembly assembly = context.LoadFromAssemblyPath(path);

    foreach (Type type in assembly.GetExportedTypes())
    {
        if (typeof(IPlugin).IsAssignableFrom(type) &&
            Activator.CreateInstance(type) is IPlugin plugin)
        {
            Console.WriteLine($"{plugin.Name}: {plugin.Execute()}");
        }
    }

    context.Unload();                         // starts a cooperative unload
    return new WeakReference(context);
}

public sealed class PluginLoadContext(string pluginPath)
    : AssemblyLoadContext(Path.GetFileNameWithoutExtension(pluginPath), isCollectible: true)
{
    private readonly AssemblyDependencyResolver _resolver = new(pluginPath);

    protected override Assembly? Load(AssemblyName assemblyName)
    {
        // Let the Default context supply the shared contract so IPlugin unifies.
        if (assemblyName.Name == "Plugins.Abstractions")
        {
            return null;
        }

        string? path = _resolver.ResolveAssemblyToPath(assemblyName);
        return path is null ? null : LoadFromAssemblyPath(path);
    }

    protected override nint LoadUnmanagedDll(string unmanagedDllName)
    {
        string? path = _resolver.ResolveUnmanagedDllToPath(unmanagedDllName);
        return path is null ? 0 : LoadUnmanagedDllFromPath(path);
    }
}

Unloading is cooperative: Unload() only starts the process, which finishes when no thread is running plugin code and nothing outside the context references its assemblies, types or objects. RunPlugin is marked NoInlining so no JIT-introduced local keeps the context alive. Collectible contexts ignore ReadyToRun code, and plugin projects should set EnableDynamicLoading and reference the contract with Private=false and ExcludeAssets=runtime.

CoreCLR vs Mono vs Native AOT: Runtime Flavors Compared#

.NET ships three runtime flavors that share the base class libraries and much of the type system but differ in how code is produced:

FlavorCode generationTypical targetsTrade-offs
CoreCLRRyuJIT at run time, tiered, plus ReadyToRunServers, desktop, containers; Android Release builds by default in .NET 11Best peak performance and full reflection; needs a JIT-capable platform
MonoJIT, interpreter or full AOTiOS and browser WebAssembly in .NET 10Mature on mobile and in the browser; .NET is moving these targets to CoreCLR
Native AOTWhole-program ILCompiler at publish timeCLIs, serverless, containers, native librariesFastest startup and no JIT; no runtime code generation and trimmed reflection

The Native AOT compiler uses RyuJIT as its code generator (its package ships libclrjit builds), then links your code with a stripped-down runtime into one native executable. The shift toward CoreCLR everywhere is visible in the release notes: .NET 10 made CoreCLR an experimental option for Android, .NET 11 previews made it the default for Android Release builds, and CoreCLR on WebAssembly now passes the libraries test suite in the .NET 11 cycle. See the Native AOT and trimming guide for what whole-program compilation means for your code.

Tools to Inspect the Runtime: ILSpy, dotnet-dump and SOS#

  • ILSpy (ilspycmd -il) shows IL and decompiled C# for any assembly, including what the compiler generated for records, lambdas and async methods.
  • dotnet-dump collects a dump from a running process and analyzes it with the SOS commands, on any OS, without a native debugger.
  • DOTNET_JitDisasm prints the machine code the JIT produces, per tier, from a normal release runtime.

A typical investigation of the Order records above looks like this:

Bash
dotnet tool install --global dotnet-dump
dotnet-dump collect -p 4242 -o orders.dmp
dotnet-dump analyze orders.dmp
Text
> dumpheap -stat -type Order
          MT Count TotalSize Class Name
7f6b50225208     1        32 System.Collections.Generic.List<Order>
7f6b50228a00    13   131,352 Order[]
7f6b502250a8 5,000   240,000 Order

> dumpobj 7f5ebc00ceb8
Name:        Order
MethodTable: 00007f6b502250a8
Size:        48(0x30) bytes
Fields:                      (MT, Field and Attr columns trimmed for width)
  Offset                 Type   VT            Value Name
      10         System.Int32  Yes               99 <Id>k__BackingField
       8        System.String   No 00007f5ebc00ce90 <Sku>k__BackingField
      18       System.Decimal  Yes 00007f5ebc00ced0 <Amount>k__BackingField

> dumpmt -md 7f6b502250a8
BaseSize:            0x30
Number of Methods:   19
MethodDesc Table     (Entry and Slot columns trimmed for width)
      MethodDesc    JIT  Name
00007F6B50224E60    JIT  Order..ctor(Int32, System.String, System.Decimal)
00007F6B50224EE0   NONE  Order.get_Sku()
00007F6B50224F60   NONE  Order.ToString()
...

The 13 Order[] arrays are the list growing by doubling, and the record's string field was laid out first. Other useful commands are clrstack, gcroot (what keeps an object alive), name2ee, dumpil and dumpalc. The .NET diagnostics toolkit guide covers counters and traces.

Best Practices#

  • Keep types sealed unless designed for inheritance. Sealing lets the JIT devirtualize and inline calls without runtime guards.
  • Treat allocations as layout decisions. Every small object costs at least 24 bytes on 64-bit, so prefer structs, spans or pooling on hot paths that create millions of tiny objects.
  • Read metadata without loading types. Tools that only inspect assemblies should use System.Reflection.Metadata.
  • Design plugin systems around shared contracts. Put interfaces in one assembly loaded by the default context and keep plugin dependencies private to each ALC.
  • Learn SOS before you need it. Practice dumpheap, gcroot and clrstack on a healthy app.

Common Pitfalls#

  • Catching load failures in the wrong frame. Type and assembly load exceptions often occur while the calling method is JIT-compiled; isolate risky references in NoInlining helpers.
  • Duplicated contract assemblies. A second copy of the interface assembly in the plugin folder creates a second IPlugin type, and casts fail.
  • Leaking collectible contexts. Static caches, event handlers, timers and GCHandles that reference plugin types keep an ALC alive forever.
  • Assuming declaration order equals field layout. Classes use auto layout; use StructLayout(LayoutKind.Sequential) or explicit layout only on types that cross interop boundaries.
  • Benchmarking before tier-up. Early calls run tier-0 or ReadyToRun code without PGO, which says little about steady state.

Frequently Asked Questions#

What is the difference between the CLR and CoreCLR?#

The CLR is the general name for Microsoft's implementation of the ECMA-335 execution engine, originally part of .NET Framework. CoreCLR is the cross-platform, open-source runtime used by modern .NET on Windows, Linux and macOS. The core concepts are the same, but CoreCLR drops multiple AppDomains and adds AssemblyLoadContext, tiered compilation and container awareness.

What is a MethodTable in .NET?#

A MethodTable is the runtime's primary data structure for a loaded type. It holds the parent type, the interface map, the virtual method slots, the instance size and the GC layout, and every object on the heap starts with a pointer to its MethodTable. Rarely used data lives in a separate EEClass.

When does the CLR compile a method to machine code?#

By default, on its first call. The method's temporary entry point routes to the prestub, which JIT-compiles the method or uses ReadyToRun code and then patches the entry point. With tiered compilation enabled, frequently called methods are compiled again later with full optimizations, so a single method can have several native versions over the life of a process.

Can I unload assemblies in modern .NET?#

Yes, by loading them into a collectible AssemblyLoadContext and calling Unload(). Unloading is cooperative and completes only after the GC finds no remaining references to the context's assemblies, types or objects and no thread is executing its code. Use a WeakReference to the context to confirm that unloading actually finished.

How can I see the machine code the JIT generates?#

Set the DOTNET_JitDisasm environment variable to a method name, for example DOTNET_JitDisasm="TotalArea", and run a normal release build; the runtime prints each tier it compiles. Tools such as the Disasmo Visual Studio extension and SOS's clru command offer the same view from an IDE or a dump.

Summary#

  • The CLR executes IL and metadata produced by Roslyn; tokens in IL point into metadata tables, and the assembly manifest records identity and references.
  • The host (hostfxr, hostpolicy) chooses the runtime and dependencies before CoreCLR loads your assembly into the default AssemblyLoadContext.
  • Types are built lazily into MethodTables and EEClasses; methods start behind precode stubs and are compiled on first call, then possibly recompiled by tiered compilation.
  • Virtual calls use method table slots, interface calls use virtual stub dispatch, and the JIT removes both when it can devirtualize.
  • Every object carries 16 bytes of overhead on 64-bit, with a 24-byte minimum, and the runtime may reorder class fields.

Further Reading#