Memory Management in C#

Adam Thorn
XRLO — eXtended Reality Lowdown
12 min readJul 22, 2020

The goal of this article is to give a high-level overview of application memory and it’s management in .Net. The languages within the .Net family are managed, which means that in addition to a runtime environment, they can take advantage of a number of services the runtime provides; not least of which is the Garbage Collector. However, while the garbage collector does an amazing job of managing memory on our behalf — something you have to do yourself in unmanaged languages such a C or C++ — there are still times when this process gives undesired results.

In addition to the subject of memory management, we will also discuss the structure of memory, how it is divided into logical areas and how our data is stored. Understanding this will help you to write more efficient applications.

Throughout this article, we give examples of the size of these areas of memory and of the memory addresses they contain. These sizes will change depending on the operating system, so here we assume a 32-bit Windows machine.

Variables

Variables are used to store data. When we declare variables, we do so using meaningful names that hopefully describe the data being stored. Memory is broken up into ‘slots’, each of which is one byte in size and has its own unique address. When a new variable is declared, a contiguous block of these slots is allotted for holding its data. The number of memory slots allocated depends on the variable type. For example, an int data type requires 4 bytes, or 4 slots of memory to hold it, a char data type requires 1. The variable holds the address of the first slot of contiguous memory it has been allocated. The compiler uses this address and the data type to determine which slots contain the variables data.

Memory addresses are stored as hexadecimal, but the following diagrams are shown in decimal to make it easier to read.

Variables fall into two categories: value type and reference type. It is this category that often determines where in memory they will be stored.

Value type variables store the data directly in the associated memory space, as shown in the figure above. Reference type variables store a reference to an object. If a reference type variable holds a valid reference (one that is not null), then it must be for an object that is of the type specified by the variable. While a value type variable will point to a space in memory large enough to hold the value, a reference type variable will point to a space in memory large enough to hold a reference to the object. Regardless of how large the object is, that reference will occupy a fixed size, for example, 4 bytes in a 32-bit Windows machine.

The diagram above shows a string reference variable. In this example, the address of the object is stored on the stack. The object itself with the string “hello world”, is stored on the heap.

Memory Structure

When an application is run, each process is allocated a block of virtual memory space to use. On a 32-bit computer, an application has 2GB of virtual address space, which is shared by these processes. These virtual memory addresses do not map directly to the physical memory. This mapping is handled by the page table, a structure which the system maintains for each process. Virtual memory is used as it provides application isolation, with each in their own address space. This means one application can write to and read from their virtual block of memory, without worrying about the possible impact on other applications.

Credit: Sushovon Sinha, Microsoft Community

This virtual memory space is split into multiple segments: Text, Initialised Data, Uninitialised Data, Stack, and the Heap. In this article, we are mostly concerned with the Stack and Heap segments. However, the Text, Initialised, and Uninitialised Data segments are briefly outlined below.

Text Segment

The text segment (also known as the code segment) is an area of the virtual memory that contains executable instructions. This segment cannot be changed by the application and is therefore read-only and of a fixed size. It is worth noting that any const instance variables are replaced inline by the compiler and are stored with the code here.

Initialised Data Segment

Also known as the data segment, this portion of the virtual memory stores the global variables that have been initialised by the developer. This segment is not read-only as the values can be changed at runtime.

Uninitialised Data Segment

Also known as the BSS (Block Started by Symbol) segment, this portion of the virtual memory stores uninitialised data. Again, this segment is read-only as the values must be set at runtime.

Stack Segment

The stack is responsible for keeping track of code execution. It does this using stack frames, which contain all the data for a single function call.

Heap

The heap is a shared area of virtual memory used for dynamic allocations.

The following code sample highlights where different types of variables are stored.

public class MyClass
{
public struct MyStruct
{
public string structString;
public float structFloat;
}
// Text: const variables.
private const int myInt = 123;
// Data: global instance variables.
private const string myString = “Hello”;
// Heap: Instance variables for reference types.
private string anotherString;
// Heap: Value type instance variables are stored
// with class instance.
private MyStruct structA;
public void AFunctionThatDoesSomething()
{
// Stack: Local value type variables.
double myLocalDbl = 0.123;
// Stack: Local value type instance variables
// are stored with function.
MyStruct structB = new MyStruct();
MyOtherClass otherClass = new MyOtherClass();
otherClass.AnotherFunction(47.5f, ref structB);
}
}
public class MyOtherClass
{
// BSS: Uninitialised global array.
private float[] myFloats;
// Heap: All static variables regardless of
// whether value or reference type.
private static int myStaticInt;
// Stack: Method parameters. Stored with calling
// function if they use ref keyword.
public void AnotherFunction(
float floatParam,
ref MyStruct structRef)
{
structRef.structString = “My new struct.”;
structRef.structFloat = floatParam;
}
}

Stack

When an application is run, the operating system allocates a stack for every process or thread of execution. This stack exists for the lifetime of the thread to which it is associated. The stack is responsible for keeping track of code execution. It does this using stack frames, which contain all the data for a single function call. This data includes a function’s local variables, the return address, and any arguments passed by the calling function.

The stack grows downwards towards the lower memory addresses, so the ‘top’ of the stack is, therefore, the lowest memory address with live data. As its name suggests, the stack is a LIFO (Last In First Out) stack data structure. This makes it very efficient as it is using ‘push’ and ‘pop’ commands to manage a single stack pointer, which is always pointing to the last item.

In addition to the stack pointer, there is a frame pointer, which ‘points’ to a fixed location within each stack frame. The memory address of each local variable and argument is relative to this frame pointer. As can be seen above, frame pointers form a linked list and can be used to navigate the stack — which is exactly how debuggers get their stack traces.

The diagram above shows a simplified representation of how stack frames are created with program execution. The creation of each frame is broken up into two phases: the frame prologue and the frame epilogue.

Frame prologue

The frame prologue happens at the beginning of the function call and is responsible for setting up a stack frame for the function being called. The Program Counter (PC) — a processor register that stores the current location in the program — is pushed onto the stack and becomes the stack frame return address. The frame pointer for the calling function is saved and the frame pointer for the called function is set — remember the linked list mentioned above. The stack frame size is calculated and space reserved for storing local variables and parameters, which are then pushed onto the stack.

Frame epilogue

The frame epilogue is what happens last to the called function and is responsible for restoring the calling functions stack frame. The stack pointer and program execution returns to where it was prior to this function being called.

Heap

The heap is an area of virtual memory used for dynamic allocations, that is, allocations that happen at runtime. While an application may have multiple stacks — one for each thread of execution — there will be only one heap which is shared by all the application processes.

Before we go into any more detail about the heap, we first need to introduce the Common Language Runtime (CLR), as it is the CLR, and the Garbage Collector, in particular, that manages the Heap.

Common Language Runtime (CLR)

The CLR is responsible for managing the execution of .NET code. It does this by providing a runtime environment for our code, as well as various services. The steps involved in executing our code are divided broadly into two stages: compilation and runtime.

Compilation

During compilation, the language specific compiler compiles our source code into Microsoft Intermediate Language (MSIL) and corresponding Metadata. MSIL is also known as Common Intermediate Language (CIL). As the .NET development environment includes multiple languages, the Metadata is particularly important. It describes the language, environment, version of our classes and the libraries they use.

Runtime

The CLR includes the Just-In-Time Compiler (JIT) which uses the Metadata to convert the MSIL into native machine code to be executed by the CPU. As its name suggests, the JIT compiles code as it is needed, it does not compile it all at once.

In addition to the JIT, the CLR provides the following services;

  • Common Language Specification (CLS).
  • Common Type Specification (CTS).
  • Garbage Collector (GC).

The CLS and CLT are both concerned with providing interoperability and type safety between the different languages in the .NET family.

Garbage Collector (GC)

As mentioned above, it is the Garbage Collector (GC), which is responsible for managing the heap. In C and C++ you have to manually handle the allocation and release of memory, as well as put safeguards in place to protect against issues that may arise from the implementation of this. In .NET, the garbage collector takes care of a number of important memory-related tasks on our behalf.

  • Allocates objects on the heap.
  • Deallocates objects and releases memory.
  • Compacts the heap after removing unreachable objects.
  • Automatically creates new objects with default values.
  • Provides memory safety by ensuring one object cannot access the contents of another.

When the CLR is first loaded, the GC allocates two initial managed heap segments: the Small Object Heap (SOH) and the Large Object Heap (LOH). Most objects when they are created will go onto the SOH, though objects >= 85,000 bytes will be allocated to the LOH. The SOH heap segment is logically broken down further into generations, which makes the allocation and deallocation of objects to memory far more efficient. Generations are discussed in more detail below.

As discussed in the section on memory structure, all memory allocated to an application, regardless of whether it is being used by stack, heap, text or data segments, is virtual. This virtual memory is mapped to the physical memory via the page table. All virtual memory in the heap can be in one of three states;

  1. Free — This block of memory has no reservations and is available for allocation.
  2. Reserved — This virtual block has been reserved by an allocation request. Data cannot be stored in this block until it has been committed to the physical memory.
  3. Committed — This block of memory has been allocated to the physical storage and an allocation can be made to it.

Allocations

The GC maintains a pointer to the next block of available space in the heap. When the process starts, it points to the base address. As objects are created, they are assigned contiguous blocks of memory, with objects placed one after another. At this point, allocation is very cheap, as it simply involves adding a value (the size of the allocated object) to the pointer to move it to the next block. However, it gets less performant as objects are deallocated and their memory released.

Deallocation and Memory Release

The GC creates a graph that maps all objects that are reachable. It creates this graph using a list of active objects that the JIT and runtime maintain. These active objects are reference objects, that is, objects that hold a reference to an object on the heap. The application root objects include the static fields, local variables, and parameters that are in a threads stack and CPU registers. Objects that are not in the graph are unreachable by these roots and are therefore considered garbage. Once unreachable objects have been found, the GC can then perform a collection, releasing their memory.

Memory Compacting

After the GC has performed a collection, if all objects survive, then nothing further is done. However, if the number of unreachable objects passes a threshold, the GC compacts the memory. It does this by moving the objects and compacting the memory, removing the holes left by unreachable objects. The GC then updates the pointers so that they now point at the new address, and positions the managed heap pointer to the position after the last object. It is worth noting that for performance reasons, the LOH is not compacted

What Triggers a Garbage Collection?

A garbage collection is triggered when one of the following occurs:

  • The system has low physical memory.
  • The virtual memory used by allocated objects on the heap passes a threshold.
  • GC.Collect is called.

The frequency of collections is directly proportional to the number of allocations and the amount of memory surviving a collection. To speed up the process of garbage collection and compacting memory, the heap is divided into logical segments called Generations.

Generations

The heap is composed of three generations: Generation 0, Generation 1 and Generation 2. All new objects (except large ones over 85,000 bytes) are allocated to generation 0. When the GC performs a collection, it does so by generation. After performing a collection on generation 0, any objects that are still reachable, and therefore alive, are promoted to generation 1. When the GC performs a generation 1 collection, any objects that survive are promoted to generation 2. After a generation 2 collection, objects will remain in generation 2. When the GC performs a collection on a specific generation, it also performs one on the generations beneath it, so a generation 2 collect will also collect generations 1 and 0, which is why it’s referred to as a full garbage collection.

Generations are used because they improve performance. New objects tend to be short-lived and created with other related objects that have the same lifespan. Due to this regular allocation and deallocation of short-term objects, it is more efficient to perform a collection on a small region of the heap (generation 0) rather than on the whole thing. So when a collection is triggered, generation 0 is evaluated first. If additional memory is still required, generation 1 is evaluated and so on.

When the GC is allocating space for a large object it first has to scan for a block of contiguous memory large enough to accommodate it. If none is found, the GC attempts to acquire more memory segments from the OS. If that fails, it triggers a collection of generation 2 (which also collects generation 1 and 0) in the hope of freeing up more space.

When the user creates a new object, they have no control over which generation it is allocated to, only the GC can allocate objects to generations 1 and 2. The LOH forms part of generation 2 and is collected along with it. It is worth noting that for performance reasons, memory in the LOH is not compacted and will become fragmented as a result.

Garbage collection can often feel like an opaque black box in managed languages, with incomprehensible behaviour. Understanding how garbage collection works at a high level, however, is often instrumental in understanding the performance profile of your application.

I hope that this article has shed some light on memory management, the structure of memory, how data is stored and the inner workings of garbage collection in .Net, so that the next time your application stalls due to GC, you’ll know why!

XRLO: eXtended Reality Lowdown is brought to you by Magnopus, an immersive design and innovation company. If you want to talk tech, ideas, and the future, get in touch here.

Your claps and follows help us understand what our readers like. If you liked our articles, show them some love! ❤️

We’d also love to hear from you. If you’re passionate about all things XR, you can apply to contribute to XRLO here. ✍️

--

--