Skip to content

.NET Core · Fundamentals

How the .NET CLR Works: JIT, GC, and the Assembly Model

8 min read Updated 2026-06-22 Share:

Practice CLR Runtime interview questions

Why the CLR comes up in interviews

Senior .NET interviews almost always start here. Understanding the runtime is what separates developers who know how to use C# from developers who understand why it behaves the way it does. Performance problems, unexpected memory usage, and subtle threading bugs all trace back to CLR behaviour. If you cannot explain what happens between writing Console.WriteLine("hello") and the CPU executing it, interviewers know the gaps in your mental model.

From C# source to native CPU instructions

The journey a .NET program takes before any instruction reaches the CPU:

C# source (.cs)
   │ csc / Roslyn compiler
   ▼
Assembly (.dll / .exe) containing:
  - IL bytecode          ← language-agnostic instructions
  - Metadata             ← type descriptions, method signatures
  - Manifest             ← assembly identity, version, references
   │
   │ At runtime: CLR loads the assembly
   ▼
JIT Compiler (RyuJIT)
   │ method-by-method, first time each is called
   ▼
Native machine code (x86-64, ARM64…)
   │ cached for the lifetime of the process
   ▼
CPU execution

The key insight is the intermediate step: .NET compilers do not produce native code. They produce IL (Intermediate Language) — a stack-based, CPU-agnostic bytecode that the CLR understands. This is what makes .NET polyglot: C#, F#, and VB.NET all compile to the same IL, and can call each other's assemblies without any wrappers.

What IL looks like

IL is a low-level, typed, stack-based instruction set. Every operation pushes or pops from the evaluation stack. You rarely read IL directly, but tools like dotnet-ildasm, ILSpy, or dnSpy let you inspect it.

// C# source:
static int Add(int a, int b) => a + b;

The compiler emits roughly:

.method private hidebysig static int32 Add(int32 a, int32 b) cil managed
{
  ldarg.0    // push 'a' onto evaluation stack
  ldarg.1    // push 'b' onto evaluation stack
  add        // pop two values, push their sum
  ret        // return top of stack
}

This IL is the same regardless of whether you are running on Windows x64, Linux ARM64, or macOS. The JIT compiler translates it to the platform-appropriate native instructions at runtime.

The JIT compiler — RyuJIT

.NET 5+ uses RyuJIT as the single cross-platform JIT compiler. When a method is first called, the CLR's JIT stub is invoked, which:

  1. Reads the IL for that method from the assembly
  2. Verifies it for type safety and stack consistency
  3. Generates native code for the current CPU architecture
  4. Patches the call site so future calls go directly to native code
  5. Caches the native code for the process lifetime

The second call to the same method incurs zero JIT cost — it goes directly to the cached native code.

Tiered compilation

Since .NET Core 3.0, the JIT uses tiered compilation to balance startup speed with peak throughput:

  • Tier 0: JIT compiles quickly with minimal optimisation. Fast startup. The method is instrumented with a call counter.
  • Tier 1: Once a method crosses a call threshold, it is queued for full recompilation with aggressive optimisations (inlining, loop unrolling, register allocation). This happens on a background thread; the old tier-0 code stays active until tier 1 is ready, then call sites are patched atomically.

The result: .NET applications start quickly (tier 0) and reach high throughput on hot paths (tier 1) without developer intervention. Tiered compilation is why .NET server applications warm up relatively quickly and then stabilise at their peak performance.

ReadyToRun (R2R) and Native AOT

ReadyToRun (set <PublishReadyToRun>true</PublishReadyToRun>) pre-compiles IL to native code at publish time, reducing JIT work on startup. The native code is embedded in the assembly alongside the IL, so tier-1 recompilation can still occur at runtime.

Native AOT (<PublishAot>true</PublishAot>) eliminates the JIT entirely: the entire application is compiled to native code ahead of time. No CLR is needed at runtime. The trade-off is a larger binary and no runtime profile-guided optimisation.

The Common Type System and the Common Language Specification

The Common Type System (CTS) is the ruleset that defines every type the CLR understands. All .NET languages must compile their types to CTS-compliant types, which is what enables cross-language interop. C# int = VB.NET Integer = F# int = CTS System.Int32. They are literally the same type.

The Common Language Specification (CLS) is a more restrictive subset. It defines the minimum a .NET language must support to interoperate with any other CLS-compliant language. Public APIs should be CLS-compliant: no uint parameters (VB.NET has no unsigned integers), no case-only identifier differences, no global functions.

Annotate your library with [assembly: CLSCompliant(true)] to get compiler warnings on CLS violations.

Assemblies — the unit of deployment

An Assembly is the compiled, versioned, deployable unit of .NET code. It is typically a .dll or .exe file but is more than just a binary:

  • IL bytecode — the compiled code for all types in the assembly
  • Manifest — assembly identity (name, version, culture, public key token), list of referenced assemblies, and list of modules
  • Type metadata — full type descriptions: class names, method signatures, field names, custom attributes — consumed by reflection and the JIT
  • Resources — embedded images, strings, localisation files (optional)

The manifest is what the CLR's assembly resolver uses to locate the right version of a dependency. Strong-named assemblies include a cryptographic public key token in their identity, enabling side-by-side deployment of different versions.

In modern .NET, NuGet packages are the distribution unit; they contain one or more assemblies plus metadata. A project reference (<ProjectReference>) becomes an assembly reference; a package reference (<PackageReference>) resolves to the package's assembly or assemblies.

Garbage collection — the generational model

The CLR GC is a tracing, generational, compacting collector. It works by:

  1. Marking all objects reachable from GC roots (static fields, stack variables, CPU registers, GC handles)
  2. Sweeping unreachable objects (reclaiming their memory)
  3. Compacting the heap to eliminate fragmentation (sliding live objects together)

Generations

Most objects die young — a local variable created for a single method call lives only microseconds. The GC exploits this with three generations:

GenerationContentsCollection frequency
Gen 0Newly allocated objectsVery frequent (sub-millisecond)
Gen 1Survived one Gen 0 collectionModerate
Gen 2Long-lived objectsRare, most expensive

When Gen 0 fills up, a Gen 0 collection runs. Objects that survive are promoted to Gen 1. A Gen 1 collection collects Gen 0 and Gen 1. A full (Gen 2) collection collects all generations and compacts the heap — this is the most expensive and causes the most noticeable pauses.

Large Object Heap

Objects larger than ~85 KB are allocated on the Large Object Heap (LOH), which is collected with Gen 2 but not compacted by default (compacting large objects is expensive). LOH fragmentation is a common source of memory growth in long-running services that repeatedly allocate large byte arrays.

Fix: use ArrayPool<byte>.Shared to rent and return large buffers rather than allocating new ones on every request.

Background GC

.NET's default Server GC and Workstation GC both support background GC, where the Gen 2 collection runs on dedicated background threads, allowing your application threads to continue running concurrently for most of the collection.

Managed vs unmanaged code

Code running under CLR supervision is managed — the runtime handles memory, type safety, and exception propagation. Code that runs directly on the OS without CLR overhead is unmanaged — typically native C/C++ libraries or OS APIs.

.NET interacts with unmanaged code through:

  • P/Invoke (Platform Invocation Services) — calling C functions in .dll/.so files
  • COM Interop — calling or hosting COM components
  • unsafe blocks — pointer arithmetic within managed code (CLR still runs but safety checks are suspended for the unsafe region)
// P/Invoke example — calling a Win32 API directly:
[DllImport("kernel32.dll", SetLastError = true)]
static extern bool GetConsoleMode(IntPtr hConsoleHandle, out uint lpMode);

.NET Framework vs .NET Core vs modern .NET

This comes up in almost every .NET interview:

.NET Framework.NET Core.NET 5+
Released200220162020
PlatformWindows onlyCross-platformCross-platform
Open sourceNoYesYes
StatusMaintenance only (v4.8.1 final)Merged into .NET 5Active (annual releases)
Side-by-side versionsNoYesYes

Modern .NET (5, 6, 7, 8, 9) unified .NET Core and .NET Framework into a single platform. Even-numbered releases (6, 8, 10…) are LTS (3-year support); odd-numbered (7, 9…) are STS (18-month support). For new projects, target the current LTS.

.NET Framework is not dead — Microsoft maintains it for backwards compatibility — but it receives no new features. If you are writing a new project or migrating, target modern .NET.

Reflection and source generators

Reflection allows .NET code to inspect type metadata at runtime: enumerate methods and properties, read custom attributes, and invoke members by name. It powers DI containers, ORMs, serialisers, and test frameworks. The downside is performance overhead and loss of compile-time safety.

Source generators (C# 9+, Roslyn) generate code at compile time as an alternative to runtime reflection. System.Text.Json's JsonSerializerContext, EF Core's compiled models, and the [GeneratedRegex] attribute all use source generators to produce zero-overhead, AOT-compatible code. When you see [JsonSerializable(typeof(MyType))] in modern .NET code, that is a source generator at work.

Recap

The CLR is the execution engine for all .NET code. C# compiles to IL, which the JIT compiler (RyuJIT) translates to native code on first call, caching the result for the process lifetime. Tiered compilation starts methods at tier 0 (fast JIT) and promotes hot methods to tier 1 (full optimisation) on a background thread. The Common Type System ensures all .NET languages share the same type model, enabling cross-language interop. Assemblies are the unit of versioning and deployment — they carry IL, metadata, and a manifest. The generational GC collects short-lived objects cheaply in Gen 0 and reserves full-heap compaction for rare Gen 2 collections. Understanding these fundamentals is what lets you reason confidently about performance, memory, and the difference between .NET versions.

More ways to practice

The self-quiz is live. Get notified when mock interviews and new question packs drop.

or
Join our WhatsApp Channel