Пам’ять в .NET: Stack, Heap, Garbage Collector
Мене звати Михняк Олексій, я понад 10 років працюю .NET-розробником у різних сферах, а останній рік — викладачем у KSE. Готуючи тему про Garbage Collector та управління пам’яттю, я помітив характерну закономірність: більшість статей обмежуються описом поколінь GC і майже не приділяють уваги тому, як саме об’єкти потрапляють у Heap, скільки місця вони реально займають, і за якими критеріями GC визначає, що зовнішніх посилань на об’єкт уже немає.
Ця стаття — спроба заповнити цю прогалину. Ми розберемо, як працюють Garbage Collector, System Stack (він же Thread Stack) та Managed Heap у .NET. Щоб матеріал був корисним і для початківців, і для досвідчених розробників, теорію підкріпимо практикою: за допомогою утиліти dotnet-dump ми побачимо реальні дампи пам’яті, зрозуміємо, де саме зберігаються об’єкти, скільки місця вони займають, і як GC визначає, що об’єкт можна видалити.
Що таке .NET та чому це важливо
Для тих, хто ще не знайомий із платформою: .NET — це середовище розробки від Microsoft, яке дозволяє писати код мовою C# (або іншими мовами), що спочатку компілюється в проміжну мову — Intermediate Language (IL). IL нагадує асемблер, але не прив’язаний до конкретного процесора. Під час виконання програми JIT-компілятор (Just-In-Time) перетворює IL на машинний код для конкретного процесора саме в момент першого виклику методу. Завдяки цьому .NET працює на різних платформах (Windows, Linux, macOS) та архітектурах (x64, ARM64) без перекомпіляції.
Однією з ключових особливостей .NET є автоматичне управління пам’яттю через Garbage Collector. Це звільняє розробника від необхідності вручну керувати виділенням та звільненням пам’яті, як у C++. Проте автоматичне управління не означає, що питання пам’яті можна повністю ігнорувати. Розуміння того, як працюють Stack, Heap та GC, допомагає писати ефективніший код, уникати витоків пам’яті та оптимізувати продуктивність.
Debug vs Release: чому це має значення
Перш ніж заглиблюватися в аналіз пам’яті, варто розуміти контекст виконання. .NET-програму можна запускати в режимах Debug або Release — і це не просто налаштування для зручності розробника. Це кардинально різні способи виконання коду, що безпосередньо впливають на роботу з пам’яттю.
Debug-режим оптимізований для налагодження: компілятор зберігає додаткову інформацію (символи для дебагера), мінімально оптимізує код і подовжує час життя змінних, щоб їх можна було перевірити в будь-який момент. Код працює повільніше, але ви бачите стан змінних навіть після того, як вони формально «не використовуються».
Release-режим — це production-конфігурація: JIT-компілятор агресивно оптимізує код, видаляє «мертвий» код (Dead Code Elimination), може зберігати змінні в регістрах процесора замість Stack, зменшує кількість алокацій. Код працює швидше, але поведінка може відрізнятися від Debug.
JIT-компілятор також може перекомпілювати метод у ефективніший варіант після певної кількості викликів (Tiered Compilation), постійно оптимізуючи код під час виконання.
Чому це важливо для аналізу пам’яті
У production-середовищі код працюватиме інакше: зменшиться кількість алокацій, зміниться час життя змінних, об’єкти можуть зберігатися в регістрах замість Stack. Для аналізу та отримання передбачуваної поведінки ми запускатимемо код у Debug-режимі. Водночас покажемо й поведінку в Release, щоб ви розуміли різницю та не були здивовані в production.
Примітка: через Debug-режим можуть з’являтися певні артефакти (наприклад, подовжений час життя змінних), про які йтиметься нижче.
Інструмент дослідження: dotnet-dump
Щоб побачити, що насправді відбувається з пам’яттю, потрібен спеціальний інструмент. На відміну від мов із розвиненою інструментацією, інспекції об’єктів (наприклад, Python), де можна аналізувати логічну структуру об’єктів під час виконання, .NET не надає доступу до внутрішнього стану runtime на рівні пам’яті. Це потребує використання зовнішніх інструментів діагностики.
dotnet-dump — утиліта, яка дозволяє зібрати «знімок» стану пам’яті програми (дамп) у конкретний момент часу та проаналізувати його.
Як працює dotnet-dump
- Збір дампу. Коли програма виконується, ми можемо зібрати дамп за допомогою команди dotnet-dump collect -p ProcessId. ProcessId можна отримати з логів або через dotnet-dump ps. Створюється файл із повним станом пам’яті програми на момент збору.
- Аналіз дампу. Після збору аналізуємо дамп командою dotnet-dump analyze core_yyyyMMdd_hhmmss. Відкривається інтерактивна консоль для дослідження пам’яті.
- Основні команди:
- clrstack -a показує стек викликів із локальними змінними та параметрами;
- dumpheap -type TypeName показує всі об’єкти певного типу в Heap;
- dumpobj address показує детальну інформацію про конкретний об’єкт;
- gcroot address показує, чи є в об’єкта GC Roots (чи вважається він «живим»).
Файл дампу може займати
Value Type vs Reference Type
У .NET існує два основних типи даних:
- Value Type (struct) — розміщуються в Stack.
- Reference Type (class) — розміщуються в Heap.
Що таке Stack
Stack — це структура даних, що працює за принципом LIFO (Last In, First Out). System Stack працює аналогічно, але оперує абстракцією Stack Frame і створюється для кожного потоку (thread) у програмі. За замовчуванням його розмір становить 1 MB, але цей параметр можна змінити при створенні потоку.
Що таке Stack Frame
Stack Frame — логічна структура, що створюється при виклику методу. Вона містить:
- адресу повернення (де відбувся виклик);
- вхідні аргументи;
- локальні змінні;
- return-значення.
Аналізуємо Stack Frame через дамп пам’яті
Тепер подивимося, як теорія виглядає на практиці. Створимо просту програму з об’єктом класу та структурою, а потім проаналізуємо, де вони знаходяться в пам’яті.
Console.WriteLine("PID: " + Environment.ProcessId);
var myClass = new MyClass();
var myStruct = new MyStruct();
Thread.Sleep(120_000);
class MyClass
{
public int Data = 42;
}
struct MyStruct
{
public int Data = 55;
public MyStruct()
{
}
}
Цей приклад демонструє базову різницю між двома типами даних. Рядок Console.WriteLine("PID: " + Environment.ProcessId) виводить ID процесу для подальшого аналізу. Далі створюється об’єкт класу (Reference Type), який потрапляє в Heap, та структура (Value Type), яка залишається в Stack. Thread.Sleep(120_000) дає нам час зібрати дамп.
Запускаємо програму та збираємо дамп:
dotnet run -c Debug PID: 37374 [createdump] Gathering state for process 37374 [createdump] Writing full dump to file /Users/oleksiimykhniak/Fun/OOP/core_20251226_180947 [createdump] Written 6394204312 bytes (390271 pages) to core file [createdump] Target process is alive [createdump] Dump successfully written in 48731ms
Файл дампу займає близько 6 GB — повний знімок пам’яті програми. Відкриваємо дамп і виконуємо команду clrstack -a (параметр -a показує і PARAMETERS, і LOCALS):
> clrstack -a OS Thread Id: 0x65b37cc (0) Child SP IP Call Site 000000016D4A6238 000000019611e6ec [InlinedCallFrame: 000000016d4a6238] 000000016D4A6238 0000000104fec460 [InlinedCallFrame: 000000016d4a6238] 000000016D4A6210 0000000104FEC460 System.Threading.Thread.Sleep(Int32) [/_/src/runtime/src/libraries/System.Private.CoreLib/src/System/Threading/Thread.cs @ 381] PARAMETERS: millisecondsTimeout = <no data> 000000016D4A62E0 0000000105EF1D74 Program.<Main>$(System.String[]) [/Users/oleksiimykhniak/Fun/OOP/gc_dump_1.cs @ 6] PARAMETERS: args (0x000000016D4A6338) = 0x000000030000db30 LOCALS: 0x000000016D4A6330 = 0x00000003000141f0 0x000000016D4A6328 = 0x0000000000000037 0x000000016D4A6320 = 0x00000000000091fe
У виводі бачимо Stack Frame методу Main, що починається з адреси 0×000000016D4A62E0. Зверніть увагу на локальні змінні:
- 0×000000016D4A6330 = 0×00000003000141f0 — адреса змінної myClass у Stack, яка містить посилання на об’єкт у Heap за адресою 0×00000003000141f0
- 0×000000016D4A6328 = 0×0000000000000037 — значення змінної myStruct безпосередньо в Stack (55 у десятковій системі = 0×37 у шістнадцятковій)
Ключова різниця: для myClass бачимо посилання (адресу в Heap), а для myStruct — саме значення.

Тепер перевіримо, що знаходиться за адресою об’єкта myClass у Heap, командою dumpobj:
> dumpobj 0x00000003000141f0 Name: MyClass MethodTable: 0000000105f910f0 Canonical MethodTable: 0000000105f910f0 Tracked Type: false Size: 24(0x18) bytes File: /Users/oleksiimykhniak/Fun/OOP/bin/Debug/net10.0/OOP.dll Fields: MT Field Offset Type VT Attr Value Name 0000000104d0e180 4000001 8 System.Int32 1 instance 42 Data
Із цього дампу дізнаємося важливі деталі:
- Об’єкт займає 24 байти в пам’яті.
- 16 байтів — метадані (8 байтів MethodTable + 8 байтів SyncBlock), присутні в будь-якого об’єкта в .NET.
- 8 байтів — дані класу (4 байти для int Data = 42 + 4 байти padding для вирівнювання).
Коли звертаємося до myClass.Data, відбувається такий ланцюжок:
- Зі Stack за адресою 0×16D4A6330 беремо посилання 0×3000141f0.
- Ідемо в Heap за цією адресою.
- Дістаємо значення Data (42).
А для myStruct.Data:
- Звертаємося безпосередньо до Stack за адресою 0×16D4A6328.
- Отримуємо значення 55.
Це пояснює, чому робота зі структурами швидша — немає проміжного кроку через Heap.
Передача Class та Struct у методи
Розуміючи базову різницю, перейдемо до складнішого сценарію: що відбувається при передачі об’єктів у методи? Поведінка Value Type та Reference Type тут кардинально відрізняється.
Console.WriteLine("PID: " + Environment.ProcessId);
var myClass = new MyClass();
var myStruct = new MyStruct();
Example(myClass, myStruct);
Console.WriteLine("Dump after Example");
Thread.Sleep(120_000);
void Example(MyClass a, MyStruct b)
{
Console.WriteLine("Dump Start Example");
Thread.Sleep(120_000);
a.Data = 84;
b.Data = 77;
Console.WriteLine("Dump Finish Example");
Thread.Sleep(120_000);
}
// ... визначення класів ті самі ...
Тут створюються myClass та myStruct у методі Main, потім обидва передаються в метод Example, де модифікуються. Збираємо дампи на трьох етапах: на початку методу, після модифікації та після виходу.
Етап 1: Початок методу Example
> clrstack -a OS Thread Id: 0x65eaf3e (0) Child SP IP Call Site 000000016D04E218 000000019611e6ec [InlinedCallFrame: 000000016d04e218] 000000016D04E218 00000001049fc460 [InlinedCallFrame: 000000016d04e218] 000000016D04E1F0 00000001049FC460 System.Threading.Thread.Sleep(Int32) [/_/src/runtime/src/libraries/System.Private.CoreLib/src/System/Threading/Thread.cs @ 381] PARAMETERS: millisecondsTimeout = <no data> 000000016D04E2C0 00000001059036AC Program.<<Main>$>g__Example|0_0(MyClass, MyStruct) [/Users/oleksiimykhniak/Fun/OOP/gc_dump_2.cs @ 14] PARAMETERS: a (0x000000016D04E2D8) = 0x00000003000141f0 b (0x000000016D04E2D0) = 0x0000000000000037 000000016D04E2E0 0000000105901D74 Program.<Main>$(System.String[]) [/Users/oleksiimykhniak/Fun/OOP/gc_dump_2.cs @ 6] PARAMETERS: args (0x000000016D04E338) = 0x000000030000db30 LOCALS: 0x000000016D04E330 = 0x00000003000141f0 0x000000016D04E328 = 0x0000000000000037 0x000000016D04E320 = 0x000000000000aa99
Створюється новий Stack Frame для методу Example із виділеною пам’яттю під вхідні параметри:
- Параметр a (0×16D04E2D8) містить посилання на той самий об’єкт у Heap (0×3000141f0), на який вказує myClass (0×16D04E330) у методі Main. Обидві змінні посилаються на один об’єкт.
- Параметр b (0×16D04E2D0) містить копію значення з myStruct (0×16D04E328) = 0×37 (55). Це окрема копія, не пов’язана з оригіналом.

Етап 2: Після модифікації
> clrstack -a OS Thread Id: 0x65eaf3e (0) Child SP IP Call Site 000000016D04E218 000000019611e6ec [InlinedCallFrame: 000000016d04e218] 000000016D04E218 00000001049fc460 [InlinedCallFrame: 000000016d04e218] 000000016D04E1F0 00000001049FC460 System.Threading.Thread.Sleep(Int32) [/_/src/runtime/src/libraries/System.Private.CoreLib/src/System/Threading/Thread.cs @ 381] PARAMETERS: millisecondsTimeout = <no data> 000000016D04E2C0 0000000105903704 Program.<<Main>$>g__Example|0_0(MyClass, MyStruct) [/Users/oleksiimykhniak/Fun/OOP/gc_dump_2.cs @ 20] PARAMETERS: a (0x000000016D04E2D8) = 0x00000003000141f0 b (0x000000016D04E2D0) = 0x000000000000004d 000000016D04E2E0 0000000105901D74 Program.<Main>$(System.String[]) [/Users/oleksiimykhniak/Fun/OOP/gc_dump_2.cs @ 6] PARAMETERS: args (0x000000016D04E338) = 0x000000030000db30 LOCALS: 0x000000016D04E330 = 0x00000003000141f0 0x000000016D04E328 = 0x0000000000000037 0x000000016D04E320 = 0x000000000000aa99
При виконанні a.Data = 84 ми за адресою параметра ...E2D8 дістаємо посилання ...141f0, ідемо в Heap і оновлюємо значення Data на 84. Посилання залишається незмінним, але об’єкт у Heap змінюється. Оскільки myClass у Main посилається на той самий об’єкт, зміни будуть видимі й там.
При виконанні b.Data = 77 ми звертаємося до ділянки пам’яті в Stack ...E2D0 (копія значення) і оновлюємо її на 77 (0×4D). Але оригінальна змінна myStruct у Stack Frame методу Main (...E328) залишається 0×37 (55) — вона не пов’язана з копією.

Етап 3: Після виходу з Example
> clrstack -a OS Thread Id: 0x65eaf3e (0) Child SP IP Call Site 000000016D04E238 000000019611e6ec [InlinedCallFrame: 000000016d04e238] 000000016D04E238 00000001049fc460 [InlinedCallFrame: 000000016d04e238] 000000016D04E210 00000001049FC460 System.Threading.Thread.Sleep(Int32) [/_/src/runtime/src/libraries/System.Private.CoreLib/src/System/Threading/Thread.cs @ 381] PARAMETERS: millisecondsTimeout = <no data> 000000016D04E2E0 0000000105901DB8 Program.<Main>$(System.String[]) [/Users/oleksiimykhniak/Fun/OOP/gc_dump_2.cs @ 9] PARAMETERS: args (0x000000016D04E338) = 0x000000030000db30 LOCALS: 0x000000016D04E330 = 0x00000003000141f0 0x000000016D04E328 = 0x0000000000000037 0x000000016D04E320 = 0x000000000000aa99
Stack Frame методу Example повністю видаляється. Параметри a та b перестають існувати. Але зверніть увагу на різницю:
- Параметр a (посилання) зник, але об’єкт у Heap залишився — на нього все ще посилається myClass у методі Main.
- Параметр b (копія значення) просто зник — він існував лише в Stack Frame методу Example.

Пам’ять у Stack самоочищується при завершенні методу, на відміну від Heap. Саме для керування пам’яттю в Heap і існує Garbage Collector.
Примітка. Може виникнути StackOverflowException, коли відбувається забагато викликів методів, що не завершуються (зазвичай при рекурсії) — закінчується місце в Stack і не може створитися новий Stack Frame.
Як об’єкти потрапляють у Heap
Розглянемо різні варіанти створення об’єктів та їхню
new MyClass();
var a = new MyClass();
new MyStruct();
var b = new MyStruct();
class MyClass
{
public int Data = 42;
}
struct MyStruct
{
public int Data = 55;
public MyStruct()
{
}
}
Варіант 1: new MyClass() без присвоєння
IL_0000: newobj instance void MyClass::.ctor() IL_0005: pop
Створюється новий об’єкт у Heap, але посилання не зберігається в змінну. Команда pop видаляє посилання з Evaluation Stack. Evaluation Stack — логічна абстракція IL, яка не є фізичним стеком у пам’яті. Під час JIT-компіляції він може відображатися на регістри процесора, кеш L1—L3 або виділену ділянку RAM. Фактичний спосіб зберігання визначається JIT-оптимізаціями.
Об’єкт створюється в Heap, але одразу стає недосяжним (немає посилань). GC зможе його видалити при наступній збірці.

Варіант 2: var a = new MyClass()
IL_0006: newobj instance void MyClass::.ctor() IL_000b: stloc.0 // a
Створюється новий об’єкт у Heap, посилання зберігається в локальну змінну a (stloc.0 означає «store to local variable 0»). Об’єкт залишається «живим», поки існує посилання.

Варіант 3: new MyStruct() без присвоєння
IL_000c: newobj instance void MyStruct::.ctor() IL_0011: pop
Хоч команда виглядає аналогічно до класу, під час виконання JIT може інтерпретувати це як «помістити число в регістр і очистити» або взагалі проігнорувати. На відміну від класу, структура не створюється в Heap. JIT може оптимізувати цю послідовність до нуля операцій, оскільки результат ніде не використовується.

Варіант 4: var b = new MyStruct()
IL_0012: ldloca.s b IL_0014: call instance void MyStruct::.ctor() IL_0019: ret
ldloca.s завантажує адресу локальної змінної b, потім викликається конструктор, який ініціалізує значення 55 безпосередньо в Stack Frame. Хоч в IL це виглядає як три інструкції, в реальності JIT може виконати це як одну машинну операцію.

Boxing: коли структури стають об’єктами
Один із найважливіших моментів в управлінні пам’яттю — процес boxing (запаковки). Він відбувається, коли Value Type використовується як Reference Type.
Що таке Boxing
Boxing — процес перетворення Value Type (struct) на Reference Type (object). Відбувається автоматично, коли, наприклад, присвоюємо int змінній типу object або передаємо структуру в метод, що очікує object.
Просте копіювання без boxing
Спочатку подивимося на звичайне копіювання:
int a = 11;
int b = a;
b = 22;
Console.WriteLine($"a={a}, b={b}");
Створюються дві незалежні змінні типу int. Зміна b не впливає на a. Результат: a=11, b=22.
Крок 1. Створюємо змінну a в Stack Frame і встановлюємо значення 11 (0xB).
IL_001d: ldc.i4.s 11 // 0x0b IL_001f: stloc.0 // a

Крок 2: Створюємо змінну b і копіюємо значення.
IL_0020: ldloc.0 // a IL_0021: stloc.1 // b

Крок 3: Модифікуємо b. Результат: a=11, b=22.
IL_0022: ldc.i4.s 22 // 0x16 IL_0024: stloc.1 // b

Boxing у дії
Тепер подивимося, що відбувається при boxing:
object a = 11;
object b = a;
b = 22;
Console.WriteLine($"a={a}, b={b}");
При спробі використати int як object запускається автоматичний boxing.
Крок 1. Створюємо змінну a в Stack. Оскільки відбувається приведення int до object, запускається boxing
IL_001d: ldc.i4.s 11 // 0x0b IL_001f: box [System.Runtime]System.Int32 IL_0024: stloc.0 // a

Крок 2: Копіюємо посилання на об’єкт у Heap до змінної b в Stack.
IL_0025: ldloc.0 // a IL_0026: stloc.1 // b

Крок 3. Цей момент може здатися неочевидним — створюється нова змінна в Heap зі значенням 22, і посилання на неї присвоюється b. Попередній об’єкт, на який посилалися обидві змінні, тепер має лише одне посилання (від a).
IL_0027: ldc.i4.s 22 // 0x16 IL_0029: box [System.Runtime]System.Int32 IL_002e: stloc.1 // b

Ключовий висновок: При boxing структури отримані дані в Heap можна вважати immutable. При зміні struct завжди створюється нова сутність, а на попереднє значення зникає посилання — його потрібно очистити через GC. Саме тому варто уникати непотрібного boxing.
Масиви та Generics: як уникнути boxing
Масиви — особливий випадок, що демонструє важливість правильного вибору типів для оптимізації пам’яті.
object[] arr1 = new object[2]; arr1[0] = 10; arr1[1] = new MyClass(); int[] arr2 = new int[2]; arr2[0] = 10; arr2[1] = 20;
Для object[] відбувається boxing при присвоєнні 10, що створює додаткові об’єкти в Heap. Для int[] значення зберігаються безпосередньо в масиві.


Для object[]:
- Створюється масив у Heap із двома комірками під посилання.
- При arr1[0] = 10 відбувається boxing — створюється об’єкт у Heap.
- Щоб дістати значення: Stack → масив у Heap → посилання на об’єкт → значення (3 кроки).
Для int[]:
- Створюється масив у Heap з виділеним місцем під значення int.
- Значення зберігаються безпосередньо в масиві.
- Щоб дістати значення: Stack → масив у Heap → значення (2 кроки).
> dumpobj 0003000141e8 Name: System.Object[] MethodTable: 0000000107c98f50 Canonical MethodTable: 0000000107c98f50 Tracked Type: false Size: 40(0x28) bytes Array: Rank 1, Number of elements 2, Type CLASS Fields: None > dumpobj 000300014240 Name: System.Int32[] MethodTable: 0000000108e53650 Canonical MethodTable: 0000000108e53650 Tracked Type: false Size: 32(0x20) bytes Array: Rank 1, Number of elements 2, Type Int32 Fields: None
Generics: типізовані колекції без boxing
Generics створені саме для уникнення дублювання коду та зайвого boxing. Замість приведення всього до object можна створити типізовані колекції.
List<int> list = new List<int>(); // Створення (1) // Створення (2) list.Add(10); list.Add(20); list.Add(30); list.Add(40); // Розширення (3) list.Add(50);
List<int> використовує внутрішній масив int[], де значення зберігаються безпосередньо, без створення окремих об’єктів для кожного елемента.
List працює поверх звичайного масиву та містить додаткові поля для відстеження розміру й версії.
Етап 1. Під час створення списку виділяється об’єкт із посиланням на порожній масив (або з capacity за замовчуванням).

Етап 2. При додаванні першого елемента створюється масив на 4 елементи. Подальше додавання заповнює його без зміни розміру.

Етап 3. Коли кількість елементів перевищує розмір масиву (при додаванні

Посилання на змінну list не змінюється. Generics допомогли уникнути boxing для кожного елемента.
> dumpobj 0001480141e8 Name: System.Collections.Generic.List`1[[System.Int32, System.Private.CoreLib]] MethodTable: 0000000105e92018 Canonical MethodTable: 0000000105e92018 Tracked Type: false Size: 32(0x20) bytes File: /usr/local/share/dotnet/shared/Microsoft.NETCore.App/10.0.0/System.Private.CoreLib.dll Fields: MT Field Offset Type VT Attr Value Name 0000000105dc3650 40023af 8 System.Int32[] 0 instance 0000000148014230 _items 0000000104c0e180 40023b0 10 System.Int32 1 instance 5 _size 0000000104c0e180 40023b1 14 System.Int32 1 instance 5 _version 0000000105dc3650 40023b2 8 System.Int32[] 0 static 000000013bb354a0 s_emptyArray > dumpobj 0000000148014230 Name: System.Int32[] MethodTable: 0000000105dc3650 Canonical MethodTable: 0000000105dc3650 Tracked Type: false Size: 56(0x38) bytes Array: Rank 1, Number of elements 8, Type Int32 Fields: None
Примітка. Оскільки створення порожнього списку — часта операція, для зменшення алокацій використовується спільний статичний масив на 0 елементів:
0000000105dc3650 40023b2 8 System.Int32[] 0 static 000000013bb354a0 s_emptyArray
Nullable: третій стан для Value Types
Value Types за своєю природою не можуть бути null. Проте на практиці часто потрібен «третій стан» для структур — відсутність даних. Типовий приклад — робота з базами даних або конфігураціями. Для цього використовують Nullable (int?, float?). На синтаксичному рівні int? виглядає як окремий тип, але насправді це синтаксичний цукор над узагальненою структурою Nullable<T>.
public partial struct Nullable<T> where T : struct
{
private readonly bool hasValue; // Do not rename (binary serialization)
internal T value; // Do not rename (binary serialization) or make readonly (can be mutated in ToString, etc.)
[NonVersionable]
public Nullable(T value)
{
this.value = value;
hasValue = true;
}
public readonly bool HasValue
{
[NonVersionable]
get => hasValue;
}
public readonly T Value
{
get
{
if (!hasValue)
{
ThrowHelper.ThrowInvalidOperationException_InvalidOperation_NoValue();
}
return value;
}
}
//...
}
Обмеження where T : struct гарантує, що T буде Value Type. Це дозволяє писати int? a = null і уникати boxing та виділення пам’яті в Heap.
Тобто:
int? a = 10;
компілюється як:
Nullable<int> a = new Nullable<int>(10);
Garbage Collection: коли очищати Heap
До цього моменту ми розглядали, як об’єкти потрапляють у Heap та зберігаються там. Залишається питання: коли й як об’єкти видаляються?
Stack очищується автоматично при завершенні методу — простий механізм LIFO. Heap потребує складнішого механізму — Garbage Collector.
Як GC визначає, що об’єкт можна видалити
GC використовує концепцію GC Roots (коренів збирача сміття). Якщо до об’єкта неможливо дістатися з жодного кореня, він вважається недосяжним і може бути видалений.
Основні типи GC Roots:
- Stack References. Локальні змінні та параметри методів, що виконуються (JIT відстежує їхній час життя).
- Static Variables. Статичні поля класів живуть весь час життя процесу.
- Handles. GCHandle (наприклад, Pinned-об’єкти для Interop, unsafe code).
- Finalization Queue. Об’єкти, що очікують виклику деструктора.
Циклічні залежності: чи може GC їх видалити
Часте запитання: що відбувається, якщо об’єкти посилаються один на одного в циклі?
void Example()
{
var a = new A();
var b = new B();
a.Child = b;
// b.Child = a; // Можлива циклічна залежність
}
class A
{
public B Child { get; set; }
}
class B
{
public A Child { get; set; } // Для циклічного посилання
}
Навіть якщо об’єкти посилаються один на одного (цикл), але на них немає посилань з активних GC Roots (після завершення методу Example змінні a та b зникають зі стеку), GC коректно визначить їх як недосяжні та видалить усю групу. GC використовує алгоритм маркування, що проходить по всіх посиланнях від коренів, тому циклічні залежності не є проблемою.


GC Roots та артефакти Debug-режиму
Теоретичне розуміння Garbage Collector часто розходиться з практикою. Для аналізу механізму визначення досяжності об’єктів потрібно розрізняти поведінку в режимах Debug та Release.
Console.WriteLine("PID: " + Environment.ProcessId);
// Створюємо об'єкт без посилання (сирота)
new MyClass();
// Створюємо об'єкт з посиланням
var a = new MyClass();
new MyStruct();
var b = new MyStruct();
Thread.Sleep(120_000);
class MyClass
{
public int Data = 42;
}
struct MyStruct
{
public int Data = 55;
public MyStruct() { }
}
Створюються два екземпляри класу — один без збереження посилання («сирота»), інший з присвоєнням змінній. Також створюються структури.
Зібравши дамп під час Thread.Sleep, аналізуємо Heap командою dumpheap:
> dumpheap -type MyClass Address MT Size 0003000141d8 000105c125d8 24 0003000141f0 000105c125d8 24 Statistics: MT Count TotalSize Class Name 000105c125d8 2 48 MyClass Total 2 objects, 48 bytes
Два об’єкти в пам’яті — очікувано, оскільки збірка сміття ще не ініціювалася. Ключове питання: чи вважає GC ці об’єкти «живими»? Перевіримо їхні корені командою gcroot.
Перший об’єкт (без збереженого посилання):
> gcroot 0003000141d8 Caching GC roots, this may take a while. Subsequent runs of this command will be faster. Thread 668fa3f: 16dd323a0 105b71dc8 Program.<Main>$(System.String[]) Fp+28: 000000016dd323c8 -> 0003000141d8 MyClass Found 1 unique roots.
Другий об’єкт (змінна a):
> gcroot 0003000141f0
> gcroot 0003000141f0 Thread 668fa3f: 16dd323a0 105b71dc8 Program.<Main>$(System.String[]) Fp+20: 000000016dd323c0 -> 0003000141f0 MyClass Fp+60: 000000016dd32400 -> 0003000141f0 MyClass Found 2 unique roots.
Парадоксальна картина: об’єкт без посилання має 1 корінь, а змінна a — 2 корені. Це характерна ознака Debug-режиму.
JIT-компілятор у Debug-конфігурації навмисно подовжує час життя (lifetime) усіх локальних змінних до завершення методу. Це зроблено для налагодження: розробник має змогу перевірити стан будь-якого об’єкта в будь-який момент. Навіть якщо об’єкт фактично не використовується, під час Safe Point (яким є Thread.Sleep) система утримує посилання на нього.
Щоб побачити реальну поведінку, що відповідає production-середовищу, потрібно:
- Запустити програму в конфігурації Release.
- Вимкнути Tiered Compilation та встановити змінні оточення для максимальної оптимізації JIT.
export COMPlus_TieredCompilation=0 export COMPlus_JITMinOpts=0 dotnet run -c Release
Також слід врахувати Dead Code Elimination. У Release-режимі JIT діє агресивно: якщо змінна ініціалізується, але не використовується після паузи, компілятор може видалити її створення. Тому додамо звернення до a.Data після Thread.Sleep.
Console.WriteLine("PID: " + Environment.ProcessId);
new MyClass();
var a = new MyClass();
new MyStruct();
var b = new MyStruct();
Console.WriteLine("Before: " + a.Data);
Thread.Sleep(120_000);
// Використовуємо змінну, щоб JIT не видалив її до sleep
a.Data = new Random().Next(0, 100);
Console.WriteLine("After: " + a.Data);
class MyClass
{
public int Data = new Random().Next(0, 100);
}
// MyStruct залишається без змін...
Аналіз незбереженого об’єкта в Release
> gcroot 0001400142a8 Caching GC roots, this may take a while. ... Found 0 unique roots.
Результат Found 0 unique roots свідчить, що Garbage Collector коректно ідентифікував об’єкт як недосяжний. Пам’ять буде звільнена при наступній збірці.
Аналіз змінної a в Release
> gcroot 000140014308 Thread 6694fd0: 16dab23a0 106781d80 Program.<Main>$(System.String[]) X19: -> 000140014308 MyClass Found 1 unique roots.
Тут бачимо 1 унікальний корінь — коректний результат. Зверніть увагу на джерело: X19.
Це демонструє важливий аспект низькорівневої оптимізації. На архітектурі ARM64 (про що свідчить назва регістра X19) JIT-компілятор вирішив не зберігати посилання в оперативній пам’яті стеку. Для підвищення швидкодії посилання утримується безпосередньо в регістрі процесора.
Важливий висновок. Поняття «Stack Roots» ширше, ніж просто дані в структурі стеку в RAM. Garbage Collector сканує контекст потоку, що включає як фізичний стек, так і регістри CPU. Це гарантує, що активно використовувані об’єкти не будуть видалені, навіть якщо вони існують лише на рівні регістрів.
Висновки
Розуміння механізмів роботи Stack, Heap та Garbage Collector трансформує теоретичні знання в передбачувану та стабільну продуктивність системи. Замість «магії» автоматичного управління пам’яттю ми побачили послідовний і цілком логічний механізм.
Ключові практичні інсайти зі статті:
- Value Types зберігаються безпосередньо в Stack (або в структурі, що їх містить), Reference Types — у Heap із посиланням зі Stack.
- При передачі в методи структури копіюються, а для класів копіюється лише посилання на той самий об’єкт.
- Boxing створює об’єкти в Heap навіть для простих чисел — уникайте його там, де це можливо.
- Generics допомагають уникнути boxing у колекціях.
- GC Roots визначають «живі» об’єкти — якщо до об’єкта неможливо дістатися від кореня, він буде видалений.
- Debug та Release-режими мають суттєво різну поведінку — аналізуйте в тому режимі, де буде працювати production-код.
Тепер ви можете самостійно аналізувати пам’ять за допомогою dotnet-dump: збирати дампи, досліджувати Stack та Heap, перевіряти GC Roots об’єктів.
Дякую за увагу! Якщо щось залишилося незрозумілим або хочеться заглибитися в деталі — пишіть у коментарях.
У другій частині можемо поговорити про:
- Покоління Garbage Collector (Generation 0, 1, 2).
- Large Object Heap (LOH).
- Фіналізацію та деструктори.
- Паузи GC та як їх мінімізувати.
- Zero-allocation підходи та high-performance техніки: Span<T>, Memory<T> та як ValueTask зменшує навантаження на Heap у високочастотних асинхронних сценаріях.
Сподобалась стаття? Підписуйтесь на автора, щоб отримувати сповіщення про нові публікації на пошту.
3 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів