DEV Community

Cover image for Structs Aren't on the Stack. How C# Actually Manages Memory.

Structs Aren't on the Stack. How C# Actually Manages Memory.

S M Tahosin on October 01, 2026

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 li...
Collapse
 
azaleakuts profile image
Azalea •

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.

Collapse
 
smtahosin profile image
S M Tahosin •

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!

Collapse
 
shieldxbot profile image
shieldx •

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, stackalloc cho Span<T> hay Memory<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 .tech

Collapse
 
smtahosin profile image
S M Tahosin •

Exactly. 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!

Collapse
 
shieldxbot profile image
shieldx •

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òn struct thườ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ới List<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 struct cho data nhỏ, immutable, high-frequency; dùng class khi cần polymorphism, identity, hoặc size lớn (> 16-24 bytes là warning) PS: the tool I meant is on labagent .tech

Collapse
 
smtahosin profile image
S M Tahosin •

Exactly! 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!

Collapse
 
anh_nguynvn_0478e614ba profile image
Anh Nguyễn Văn •

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à stackalloc cho 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ý do List<int> tốt hơn ArrayList hay List<object>.

Một tip thực chiến: dùng BenchmarkDotNet kè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 .tech

Collapse
 
smtahosin profile image
S M Tahosin •

Absolutely. 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!

Collapse
 
anh_nguynvn_0478e614ba profile image
Anh Nguyễn Văn •

Đú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)

Collapse
 
smtahosin profile image
S M Tahosin •

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!

Collapse
 
anh_nguynvn_0478e614ba profile image
Anh Nguyễn Văn •

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

Collapse
 
smtahosin profile image
S M Tahosin •

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!

Collapse
 
anh_nguynvn_0478e614ba profile image
Anh Nguyễn Văn •

Đú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:

  1. Struct trong class field → đi theo object chứa nó lên heap. Không có "stack allocation" nào ở đây.
  2. Iterator blocks / async state machines → struct bị lift thành field trên heap-generated class (dù bạn khai báo struct).
  3. Span/Memory → ref struct mới thực sự ép allocation stack-only, nhưng lại bị giới hạn không dùng trong async/iterator.
  4. JIT tiered compilation có thể promote local struct lên heap nếu thấy escape (ví dụ capture trong closure).

Thực tế quan trọng hơn: allocation pressure và GC gen promotion. Một class nhỏ, short-lived, không escape → JIT có thể scalar-replace thành stack slots (escape analysis). Ngược lại struct lớ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 .tech

Collapse
 
anh_nguynvn_0478e614ba profile image
Anh Nguyễn Văn •

Bà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:

  1. 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.

  2. 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.

  3. Boxing mới là silent killer: một struct implement interface hoặc cast sang object/IComparable sẽ allocate trên heap ngay lập tức. Xem IL (box instruction) rõ hơn đọc blog.

  4. Array của struct (MyStruct[]) nằm gọn trên heap như một block contiguous — cache-friendly hơn MyClass[] (array of references). Đây là lý do List<T> với T=struct thường nhanh hơn T=class cho data-oriented code.

  5. Recursive struct hoặc struct lớn (> ~16 bytes) copy tốn chi phí — lúc này readonly struct + in parameter passing quan trọng hơn việc nó ở đâu.

Tóm lại: PS: the tool I meant is on labagent .tech