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 Fundamentals interview questions
More ways to practice
The self-quiz is live. Get notified when mock interviews and new question packs drop.