JavaRush /Курси /C# SELF /Памʼять у C#: стек, купа й збирання сміття (

Памʼять у C#: стек, купа й збирання сміття ( GC)

C# SELF
Рівень 65 , Лекція 0
Відкрита

1. Основи памʼяті: Стек і Купа (Stack & Heap)

Коли ваша програма запускається, вона отримує доступ до памʼяті. У контексті .NET (CLR — Common Language Runtime) цю памʼять керовано автоматично й поділено на дві основні області: Стек і Купа. Розуміння того, де та як зберігаються дані, критично важливе для написання ефективного й стабільного коду.

Стек (Stack)

Уявіть Стек як стос тарілок: ви завжди кладете нову тарілку зверху та берете також зверху. Це принцип LIFO (Last In, First Out) — «останній прийшов — перший пішов». Стек — дуже швидка, але обмежена за розміром ділянка памʼяті.

Що зберігається в стеці:

  • Типи-значення: самі значення змінних, оголошених безпосередньо.
  • Посилання на обʼєкти: для посилальних типів у стеці зберігається лише посилання на обʼєкт у купі.
  • Параметри методів: значення, які передаються під час виклику методів.
  • Локальні змінні: змінні, оголошені всередині методів.
  • Адреси повернення: куди повернутися після виконання методу.

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

Приклад: Значущі типи в стеці

void MyMethod()
{
    int a = 10;       // 'a' та значення 10 — у стеці
    bool flag = true; // 'flag' та значення true — у стеці
    char initial = 'Z'; // 'initial' та значення 'Z' — у стеці

    // ...
} // Коли MyMethod завершується, 'a', 'flag', 'initial' видаляються зі стеку.

Приклад: Посилання в стеці

class MyObject { }

void AnotherMethod()
{
    MyObject objRef; // 'objRef' (посилання) — у стеці. Сам об'єкт ще не створено.
    // ...
} // Коли AnotherMethod завершується, 'objRef' (посилання) видаляється зі стеку.

Купа (Heap)

Купа — значно більша і гнучкіша область памʼяті. Тут немає суворого порядку LIFO; дані можуть бути розміщені в будь-якому вільному місці. Купа використовується для великих та/або довгоживучих обʼєктів.

Що зберігається в купі:

  • Обʼєкти посилальних типів: дані класів, масивів, рядків — створюються через new.
  • Вкладені типи-значення: якщо тип-значення (struct) є полем посилального типу (class), він зберігається всередині обʼєкта в купі.

Керування памʼяттю в купі здійснюється автоматично за допомогою GC (Garbage Collector).

Приклад: Обʼєкти класів у купі

class Person { public string Name; public int Age; }

void CreatePerson()
{
    Person p = new Person(); // Об'єкт Person — у купі. 'p' (посилання) — у стеці.
    p.Name = "Alice";        // Рядок "Alice" (також об'єкт) — у купі.
    p.Age = 30;              // 30 (int, значущий тип) — всередині об'єкта Person у купі.
    // ...
} // Коли CreatePerson завершується, 'p' (посилання) видаляється зі стеку.
  // Об'єкт Person у купі стає недосяжним і підлягає збиранню сміття.

Приклад: Масиви в купі

void ProcessArray()
{
    int[] numbers = new int[5]; // Масив з 5 int-ів — у купі. 'numbers' (посилання) — у стеці.
    numbers[0] = 10;            // Елемент 10 — частина масиву в купі.
    // ...
} // Масив буде очищено GC, коли на нього не залишиться посилань.

2. Збирач сміття (Garbage Collection)

Керування памʼяттю в купі відбувається автоматично завдяки GC. Це одна з ключових переваг .NET, що позбавляє потреби в ручному звільненні памʼяті (часта причина витоків у мовах без збирання сміття).

Призначення та принцип роботи

Головна мета GC — автоматично звільняти памʼять, зайняту обʼєктами в купі, коли їх більше не використовує програма.

  1. Створення обʼєкта: під час new CLR виділяє місце в купі.
  2. Відстеження посилань: GC відстежує активні посилання; обʼєкт «досяжний», якщо на нього є посилання зі стеку, статичного поля або іншого досяжного обʼєкта.
  3. Визначення «сміття»: якщо посилань немає — обʼєкт «недосяжний» і вважається сміттям.
  4. Збирання: за потреби GC позначає досяжні обʼєкти, а памʼять недосяжних звільняє.
  5. Компактизація: щоб зменшити фрагментацію, решта обʼєктів можуть бути переміщені, утворюючи суцільні області.

Покоління в GC (Generational GC)

GC використовує покоління, бо більшість обʼєктів «помирають молодими»:

  • Покоління 0 (Gen 0): нові обʼєкти; збирання часті та швидкі.
  • Покоління 1 (Gen 1): ті, що пережили Gen 0; збирання рідші й довші.
  • Покоління 2 (Gen 2): довгоживучі обʼєкти; збирання найрідкісніші й «дорогі».

Коли GC запускається?

  • Недостатньо вільної памʼяті для нового виділення.
  • Регулярні перевірки в періоди простою.
  • Явний (зазвичай не рекомендується) виклик GC.Collect().

Продуктивність GC

Запуск GC може спричиняти короткі паузи, оскільки виконання вашого коду призупиняється. Надмірне створення короткоживучих обʼєктів у купі призводить до частіших збирань і зниження продуктивності.

Приклад: Обʼєкт стає недосяжним

class DataBlock { public byte[] Data; public DataBlock() => Data = new byte[1024 * 1024]; } // 1 МБ даних

void AllocateAndLose()
{
    DataBlock block1 = new DataBlock(); // Об'єкт block1 у купі, посилання 'block1' у стеці

    // ... блок коду ...

    block1 = null; // Тепер на об'єкт DataBlock немає активних посилань. Він став недосяжним.
    // На цьому етапі GC ЩЕ НЕ ВИДАЛИВ його, але він став кандидатом на видалення.

    // Виклик GC.Collect() тут (лише для демонстрації, не роби так у продакшені!)
    Console.WriteLine("Calling GC.Collect()");
    GC.Collect(); // GC може (але не гарантовано) очистити пам'ять зараз
}

AllocateAndLose();
// Коли AllocateAndLose завершується, посилання 'block1' видаляється зі стеку,
// і об'єкт DataBlock у купі стає недосяжним, таким, що підлягає збиранню.

3. Керування некерованими ресурсами

GC керує «керованою» памʼяттю .NET. Але є некеровані ресурси, які перебувають поза контролем GC і потребують явного звільнення:

  • Дескриптори файлів (відкриті файли).
  • Мережеві сокети.
  • Дескриптори вікон/графічних обʼєктів (наприклад, GDI+).
  • Памʼять, виділена операційною системою (наприклад, через P/Invoke).

Фіналізатори (Finalizers)

Деструктор — спеціальний метод для фінального очищення некерованих ресурсів перед видаленням обʼєкта GC. Синтаксис схожий на конструктор, але з тильдою:

class MyClassWithFinalizer
{
    ~MyClassWithFinalizer() { /* Звільнення некерованих ресурсів */ }
}

Викликається GC непередбачувано, із затримкою, у спеціальному потоці фіналізатора. Мінуси: непередбачуваність, накладні витрати (обʼєкти з фіналізатором потребують двох проходів). Тому фіналізатори — лише «страховка», а не основний механізм очищення.

Приклад: Деструктор як «страховка»

class UnmanagedResourceHolder
{
    private bool _resourceReleased = false;

    // Імітація некерованого ресурсу
    public UnmanagedResourceHolder() => Console.WriteLine("Ресурс створено.");
    
    public void ReleaseResource()
    {
        if (!_resourceReleased)
        {
            Console.WriteLine("Ресурс ЗВІЛЬНЕНО явним методом.");
            _resourceReleased = true;
        }
    }

    // Деструктор (Finalizer) — викликається GC як останній захід
    ~UnmanagedResourceHolder()
    {
        Console.WriteLine("Деструктор ВИКЛИКАНО (ресурс НЕ був звільнений явно).");
        ReleaseResource(); // Спроба звільнити ресурс
    }
}

Інтерфейс IDisposable

IDisposable — переважний механізм явного звільнення ресурсів. Містить один метод: void Dispose(). Часто застосовують шаблон, у межах якого Dispose() можна викликати багаторазово, а фіналізатор пригнічується через GC.SuppressFinalize(this).

Приклад: Реалізація IDisposable

using System.IO;

class MyFileWriter : IDisposable
{
    private StreamWriter _writer;
    private bool _disposed = false;

    public MyFileWriter(string path)
    {
        _writer = new StreamWriter(path, true);
        Console.WriteLine($"Файл '{path}' відкрито.");
    }

    public void WriteLog(string message) => _writer.WriteLine(message);

    // Реалізація IDisposable
    public void Dispose()
    {
        Dispose(true); // Викликаємо основну логіку Dispose
        GC.SuppressFinalize(this); // Кажемо GC, що деструктор не потрібен
        _disposed = true;
    }

    // Захищений віртуальний метод для загального патерну Dispose
    protected virtual void Dispose(bool disposing)
    {
        if (!_disposed)
        {
            if (disposing)
            {
                // Звільнити керовані ресурси
                _writer?.Dispose();
            }
            // Звільнити некеровані ресурси (якщо такі є)
            Console.WriteLine("Файл ЗАКРИТО через Dispose.");
        }
    }

    // Опційний деструктор як "страховка"
    ~MyFileWriter()
    {
        Console.WriteLine("Файл ЗАКРИТО через деструктор ( Dispose() не було викликано).");
        Dispose(false); // Викликаємо Dispose, вказуючи, що це не явний виклик
    }
}

Оператор using

Оператор using — синтаксична конструкція, що гарантує виклик Dispose(), коли обʼєкт виходить за межі області видимості, навіть у разі винятків. Це найрекомендованіший спосіб роботи з обʼєктами, що реалізують IDisposable.

Приклад: Автоматичне очищення з using

// Використання MyFileWriter з прикладу вище
void ProcessFile(string fileName)
{
    // Об'єкт MyFileWriter буде автоматично «disposed» після блоку using
    using (var writer = new MyFileWriter(fileName))
    {
        writer.WriteLog("Перший рядок.");
        writer.WriteLog("Другий рядок.");
        // Навіть якщо тут станеться виняток, Dispose() все одно буде викликаний
    } // Тут викликається writer.Dispose() автоматично

    Console.WriteLine("Блок using завершено, файл закритий.");
}

ProcessFile("testlog.txt");
// У цьому випадку деструктор MyFileWriter не буде викликано, оскільки Dispose() викликано явно.

Розуміння цих концепцій — ключ до написання продуктивних, надійних і масштабованих застосунків на C#.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ