DEV Community

Cover image for Understanding Memory in .NET
Alexey Popov
Alexey Popov

Posted on

Understanding Memory in .NET

Introduction

With the rise of large language models (LLMs), the distinction between writing code and programming has become more apparent than ever.

To an LLM, source code is merely text. To a computer, it is a sequence of instructions. A developer’s task is to understand what actually happens when those instructions are executed.

This is why studying a programming language often begins not with syntax, but with the program’s execution model. One of the most important parts of that model is memory: how data is represented, how it is copied, how long it remains alive, and how different pieces of data interact while a program is running.

In this article, we will explore two primary memory regions used by .NET applications, why they serve different purposes, and how this design influences both the behavior and performance of .NET applications.

Memory Regions

When a .NET application starts, the operating system creates a process and its first execution thread (primary thread). The Common Language Runtime (CLR) is then initialized and takes responsibility for executing the application, including managing the Managed Heap and the Garbage Collector [1].

During execution, additional threads may be created by the application or used by the runtime. Each thread has its own Stack and CPU register state [1].

As a result, a running .NET application relies on two primary memory regions:

  • Stack - private to each thread and used for method execution and local state [1]
  • Managed Heap - shared by threads in the process and managed by the CLR's Garbage Collector [2]

But why does the platform need two different memory regions?

Because different kinds of data have different requirements regarding access speed, lifetime, and storage.

Data Representation

In .NET, there are two fundamental ways of representing data: Value Types and Reference Types [3].

Value Types

Value Types are used when only the value itself matters.

Consider a simple example:

int x = 10;
int y = x;
y = 20;
Enter fullscreen mode Exit fullscreen mode

After the assignment, the program contains two independent copies of the value. Changing one variable has no effect on the other because each variable stores its own value [3]:
Figure 1

Figure 1. Assigning a Value Type creates an independent copy of the value

From the CLR’s perspective, such data does not need a distinct identity because only the value matters. Its exact storage location is therefore determined entirely by the context in which it is used.

This is why small, independent pieces of data (numbers, coordinates, dates, dimensions, or colors) are commonly represented as Value Types in .NET.

For example, an application may store the coordinates of millions of points on a map or the vertices of a three-dimensional model. Each point is merely a set of numbers. Copying such values is a natural process and does not require the creation of separate objects with their own identities.

Reference Types

Reference Types, by contrast, are used when not only the information matters, but also the identity of the entity to which that information belongs.

For example:

var user1 = new User();
var user2 = user1;
user2.Age = 30;
Enter fullscreen mode Exit fullscreen mode

After the user1 and user2 assignments, both variables refer to the same object [3]:
Figure 2

Figure 2. Assigning a Reference Type copies the reference, not the object

Such an object may be passed between methods, returned from them, stored in collections, and remain alive far longer than any individual method call. Its lifetime must therefore not depend on a particular stack frame. Instances of Reference Types are allocated on the Managed Heap [2].

Reference Types are therefore well suited to modeling entities shared by multiple parts of a program. An object of User class, for example, may be displayed in the user interface, stored in a collection, passed to a service, and used by an authentication system. All of these components must work with the same object instance rather than with independent copies.

The same principle underlies most dynamic data structures. A linked list, for instance, consists of nodes, each of which stores a reference to the next node:

class Node
{
    public int Value;
    public Node? Next;
}
Enter fullscreen mode Exit fullscreen mode

Reference semantics make this representation natural: nodes can be connected and rearranged by changing references without copying the nodes themselves.

Choosing between Value Types and Reference Types is therefore not primarily a matter of performance or coding style - it is a matter of modeling data correctly. As a general modeling principle, Value Types are well suited to independent values, while Reference Types are well suited to entities whose identity and shared state matter.

Boxing and Unboxing

.NET is built around an unified type system whose root is System.Object [3].

As a result, a value of any type can be treated as an object when necessary [4].

For example:

List<object> values = new();

values.Add("Alice");
values.Add(42);
values.Add(DateTime.Today);
Enter fullscreen mode Exit fullscreen mode

string is already a Reference Type, but 42 and DateTime.Today are Value Types. So how can they all be stored in the same collection?

Boxing

Boxing is the implicit conversion of a Value Type to object or to an interface implemented by that type [4]:

int i = 123;
object o = i;

i = 456;
Enter fullscreen mode Exit fullscreen mode

During boxing, the CLR [4]:

  1. Allocates memory on the Managed Heap.
  2. Creates a new object.
  3. Copies the value into that object.

Figure 3

Figure 3. Boxing creates a new object on the Managed Heap containing a copy of the value

Boxing does not move a value from the Stack to the Managed Heap. Instead, the CLR allocates a new object on the heap and copies the value into it [4]. The original value remains independent of the boxed copy.

Every boxing operation therefore creates an additional heap object. Although allocating memory on the Managed Heap is generally fast, repeated boxing creates additional allocations and can increase the amount of work the Garbage Collector must eventually perform [4].

Unboxing

After boxing, the value is represented as an object. To use it again as an int, DateTime, or any other Value Type, it must first be unboxed [4]:

object val = 42;
int num = (int)val;
num = 99;
Enter fullscreen mode Exit fullscreen mode

During unboxing, the CLR [4]:

  1. Verifies that the object contains a value of the expected type.
  2. Copies that value into a new variable.

Figure 4

Figure 4. Unboxing copies the value from the boxed object into a new Value Type variable

The boxed object itself remains on the Managed Heap until it eventually becomes unreachable, and its memory can be reclaimed by the Garbage Collector.

Garbage Collector

As long as an object exists on the Managed Heap, the CLR must eventually determine when its memory can be reclaimed. This responsibility belongs to the Garbage Collector, which serves as .NET's automatic memory manager [2].

When a garbage collection is triggered, the Garbage Collector examines the object graph starting from a set of Garbage Collector Roots, determining which objects are still reachable. Objects that cannot be reached from these roots are considered garbage and their memory can be reclaimed [2].

Allocating objects on the managed heap is generally very fast because, while space is available, allocation primarily involves advancing a pointer [2]. However, increasing the number and rate of heap allocations increases the amount of work the Garbage Collector may need to perform.

Moreover, if an object remains reachable - for example, through a static field or a collection - the GC considers it alive and will not reclaim its memory. Keeping references to objects that are no longer logically needed can therefore cause memory leaks even in managed applications [2].

Conclusion

Looking at the complete picture, it becomes clear that these mechanisms are not isolated features but parts of a single execution model.

Understanding this model allows us to see .NET not as a collection of unrelated features, but as a coherent system whose components naturally build upon one another. This is why understanding the memory model forms the foundation for understanding many other parts of the modern .NET platform, including Span<T>, stackalloc, async/await, yield, and many others.

References

  1. Microsoft Learn - Threads and threading
  2. Microsoft Learn - Fundamentals of garbage collection
  3. Microsoft Learn - Common type system
  4. Microsoft Learn - Boxing and Unboxing

Top comments (0)