Introduction
Understanding how .NET manages memory helps you write efficient applications and diagnose performance problems. Unlike languages where you manually allocate and free memory, .NET uses a garbage collector that automatically reclaims unused objects. This automation prevents common errors like use-after-free bugs and memory leaks from forgotten deallocations.
The garbage collector isn't magic, though. It makes assumptions about object lifetimes and access patterns. When your code violates these assumptions, you might see performance degradation or even memory leaks despite automatic management. Knowing how the system works lets you write code that cooperates with the garbage collector.
You'll learn how objects are allocated and collected, how the generational garbage collector optimizes performance, what causes objects to survive collection, and how to properly dispose of resources that the garbage collector can't handle automatically.
Object Allocation and the Managed Heap
When you create an object with the new keyword, .NET allocates memory from the managed heap. The heap maintains a pointer to the next available address. Allocating a new object simply advances this pointer by the object's size. This makes allocation extremely fast compared to traditional malloc implementations.
Small objects allocate on the small object heap, while large objects over 85,000 bytes go on the large object heap. This separation prevents fragmentation of small object allocations caused by interspersed large objects.
using System;
public class Customer
{
public int Id { get; set; }
public string Name { get; set; } = "";
public DateTime Created { get; set; }
public Customer(int id, string name)
{
Id = id;
Name = name;
Created = DateTime.UtcNow;
Console.WriteLine($"Customer {id} allocated");
}
}
// Allocate objects
var customer1 = new Customer(1, "Alice");
var customer2 = new Customer(2, "Bob");
// These objects live on the managed heap
Console.WriteLine($"Customer1: {customer1.Name}");
Console.WriteLine($"Customer2: {customer2.Name}");
// When these variables go out of scope and no other
// references exist, objects become eligible for collection
Each allocation advances the heap pointer. When the heap fills up, the garbage collector runs to reclaim memory from unreachable objects. This allocation strategy is much faster than searching free lists, which traditional memory allocators must do.
How Garbage Collection Works
The garbage collector determines which objects are still reachable by tracing from root references. Roots include local variables on thread stacks, static fields, and CPU registers. Any object reachable from these roots is considered alive. Everything else is garbage.
Collection happens in phases. First, the collector marks all reachable objects. Then it compacts the heap by moving live objects together and updating all references to point to new locations. Finally, it resets the allocation pointer to the end of the compacted region.
using System;
public class MemoryDemo
{
public static void ShowGCInfo()
{
Console.WriteLine($"Gen 0 collections: {GC.CollectionCount(0)}");
Console.WriteLine($"Gen 1 collections: {GC.CollectionCount(1)}");
Console.WriteLine($"Gen 2 collections: {GC.CollectionCount(2)}");
Console.WriteLine($"Total memory: {GC.GetTotalMemory(false) / 1024}KB");
}
}
// Create temporary objects
void CreateTemporaryObjects()
{
for (int i = 0; i < 10000; i++)
{
var temp = new Customer(i, $"Customer{i}");
// temp becomes unreachable when loop iterates
}
}
Console.WriteLine("Before allocation:");
MemoryDemo.ShowGCInfo();
CreateTemporaryObjects();
Console.WriteLine("\nAfter allocation:");
MemoryDemo.ShowGCInfo();
// Force collection for demonstration
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Console.WriteLine("\nAfter collection:");
MemoryDemo.ShowGCInfo();
Most objects in CreateTemporaryObjects become unreachable immediately after allocation. The garbage collector reclaims them during the next collection. This pattern of short-lived objects is extremely common and the generational collector optimizes for it.
Understanding GC Generations
.NET divides the heap into three generations based on object age. Generation 0 contains newly allocated objects. When Gen 0 fills up, a collection occurs. Objects that survive move to Gen 1. Objects that survive Gen 1 collection move to Gen 2.
Most objects die young, so Gen 0 collections happen frequently but finish quickly because they only scan new objects. Gen 2 collections scan the entire heap and happen much less frequently. This generational approach dramatically improves performance.
using System;
public class GenerationDemo
{
public static void ShowObjectGeneration(object obj, string name)
{
int generation = GC.GetGeneration(obj);
Console.WriteLine($"{name} is in generation {generation}");
}
}
// Short-lived object
var tempObject = new Customer(1, "Temporary");
GenerationDemo.ShowObjectGeneration(tempObject, "Temp object");
// Force Gen 0 collection
GC.Collect(0);
GenerationDemo.ShowObjectGeneration(tempObject, "After Gen 0 GC");
// Force Gen 1 collection
GC.Collect(1);
GenerationDemo.ShowObjectGeneration(tempObject, "After Gen 1 GC");
// Force full collection
GC.Collect();
GenerationDemo.ShowObjectGeneration(tempObject, "After full GC");
// Create object that will stay in Gen 0
var newObject = new Customer(2, "New");
GenerationDemo.ShowObjectGeneration(newObject, "New object");
Output:
Temp object is in generation 0
After Gen 0 GC is in generation 1
After Gen 1 GC is in generation 2
After full GC is in generation 2
New object is in generation 0
Objects promote through generations as they survive collections. Long-lived objects eventually reach Gen 2 where they're collected infrequently. This prevents the collector from repeatedly scanning objects that will likely remain alive.
Managing Unmanaged Resources with IDisposable
The garbage collector only handles managed memory. Resources like file handles, database connections, and network sockets need explicit cleanup. The IDisposable interface provides a standard pattern for deterministic resource cleanup.
The using statement automatically calls Dispose when the variable goes out of scope, ensuring cleanup even if exceptions occur. This is much safer than manually calling Dispose in try-finally blocks.
using System;
using System.IO;
public class LogWriter : IDisposable
{
private StreamWriter? writer;
private bool disposed = false;
public LogWriter(string path)
{
writer = new StreamWriter(path);
Console.WriteLine($"Opened log file: {path}");
}
public void WriteLog(string message)
{
if (disposed)
{
throw new ObjectDisposedException(nameof(LogWriter));
}
writer?.WriteLine($"{DateTime.Now}: {message}");
}
public void Dispose()
{
if (!disposed)
{
writer?.Dispose();
writer = null;
disposed = true;
Console.WriteLine("Log file closed");
}
}
}
// Using statement ensures Dispose is called
using (var log = new LogWriter("app.log"))
{
log.WriteLog("Application started");
log.WriteLog("Processing data");
}
// Dispose called automatically here
Console.WriteLine("File closed and resources released");
The using statement generates a try-finally block that calls Dispose even if exceptions occur. This guarantees resource cleanup without verbose error handling code. Modern C# allows using declarations without braces, disposing at the end of the containing scope.
Try It Yourself
Here's a complete example demonstrating memory allocation, garbage collection, and proper resource disposal.
using System;
public class DataBuffer : IDisposable
{
private byte[] buffer;
public int Size => buffer.Length;
public DataBuffer(int size)
{
buffer = new byte[size];
Console.WriteLine($"Allocated {size} byte buffer");
}
public void Dispose()
{
Console.WriteLine("Disposing buffer");
buffer = null!;
}
}
Console.WriteLine("=== Memory Lifecycle Demo ===\n");
Console.WriteLine("Initial state:");
MemoryDemo.ShowGCInfo();
// Create and immediately abandon objects
Console.WriteLine("\nCreating temporary objects...");
for (int i = 0; i < 1000; i++)
{
var temp = new Customer(i, $"User{i}");
}
Console.WriteLine("After temporary allocations:");
MemoryDemo.ShowGCInfo();
// Use disposable resources properly
Console.WriteLine("\nUsing disposable resources:");
using (var buffer = new DataBuffer(1024))
{
Console.WriteLine($"Buffer size: {buffer.Size}");
}
Console.WriteLine("\nFinal state:");
MemoryDemo.ShowGCInfo();
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
</Project>
Run with dotnet run. Watch how temporary objects increase memory usage and how the garbage collector reclaims it. The disposable pattern ensures immediate cleanup of the buffer rather than waiting for garbage collection.
Memory Management Best Practices
Let the garbage collector do its job: Don't call GC.Collect unless you have specific, measured reasons. The collector's heuristics usually make better decisions than manual collection. Forcing collection at the wrong time can actually hurt performance.
Minimize object allocations in hot paths: Creating millions of short-lived objects creates garbage collection pressure. Use object pooling for frequently allocated objects, consider stack allocation with Span<T> for temporary data, and reuse buffers when possible.
Unsubscribe from events: Event handlers create strong references that prevent garbage collection. Always unsubscribe when you're done listening, especially for long-lived event sources like static objects or singletons. Consider weak event patterns for complex scenarios.
Use using statements for IDisposable: Resources wrapped in IDisposable need explicit cleanup. Using statements make this automatic and exception-safe. Modern C# using declarations further simplify the syntax while maintaining safety.
Be careful with large objects: Objects over 85KB go to the large object heap, which isn't compacted by default. Creating and discarding many large objects causes heap fragmentation. Pool large buffers or use ArrayPool<T> for temporary allocations.
Avoid finalizers unless necessary: Finalizers make garbage collection slower and more complex. They also delay object collection because objects with finalizers need two collection cycles. Use IDisposable for cleanup instead, and only add finalizers as a safety net for unmanaged resources.