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 — автоматично звільняти памʼять, зайняту обʼєктами в купі, коли їх більше не використовує програма.
- Створення обʼєкта: під час new CLR виділяє місце в купі.
- Відстеження посилань: GC відстежує активні посилання; обʼєкт «досяжний», якщо на нього є посилання зі стеку, статичного поля або іншого досяжного обʼєкта.
- Визначення «сміття»: якщо посилань немає — обʼєкт «недосяжний» і вважається сміттям.
- Збирання: за потреби GC позначає досяжні обʼєкти, а памʼять недосяжних звільняє.
- Компактизація: щоб зменшити фрагментацію, решта обʼєктів можуть бути переміщені, утворюючи суцільні області.
Покоління в 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#.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ