Да. И чтобы понять почему, необходимо отказаться от одного из самых распространенных мифов в разработке программного обеспечения.
Миф: сборка мусора предотвращает утечки памяти. Реальность: сборка мусора предотвращает один конкретный тип проблем с памятью — висячую ссылку, когда программа теряет все указатели на блок памяти и не имеет способа его освободить. Чего сборка мусора не предотвращает и не может предотвратить, так это противоположную проблему: программа сохраняет ссылку на объект, который ей больше не нужен. Сборщик мусора видит активную ссылку и корректно оставляет объект в покое. Разработчик намеревался прекратить использование этого объекта. Память увеличивается. Эффект идентичен традиционной утечке: неограниченный рост памяти, в конечном итоге ошибки нехватки памяти (OOM), перезапуск служб, но механизм другой.
Это различие важно, потому что оно меняет место поиска проблемы и способ её решения. В C и C++ утечка памяти означает отсутствие памяти. free() or deleteВ Java, Python, C#, Go или JavaScript это означает ссылку, которая должна была быть очищена, но не была. Сборщик мусора не работает неправильно. Он корректно обрабатывает ссылку, которую программа не должна была сохранять.
Обнаружение утечек на всех языках
SMART TS XL отображает долгоживущие цепочки ссылок и неограниченные структуры данных по всей вашей кодовой базе.
ПодробнееЧем на самом деле занимаются сборщики мусора
Задача сборщика мусора — идентифицировать и освободить память, недоступную из корневых каталогов программы, глобальных переменных, переменных стека и локального состояния потока. Любой объект, доступный из корневого каталога, считается активным. Любой недоступный объект может быть собран сборщиком мусора.
Три доминирующие стратегии GC по-разному определяют достижимость:
Разметка и подметание (используется в Java HotSpot, Go, C#/.NET) начинается с корневого множества и обходит все доступные ссылки на объекты. Объекты, не достигнутые во время обхода, удаляются. Это корректно обрабатывает циклические ссылки: два объекта, указывающие друг на друга, но недоступные извне, оба удаляются. Что он не может обработать: доступный из корневого множества контейнер (статический объект). HashMapуровень класса List), который растет без ограничений, поскольку элементы добавляются и никогда не удаляются.
Подсчет ссылок Основной механизм CPython отслеживает количество ссылок на каждый объект. Когда счетчик достигает нуля, объект немедленно освобождается. Классический пример ошибки: циклические ссылки, где A ссылается на B, а B ссылается на A. Оба объекта имеют счетчик ссылок не менее 1 и никогда не освобождаются. CPython решает эту проблему с помощью дополнительного сборщика циклов, но у этого сборщика есть граничные случаи, связанные с объектами, имеющими циклические ссылки. __del__ методы.
Генерационная сборка мусора (используемая большинством современных сред выполнения в сочетании с вышеописанным) делит объекты на молодые и старые поколения в зависимости от того, как долго они существуют. Короткоживущие объекты собираются часто и с низкими затратами. Долгоживущие объекты собираются редко. Следствием этого являются утечки памяти: объекты, которые должны были стать короткоживущими, но случайно сохраняются, перемещаются в старое поколение и собираются все реже и реже, что затрудняет обнаружение и диагностику утечки.
Ни один из этих механизмов не проверяет, следует ли программе по-прежнему использовать объект, а лишь проверяет, может ли она до него добраться.
Пять механизмов, вызывающих утечки памяти в языках программирования с компиляцией мусора.
1. Статические и классовые коллекции
Статическое поле в Java, переменная класса в Python, статическое свойство в C# — все они существуют на протяжении всего процесса работы приложения. Любая коллекция, хранящаяся в статическом поле, накапливает объекты на протяжении всего времени работы программы, если только она не будет явно очищена.
Ява
// Java: static cache that grows without bound
public class SessionManager {
// Lives for the lifetime of the JVM process
private static final Map<String, UserSession> sessions = new HashMap<>();
public void createSession(String userId) {
sessions.put(userId, new UserSession(userId));
// Session is added but never removed when it expires
}
// Missing: cleanup when session expires or user logs out
}
sessions Карта доступна из загрузчика классов, который, в свою очередь, доступен из корневого каталога. Каждый UserSession Создаваемое постоянно значение накапливается. В длительно работающем сервисе это приводит к истощению памяти в течение нескольких часов или дней. Для решения проблемы требуется либо активное удаление (вызов службы). sessions.remove(userId) при выходе из системы или истечении срока действия) или при переключении на структуру с автоматическим вытеснением, например, кэш с ограниченным временем действия.
2. Обработчики событий и обратные вызовы
Регистрация наблюдателя, слушателя или функции обратного вызова создает ссылку от источника событий к слушателю. Если слушатель никогда не отменяется, источник событий поддерживает его активность до тех пор, пока существует сам источник событий, что может быть намного дольше, чем предполагалось.
питон
# Python: listener retained by long-lived event source
class DataSource:
def __init__(self):
self._listeners = []
def add_listener(self, listener):
self._listeners.append(listener)
# Missing: remove_listener method
class DataProcessor:
def __init__(self, source: DataSource):
self.source = source
source.add_listener(self._on_data) # strong reference held in source
def _on_data(self, data):
pass
# Each DataProcessor created is never collected because
# DataSource._listeners holds a reference to its bound method
source = DataSource()
for _ in range(10000):
processor = DataProcessor(source)
# processor goes out of scope here
# but source._listeners still holds 10,000 bound method references
3. Потоковые локальные переменные в пулах потоков
Локальное хранилище данных ограничено конкретным потоком, а не какой-либо конкретной задачей. В приложениях, использующих пулы потоков, где потоки используются повторно во многих запросах, локальные переменные, установленные во время одного запроса, остаются установленными для каждого последующего запроса, обрабатываемого тем же потоком.
Ява
// Java: ThreadLocal leak in a thread pool
public class RequestContextHolder {
private static final ThreadLocal<RequestContext> context = new ThreadLocal<>();
public static void setContext(RequestContext ctx) {
context.set(ctx);
}
public static RequestContext getContext() {
return context.get();
}
// Missing: clearContext() method called after each request
}
// In a servlet or request handler:
public void handleRequest(HttpRequest req) {
RequestContextHolder.setContext(new RequestContext(req));
// ... process request
// Missing: RequestContextHolder.clearContext();
// The RequestContext stays associated with this thread indefinitely
}
В пуле из 50 потоков, обрабатывающих миллионы запросов, каждый поток в конечном итоге занимает определенное место. RequestContext Данные последнего запроса, сведения о подключении, учетные данные пользователя, параметры запроса — все это никогда не собирается, поскольку сами потоки данных никогда не собираются.
4. Замыкания, позволяющие захватывать графы больших объектов
Замыкания захватывают ссылки на переменные в пределах своей области видимости. В JavaScript и Python, в частности, небольшой коллбэк или обработчик может непреднамеренно захватить большой объект, целое дерево DOM, объект запроса, большой набор данных, препятствуя его сборке мусора спустя долгое время после того, как это должно произойти.
Javascript
// JavaScript: closure capturing large object in event listener
function setupHandler() {
const largeDataset = fetchLargeDataset(); // 50MB of data
document.getElementById('btn').addEventListener('click', function() {
// Only uses one field, but captures entire largeDataset in closure
console.log(largeDataset.summary);
});
// largeDataset cannot be collected as long as the button exists
// because the event listener closure holds a reference to it
}
// Fix: capture only what you need
function setupHandlerFixed() {
const largeDataset = fetchLargeDataset();
const summary = largeDataset.summary; // extract only needed data
document.getElementById('btn').addEventListener('click', function() {
console.log(summary); // closure captures only the string
});
// largeDataset is now eligible for collection
}
5. Неограниченное количество тайников без выселения.
Кэши являются одним из наиболее распространенных источников утечек памяти в производственных сервисах. Кэш, который разрастается без политики вытеснения, накапливает объекты, которые невозможно удалить, поскольку сам кэш всегда доступен.
острый
// C#: unbounded dictionary used as cache
public class PriceService {
// Growing forever -- no eviction, no size limit
private static Dictionary<string, decimal> _priceCache = new();
public decimal GetPrice(string productId) {
if (!_priceCache.TryGetValue(productId, out var price)) {
price = FetchPriceFromDatabase(productId);
_priceCache[productId] = price;
}
return price;
}
// After processing 1 million unique product IDs, the cache
// holds 1 million entries and grows with every new product
}
// Fix: use a cache with eviction policy
using Microsoft.Extensions.Caching.Memory;
public class PriceService {
private readonly IMemoryCache _cache;
public PriceService(IMemoryCache cache) {
_cache = cache;
}
public decimal GetPrice(string productId) {
return _cache.GetOrCreate(productId, entry => {
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(10);
entry.SizeLimit = 1; // bounded by cache size configuration
return FetchPriceFromDatabase(productId);
});
}
}
Утечки памяти в зависимости от языка
Java
Наиболее распространённые шаблоны утечек памяти в Java, помимо статических коллекций, включают в себя: finalize() методы и внутренние классы. Нестатические внутренние классы содержат неявную ссылку на экземпляр внешнего класса, окружающего их. Анонимный класс, определенный внутри Activity или Fragment (в Android), содержит ссылку на эту Activity, предотвращая ее сборку после поворота экрана или навигации.
Java WeakHashMap Решает проблему статических коллекций в случаях, когда ключами являются значимые объекты: когда ключ становится недоступным извне, запись автоматически удаляется. Для значений, которые должны быть освобождены, когда на них больше не ссылаются, WeakReference и SoftReference оборачивать объекты, не создавая сильных ссылок, которые препятствуют сборке мусора.
Инструменты обнаружения: Java VisualVM, Eclipse Memory Analyzer (MAT), JProfiler, YourKit и HeapHero для анализа дампов памяти. Ищите byte[], char[] и Object[] Рост гистограмм кучи, как правило, поддерживает структуры данных в расширяющихся коллекциях.
Питон
Сборщик мусора Python имеет два уровня: основной (подсчет ссылок) и дополнительный (циклический сборщик). Уровень подсчета ссылок немедленно освобождает объекты, когда счетчик достигает нуля, что быстро и предсказуемо. Циклический сборщик обрабатывает циклические ссылки, но работает реже и имеет исключения.
питон
# Python circular reference with __del__ -- historically problematic
class Node:
def __init__(self):
self.child = None
def __del__(self):
print("Node deleted")
a = Node()
b = Node()
a.child = b
b.child = a # circular reference
del a, b # neither freed in Python 2; freed by cycle collector in Python 3.4+
В Python 3.4 и более поздних версиях большинство циклических ссылок обрабатываются корректно. Оставшиеся схемы утечек памяти: объекты, хранящиеся в глобальных переменных на уровне модуля, увеличиваются в размерах. defaultdict or dict структуры, используемые в качестве реестров, и память в модулях расширения C, которая не доступна сборщику мусора Python.
Обнаружение: tracemalloc (стандартная библиотека), memory_profiler, objgraph, objgraph.show_most_common_types() и objgraph.show_growth() Функции определяют, какие типы накапливаются.
C# и .NET
Утечки памяти в C# чаще всего происходят из-за: неотписанных обработчиков событий, статических источников событий, содержащих ссылки на экземпляры подписчиков, и неудаленных объектов IDisposable.
острый
// C#: event handler leak
public class Publisher {
public static event EventHandler DataChanged; // static event
}
public class Subscriber {
public Subscriber() {
Publisher.DataChanged += OnDataChanged; // strong reference
}
private void OnDataChanged(object sender, EventArgs e) { }
// Missing: Dispose() calling Publisher.DataChanged -= OnDataChanged
}
Subscriber никогда не собирается до тех пор, пока Publisher существует (что, будучи статическим, означает, что событие существует вечно), потому что статическое событие содержит делегат, ссылающийся на экземпляр подписчика. Решение заключается в реализации IDisposable и отписаться в Dispose()или используя WeakEventManager.
Обнаружение: .NET Memory Profiler, dotMemory, Visual Studio Diagnostic Tools и WinDbg с расширением SOS для дампов кучи в производственной среде.
Go
В Go используется сборщик мусора с алгоритмом пометки и очистки памяти (mark-and-sweep GC) с очень низкой паузой остановки выполнения программы. Несмотря на это, в программах на Go возникают утечки памяти из-за утечек горутин — горутин, которые запускаются и никогда не завершаются.
go
// Go: goroutine leak -- channel never closed, goroutine blocked forever
func processItems(items []Item) {
results := make(chan Result)
go func() {
for _, item := range items {
results <- process(item)
}
// Missing: close(results)
// This goroutine blocks on the send if no one reads after items run out
}()
for result := range results {
handleResult(result)
}
// If the goroutine blocks and results is never closed,
// the loop here also blocks -- both goroutines leak
}
Каждая утечка горутины содержит её стек (изначально 2 КБ, увеличивающийся по мере необходимости) и любые объекты кучи, на которые она ссылается. Сервис, который допускает утечку одной горутины за запрос, со временем накапливает тысячи таких объектов.
Обнаружение: runtime.NumGoroutine() метрика, pprof Конечная точка профиля горутины (/debug/pprof/goroutine), а также goleak для тестирования. Профиль pprof показывает трассировку стека каждой горутины, что позволяет легко определить, какие функции блокируются на неопределенное время.
JavaScript и Node.js
В Node.js наиболее распространенные утечки памяти связаны с глобальными переменными, накоплением обработчиков событий без их удаления, а также некорректным закрытием буферов/потоков.
Javascript
// Node.js: EventEmitter listener leak
const EventEmitter = require('events');
const emitter = new EventEmitter();
function createHandler() {
emitter.on('data', (data) => {
// This listener is never removed
// Each call to createHandler adds another listener
console.log(data);
});
}
// Called once per request in a server:
// After 11 calls, Node prints MaxListenersExceededWarning
// After thousands of calls, thousands of listeners accumulate
// Fix: use emitter.once() for one-time listeners
// or explicitly remove with emitter.off()
Обнаружение: снимки кучи в инструментах разработчика Chrome (для JavaScript браузера). node --inspect с помощью Chrome DevTools (для Node.js), clinic.js для профилирования Node.js в производственной среде, и heapdump npm-пакет для создания и сравнения снимков состояния кучи за разные периоды времени.
Модели профилактики в различных языках GC
Вместо того чтобы устранять утечки после того, как они проявляются в рабочей среде, эти структурные шаблоны предотвращают наиболее распространенные причины:
Слабые ссылки Позволяет ссылаться на объект, не препятствуя его сборке мусора. Когда сборщику мусора требуется память, слабоссылочные объекты могут быть собраны, даже если на них уже существуют ссылки. WeakReference<T> на Java и C#, weakref.ref() на языке Python и WeakRef В JavaScript для кэширования и регистрации наблюдателей, где ссылающийся объект не должен продлевать время жизни ссылаемого объекта.
Ограниченные по размеру кэши с вытеснением заменить необработанный HashMap or dict с использованием специально разработанных реализаций кэширования: Guava CacheBuilder на Яве, functools.lru_cache or cachetools на языке Python, IMemoryCache с ограничениями по размеру в C#, и node-lru-cache в Node.js.
Явное управление жизненным циклом гарантирует, что объекты с зарегистрированными слушателями, открытыми ресурсами или локальным для потока состоянием реализуют close(), dispose()или эквивалентный метод, выполняющий очистку, который вызывающие стороны всегда вызывают через try-with-resources на Яве, with операторы в Python, using объявления в C#, и defer в Го.
Отмена горутины в Go используется context.Context с отменой, чтобы гарантировать, что у каждой горутины есть путь к завершению. Горутины, которые выбирают на ctx.Done() может быть подан сигнал к выходу, предотвращающий накопление.
Как статический анализ выявляет потенциальные утечки до начала производства.
Профилирование памяти выявляет утечки после того, как они проявляются. Статический анализ обнаруживает структурные закономерности, вызывающие их, до выполнения кода.
SMART TS XLАвтора статический анализ кода В исследовании анализируются структурные характеристики кодовых баз на языках Java, Python, C#, JavaScript и других, выявляются закономерности, связанные с непреднамеренным удержанием: статические коллекции без документированного удаления, регистрация обработчиков событий без соответствующей отмены регистрации, использование ThreadLocal без очистки и выделение ресурсов без соответствующего освобождения.
Возможность отображения зависимостей приложения выявляет длинные цепочки ссылок, которые обычно возникают при утечках памяти, связанных со сборкой мусора, — объекты, доступные из корневых каталогов программ через несколько уровней косвенного доступа, где путь к корню проходит через долгоживущий контейнер, предназначенный для временного хранения объектов.
Для организаций, управляющих большими устаревшими кодовыми базами, где модели управления памятью были установлены много лет или десятилетий назад, SMART TS XLАвтора анализ воздействия Эта возможность позволяет проводить целенаправленное и систематическое устранение неполадок: выявлять каждое место, где проявляется конкретный антипаттерн (неограниченный статический кэш, неудаленный слушатель), перечислять затронутые компоненты и планировать устранение неполадок в порядке возрастания риска, а не обнаруживать отдельные инциденты в производственной среде.