Skip to content

CLR Runtime Interview Questions & Answers

15 questions Updated 2026-06-22 Share:

How the .NET CLR executes managed code — IL, JIT compilation, garbage collection, the Common Type System, and the assembly model.

Read the in-depth guideHow the .NET CLR Works: JIT, GC, and the Assembly Model(opens in new tab)
15 of 15

The Common Language Runtime (CLR) is the virtual machine at the heart of .NET. It takes compiled Intermediate Language (IL) bytecode and executes it on the host OS, providing services that would otherwise be the developer's responsibility.

// You write C# → the compiler produces IL → the CLR executes IL
// dotnet run triggers: csc → assembly.dll (IL) → CLR JIT → native CPU instructions
Console.WriteLine("Hello from managed code"); // CLR handles memory, exceptions, types

The CLR provides: JIT compilation (IL → native code), garbage collection (automatic memory management), type safety enforcement, exception handling, thread management, and security sandboxing. Every .NET language (C#, F#, VB.NET) compiles to the same IL, so the CLR is truly language-agnostic.

Rule of thumb: The CLR is to .NET what the JVM is to Java — an execution engine that abstracts the OS and hardware while enforcing language rules at runtime.

Intermediate Language (IL), also called CIL (Common Intermediate Language) or MSIL, is the CPU-agnostic bytecode that .NET compilers produce. It is a stack-based instruction set — not native machine code, not source code.

// Source:
int Add(int a, int b) => a + b;

// IL (roughly, as shown by ildasm / dnSpy):
// ldarg.1   — push 'a' onto evaluation stack
// ldarg.2   — push 'b' onto evaluation stack
// add       — pop two values, push their sum
// ret       — return top of stack

When the runtime first calls a method, the JIT compiler translates that method's IL to native CPU instructions for the current machine. The result is cached for the process lifetime — the JIT only runs once per method per process. You can inspect IL with dotnet tool install -g dotnet-ildasm or view it in dnSpy/ILSpy.

Rule of thumb: IL is the "assembly language" of .NET — write once in any CLS language, run anywhere the CLR is installed, on any CPU architecture.

JIT (Just-In-Time) compiles IL to native code at runtime, method by method, the first time each method is called. AOT (Ahead-Of-Time) compiles everything to native code before the app starts.

// JIT (default): compilation happens at runtime
// First call to Foo() triggers JIT; subsequent calls use cached native code

// AOT via .NET Native Ahead-of-Time (PublishAot = true in .csproj):
// <PublishAot>true</PublishAot>
// dotnet publish -r linux-x64 -c Release
// Result: single self-contained native binary, no CLR required at runtime
JIT AOT
Startup Slower (JIT work on first calls) Fast (native binary)
Peak perf High (tiered JIT optimises hot paths) High but fixed
Deploy size Needs .NET runtime installed Larger self-contained binary
Use case Long-running services CLI tools, containers, IoT

.NET 6+ also has Tiered Compilation: methods start with a quick "tier 0" compile, then hot methods are recompiled with full optimisations at "tier 1".

Rule of thumb: Use JIT for servers and services (runtime profiling = better optimisation); use AOT for CLI tools and latency-sensitive cold starts.

The Common Type System (CTS) defines every data type that the CLR understands and the rules for declaring, using, and managing those types. It ensures that objects written in different .NET languages can interact without type-mismatch errors.

// C# int → CTS System.Int32
// VB.NET Integer → CTS System.Int32
// F# int → CTS System.Int32
// All three are the exact same CLR type — fully interoperable

int x = 42;                   // C# keyword
System.Int32 y = 42;          // CTS type name — identical at runtime
Console.WriteLine(x.GetType().FullName); // "System.Int32"

The CTS defines two top-level categories: value types (derive from System.ValueType, stored by value) and reference types (derive from System.Object, stored as references). Every type in every .NET language must conform to these rules, which is why a C# List<int> can be consumed by F# code without any wrapper.

Rule of thumb: The CTS is the shared type contract between all .NET languages — it is what makes cross-language .NET libraries possible.

The Common Language Specification (CLS) is a subset of the CTS that defines the minimum set of features a .NET language must support to be interoperable with any other CLS-compliant language. It is more restrictive than the full CTS.

// CLS-non-compliant: unsigned integers are not in the CLS
// (VB.NET doesn't have uint, so a public API using uint breaks interop)
public uint GetCount() => 42u;  // not CLS-compliant

// CLS-compliant fix:
public int GetCount() => 42;    // all CLS languages support int (System.Int32)

// Mark your assembly as CLS-compliant to get compiler warnings:
[assembly: CLSCompliant(true)]

Features excluded from the CLS include: unsigned integer types in public APIs, global functions, pointer types, operator overloading in certain forms, and some naming conventions (CLS is case-insensitive for public identifiers). If you are writing a library for broad .NET language consumption, mark it [CLSCompliant(true)] and the compiler will flag violations.

Rule of thumb: Target CLS compliance for public library APIs; internal code can use the full CTS freely.

Managed code runs under CLR supervision — the runtime handles memory allocation, garbage collection, type safety, and exception propagation. Unmanaged code runs directly on the OS without CLR oversight — the developer is responsible for memory.

// Managed: CLR allocates and frees memory automatically
var list = new List<string>();
list.Add("hello");
// GC will reclaim list when it's no longer reachable — no manual free()

// Calling unmanaged code via P/Invoke:
[DllImport("kernel32.dll")]
static extern IntPtr GetConsoleWindow();

// Unsafe managed code (pointer arithmetic, must mark block):
unsafe void ProcessBuffer(byte* ptr, int length)
{
    for (int i = 0; i < length; i++)
        ptr[i] = 0; // direct memory access — no GC protection here
}

Interop between managed and unmanaged code uses P/Invoke (platform invocation) or COM Interop. The unsafe keyword enables pointer operations within managed code but the CLR still bounds-checks when you re-enter safe territory.

Rule of thumb: Write managed code by default; drop to P/Invoke or unsafe only when you need OS APIs, native libraries, or zero-allocation hot paths.

A .NET Assembly is the compiled, deployable unit of .NET code — typically a .dll or .exe file. It is the fundamental unit of versioning, deployment, and security in .NET.

// View assembly info at runtime:
var asm = typeof(string).Assembly;
Console.WriteLine(asm.FullName);
// "System.Private.CoreLib, Version=8.0.0.0, Culture=neutral, PublicKeyToken=7cec85d7bea7798e"

// Load an assembly dynamically:
var loaded = Assembly.LoadFrom("MyPlugin.dll");
var type = loaded.GetType("MyPlugin.EntryPoint");

An assembly contains: IL bytecode (the compiled code), a manifest (name, version, culture, strong-name public key, list of referenced assemblies), metadata (type descriptions used by reflection), and optionally resources (embedded images, strings). A single assembly can span multiple source files but compiles to one binary. Multi-file assemblies are rare in .NET Core.

Rule of thumb: One project → one assembly. The assembly manifest is what NuGet and the runtime use to resolve the right version of a dependency.

.NET Framework (2002–2019) is Windows-only, ships with Windows, and is legacy. .NET Core (2016–2020) was the cross-platform rewrite. .NET 5+ (2020–present) unified both under a single cross-platform runtime, dropping the "Core" suffix.

<!-- .csproj target framework monikers -->
<TargetFramework>net48</TargetFramework>       <!-- .NET Framework 4.8, Windows only -->
<TargetFramework>netcoreapp3.1</TargetFramework> <!-- .NET Core 3.1, LTS, EOL 2022 -->
<TargetFramework>net8.0</TargetFramework>       <!-- .NET 8 (current LTS), cross-platform -->
<TargetFramework>net9.0</TargetFramework>       <!-- .NET 9 (current STS) -->
.NET Framework .NET Core / .NET 5+
Platform Windows only Windows, Linux, macOS
Open source No Yes
Side-by-side No (machine-wide) Yes (multiple versions per machine)
New features None (frozen) Yes (annual releases)

.NET follows a predictable cadence: even-numbered versions (6, 8, 10) are LTS (3-year support); odd-numbered (7, 9) are STS (18-month support).

Rule of thumb: Always target the latest LTS for new projects; migrate .NET Framework apps when you need Linux/container deployment or modern C# features.

The .NET Garbage Collector (GC) is a generational, tracing collector. It periodically identifies objects no longer reachable from any GC root (static fields, local variables, CPU registers) and reclaims their memory.

// GC roots keep objects alive:
static List<string> _cache = new();   // static field — GC root, always alive
void ProcessRequest()
{
    var data = new byte[1024];         // local var — GC root while method runs
    _cache.Add("entry");              // added to static — stays alive
    // 'data' goes out of scope here — eligible for collection next GC cycle
}

// Force a collection (rarely appropriate in production):
GC.Collect(2, GCCollectionMode.Forced, blocking: true);

The GC uses a mark-and-compact algorithm: mark all live objects, then compact the heap by sliding live objects together (eliminating fragmentation). Large objects (>85 KB by default) go to the Large Object Heap (LOH) which is swept but not compacted by default. The GC can run concurrently with your code in background GC mode to minimise pause times.

Rule of thumb: Don't fight the GC — avoid unnecessary allocations, use ArrayPool<T> for large buffers, and implement IDisposable only for unmanaged resources.

The .NET GC divides the managed heap into three generations (Gen 0, Gen 1, Gen 2) based on the observation that most objects die young. Collecting younger generations is cheaper because they are smaller.

// Check which generation an object is in:
var obj = new object();
Console.WriteLine(GC.GetGeneration(obj)); // 0 — just allocated

GC.Collect(0); // collect Gen 0 only (cheapest)
// If 'obj' survived, it is now promoted to Gen 1
Console.WriteLine(GC.GetGeneration(obj)); // 1

GC.Collect(1); // collect Gen 0 + Gen 1
Console.WriteLine(GC.GetGeneration(obj)); // 2 — fully tenured

Gen 0 — new allocations; collected frequently (milliseconds). Gen 1 — objects that survived one Gen 0 collection; a buffer zone collected less often. Gen 2 — long-lived objects (static-like); collected rarely, most expensive. A Gen 2 collection (full GC) can cause noticeable pauses in latency-sensitive apps.

Memory pressure on Gen 2 is a common production issue — typically caused by excessive caching, long-lived string allocations, or IDisposable objects not being disposed.

Rule of thumb: Short-lived objects (request-scoped) are cheap; long-lived objects (Gen 2) cost GC pressure — minimise how much data you promote to Gen 2.

Reflection is the ability of .NET code to inspect and invoke type metadata — class names, methods, properties, attributes — at runtime without knowing them at compile time. It is provided by the System.Reflection namespace.

using System.Reflection;

var type = typeof(DateTime);

// List all public instance methods:
foreach (var method in type.GetMethods(BindingFlags.Public | BindingFlags.Instance))
    Console.WriteLine(method.Name);

// Invoke a private method dynamically:
var mi = type.GetMethod("InternalTicksToDateTime",
                        BindingFlags.NonPublic | BindingFlags.Static);
// mi?.Invoke(null, new object[] { ticks });

// Read a custom attribute:
var attr = typeof(MyService).GetCustomAttribute<ObsoleteAttribute>();
Console.WriteLine(attr?.Message);

Reflection is used by: dependency injection containers (scanning for constructors), serialisers (JSON.NET, System.Text.Json), ORMs (EF Core mapping), test frameworks (discovering [Test] methods), and plugin systems. The downsides are performance overhead and loss of compile-time type safety.

Rule of thumb: Let frameworks use reflection internally; avoid it in your own hot paths. Prefer source generators (C# 9+) for compile-time code generation as a zero-overhead alternative.

The CLR manages two primary memory regions. The stack is a per-thread, LIFO structure for method frames, local variables of value types, and return addresses. The heap is a shared, GC-managed area for all reference-type objects.

void Example()
{
    int x = 10;           // value type local — lives on the STACK for this frame
    var s = "hello";      // string is a reference type — object on HEAP, reference on stack
    var p = new Person(); // Person object on HEAP, 'p' reference on stack
}                         // frame pops: x, s, p references gone; GC eventually frees heap objects

struct Point { public int X, Y; }   // value type
class Circle { public Point Center; } // Circle on heap; Center (struct) embedded in it

Value types declared as fields of a class live on the heap — inside the object that contains them. "Value types live on the stack" is a simplification true only for local variables and parameters. Span<T> and ref struct types are designed to be stack-only to enable safe, zero-copy buffer operations.

Rule of thumb: Stack allocation is free and deterministic; heap allocation incurs GC pressure. For hot loops, prefer value types and Span<T> to avoid heap allocations.

Tiered compilation (enabled by default since .NET Core 3.0) allows the JIT to compile methods multiple times at increasing optimisation levels, trading startup speed for peak throughput.

// You write:
int Sum(int a, int b) => a + b;

// Tier 0 (first call): compiled quickly with minimal optimisation
// → fast startup, no inlining, no loop unrolling

// After the method is called enough times (instrumented with call counters):
// Tier 1 (hot method): recompiled with full optimisation
// → inlined, loop-unrolled, register-allocated aggressively

// Disable via environment variable (for benchmarking):
// DOTNET_TieredCompilation=0

Tier 0 uses instrumentation stubs to count calls. Once a method crosses a threshold, the runtime queues it for tier 1 recompilation on a background thread. The old tier 0 code stays active until tier 1 is ready, then the call-site patch is swapped atomically.

ReadyToRun (R2R) is a related feature that pre-compiles IL to native code at publish time, providing faster startup (like AOT) while still allowing tier 1 recompilation at runtime for peak performance.

Rule of thumb: Tiered compilation is transparent — leave it on. If you're micro-benchmarking (BenchmarkDotNet), disable it or let the benchmark harness warm up properly before measuring.

An AppDomain was the .NET Framework mechanism for isolating multiple logical applications within a single process. It provided separate heaps, separate type systems, and remoting between domains. In .NET Core and .NET 5+, AppDomains are largely removed — there is only one AppDomain per process.

// .NET Framework: create a sandbox domain (no longer works in .NET Core)
// var domain = AppDomain.CreateDomain("Sandbox");
// domain.ExecuteAssembly("plugin.dll");

// .NET 5+ equivalent: AssemblyLoadContext for plugin isolation
var context = new System.Runtime.Loader.AssemblyLoadContext("Plugin", isCollectible: true);
var asm = context.LoadFromAssemblyPath("/path/to/plugin.dll");
var type = asm.GetType("Plugin.EntryPoint");
// ... invoke methods via reflection ...

// Unload the context (and all assemblies in it) when done:
context.Unload();

// True process isolation still requires separate processes:
// var proc = Process.Start(new ProcessStartInfo("dotnet", "plugin.dll"));

AssemblyLoadContext (ALC) is the modern replacement: it provides assembly isolation and — when marked isCollectible: true — the ability to unload assemblies, which is crucial for hot-reload plugin systems.

Rule of thumb: Forget AppDomains for .NET Core/5+ work. Use AssemblyLoadContext for plugin loading and unloading. For true security isolation, use separate processes or containers.

When an exception is thrown, the CLR unwinds the call stack looking for a matching catch handler. If none is found, the process terminates (with an unhandled-exception event fired first). The mechanism relies on Structured Exception Handling (SEH) at the OS level on Windows and equivalent mechanisms on Linux/macOS.

void Outer()
{
    try
    {
        Inner();
    }
    catch (InvalidOperationException ex)
    {
        // CLR walked up the stack, found this handler
        Console.WriteLine(ex.Message);
    }
    finally
    {
        // Always runs — even if no catch matched, after the handler runs
        Console.WriteLine("cleanup");
    }
}

void Inner()
{
    // Note: rethrowing with 'throw' preserves the original stack trace
    // Rethrowing with 'throw ex' resets the stack trace — avoid that
    try { DoWork(); }
    catch { throw; } // Good: preserves original stack trace
}

The CLR distinguishes first-chance exceptions (before any handler runs — useful for debuggers) from second-chance exceptions (unhandled). The finally block runs after the matching catch block, or during stack unwinding if no handler is found in the current frame.

Rule of thumb: Always rethrow with bare throw, never throw ex — the latter destroys the original stack trace. Use finally for cleanup, not to swallow exceptions.

More ways to practice

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

or
Join our WhatsApp Channel