Существует много устойчивых заблуждений об устройстве памяти в .NET, которые кочуют из учебника в учебник, и из совета в совет.
Например, что вся память находится под управлением сборщика мусора. Увы, но это не так. Есть области памяти, которые находятся под управлением системы и которыми можно управлять лишь опосредовано и выделенная в них память НЕ освобождается.
В каждом домене приложений AppDomain память разделена на две части. Системная (на схеме она отмечена красной рамкой) и пользовательская (голубая рамка).
Системная область не допускает прямого управления пользователем, и память в ней освобождается только при завершении домена (за редким исключением).
Начнём с таблицы типов — она заполнена специальными объектами, наследниками RuntimeType, которые можно получить методом GetType(). Каждый класс в .NET состоит из статической и экземплярной части. При первом упоминании типа в выполняемом виртуальной машиной выражении его статическая часть размещается таблице. При этом для КАЖДОГО T новый тип Generic сохраняется отдельно. (И поэтому можно реализовать Singleton через статические поля и для каждого T будет существовать не более одного экземпляра Singleton).
public static class Singleton<T> where T : class, new()
{
static Singleton()
{
// Здесь могут быть дополнительные условия на тип T
}
public static readonly T Instance =
typeof(T).InvokeMember(typeof(T).Name,
BindingFlags.CreateInstance |
BindingFlags.Instance |
BindingFlags.Public |
BindingFlags.NonPublic,
null, null, null) as T;
}
И если домен активно использует внешние сборки: загружает, использует их типы и выгружает, то таблица типов медленно но верно растёт, что в перспективе может вызвать OutOfMemory.
Список блоков синхронизации — набор блоков синхронизации, который может расширяться системой в случае необходимости.
Строковой пул — хранит в себе интернированные строки. С ним связана забавная особенность — в него по умолчанию помещаются все строковые константы использованные в сборке. А так же в него можно помещать строки при помощи метода String.Itern. Так вот, один и тот же алгоритм пересечения отсортированных списков строк реализованый на C++, C# (без интернирования и с интернированием) дал следующие результаты, примем время работы программы на C++ за 1. Тогда C# без интернирования затратил 0.9, а C# с интернированием 0.65.
Пул потоков — хранилище потоков под управлением виртуальной машины. Если в Task'ах регулярно виснут потоки, то пул будет досоздавать новые (и разумеется никогда их не высвобождать).
Прочее — отображение системных ресурсов, некоторые заранее выделенные переменные (например OutOfMemoryException — память под которое выделяется при старте домена).
Пользовательская область памяти.
SOH — Small Object Heap — куча для хранения маленьких объектов (в текущих реализациях .NET для объектов меньше 85000 байт). Разделённая на 3 поколения. 0 (эфемерное поколение), 1 и 2 поколение объектов. Для каждого из поколений установлен лимит памяти, и после того как он будет достигнут — может быть запущен сборщик мусора. При сборке мусора объекты «утрамбовываются» для избежания фрагментации памяти.
LOH — Large Object Heap — куча для больших объектов (в текущих реализациях .NET больше 85000 байт). Эта куча не разделена на поколения и в неё не выполняется дефрагментация.
Stack — стек приложения. в нём выделяется память под структуры *те, которые используются без боксинга, и не являющиеся полями классов*, ссылки на экземпляры классов, fixed и stackalloc. Каждая ссылка на объект содержит три поля.
Сам указатель на экземпляр (4 байта на 32-битных машинах, и 8 на 64-битных).
Индекс блока синхронизации (4 байта. На самом деле часть битов в нём выделена под системные флаги, например флаг пометки объекта к удалению сборщиком мусора).
Указатель на RuntimeType в системной области памяти.