C# List Capacity Management and Expansion

This article provides a comprehensive overview of how the generic List<T> collection manages its internal memory in C#. It explores the critical distinction between the Count and Capacity properties, breaks down the internal array reallocation process during capacity expansion, analyzes the performance and garbage collection impacts of dynamic resizing, and details best practices to optimize list operations.

The Underlying Structure of List<T>

Although List<T> behaves like an elastic data structure, it is internally backed by a flat, contiguous array (T[]). Because standard arrays in .NET have a fixed size once allocated, List<T> handles dynamic growth by automatically creating new arrays behind the scenes when space runs out.

Count vs. Capacity

Understanding internal memory management requires distinguishing between two fundamental properties:

  • Count: The actual number of elements currently stored in the list. This value increases by one each time an element is added and decreases when an element is removed.
  • Capacity: The total number of elements that the internal array can hold without needing to resize. This value is always greater than or equal to Count.

When a list is initialized using the default parameterless constructor (new List<T>()), its initial Capacity is 0, and the internal array is set to an empty array singleton.

How Capacity Expansion Works

When an element is added using methods like Add() or AddRange(), the list checks whether Count + 1 exceeds Capacity. If the internal array has sufficient room, the element is placed at index Count, and Count is incremented. If the array is full, an expansion process is triggered.

The expansion follows a specific sequence:

  1. Calculate New Capacity: If the current capacity is 0, the runtime sets the new capacity to a default minimum, which is 4. If the capacity is already greater than 0, the runtime doubles the current capacity (Capacity * 2).
  2. Handle Upper Limits: If doubling the capacity exceeds the maximum allowed array size (Array.MaxLength), the capacity is clamped to Array.MaxLength or int.MaxValue.
  3. Allocate New Array: A brand-new contiguous array of the calculated capacity is allocated on the managed heap.
  4. Copy Elements: All existing elements from the old internal array are copied to the new array using Array.Copy.
  5. Reassign Reference: The internal reference pointer is updated to point to the new array. The previous array is abandoned and becomes eligible for Garbage Collection (GC).
  6. Insert Element: The new item is placed into the first empty slot.

Performance Implications

Understanding this growth cycle is critical for writing high-performance C# code due to two primary factors:

Time Complexity

  • Amortized O(1): While inserting an element into an existing array slot is an \(O(1)\) constant-time operation, resizing requires an \(O(N)\) operation because every existing element must be copied to the new array. Because the capacity doubles each time, expansions become less frequent as the list grows, maintaining an amortized \(O(1)\) time complexity for Add.
  • Latency Spikes: In time-critical paths, an unexpected resize introduces CPU pauses due to memory allocation and large-block copying.

Garbage Collection and Memory Overhead

Frequent resizes generate multiple orphaned arrays that must be cleaned up by the Garbage Collector. For lists containing large numbers of elements or large value types, the internal array can exceed 85,000 bytes, which places it directly onto the Large Object Heap (LOH). Repeated expansions on the LOH can cause heap fragmentation and trigger costly full Gen 2 GC sweeps.

Best Practices for Capacity Management

1. Pre-allocate Capacity

If the number of items is known or can be estimated in advance, define the capacity during instantiation to eliminate all reallocation overhead:

// Allocates an internal array of size 1,000 immediately
List<int> numbers = new List<int>(1000);

2. Manual Capacity Adjustments

You can manually adjust the capacity at any point during runtime before performing bulk operations:

List<string> items = new List<string>();
items.Capacity = expectedItemCount;

3. Reclaiming Memory with TrimExcess

If a list grows significantly and then sheds elements without expecting further growth, you can release unused capacity back to the system:

items.TrimExcess();

TrimExcess() checks if the list is utilizing less than 90% of its allocated capacity. If so, it allocates a new array sized precisely to the current Count, copies the active items over, and frees the remaining buffer.