Куча больших объектов в системах Windows

Сборщик мусора .NET (GC) разделяет объекты на две категории — большие и маленькие. Некоторые атрибуты больших объектов становятся более значимыми, чем атрибуты маленьких. Например, для сжатия объекта—копирования его в другое место в куче—требуется много ресурсов. Поэтому сборщик мусора помещает большие объекты в кучу больших объектов (LOH). В этой статье мы поговорим о том, что такое большой объект, как собираются большие объекты и как они влияют на производительность.

Важно!

В этой статье рассматривается куча больших объектов в .NET Framework и .NET Core только в системах Windows. Эти сведения не относятся к куче больших объектов в реализациях .NET на других платформах.

Как объект оказывается в куче больших объектов

Большим считается объект не менее 85 000 байт. Это число рассчитано путем настройки производительности. Если запрашивается выделение 85 000 или более байтов, среда выполнения отправляет объект в кучу больших объектов.

Чтобы понять, что это значит, давайте рассмотрим основные принципы работы сборщика мусора.

Сборщик мусора работает по поколениям. Он имеет три поколения: поколение 0, поколение 1 и поколение 2. Причина использования трех поколений в том, что в правильно настроенных приложениях большинство объектов отмирают на стадии gen0. Например, в серверном приложении выделения памяти, связанные с каждым запросом, должны исчезнуть после завершения запроса. Находящиеся в пути запросы на выделение памяти достигнут поколения 1 и умрут там. По сути, поколение 1 служит буфером между областями молодых объектов и областями долгоживущих объектов.

Маленькие объекты всегда размещаются в поколении 0 и, в зависимости от времени их существования, могут перейти в поколение 1 или поколение 2. Большие объекты всегда размещаются в поколении 2.

Большие объекты принадлежат к поколению 2, поскольку они собираются только при сборке поколения 2. При сборке поколения собираются и все предыдущие поколения. Например, когда происходит сборка мусора поколения 1, собираются поколения 1 и 0. А когда происходит сборка мусора поколения 2, собирается вся куча. Поэтому сборка мусора поколения 2 также называется полной сборкой мусора. В этой статье используется термин "сборка мусора поколения 2", а не "полная сборка мусора", но эти термины равнозначны.

Поколения обеспечивают логическое представление кучи сборки мусора. На физическом уровне объекты существуют в управляемых сегментах кучи. Управляемый сегмент кучи — это блок памяти, который сборщик мусора резервирует в ОС через вызов функции VirtualAlloc от имени управляемого кода. При загрузке CLR сборка мусора выделяет два первоначальных сегмента кучи: один для маленьких объектов (куча маленьких объектов, или SOH) и один для больших объектов (куча больших объектов, или LOH).

После этого запросы на выделение памяти удовлетворяются путем размещения управляемых объектов в одном из этих сегментов управляемой кучи. Если объект меньше 85 000 байтов, он будет помещен в сегмент SOH. В противном случае он помещается в сегмент LOH. Память из сегментов выделяется (блоками) по мере того, как в них помещается все больше объектов. В куче маленьких объектов объекты, пережившие сборку мусора, переходят в следующее поколение. Объекты, пережившие сборку мусора поколения 0, считаются объектами поколения 1 и так далее. Однако объекты, пережившие сборку мусора последнего поколения, по-прежнему будут относиться к этому поколению. Другими словами, выжившие из поколения 2 — это объекты поколения 2; а выжившие из кучи больших объектов — это объекты кучи больших объектов (которые собираются с поколением 2).

Пользовательский код может размещать объекты только в поколении 0 (маленькие объекты) или в куче больших объектов (большие объекты). Только сборщик мусора может помещать объекты в поколение 1 (повышая уровень выживших из поколения 0) и поколение 2 (повышая уровень выживших из поколений 1 и 2).

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

.NET Core и .NET Framework (начиная с .NET Framework 4.5.1) включают в себя свойство GCSettings.LargeObjectHeapCompactionMode, которое дает пользователям возможность указать, что необходимо сжать кучу больших объектов при следующей полной блокирующей сборке мусора. И в будущем .NET может сжимать кучу больших объектов автоматически. Это означает, что в случае выделения больших объектов, перемещение которых недопустимо, их по-прежнему следует закреплять.

На рис. 1 проиллюстрирована ситуация, где сборщик мусора формирует поколение 1 после первого поколения 0, где объекты Obj1 и Obj3 мертвы; и он формирует поколение 2 после первого поколения 1, где объекты Obj2 и Obj5 мертвы. Это и следующие изображения приводятся только для иллюстрации; они содержат мало объектов, чтобы продемонстрировать происходящее в куче. На самом деле сборщик мусора обрабатывает гораздо больше объектов.

Рис. 1. Сборка мусора поколения 0 и поколения 1
Рис. 1. Сборка мусора поколения 0 и поколения 1.

На рисунке 2 показано, что после сборки мусора поколения 2, где объекты Obj1 и Obj2 мертвы, сборщик мусора создает непрерывное свободное пространство в памяти, которое занимали объекты Obj1 и Obj2. Это пространство затем используется для удовлетворения запроса на выделение памяти для объекта Obj4. Пространство после последнего объекта Obj3 и до конца сегмента все еще может быть использовано для удовлетворения дальнейших запросов на выделение памяти.

Рис. 2. После сборки мусора поколения 2
Рис. 2. После сборки мусора поколения 2

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

В ходе сборки мусора поколения 1 или 2 сборщик мусора отдает ОС сегменты, в которых нет живых объектов (вызывая функцию VirtualFree). Свободное место после последнего живого объекта до конца сегмента освобождается (за исключением временных сегментов с объектами поколения 0 и 1, в которых сборщик мусора сохраняет свободное пространство, поскольку вскоре приложение будет размещать в него объекты). А свободные пространства остаются выделенными, хотя и сбрасываются, освобождая ОС от необходимости записывать данные с них обратно на диск.

Поскольку куча больших объектов собирается только во время сборки мусора поколения 2, сегмент этой кучи можно освободить только во время этой сборки мусора. На рисунке 3 показан сценарий, где сборщик мусора возвращает ОС один сегмент (сегмент 2) и освобождает дополнительное место в оставшихся сегментах. Если освободившееся пространство в конце сегмента необходимо использовать для удовлетворения запросов на выделение памяти для большого объекта, он фиксирует память снова. (Дополнительные сведения о фиксации и освобождении см. в документации по VirtualAlloc.

Рис. 3. Куча больших объектов после сборки мусора поколения 2
Рис. 3. Куча больших объектов после сборки мусора поколения 2

Когда собираются большие объекты?

Как правило, сборка мусора происходит при выполнении одного из следующих трех условий:

  • Выделение памяти превышает пороговое значение для поколения 0 или больших объектов.

    Пороговое значение является свойством поколения. Пороговое значение для поколения задается, когда сборщик мусора распределяет в него объекты. При превышении порогового значения для этого поколения происходит сборка мусора. При распределении маленьких или больших объектов учитываются пороговые значения для поколения 0 или кучи больших объектов соответственно. Когда сборщик мусора распределяет объекты в поколения 1 и 2, учитываются соответствующие пороговые значения. Эти пороговые значения динамически настраиваются в ходе работы программы.

    Это типичный случай. Как правило, сборка мусора происходит в связи с распределениями в управляемой куче.

  • вызывается метод GC.Collect .

    Если вызывается метод GC.Collect() без параметров или другая перегрузка передается GC.MaxGeneration как аргумент, сборка мусора в куче больших объектов происходит одновременно со сборкой мусора в управляемой куче.

  • В системе недостаточно памяти.

    Это происходит, когда сборщик мусора получает от ОС уведомление верхней памяти. Если сборщик мусора считает, что сборка мусора поколения 2 будет продуктивной, он запускает ее.

Влияние кучи больших объектов на производительность

Распределение в куче больших объектов влияет на производительность следующим образом.

  • Затраты на распределение.

    CLR гарантирует очистку памяти для каждого выдаваемого им нового объекта. Это значит, что при распределении большого объекта ресурсы, в основном, расходуются на очистку памяти (если не запускается сборка мусора). Если очистка одного байта занимает два цикла, то на очистку самого маленького большого объекта уйдет 170 000 циклов. Очистка памяти для объекта размером 16 МБ на компьютере с частотой 2 ГГц занимает приблизительно 16 мс. Это довольно большие затраты.

  • Затраты на сбор.

    Поскольку куча больших объектов и поколение 2 собираются вместе, при превышении порогового значения для любого из них запускается сборка мусора поколения 2. Если сборка поколения 2 была запущена из-за кучи больших объектов, то само поколение 2 необязательно значительно уменьшится после сборки мусора. Если в поколении 2 не так много данных, влияние минимально. Но если поколение 2 большое, запуск многочисленных сборок мусора поколения 2 может вызвать проблемы с производительностью. Если у вас много больших объектов с временным размещением и большая куча маленьких объектов, на сборку мусора может уходить слишком много времени. Кроме того, затраты на распределение могут нарастать, если вы и дальше будете распределять и освобождать очень большие объекты.

  • Элементы массива со ссылочными типами.

    Очень большие объекты в куче обычно являются массивами (очень большие объекты-экземпляры весьма редки). Если элементы массива имеют много ссылок, затраты выше, чем для элементов с небольшим количеством ссылок. Если элемент не содержит ссылок, сборщику мусора совсем не придется перебирать массив. Например, в случае использования массива для хранения узлов в двоичном дереве одной из возможных реализаций является обозначение узлов справа и слева от узла реальными узлами:

    class Node
    {
       Data d;
       Node left;
       Node right;
    };
    
    Node[] binary_tr = new Node [num_nodes];
    

    Если num_nodes является большим, сборщик мусора должен обработать как минимум две ссылки на элемент. Другой подход состоит в сохранении индекса правого и левого узла:

    class Node
    {
       Data d;
       uint left_index;
       uint right_index;
    } ;
    

    Ссылка на данные левого узла может выглядеть не как left.d, а как binary_tr[left_index].d. А сборщику мусора не понадобится просматривать ссылки для левого и правого узлов.

Из трех факторов первые два обычно важнее, чем третий. По этой причине рекомендуется распределять пул больших объектов, которые можно использовать повторно, вместо распределения временных.

Сбор данных производительности для кучи больших объектов

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

  1. Найти свидетельство, которое нужно учитывать в этой области.

  2. Исследовать другие известные области и не найти в них причину проблемы с производительностью.

Дополнительные сведения об основах памяти и ЦП см. в блоге Понять проблемы, прежде чем пытаться найти решение.

Для сбора данных о производительности кучи больших объектов можно использовать следующие средства:

Счетчики памяти .NET CLR

Использование счетчиков памяти обычно является хорошим первым этапом поиска проблем с производительностью (хотя мы рекомендуем использовать События трассировки Windows). Чтобы настроить монитор производительности, добавьте нужные счетчики, как показано на рисунке 4. К куче больших объектов относятся следующие счетчики:

  • Число сборок мусора поколения 2

    Показывает количество сборок мусора поколения 2 с момента запуска процесса. Этот счетчик увеличивает число в конце сборки мусора поколения 2 (иначе называемой полной сборкой мусора). Этот счетчик отображает последнее значение.

  • Размер кучи для массивных объектов

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

Распространенным средством просмотра счетчиков производительности является монитор производительности (perfmon.exe). Нажмите "Добавить счетчики", чтобы добавить нужный счетчик для интересующих вас процессов. Данные счетчика производительности можно сохранить в файле журнала, как показано на рисунке 4.

Снимок экрана, демонстрирующий добавление счетчиков производительности. Рис. 4. Куча больших объектов после сборки мусора поколения 2

Счетчики производительности также можно запросить программно. Многие собирают эти данные таким способом в рамках рутинного процесса тестирования. При обнаружении счетчиков с необычными значениями можно использовать другие способы получения подробностей, чтобы помочь расследованию.

Примечание

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

ETW-события

Сборщик мусора предоставляет широкий набор событий трассировки событий Windows, помогая разобраться в функциях кучи. В следующих записях блога описывается, как собирать и интерпретировать события сборки мусора с помощью трассировки событий Windows:

Чтобы выявить чрезмерную сборку мусора поколения 2, вызванную временными распределениями кучи больших объектов, ищите сборку мусора в столбце "Причина активации". Чтобы провести простой тест и распределить только временные большие объекты, соберите информацию о событиях трассировки событий Windows с помощью следующей командной строки PerfView:

perfview /GCCollectOnly /AcceptEULA /nogui collect

Результат будет выглядеть примерно следующим образом:

Снимок экрана, показывающий события трассировки событий Windows в PerfView. Рис. 5. События трассировки событий Windows, отображаемые с помощью PerfView

Как видите, все сборки мусора относятся к поколению 2 и активируются функцией AllocLarge. Это означает, что эта сборка мусора вызвана распределением большого объекта. Мы знаем, что эти распределения являются временными, так как в столбце % выживания LOH значится 1 %.

Можно собирать дополнительные события трассировки событий Windows, которые показывают, кто распределил эти большие объекты. Следующая командная строка:

perfview /GCOnly /AcceptEULA /nogui collect

собирает событие AllocationTick, которое возникает примерно каждые 100 000 распределений. Другими словами, событие возникает при каждом выделении памяти для большого объекта. Затем вы можете изучить одно из представлений GC Heap Alloc, где отображаются стеки вызовов, распределившие большие объекты:

Снимок экрана, показывающий представление кучи сборщика мусора. Рис. 6. Представление GC Heap Alloc

Как видите, это очень простой тест, который выделяет память для большого объекта из метода Main.

Отладчик

Если у вас есть только дамп памяти и вам нужно увидеть, какие объекты находятся в куче, можно использовать расширение отладчика SoS, предоставляемое .NET.

Примечание

Команды отладки, описанные в этом разделе, применимы к отладчикам Windows.

Ниже приведен пример выходных данных анализа кучи больших объектов:

0:003> .loadby sos mscorwks
0:003> !eeheap -gc
Number of GC Heaps: 1
generation 0 starts at 0x013e35ec
sdgeneration 1 starts at 0x013e1b6c
generation 2 starts at 0x013e1000
ephemeral segment allocation context: none
segment   begin allocated     size
0018f2d0 790d5588 790f4b38 0x0001f5b0(128432)
013e0000 013e1000 013e35f8 0x000025f8(9720)
Large object heap starts at 0x023e1000
segment   begin allocated     size
023e0000 023e1000 033db630 0x00ffa630(16754224)
033e0000 033e1000 043cdf98 0x00fecf98(16699288)
043e0000 043e1000 05368b58 0x00f87b58(16284504)
Total Size 0x2f90cc8(49876168)
------------------------------
GC Heap Size 0x2f90cc8(49876168)
0:003> !dumpheap -stat 023e1000 033db630
total 133 objects
Statistics:
MT   Count   TotalSize Class Name
001521d0       66     2081792     Free
7912273c       63     6663696 System.Byte[]
7912254c       4     8008736 System.Object[]
Total 133 objects

Размер кучи больших объектов равен (16 754 224 + 16 699 288 + 16 284 504) = 49 738 016 байт. Между адресами 023e1000 и 033db630 8 008 736 байт занимает массив объектов System.Object, 6 663 696 байт занимает массив объектов System.Byte и 2 081 792 байт свободно.

Иногда в отладчике общий размер кучи больших объектов менее 85 000 байт. Дело в том, что сама среда выполнения использует кучу больших объектов для размещения некоторых объектов, которые меньше большого объекта.

Поскольку куча больших объектов не сжимается, иногда она считается источником фрагментации. Фрагментация означает:

  • Фрагментация управляемой кучи, на которую указывает объем свободного пространства между управляемыми объектами. В SoS команда !dumpheap –type Free отображает объем свободного пространства между управляемыми объектами.

  • Фрагментация диапазона адресов виртуальной памяти. Это память, помеченная как MEM_FREE. Ее можно получить, используя различные команды отладки в windbg.

    Ниже приведен пример фрагментации в пространстве виртуальной памяти:

    0:000> !address
    00000000 : 00000000 - 00010000
    Type     00000000
    Protect 00000001 PAGE_NOACCESS
    State   00010000 MEM_FREE
    Usage   RegionUsageFree
    00010000 : 00010000 - 00002000
    Type     00020000 MEM_PRIVATE
    Protect 00000004 PAGE_READWRITE
    State   00001000 MEM_COMMIT
    Usage   RegionUsageEnvironmentBlock
    00012000 : 00012000 - 0000e000
    Type     00000000
    Protect 00000001 PAGE_NOACCESS
    State   00010000 MEM_FREE
    Usage   RegionUsageFree
    … [omitted]
    -------------------- Usage SUMMARY --------------------------
    TotSize (     KB)   Pct(Tots) Pct(Busy)   Usage
    701000 (   7172) : 00.34%   20.69%   : RegionUsageIsVAD
    7de15000 ( 2062420) : 98.35%   00.00%   : RegionUsageFree
    1452000 (   20808) : 00.99%   60.02%   : RegionUsageImage
    300000 (   3072) : 00.15%   08.86%   : RegionUsageStack
    3000 (     12) : 00.00%   00.03%   : RegionUsageTeb
    381000 (   3588) : 00.17%   10.35%   : RegionUsageHeap
    0 (       0) : 00.00%   00.00%   : RegionUsagePageHeap
    1000 (       4) : 00.00%   00.01%   : RegionUsagePeb
    1000 (       4) : 00.00%   00.01%   : RegionUsageProcessParametrs
    2000 (       8) : 00.00%   00.02%   : RegionUsageEnvironmentBlock
    Tot: 7fff0000 (2097088 KB) Busy: 021db000 (34668 KB)
    
    -------------------- Type SUMMARY --------------------------
    TotSize (     KB)   Pct(Tots) Usage
    7de15000 ( 2062420) : 98.35%   : <free>
    1452000 (   20808) : 00.99%   : MEM_IMAGE
    69f000 (   6780) : 00.32%   : MEM_MAPPED
    6ea000 (   7080) : 00.34%   : MEM_PRIVATE
    
    -------------------- State SUMMARY --------------------------
    TotSize (     KB)   Pct(Tots) Usage
    1a58000 (   26976) : 01.29%   : MEM_COMMIT
    7de15000 ( 2062420) : 98.35%   : MEM_FREE
    783000 (   7692) : 00.37%   : MEM_RESERVE
    
    Largest free region: Base 01432000 - Size 707ee000 (1843128 KB)
    

Фрагментация виртуальной памяти чаще вызывается временными большими объектами, которые требуют частых сборок мусора для получения новых сегментов управляемой кучи от ОС и возвращения ОС пустых сегментов.

Чтобы проверить, вызывается ли фрагментация виртуальной памяти кучей больших объектов, можно установить точку останова на функциях VirtualAlloc и VirtualFree, чтобы увидеть, кто ее вызывает. Например, чтобы узнать, кто пытался выделить блоки виртуальной памяти ОС размером более 8 МБ, можно установить точку останова следующим образом:

bp kernel32!virtualalloc "j (dwo(@esp+8)>800000) 'kb';'g'"

Эта команда останавливает отладчик и показывает стек вызовов только в том случае, если VirtualAlloc вызывается с объемом выделенной памяти более 8 МБ (0x800000).

В CLR 2.0 добавлена функция накопления виртуальной памяти, которую можно использовать в случае, когда сегменты (включая кучи больших и маленьких объектов) часто фиксируются и освобождаются. Для настройки накопления виртуальной памяти установите флаг запуска STARTUP_HOARD_GC_VM через API размещения. Вместо возвращения пустых сегментов операционной системе CRL освобождает память в этих сегментах и помещает их в список ожидания. (Обратите внимание, что среда CLR не выполняет эти действия со слишком большими сегментами.) Позже среда CLR использует эти сегменты для удовлетворения запросов на новый сегмент. В следующий раз, когда приложению потребуется новый сегмент, среда CLR возьмет его из списка ожидания, если найдет сегмент подходящего размера.

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

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