Уявіть, що ви пишете програму на C або C++. Ви виділяєте пам'ять за допомогою malloc() або new, працюєте з нею, а потім... забуваєте її звільнити. Результат? Memory leak (витік пам'яті). Ваша програма поступово споживає все більше пам'яті, поки операційна система не вирішить припинити цей хаос.
З іншого боку, якщо ви звільните пам'ять надто рано або двічі, ви отримаєте dangling pointer (висячий вказівник) та undefined behavior - ваша програма може впасти в будь-який момент, часто у найнесподіваніший.
// C++ - ручне управління пам'яттю
int* CreateArray() {
int* arr = new int[1000];
// ... робота з масивом ...
delete[] arr; // Потрібно пам'ятати звільнити!
return arr; // ❌ ПОМИЛКА: повертаємо dangling pointer
}
void ProcessData() {
int* data = new int[500];
// ... складна логіка ...
if (error) {
return; // ❌ Memory leak - забули delete[]
}
delete[] data;
}
1990-ті роки: Мови програмування вимагали ручного управління пам'яттю. Розробники витрачали значну частину часу на відлагодження memory leaks та crashes.
1995 рік - Java: Представила Garbage Collection як стандарт для enterprise-додатків. Продуктивність розробки зросла, але з'явився overhead на runtime.
2002 рік - .NET Framework 1.0: Microsoft інтегрувала покоління GC (Generational Garbage Collector) з оптимізаціями для різних сценаріїв.
2010+ роки: Continuous improvements - Server GC, Background GC, LOH compaction, і з .NET 5+ навіть Pinned Object Heap (POH).
Після цього розділу ви зможете:
IDisposable, розуміти різницю між Dispose та FinalizerWeakReference, як уникати LOH фрагментації, як налаштувати GC modeПеред вивченням цієї теми переконайтесь, що ви розумієте:
Коли ваш .NET додаток стартує, CLR (Common Language Runtime) резервує непрерывну область віртуальної пам'яті, яка називається managed heap. Це не фізична пам'ять, а віртуальний адресний простір, який операційна система виділяє процесу.
Managed heap підтримує allocation pointer (вказівник на наступну вільну позицію). Коли ви створюєте новий об'єкт:
var person = new Person("John", 30);
CLR виконує наступні кроки:
Визначається скільки байтів потрібно для об'єкта:
CLR перевіряє, чи є достатньо місця від поточного allocation pointer до кінця Gen0.
Якщо місце є:
Якщо місця недостатньо, спрацьовує Garbage Collection для звільнення пам'яті.
// Псевдокод роботи CLR при алокації
object AllocateObject(Type type)
{
int size = CalculateSize(type); // Розмір з header + fields + padding
if (allocationPointer + size > gen0End)
{
// Недостатньо місця - запускаємо GC
GC.Collect(0); // Збираємо Gen0
if (allocationPointer + size > gen0End)
{
// Все ще недостатньо - розширюємо heap
ExpandHeap();
}
}
// Розміщуємо об'єкт
void* address = allocationPointer;
InitializeObject(address, type);
allocationPointer += size;
return (object)address;
}
malloc() у C, який повинен шукати вільний блок у складних структурах даних.| Характеристика | Stack (Стек) | Heap (Купа) |
|---|---|---|
| Що зберігається | Value types, локальні змінні, параметри методів | Reference types (об'єкти класів, масиви, strings) |
| Розмір | Обмежений (1-4 MB за замовчуванням) | Обмежений тільки доступною RAM та віртуальною пам'яттю |
| Швидкість доступу | Дуже швидко (CPU cache-friendly) | Повільніше (потрібна індирекція через референс) |
| Час життя | До завершення методу (scope-based) | До збору GC (недосяжні об'єкти) |
| Алокація | Переміщення stack pointer | Переміщення allocation pointer + можливий GC |
| Деалокація | Автоматично при виході з scope | Автоматично GC (недетерміновано) |
| Фрагментація | Неможлива (LIFO структура) | Можлива (розв'язується compaction) |
| Thread safety | Кожен thread має свій stack | Shared між threads (потрібна синхронізація) |
void StackVsHeapDemo()
{
// Stack: value type
int x = 42; // 4 байти на стеку
// Stack: struct (якщо не boxed)
Point p = new Point(10, 20); // 8 байт на стеку
// Heap: reference type
Person person = new Person(); // Референс (4/8 байт) на стеку
// Об'єкт на heap
// Heap: boxing
object boxedX = x; // Створює об'єкт на heap з значенням 42
} // <-- При виході: stack очищується автоматично
// Heap об'єкти стають eligible for GC
Зберігаються inline - безпосередньо там, де оголошені:
struct Point
{
public int X; // 4 байти
public int Y; // 4 байти
// Загалом: 8 байт
}
class Container
{
public int id; // 4 байти inline в об'єкті
public Point point; // 8 байт inline в об'єкті
// Загалом в об'єкті: Object Header + MethodTable + 4 + 8 = ~28-40 байт
}
void Method()
{
Point p; // 8 байт на стеку
Container c = new Container(); // Референс на стеку, об'єкт на heap
}
Зберігаються тільки на heap. Змінна містить референс (адресу):
class Person
{
public string Name; // Референс на string (який теж на heap)
public int Age; // 4 байти inline
}
Person p1 = new Person(); // Heap: Person об'єкт
Person p2 = p1; // Stack: обидві змінні вказують на ОДИН об'єкт
p2.Age = 30;
Console.WriteLine(p1.Age); // 30 - змінили через p2, але p1 бачить зміни!
null.Boxing - це процес перетворення value type у reference type (алокація на heap).
int value = 42; // Stack: 4 байти
object boxed = value; // Heap: створюється об'єкт Int32 з value 42
// Stack: референс на цей об'єкт
// Unboxing - зворотній процес
int unboxed = (int)boxed; // Копіювання значення з heap на stack
Performance implications:
// ❌ Погано: Boxing у циклі
List<object> items = new List<object>();
for (int i = 0; i < 10000; i++)
{
items.Add(i); // 10000 boxing операцій = 10000 heap алокацій!
}
// ✅ Добре: Використання generic колекції
List<int> items = new List<int>();
for (int i = 0; i < 10000; i++)
{
items.Add(i); // Без boxing, зберігається inline
}
object або interfaceToString(), GetHashCode(), Equals() на value typeArrayList, Hashtable)Garbage Collector (GC) - це компонент CLR, який автоматично керує пам'яттю у managed heap. Його основні завдання:
GC працює у три основні фази:
GC проходить граф об'єктів, починаючи з roots та позначає всі досяжні об'єкти:
class Node
{
public Node Next;
public string Data;
}
void Example()
{
Node head = new Node { Data = "A" }; // Root
head.Next = new Node { Data = "B" };
head.Next.Next = new Node { Data = "C" };
Node orphan = new Node { Data = "X" }; // Недосяжний після наступного рядка
orphan = null;
// GC marking:
// 1. Знайти roots: head (локальна змінна)
// 2. Маркувати Node "A" як reachable
// 3. Слідувати за Next, маркувати "B"
// 4. Слідувати за Next, маркувати "C"
// 5. Node "X" не маркований -> garbage
}
Roots включають:
Після marking GC переміщує всі живі об'єкти до початку heap, усуваючи фрагментацію:
Before Compaction:
[Obj A][ ][Obj B][ ][Obj C][ ]
^live ^dead ^live ^dead ^live ^dead
After Compaction:
[Obj A][Obj B][Obj C][ ]
^ Free space for new objects
Після переміщення об'єктів GC оновлює всі посилання на нові адреси:
// До compaction
Node n1 = /* 0x1000 */;
Node n2 = /* 0x2000 */;
n1.Next = n2; // Next вказує на 0x2000
// Після compaction
// n2 переміщений на 0x1100
// GC автоматично оновлює:
n1.Next = /* тепер 0x1100 */;
.NET GC базується на емпіричному спостереженні:
Більшість об'єктів живуть дуже коротко, а ті що виживають - живуть довго.
Це означає:
На основі цього спостереження .NET використовує три покоління:
| Покоління | Призначення | Типові Об'єкти | Частота Збору |
|---|---|---|---|
| Gen0 | Молоді об'єкти | Тимчасові змінні, буфери, короткоживучі об'єкти | Дуже часто |
| Gen1 | Буфер між Gen0 і Gen2 | Об'єкти середньої тривалості життя | Рідше ніж Gen0 |
| Gen2 | Старі об'єкти | Статичні дані, довгоживучі об'єкти, кеші | Рідко |
Характеристики:
void Gen0Example()
{
for (int i = 0; i < 1000; i++)
{
// Кожна ітерація створює об'єкти у Gen0
var temp = new StringBuilder();
temp.Append("Temporary data ");
temp.Append(i);
string result = temp.ToString();
// temp та проміжні string об'єкти стають garbage
// після завершення ітерації
}
// Під час циклу може статися декілька Gen0 collections
}
Коли спрацьовує Gen0 GC:
GC.Collect(0)class Gen0PressureDemo
{
static void Main()
{
// Відслідковуємо GC події
int gen0Before = GC.CollectionCount(0);
// Створюємо багато короткоживучих об'єктів
for (int i = 0; i < 100_000; i++)
{
var obj = new byte[1024]; // 1 KB об'єкт
// obj стає garbage одразу після ітерації
}
int gen0After = GC.CollectionCount(0);
Console.WriteLine($"Gen0 collections: {gen0After - gen0Before}");
// Вивід: Gen0 collections: 25 (приблизно, залежить від heap size)
}
}
class CheckGeneration
{
static void Main()
{
var obj = new object();
// Щойно створений - в Gen0
Console.WriteLine($"Generation: {GC.GetGeneration(obj)}"); // 0
// Викликаємо GC - об'єкт виживає
GC.Collect();
Console.WriteLine($"Generation: {GC.GetGeneration(obj)}"); // 1
// Ще раз - промоція до Gen2
GC.Collect();
Console.WriteLine($"Generation: {GC.GetGeneration(obj)}"); // 2
// Далі об'єкт залишається в Gen2
GC.Collect();
Console.WriteLine($"Generation: {GC.GetGeneration(obj)}"); // 2
}
}
Призначення: Gen1 діє як буфер між часто зібраним Gen0 та рідко зібраним Gen2.
Характеристики:
class Gen1Scenario
{
private List<string> _cache = new List<string>();
void ProcessRequests()
{
for (int i = 0; i < 100; i++)
{
string data = FetchData(i); // Gen0
// Деякі дані кешуються - виживуть Gen0 GC
if (i % 10 == 0)
{
_cache.Add(data); // Переміститься до Gen1, потім Gen2
}
ProcessData(data); // Більшість data стане garbage в Gen0
}
}
}
Коли спрацьовує Gen1 GC:
GC.Collect(1) - збере Gen0 та Gen1Характеристики:
Типові об'єкти в Gen2:
// Статичний кеш - швидко потрапить у Gen2
public static class ApplicationCache
{
// Ці об'єкти будуть жити весь час роботи додатку
private static Dictionary<string, UserProfile> _userCache
= new Dictionary<string, UserProfile>();
private static ConnectionPool _connections = new ConnectionPool();
// Gen2 об'єкти!
}
// Singleton - також Gen2
public class ConfigurationManager
{
private static readonly Lazy<ConfigurationManager> _instance =
new Lazy<ConfigurationManager>(() => new ConfigurationManager());
public static ConfigurationManager Instance => _instance.Value; // Gen2
}
Full GC (Gen2 Collection):
Коли CLR збирає Gen2, відбувається Full GC - збір всіх поколінь (Gen0, Gen1, Gen2):
void FullGCDemo()
{
Console.WriteLine("Before GC:");
Console.WriteLine($"Gen0: {GC.CollectionCount(0)}");
Console.WriteLine($"Gen1: {GC.CollectionCount(1)}");
Console.WriteLine($"Gen2: {GC.CollectionCount(2)}");
// Full GC - збере всі покоління
GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true);
Console.WriteLine("\nAfter Full GC:");
Console.WriteLine($"Gen0: {GC.CollectionCount(0)}"); // +1
Console.WriteLine($"Gen1: {GC.CollectionCount(1)}"); // +1
Console.WriteLine($"Gen2: {GC.CollectionCount(2)}"); // +1
}
GC.CollectionCount(2).Об'єкти не переміщуються між поколіннями фізично при кожному GC. Натомість GC відслідковує boundaries (межі) між поколіннями:
Механізм promotion:
Initial State:
[Gen2][Gen1][Gen0: Obj A | Obj B | Obj C][Free]
^live ^dead ^live
After Gen0 GC:
[Gen2][Gen1: Obj A | Obj C][Gen0][Free]
Gen1 boundary moved ↑
After Another Gen0+Gen1 GC (Obj A та C survive):
[Gen2: Obj A | Obj C][Gen1][Gen0][Free]
Gen2 boundary moved ↑
Важливо: Promotion не вимагає копіювання - просто оновлюються generation boundaries. Це робить процес дуже ефективним.
Компактування великих об'єктів (>85,000 байт) дороге з точки зору CPU:
Тому .NET розділяє heap на дві частини:
| Характеристика | SOH (Small Object Heap) | LOH (Large Object Heap) |
|---|---|---|
| Розмір об'єктів | < 85,000 байт | ≥ 85,000 байт |
| Компактування | Завжди (за замовчуванням) | Ніколи (за замовчуванням до .NET 4.5.1) |
| Покоління | Gen0, Gen1, Gen2 | Тільки Gen2 |
| Алокація | Sequential (послідовно) | Free list (пошук вільного блоку) |
| Фрагментація | Мінімальна (завдяки compaction) | Можлива (без compaction) |
// SOH - малий об'єкт
byte[] small = new byte[84_999]; // Розташується в SOH
Console.WriteLine(GC.GetGeneration(small)); // 0 (Gen0)
// LOH - великий об'єкт
byte[] large = new byte[85_000]; // Розташується в LOH
Console.WriteLine(GC.GetGeneration(large)); // 2 (LOH завжди Gen2)
LOH використовує free list allocation - пошук вільного блоку достатнього розміру:
LOH Before Allocation:
[Large Obj A: 100KB][Free: 50KB][Obj B: 200KB][Free: 150KB][Obj C: 80KB]
Allocate 120KB object:
Need 120KB, but largest free block is 150KB
[Obj A][Free][Obj B][New Obj: 120KB][Free: 30KB][Obj C]
Проблема: free block 50KB може ніколи не бути використаний,
якщо всі нові об'єкти >50KB
Наслідки фрагментації:
OutOfMemoryException навіть при достатній загальній вільній пам'ятіclass LOHFragmentationDemo
{
static void Main()
{
List<byte[]> allocated = new List<byte[]>();
// Алокуємо багато великих об'єктів
for (int i = 0; i < 100; i++)
{
allocated.Add(new byte[100_000]); // 100 KB
}
// Звільняємо кожен другий
for (int i = 0; i < allocated.Count; i += 2)
{
allocated[i] = null;
}
// Тепер LOH фрагментований:
// [Obj][Free 100KB][Obj][Free 100KB][Obj]...
GC.Collect(2, GCCollectionMode.Forced);
// Спробуємо алокувати об'єкт більше ніж 100KB
try
{
var huge = new byte[150_000]; // Може зайняти час через пошук
}
catch (OutOfMemoryException)
{
Console.WriteLine("LOH fragmented - cannot allocate!");
}
}
}
class LOHCompactionDemo
{
static void Main()
{
// За замовчуванням LOH не компактується
Console.WriteLine($"LOH Compaction: {GCSettings.LargeObjectHeapCompactionMode}");
// Default
// Увімкнути компактування для наступного GC
GCSettings.LargeObjectHeapCompactionMode =
GCLargeObjectHeapCompactionMode.CompactOnce;
// Виконати Full GC з компактуванням LOH
GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true);
// Після цього режим повертається до Default
Console.WriteLine($"After GC: {GCSettings.LargeObjectHeapCompactionMode}");
// Default
}
}
З .NET 5 додано нове покоління - Pinned Object Heap:
Призначення: Зберігання pinned об'єктів окремо від SOH/LOH для уникнення фрагментації.
Що таке Pinned Objects?
unsafe void PinningExample()
{
byte[] buffer = new byte[1000];
// Pinning - фіксуємо об'єкт у пам'яті (не може бути переміщений GC)
fixed (byte* ptr = buffer)
{
// Викликаємо native функцію, яка очікує вказівник
NativeApi.ProcessData(ptr, buffer.Length);
// Під час цього buffer НЕ МОЖЕ бути переміщений compaction!
}
// Після виходу з fixed, об'єкт unpinned
}
// Альтернатива: GCHandle
void PinWithGCHandle()
{
byte[] buffer = new byte[1000];
GCHandle handle = GCHandle.Alloc(buffer, GCHandleType.Pinned);
try
{
IntPtr ptr = handle.AddrOfPinnedObject();
NativeApi.ProcessData(ptr, buffer.Length);
}
finally
{
handle.Free(); // Unpinning
}
}
Проблема з pinned об'єктами в SOH:
Heap перед GC compaction:
[Obj A][Pinned B][Obj C][Free]
Compaction неможливий для B, тому:
[Obj A][Pinned B][ ][Obj C][Free]
↑ gap - фрагментація!
POH вирішення:
SOH: [Obj A][Obj C][Free] ← Може бути компактований
POH: [Pinned B][Pinned D] ← Окремий heap для pinned об'єктів
GC.AllocateArray<T> для створення pinned масивів напряму в POH:// .NET 5+
byte[] pinnedBuffer = GC.AllocateArray<byte>(1000, pinned: true);
// Цей масив відразу у POH, не потребує GCHandle!
Порівняння Heaps:
| Heap | Розмір Об'єктів | Compaction | Покоління | Use Case |
|---|---|---|---|---|
| SOH | < 85 KB | Так | Gen0/1/2 | Звичайні об'єкти |
| LOH | ≥ 85 KB | За запитом | Gen2 | Великі буфери, масиви |
| POH (.NET 5+) | Будь-який | Ні | Gen2 | Pinned об'єкти для interop |
.NET надає різні режими роботи GC для різних сценаріїв використання.
Призначення: Оптимізований для клієнтських додатків (desktop, mobile).
Характеристики:
Конфігурація:
<PropertyGroup>
<ServerGarbageCollection>false</ServerGarbageCollection>
</PropertyGroup>
{
"runtimeOptions": {
"configProperties": {
"System.GC.Server": false
}
}
}
Use Case:
// WPF/WinForms Desktop Application
public partial class MainWindow : Window
{
// Workstation GC - низькі паузи для responsive UI
private async void LoadData_Click(object sender, RoutedEventArgs e)
{
// GC може відбутись під час роботи,
// але паузи короткі -> UI responsive
var data = await DataService.FetchLargeDataset();
DataGrid.ItemsSource = data;
}
}
Призначення: Оптимізований для високопродуктивних серверних додатків.
Характеристики:
Як працює:
Конфігурація:
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
<!-- Опціонально: кількість GC heaps (за замовчуванням = logical cores) -->
<GCHeapCount>8</GCHeapCount>
</PropertyGroup>
Use Case:
// ASP.NET Core Web API
public class OrdersController : ControllerBase
{
// Server GC - throughput важливіший за latency
[HttpPost]
public async Task<IActionResult> ProcessBulkOrders([FromBody] List<Order> orders)
{
// Обробка тисяч orders паралельно
var tasks = orders.Select(ProcessOrder);
await Task.WhenAll(tasks);
// Server GC ефективно працює з багатопотоковим навантаженням
return Ok();
}
}
Порівняльна Таблиця:
| Характеристика | Workstation GC | Server GC |
|---|---|---|
| Threads | Один GC thread | По thread на кожне core |
| Heap Segments | Один сегмент | Кілька сегментів (per core) |
| Latency (Паузи) | Короткі | Потенційно довші |
| Throughput | Нижчий | Вищий (до 2-3x) |
| Memory Consumption | Менше | Більше (~20-30% більше) |
| Best For | UI додатки, клієнтські застосунки | Web серверы, background processes |
| Overhead | Мінімальний | Вищий через multiple heaps |
Background GC (раніше відомий як Concurrent GC) дозволяє application threads продовжувати роботу під час Gen2 collection.
Як працює:
Фази Background GC:
Конфігурація:
<PropertyGroup>
<!-- За замовчуванням увімкнено, але можна вимкнути -->
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>
Вимкнути Background GC:
// У коді (до запуску GC):
System.Runtime.GCSettings.LatencyMode = GCLatencyMode.Batch;
// Batch mode - без background, компактує агресивніше
Переваги:
Недоліки:
Найчастіша причина - вичерпано budget (ліміт розміру) для покоління:
void AllocateTillGC()
{
int collectionsBefore = GC.CollectionCount(0);
// Продовжуємо алокувати, поки не спрацює Gen0 GC
while (GC.CollectionCount(0) == collectionsBefore)
{
var obj = new byte[1024]; // 1 KB
// Gen0 budget зменшується...
}
Console.WriteLine("Gen0 GC triggered!");
}
Як CLR визначає budget:
// ❌ ПОГАНА практика у більшості випадків
GC.Collect();
// Збір конкретного покоління
GC.Collect(0); // Тільки Gen0
GC.Collect(1); // Gen0 + Gen1
GC.Collect(2); // Full GC (Gen0 + Gen1 + Gen2)
// З додатковими параметрами
GC.Collect(2,
GCCollectionMode.Optimized, // Оптимальний час для збору
blocking: true, // Чекати завершення
compacting: true); // З компактуванням
ОС повідомляє CLR про низький рівень доступної пам'яті:
// Симуляція: реєстрація на memory pressure
AppDomain.CurrentDomain.MonitoringIsEnabled = true;
void MonitorMemory()
{
long before = GC.GetTotalMemory(forceFullCollection: false);
// При низькому рівні пам'яті ОС повідомить CLR
// CLR може викликати aggressive Gen2 GC
long after = GC.GetTotalMemory(forceFullCollection: false);
Console.WriteLine($"Memory: {before / 1024 / 1024} MB -> {after / 1024 / 1024} MB");
}
CLR отримує notifications через:
CreateMemoryResourceNotification / QueryMemoryResourceNotification/proc/meminfoМожна запросити CLR не викликати GC протягом критичної секції:
bool TryNoGCRegion()
{
long totalMemory = 10 * 1024 * 1024; // 10 MB budget
try
{
// Запитуємо CLR: "Не викликай GC, поки є 10 MB"
if (GC.TryStartNoGCRegion(totalMemory))
{
// Критична секція - GC не спрацює (якщо вистачить budget)
PerformLatencySensitiveOperation();
return true;
}
return false;
}
finally
{
if (GC.TryStartNoGCRegion(0)) // Перевірка, чи в no-gc режимі
GC.EndNoGCRegion();
}
}
void PerformLatencySensitiveOperation()
{
// Наприклад: real-time audio processing, high-frequency trading
for (int i = 0; i < 1000; i++)
{
ProcessFrame(); // Немає GC паузи!
}
}
InvalidOperationExceptiongeneration (0-2), mode, blocking, compacting.forceFullCollection=true, виконується Full GC перед підрахунком.GC.Collect() для гарантії, що finalizers виконались.Приклад моніторингу:
class GCMonitor
{
static void Main()
{
Console.WriteLine("=== GC Statistics ===\n");
// Загальна пам'ять
long totalMemory = GC.GetTotalMemory(forceFullCollection: false);
Console.WriteLine($"Total Memory: {totalMemory / 1024 / 1024} MB");
// Кількість збірок по поколіннях
Console.WriteLine($"Gen0 Collections: {GC.CollectionCount(0)}");
Console.WriteLine($"Gen1 Collections: {GC.CollectionCount(1)}");
Console.WriteLine($"Gen2 Collections: {GC.CollectionCount(2)}");
// Створюємо об'єкт і відслідковуємо його покоління
var obj = new object();
Console.WriteLine($"\nObject generation: {GC.GetGeneration(obj)}");
GC.Collect();
Console.WriteLine($"After GC(0): {GC.GetGeneration(obj)}");
GC.Collect();
Console.WriteLine($"After GC(1): {GC.GetGeneration(obj)}");
// GC Settings
Console.WriteLine($"\n=== GC Configuration ===");
Console.WriteLine($"GC Mode: {(GCSettings.IsServerGC ? "Server" : "Workstation")}");
Console.WriteLine($"Latency Mode: {GCSettings.LatencyMode}");
Console.WriteLine($"LOH Compaction: {GCSettings.LargeObjectHeapCompactionMode}");
// GC Max Generation
Console.WriteLine($"Max Generation: {GC.MaxGeneration}"); // Зазвичай 2
}
}
Output:
=== GC Statistics ===
Total Memory: 2 MB
Gen0 Collections: 0
Gen1 Collections: 0
Gen2 Collections: 0
Object generation: 0
After GC(0): 1
After GC(1): 2
=== GC Configuration ===
GC Mode: Workstation
Latency Mode: Interactive
LOH Compaction: Default
Max Generation: 2
Unmanaged resources (некеровані ресурси) - це ресурси операційної системи, які не управляються Garbage Collector:
Marshal.AllocHGlobal(), malloc() в C/C++.GC відповідає тільки за managed heap. Він не знає про:
close() для сокетаclass ProblematicCode
{
void OpenFile()
{
FileStream fs = new FileStream("data.txt", FileMode.Open);
byte[] buffer = new byte[1024];
fs.Read(buffer, 0, buffer.Length);
// ❌ ПОМИЛКА: Не закрили файл!
// fs.Dispose() не викликано
} // fs вийшов з scope, але FILE HANDLE ще відкритий!
// GC зібере FileStream об'єкт, але handle залишиться!
}
Наслідки: File handle leak → через якийсь час ОС заборонить відкривати нові файли.
IOException: Too many open files.Linux: Ліміт можна перевірити: ulimit -n. Зазвичай 1024-65535.IDisposable - стандартний механізм .NET для явного звільнення ресурсів:
public interface IDisposable
{
void Dispose();
}
Контракт:
Dispose() має звільнити всі некеровані ресурсиDispose() об'єкт стає unusable (не можна використовувати)Dispose() кілька разів безпечно (idempotent)Dispose() не викликається автоматично - відповідальність користувача класуНайпростіший випадок - клас з одним unmanaged resource:
class FileLogger : IDisposable
{
private FileStream _fileStream;
public FileLogger(string path)
{
_fileStream = new FileStream(path, FileMode.Append);
}
public void Dispose()
{
// Звільнити unmanaged resource
_fileStream?.Dispose();
_fileStream = null;
}
public void Log(string message)
{
if (_fileStream == null)
throw new ObjectDisposedException(nameof(FileLogger));
byte[] data = Encoding.UTF8.GetBytes(message + Environment.NewLine);
_fileStream.Write(data, 0, data.Length);
}
}
// Використання
using (var logger = new FileLogger("app.log"))
{
logger.Log("Application started");
logger.Log("Processing data");
} // Dispose() викликається автоматично
_fileStream?.Dispose() безпечно викликає Dispose навіть якщо _fileStream вже null. Це забезпечує idempotency.Для складніших сценаріїв (наслідування, комбінація managed + unmanaged resources) використовується повний dispose pattern:
class ResourceManager : IDisposable
{
// Unmanaged resource
private IntPtr _nativePtr;
// Managed resource (також IDisposable)
private FileStream _fileStream;
// Flag для відстеження disposed стану
private bool _disposed = false;
public ResourceManager(string filePath)
{
// Алокуємо unmanaged memory
_nativePtr = Marshal.AllocHGlobal(1024);
// Відкриваємо файл
_fileStream = new FileStream(filePath, FileMode.Create);
}
// Public Dispose method
public void Dispose()
{
// Викликаємо protected Dispose з disposing=true
Dispose(disposing: true);
// Вказуємо GC: не викликати finalizer
GC.SuppressFinalize(this);
}
// Protected virtual Dispose method
protected virtual void Dispose(bool disposing)
{
// Перевірка: чи вже disposed?
if (_disposed)
return;
if (disposing)
{
// Звільняємо MANAGED resources
_fileStream?.Dispose();
}
// Звільняємо UNMANAGED resources (завжди!)
if (_nativePtr != IntPtr.Zero)
{
Marshal.FreeHGlobal(_nativePtr);
_nativePtr = IntPtr.Zero;
}
_disposed = true;
}
// Destructor (Finalizer)
~ResourceManager()
{
// Викликається GC, якщо Dispose() не був викликаний
// disposing=false -> тільки unmanaged cleanup
Dispose(disposing: false);
}
// Helper method для перевірки disposed стану
private void ThrowIfDisposed()
{
if (_disposed)
throw new ObjectDisposedException(GetType().Name);
}
public void WriteData(byte[] data)
{
ThrowIfDisposed();
_fileStream.Write(data, 0, data.Length);
}
}
Розбір ключових моментів:
Dispose(bool disposing) - Серце PatternПараметр disposing:
true: викликано з Dispose() (managed context) → очищаємо і managed, і unmanagedfalse: викликано з finalizer (недетерміновано) → очищаємо ТІЛЬКИ unmanagedЧому? Під час finalization managed об'єкти можуть вже бути зібрані GC!
GC.SuppressFinalize(this)Якщо Dispose() викликано явно, finalizer не потрібен → економимо ресурси GC.
_disposed FlagЗахист від повторного виклику. Без цього можна двічі звільнити ресурс → crash!
~ResourceManager()Fallback на випадок, якщо користувач забув викликати Dispose(). Не ідеально, але краще ніж leak.
Якщо клас успадковує інший disposable клас:
class BaseResource : IDisposable
{
private IntPtr _baseHandle;
protected bool _disposed = false;
public BaseResource()
{
_baseHandle = Marshal.AllocHGlobal(512);
}
public void Dispose()
{
Dispose(disposing: true);
GC.SuppressFinalize(this);
}
// VIRTUAL - дозволяємо override в derived
protected virtual void Dispose(bool disposing)
{
if (_disposed)
return;
if (disposing)
{
// Managed resources (якщо є)
}
// Unmanaged cleanup
if (_baseHandle != IntPtr.Zero)
{
Marshal.FreeHGlobal(_baseHandle);
_baseHandle = IntPtr.Zero;
}
_disposed = true;
}
~BaseResource()
{
Dispose(disposing: false);
}
}
class DerivedResource : BaseResource
{
private FileStream _derivedFile;
public DerivedResource(string path)
{
_derivedFile = new FileStream(path, FileMode.Create);
}
protected override void Dispose(bool disposing)
{
if (_disposed)
return;
if (disposing)
{
// Очищаємо СВОЇ managed resources
_derivedFile?.Dispose();
}
// Unmanaged resources derived класу (якщо є)
// ...
// КРИТИЧНО: Викликати base.Dispose()
base.Dispose(disposing);
// НЕ встановлюємо _disposed тут - це робить base
}
// Finalizer НЕ потрібен - base вже має
}
class Program
{
static void Main()
{
using (var resource = new DerivedResource("data.bin"))
{
// Використання resource
}
// При виході з using:
// 1. resource.Dispose() викликається
// 2. DerivedResource.Dispose(true) виконується
// 3. base.Dispose(true) викликається
// 4. Очищені обидва рівні ієрархії
}
}
virtualoverride Dispose, викликати base.Dispose(disposing) в кінці_disposed flag можна зробити protected для доступу в derivedДля асинхронних ресурсів (наприклад, network streams, async file I/O):
class AsyncResourceManager : IAsyncDisposable
{
private NetworkStream _networkStream;
private SemaphoreSlim _semaphore = new SemaphoreSlim(1);
private bool _disposed = false;
public async ValueTask DisposeAsync()
{
if (_disposed)
return;
await DisposeAsyncCore().ConfigureAwait(false);
// Cleanup synchronous resources
Dispose(disposing: false);
GC.SuppressFinalize(this);
_disposed = true;
}
protected virtual async ValueTask DisposeAsyncCore()
{
// Асинхронне очищення
if (_networkStream != null)
{
await _networkStream.FlushAsync().ConfigureAwait(false);
await _networkStream.DisposeAsync().ConfigureAwait(false);
}
_semaphore?.Dispose();
}
protected virtual void Dispose(bool disposing)
{
// Синхронні unmanaged resources (якщо є)
}
}
// Використання з await using
async Task ProcessAsync()
{
await using var manager = new AsyncResourceManager();
// Асинхронна робота
await manager.ProcessDataAsync();
} // DisposeAsync() викликається автоматично
Коли використовувати IAsyncDisposable:
D ispose() буде fallback для synchronous context, DisposeAsync() - для async.class DualDisposable : IDisposable, IAsyncDisposable
{
public void Dispose() { /* sync path */ }
public ValueTask DisposeAsync() { /* async path */ }
}
using statement забезпечує автоматичний виклик Dispose навіть при exceptions:
using (FileStream fs = new FileStream("data.txt", FileMode.Open))
{
// Робота з файлом
byte[] buffer = new byte[1024];
fs.Read(buffer, 0, buffer.Length);
// Навіть якщо exception -> Dispose() викликається!
}
// fs.Dispose() гарантовано викликано
Компіляція в IL (спрощено):
FileStream fs = new FileStream("data.txt", FileMode.Open);
try
{
byte[] buffer = new byte[1024];
fs.Read(buffer, 0, buffer.Length);
}
finally
{
if (fs != null)
((IDisposable)fs).Dispose();
}
Scope-based disposal без фігурних дужок:
void ProcessFile()
{
using FileStream fs = new FileStream("data.txt", FileMode.Open);
using StreamReader reader = new StreamReader(fs);
// Використання fs та reader
string content = reader.ReadToEnd();
Console.WriteLine(content);
// Dispose викликається при виході з методу (в ЗВОРОТНОМУ порядку):
// 1. reader.Dispose()
// 2. fs.Dispose()
}
Порівняння:
void TraditionalUsing()
{
using (var resource1 = new Resource())
{
using (var resource2 = new Resource())
{
using (var resource3 = new Resource())
{
// Використання
DoWork();
}
}
}
// Вкладені блоки -> "Pyramid of Doom"
}
void UsingDeclaration()
{
using var resource1 = new Resource();
using var resource2 = new Resource();
using var resource3 = new Resource();
// Використання - чистіший код!
DoWork();
// При виході з методу dispose в reverse order:
// resource3, resource2, resource1
}
Для IAsyncDisposable:
async Task ProcessDataAsync()
{
// Traditional await using
await using (var stream = new NetworkStream(...))
{
await stream.WriteAsync(data);
await stream.FlushAsync();
} // await stream.DisposeAsync()
// await using declaration
await using var connection = new AsyncDatabaseConnection();
await connection.ExecuteQueryAsync("SELECT * FROM Users");
} // await connection.DisposeAsync()
Компіляція:
// await using (var res = new AsyncResource())
var res = new AsyncResource();
try
{
// Use resource
}
finally
{
if (res != null)
await res.DisposeAsync().ConfigureAwait(false);
}
Кілька ресурсів одного типу:
// Традиційний синтаксис (рідко використовується)
using (FileStream input = File.OpenRead("input.txt"),
output = File.OpenWrite("output.txt"))
{
input.CopyTo(output);
}
// Краще: using declarations
void CopyFile()
{
using var input = File.OpenRead("input.txt");
using var output = File.OpenWrite("output.txt");
input.CopyTo(output);
}
Різні типи:
async Task ProcessWithMultipleResources()
{
using var connection = new SqlConnection(connectionString);
await using var transaction = await connection.BeginTransactionAsync();
using var semaphore = new SemaphoreSlim(1);
await semaphore.WaitAsync();
try
{
// Робота з resources
await ExecuteInTransactionAsync(connection, transaction);
await transaction.CommitAsync();
}
finally
{
semaphore.Release();
}
// Dispose order (reverse):
// 1. semaphore.Dispose()
// 2. await transaction.DisposeAsync()
// 3. connection.Dispose()
}
Finalizer (деструктор) - це спеціальний метод, який викликається GC перед звільненням пам'яті об'єкта. Детальний розбір механізму:
class ResourceHolder
{
private IntPtr _handle;
public ResourceHolder()
{
_handle = AllocateResource();
}
// Finalizer (деструктор)
~ResourceHolder()
{
// Cleanup код
if (_handle != IntPtr.Zero)
{
FreeResource(_handle);
_handle = IntPtr.Zero;
}
}
}
Під капотом компілятор перетворює ~ClassName() у метод Finalize():
// Що генерує компілятор:
protected override void Finalize()
{
try
{
// Ваш cleanup код тут
if (_handle != IntPtr.Zero)
{
FreeResource(_handle);
_handle = IntPtr.Zero;
}
}
finally
{
base.Finalize(); // Автоматичний виклик finalizer базового класу
}
}
GC використовує дві спеціальні черги для управління об'єктами з finalizers. Процес складний і цікавий - розберемо детально.
Проблема: Об'єкти з finalizers живуть мінімум 2 GC cycles, що затримує звільнення пам'яті.
WeakReference дозволяє посилатися на об'єкт без запобігання його збору GC - ідеально для кешів.
class ImageCache
{
private Dictionary<string, WeakReference<Image>> _cache = new();
public Image GetImage(string path)
{
if (_cache.TryGetValue(path, out var weakRef) &&
weakRef.TryGetTarget(out var image))
{
return image; // Cache hit
}
// Cache miss - завантажити
var newImage = Image.Load(path);
_cache[path] = new WeakReference<Image>(newImage);
return newImage;
}
}
Кожен об'єкт має overhead:
64-bit процес:
┌──────────────┬──────────────┬─────────────┐
│ Sync Block 8B│MethodTable 8B│ Fields... │
└──────────────┴──────────────┴─────────────┘
Мінімум: 24 байти для порожнього об'єкта
32-bit процес:
┌──────────────┬──────────────┬─────────────┐
│ Sync Block 4B│MethodTable 4B│ Fields... │
└──────────────┴──────────────┴─────────────┘
Мінімум: 12 байт
struct BadLayout
{
byte b1; // 1 byte
long l1; // 8 bytes
byte b2; // 1 byte
}
// Actual size: 24 bytes (з padding!)
struct GoodLayout
{
long l1; // 8 bytes
byte b1; // 1 byte
byte b2; // 1 byte
}
// Actual size: 16 bytes
GC.Collect() без вагомої причиниusing для всіх IDisposable ресурсів// ❌ ПОГАНО - після кожної операції
void ProcessItems()
{
foreach (var item in items)
{
ProcessItem(item);
GC.Collect(); // Катастрофа для performance!
}
}
// ✅ ДОБРЕ - довіряйте GC
void ProcessItems()
{
foreach (var item in items)
{
ProcessItem(item);
}
// GC сам вирішить, коли збирати
}
Винятки (коли можна викликати):
Створіть клас FileLogger, який:
FileStream (managed resource)ObjectDisposedException після disposeОчікуваний результат:
using (var logger = new FileLogger("app.log"))
{
logger.Write("Test");
} // Dispose викликається автоматично
Реалізуйте thread-safe cache для images:
WeakReference<Bitmap>Створіть utility, який аналізує структури:
Marshal.SizeOf() та Unsafe.SizeOf()Побудуйте GC monitoring tool:
✅ Managed Heap - швидка алокація через pointer bumping
✅ GC Phases - marking, compaction, reference updating
✅ Generations - Gen0/1/2 оптимізація на основі object lifetime
✅ LOH/SOH/POH - різні heaps для різних сценаріїв (>85KB, pinned objects)
✅ GC Modes - Workstation (low latency) vs Server (high throughput)
✅ Background GC - concurrent збір для зменшення пауз
✅ IDisposable Pattern - правильне управління unmanaged resources
✅ using Statement - автоматичний dispose з гарантією cleanup
✅ Finalizers - safety net з затримкою звільнення пам'яті
✅ WeakReference - кешування без memory leaks
✅ Memory Layout - object header, padding, alignment оптимізація
| Концепція | Ключ до Розуміння |
|---|---|
| Generational GC | Більшість об'єктів живуть коротко → Gen0 збирається часто |
| Dispose Pattern | Dispose(bool) - managed cleanup якщо true, unmanaged завжди |
| LOH Fragmentation | Об'єкти ≥85KB не компактуються → можливі gaps |
| WeakReference | Посилання не запобігає GC → ideal для caches |
| Memory Overhead | Мінімум 12-24 байти на об'єкт (header) |
Наступні кроки:
📖 Threading and Async Programming - Дізнайтесь про конкурентність
📖 .NET Performance Tips - Оптимізація додатків
🔧 PerfView - GC profiling tool
🔧 dotMemory - Memory profiler
LINQ (Language Integrated Query)
Повний гід по LINQ в C# — від синтаксису запитів до продуктивних операторів фільтрації, проекції, групування та агрегації даних.
Reflection API: System.Type та Метадані
Глибоке занурення у механізми рефлексії C#: System.Type, інспекція метаданих, динамічний виклик членів, та практичне застосування.