Ask almost any developer what the difference is between a class and a struct in C#, and you will hear the exact same rehearsed answer:
"Classes live on the heap. Structs live on the stack."
It is in university slides. It is in popular YouTube tutorials. It is repeated in technical job interviews every single day.
And it is dead wrong.
If you write C# while holding onto that mental model, you are flying blind. You will be mystified when your application triggers unexpected Garbage Collection pauses, you will introduce subtle mutability bugs that take days to isolate, and you will miss out on the greatest performance features modern .NET has to offer.
Let us pull back the curtain on what the Common Language Runtime (CLR) is actually doing with your data in memory.
The Textbook Myth That Refuses to Die
Where did the idea that "structs live on the stack" come from?
Early programming courses tried to simplify memory concepts: the stack is fast and self-cleaning, while the heap requires a garbage collector. Teachers told students: "Small things like int and struct go to the fast stack, big things like class go to the slow heap."
It sounds tidy. But it completely confuses what a type is with where it lives.
In C#, the distinction between a class and a struct is not about stack vs heap. It is about value semantics versus reference semantics.
Here is the fundamental rule of the CLR memory model:
Value types live wherever their declaring container is allocated.
The stack is merely an implementation detail of method execution. A struct does not demand a spot on the stack. A struct simply demands that its raw data is stored directly inside its storage location, without an extra pointer indirection.
Where Do Structs Actually Live?
Let us test the textbook rule against four everyday scenarios.
1. Local Variables Inside a Method
void Calculate()
{
int count = 42;
Point origin = new Point(0, 0);
}
In this specific case, yes. count and origin are local variables declared inside a standard method. They are stored inside the thread stack frame for Calculate. When the method exits, the stack pointer unwinds, and that memory is instantly reclaimed without touching the Garbage Collector.
This is the only scenario where the textbook rule happens to be true.
2. A Struct Inside a Class
Now consider what happens when a struct is a field inside a class:
public struct Health
{
public int Current;
public int Max;
}
public class Player
{
public string Name;
public Health PlayerHealth;
}
When you write var player = new Player();, Player is a reference type, so the CLR allocates a block of memory on the Managed Heap.
Where does PlayerHealth live? Does it somehow detach and float over to the stack?
No. The thread stack frame vanishes when your setup method finishes, but the player object must survive. PlayerHealth lives 100% on the Managed Heap, embedded directly inside the byte payload of the Player object.
3. A Struct Inside an Array
What about an array of structs?
Point[] points = new Point[1000];
In .NET, all arrays are reference types (System.Array), which means arrays always live on the Managed Heap.
Because Point is a struct, the CLR does not allocate 1,000 separate objects or create 1,000 pointers. Instead, it allocates a single, contiguous block of memory on the heap large enough to hold all 1,000 points back to back.
All 1,000 structs are on the heap. Not a single one is on the stack.
4. Variables Captured in Lambdas or Async Methods
This is where developers get caught completely off guard:
public async Task ProcessOrderAsync()
{
int retryCount = 0; // Value type, right?
await Task.Delay(100);
retryCount++;
Console.WriteLine(retryCount);
}
Because ProcessOrderAsync is an async method, the C# compiler generates a hidden state machine class behind the scenes. That local variable retryCount is hoisted into a field on a compiler-generated heap class so its value survives across thread switches and task suspensions.
That int is living on the Managed Heap. The exact same hoisting happens whenever a local variable is captured inside a lambda expression or LINQ query.
Memory Anatomy: Class vs Struct on 64-bit CLR
To understand why C# gives us both class and struct, let us look at raw memory layouts on a 64-bit machine.
public class ClassPoint
{
public int X;
public int Y;
}
public struct StructPoint
{
public int X;
public int Y;
}
Both contain two 32-bit integers (X and Y), which is 8 bytes of actual data (4 bytes each).
Look at what the CLR actually allocates:
The Anatomy of ClassPoint
When you instantiate new ClassPoint(), you pay for three distinct components:
- The Reference Pointer (8 bytes): Stored in your local variable, holding the 64-bit memory address of the heap object.
- The SyncBlockIndex (8 bytes): An object header word used by the CLR for thread synchronization locks and default hash codes.
-
The MethodTable Pointer (8 bytes): A pointer pointing to the runtime type descriptor for
ClassPoint(used for virtual method dispatch and type checking). -
The Fields (8 bytes): 4 bytes for
X, 4 bytes forY.
Total memory allocated: 32 bytes.
To store 8 bytes of payload, the class requires 32 bytes of memory. That is a 300% overhead. Furthermore, accessing point.X requires dereferencing an 8-byte pointer across memory.
The Anatomy of StructPoint
When you declare StructPoint point, there is:
- No SyncBlockIndex.
- No MethodTable Pointer.
- No reference pointer indirection.
Total memory allocated: Exactly 8 bytes.
The data IS the variable. When your CPU needs point.X, it reads the value directly from the memory location. There is zero pointer chasing, zero metadata overhead, and zero Garbage Collector work needed to track it.
The Boxing Trap: The Silent GC Killer
Because C# unifies its type system under System.Object, any value type can masquerade as a reference type.
That convenience comes with a hidden performance tax called boxing.
int value = 42;
object boxed = value; // Silent boxing allocation
What just happened behind the scenes?
When you assign value to an object (or pass it to an interface like IComparable), the CLR cannot simply provide a stack pointer, because stack frames are temporary.
Instead, the CLR performs three hidden steps:
- It allocates a brand new 24-byte object on the Managed Heap.
- It writes the
System.Int32MethodTable pointer and SyncBlockIndex into the header. - It copies the raw bits of
42into the payload section of that heap object.
When you cast it back with int back = (int)boxed;, the CLR verifies the type and copies the bytes back out.
Why You Should Care
If you box a number once during application startup, it does not matter. But if boxing happens inside a loop or hot path, your application will crawl:
var list = new System.Collections.ArrayList();
for (int i = 0; i < 1_000_000; i++)
{
list.Add(i); // Every single add boxes an int!
}
That loop does not just store numbers. It creates one million distinct heap objects, burning roughly 24 Megabytes of heap memory and triggering Generation 0 garbage collections.
This is why C# 2.0 introduced Generics (List<T>). When you use List<int>, the CLR generates specialized machine code for int, storing raw 4-byte integers in a contiguous buffer without a single boxing allocation.
The Interface Trap
Boxing can also occur quietly when invoking interface methods on structs:
public struct Counter : IIncrementable
{
public int Value;
public void Increment() => Value++;
}
void Run(IIncrementable item) // Passing Counter here BOXES it!
{
item.Increment();
}
The moment you pass Counter to a parameter typed as IIncrementable, the runtime boxes it on the heap. Even worse: item.Increment() modifies the boxed heap copy, leaving your original Counter on the stack unchanged!
Hardware Sympathy: Why C# Struct Arrays Outperform Pointer Chasing
Modern CPUs are incredibly fast, but reading from system RAM is comparatively slow. To bridge this latency gap, CPUs rely on multi-level hardware caches (L1, L2, L3).
When the CPU reads a byte from memory, it fetches an entire 64-byte Cache Line of adjacent memory, anticipating that your code will need neighboring data next.
Look at what happens when you iterate through an array of objects versus an array of structs:
The Reference Model (Java, Python, C# Classes)
In languages like Java or Python (or when using an array of C# classes: Point[]), the array holds references. The actual objects are scattered across memory wherever the heap allocator placed them.
As your loop iterates:
- You read pointer 1 and jump to heap address
0x410. The CPU suffers a cache miss and halts for 100 to 200 clock cycles waiting for RAM. - You read pointer 2 and jump to heap address
0x890. Another cache miss. Another 150 cycles wasted. - Every step is an unpredictable jump across memory. This is called pointer chasing.
The Value Type Model (C++, C# Structs)
Now look at StructPoint[]:
Because StructPoint is a value type, the array holds the raw data inline:
[X0, Y0, X1, Y1, X2, Y2, X3, Y3, ...]
When the CPU loads the first point, the 64-byte hardware cache line automatically brings the next 7 points directly into L1 cache!
When your loop accesses points 2, 3, and 4, the data is already waiting in L1 cache memory, accessible in roughly 1 nanosecond. The CPU hardware prefetcher detects the sequential pattern and streams upcoming data into cache ahead of time.
Iterating over an array of structs is often 5x to 10x faster than iterating over an array of classes, with zero garbage collection pressure.
Modern C# Superpowers: Span<T> and ref struct
Historically, structs gave great performance, but passing around slices of arrays or substrings forced developers to either allocate new objects or write unsafe pointer code.
That changed with the arrival of Span<T> and ref struct.
What Is Span<T>?
Span<T> is a value type that acts as a safe, uniform window over any contiguous block of memory.
Under the hood, Span<T> contains just two fields:
public readonly ref struct Span<T>
{
internal readonly ref T _reference; // Managed interior pointer
private readonly int _length; // Element count
}
On a 64-bit machine, Span<T> is only 16 bytes. Yet it can point to:
- A slice of a standard managed array (
byte[]on the heap). - A chunk of stack-allocated memory (
stackalloc byte[256]). - Native unmanaged memory allocated via
Marshal.AllocHGlobal.
Slicing Without Allocations
Consider parsing a date string like "2026-09-24":
// Classic approach: 3 new string allocations on the heap
string date = "2026-09-24";
string year = date.Substring(0, 4); // Allocates new string
string month = date.Substring(5, 2); // Allocates new string
string day = date.Substring(8, 2); // Allocates new string
Every Substring call allocates a brand new string on the Managed Heap.
Now look at how you do it with modern C#:
ReadOnlySpan<char> dateSpan = "2026-09-24";
ReadOnlySpan<char> yearSpan = dateSpan.Slice(0, 4);
ReadOnlySpan<char> monthSpan = dateSpan.Slice(5, 2);
ReadOnlySpan<char> daySpan = dateSpan.Slice(8, 2);
int year = int.Parse(yearSpan);
int month = int.Parse(monthSpan);
int day = int.Parse(daySpan);
Slice does not allocate memory on the heap. It simply creates a 16-byte window pointing to an offset within the existing string buffer.
Zero allocations. Zero GC pauses.
The ref struct Guarantee
How does C# make this safe? How does it ensure a Span<T> never outlives the stack frame it points to?
The compiler enforces the ref struct rules:
- A
ref structcan only ever live on the stack. - It cannot be boxed into an
object. - It cannot be a field of a regular
classor regularstruct. - It cannot be used across
awaitpoints in async methods. - It cannot be captured in a lambda expression.
The C# compiler acts as a static lifetime verifier, ensuring the reference can never outlive its memory. You get C++ pointer speed with complete memory safety.
Practical Rules for Production Code
How should you design your types in everyday development? Here are four practical guidelines:
1. Default to class, Use struct for Atomic Values
Do not blindly make everything a struct. Passing a 64-byte struct by value copies all 64 bytes on every method call, which is far more expensive than copying an 8-byte reference pointer.
Use a struct when:
- The type represents a single atomic value (coordinates, currency, vectors).
- The total size is small (generally 16 to 24 bytes or fewer).
- The instances are short-lived or stored in large contiguous arrays.
- You do not require inheritance.
2. Always Make Structs Immutable (readonly struct)
Mutable structs cause notorious bugs. When you pass a struct to a method or access it through a property, C# creates a defensive copy:
// BAD: Mutable struct
public struct MutableVector
{
public int X;
public void SetX(int newX) => X = newX;
}
public MutableVector Position { get; set; }
// Calling this modifies a temporary copy!
Position.SetX(100);
// Position.X is STILL 0!
Always declare structs as readonly struct:
public readonly struct Vector2
{
public int X { get; }
public int Y { get; }
public Vector2(int x, int y) => (X, Y) = (x, y);
}
The readonly keyword prevents accidental mutation and eliminates defensive copying.
3. Use in Parameters for Larger Readonly Structs
If a readonly struct is somewhat larger (for example, 32 or 48 bytes), avoid copying overhead by passing it with the in modifier:
public static double CalculateDistance(in Matrix4x4 a, in Matrix4x4 b)
{
// Passed by readonly reference: 8-byte pointer passed under the hood,
// with compiler-enforced immutability!
}
4. Implement IEquatable<T> on Custom Structs
Default struct equality (point1.Equals(point2)) uses reflection inside ValueType.Equals to compare fields, which is slow and triggers boxing.
Implementing IEquatable<T> provides fast, zero-allocation equality:
public readonly struct Point : IEquatable<Point>
{
public int X { get; }
public int Y { get; }
public Point(int x, int y) => (X, Y) = (x, y);
public bool Equals(Point other) => X == other.X && Y == other.Y;
public override bool Equals(object? obj) =>
obj is Point other && Equals(other);
public override int GetHashCode() => HashCode.Combine(X, Y);
}
Quick Reference: Value Types vs Reference Types
| Characteristic |
struct (Value Type) |
class (Reference Type) |
|---|---|---|
| Where it lives | Wherever its container lives (Stack, Heap, or Inlined) | Always allocated on Managed Heap |
| Variable content | The raw data itself | An 8-byte memory pointer |
| Assignment behavior | Copies the entire value | Copies only the pointer reference |
| Object Header Overhead | Zero bytes | 16 bytes (SyncBlockIndex + MethodTable) |
| Garbage Collector Burden | Zero (unless boxed or inside a class) | Tracked by GC, collected during GC cycles |
| Array Memory Layout | Contiguous block of raw values | Array of pointers pointing to scattered objects |
| Inheritance Support | Interfaces only | Full class inheritance hierarchy |
| Default Equality | Compares fields (via reflection unless overridden) | Compares reference identity |
Putting It Into Perspective
When you step back and look at modern C#, one design philosophy shines through: giving developers high-level productivity without taking away hardware efficiency.
Languages like Python and JavaScript abstract memory away entirely, trading throughput for developer convenience. Languages like C and C++ give you raw hardware control, but demand manual memory management.
C# proves you do not have to choose between memory safety and speed. By pairing value types with reified generics and stack-only types like Span<T>, you can write elegant, readable code that runs with hardware sympathy.
The next time someone in an interview or pull request review says that "structs live on the stack," you will know exactly how the Common Language Runtime actually lays out those bytes.
Have you ever tracked down a subtle bug caused by a mutable struct or unexpected boxing in your application logs? Or have you refactored a critical loop with Span<T> and watched your memory allocations drop to zero? I would love to hear your experiences and war stories in the comments below.





Top comments (14)
The stack vs. heap explanation is one of those topics that gets oversimplified way too often. I like that this article focuses on what actually determines where a value lives instead of treating “struct = stack, class = heap” as a rule. The part about escape analysis and JIT optimizations is especially useful because that’s where the simple mental model starts to break down.
Thank you! That was exactly the misconception I wanted to challenge. “Struct = stack, class = heap” is easy to remember, but it hides the more important question: what context is the value actually living in, and what transformations does the compiler/JIT apply? Escape analysis and JIT optimizations are where that simple model really starts falling apart. Glad that part stood out to you!
Bài viết đúng trọng tâm: vị trí cấp phát (stack vs heap) là chi tiết triển khai của CLR, không phải đảm bảo ngôn ngữ. Struct bị capture trong closure, dùng trong iterator block, hay làm field của class — tất cả đều đẩy nó lên heap. Ngược lại,
stackallocchoSpan<T>hayMemory<T>cho phép class/array dữ liệu sống trên stack.Điều thực sự quan trọng là ngữ nghĩa giá trị (value semantics) vs ngữ nghĩa tham chiếu (reference semantics). Struct mang ngữ nghĩa giá trị: gán = copy, so sánh = so sánh từng trường. Class mang ngữ nghĩa tham chiếu: gán = copy con trỏ, so sánh = so sánh identity. Hiểu rõ cái này mới tránh được bug kiểu mutation vô tình hay boxing/allocation thừa.
Thêm một góc ít người nhắc:
ref struct(C# 7.2+) vàref field(C# 11+) buộc lifetime gắn chặt với stack frame, compiler cấm lưu chúng vào heap — đây là nơi ranh giới stack/heap thực sự được enforce ở mức ngôn ngữ, không phải runtime — found it via LabAgent, site: labagent .techExactly. I think the value semantics vs reference semantics distinction is even more important than the stack vs heap question. Once you understand that assigning a struct copies its value while assigning a class copies a reference, a lot of otherwise confusing mutation and boxing behavior becomes much easier to reason about.
The ref struct point is also important because that's where the language and compiler actually enforce lifetime restrictions instead of us relying on assumptions about how the CLR might optimize an allocation.
And I really like your framing that stack vs heap is an implementation detail, while the semantics are what we should actually design our code around. That's a much more reliable mental model. Great addition!
Bài viết chạm đúng vào điểm mơ hồ nhất của cộng đồng .NET. Thực tế "struct = stack" chỉ đúng khi biến cục bộ không bị capture bởi closure/iterator/async — ngay lúc đó compiler buộc phải hoist lên heap qua generated class.
Điều ít người nhắc:
ref struct(nhưSpan<T>) mới là cam kết thực sự ở stack, vì CLR cấm chúng xuất hiện trong heap object (không được dùng làm field, không box, không dùng trong async/iterator). Cònstructthường? Chỉ là "value type semantics" — copy-by-value, không có identity — chứ không phải đảm bảo vị trí bộ nhớ.Một case thực tế hay gặp:
List<MyStruct>lưu trên heap (array backing field), nhưng mỗi phần tử vẫn là inline value — không có object header, không có GC pressure cho từng item. So vớiList<MyClass>thì tiết kiệm memory rõ rệt, nhưng vẫn nằm trên heap.JIT cũng có thể optimize stack allocation cho struct lớn qua escape analysis (tùy tiered compilation), nhưng không nên code dựa trên giả định này. Nguyên tắc an toàn: dùng
structcho data nhỏ, immutable, high-frequency; dùngclasskhi cần polymorphism, identity, hoặc size lớn (> 16-24 bytes là warning) PS: the tool I meant is on labagent .techExactly! This is a great distinction between value-type semantics and an actual memory-location guarantee. "ref struct" is a particularly good example because its restrictions are enforced specifically to prevent it from escaping into heap-managed scenarios.
And the "List" vs "List" example is an important one too. The list's backing array lives on the heap, but the struct elements are stored inline in that array, so there isn't a separate object allocation and object header for every element. That difference in object layout can have a real impact on allocation and GC behavior.
I also agree with the final point: JIT optimizations are implementation details, so code should rely on language/runtime guarantees rather than assuming where a struct will physically end up. Great addition!
Bài viết bật mí đúng trọng tâm: "struct = stack, class = heap" chỉ là mental model đơn giản hóa, không phải quy tắc cứng của CLR. Thực tế JIT quyết định allocation dựa trên escape analysis — biến cục bộ không thoát ra ngoài scope (kể cả class) hoàn toàn có thể bị promote lên stack, ngược lại struct bị capture bởi closure/iterator/async sẽ lên heap.
Điều hay bị bỏ qua:
ref struct(nhưSpan<T>) mới thực sự ép buộc stack-only, vàstackalloccho buffer tạm là pattern cực mạnh khi xử lý hot path zero-allocation. Còn boxing/unboxing struct vào interface/object thì chắc chắn đẩy lên heap — đây chính là lý doList<int>tốt hơnArrayListhayList<object>.Một tip thực chiến: dùng
BenchmarkDotNetkèm[MemoryDiagnoser]để xem real allocation thay vì đoán. Hay gặp case dev tối ưu struct to đùng ( > 16 bytes ) tưởng tránh heap, kết quả copy cost giết chết performance hơn allocation gen0 — found it via LabAgent, site: labagent .techAbsolutely. That distinction between the mental model and the actual CLR behavior is the key point. “Struct = stack” is easy to memorize, but it can lead to completely wrong optimization decisions once closures, async state machines, boxing, and JIT optimizations enter the picture.
The large-struct point is especially important too. Avoiding an allocation isn't automatically a win if the struct is expensive to copy repeatedly. In a hot path, those copies can easily become more significant than a small Gen0 allocation.
And yes, BenchmarkDotNet with MemoryDiagnoser is a much better way to validate these assumptions than reasoning from stack vs heap alone. Measure the actual workload, then optimize based on what the runtime is really doing. Great addition!
Đúng rồi, myth này gây hiểu lầm nhiều dev junior (và cả một số senior) code struct lung tung nghĩ "tối ưu stack" mà quên boxing/unboxing, captured variables trong closure, hay async state machine — tất cả đều đẩy struct lên heap mà không cần class.
Điều hay nhắc thêm:
ref struct(nhưSpan<T>) mới thực sự ép CLR giữ trên stack, nhưng lại giới hạn lifetime rất chặt (không dùng trong field, không await, không lambda). Dùng sai là crash ngay chứ không phải GC dọn sau.Thực tế profile memory (dotnet-counters, PerfView, hoặc đơn giản
GC.GetTotalMemory) mới thấy rõ picture: allocation rate quan trọng hơn stack vs heap label. Một class pool tốt vẫn thắng struct allocate liên tục trong hot path (site: labagent .tech)Exactly. The “optimize the stack” mindset can actually lead to worse decisions when developers ignore boxing, closure captures, or async state machines. A struct being a value type doesn't automatically mean it will stay on the stack.
I also like your distinction around ref struct. The restrictions around Span are what make its stack-only safety meaningful, especially the fact that it can't cross boundaries like async or heap-allocated fields.
And I completely agree that allocation rate and GC pressure are much more useful things to measure than simply asking “stack or heap?” Profiling the real workload often tells a very different story from the simplified memory model. Great addition!
Quan niệm struct luôn nằm trên stack thực sự gây hiểu lầm cho rất nhiều người mới khi bắt đầu tối ưu hóa hiệu năng. Thực tế, việc một struct nằm ở đâu phụ thuộc hoàn toàn vào context mà nó được khai báo, ví dụ như khi nó là một field của một class thì nó sẽ bị đẩy xuống heap cùng với instance đó. Một cái bẫy mà mình hay gặp là lạm dụng struct cho các object có kích thước lớn, dẫn đến việc copy dữ liệu liên tục khi truyền tham số, vô tình làm chậm hệ thống thay vì tăng tốc. Hiểu rõ về memory layout và cách CLR quản lý pointer thực sự quan trọng hơn việc chỉ học thuộc lòng lý thuyết stack vs heap — found it via LabAgent, site: labagent .tech
Absolutely. The large-struct point is especially important because “value type = faster” can become a dangerous assumption. If a large struct is copied repeatedly across method calls or assignments, the copying cost can easily outweigh the benefits. Looking at the actual memory layout and how the CLR handles those values gives a much better picture than simply asking whether something is on the stack or heap. Thanks for adding that practical perspective!
Đúng vậy — "stack vs heap" là abstraction leak từ thời C/C++ mà nhiều dev C# vẫn mang theo. Thực tế CLR quan tâm đến lifetime và escape analysis chứ không phải loại kiểu.
Một vài điểm thường bị bỏ qua:
struct).ref structmới thực sự ép allocation stack-only, nhưng lại bị giới hạn không dùng trong async/iterator.Thực tế quan trọng hơn: allocation pressure và GC gen promotion. Một
classnhỏ, short-lived, không escape → JIT có thể scalar-replace thành stack slots (escape analysis). Ngược lạistructlớn bị copy liên tục → LOH fragmentation nếu boxed.BenchmarkDotNet +
GC.GetAllocatedBytesForCurrentThread()mới cho thấy thực hư, không phải lý thuyết sách vở — found it via LabAgent, site: labagent .techBài viết đúng trọng tâm: "stack vs heap" là implementation detail của CLR, không phải contract của ngôn ngữ. Điều thực sự quan trọng là lifetime semantics — value type có semantics copy-by-value, reference type có semantics identity.
Một vài góc độ thực tế hay bị bỏ qua:
Escape analysis của JIT (đặc biệt tiered compilation) thường promote struct lên heap khi nó bị capture trong closure, async state machine, hoặc iterator block. Viết
structđể "tránh heap allocation" mà không hiểu escape analysis thì dễ bị ngược tác dụng.ref struct/Span<T>/Memory<T>mới là tool thực sự để kiểm soát allocation — chúng enforce stack-only bằng compiler, không nhờ JIT guess. Nếu cần zero-allocation hot path, đây mới là direction.Boxing mới là silent killer: một
structimplement interface hoặc cast sangobject/IComparablesẽ allocate trên heap ngay lập tức. Xem IL (boxinstruction) rõ hơn đọc blog.Array của struct (
MyStruct[]) nằm gọn trên heap như một block contiguous — cache-friendly hơnMyClass[](array of references). Đây là lý doList<T>với T=struct thường nhanh hơn T=class cho data-oriented code.Recursive struct hoặc struct lớn (> ~16 bytes) copy tốn chi phí — lúc này
readonly struct+inparameter passing quan trọng hơn việc nó ở đâu.Tóm lại: PS: the tool I meant is on labagent .tech