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

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

Внимание

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

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

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

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

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

Вновь распределенные объекты образуют новое поколение объектов и неявно являются сборками поколения 0. Однако если это большие объекты, то они попадают в кучу больших объектов, которая иногда называется поколением 3. Поколение 3 — это физическое поколение, которое логически собирается как часть поколения 2.

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

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

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

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

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

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

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

Figure 1: A gen 0 GC and a gen 1 GC
Рис. 1. Сборка мусора поколения 0 и поколения 1.

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

Figure 2: After a gen 2 GC
Рис. 2. После сборки мусора поколения 2

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

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

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

Figure 3: LOH after a gen 2 GC
Рис. 3. Куча больших объектов после сборки мусора поколения 2

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

    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

Счетчики производительности памяти .NET CLR обычно являются хорошим шагом в изучении проблем с производительностью (хотя мы рекомендуем использовать события ETW). Распространенным средством просмотра счетчиков производительности является монитор производительности (perfmon.exe). Нажмите кнопку "Добавить" (CTRL + A), чтобы добавить интересные счетчики для процессов, которые вам нужны. Вы можете сохранить данные счетчика производительности в файл журнала.

Для loH относятся следующие два счетчика в категории памяти .NET CLR:

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

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

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

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

Screenshot that shows adding counters in Performance Monitor.

Вы также можете запрашивать счетчики производительности программным способом PerformanceCounter с помощью класса. Для loH укажите в качестве размера кучи больших объектов в качестве CategoryNameCounterNameпамяти .NET CLR.

PerformanceCounter performanceCounter = new()
{
    CategoryName = ".NET CLR Memory",
    CounterName = "Large Object Heap size",
    InstanceName = "<instance_name>"
};

Console.WriteLine(performanceCounter.NextValue());

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

Примечание.

Мы рекомендуем использовать события ETW вместо счетчиков производительности, так как ETW предоставляет гораздо более подробную информацию.

ETW-события

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

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

perfview /GCCollectOnly /AcceptEULA /nogui collect

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

Screenshot that shows ETW events in PerfView.

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

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

perfview /GCOnly /AcceptEULA /nogui collect

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

Screenshot that shows a garbage collector heap view.

Как видите, это очень простой тест, который выделяет память для большого объекта из метода 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, 8008 736 байт занимают System.Object массив объектов, 6663 696 байт занимают массив объектов, а 2 081 792 байта занимают System.Byte свободное пространство.

Иногда в отладчике общий размер кучи больших объектов менее 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)
    

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

Чтобы убедиться, что loH вызывает фрагментацию виртуальной машины, можно задать точку останова в 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 использует один из этого резервного списка, если он может найти достаточно большой.

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

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