Публікація

Garbage Collection у .NET - все, що потрібно знати

Garbage Collection у .NET - все, що потрібно знати

Збирання сміття (Garbage Collection, GC) – це автоматичний механізм керування пам’яттю, який звільняє розробників від необхідності вручну виділяти та звільняти пам’ять. У .NET це одна з ключових технологій, яка відрізняє платформу від мов, де керування пам’яттю відбувається вручну.

Куча пам’яті (Heap) – це область пам’яті, де зберігаються об’єкти, створені під час виконання програми. На відміну від стеку, де зберігаються значення типів-значень (int, float, struct тощо), куча використовується для зберігання об’єктів типів-посилань (класів).

Основи кучі пам’яті в .NET

У .NET вся куча пам’яті є керованою, тобто CLR (Common Language Runtime) бере на себе відповідальність за її управління. Коли створюється новий об’єкт, CLR виділяє для нього пам’ять у кучі та повертає посилання на цей об’єкт.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
class Program
{
    static void Main()
    {
        // Створення об'єкта у кучі
        Person person = new Person("Тарас", 32);
        
        // Використання об'єкта
        Console.WriteLine(person.Name);
        
        // Тут не потрібно вручну звільняти пам'ять!
        // GC зробить це автоматично, коли об'єкт стане недосяжним
    }
}

class Person(string name, int age)
{
    public string Name { get; set; } = name;
    
    public int Age { get; set; } = age;
}

У цьому прикладі об’єкт person створюється у кучі, а посилання на нього зберігається у локальній змінній. Коли метод Main завершиться, це посилання зникне, і об’єкт стане недоступним. У цей момент GC зможе звільнити пам’ять, зайняту цим об’єктом.

Покоління об’єктів у .NET GC

Одна з головних особливостей .NET GC – це розподіл об’єктів за поколіннями. Існує три генерації:

  • Покоління 0 (Gen 0) – нові об’єкти, які щойно створені.
  • Покоління 1 (Gen 1) – об’єкти, які пережили один цикл збору сміття.
  • Покоління 2 (Gen 2) – об’єкти, які пережили два або більше циклів збору сміття.

Цей розподіл базується на гіпотезі, що нові об’єкти, ймовірно, будуть жити недовго, а старі об’єкти, ймовірно, залишаться живими ще довше.

graph TD
    A[Створення об'єкта] --> B[Покоління 0]
    B -->|Пережив один цикл GC| C[Покоління 1]
    C -->|Пережив ще один цикл GC| D[Покоління 2]
    B -->|Став недосяжним| E[Звільнення пам'яті]
    C -->|Став недосяжним| E
    D -->|Став недосяжним| E

Цикли збору сміття

GC у .NET запускає цикли збору сміття за різних умов:

  • Виділення пам’яті — якщо система намагається виділити пам’ять у Gen 0, але вона заповнена.
  • Явний виклик — коли у коді викликається GC.Collect().
  • Низький тиск системної пам’яті — коли операційна система повідомляє про нестачу пам’яті.
  • Зміна домену застосунку — коли AppDomain вивантажується.
  • Завершення програми — коли програма закінчує роботу.

Існує три типи збору сміття залежно від генерації:

  • Gen 0 — найчастіший, перевіряє тільки найновіші об’єкти.
  • Gen 1 — відбувається, коли Gen 0 не звільнила достатньо пам’яті.
  • Gen 2 — повний збір, який перевіряє всі об’єкти у кучі. Найдовший і найрідший.
sequenceDiagram
    participant App as Застосунок
    participant G0 as Покоління 0
    participant G1 as Покоління 1
    participant G2 as Покоління 2
    
    App->>G0: Створення об'єктів
    Note over G0: Заповнення...
    G0->>G0: Спроба виділити пам'ять
    Note over G0: Пам'ять закінчилась
    G0->>G0: Запуск GC Gen 0
    Note over G0: Перевірка досяжності
    G0->>G0: Звільнення недосяжних об'єктів
    G0->>G1: Переміщення живих об'єктів
    
    Note over G1: Заповнення...
    G0->>G0: Нове заповнення
    Note over G0: Пам'ять знову закінчилась
    G0->>G0: Запуск GC Gen 0
    G0->>G0: Звільнення недосяжних об'єктів
    Note over G0: Недостатньо пам'яті звільнено
    G0->>G1: Запуск GC Gen 1
    G1->>G1: Звільнення недосяжних об'єктів
    G1->>G2: Переміщення живих об'єктів
    
    Note over G0,G2: Через деякий час...
    G0->>G2: Запуск GC Gen 2 (повний збір)
    G2->>G2: Звільнення недосяжних об'єктів

Фази GC: Від позначення до ущільнення

Процес збору сміття складається з декількох фаз:

  • Фаза маркування (Mark Phase) — GC створює граф об’єктів, починаючи від “коренів” (root references) і позначає всі досяжні об’єкти як живі.
  • Фаза планування (Plan Phase) — GC визначає, які з мертвих об’єктів можна звільнити і як компактно переорганізувати живі об’єкти.
  • Фаза переміщення (Relocate Phase) — живі об’єкти переміщуються в нові місця для компактності.
  • Фаза ущільнення (Compact Phase) — пам’ять ущільнюється, щоб запобігти фрагментації.
graph LR
    A[Фаза маркування] --> B[Фаза планування]
    B --> C[Фаза переміщення]
    C --> D[Фаза ущільнення]
    
    subgraph "Фаза маркування"
    A1[Знайти корені]
    A2[Пройти граф об'єктів]
    A3[Позначити живі об'єкти]
    end
    
    subgraph "Фаза планування"
    B1[Визначити мертві об'єкти]
    B2[Спланувати нове розміщення]
    end
    
    subgraph "Фаза переміщення"
    C1[Оновити посилання]
    C2[Перемістити об'єкти]
    end
    
    subgraph "Фаза ущільнення"
    D1[Дефрагментація пам'яті]
    D2[Обробка закріплених об'єктів]
    end

Ущільнення та фрагментація пам’яті в контексті GC

Фрагментація пам’яті

Фрагментація пам’яті — це явище, коли вільна пам’ять стає “роздробленою” на маленькі ділянки, розкидані між зайнятими об’єктами. Це схоже на паркінг, де є багато маленьких вільних місць, розкиданих між автомобілями, але немає жодного великого місця для паркування автобуса.

Уявіть пам’ять як лінійний масив комірок:

[A][A][A][B][B][_][C][_][D][_][_][E]

Де:

[A], [B], [C], [D], [E] — зайняті блоки пам’яті (живі об’єкти)

[_] — вільні блоки пам’яті

У цьому прикладі вільна пам’ять фрагментована на кілька маленьких шматків. Хоча загальний обсяг вільної пам’яті може бути достатнім для створення нового об’єкта, але жоден окремий фрагмент не має достатнього розміру для розміщення великого об’єкта.

Наслідки фрагментації:

Неефективне використання пам’яті

  • Проблеми з розміщенням нових великих об’єктів
  • Зниження швидкодії програми

Ущільнення пам’яті (Compaction)

Ущільнення — це процес, коли GC переміщує живі об’єкти так, щоб вони були розташовані поруч один з одним, а вся вільна пам’ять об’єднувалася в один суцільний блок.

До ущільнення:

[A][A][A][_][B][_][C][_][D][_][_][E]

Після ущільнення:

[A][A][A][B][C][D][E][_][_][_][_][_]

Тепер всі вільні блоки об’єднані в один великий, що дозволяє ефективно розміщувати нові об’єкти, навіть великі.

Як працює ущільнення в .NET

Процес ущільнення пам’яті в .NET представляє собою складну послідовність операцій. Спочатку GC визначає, які об’єкти залишаються досяжними в пам’яті. Потім він створює план переміщення цих живих об’єктів таким чином, щоб усунути прогалини (“дірки”) в пам’яті. Згідно з планом, GC копіює живі об’єкти у нові, послідовні місця в пам’яті. Після переміщення збирач сміття оновлює всі посилання на ці об’єкти, щоб вони вказували на нові адреси пам’яті. Нарешті, система звільняє стару пам’ять, де раніше знаходились об’єкти, роблячи її доступною для нових алокацій.

Особливості ущільнення суттєво відрізняються для різних частин керованої кучі. У Small Object Heap (SOH) ущільнення виконується регулярно під час кожного циклу збору сміття, що допомагає ефективно використовувати пам’ять для малих об’єктів. Large Object Heap (LOH) історично не піддавалася ущільненню, оскільки переміщення великих об’єктів вимагає значних обчислювальних ресурсів. Саме через цю особливість LOH часто страждає від фрагментації пам’яті.

До версії .NET 4.5.1 ущільнення LOH взагалі не виконувалося. Починаючи з .NET 4.5.1, розробники отримали можливість увімкнути ущільнення LOH за допомогою параметра GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;.

У новіших версіях .NET механізм ущільнення LOH став значно ефективнішим, хоча за замовчуванням він все ще вимкнений.

Приклад

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
class Program
{
    static void Main()
    {
        // Створюємо і звільняємо об'єкти різного розміру, що призводить до фрагментації
        var references = new List<byte[]>();
        
        // Створення 100 об'єктів по 1MB (викликає фрагментацію)
        for (int i = 0; i < 100; i++)
        {
            // 1MB
            references.Add(new byte[1024 * 1024]);
            
            // Видаляємо об'єкти через один, створюючи "дірки"
            if (i % 2 == 0)
            {
              references.RemoveAt(references.Count - 1);
            }
        }
        
        // Спроба створити великий об'єкт (може не вдатися через фрагментацію)
        try
        {
            // 50MB
            var largeObject = new byte[50 * 1024 * 1024];
            Console.WriteLine("Великий об'єкт успішно створено");
        }
        catch (OutOfMemoryException)
        {
            Console.WriteLine("Помилка: недостатньо пам'яті (ймовірно через фрагментацію)");
            
            // Викликаємо повний GC з ущільненням
            GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
            GC.Collect(2, GCCollectionMode.Forced, true, true);
            
            try
            {
                // 50MB
                var largeObject = new byte[50 * 1024 * 1024];
                Console.WriteLine("Після ущільнення великий об'єкт успішно створено");
            }
            catch
            {
                Console.WriteLine("Все одно недостатньо пам'яті");
            }
        }
    }
}

Як запобігти проблемам з фрагментацією

  • Використовуйте пули об’єктів — повторно використовуйте об’єкти замість створення нових (ArrayPool<T>, ObjectPool<T>)
  • Мінімізуйте використання закріплення об’єктів, зафіксовані через fixed або GCHandle.Alloc(obj, GCHandleType.Pinned), створюють “дірки” при ущільненні
  • Враховуйте розмір об’єктів — уникайте створення об’єктів, розмір яких близький до межі LOH (85KB)
  • Структуруйте дані — організовуйте дані таким чином, щоб об’єкти, які використовуються разом, створювалися разом
  • Використовуйте структури — для маленьких типів даних використовуйте структури замість класів, щоб зменшити навантаження на кучу

Сегменти пам’яті та Large Object Heap (LOH)

Куча пам’яті в .NET розділена на два основних типи:

  • Small Object Heap (SOH) — для об’єктів менше 85,000 байт. Ця куча поділена на три генерації (0, 1, 2).
  • Large Object Heap (LOH) — для об’єктів більше 85,000 байт. Ця куча працює інакше:

Об’єкти в LOH одразу потрапляють до Gen 2

  • LOH не проходить ущільнення за замовчуванням (це можна змінити в .NET 4.5.1+)
  • LOH частіше страждає від фрагментації
graph TD
    A[Куча пам'яті в .NET]
    A --> B[Small Object Heap SOH]
    A --> C[Large Object Heap LOH]
    
    B --> D[Покоління 0]
    B --> E[Покоління 1]
    B --> F[Покоління 2]
    
    C --> G[Об'єкти > 85,000 байт]
    G --> H[Тільки Gen 2]

Режими GC: Workstation vs. Server

.NET підтримує два режими GC:

Workstation GC:

  • Оптимізований для клієнтських програм
  • Мінімізує паузи для кращого інтерактивного досвіду
  • Зазвичай використовує один потік для GC

Server GC:

  • Оптимізований для серверів
  • Підвищує загальну продуктивність системи
  • Використовує кілька потоків (по одному на процесор)
  • Більші паузи, але вища загальна пропускна здатність

Також є два варіанти GC:

  • Неконкурентний GC — призупиняє всі потоки програми під час роботи GC.
  • Фоновий GC — намагається виконувати частину роботи паралельно з програмою.
graph TD
    A[Режими GC у .NET]
    A --> B[Workstation GC]
    A --> C[Server GC]
    
    B --> D[Неконкурентний]
    B --> E[Фоновий]
    
    C --> F[Неконкурентний]
    C --> G[Фоновий]
    
    D --> H[Один потік]
    E --> I[Один потік + фоновий потік]
    
    F --> J[N потоків]
    G --> K[N потоків + N фонових потоків]

Найкращі практики роботи з GC

Ось кілька порад, як ефективно працювати з GC у .NET:

  • Уникайте непотрібних виділень пам’яті:
    • Використовуйте пули об’єктів для частих алокацій (ObjectPool<T>)
    • Уникайте боксингу типів-значень
    • Використовуйте StringBuilder замість конкатенації рядків у циклах
  • Контролюйте великі об’єкти:
    • Уникайте створення тимчасових великих масивів і колекцій
    • Розгляньте варіант розбиття великих об’єктів на менші
    • Використовуйте ArrayPool<T> для роботи з тимчасовими великими масивами
  • Правильно реалізуйте IDisposable:
    • Використовуйте шаблон using для об’єктів, які потрібно звільнити
    • Завжди звільняйте некеровані ресурси
  • Рідко використовуйте явний GC:
    • Уникайте викликів GC.Collect() у більшості випадків
    • Використовуйте його тільки в особливих ситуаціях (наприклад, після великих операцій з пам’яттю)
  • Використовуйте WeakReference:
    • Для кешування даних, які можуть бути видалені GC при нестачі пам’яті
  • Уникайте циклічних посилань:
    • Хоча GC може їх обробляти, вони можуть затримати звільнення пам’яті
  • Використовуйте Span<T> і Memory<T>:
    • У .NET для роботи з блоками пам’яті без копіювання
graph LR
    A[Найкращі практики GC]
    A --> B[Мінімізуйте алокації]
    A --> C[Керуйте великими об'єктами]
    A --> D[Звільняйте ресурси]
    A --> E[Обмежте явні виклики GC]
    
    B --> B1[Пули об'єктів]
    B --> B2[Уникайте боксингу]
    B --> B3[Структури замість класів]
    
    C --> C1[Уникайте LOH]
    C --> C2[ArrayPool<T>]
    
    D --> D1[IDisposable]
    D --> D2[using]
    D --> D3[Finalizers]
    
    E --> E1[Лише за потреби]
    E --> E2[За межами критичного коду]

Типові помилки та їх вирішення

Витік пам’яті через забуті події

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// Помилка: підписуємось на подію і не відписуємось
public void Initialize()
{
    EventSource.SomeEvent += HandleEvent;
}

// Вирішення: правильно відписуємось
public void Initialize()
{
    EventSource.SomeEvent += HandleEvent;
}

public void Dispose()
{
    EventSource.SomeEvent -= HandleEvent;
}

Створення тимчасових рядків у циклах

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Помилка: багато тимчасових рядків
string result = "";
for (int i = 0; i < 1000; i++)
{
    result += i.ToString();
}

// Вирішення: StringBuilder
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 1000; i++)
{
    sb.Append(i);
}
string result = sb.ToString();

Неправильне керування некерованими ресурсами

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
// Помилка: відсутнє звільнення ресурсів
public class ResourceHandler
{
    private IntPtr nativeResource;
    
    public ResourceHandler()
    {
        nativeResource = NativeMethods.Allocate();
    }
}

// Вирішення: правильна реалізація IDisposable
public class ResourceHandler : IDisposable
{
    private IntPtr nativeResource;
    private bool disposed = false;
    
    public ResourceHandler()
    {
        nativeResource = NativeMethods.Allocate();
    }
    
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }
    
    protected virtual void Dispose(bool disposing)
    {
        if (!disposed)
        {
            if (nativeResource != IntPtr.Zero)
            {
                NativeMethods.Free(nativeResource);
                nativeResource = IntPtr.Zero;
            }
            disposed = true;
        }
    }
    
    ~ResourceHandler()
    {
        Dispose(false);
    }
}

Висновки

Збір сміття в .NET – це потужний механізм, який звільняє розробників від необхідності вручну керувати пам’яттю. Розуміння його принципів роботи та вдумливе проектування системи з урахуванням особливостей керування пам’яттю допоможе уникнути проблем з фрагментацією та забезпечить ефективну роботу програми навіть під великим навантаженням.

Ключові моменти:

  • GC у .NET використовує систему поколінь (0, 1, 2) для оптимізації процесу збору сміття.
  • Цикли GC запускаються при потребі в пам’яті, явному виклику або системних подіях.
  • Фази GC включають маркування, планування, переміщення та ущільнення.
  • Різні режими GC (Workstation vs. Server, неконкурентний vs. фоновий) оптимізовані для різних сценаріїв.
  • Дотримання найкращих практик допомагає зменшити навантаження на GC та підвищити продуктивність.

Пам’ятайте, що хоча GC автоматизує керування пам’яттю, відповідальність за ефективне використання ресурсів все ще лежить на розробнику.

Публікація захищена ліцензією CC BY 4.0 .