The CLR (Common Language Runtime) · How it works
12 min readHow it works
What the CLR is
A: The Common Language Runtime is the execution engine of .NET: the component that takes compiled .NET code and runs it, managing memory, types, threads and errors on the program's behalf. Code that runs under its control is called managed code; ordinary native code (C, C++, the OS) is unmanaged. It is Microsoft's implementation of the CLI (Common Language Infrastructure), an ECMA/ISO standard (ECMA-335) that defines IL, metadata and the type system, so other runtimes (such as Mono) can run the same assemblies.
- Loading: finds and loads assemblies and their dependencies, and builds the runtime representation of types.
- JIT compilation: turns IL into native code for the current CPU on demand.
- Memory management: allocates objects on the managed heap and frees them with the garbage collector; no manual
free. - Type safety: verifies casts (
InvalidCastException), array bounds (IndexOutOfRangeException), null dereferences (NullReferenceException), and prevents treating memory as the wrong type (outsideunsafecode). - Exception handling: a single structured exception model across languages and stack frames.
- Threading: managed threads, the thread pool, synchronization primitives, and the machinery behind
async/awaitcontinuations. - Interop: P/Invoke into native libraries, COM interop, marshalling of data between managed and native memory.
- Reflection and metadata: inspecting and invoking types at runtime; attributes.
- Diagnostics: debugging, profiling and event tracing (EventPipe, ETW) hooks.
A:
- CLR: the runtime engine (JIT, GC, type loader), in
coreclr.dll/libcoreclr.so. - BCL (Base Class Library): the standard libraries,
System.*namespaces such as collections, IO,HttpClient;System.Private.CoreLibis the part tightly bound to the runtime (object,string,Task). - Runtime: CLR + BCL, what you install to run apps (
dotnet --list-runtimes); ASP.NET Core adds its own shared framework on top. - SDK: compilers (Roslyn), MSBuild and the
dotnetCLI, what you install to build apps. - .NET: the whole platform.
A:
From C# to machine code
- Compile time: Roslyn compiles C# into IL plus metadata and a manifest, written to an assembly (
.dll). IL is a stack-based, CPU-independent instruction set. - Start-up: the
dotnethost (or an app's native launcher) locates the right runtime, loads the CLR, and loads your assembly. - Type loading: when a type is first used, the CLR reads its metadata and builds a MethodTable in memory (see below).
- JIT: the first call to each method goes through a stub that asks the JIT to compile that method's IL to native code for this CPU and OS; the stub is then patched so later calls jump straight to the native code.
- Execution: native code runs, calling back into the CLR for allocations, GC, type checks and exceptions.
A:
static int Add(int a, int b) => a + b;
// IL produced by the compiler
// ldarg.0 push a
// ldarg.1 push b
// add pop both, push a + b
// ret
// x64 native code produced by the JIT at first call
// lea eax, [rcx+rdx]
// ret
You can see IL with ILSpy, dotPeek, or ildasm, and the JIT's output with SharpLab or DOTNET_JitDisasm.
A: One assembly runs on any OS and CPU with a runtime (x64, Arm64, Windows, Linux, macOS). The JIT can optimize for the actual machine (for example using AVX-512 if the CPU has it) and for actual runtime behaviour (see dynamic PGO). Rich metadata enables reflection, serialization, debugging and verification. And every .NET language targets the same IL, so they interoperate.
A: Since .NET Core 3.0, a method is first compiled quickly with few optimizations (tier 0) so the app starts fast. The runtime counts calls; once a method is hot (about 30 calls, then a short delay), it is recompiled in the background with full optimizations (tier 1) and the call site is switched over. On-stack replacement (OSR, .NET 7) lets a long-running loop inside a tier-0 method jump to optimized code without waiting for the next call.
A: Profile-guided optimization at runtime, on by default since .NET 8. While code runs at an instrumented tier, the runtime records which types actually flow through virtual and interface calls and delegate invocations, and which branches are taken. The tier-1 compile uses that data: for example, if an IComparer<T> call almost always sees one class, the JIT adds a type check and inlines that class's method directly (guarded devirtualization). It makes idiomatic abstraction-heavy code noticeably faster without code changes.
- ReadyToRun (R2R): the assembly ships with precompiled native code alongside the IL, so start-up skips most JIT work; the JIT still runs for anything not precompiled and for tier-1 re-optimization. Used by the framework libraries themselves and via
PublishReadyToRun. - Native AOT (.NET 7+): the whole app is compiled ahead of time to a single native executable with a slimmed runtime and no JIT. Start-up is very fast and memory use lower, which suits containers, serverless and CLI tools. The trade-offs are that no code can be generated at runtime:
Reflection.Emitis unavailable,Expression.Compile()falls back to an interpreter, and reflection over types the trimmer removed fails, so libraries must be trim- and AOT-compatible (source generators instead of runtime reflection).
A:
Types in memory
A: The CLR's single type model shared by all .NET languages: classes, structs, interfaces, enums, delegates, the rules for inheritance, visibility, value vs reference semantics and so on. int in C#, Integer in VB and int in F# are all the CTS type System.Int32. The CLS (Common Language Specification) is a subset of rules (for example, no public unsigned types, no names that differ only by case) that a library follows if it wants to be usable from every .NET language.
- an object header (sync block index): used for
lock, the cached default hash code, and GC flags; - a MethodTable pointer: identifies the exact runtime type.
A: Every reference-type object starts with two pointer-sized fields before its data (on 64-bit, 16 bytes):
The MethodTable is a per-type structure built at type load: base type, implemented interfaces, the virtual method table (slots pointing at native code for each virtual method), field layout and size, and a pointer to static data. obj.GetType(), is/as casts and virtual calls all go through it. A value type (struct) stored inline has neither header nor MethodTable pointer, which is why it is compact but has to be boxed (copied into a new heap object with both) when treated as object or an interface.
variable (stack) managed heap type data (loader heap)
┌────────────┐ ┌───────────────────────┐ ┌───────────────────────────┐
│ ref ───────┼─────► │ object header 8 B │ │ MethodTable for Order │
└────────────┘ │ MethodTable ptr 8 B ─┼──────► │ base type, interfaces │
│ Id (int) 4 B │ │ vtable: ToString → code │
│ Total (decimal) 16 B │ │ field layout, size │
└───────────────────────┘ │ statics │
└───────────────────────────┘A: Generics are reified: List<int> and List<string> are distinct runtime types with their own MethodTables (unlike Java's type erasure). For value-type arguments the JIT generates specialized native code per type (List<int>, List<double>), so there is no boxing and code is as fast as hand-written. For reference-type arguments the native code is shared (all references are the same size), using a hidden type-context parameter where the exact type is needed. That is why List<int> avoids boxing and why generic value-type code can be as fast as non-generic code.
- Stacks: one per thread (about 1 MB reserved by default on Windows), holding call frames, locals and arguments of value types, and references.
- Managed heap, collected by the GC: the small object heap in generations 0, 1 and 2; the large object heap for objects of 85,000 bytes or more; the pinned object heap (.NET 5) for buffers that must never move.
- Loader heaps: MethodTables, JIT-compiled code and other runtime data for loaded types, living as long as their assembly load context.
- Native memory: the runtime itself, native libraries, and anything allocated with
NativeMemoryorMarshal.AllocHGlobal, which the GC does not manage.
A:
A: It is a generational, tracing, compacting collector. It starts from roots (static fields, locals and arguments on thread stacks, CPU registers, GC handles), marks every object reachable from them, and reclaims the rest; surviving objects are compacted (moved together) and references are updated. Most objects die young, so gen0 is collected often and cheaply; objects that survive are promoted to gen1 and gen2. Details (server vs workstation GC, LOH, finalization, tuning) are in the "CLR & Garbage Collector (GC)" note in Core C#.
Safety, errors and threads
A: Managed code can only access memory through correctly typed references: casts are checked, array accesses are bounds-checked, fields are accessed at known offsets, and there is no pointer arithmetic outside unsafe code. So one bug cannot silently corrupt unrelated memory the way a buffer overflow in C can. The JIT removes checks it can prove unnecessary (for example, bounds checks in a for (i = 0; i < arr.Length; i++) loop).
NullReferenceException actually happen?A: The JIT usually does not emit an explicit null check. Dereferencing null makes the CPU read address 0 (plus a small offset), which the OS rejects with an access violation / segmentation fault; the CLR's handler sees that the fault came from managed code near address 0 and converts it into a NullReferenceException. It is a free check in the common, non-null case.
A: Exceptions are objects deriving from System.Exception. When one is thrown, the runtime does a first pass up the call stack to find a matching catch (running when filters, which is why filters run before any finally), then a second pass that unwinds the stack, running finally blocks, until it reaches the handler. Throwing is expensive (stack-trace capture, two passes), which is why exceptions are for exceptional cases and not control flow. An unhandled exception on any thread terminates the process.
A: Yes. Each managed Thread maps 1:1 to an OS thread, with the CLR adding managed state (culture, execution context, GC information). The thread pool manages a set of worker threads and I/O completion threads, grows and shrinks them based on load, and runs Task work and await continuations. The GC must bring managed threads to a safe point (where it knows exactly which slots hold references) before it can move objects.
Loading, interop and isolation
A: An assembly is the unit of deployment and versioning: a PE file (.dll/.exe) containing a manifest (name, version, culture, public key, referenced assemblies), metadata tables (every type, method, field, attribute) and IL. The CLR uses metadata to lay out types, resolve method calls, check accessibility, support reflection and attributes, and let the debugger map native code back to source.
A: .NET Framework used AppDomains to isolate and unload code within one process. .NET Core+ supports only one AppDomain; isolation of loading is done with AssemblyLoadContext, which can load different versions of the same assembly side by side (plugins) and can be collectible so the loaded code and types can be unloaded. For real security isolation, use separate processes or containers.
A: Through P/Invoke: a static extern method marked [DllImport] or, since .NET 7, [LibraryImport] (source-generated marshalling, which also works with Native AOT). The CLR loads the native library, converts arguments between managed and native representations (marshalling: strings, structs, arrays), transitions the thread to "preemptive" mode so the GC can run while native code executes, and pins any managed memory handed to native code so the GC does not move it.
Implementations
- .NET Framework CLR (1.0-4.8.1): Windows-only, still supported as part of Windows, but receiving no new features.
- CoreCLR: the cross-platform, open-source runtime behind .NET Core and .NET 5+ (Windows, Linux, macOS; x64, Arm64), used by ASP.NET Core, console and desktop apps.
- Mono: a separate runtime, now part of the .NET repo, used for Android and iOS (.NET MAUI), and Blazor WebAssembly.
- Native AOT runtime: a minimal runtime compiled into the app with no JIT.
A:
Common interview gotchas
A: Compiled twice: C# is compiled to IL ahead of time by Roslyn, and IL is compiled to native code by the JIT at runtime (or ahead of time with ReadyToRun or Native AOT). The CLR does not interpret IL in normal execution.
A: The first call includes JIT compilation of that method (and possibly loading its types and running static constructors). Later calls go straight to native code, and hot methods are recompiled at tier 1 with full optimizations. Warm-up effects like this are why benchmarks use BenchmarkDotNet, which runs warm-up iterations.
A: No. Memory is reclaimed at some point after the object becomes unreachable, when the GC decides to run. Deterministic cleanup of non-memory resources (files, sockets, connections) is done with IDisposable and using, not by the GC.
A: Managed memory is allocated by the CLR on the managed heap and freed by the GC. Unmanaged memory is allocated outside the GC's control (native libraries, Marshal.AllocHGlobal, OS handles) and must be released explicitly, typically in Dispose backed by a SafeHandle.