The CLR (Common Language Runtime) · How it works

12 min read
Mid-level12 min read
Rapid overview

How it works

What the CLR is

Q: What is the CLR?

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 (outside unsafe code).
  • 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/await continuations.
  • 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.
Q: What services does the CLR provide?

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.CoreLib is 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 dotnet CLI, what you install to build apps.
  • .NET: the whole platform.
Q: What is the difference between the CLR, the BCL, the SDK and ".NET"?

A:


From C# to machine code

  1. 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.
  2. Start-up: the dotnet host (or an app's native launcher) locates the right runtime, loads the CLR, and loads your assembly.
  3. Type loading: when a type is first used, the CLR reads its metadata and builds a MethodTable in memory (see below).
  4. 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.
  5. Execution: native code runs, calling back into the CLR for allocations, GC, type checks and exceptions.
Q: What happens between writing C# and the CPU executing it?

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.

Q: Why compile to IL instead of directly to machine code?

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.

Q: What is tiered compilation?

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.

Q: What is dynamic PGO?

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.Emit is 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).
Q: What are ReadyToRun and Native AOT?

A:


Types in memory

Q: What is the Common Type System (CTS)?

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.
Q: What does the CLR store for each object on the heap?

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                  │
                                                      └───────────────────────────┘
Q: How does the CLR implement generics?

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 NativeMemory or Marshal.AllocHGlobal, which the GC does not manage.
Q: How is memory divided in a .NET process?

A:

Q: How does the garbage collector decide what to free?

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

Q: What does "type safety" mean in the CLR?

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

Q: How does a 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.

Q: How does exception handling work in the CLR?

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.

Q: Are .NET threads OS threads?

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

Q: What is an assembly, and what does the CLR use its metadata for?

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.

Q: What replaced AppDomains in .NET Core?

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.

Q: How does managed code call native code?

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.
Q: Which CLR implementations exist?

A:


Common interview gotchas

Q: Is C# compiled or interpreted?

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.

Q: Why is the first call to a method slower than later calls?

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.

Q: Does the CLR guarantee when an object is freed?

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.

Q: What is the difference between managed and unmanaged memory?

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.

See also