.NET Internals — CLR, CTS, CLS, IL, JIT, Memory Management & Garbage Collection
In short: The Common Language Runtime is .NET's execution engine.
- It loads assemblies, verifies type safety, JIT-compiles IL to native code, manages memory with the garbage collector, handles exceptions, threads and synchronisation, provides interop with native code and exposes debugging/profiling services.
- Code running under it is "managed code".
In short: CTS (Common Type System) defines how types are declared and used in .NET — value vs reference types, inheritance, members, visibility — so all languages share one type system (C# int = VB.NET Integer = System.Int32).
- CLS (Common Language Specification) is a subset of CTS rules that every .NET language supports, so a CLS-compliant library can be used from any language (e.g. avoid public unsigned types or names differing only in case).
In short: IL is the CPU-independent, stack-based intermediate code the C#/VB.NET compilers produce, stored with metadata in assemblies.
- At runtime the JIT compiles each method's IL to native machine code on its first call, optimised for the actual CPU;
- tiered compilation starts with quick code and re-compiles hot methods with full optimisation.
- ReadyToRun and Native AOT precompile to reduce startup time.
In short: .NET Framework (2002–2019) is Windows-only, installed machine-wide, and now only maintained (4.8.1 last);
- it has Web Forms, WCF and WWF. .NET Core (2016–2019) was the open-source, cross-platform, high-performance rewrite. .NET 5 (2020) onward unifies them as simply ".NET", released every November with LTS versions (6, 8, 10) supported for 3 years.
- New projects use .NET 8/10.
In short: Managed code runs under the CLR, which handles memory (GC), type safety and exceptions — C#, VB.NET, F#.
- Unmanaged code runs directly on the OS without the CLR — C/C++ DLLs, Win32 APIs, COM components — and manages its own memory. .NET calls unmanaged code via P/Invoke or COM interop;
- resources from it must be released with
IDisposable/SafeHandle.
In short: The unit of deployment and versioning in .NET — a .dll or .exe containing IL code, metadata describing all types and members, a manifest (name, version, culture, public key token, references) and optional resources.
- Assemblies can be private (in the app folder), shared in the GAC (.NET Framework) or satellite (localised resources).
In short: The GC allocates objects by bumping a pointer and, when the gen-0 budget is used up, suspends threads, marks all objects reachable from roots (stack, statics, handles), compacts survivors and updates references.
- Generations: gen 0 for new objects (collected often and cheaply), gen 1 as a buffer, gen 2 for long-lived objects (full, expensive collections).
- Large objects (≥ 85,000 bytes) go to the LOH, collected with gen 2 and not compacted by default.
In short: Dispose (from IDisposable) is called deterministically by your code (using) to release resources early;
- a finalizer (
~Class()) is called non-deterministically by the GC's finalizer thread as a safety net for unmanaged resources and makes objects survive an extra GC. - The Dispose pattern:
Dispose()callsDispose(true)andGC.SuppressFinalize(this); - the finalizer calls
Dispose(false); Dispose(bool disposing)frees managed resources only whendisposingis true and unmanaged resources always.- Prefer
SafeHandleover writing finalizers.
In short: Reference-type objects are always on the managed heap; variables hold references to them.
- Value types are stored inline where they are declared: on the stack (or in registers) for locals, inside the containing object for fields, inside the array for array elements, and on the heap when boxed or captured by closures/async methods.
- So "value types live on the stack" is only partly true.
In short: Workstation GC uses one heap and runs collections on the allocating thread — lower memory and shorter pauses, suited to desktop apps.
- Server GC creates one heap and one dedicated GC thread per logical core and collects in parallel — higher throughput and allocation rates, more memory, the default for ASP.NET Core.
- Both support background (concurrent) gen-2 collection; .NET 9 enables DATAS to adapt Server GC heap count to load.
The Global Assembly Cache is a machine-wide store for strong-named shared assemblies in .NET Framework, letting multiple apps share one version (with binding redirects for versioning). .NET Core/.NET 5+ doesn't use the GAC: dependencies come from NuGet and are deployed with the app (or the shared framework), resolved via deps.json.
In short: AppDomains (.NET Framework) isolated code within one process and could be unloaded — IIS ran each web app in its own AppDomain. .NET Core supports only one AppDomain;
- isolation and unloading of plugins use
AssemblyLoadContext(collectible contexts can be unloaded), and strong isolation uses separate processes or containers.
In short: Tiered compilation JITs methods quickly at tier 0 and recompiles frequently used methods with full optimisation (tier 1, with Dynamic PGO) — fast startup and fast steady state.
- ReadyToRun ships precompiled native code inside assemblies for faster startup while keeping the JIT.
- Native AOT compiles the entire app ahead of time into a self-contained native executable — fastest startup and lowest memory, but no runtime code generation and limited reflection.
In short: The compiler turns an async method into a state-machine struct;
- each
awaitchecks whether the awaited task has completed — if not, it stores the current state, registers a continuation and returns an incomplete Task to the caller, releasing the thread. - When the operation completes (the OS signals I/O completion), the continuation runs
MoveNext()from the saved state on a thread-pool thread (or the captured SynchronizationContext). - No thread is blocked while waiting for I/O.
In short: Confirm growth with dotnet-counters / Application Insights (GC heap size and gen-2 size rising, gen-2 collections not reclaiming).
- Capture heap snapshots (
dotnet-gcdump) or full dumps (dotnet-dump) at two points in time, compare object counts by type, and find the roots keeping them alive (gcroot, "paths to root"). - Typical culprits: unbounded static caches, event handlers not unsubscribed, timers, captive dependencies in DI, undisposed resources, LOH fragmentation from large buffers.
In short: Objects of 85,000 bytes or more are allocated on the LOH, which is collected only with gen 2 and not compacted by default, so frequent large temporary allocations (big arrays, MemoryStream buffers, large strings) cause expensive full GCs and fragmentation (memory grows though objects are freed).
- Fix with buffer reuse (
ArrayPool<T>,RecyclableMemoryStream), streaming instead of loading everything, or occasional LOH compaction (GCSettings.LargeObjectHeapCompactionMode).
In short: Each type has a MethodTable with a vtable of slots for its virtual methods; derived types copy the base slots and replace those they override.
- A virtual call loads the object's MethodTable pointer, reads the slot for the method and calls that address — so the runtime type decides which implementation runs.
- Sealed types and JIT devirtualisation (with PGO) can turn these into direct calls and enable inlining.
In short: Monitor GC counters to see if the managed heap (gen 2/LOH) grows or native memory grows.
- Take two
dotnet-gcdumpsnapshots and compare the biggest growing types; check their roots. - Common findings: an in-memory cache without limits, a singleton holding scoped objects or request data, event subscriptions,
HttpClientcreated per request, large response buffering in the LOH. - Also set container memory limits correctly (GC respects them), consider DATAS/workstation GC for small containers, and add alerts on memory growth.
In short: Each image creates large byte arrays (> 85 KB) on the LOH; allocating many short-lived large objects forces gen-2 collections and fragments the LOH.
- Reuse buffers with
ArrayPool<byte>.Shared, process images as streams, disposeBitmap/image objects promptly (they hold native memory), and measure with BenchmarkDotNet's MemoryDiagnoser ordotnet-counters.