Управляемая память не означает «о памяти можно не думать». На middle и senior собесах спрашивают устройство GC, разницу Dispose и финализатора, утечки и способы снизить аллокации.
Как работает GC
Сборщик мусора трассирующий: от корней (локальные переменные в стеках, статические поля, закреплённые дескрипторы) он помечает всё достижимое, остальное мусор. Циклические ссылки не мешают: недостижимый цикл соберётся.
После пометки живые объекты сдвигаются (уплотнение), ссылки обновляются. Поэтому выделение памяти в .NET дешёвое: это сдвиг указателя.
Поколения
- Gen 0: новые объекты, сборка частая и быстрая.
- Gen 1: буфер между молодыми и старыми.
- Gen 2: долгоживущие объекты, полная сборка редкая и дорогая.
Гипотеза поколений: большинство объектов умирает молодыми. Объект, переживший сборку, продвигается в следующее поколение.
LOH: объекты от 85 000 байт. Собирается вместе с Gen 2 и по умолчанию не уплотняется, отсюда фрагментация. POH (.NET 5): куча для закреплённых объектов.
Режимы: Workstation и Server GC (в ASP.NET Core по умолчанию Server: куча и поток сборки на ядро). Фоновая сборка Gen 2 работает параллельно с приложением.
IDisposable
GC освобождает память, но не знает, когда закрыть файл, сокет или соединение. Для этого IDisposable и using:
await using var conn = new NpgsqlConnection(cs);
await conn.OpenAsync();
Реализуйте IDisposable, если класс владеет неуправляемым ресурсом или другими IDisposable. Шаблон:
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (_disposed) return;
if (disposing) _stream.Dispose(); // управляемые ресурсы
// неуправляемые ресурсы освобождаются в обоих случаях
_disposed = true;
}
Финализатор
~MyClass() вызывается потоком финализации в неизвестный момент. Объект с финализатором переживает лишнюю сборку: сначала попадает в очередь, потом освобождается. Финализатор нужен только как страховка для неуправляемых ресурсов, и лучше обернуть ресурс в SafeHandle. GC.SuppressFinalize в Dispose снимает объект с финализации.
Утечки в .NET
GC не соберёт то, что достижимо от корня:
- статические коллекции-кеши без вытеснения;
- подписки на события долгоживущих объектов;
- таймеры, которые держат колбэк;
- захват больших объектов в замыканиях;
- неосвобождённые неуправляемые ресурсы.
Диагностика: dotnet-counters (размер поколений, частота сборок), dotnet-gcdump и dotnet-dump (кто держит объекты).
Span<T> и Memory<T>
Span<T> это окно на непрерывную память: массив, строку, стек или неуправляемый буфер. Срезы без копирования, но это ref struct: нельзя в поле класса, в лямбду, через await.
Span<byte> buffer = stackalloc byte[256]; // маленький буфер на стеке
ReadOnlySpan<char> part = text.AsSpan(5, 3);
int n = int.Parse(part);
Memory<T> можно хранить и передавать в async-методы, а .Span брать по месту.
Пулы
ArrayPool<T>.Shared.Rent(size) возвращает массив не меньше нужного, обязательно Return, данные внутри не очищены. ObjectPool<T> для дорогих объектов вроде StringBuilder.
Как снижать аллокации
- Измерить: BenchmarkDotNet с
MemoryDiagnoser, профилировщик. - Убрать лишние строки: span-парсинг,
StringBuilder, ёмкость заранее. - Убрать упаковку и замыкания в горячих циклах.
- Большие временные буферы брать из пула, чтобы не трогать LOH.
- Маленькие неизменяемые значения делать
readonly struct.
Частые ошибки
- Вызывать
GC.Collect()«для надёжности». - Писать финализатор без неуправляемых ресурсов.
- Забывать
DisposeуHttpResponseMessage, потоков,CancellationTokenSource. - Считать, что циклические ссылки приводят к утечке.
Что спросят на собесе
- Как работает GC и зачем поколения?
- Что такое LOH и чем она опасна?
- Чем
Disposeотличается от финализатора, зачемSuppressFinalize? - Какие бывают утечки памяти в .NET и как их искать?
- Что такое
Span<T>и почему его нельзя хранить в поле класса?