18 questions on what happens under C#: how the CLR loads and JITs assemblies, how the garbage collector decides what survives, and where value types, boxing, and structs spend memory.
01C# / .NETFoundation
.NET Platform Architecture: Runtime, BCL, and Execution
Answer
.NET is a unified, cross-platform, managed runtime environment and software development kit (SDK) supporting C#, F#, and Visual Basic across Windows, Linux, macOS, iOS, and Android.
The Execution Pipeline: Source code is compiled by the Roslyn compiler into Intermediate Language (IL) bytecodes and ECMA-335 standard type metadata packaged inside Common Intermediate Format assemblies (.dll). At execution time, the Common Language Runtime (CoreCLR) loads the assemblies, verifies IL type safety, allocates heap structures, and invokes RyuJIT to emit optimized native machine code for the target CPU architecture.
Base Class Library (BCL): The foundational set of standard libraries (System.*) providing fundamental types (System.Object, string, int), collections, file I/O, networking, cryptographic primitives, reflection, and concurrency constructs shared by all .NET applications.
Target Framework Monikers (TFMs): Designate the API surface contract that an application or library targets (e.g. net8.0, net9.0). Modern .NET follows a predictable release cadence with Long-Term Support (LTS) releases supported for 3 years and Standard-Term Support (STS) releases supported for 18 months.
Flow diagram: Source compilation to native execution on the .NET platform
In a managed garbage-collected runtime like .NET, a memory leak does not mean unreferenced orphan memory (like a missing free() in C++). A managed memory leak occurs when an object that is no longer needed by business logic remains reachable through a chain of references originating from a Garbage Collection Root.
GC Roots: The garbage collector determines which objects to keep alive by traversing active roots: static variables, CPU registers, local variables currently on thread execution stacks, and active finalization queues.
Root Retention: As long as any chain of references connects a GC root to an object, the collector cannot free its memory, and the object eventually promotes to Gen 2, permanently inflating memory usage.
Flow diagram: GC root retaining subscriber via event subscription delegate
C# (Preventing Memory Leaks with WeakEventManager or IDisposable)
Event Handler Subscriptions: When a long-lived publisher (such as a static event or singleton service) exposes an event, subscribing to it (publisher.OnData += HandleData) causes the publisher's internal MulticastDelegate to hold a strong reference to the subscriber instance. If the subscriber fails to unsubscribe (-=), the subscriber cannot be garbage-collected for the life of the publisher.
Static Collections and Caches: Appending items to static List<T> or Dictionary<TKey, TValue> collections without an eviction policy (TTL, LRU, or WeakReference) causes unbounded heap growth.
Captive Scoped Dependencies: A Singleton holding a reference to a Scoped service (like DbContext) prevents the Scoped instance and its entire ChangeTracker graph from being collected.
Unobserved Timers: Instances of System.Threading.Timer or periodic background tasks that are not properly disposed remain rooted in the runtime timer table.
03C# / .NETFoundation
Value types
Answer
Value types include structs, enums, and built-in primitives such as int, bool, and double. Their data is stored inline in the containing variable or field.
Local value-type variables often live on the stack. A value-type field on a class lives on the heap as part of that object.
Assignment and passing by value copy the contents. Two variables do not share mutable state unless you pass by ref or store a reference type inside the struct.
Value types cannot be null unless you use Nullable<T> (written T?) or treat nullability in a nullable-enabled context for T?.
Boxing wraps a value in a heap object when the value is used as object or as an interface. That allocates and copies.
Prefer small, immutable or mostly immutable structs. Large mutable structs invite expensive accidental copies.
readonly struct and in parameters help reduce copies for large structs when APIs are designed carefully.
Enums are value types backed by an integral type. Use Enum.HasFlag or bitwise checks only when the enum is marked for flag combinations.
Flow diagram: Value type copy
C#
int a = 10;
int b = a;
b = 20;
Console.WriteLine(a); // 10structPoint { publicintX, Y; }
04C# / .NETFoundation
Reference types
Answer
Reference types include classes, interfaces, arrays, delegates, and string. Instances live on the managed heap. Variables hold references to those instances.
Assignment copies the reference. Two variables can alias the same object, so mutations through one are visible through the other.
A reference may be null. With nullable reference types enabled, string? warns that null is possible and string means non-null by convention.
Method parameters receive a copy of the reference by default. Reassigning the parameter does not reassign the caller's variable unless you use ref or out.
Arrays are reference types even when the element type is a value type. int[] is a heap object that holds int values inline in its buffer.
string is a sealed immutable reference type. Content equality is overloaded for ==, while ReferenceEquals still checks identity.
Identity versus equality matters for dictionaries and sets. Override Equals and GetHashCode together when you define value equality for a class.
Prefer clear ownership: know which component creates a reference type, who mutates it, and who disposes any resources it owns.
Flow diagram: Variables hold references to heap objects
C#
var a = newCustomer { Name = "Ada" };
var b = a; // copies the reference
b.Name = "Grace";
Console.WriteLine(a.Name); // Grace
a = newCustomer { Name = "Edsger" }; // a points elsewhere; b unchangedclassCustomer { publicstringName { get; set; } = ""; }
05C# / .NETFoundation
Parameter passing
Answer
By default, arguments are passed by value. For a reference type, the value copied is the reference. For a value type, the value copied is the data.
ref passes an alias to the caller's variable. The method can read and write the variable, including reassigning a reference-type variable.
out is like ref for assignment, but the method must assign the parameter before it returns. The caller may pass an uninitialized variable.
in passes a read-only reference. It avoids copying large structs while preventing the callee from assigning a new value to the parameter.
ref readonly and ReadOnlySpan<T> are related tools for high-performance APIs that need to observe data without taking ownership.
TryParse-style methods use out (or modern TryParse with return bool) so success and value travel together without exceptions for expected failure.
Prefer returning tuples or small result types when multiple out parameters make the call site hard to read.
Mixing ref and async has limits: you cannot use ref or out parameters across await boundaries in ordinary async methods.
Flow diagram: Parameter modes
C#
voidIncrement(refint x) => x++;
voidTryParse(string s, outint n) =>
int.TryParse(s, out n);
int value = 1;
Increment(ref value); // value is 2
06C# / .NETFoundation
Compile and runtime pipeline
Answer
Roslyn compiles C# source into IL and metadata stored in assemblies (.dll or .exe).
At startup the CLR loads required assemblies. When a method runs for the first time, the JIT compiles that method's IL to native code.
Tiered compilation can produce a quick first JIT, then recompile hot methods with stronger optimizations after they prove busy.
ReadyToRun (R2R) images pre-compile much IL to native code to reduce startup JIT work while still allowing some runtime flexibility.
Native AOT publishes ahead-of-time native binaries with trimming. Startup and deployment size improve, but reflection-heavy patterns need care.
The runtime also handles class loading, static constructors, and security or loader contexts before managed methods run.
Debugging and profiling use PDBs and diagnostic ports. Tools attach to the running process without changing the IL you shipped.
Understanding the pipeline explains cold-start cost, why first-call latency appears, and when AOT or R2R is worth enabling.
Flow diagram: Compile then JIT at runtime
C#
// 1) dotnet build writes IL into bin/// 2) dotnet run loads the assembly// 3) First call to Main / helpers is JIT-compiledConsole.WriteLine(Sum(2, 3));
staticintSum(int a, int b) => a + b;
07C# / .NETFoundation
Boxing, Unboxing, and Memory Penalties
Answer
Boxing is the process of converting a value type (int, struct, enum) into an object reference or an implemented interface type.
Runtime Mechanics: When boxing occurs, the CLR allocates a new object on the managed heap (including the standard 16-byte object header on 64-bit platforms), copies the value type's field bytes into the newly allocated heap payload, and returns a reference to that heap instance.
Unboxing: Involves extracting the value type from the object reference. Unboxing consists of checking that the object's MethodTable matches the requested type (throwing InvalidCastException if mismatched) and copying the value bytes back to the stack.
Performance Impact: Boxing incurs three penalties: heap allocation overhead, garbage collection pressure when the boxed instances are collected, and CPU instruction cycles spent copying bytes between stack and heap.
Flow diagram: Boxing stack-to-heap allocation and unboxing verification
C# (Avoiding Boxing via Generics)
publicstructPoint : IEquatable<Point>
{
publicintX { get; set; }
publicintY { get; set; }
// Strongly-typed IEquatable<T> avoids boxing when checking equalitypublicboolEquals(Point other) => X == other.X && Y == other.Y;
publicoverrideboolEquals(object? obj) =>
obj isPoint other && Equals(other); // Boxing occurs here due to object parampublicoverrideintGetHashCode() => HashCode.Combine(X, Y);
}
// Generic constraint ensures JIT devirtualizes call with ZERO boxingpublicstaticboolCheckEqual<T>(T first, T second) whereT : IEquatable<T>
{
return first.Equals(second);
}
Common Boxing Traps in Production
Non-Generic Collections: Older collections (ArrayList, Hashtable) store object. Adding an integer to an ArrayList boxes it; reading it unboxes it. Generic collections (List<int>, Dictionary<int, string>) store value types inline in arrays with zero boxing.
Interface Calls on Structs: Calling an interface method on a struct instance without generic constraints boxes the struct. In generic methods, using where T : IMyInterface allows the JIT to emit direct, non-boxing calls.
String Concatenation and Formatting: Writing string.Format("Total: {0}", 42) or string interpolation in older C# versions boxes value arguments into object arrays.
08C# / .NETFoundation
Unboxing
Answer
Unboxing extracts a value type from a boxed heap object back into a value-type variable of the exact runtime type.
The cast (int)boxed copies the value out. If the boxed type does not match, the runtime throws InvalidCastException.
Unboxing requires a cast to the precise type that was boxed. You cannot unbox to a different value type even if a conversion exists.
After unboxing you hold an independent copy. Mutating that copy does not change the boxed object.
Pair unboxing costs with boxing avoidance: use List<int> instead of ArrayList, and typed generics instead of object APIs.
is and as patterns with value types still involve type tests. Prefer TryParse-style APIs when converting from strings.
Interface unboxing scenarios appear when a struct was boxed to IComparable and you cast back to the struct type.
In interviews, contrast boxing (value to heap object) with unboxing (heap object back to value) and name the allocation side as boxing.
Flow diagram: Unboxing
C#
object boxed = 42;
int value = (int)boxed;
09C# / .NETFoundation
Boxing vs serialization
Answer
Boxing copies a value type into a heap object typed as object or an interface. Unboxing copies that value back into a value-type slot.
Serialization turns an object graph into a transferable format such as JSON or binary bytes. Deserialization rebuilds objects from that format.
Boxing stays inside the same process memory. Serialization targets storage, messaging, or crossing process and network boundaries.
Boxing is usually cheaper than JSON serialization, yet both allocate. Prefer generics in hot loops so you skip boxing entirely.
object box = 3; boxes an int. JsonSerializer.Serialize(person) serializes a graph. Those are different jobs with different costs.
BinaryFormatter is obsolete and unsafe for untrusted data. Prefer System.Text.Json or other maintained serializers for .NET apps.
Do not confuse interface casting of structs (which boxes) with writing fields to a stream (which serializes).
When optimizing, ask whether you are reducing heap traffic in-process or reducing payload size across a wire.
Flow diagram: Same process vs across the wire
C#
int n = 3;
object boxed = n; // boxingint m = (int)boxed; // unboxingstring json = JsonSerializer.Serialize(new { n }); // serializationvar back = JsonSerializer.Deserialize<Dictionary<string, int>>(json);
10C# / .NETFoundation
Boxing in non-generic collections
Answer
ArrayList and Hashtable store elements as object. Inserting a value type boxes it into a heap object on every Add.
Reading requires a cast that unboxes. Wrong casts throw InvalidCastException at runtime instead of failing at compile time.
List<T> and Dictionary<TKey,TValue> store value types unboxed in their buffers, which removes that per-element allocation.
You still hit boxing when you call older APIs that take object, non-generic interfaces, or ArrayList-based libraries.
Migrating legacy code means replacing ArrayList with List<T>, Hashtable with Dictionary, and updating casts to typed access.
IEnumerable without T yields object and can reintroduce boxing when enumerating value-type sequences through the non-generic path.
Queue and Stack non-generic types have the same boxing problem. Use Queue<T> and Stack<T> instead.
In performance reviews, non-generic collections are a classic finding when allocation profiles show unexpected boxed ints or structs.
Flow diagram: Non-generic boxing
C#
// Avoid in new codevar legacy = newArrayList();
legacy.Add(42); // boxedint x = (int)legacy[0]; // unbox
11C# / .NETFoundation
.NET (Core) runtime
Answer
Modern .NET (starting from .NET Core lineage) runs on Windows, Linux, macOS, and containers with one SDK and runtime family.
New applications target .NET rather than .NET Framework. Framework remains supported for existing Windows desktop and server apps.
LTS releases receive longer support and suit production baselines. STS releases ship newer features on a shorter support window.
The shared BCL covers most APIs. Platform-specific packs add Windows-only or mobile-only capabilities when needed.
Side-by-side installation lets multiple runtime versions coexist. Apps roll forward according to configured policies.
Self-contained and framework-dependent publish modes choose whether the runtime ships with the app or is installed on the host.
Containers commonly use Microsoft's official .NET runtime and ASP.NET images for consistent deployments.
When interviewing, distinguish runtime version, target framework moniker (TFM), and language version. They are related but not identical knobs.
Flow diagram: Modern .NET across platforms
C#
// Target modern .NET in the project file:// <TargetFramework>net8.0</TargetFramework>Console.WriteLine($"Version: {Environment.Version}");
Console.WriteLine($"64-bit process: {Environment.Is64BitProcess}");
12C# / .NETFoundation
Managed vs unmanaged code
Answer
Managed code runs under the CLR with garbage collection, type safety checks, and structured exception handling.
Unmanaged code such as native C or C++ and classic COM manages its own memory and follows platform calling conventions.
P/Invoke (DllImport / LibraryImport) calls native exports. You must match signatures, character sets, and lifetime of allocated memory.
COM interop bridges managed objects and COM interfaces. Runtime Callable Wrappers and COM Callable Wrappers mediate the boundary.
SafeHandle and IDisposable patterns ensure native handles close even when exceptions occur. Finalizers are a last-resort backup.
C++/CLI can mix managed and native code in one assembly when a project needs a bridging layer.
Crossing the boundary costs marshalling time and can leak if you forget to free unmanaged buffers you allocated.
Prefer managed APIs when they exist. Drop to native only for OS features or libraries without a managed wrapper.
Flow diagram: CLR-managed vs native memory
C#
// Managed: GC owns the byte[]var buffer = new byte[1024];
// Unmanaged boundary: dispose native handles deterministicallyawaitusingvar stream = File.OpenRead("data.bin");
var read = await stream.ReadAsync(buffer);
13C# / .NETFoundation
CTS and CLS
Answer
The Common Type System (CTS) defines how types are represented in IL and metadata so languages share a common type model.
The Common Language Specification (CLS) is a subset of rules that public APIs should follow for reliable cross-language consumption.
CLS-compliant public surfaces avoid exposing unsigned integers and other constructs that some languages cannot consume cleanly.
C# and Visual Basic can reference the same assembly when public APIs stay within CLS rules and share CTS types.
Marking assemblies with [assembly: CLSCompliant(true)] asks the compiler to warn on CLS violations in public members.
Private and internal members can use non-CLS features freely because other languages do not call them across the public boundary.
CTS explains why a C# class and an F# type can both appear as reference types with methods and fields in metadata.
For library authors, CLS compliance is about the public contract. Internals can optimize without breaking interop consumers.
Flow diagram: Shared type system across languages
C#
// CLS-friendly public API: avoid unsigned types on public surfacepublicclassCalculator
{
publicintAdd(int a, int b) => a + b;
}
// Still valid C#, but uint on public APIs is not CLS-compliantpublic uint NotClsFriendly(uint value) => value;
14C# / .NETFoundation
Assemblies
Answer
An assembly is the unit of deployment and versioning in .NET: usually a .dll or .exe that contains a manifest, IL, and metadata.
The manifest records identity, referenced assemblies, and security or culture information the loader uses for binding.
Strong naming and fusion binding policies matter most on .NET Framework. Modern apps primarily use NuGet package references and framework-dependent resolution.
Reflection loads type metadata from assemblies at runtime for serializers, DI containers, and plugin systems.
Satellite assemblies hold localized resources. The runtime probes culture-specific satellites based on CurrentUICulture.
Trimming and single-file publish change which assemblies and members ship in containers. Reflection-heavy code needs root descriptors or annotations.
InternalsVisibleTo lets test assemblies access internal types without making them public.
Knowing assembly boundaries helps you design public APIs, manage versions, and decide what belongs in a shared class library.
Flow diagram: Assembly as the deploy unit
C#
usingSystem.Reflection;
var asm = Assembly.GetExecutingAssembly();
Console.WriteLine(asm.FullName);
Console.WriteLine(asm.Location);
15C# / .NETFoundation
DLL files in .NET
Answer
A .NET .dll is typically a managed assembly containing IL and metadata, not a classic unmanaged Win32 DLL, unless it is mixed-mode.
Class libraries compile to DLL assemblies that executables and other libraries reference through project or package references.
Native dependencies ship as runtime-specific binaries (for example runtimes/win-x64/native) alongside managed DLLs in NuGet packages.
AssemblyLoadContext (ALC) controls how assemblies load. Collectible ALCs in modern .NET allow unloading plugin assemblies when references are gone.
Binding redirects on .NET Framework resolve version conflicts. On modern .NET, package dependency graphs and shared frameworks play that role.
Loading the wrong DLL version at runtime causes FileNotFoundException or TypeLoadException. Dependency diagnostics tools help trace probes.
Do not confuse managed DLL references with P/Invoke into native DLLs. The tooling and failure modes differ.
Single-file publish may bundle managed DLLs into one host. Native libraries still extract or load according to the publish mode.
Flow diagram: Class library consumed by a host
C#
// In a class library project you ship types:publicstaticclassMathUtil
{
publicstaticintClamp(int value, int min, int max) =>
value < min ? min : value > max ? max : value;
}
// Host app references the project/package and calls MathUtil.Clamp(...)
16C# / .NETFoundation
Structs vs Classes: Memory, Semantics, and Copying
Answer
The core difference between class and struct in C# is identity versus value semantics.
Classes are Reference Types allocated on the managed heap. Variables hold references (pointers) to memory. Assignment copies the reference, pointing to the same instance. Comparing two class instances checks reference identity unless Equals is overridden.
Structs are Value Types allocated inline wherever declared: on the stack for local variables, or embedded inside the memory of their containing class or array. Assignment copies all field bytes. Modifying a copy never affects the original.
Memory Overhead: A class instance carries a 16-byte object header on 64-bit systems. A struct instance carries zero header overhead, storing only its fields packed according to memory alignment rules.
Flow diagram: Class reference on heap vs struct inline memory layout
C# (High-Performance Readonly Struct)
publicreadonlystructGeoCoordinate
{
publicdoubleLatitude { get; }
publicdoubleLongitude { get; }
publicGeoCoordinate(double lat, double lon)
{
Latitude = lat;
Longitude = lon;
}
// Using in parameter passes by readonly reference without copying 16 bytespublicdoubleDistanceTo(inGeoCoordinate other)
{
double dLat = Latitude - other.Latitude;
double dLon = Longitude - other.Longitude;
returnMath.Sqrt(dLat * dLat + dLon * dLon);
}
}
Defensive Copying and readonly struct
Defensive Copies: When a struct is passed to or accessed through an in parameter, a readonly field, or a non-mutating property, the compiler creates a defensive copy of the struct if the method might mutate fields. In high-frequency loops, defensive copying creates CPU overhead.
readonly struct: Declaring a struct as readonly struct instructs the compiler that all fields are immutable. The compiler eliminates defensive copies on readonly accesses, yielding performance improvements on large value types.
ref struct: Stack-only structs (such as Span<T> and ReadOnlySpan<T>). The compiler guarantees they cannot be boxed, cannot be stored in class fields, cannot cross await boundaries, and cannot be captured in lambda closures.
17C# / .NETAdvanced
CLR (Common Language Runtime) Internals
Answer
CoreCLR is the managed execution engine for .NET. It provides memory management, type safety verification, exception dispatching, thread pool scheduling, and JIT compilation.
Two-Stage Compilation: Roslyn compiles C# code into Intermediate Language (IL) and metadata stored in assemblies. RyuJIT translates IL into native machine code at runtime tailored to the host CPU architecture.
64-Bit Object Header Layout: Every heap-allocated object has 16 bytes of overhead before its fields. The first 8 bytes represent the Object Header (SyncBlock Index, used for lock synchronization, hash code storage, and GC flags). The next 8 bytes hold the MethodTable Pointer (TypeHandle), pointing to type metadata, method slots, and interface dispatch tables.
Value Types vs Reference Types: Value types are stored inline wherever declared: on the stack for local variables, or embedded inside the heap memory of an enclosing object. Casting a value type to object or an interface triggers boxing, allocating an object header on the heap and copying the value bytes into it.
Tier 1 (Optimized JIT): When a method crosses execution thresholds (typically 30 calls), a background compiler thread recompiles it with full optimizations (inlining, vectorization, devirtualization) and replaces the method pointer in the MethodTable.
Dynamic PGO (Profile-Guided Optimization): Tier 0 records real runtime types passing through interface calls and hot/cold branch paths. Tier 1 uses these profiles to convert interface dispatch into direct calls and optimize common code paths.
Garbage Collection Generational Model
Generational Hypothesis: Most allocated objects die shortly after creation (such as DTOs, string builders, and temporary arrays).
Gen 0: Holds newly allocated short-lived objects. Allocation consists of incrementing an allocation pointer within an ephemeral segment, making it as fast as stack allocation when memory is free.
Gen 1: Acts as a buffer between short-lived and long-lived objects. Objects surviving a Gen 0 collection promote to Gen 1.
Gen 2: Contains long-lived objects (singletons, static references, application caches). Surviving Gen 1 promotes an object to Gen 2. Full collections inspecting Gen 2 traverse the entire managed heap and cost significantly more CPU time.
Large Object Heap (LOH): Allocations of 85,000 bytes or larger allocate directly on the LOH. The LOH is collected only during Gen 2 collections and is not compacted by default to prevent expensive memory copy operations.
Pinned Object Heap (POH): Buffers pinned for native interop allocate here so GC compaction of Gen 0-2 is not blocked by immovable memory.
18C# / .NETAdvanced
CLR Garbage Collection Internals
Answer
The .NET Garbage Collector (GC) is an automatic, generational, tracing, mark-sweep-compact memory manager designed to maximize allocation throughput and minimize application pause times.
Allocation Mechanics: Allocation in Gen 0 is performed by advancing an allocation pointer inside an ephemeral segment (a pointer-bump operation), which executes in only a few CPU cycles without scanning free lists.
Generational Hypothesis: The design is based on the empirical rule that newly created objects have a very short lifespan (local variables, temporary strings, DTOs), while objects that survive multiple collections tend to remain alive for a long time (caches, singletons, static state).
The Three Generations: Gen 0 (holds newly allocated short-lived objects), Gen 1 (acts as an aging buffer between short-lived and long-lived memory), and Gen 2 (holds long-lived application state). When Gen 0 fills, an ephemeral collection cleans Gen 0; survivors are promoted to Gen 1. When Gen 1 fills, Gen 0 and Gen 1 are collected. Gen 2 collections (Full GCs) inspect the entire managed heap.
Flow diagram: Generational heap progression and LOH/POH layout
C# (Inspecting GC Generations and Forcing Ephemeral Cleanup)
usingSystem;
var obj = newobject();
Console.WriteLine($"Initial Generation: {GC.GetGeneration(obj)}"); // Gen 0// Collect Gen 0 and Gen 1; survivors promoteGC.Collect(0, GCCollectionMode.Forced);
Console.WriteLine($"Post-Collection 1: {GC.GetGeneration(obj)}"); // Gen 1GC.Collect(1, GCCollectionMode.Forced);
Console.WriteLine($"Post-Collection 2: {GC.GetGeneration(obj)}"); // Gen 2// Total memory currently allocatedlong memoryBytes = GC.GetTotalMemory(forceFullCollection: false);
Console.WriteLine($"Heap Memory: {memoryBytes / 1024 / 1024} MB");
Large Object Heap (LOH) and Pinned Object Heap (POH)
Large Object Heap (LOH): Allocations of 85,000 bytes or larger bypass Gen 0 and allocate directly on the LOH. Because moving large memory buffers costs significant CPU memory-copy time, the LOH is not compacted by default during collections, which can lead to memory fragmentation if arrays of varying sizes are allocated repeatedly. Modern .NET supports compacting the LOH on demand (GCSettings.LargeObjectHeapCompactionMode).
Pinned Object Heap (POH): Introduced in .NET 5. Buffers pinned for native P/Invoke or asynchronous socket I/O allocate directly on the POH, preventing pinned memory addresses from obstructing compaction in Gen 0, 1, and 2.
Workstation GC vs Server GC
Workstation GC: Optimized for low latency and UI responsiveness. Uses a single managed heap and runs GC threads at normal priority on the thread triggering the allocation.
Server GC: Optimized for high throughput and multi-threaded scalability on web servers. Creates one dedicated managed heap and one dedicated background GC thread per logical CPU core. Allocation threads operate independently without cross-core lock contention.