Back to Blog
C#

C# Memory Usage: Diagnosing and Reducing Allocations

c# memory usage: Practical techniques to identify, reduce, and manage C# memory usage: profiling, allocation patterns, pooling, and avoiding common GC traps.

memory managementGC pressurearray poolingperformancedebugging
A visual representation of a C# heap with managed objects and a magnifying glass analyzing allocation hotspots.

When a C# application shows high memory usage, the cause is often not a single leak but a pattern of allocation that keeps the garbage collector busy. High allocation rates can lead to frequent Gen0 collections, increased pause times, and larger committed heap. Understanding where those allocations come from is the first step to reducing them. This article covers practical techniques for diagnosing and lowering C# memory usage, with a focus on managed heap allocation and GC pressure.

Profiling Allocation Hotspots

Before changing any code, you need data. A profiler that tracks allocations—such as Visual Studio's .NET Object Allocation tool or dotMemory—can show you the types that are allocated most frequently and the code paths that allocate them.

For example, a simple console application that processes a large log file might show heavy allocations of string and char[]. The call tree tells you whether those strings come from parsing, splitting, or building output. Without this data, you risk optimizing code that has little impact.

You can also use ETW (Event Tracing for Windows) with the dotnet-trace tool to capture allocation rate and GC events. The key metrics are:

  • Allocation rate in bytes per second
  • GC count and type (Gen0, Gen1, Gen2)
  • Time spent in GC

If the allocation rate is enormous but the heap stays small, you're seeing Gen0 collections that recover quickly. If the heap grows steadily, you may have a genuine leak or excessive large object heap (LOH) usage.

The Managed Heap and the Large Object Heap

The managed heap is divided into generations. Small objects go to Gen0, survive into Gen1 and Gen2 after passing collections. Objects larger than 85,000 bytes go directly to the large object heap (LOH), which is collected only in a full GC. High LOH usage can cause memory fragmentation and trigger frequent full GCs.

To keep the LOH under control, avoid allocating many large byte arrays or string builders on a hot path. If you need a large buffer temporarily, use ArrayPool<byte> or ArrayPool<char> from the System.Buffers package. The pool reuses large arrays, preventing them from being allocated and deallocated repeatedly.

byte[] buffer = ArrayPool<byte>.Shared.Rent(1024 * 1024); // 1 MB try { // Use the buffer FillFromStream(stream, buffer); } finally { ArrayPool<byte>.Shared.Return(buffer); }

In this example, Rent provides an array of at least the requested size. Reusing the array avoids allocation on the LOH and reduces GC pressure. The finally block ensures the buffer is returned even if an exception occurs. A common mistake is to assume the rented array has exactly the requested length; check buffer.Length because the pool may return a larger array.

Avoiding Common Allocation Traps

Many allocations are avoidable. Some patterns are so common they've become idioms, but they still generate garbage.

Boxing and Unboxing

Boxing occurs when a value type is converted to object or to an interface it implements. This creates a new object on the heap. For instance, passing an int to a method that expects object boxes it.

void Log(object item) { Console.WriteLine(item); } int count = 42; Log(count); // Boxes the int

The Log method receives a boxed Int32. Every call allocates a new object. If this happens inside a loop processing thousands of items, the allocations add up. One fix is to provide a generic overload:

void Log<T>(T item) { Console.WriteLine(item); }

Now the call uses generic type inference and avoids boxing for value types when the implementation provides constrained generic methods. Note that Console.WriteLine itself has an overload for int, so the first example would actually call that overload directly without boxing, but the pattern still exists in other APIs.

String Concatenation in Loops

String building in a loop creates a new string each concatenation, which is often quadratic in time and causes many short-lived allocations. Use a StringBuilder instead.

var builder = new StringBuilder(); for (int i = 0; i < data.Count; i++) { builder.Append(data[i]); // Avoids repeated string allocation } string result = builder.ToString();

Even with StringBuilder, calling ToString() at the end allocates a new string. If you need to return the string, that allocation is necessary. But if you're writing to a Stream, consider writing directly to the stream without building the whole string first.

Capturing Variables in Lambdas and Closures

When a lambda captures local variables, the compiler often creates a display class that holds those variables. That object is allocated, and if the delegate is repeatedly created, each iteration may allocate a new closure.

var numbers = Enumerable.Range(0, 1000000); int threshold = 5; var filtered = numbers.Where(n => n > threshold); // closure captures threshold

The closure captures threshold. When filtered is enumerated, the delegate is created once. However, if you create the delegate inside a loop or a hot path, those allocations can be significant. Where possible, avoid capturing local variables in frequently executed lambdas, or use a static lambda if no capture is needed.

Using Structs When Appropriate

If you have a small data type that is frequently allocated as a class, converting it to a struct can reduce allocations dramatically, because structs are value types stored inline. But the trade-off is that passing a struct around copies data. In performance-critical paths, using ref or in parameters can avoid copies.

public readonly struct Point { public int X { get; } public int Y { get; } public Point(int x, int y) { X = x; Y = y; } } static double Distance(in Point a, in Point b) { double dx = a.X - b.X; double dy = a.Y - b.Y; return Math.Sqrt(dx * dx + dy * dy); }

Using in passes a read-only reference, avoiding a copy and retaining the benefit of value type semantics. This is useful when you have many points and need to compute distances without allocating objects.

However, structs are not a silver bullet. If you place a struct in an interface or object context, it gets boxed. So struct implementations of interfaces are problematic when stored in a List<IMyInterface>.

Pooling Objects and Buffers

The .NET runtime provides ArrayPool<T> for arrays and the MemoryPool<T> for Memory<T> buffers. Both are useful for reusable buffers. When you have expensive objects that are frequently created and discarded, consider an object pool.

public class Connection { // Costly to construct } public static class ConnectionPool { private static readonly ConcurrentBag<Connection> _pool = new(); public static Connection Rent() { return _pool.TryTake(out var conn) ? conn : new Connection(); } public static void Return(Connection conn) { _pool.Add(conn); } }

In this example, ConnectionPool reuses connections instead of creating a new one each time. The ConcurrentBag is thread-safe for concurrent take and add operations. The pool saves the cost of constructing a new Connection each time. The key is to ensure the returned object is in a usable state; reset any state before returning it to the pool, otherwise the next consumer may see stale data.

Understanding the Garbage Collector Behavior

The garbage collector in .NET is generational and usually concurrent. It tracks object references and compacts the heap for small objects. The collector adjusts its behavior based on allocation rates and available memory. If you see frequent Gen2 collections, that's a sign of either high allocation or a tendency for objects to survive into Gen1 and Gen2.

You can configure the runtime with different GC modes, such as workstation vs. server GC, or use ServerGarbageCollection in your project file. Server GC uses multiple heaps and can improve throughput for multi-threaded applications, but it uses more memory. Changing GC mode is a configuration trade-off, not a fix for excessive allocation.

For most applications, reducing allocation is better than tweaking GC settings. The less garbage produced, the less work the collector does.

Memory Leaks: When Memory Usage Keeps Growing

Memory leaks in .NET are usually not a failure to free memory; they are references that keep objects alive. Common causes include:

  • Static collections holding on to items
  • Event handlers not being unsubscribed
  • CancellationTokenSource or Task references that are never disposed
  • WeakReference misunderstanding

For example, subscribe to an event with a lambda, and that lambda becomes a new delegate referencing the handler. If the handler is a long-lived object, the event publisher may keep it alive. Use the -= operator to unsubscribe when appropriate.

class Service { public event EventHandler? SomethingHappened; } class Listener { private readonly Service _service; public Listener(Service service) { _service = service; _service.SomethingHappened += OnSomethingHappened; } public void Detach() { _service.SomethingHappened -= OnSomethingHappened; } private void OnSomethingHappened(object? sender, EventArgs e) { } }

The Detach method removes the handler, allowing the listener to be collected if no other references exist. Without it, the service holds a delegate that references the listener, causing a leak.

Use memory profilers to inspect root paths and identify what objects are still rooted. The dotnet-dump command can analyze a process's memory to find leak suspects.

Practical Guidance for Reducing Allocations

When you've identified high allocation sites, apply a targeted fix. Don't optimize every small allocation unless that code runs extremely frequently. Use the following criteria:

  • If a method is called millions of times per second, even a small allocation per call matters.
  • If a path is called rarely, the allocation is not worth the added complexity.
  • Prefer reusable buffers for large arrays.
  • Prefer Span<T> or Memory<T> when working with slices of arrays to avoid copying.

A concrete example: reading a file in chunks. If you create a new byte[] for each chunk, you allocate a large array each iteration, which goes to the LOH. Instead, rent a buffer from ArrayPool<byte> and process it in a loop.

using (var stream = File.OpenRead("large.bin")) { byte[] buffer = ArrayPool<byte>.Shared.Rent(81920); int bytesRead; while ((bytesRead = stream.Read(buffer, 0, buffer.Length)) > 0) { Process(buffer.AsSpan(0, bytesRead)); } ArrayPool<byte>.Shared.Return(buffer); }

The Process method accepts a ReadOnlySpan<byte> or a Span<byte>, avoiding any additional allocation. Not only is the buffer reused, but passing the span avoids intermediate copies.

Observability in Production

Measurement doesn't stop at profiling. In production, you can observe allocation rates using Application Insights or OpenTelemetry with a .NET meter that reports gc-heap-size and gc-gen0-collection-count. These metrics help you see if your optimization reduced the allocation rate over time.

You can also attach dotnet-counters to a running process to watch GC and memory counters:

dotnet-counters monitor --process-id 1234

That command shows live counter values including gc-heap-size and alloc-rate. A sudden increase in allocation rate may indicate a new request pattern or a regression.

If you identify a leak in a long-running service, use dotnet-dump collect to get a memory dump, then analyze it with the dotnet-dump analyze command to inspect roots. The effort spent here is valuable because leaks are often subtle and only appear under sustained load.

The Cost of Over-Optimization

Reducing c# memory usage is not free. Pooling adds complexity, and structs can make code less readable. Before adopting a pooling strategy, consider whether the GC is actually a bottleneck. Profiling data should guide the decision.

For example, a simple log parser that handles a few hundred thousand lines a day may not need pooling. But a high-throughput web server that allocates buffers per request benefits significantly. Use the principle: don't optimize until measurements show a problem, but measure before you assume.

Sometimes the best optimization is to avoid creating unnecessary data. Using Enumerable.ToArray() creates a new array even when you only need to iterate. Consider returning IEnumerable<T> or using yield return to produce elements lazily.

public static IEnumerable<int> ReadNumbers(string path) { foreach (var line in File.ReadLines(path)) { if (int.TryParse(line, out var value)) { yield return value; } } }

This method streams lines and yields results. It avoids allocating an entire list of numbers. The trade-off is that the caller must handle a lazy sequence, which may have implications if the source is locked or if you need to pass it to a method that expects a list.

c# memory usage: Practical Usage and Code Examples | RYUSLOG DEV