Мы все привыкли писать 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 ListBenchmark
{
    [ShortRunJob(RuntimeMoniker.Net48)]
    [ShortRunJob(RuntimeMoniker.NetCoreApp31)]
    [ShortRunJob(RuntimeMoniker.Net80)]
    [ShortRunJob(RuntimeMoniker.Net10_0)]
    [MemoryDiagnoser]
    [HideColumns("Job", "Error", "StdDev", "Gen0")]
    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.15.6, Windows 10 (10.0.19045.6456/22H2/2022Update)
AMD Ryzen 7 7840H with Radeon 780M Graphics 3.80GHz, 1 CPU, 16 logical and 8 physical cores
.NET SDK 10.0.100
[Host]                      : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v4
ShortRun-.NET 10.0          : .NET 10.0.0 (10.0.0, 10.0.25.52411), X64 RyuJIT x86-64-v4
ShortRun-.NET 8.0           : .NET 8.0.22 (8.0.22, 8.0.2225.52707), X64 RyuJIT x86-64-v4
ShortRun-.NET Core 3.1      : .NET Core 3.1.32 (3.1.32, 4.700.22.55902), X64 RyuJIT VectorSize=256
ShortRun-.NET Framework 4.8 : .NET Framework 4.8.1 (4.8.9310.0), X64 RyuJIT VectorSize=256

IterationCount=3  LaunchCount=1  WarmupCount=3

| Method             | Runtime            |      Mean | Allocated |
|--------------------|--------------------|----------:|----------:|
| InitList1          | .NET Framework 4.8 | 13.904 ns |      80 B |
| InitListWithSize1  | .NET Framework 4.8 |  6.658 ns |      72 B |
| InitList1          | .NET Core 3.1      | 11.091 ns |      72 B |
| InitListWithSize1  | .NET Core 3.1      |  7.407 ns |      64 B |
| InitList1          | .NET 8.0           | 10.084 ns |      72 B |
| InitListWithSize1  | .NET 8.0           |  6.838 ns |      64 B |
| InitList1          | .NET 10.0          |  8.170 ns |      72 B |
| InitListWithSize1  | .NET 10.0          |  6.638 ns |      64 B |
| InitList5          | .NET Framework 4.8 | 31.298 ns |     136 B |
| InitListWithSize5  | .NET Framework 4.8 | 12.013 ns |      88 B |
| InitList5          | .NET Core 3.1      | 26.466 ns |     128 B |
| InitListWithSize5  | .NET Core 3.1      |  9.446 ns |      80 B |
| InitList5          | .NET 8.0           | 23.714 ns |     128 B |
| InitListWithSize5  | .NET 8.0           | 15.587 ns |      80 B |
| InitList5          | .NET 10.0          | 20.002 ns |     128 B |
| InitListWithSize5  | .NET 10.0          |  8.712 ns |      80 B |
| InitList10         | .NET Framework 4.8 | 53.488 ns |     225 B |
| InitListWithSize10 | .NET Framework 4.8 | 18.185 ns |     104 B |
| InitList10         | .NET Core 3.1      | 44.371 ns |     216 B |
| InitListWithSize10 | .NET Core 3.1      | 12.496 ns |      96 B |
| InitList10         | .NET 8.0           | 38.707 ns |     216 B |
| InitListWithSize10 | .NET 8.0           | 12.024 ns |      96 B |
| InitList10         | .NET 10.0          | 33.854 ns |     216 B |
| InitListWithSize10 | .NET 10.0          | 15.822 ns |      96 B |

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

Использование памяти списками с инициализацией и без
Использование памяти списками с инициализацией и без

История из жизни

В одном проекте в Контуре столкнулся с тем, что приложение держит гигабайты памяти — это некий огромный кэш, который поднимается в памяти на старте. Ну, казалось бы, бывает. Кэш этот нельзя было выбросить, но я решил посмотреть профайлером, из чего же он состоит. Оказалось, что почти вся память занята объектами типа List<Data>. Нюанс же был в том, что создавались эти списки таким образом: new List<Data> { value }. Как вы можете догадаться, примерно 3/4 всей памяти было занято «пустотой».

Решение было простым. Там, где коллекция точно не менялась, стал создаваться массив: new[] { value }, в остальных же случаях обязательно стали указывать размер коллекции: new List<Data>(1) { value }.

Анализатор

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

Анализатор находит коллекции без указания начального размера.
Анализатор находит коллекции без указания начального размера.

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

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

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

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

  • Если вы создаёте список и не знаете его будущий размер — установите ожидаемый размер.

Ссылки

Комментарии (5)


  1. avsolovyev
    04.12.2025 10:36

    А еще List не очень дружит с мультипоточностью, поэтому и использую ConcurrentQueue вместо него.


  1. bighorik
    04.12.2025 10:36

    Спасибо за хорошую статью, в грядущих оптимизациях буду иметь ввиду, на что обратить внимание)


  1. ValeriyPus
    04.12.2025 10:36

    Ого, экономия 15 нс на каждой инициализации списка! (сарказм)

    А где самый очевидный вариант - построить реализацию IList на массивах?

    Около 0 malloc на добавление\удаление\замену элемента.

    Чтобы выделять большие куски памяти, освобождать и т.д.?

    (Берем массив размера N, кончается - Добавляем еще массив размера N, и т.д.)


  1. ValeryIvanov
    04.12.2025 10:36

    Если у вас .NET 8 или выше, то лучше использовать Collection expressions. Хороший баланс производительности и удобства.

    // вместо трёх вызовов items.Add, будет один вызов билдера, 
    // который принимает Span<T> содержащий перечисленные элементы
    List<int> items = [1, 2, 3];
    


    1. sibogatovr
      04.12.2025 10:36

      ага, когда мы используем Collection expressions, за нас уже считается Capacity и сразу создается список нужного размера.

      Вообще статья отличная и тема интересная. Но для Контура это очень мало, ожидания были другие. Я выделил себе час, только втянулся, завел бенчмарки. А потом все закончилось, оказывается здесь просто про Capacity. И что список это динамический массив.