DEV Community

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

Posted on Edited on

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

Comments explain escape analysis and closures

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.

The Textbook Myth vs How C# Actually Stores Data

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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];
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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:

Memory Anatomy: Class vs Struct on 64-bit CLR

The Anatomy of ClassPoint

When you instantiate new ClassPoint(), you pay for three distinct components:

  1. The Reference Pointer (8 bytes): Stored in your local variable, holding the 64-bit memory address of the heap object.
  2. The SyncBlockIndex (8 bytes): An object header word used by the CLR for thread synchronization locks and default hash codes.
  3. The MethodTable Pointer (8 bytes): A pointer pointing to the runtime type descriptor for ClassPoint (used for virtual method dispatch and type checking).
  4. The Fields (8 bytes): 4 bytes for X, 4 bytes for Y.

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
Enter fullscreen mode Exit fullscreen mode

What just happened behind the scenes?

The Boxing Trap: The Silent Garbage Collection Killer

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:

  1. It allocates a brand new 24-byte object on the Managed Heap.
  2. It writes the System.Int32 MethodTable pointer and SyncBlockIndex into the header.
  3. It copies the raw bits of 42 into 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!
}
Enter fullscreen mode Exit fullscreen mode

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();
}
Enter fullscreen mode Exit fullscreen mode

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:

Cache Locality: Why C# Struct Arrays Outperform Pointer Chasing

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:

  1. 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.
  2. You read pointer 2 and jump to heap address 0x890. Another cache miss. Another 150 cycles wasted.
  3. 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.

Modern C# Superpowers: Span 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
}
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 struct can only ever live on the stack.
  • It cannot be boxed into an object.
  • It cannot be a field of a regular class or regular struct.
  • It cannot be used across await points 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!
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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!
}
Enter fullscreen mode Exit fullscreen mode

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);
}
Enter fullscreen mode Exit fullscreen mode

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)

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