Сборщик мусора написан в .net framework/core runtime пакетах Рихтер описывает его работу в .net framework. Создание объекта ссылочного типа алгоритмически можно описать так:
- Подсчитать сколько необходимо выделить памяти под поля объекта(все поля включая и поля базового класса)
- Подсчитать сколько необходимо выделить памяти на системные поля. Это указатель на объект тип и индекс блока синхронизации.
- Проверить достаточно ли места в куче, если да, то обнулить байты начиная с NextObjPtr и создать объект, передав NextObjPtr в конструктор (как this). В конструкторе происходит инициализация всех полей объекта(для этого и передается NextObjPtr как this) и оператор new возвращает ссылку на новый объект. Перед возвратом NextObjPtr сдвигается и теперь указывает на первое свободное место после только что созданного объекта.
Сам этот процесс довольно прост и заключается в простом сдвиге указателя, (пока мы не рассматривали вариант, если в шаге 3 памяти не хватило). Зная такой алгоритм выделения памяти, можно сделать вывод - объекты которые используются в программе совместно, нужно располагать в памяти также рядом, потому что процессор читает данные порциями равными линейки кэша процессора, т.е будут также считаны еще несколько(а возможно и не будут если объект идеально поместится) объектов, и процессору не придется грузить их из RAM при обращении к этим объектам.
Чтобы этот подход продолжал работать нужно как-то собирать объекты, которые уже не используются, иначе вся память процесса будет исчерпана. Для этого используется сборщик мусора. Алгоритм сборки мусора основан на анализе объектов, в определеный момент времени CLR останавливает все потоки и начинает просматривать объекты из кучи. Перед работой GC определяются корни:
- Переменные в стеке или в регистрах процессора(не все, какие именно рассчитывает JIT). У каждого потока есть структура eager roots collection - она хранит какие из локальных переменных находятся в регистрах, а какие уже нет(т.е они могут быть собраны). Те которые еще находятся являются корнями.
- Таблица хэндлов (в ней хранятся объекты привязанные с использованием GCHandle Api)
- Статические поля
- Очередь финализатора (далее про это)
- Ссылки из старших поколений (если убираем младшие поколения, далее про это)
Сборка мусора начинается со стадии маркировки: Проходимся по всем объектам в куче и в младших разрядах methodtable проставляем значение 0 - указывающее, что объект может быть собран GC.
Далее проходимся по активным корням в глубину(например если корень указывает на экземпляр класса - просматриваем все его поля и проставляем объектам в куче, на которые они ссылаются, в младший бит methodtable блока синхронизации единицу - тем самым указывая, что они не должны быть собраны, если при прохождении мы встречаем объект у которого уже проставлена единица - не заходим в него, тем самым игнорируя циклические ссылки.
Тем самым мы сразу отсекаем island of isolation( если группа объектов ссылается только друг на друга и не ссылаются ни на кого извне) - они просто не попадут в активные корни.
После завершения маркировки происходит перемещение кучи - compacting. В результате выжившие объекты смещаются друг к другу и заполняют освободившееся место(это позволяет избежать фрагментации кучи).
Теперь нужно обсудить поколения. Они фиксированы по памяти, первое и второе размером от нескольких сотен KB до 256МБ в x64 CLR(Это регулируется CLR). Третье не ограничено. В нулевое объект попадает сразу при рождении, в первое если переживет первую сборку мусора, во второе - вторую сборку. GC начинает работать если объем памяти поколения превосходит определенное значение. Это подсчитывается при создании нового объекта(а именно при выделении свободного места в куче)
Если CLR определяет, что поколение 0 переполнено, иницируется сборка мусора в этом поколении. При этом если активные корень указывает на объект из 1 или второго поколения - то он не анализируется, т.к это оказалось очень дорого, каждый раз вычислять весь граф объектов, особенно если мы не собираемся удалять эти объекты. Но может возникнуть ситуация, что поле старого объекта указывает на объект 0 поколения и если не просматривать старые объекты - то мы можем в итоге удалить используемый объект. Поэтому придумали card table, это структура данных в которой хранятся указатели на 128 байтные ди апазоны в куче. Если в этом диапазоне есть объект первого или второго поколения поля которого изменились с момента последней сборки GC - то в card table для данного диапазона проставляется 1. При сборке нулевого (или первого поколения) GC также просматривает все объекты первого и второго (или второго соответсвенно) поколений, которые маркированы в card table, если новое поле указывает на объект нулевого (или нулевого и первого) поколения, то по этим полям также нужно пройти, и для всех найденных объектов проставить младший бит в methotable блока синхронизации на 1.
Осталось понять, кто отвечает на проставление этих значений в card table - это JIT. Он при каждом изменении поля ссылочного типа компилирует дополнительных код.
- Если объект поле которого меняется не принадлежит к 1 или второму поколению - JIT завершает доп. работу
- Если пренадлежит, то он проставляет отметку в card table, что нужно дополнительно проверить этот диапазон памяти при следующей сборке мусора.
- После завершения сборки - card table обнуляется.
Для записи используется write memory barier(который заставляет процессоры сихнронизироваться, тем самым - потеря в производительности). Даже если объект принадлежит не к первому/второму поколению, дополнительная проверка принадлежности к этим поколениям - накладные расходы. Если принадлежит, то сюда добавляются накладные расходы на запись в card table.
Поэтому запись в поля структуры или статического поля - производительнее, чем экземплярного поля класса
После сборки мусора в 0 поколении, выжившие объекты перемещаются в первое поколение и это поколение может переполниться, тогда при следующей сборке мусора(когда переполнится и 0 поколение) произойдет сборка мусора в 0 и 1 поколении. То же самое справедливо если второе поколение переполнится - будет запущена полная сборка мусора.
Также сборщик мусора может динамически изменять размер поколения 0, например если после каждой сборки мусора в нем, будет оставаться мало выживших объектов, то его размер можно уменьшить, тем самым уменьшится количество выживших объектов после каждой очистки, это позволит не фрагментировать память и перемещать NextObjPtr сразу в начало поколения 0.
Если же, наоборот после каждой сборки остается много выживший объектов сборщик может поднять размер 0 поколения, тем самым он будет вызываться реже и очищать больше.
GC может работать в двух режимах - в режиме рабочей станции и в серверном. В режиме рабочей станции GC старается не мешать работе ОС, и выполняет облегченный режим сборки, стараясь никому не мешать. В случае сервера одновременно выполняется сборка мусора в нескольких потоках, каждый поток получает свою область кучи, которую обрабатывает, все ресурсы процессоров бросаются на сборку мусора. Если процессор один, то такой режим бессмысленен и вызван не будет. Чтобы узнать в каком режиме идет работа, можно запросить поле GCSetting.IsServerGC
По умолчанию запускается фоновая сборка мусора(параллельная сборка) - один поток проходится по объектам и помечает их. При этом если происходит переполнение 0 или 1 поколения - выполняется стандартная сборка мусора(видимо младший бит в methodtable остается таким, каким его проставил фоновый поток(проверить)). Если же необходимо собрать мусор поколения 2, то сборки не происходит - происходит расширение поколения 0. Когда фоновый поток набрал достаточно сведений(сколько?), то все потоки приложения останавливаются и решается вопрос, необходимо ли дефрагментировать кучу и сдвигать ссылки(если да, происходит дефрагментация и сдвиг ссылок, если нет - ничего)) (вопрос: а как происходит вообще сборка второго поколения(потому что судя по описанию у Рихтера она вообще в таком случае не происходит, или происходит только когда фоновый поток закончит)). По умолчанию запускается фоновая сборка мусора.
Также Рихтер пишет, что иногда необходимо вручную регулировать когда нужно запускать сборку мусора. Для этого есть поле GCSettings.GCLatencyMode:
- Batch - отключает параллельную сборку мусора
- Interactive - включает параллельную сборку мусора
- LowLatency - сборщик мусора не собирает поколение 2(но если вручную вызвать GC.Collect() - соберет). Используется для маленьких кусков кода, в которых должна отсутсвовать любая задержка.(поддерживается только рабочей станцией)
- SustainedLowLatency - запрещает сборку второго поколения, если существует свободная память (поддерживается рабочей станцией и сервером). Предназначено для выполнения длительных операций
Чтобы заставить GC произвести сборку мусора нужно вызвать метод static void Collect(int generation, GCCollectinMode mode, bool blocked)
- generation - номер поколения в котором нужно произвести сборку
- GCCollectinMode -
- Default - тоже самое что и GC.Collection без флагов, работает аналогично передачи Forced
- Forced - будет сборка мусора в поколениях от 0 до generation
- Optimized - будет сборка мусора только если он гарантировано сможет освободить много памяти или уменьшить фрагментацию.
- Boolean blocking - выполнять параллельную(фоновую) или непараллельную сборку мусора
Принудительная сборка мусора(даже в режиме Optimized не рекомендуется в большинстве случаев. Лучше полагаться на эвристики Garbage Collector'a, однако иногда использование GC вручную оправдано, например:
-
При завершении нечастых операций, во время которых выделяется большой объем памяти. При таком сценарии возможно, что во втором поколении останется много занятой, уже не используемой памяти. Чтобы избежать вытеснение оперативной памяти программы в файл подкачки(более медленная память, вытеснение происходит, когда кусок оперативной памяти перестает использоваться), можно выполнить полную сборку мусора
-
При использовании сборщика мусора в режиме LowLatency, когда возникает пауза и можно собрать мусор. Если приложение очень чувствительно к выбору моментов времени на сборку мусора, то стоит запускать сборку в моменты простоя.
-
Когда нужно приостановить работу пока не будут вызваны все финализаторы. Тогда нужно воспользоваться последовательным вызовом
GC.Collect();
иGC.WaitForPendingFinalizers()
. Но предпочтельнее использовать детерминированную финализацию
GC предоставляет некоторые возможности отслеживать статистики, например long GC.CountCollections(int generation)
- количество сборок мусора в конкретном поколении.
GetTotalMemory(bool forceFullCollection) // forceFullCollection - дождаться выполнения сборки мусора
- показывает количество выделенной памяти .
Также Рихтер отмечает еще методы (WaitForFullGCApproach, WaitForFullGCComplete и CancelFullGCNotification)
, но ничего подробно про них не пишет
Поскольку в C# можно вызывать неуправляемый код, значит должна быть предусмотрена возможность освобождать ресурсы. Для этого в C# есть финализаторы. Они вызываются когда объект уже собран сборщиком мусора в конце его работы. Поэтому работу финализатора можно сравнить с воскрешением объекта. Его кстати можно совсем воскресить, записав в статическое поле.
Код финализатора:
public class Test{
public ~Test(){
}
Это специальный синтаксис для protected метода Finalize в object'e. на самом деле он выполняет логику по финализации записанную в нем(в блоке try) и потом вызов base.Finalize();(в блоке finally).
Процесс финализации достаточно сложен, но логичен:
- Если объект определяет финализатор, то при создании такого объекта ссылка на него будет записана в список финализации.
- Перед очисткой кучи(скорее всего в стадии планирования) GC проверяет ссылается ли что-то из списка финализации на помеченные на удаление объекты, если да, то такой объект переводится в очередь финализации.
- Объекты ссылки на которые оказались в очереди финализации не собираются сборщиком мусора, также не собираются и объекты на которые ссылаются объекты очереди финализации. После сборки очередь финализации очищается, и очищенные объекты будут собраны сборщиком мусора при следующем проходе(возможно и позже), поскольку на них не ссылаются ни корни, ни очередь финализации.
Вызов методов Finalize происходит в отдельном потоке(высокоприоритетном, чтобы избежать блокировок), если этот поток блокируется, то финализируемые объекты продолжают существовать в приложении. Если код финализатора выбрасывает exception - приложение аварийно завершается
Рихтер говорит использовать финализатор только для освобождения системных ресурсов. А вообще существует специальный класс, которым он советует пользователься при работе с неуправляемым ресурсом - это SafeHandle
- метод инкапсулирует использование IntPtr(дескриптора ОС) и далее там реализован паттерн Dispose. Для того, чтобы использовать этот класс нужно унаследоваться от него переопределив конструктор, принимающий IntPtr и значение указывающее нужно ли уничтожать системный ресурс при вызове финализатора. Для корректной обработки нужно переопределить методы :
public abstract bool IsInvalid{get;} // определяет валиден ли IntPtr
public abstract bool ReleaseHandle()
Также класс SafeHandle является производным от класса CriticalFinalizerObject, который имеет свойства вызываться после всех остальных финализаторов в цепочке(таким образом из финализаторов не наследующих CriticalFinalizerObject можно к нему обращаться), его финализатор вызывается всегда(даже если приложение аварийно завершается) и JIT встречая такой класс сразу компилирует все финализаторы, чтобы не возникла нехватка памяти при компиляции этого метода и OutMemoryException мог быть обработан.
Объекты наследующие SafeHandle полезны, так абстрагирует от обработки финализации неуправляемого ресурса, так же этот класс защищает системный ресурс используя подсчет ссылок(DangerousAddRef, DangerousGetHandle,DangerousRelease
)
Как показали тесты с финализацией есть проблема. За выполнение финализаторов отвечает отдельный поток финализации в котором, который создается CLR при старте, и этот поток, может конкурировать с прикладными потоками, по крайней мере мой тест показал это на netcoreapp3.1. Поэтому если финализируемые объекты создаются быстрее чем отрабатывает очередь финализации, то гарантирована утечка память, я тестировал на таком коде:
public class File1
{
public File1()
{
Console.WriteLine(Q);
}
public static int Q;
~File1()
{
Q++;
Console.WriteLine("Before Finalizer " + Q);
Console.WriteLine("After Finalizer " + Q);
}
}
class Program
{
private static int i = 0;
static void Main(string[] args)
{
while (i < 1000_000_000_000)
{
_file = new File1();
i++;
}
}
}
В итоге в консоль выводилось что-то подобное:
123
123
Before Finalizer
124 <- выполнение ctor'a
124 <- выполнение ctor'a
After Finalizer
Также нужно понимать, что финализаторы, даже если объекты наследуют CriticalFinalizerObject, могут не выполниться. Например, такое возможно, когда процесс грубо прерывается (через диспетчер задач). Еть также еще несколько сценариев, когда финализаторы не вызываются вообще. Также CLR ограничивает время работы финализатора двумя секундами, а общее время работы всех финализаторов 40 секундами(точно верно для Framework4.5)
В чем проблема финализатора? Рихтер приводит пример с FileStream,со FileStream, необходимо вызвать метод Dispose, который вызовет метод Dispose у SafeFileHandle(наследника SafeHandle) тем самым освободим дескрипотр. Но давайте предположим что мы также еще используем StreamWriter:
var fs = new FileStream("in.txt");
var sw = new StreamWriter(fs);
// logic
StreamWriter выполняет внутреннию буфферизацию при записи и пишет данные в FileStream только при уничтожении (финализации или Dispose). Предположим теперь, что мы дошли до вызова финализатора FileStream и при этом не финализатор StreamWriter еще не вызвался, мы потеряем буферезированные данные. Поэтому разработчики не стали писать финализатор для StreamWriter'a , реализовав только метод Dispose, тем самым если пользователь вызван Dispose у StreamWriter'a то:
- Буфер сбросился в поток(FileStream)
- Вызвался метод Dispose FileStream
- А далее в этом Dispose был вызван Dispose SafeFileHanle
Тем самым можно работать с ресурсами и неуправляемым кодом. Если был просто вызван Dispose FileStream - данные из буффера были бы потеряны
Некоторые советы по реализации метода Dispose.
- Если какое-то поле реализует интерфейс IDisposable, то сам объект тоже должен
- После вызова IDisposable у класса, у него также могут остаться активные клиенты, поэтому хорошо предоставить публичное свойство для проверки произошел ли Dispose объекта и при обращение к уже очищеному классу выкидывать DisposedException.
- Несколько методов Dispose вызванных подряд не должны бросать исключения, только отдавать управление если вызов уже не нужен
- Dispose, по мнениею Рихтера, нужно определять только если объект работает с неуправляемым кодом
- В случае реализацию интерфейса IDisposable, не помешает также реализовать паттерн Disposable, тем самым обработав очистку ресурсов и при вызове Dispose и при вызове финализаторы
- Использовать using, вместо ручного написание, потому что при его использовани Dispose вызывается в блоке finally, повышая вероятность быть вызванным. Также в using можно передать несколько параметров реализуюзий IDisposable
Если в режиме рабочей станции(при запуске ui приложений) происходит выделение памяти из разных потоков, это очень невыгодно, поскольку все потоки должны ждать пока один выделяет память. Если же включен режим сервера, то куча разделяется на несколько сегментов и каждому из них отдается отдельный поток. В таком случае выделение памяти будет намного быстрее, потому что не будет конфликтов(парадоксально, но запуская многопоточный код в режиме сервера и в режиме рабочей станции во втором случае код отрабатывал быстрее, возможно что-то другое также влияет)
Память для каждого потока выделяется в своем allocation context'e, соответсвенно эта память выделяется в первом поколении. Если выясняется, что в такущем allocation context'e недостаточно места то он расширяется, если его расширить неудается(справа уже есть другой allocation context, но GC пытается его переместить на свободный участок, чтобы он мог расшириться. allocation context - скользящее окно, которое указывается место выделения памяти для каждого потока. AllocationContext расширяется SOH: 0) Самый простой вариант, влезли в allocation context, в таком случае просто передвинули указатель
- Попытаться расширить allocation context(собрать мусора и попробовать еще раз)
- Попытаться найти свободный участок, чтобы перенести allocation context туда и расширить контекст туда
- Попытаться закоммитить память(нужно попросить у windows расширить кучу, очень долго)
- Произвести сборку мусора
- Выбросить OutOfMemoryException
LOH:
Сюда добавляются объекты которые весят более 85000 кбайт, в куче больших объектов не запускается компакт(сжатие кучи), только поиск свободных участков
Алгоритм в данном случае аналогичен алгоритму SOH, только в SOH один сегмент(почему? и и что такое segment) и в данном случае поиск свободных участков происходит по всем сегментам.
Для поиска свободных участков хранится специальная структура. Это двухуровневый linkedlist, на первом уровне хранится массив бакетов, в котором есть ссылка на начало и конец, при поиске мы проходимся по этому списку от начала до конца и ищем первый подходящий по памяти кусок.
На каждого ядра создается два хипа, LOH и SOH, при этом возможна ситуация, когда поток(следовательно и allocation context) перепрыгивает с одного ядра на другое. Такое случается, когда одно ядро(с какого перепрыгивает) аллоцирует намного больше памяти чем то на которое перепрыгивает.
Это участки памяти, которые выделяются операционной системой специально для кучи. Когда runtime понимает, что SOH переполнен он пытается как-то расширить allocation context в котором происходит выделение, пытается передвинуть его на другое место, пытается собрать мусор, после сбора снова пытается расширить. И если все эти манипуляции не помогли увеличить SOH. Это можно делать пытаясь выделить дополнительно(закоммитить память) в уже выделенном сегменте(а это долго посколько идет обращение к операционной системе), но если вся память этого сегмента уже закомиичена, то у операционной системы запрашивается новый сегмент памяти.
Под второе поколение(в том числе и под LOH), выделяются несколько сегментов, под 1 и 0 поколение - один сегмент(эфемерный).
Если один из сегментов второго поколения (only2) опустел, то при следующей сборке месора в поколениях 0 и 1, память, выделяемая под эти поколения, будет перемещена в опустевший второй сегмент, а второй сегмент переместиться в память, которую занимали 0 и 1 поколения до сборки мусора. Такое копирование позволяет(как я думаю) разместить поколения 1 и 0 сплошным участком в памяти, избежав фрагментации.
Размеры выделяемых сегментов в x32 для рабочей станции 16МБ, а для сервера от 16 до 64 MB. В x64 для рабочей станции от 128МБ до 256МБ, а для сервера от 128МБ до 2ГБ.
Сегменты выделяются из виртуального адресного пространства, которое выделяется при старте процесса, далее, когда приложению нужен дополнительный сегмент памяти, то он выделяется. Если после сборки мусора он становится пустым, то CLR считает необходимым вернуть его операционной системе, однако это не всегда так. Можно запросить сохранение сегмента для будущего использования(это пригождается для ASP.NET приложений, которые пиково много выделяют и особождают память, там режим сохранения сегмента выставлен автоматически).
Иногда не представляется возможным выделить сегмент памяти, хотя размер выделенной уже памяти не превосходит 2Гб(для 32x системы адресное пространство составляет 2 ГБ), потому что в каждом сегменте может быть "испорченая память". Например, память может быть занята неупправляемым кодом(при запуске unsafe, он не будет выделять память в clr пулах, а создаст свой. Или например при наличии динамических сборок(а в чем тут проблема?). Это решается переходом на 64x разрядную систему, с размером адресного пространства процесса равного 8ТБ, либо, если это невозможно, то ограничением выделения памяти unsafe кодом и ограничением количества динамических assembly.
В методе корни локальных переменных определяются по eager root collection. На основе этой таблицы, если у нас вдруг запускается сборка мусора во время выполнения метода определяется используется ли переменная метода дальше или ее уже можно собрать. Для этого определения анализируется содержимое регистров. Поэтому если мы запускаем таймер в другом потоке, то он может быть собран посередине метода и прервет выполнение.
Чтобы продлить срок использования переменной можно использовать GC.KeepAlive(object)
- в таком случае ссылка на object будет сохранена, сам метод GC.KeepAlive - пустой и нужен только для того, чтобы оставить ссылку на локальную переменную(object)
Объекты маркируются и ссылка ставится в первые несколько битов MT(Method table), потому что первые два байта MethodTable пустые, и добавлены только для выравнивания, чтобы процессор считывал данные ровными порциями(4 байта для 32 разрядной системе, 8 на 64 разрядной)
В SOH может использоваться как Sweet так и Compacting алгоритмы менеджмента памяти. Для того, чтобы определить какой алгоритм применять вызывается фаза планирования. Она вызывается после фазы маркировки
После этой фазы у нас есть информация какой объект должен остаться живым(plug), а какой можно собрать(gap). Gap - хранит информацию об уже не нужных объектах, поэтому ее можно перетереть. Перетираются последние три байта в gap'e, который предшествует plug'у. В первый бит записывается размер gap'a, во второй смещение на которое должен переместиться plug и в последнем какой-то plug находящийся слева и справа, в итоге получается двоичное дерево.
По такому принципу строится BST (Binary Search Tree), но строить BST по большой куче невыгодно(потому что оно должно быть сбалансированным, и при добавлении новых элементов должно перебалансироваться(предположение)), поэтому SOH разбивают на куски размером 4096 бит, и для каждого строится дерево. Указатели на вершину этого дерева хранятся в brick table
Соответсвенно, если там 0 - то в диапазоне нет данных(вся память - gap'ы), если там число больше 0, то это указатель на вершину(корень) BST, если число меньше нуля, то все эти данные - plug и там лежит число - количество bricks на которое нужно сместиться к началу BST, которому принадлежит этот plug.
В C# можно pin'ить объекты(ключевое слово csharp fixed
), когда объект запинен через fixed, то в регистрах делается отметка, что происходит работа с pinned объектом и этот объект по особенному обрабатывается GC. Он не перемещается по памяти, а всегда остается на месте, поскольку фаза планирования проставляет специальный флажок в gap'ах, бывает ситуация, когда этот флажок проставить нельзя:
В таком случае объект идущий сразу за запиненным объектом, также не переместиться сборщиком мусора
Но объект, который располагается рядом, может быть переписан, для этого используется специальная очередь, pinned plug queqe. В saved_pre_plug сохраняется информация из только что переписанного объекта, которая в конце возвращается в этот объект
В случае идущих подряд за запиненным объектом, объектов, к запиненному прицепляется только первый, для хранения информации об остальных объектах перезаписывается первый.
Насколько я понимаю pinned объект в данном случае трактуется как pinned plug, что отличается от обычного pinned, и это вынуждает обрабатывать его по особенному.
Однако если запиненный объект располагается в LOH, не нужно бояться пиннинга(поскольку в LOH не работает Compacting), он никак не повлияет на работу GC.
В LOH планирование запускается только когда программист заставляет GC собрать мусор используя compacting, иначе всегда срабатывает Sweet, который после фазы маркировки помещает умершие объекты участки в список свободных участков. Когда программист запускает сжатие кучи больших объектов, никакие объекты не перезаписываются, offset для хранения offset'a, выделяется дополнительная память. таким образом в LOH фаза планирования - подготовка к Compacting.
Есть несколько причин почему в SOH может быть запущен Compacting:
- Последний шанс перед OutOfMemory
- Сликом большая фрагментация кучи - показатель фрагментации собирается для каждого поколения при работе в фазе планирования, для первого поколения Fragmentation Size > 40,000 байт и Fragmentation Ratio(Общий размер фрагментированных участков в поколении/ общий размер поколения) > 50%, то запускаем compacting.
Вот таблица, когда compacting запускается:
- Было израсходовано все место в эфемерном сегменте(сегмент в котором живут поколения 0 и 1)
- Высокая степень фрагментации
- Процесс занял очень много места в памяти
SOH После построение BST принимается решение какой алгоритм сборки мусора выбрать: sweet или compact, если память выделяется равномерно и отсутсвует высокая степень фрагментации выбирается sweet, тогда запоминается положение gap'ов в список свободных участков. Далее происходит дополнительная работа по обновлению очереди на финализацию(скорее всего в данный момент она очищается). В конце производится перестроение сегментов памяти, если образовался пустой сегмент он переходит из состояния committed обратно в reserved, чтобы мог использоваться другим процессом.
Если выбирается compact, то по построенному BST находятся все offset'ы, потом для всех объектов, адреса, на которые они указывали заменяются новыми(со смещением соответсвующим offset'у). В процессе замены просматриваются:
- Ссылки со стека - все локальные переменные и параметры методов, пережанные через стек + eager root collection
- Ссылки с полей объектов, полученные через card table
- Ссылки с полей LOH/SOH объектов полученные при построении BST
- Ссылки в запиненных объектах
- Ссылки в финализируемых объектах(в списке и очереди финализации)
- Ссылки в Handle tables
Далее куча сжимается, saved-pre-plug и saved-post-plug, которые образовались из-за запиненных объектов - восстанавливаются.
Исправляем положение поколений.
Далее неиспользуемые сегменты раскоммичиваются или удаляются. Место перед запиненными объектами добавляется в список свободных участков.
LOH Не использует фазу планирования, потому что sweet для LOH - основной алгоритм в котором происходит сборка мусора. В ходе алгоритма выбирается список gap'ов они склеиваются и по ним строится список свободных участков(Если уже существовал свободный участок и новый прилегает к нему - то они состыковываются, иначе добавляются в список свободных участков)
Алгоритм compact'a(выполняется только если запрошен программистом) LOH идентичен compact'у в SOH
-
Снижаем кросспоколненческую связность. Поскольку существует карточный стол, который указывает на ссылки из старшего поколения в младшее, то если привязать младшие объекты к старшим, придется их тоже обходить, поскольку если в card bundle table где-то стоит единичка, а не 0, приходится спускаться в card table конкретного бакета и просматривать какие биты в card table, если в какой-то бит 1 - просмотреть 128 б памяти(одна ячейка в card table указывает на 128 б, 256б байт для x32, x64 архитектуры, а одна ячейка в card bundle table указывает на 4096б. 8192 б памяти). Получается если в какой-то ячейке card bundle table стоит 1, нужно просмотреть еще 32 ячейки card table, если в какой-то ячейке card table стоит 1 - то 128/256 байт чистой памяти(прямо пройтись по всем объектам и их полям при фазе маркировки). Хорошее решение для этого - следить за большими объектами, запрещая им лезть к маленьким. Если приходится все-таки лезть, то нужно расположить эти объекты рядом, чтобы уменьшить количество единиц в card table.
-
Избегать сильной связности. Для сжатия необходимо обойти BST и заменить все ссылки на новые, при этом если завязаться на одно поле в нескольких местах придется исправлять эту ссылку несколько раз, особенно если создать новое поле, которое будет ссылаться на старое и объект этого нового поля попадет во первое или второе поколение, тогда включается card table
-
Мониторить использование сегментов. Если при каком-то действий происходит Stop the world, то, возможно происходит выделение сегмента, это можно просмотреть под профилятором. Решение - использование пулов объектов. Это решение уменьшит количество единичных битов с карточного стола и количество выделенных бакетов в куче свободных участков.
-
Не выделять память в нагруженных участках кода. Такие участки заставляют объекты нулевого или первого поколения быстро уходить во второе, коммитить новую память под поколения. Поэтому нужно свести количество аллокаций к минимуму, не использовать лямбды или боксинг, для временных объектов использовать структуры. В методах, которые выполняются долго, как можно скорее переставать использовать временные локальные объекты (создал объект, сразу используй и оставь), чтобы GC имел возможность собрать его из нулевого поколения, не переводя его в старшие поколения.
-
Избегать лишнее выделение памяти в LOH. Не хранить массив сплошным блоком памяти, а использовать чанки, не превосходящие 85000КБ.
-
Не выделять память в нагруженных участках кода Использовать Span stackalloc, которые не будут аллоцировать память в куче, а только на стеке, тем самым при выходе из метода будут уничтожаны и не выделят память в куче.
-
Избегать пиннинг. Он фрагментирует память и удерживает объект выделенный сразу после пиннинга на одном месте, препятсвуя его сборке мусора. Тем самым может образовать ссылку с карточного стола на этот объект, а в последствии переход этого объекта в более старшее поколение
-
Избегать финализации. Финализаторы удерживают финализируемые объекты и зависимые от них перенося их в более старшее поколение, получается, что объект нулевого поколения со всеми зависимостями может оказаться в первом, тем самым расширив кучу. Если эти объекты содержат ссылки на младшее поколение, то порождаются ссылки в карточном столе. Также они фрагментируют хип из-за чего при сборке мусора может быть выбран алгоритм compacting'a вместо sweep.
-
Избегать большое количество потоков, потому что много потоков в Gen0 выделяют много allocation context'ов (один на каждый поток), расширяют нулевое поколение, переводят объекты, которые должны уничтожиться в 0 поколении, в первое.
-
Избегать траффика объектов разного размера. Такой траффик приводит к фрагментации кучи, потому что большие объекты(чаще) живут дольше чем маленькие, и между большими объектами образуются пустоты, это заставляет выбирать compacting вместо sweep'a, а это очень дорого. Если это критично, то можно разорвать логическое создание объектов(соответсвующее логическому ходу программы), создав схожие объекты рядом, а потом инициализируя их.
Когда происходит работа с неуправляемой памятью, то GC не может точно определить сколько памяти реально выделено процессу(поскольку выделяется память в небезопасном контексте), поэтому для того, чтобы держать его в курсе, нужно сообщать информацию об этом. Для этого используются методы GC.AddMemoryPressure(int bytesAllocated) и GC.RemoveMemoryPressure(int byteAllocated). Если работа происходит с ресурсом который можно создать не более n штук, то можно использовать HandleCollector(почему бы не просто не диспозить такие объекты? ведь HandleCollector просто вызывает GC.Collect, когда выделено слишком много таких объектов) Пример использования:
public class NotepadWriter
{
private readonly ITestOutputHelper _testOutputHelper;
private Process _process;
private static HandleCollector _handleCollector
= new HandleCollector("NotepadWriter", 1, 3);
public NotepadWriter(ITestOutputHelper testOutputHelper)
{
_testOutputHelper = testOutputHelper;
_handleCollector.Add();
_process = Process.Start(@"C:\\Windows\\System32\\notepad.exe");
}
~NotepadWriter()
{
_handleCollector.Remove();
_testOutputHelper.WriteLine($"GC, collect");
_process.Kill();
}
}
Если открываем 3 Notepad'a, то запускается сборка мусора(GC.Collect()); Я не разбирался с внутренним устройством этого класса, но скорее всего все именно так.
GcHandle - для каждого домена существует своя таблица хэндлов. Она представляет собой ссылки на объекты в куче и способ управления этими объектами. GcHandle используется для взаимодействия с неуправляемым кодом, например, если неуправляемый код работает с участком памяти, который выделен CLR.
Если необходимо вызвать unmanage код и передать ему управляемую память, нужно доверять этому неуправляемому коду, поскольку он может без проверок писать в управляемую память, даже в ту область, в которую ему нельзя писать.
Если есть доверие к неуправляемому коду, то нужно позаботиться, чтобы участок памяти, переданный ему, мог быть обработан. Если неуправляемый код принимает массив, то его необходимо запиннить(запретить GC перемещать при Compact'e кучи). Пинить можно используя конструкцию fixed(и нужно, поскольку это более производительно) либо GcHandleTable. Обсудим последнюю.
Для того, чтобы сказать GC не трогать объект, нужно поместить запись об этом объекте в GCHandleTable, Это делается методом
GCHandle.Alloc(object obj, GCHandleType options)
. Если передать в options GCHandleType.Pinned, то GC, просматривая GCHandleTable в фазе маркировки и планирования, пометит этот объект и не будет двигать при Compacting'e. Работа с неуправляемым c++ кодом может быть организована так:
var arr = new int[1000];
var gcHandle = GCHandle.Alloc(arr, GCHandleType.Pinned); // Создаем запись в GCHandleTable
var intPtr = gcHandle.AddrOfPinnedObject(); // Берем реальный адрес массива
NativeMethods.initialArray(intPtr, arr.Length); // вызываем неуправляемый метод
gcHandle.Free(); // <- удаляем запись из GCHandle
foreach (var item in arr) // Важно, чтобы после удаления из GCHandle осталась ссылка на массив из упр. кода, иначе массив будет собран GC
{
Console.WriteLine(item);
}
Но такой способ обладает недостатком - мы пиннуем объект, тем самым препятсвуем естессвенной работе GC(если массив размещается в LOH, то мы ничему не препятствуем). Если метод принимает GCHandle, то можно даже отказаться от пиновки:
var arr = new int[1000];
var gcHandle = GCHandle.Alloc(arr, GCHandleType.Pinned); // Создаем запись в GCHandleTable
var intPtr = GCHandle.ToIntPtr(gcHandle); // Кастим GCHandle к IntPtr
NativeMethods.initialArray(intPtr, arr.Length); // вызываем неуправляемый метод, который принимает GCHandle
gcHandle.Free(); // <- удаляем запись из GCHandle
foreach (var item in arr) // Важно, чтобы после удаления из GCHandle осталась ссылка на массив из упр. кода, иначе массив будет собран GC
{
Console.WriteLine(item);
}
Выигрыш такого подхода в том, что GC может свободно перемещать объект по памяти, При этом он будет обновлять ссылку на него в GCHandleTable, поскольку неуправляемому коду перед указатель на ячейку в этой таблице и он работает в этой ячейке, то он также будет работать с перемещенным объектом. Также Рихтер предлагает вариант, когда неуправляемому методу передается делегат обратного вызова, который он может дернуть, когда закончит работу.
GCHandleType.Weak - полезно, когда не нужна жесткая ссылка и если GC понадобится собрать объект - он может его свободно собирать, наш код не против, но если не собрал, то будем использовать его. Для этого GC после маркировки проходится по GCHandle, ячейкам хранящим Weak и если ячейка указывает на немаркированный объект, то она обнуляется, даже если этот объект позже воскреснет при финализации.
GCHandleType.WeakTrackResurrection - GC просматривает эту очередь после очиски finalization queque, тем самым ячейки указывающие на воскресшие объекты не обнуляются после финализации объектов. После очистки finalization queqe ячейки GCHandle с типом GCHandleType.WeakTrackResurrection проверяются и если ячейка указывает на немаркированный объект - то она обнуляется.
Объекты, которые помечены как GCHandleTable.Weak называются слабыми ссылками и для них создана специальная ООП обертка, которая позволяет не спускаться к использованию GCHandle api, а предоставить это классу WeakReference(через extern код).
Такой класс может использоваться для кэшей, если кэш должен быть неявным и не удерживать ссылок. Рихтер пишет, что это плохо, поскольку 0 поколение (в котором и создаются объекты) очищается очень быстро и кеш будет вынужден создавать новые объекты часто, по ео мнению кэш должен миксовать использованием слабых и сильных ссылок, т.к используя сильные ссылки мы можем сделать пул, который будет жить сплошным блоком памяти, память под него будет очищаться сплошным блоком, тем самым это не будет фрагментировать кучу. Также он предлагает преобразовать сильные ссылки в слабые, когда память будет заканчиваться.
Кроме WeakReference, есть еще ConditionWeakTable. Она также работает со слабыми ссылками, но не через GCCHandle api, а через extern методы, используя GCHandleTable. Добавленная запись будет располагаться в таблице до тех пор пока на нее есть ссылка, как только ссылки не будет - ключ помечается как недоступный. Это может помочь при memory leaks, когда таблица статическая и не хочется хранить там уже не нужные объекты, при расширении, недоступные объекты удаляются. Как мне кажется ConditionReferenceTable не очень нужны, если мы точно знаем сколько будем пользоваться объектом
private static ConditionalWeakTable<object, string> _weakTable = new ConditionalWeakTable<object, string>();
static void Main(string[] args)
{
var o = new object();
_weakTable.Add(o, "1"); // <- запись удалится, как только o перестанет быть корнем, защищает от leaks
}
В режиме рабочей станции можно сконфигурировать сборку мусора как в параллельном так и непараллельном режиме. При этом в параллельном режиме будет выполнена только маркировка, фаза планирования(точно?), и сжатия будет выполнена с приостановкой все потоков приложения. Сборка мусора выполняется в самом приоритетной потоке приложения.
Параллельный режим сборки имеет смысл для приложений с графическим интерфейсом, когда мы не хотим, чтобы пользователь видел какие-либо задержки вызванные сборщиком мусора и приостановкой всех потоков, поэтому можно согласиться с тем, что сборка будет выполняться медленнее, но при это UI поток не будет остановлен, а будет конкурировать с потоком сборщика мусора.
В ASP.NET приложениях сборка происходит с приостановкой всех потоков, поскольку предпочтение отдается пропускной способности, а значит нужно чтобы GC отработал как можно скорее. Однако в CLR 4.5 появился параллельный сборщик мусора для сервера, он использует фоновый и основной поток для сборки мусора для каждого процессора. Фоновый выполняет сборку во втором поколении(прикладные потоки могут работать параллельно),а основной включается при сборке мусора в младших поколениях, в момент работы основного потока, все прикладные останавливаются. Также фоновые потоки не могут выполнять compact кучи, только маркировку и sweet(точно?). (В каком режиме по умолчанию стартует приложение ASP.NET CORE?)
Переключаться между различными типами работы сборщика мусора, можно, используя GCSettings.LatencyMode, значения этого enum'a уже приведены выше. Отмечу только LowLatency и SustainedLowLatency, которые обозначают, что сейчас не нужно проводить полную сборку мусора
Интересно, что при большой фрагментации поколения 0 CLR может передвинуть границы поколений, чтобы фрагментированный участок памяти оказался в поколении 1, высокий процент фрагментации возможен, когда приложением создается много объектов, которые сразу пиннятся
Также размер поколения 0 сильно зависит от размеров L1 и L2 кэшей процессора, поскольку если в кэш процессора поколение помещается полностью, значит при его обходе количество промахов кэша процессора будет минимальным. В среднем размеры варьируются от 256 Кбайт до 4 Мбайт памяти.
Это буффер между поколениями, все, что случайно пережило поколение 0 не должно пройти во второе поколение. Финализируемые объекты гарантированно попадают в первое поколение.
Самая страшная ситуация, когда временный объект, попадает в поколение 2, этот процесс называется "кризисом среднего возраста"
Размер этого поколения до 2ГБ в 32x системе и 8ТБ d 64x. Коэффициент эффективности сборки в этом поколении не должен быть достаточно высок, потому что большинство объектов должно быть уничтожено в 0 и 1 поколениях.
Сборщик мусора предоставляет api, которое можно использовать для получения уведомлений о сборке мусора, например, метод RegisterForFullGCNotification(int maxGenerationThreshold, int largeObjectHeapThreshold)
- позволяет настроить сборщик мусора на получения уведомлений. Первый параметр принимает число от 1 до 99, и позволяет задать какой временный интервал должен быть между получением уведомления и полной сборкой мусора. Второй параметр также принимает число от 1 до 99 и задает временной интервал между получением уведомления и сборкой мусора в куче больших объектов. Далее для получения уведомлений предполагается ожидать пока блокирующая функция WaitForFullGCApproach
вернет управление, возвращаемое GCNotificationStatus, это enum, который возвращает сведения удалось ли подписаться на уведомления о следующей сборке мусора.
Также GC предоставляет метод WaitForFullGCComplete - получение уведомление о завершении полной сборки мусора. Возможная реализация:
public class GCWatcher
{
private Thread listener;
private EventHandler GCApproaches;
private EventHandler GCCompletes;
public void Watch()
{
GC.RegisterForFullGCNotification(50, 50);
listener = new Thread(() =>
{
while (true)
{
var waitStatus = GC.WaitForFullGCApproach();
if (waitStatus != GCNotificationStatus.Succeeded)
break;
// по подписке на этой событие можно выгружать данные из кеша, чтобы они были собраны сборщиком мусора,
// выигрыш из-за того, что они не выгружаются сразу, остаются доступными приложению
GCApproaches?.Invoke(this, EventArgs.Empty);
var completeStatus = GC.WaitForFullGCComplete();
if (completeStatus != GCNotificationStatus.Succeeded)
break;
// Можно проверить WeakReference объекты, оставшиеся в живых и удалить все собранные сборщиком
GCCompletes?.Invoke(this, EventArgs.Empty);
}
});
listener.Start();
}
public void Cancel()
{
GC.CancelFullGCNotification(); // <- waitStatus и completeStatus начинают возвращать GCNotificationStatus.Cancel;
listener.Join();
}
}
Также GC способен вернуть количество сборок мусора со старта приложения (GC.CollectionCount()
), объем занятой памяти в байтах (GC.GetTotalMemory(bool forceFullCollection)
), если передать true, то будет выполнена полная сборка мусора, что точно определить занятый объем. И метод GC.GetGeneration(object obj)
- определяет в каком поколении находится obj.
Сборщик мусора предоставляет более низкоуровневые средства мониторинга, например IHostMemoryManager, я не буду их описывать, поскольку мне сложно проверить, как они работают
- Закрепляйте объекты на как можно более короткий срок. Если неуправляемый код работает с закрепленным участком довольно долго, лучше выполнить копирование объекта в неуправляемую память, чем его закрепление
- Лучше закрепить один большой буффер, чем несколько маленьких, потому что большой буффер создаст меньшую фрагментацию, посколькую большие объекты(LOH) не перемещаются в куче
- Закрепляйте и переиспользуйте объекты созданные на старте приложения, поскольку старые объекты редко перемещаются по памяти и это должно уменьшить фрагментацию.
- Если приложение использует много закрепленных объектов, то, возможно, стоит выделить блок неуправляемой памяти и работать с ним.
С использованием C# указателей легко можно организовать работу с неуправляемой памятью без копирования данных в управляемые структуры.Выделение неуправляемой памяти происходит с помощью
System.Runtime.InteropServices.Marshal
- Старайтесь использовать детерминированную финализацию, нежели финализаторы объектов. Используйте финализаторы только для типов, к которым нельзя дотянуться из прикладного кода в приложении, а можно только довериться CLR и вызову финализатора. При этом при детерминированной финализации используйте
GC.SuppressFinalize(this)
, чтобы объект не попал в очередь финализации и не пережил дополнительный этап сборки мусора - В методах финализаторов нужно использовать журналирование (
Assert.Debug
), чтобы отследить, что финализация этого объекта происходит недетерминированно. - При реализации больших и сложных объктов, которые используют неуправляемые ресурсы, оборачивайте неуправляемые ресурсы в SafeHandle, и в сложных объектах используйте ссылки на SafeHandle. Это позволит собрать большие объекты раньше, чем произойдет уничтожение неуправляемых ресурсов при недетерминированной финализации, поскольку методы финализаторы будет реализовывать SafeHandle, а не большой объект.
Лучше использовать структуры где только возможно, поскольку:
- Они не влекут затрат на сборку мусора(при выходе из метода кадр стека уничтожается и все)
- Более быстрое выделение памяти на стеке (при выделении в куче, возможна сборка мусора, если allocation context не способен вместить создаваемый объект)
- Даже встраиваясь в ссылочный тип структуры распределяются в памяти более компактно(потому что не содержат SyncBlockIndex и указатель на VMT).При встраивании в ссылочный тип, не требуется использовать ссылки для обращения к ним(в отличии от ссылочных типов), таким образом устраняется необходимость хранить дополнительные ссылки. Также обращение к ссылочному типу содержащему поля-структуры более быстрое, поскольку если ссылочный тип попал в кэш процессора, скорее всего и его поля-структуры также попали(это называется локальностью доступа)
- Использование структур как полей ссылочного типа уменьшает количество ссылок между поколениями(поскольку уменьшает количество ссылок вообще)
- Поскольку объект с полями-структурами занимает больше памяти, то и маркировка такого объекта и перемещение более выгодно, чем маркировка и перемещение множества маленьких объектов.
Нужно уменьшать графы объектов, простой граф с большими объектами, обрабатывается более быстро, чем сложный граф с множеством маленьких объектов. Также сокращение локальных переменных ссылочных типов уменьшает размеры локальных таблица, создаваемых jit компилятором, что увеличивает скорость jit компиляции и экономит небольшое количество памяти.
- Количество операций синхронизации между выделением и освобождением памяти должно быть минимизировано, потому что при выделении и освобожении одновременно возможны взаимоблокировки
- Пул не может расти всегда, лишние объекты должны удаляться из пула с использованием сборщика мусора
- Пул не должен часто переполняться Чаще всего бывает выгодно возвращать последние использующиеся блоки, потому что они могут оказаться в кэше процессора. Также можно организовать возвращение в pool, при вызове метода Dispose у объекта. Необходимо также обработать вызов финализатора, чтобы вернуть объект в pool(во время использования объекта на него нет ссылки из пула)
Если приложение размещается в условиях сильно ограниченной оперативной памяти и этой памяти приложению не хватает, то происходит выгрузка оперативной памяти на диск в файл подкачки. Диспетчер Windows сам обеспечит памяти с наиболее часто используемыми объектами, а остальную выгрузит на диск. Если приложение выделило очень много памяти, и не использует ее, то эта память может быть вытеснена в файл подкачки. Сборщику мусора в итоге потребуется обойти всю эту память, чтобы промаркировать объекты(нужно будет прочитать с диска все данные приложения). А если оперативная память переполнена, то перед считываением, нужно будет выгрузить память в файл подкачки, чтобы появилась память куда можно считать. Учитывая сколько памяти может быть на диске, этот процесс может продлиться более минуты.
Решение - размещать объекты в обход сборщика мусора в неуправляемой памяти, тогда, эта память, даже выгруженная на диск, не принесет вреда, потому что не будет обрабатываться сборщиком мусора.
Также можно запросить размещение страниц в динамической памяти(windows имеет такую функцию и игнорирует такие запросы только в исключительных ситуациях). Т.Е можно попросить закрепить всю память приложения в физической памяти и не выгружать на диск. Такой прием нельзя использовать напрямую(управляемым способом), для этого нужно использовать прием размещения CLR(не разбирался, как это сделать), это позволит при запросах за выделением памяти от CLR, закреплять страницы памяти и отдавать приложению