Инициализатор коллекций: List<T>

Изучите внутреннюю работу инициализации List<T> в C#. Сравните работу массивов и списков, узнайте их влияние на производительность и выделение памяти, а также изучите методы оптимизации для высокопроизводительного кода.
Published on Saturday 27 September 2025

Мы все привыкли писать new List<int> { 1, 2, 3, 4 } или new int[] { 1, 2, 3, 4}, чтобы инициализировать коллекции какими-то значениями. Синтаксически это выглядит похоже, но поведение отличается, и вам следует быть осторожными, если вы заботитесь о производительности.

Массив

Как мы знаем, массивы содержат последовательность элементов фиксированного размера. После создания размер нельзя изменить в течение всего времени жизни массива.

var array = new int[] { 1, 2, 3, 4};

List<T>

Когда мы не знаем конечный размер коллекции или нам нужно добавлять/удалять элементы в течение ее жизненного цикла, подходит использование тип List<T>.

var list = new List<int>();
list.Add(1);
list.Add(2);

На первый взгляд может показаться, что мы всегда можем использовать список вместо массива — у него есть все возможности массива, но его также можно динамически изменять. Но чтобы решить, использовать ли список, нам нужно больше узнать о его внутренней структуре.

Часть исходного кода:

public class List<T> : IList<T>, IList, IReadOnlyList<T>
{
    private const int DefaultCapacity = 4;

    internal T[] _items;
    internal int _size;

    private static readonly T[] s_emptyArray = new T[0];

    // Constructs a List. The list is initially empty and has a capacity
    // of zero. Upon adding the first element to the list the capacity is
    // increased to DefaultCapacity, and then increased in multiples of two
    // as required.
    public List()
    {
        _items = s_emptyArray;
    }
    ...

    public int Capacity
    {
        get => _items.Length;
        set {...}
    }

    public int Count => _size;
}
  • _items - внутренний массив для хранения элементов;
  • _size - количество элементов в массиве и общий размер списка;
  • Capacity - размер массива _items и максимальное количество элементов, которые могут в него поместиться без изменения размера.

Изменение размера списка

Проще говоря, можно сказать, что список — это просто массив, который может изменять размер при необходимости.

Каждый раз, когда мы пытаемся добавить еще один элемент, список проверяет, достаточно ли в _items свободного места, в противном случае он устанавливает новую емкость:

internal void Grow(int capacity)
{
    ...
    int newCapacity = _items.Length == 0 ? DefaultCapacity : 2 * _items.Length;
    ...
    Capacity = newCapacity;
}

Давайте посмотрим поближе на свойство Capacity:

// Gets and sets the capacity of this list.  The capacity is the size of
// the internal array used to hold items.  When set, the internal
// array of the list is reallocated to the given capacity.
public int Capacity
{
    get => _items.Length;
    set
    {
        if (value < _size)
        {
            ThrowHelper.ThrowArgumentOutOfRangeException(ExceptionArgument.value, ExceptionResource.ArgumentOutOfRange_SmallCapacity);
        }

        if (value != _items.Length)
        {
            if (value > 0)
            {
                T[] newItems = new T[value];
                if (_size > 0)
                {
                    Array.Copy(_items, newItems, _size);
                }
                _items = newItems;
            }
            else
            {
                _items = s_emptyArray;
            }
        }
    }
}

Итак, что мы видим? Изначально каждый список создается с пустым внутренним массивом. После добавления первого элемента список создает новый массив на 4 элемента (DefaultCapacity равно 4). И когда текущий массив исчерпан, создается новый с удвоенным размером, и все элементы копируются.

Производительность

Что происходит, когда мы создаем новый список и инициализируем его значениями?

var list = new List<int> { 1, 2, 3, 4, 5 };

Это выглядит как инициализация массива, но работает совершенно по-другому. Согласно документации, любой тип, который реализует IEnumerable и имеет метод Add может использоваться с инициализатором коллекции.

Таким образом, предыдущий пример — это просто краткая форма последовательных вызовов метода Add:

var list = new List<int>();
list.Add(1);
list.Add(2);
list.Add(3);
list.Add(4);
list.Add(5);

Компилятор просто преобразует короткий инициализатор коллекции и автоматически добавляет необходимые вызовы. И это может вызвать снижение производительности.

Давайте подробнее рассмотрим процесс добавления 5 элементов:

  • Перед первым вызовом Add список пуст, внутренний массив пуст.
  • Первый добавленный элемент создает новый внутренний массив на 4 элемента.
  • Элементы 2, 3, 4 при добавлении ничего не меняют.
  • Когда мы добавляем пятый элемент, внутренний массив заполнен и требует изменения размера. Создается новый массив размером 8, все элементы копируются из предыдущего массива, и добавляется пятый элемент.

В итоге у нас есть список с 5 элементами и внутренний массив на 8 элементов. При этом мы создали 2 массива, и конечный тратит 37.5% своего пространства впустую. Как вы могли догадаться, создание новых массивов и копирование элементов приводит к выделению памяти и занимает дополнительное время.

Это может стать неприятным сюрпризом в критически важных местах. Есть ли у нас решение? Да!

Capacity

Если мы знаем или предполагаем конечный размер списка, мы можем создать его с начальной емкостью (Capacity).

var list = new List<int>(5);
list.Add(1);
list.Add(2);
list.Add(3);
list.Add(4);
list.Add(5);

Или

var list = new List<int>(5) { 1, 2, 3, 4, 5 };

Теперь мы сразу создаем один внутренний массив на 5 элементов и больше нет никаких ненужных выделений памяти.

Бенчмарк

Давайте сравним создание списков с начальной емкостью и без нее.

Код бенчмарка. Нажмите, чтобы развернуть.
using System.Collections.Generic;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Jobs;

namespace Benchmarks
{
    [ShortRunJob(RuntimeMoniker.Net48)]
    [ShortRunJob(RuntimeMoniker.Net60)]
    [ShortRunJob(RuntimeMoniker.Net80)]
    [MemoryDiagnoser]
    public class InitListBenchmark
    {
        [BenchmarkCategory("One")]
        [Benchmark]
        public List<int> InitList1()
        {
            return new List<int> {1};
        }

        [BenchmarkCategory("One")]
        [Benchmark]
        public List<int> InitListWithSize1()
        {
            return new List<int>(1) {1};
        }

        [BenchmarkCategory("Five")]
        [Benchmark]
        public List<int> InitList5()
        {
            return new List<int> {1, 2, 3, 4, 5};
        }
        
        [BenchmarkCategory("Five")]
        [Benchmark]
        public List<int> InitListWithSize5()
        {
            return new List<int>(5) {1, 2, 3, 4, 5};
        }
        
        [BenchmarkCategory("Ten")]
        [Benchmark]
        public List<int> InitList10()
        {
            return new List<int> {1, 2, 3, 4, 5, 6, 7, 8, 9, 0};
        }
        
        [BenchmarkCategory("Ten")]
        [Benchmark]
        public List<int> InitListWithSize10()
        {
            return new List<int>(10) {1, 2, 3, 4, 5, 6, 7, 8, 9, 0};
        }
    }
}

BenchmarkDotNet v0.13.8, Windows 10 (10.0.19045.3930/22H2/2022Update)
AMD Ryzen 7 7840H with Radeon 780M Graphics, 1 CPU, 16 logical and 8 physical cores
.NET SDK 8.0.101
  [Host]                      : .NET 6.0.26 (6.0.2623.60508), X64 RyuJIT AVX2
  ShortRun-.NET 6.0           : .NET 6.0.26 (6.0.2623.60508), X64 RyuJIT AVX2
  ShortRun-.NET 8.0           : .NET 8.0.1 (8.0.123.58001), X64 RyuJIT AVX2
  ShortRun-.NET Framework 4.8 : .NET Framework 4.8.1 (4.8.9195.0), X64 RyuJIT VectorSize=256

IterationCount=3  LaunchCount=1  WarmupCount=3  

Method Runtime Mean Gen0 Allocated
InitList1 .NET Framework 4.8 14.523 ns 0.0127 80 B
InitListWithSize1 .NET Framework 4.8 6.678 ns 0.0115 72 B
InitList5 .NET Framework 4.8 33.884 ns 0.0216 136 B
InitListWithSize5 .NET Framework 4.8 12.232 ns 0.0140 88 B
InitList10 .NET Framework 4.8 54.156 ns 0.0357 225 B
InitListWithSize10 .NET Framework 4.8 18.609 ns 0.0166 104 B
InitList1 .NET 6.0 10.599 ns 0.0086 72 B
InitListWithSize1 .NET 6.0 5.727 ns 0.0076 64 B
InitList5 .NET 6.0 22.426 ns 0.0153 128 B
InitListWithSize5 .NET 6.0 8.611 ns 0.0096 80 B
InitList10 .NET 6.0 37.512 ns 0.0258 216 B
InitListWithSize10 .NET 6.0 12.740 ns 0.0115 96 B
InitList1 .NET 8.0 9.515 ns 0.0086 72 B
InitListWithSize1 .NET 8.0 6.638 ns 0.0076 64 B
InitList5 .NET 8.0 23.910 ns 0.0153 128 B
InitListWithSize5 .NET 8.0 9.153 ns 0.0096 80 B
InitList10 .NET 8.0 38.765 ns 0.0258 216 B
InitListWithSize10 .NET 8.0 12.288 ns 0.0115 96 B

Когда мы устанавливаем начальную емкость, это не приводит к ненужным выделениям памяти, и мы видим лучшую производительность. И нет избыточного трафика памяти. Чем больше элементов мы добавляем в список, тем большую разницу мы видим в бенчмарках.

Анализатор

Если производительность критична для вашего проекта, вы должны обращать внимание на эти ситуации. Автоматическая диагностика может вам помочь. Я поддерживаю набор диагностических инструментов на основе Roslyn - Collections.Analyzer, и теперь он может обнаруживать списки с инициализатором коллекции и без начальной емкости.

Автоматическое указание первоначального размера List

Важно понимать, что анализатор устанавливает только начальный размер коллекции, чтобы избежать лишних аллокаций на начальном этапе заполнения коллекции. Он не может предсказать, как изменится размер коллекции в будущем.

Рекомендации

  • Если вам нужна статическая коллекция, которую вы не будете изменять (добавлять или удалять элементы) — используйте массивы.
  • Если вы создаете список и точно знаете его будущий размер — установите начальную емкость с этим размером.
  • Если вы создаете список и не знаете его будущий размер — установите ожидаемый размер.

Ссылки