Las fugas de memoria ocurren en lenguajes con recolección de basura.

¿Pueden producirse fugas de memoria en lenguajes con recolección de basura?

Sí. Y para entender el porqué, es necesario desaprender uno de los mitos más persistentes en el desarrollo de software.

El mito: la recolección de basura previene las fugas de memoria. La realidad: la recolección de basura previene un tipo específico de problema de memoria, la referencia colgante, donde un programa pierde todos los punteros a un bloque de memoria y no tiene forma de liberarlo. Lo que la recolección de basura no previene ni puede prevenir es el problema opuesto: un programa que mantiene una referencia a un objeto que ya no necesita. El recolector de basura ve una referencia activa y correctamente deja el objeto intacto. El desarrollador tenía la intención de dejar de usar ese objeto. La memoria aumenta. El efecto es idéntico al de una fuga tradicional: crecimiento ilimitado de la memoria, errores de memoria insuficiente (OOM) y reinicios del servicio, pero el mecanismo es diferente.

Esta distinción importa porque cambia dónde se busca el problema y cómo se soluciona. En C y C++, una fuga de memoria significa una memoria faltante. free() or deleteEn Java, Python, C#, Go o JavaScript, significa una referencia que debería haberse liberado pero no lo fue. El recolector de basura no está funcionando mal. Está respetando correctamente una referencia que el programa no debería haber conservado.

Detección de fugas en todos los idiomas

SMART TS XL Mapea cadenas de referencias de larga duración y estructuras de datos ilimitadas en todo tu código fuente.

MÁS INFORMACIÓN

¿Qué hacen realmente los recolectores de basura?

La función del recolector de basura es identificar y recuperar la memoria que ya no es accesible desde las raíces del programa, las variables globales, las variables de la pila y el estado local de los hilos. Cualquier objeto accesible desde una raíz se considera activo. Cualquier objeto inaccesible es susceptible de ser recolector.

Las tres estrategias dominantes de GC definen la accesibilidad de manera diferente:

Marcar y barrer (utilizado por Java's HotSpot, Go, C#/.NET) comienza desde el conjunto raíz y recorre todas las referencias de objetos alcanzables. Los objetos no alcanzados durante el recorrido se descartan. Esto maneja correctamente las referencias circulares, dos objetos que apuntan entre sí pero que no son alcanzables desde ningún otro lugar se recopilan. Lo que no puede manejar: un contenedor alcanzable desde la raíz (un estático HashMap, un nivel de clase List) que crece sin límite porque se añaden entradas y nunca se eliminan.

Recuento de referencias (El mecanismo principal de CPython) rastrea cuántas referencias apuntan a cada objeto. Cuando el contador llega a cero, el objeto se libera inmediatamente. El modo de fallo clásico: referencias circulares donde A hace referencia a B y B hace referencia a A. Ambos tienen un contador de referencias de al menos 1 y nunca se liberan. CPython aborda esto con un recolector de ciclos suplementario, pero el recolector de ciclos tiene casos límite que involucran objetos con __del__ métodos.

El recolector de basura generacional (utilizado por la mayoría de los entornos de ejecución modernos en combinación con lo anterior) divide los objetos en generaciones jóvenes y antiguas según su tiempo de vida. Los objetos de corta duración se recolectan con frecuencia y a bajo costo. Los objetos de larga duración se recolectan raramente. La consecuencia para las fugas de memoria es que los objetos que deberían haber pasado a ser de corta duración, pero que se retienen accidentalmente, pasan a la generación antigua y se recolectan cada vez con menos frecuencia, lo que dificulta la observación y el diagnóstico de la fuga.

Ninguno de estos mecanismos comprueba si el programa debería seguir utilizando un objeto, solo si puede acceder a él.

Los cinco mecanismos que provocan fugas de memoria en lenguajes con recolector de basura

1. Colecciones estáticas y a nivel de clase

Un campo estático en Java, una variable de clase en Python, una propiedad estática en C#, existen durante toda la ejecución de la aplicación. Cualquier colección almacenada en un campo estático acumula objetos durante toda la vida útil del programa, a menos que se borre explícitamente.

Java

// 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
}

El sessions El mapa es accesible desde el cargador de clases, que es accesible desde la raíz. Cada UserSession Cada vez que se crea, se acumula. En un servicio de larga duración, esto agota la pila en horas o días. La solución requiere una eliminación activa (llamar sessions.remove(userId) al cerrar sesión o al expirar) o cambiando a una estructura con desalojo automático, como una caché con límite de tiempo.

2. Detectores de eventos y devoluciones de llamada

Al registrar un observador, un oyente o una función de devolución de llamada, se crea una referencia desde la fuente del evento al oyente. Si el oyente nunca se anula su registro, la fuente del evento lo mantiene activo mientras exista, lo cual puede ser mucho más tiempo del previsto.

pitón

# 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. Variables locales de subprocesos en grupos de subprocesos

El almacenamiento local de subprocesos está limitado a un subproceso, no a una tarea específica. En aplicaciones que utilizan grupos de subprocesos, donde estos se reutilizan en múltiples solicitudes, las variables locales de subprocesos definidas durante una solicitud permanecen definidas para todas las solicitudes posteriores gestionadas por el mismo subproceso.

Java

// 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
}

En un grupo de subprocesos de 50 subprocesos que manejan millones de solicitudes, cada subproceso eventualmente tiene un RequestContext Se hace referencia a los datos de la última solicitud, los detalles de conexión, las credenciales del usuario y los parámetros de la solicitud. Estos datos nunca se recopilan porque los propios hilos de ejecución nunca se recopilan.

4. Cierres que capturan grafos de objetos grandes

Los cierres capturan referencias a variables dentro de su ámbito contenedor. En JavaScript y Python, en particular, una pequeña función de devolución de llamada o un manejador pueden capturar inadvertidamente un objeto grande, un árbol DOM completo, un objeto de solicitud o un conjunto de datos extenso, impidiendo que se eliminen mucho después de que deberían.

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. Almacenamiento en caché ilimitado sin desalojo

Las cachés son una de las fuentes más comunes de fugas de memoria en los servicios de producción. Una caché que crece sin una política de desalojo es un acumulador de objetos que no se pueden eliminar porque la propia caché siempre está accesible.

CSharp

// 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);
        });
    }
}

Fugas de memoria por idioma

Java

Los patrones de fugas más comunes de Java, más allá de las colecciones estáticas, involucran: finalize() Métodos y clases internas. Las clases internas no estáticas mantienen una referencia implícita a la instancia de la clase externa que las contiene. Una clase anónima definida dentro de una Activity o Fragment (en Android) mantiene una referencia a esa Activity, lo que impide que se elimine después de la rotación o la navegación.

De Java WeakHashMap resuelve el problema de la colección estática para los casos en que las claves son los objetos significativos: cuando la clave se vuelve inaccesible desde otro lugar, la entrada se elimina automáticamente. Para los valores que deben liberarse cuando ya no se hace referencia a ellos, WeakReference y SoftReference envolver objetos sin crear referencias fuertes que impidan su recolección.

Herramientas de detección: Java VisualVM, Eclipse Memory Analyzer (MAT), JProfiler, YourKit y HeapHero para analizar volcados de memoria. Busque byte[], char[], y Object[] crecimiento en los histogramas de montón, que normalmente respaldan las estructuras de datos en colecciones en expansión.

Python

El recolector de basura de Python tiene dos capas: conteo de referencias (principal) y un recolector de ciclos (complementario). La capa de conteo de referencias libera los objetos inmediatamente cuando el contador llega a cero, lo que resulta rápido y predecible. El recolector de ciclos maneja las referencias circulares, pero se ejecuta con menos frecuencia y puede generar excepciones.

pitón

# 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+ maneja correctamente la mayoría de las referencias circulares. Los patrones de fuga restantes son: objetos almacenados en variables globales a nivel de módulo, crecientes defaultdict or dict estructuras utilizadas como registros y memoria en módulos de extensión de C que no está expuesta al recolector de basura de Python.

Detección: tracemalloc (biblioteca estándar), memory_profiler, objgraph. La función de objgraph.show_most_common_types() y objgraph.show_growth() Las funciones identifican qué tipos se están acumulando.

C# y .NET

Las fugas de memoria en C# suelen producirse a través de: controladores de eventos que no se dan de baja, fuentes de eventos estáticas que mantienen referencias a instancias de suscriptores y objetos IDisposable que no se liberan.

CSharp

// 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
}

El Subscriber nunca se recoge mientras Publisher existe (que, al ser estático, es para siempre), porque el evento estático contiene un delegado que hace referencia a la instancia del suscriptor. La solución es implementar IDisposable y cancelando la suscripción en Dispose()o usando WeakEventManager.

Detección: .NET Memory Profiler, dotMemory, Visual Studio Diagnostic Tools y WinDbg con extensión SOS para volcados de memoria en producción.

Go

Go utiliza un recolector de basura de marcado y barrido con una pausa de detención del mundo muy baja. A pesar de esto, los programas Go desarrollan fugas de memoria a través de fugas de goroutines, goroutines que se inician pero nunca terminan.

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
}

Cada goroutine filtrada contiene su pila (inicialmente de 2 KB, que crece según sea necesario) y cualquier objeto del montón al que haga referencia. Un servicio que filtra una goroutine por solicitud acumula miles con el tiempo.

Detección: runtime.NumGoroutine() métrico, pprof Punto final del perfil de goroutine (/debug/pprof/goroutine), y goleak para realizar pruebas. El perfil pprof muestra el rastro de pila de cada goroutine, lo que facilita la identificación de las funciones que se bloquean indefinidamente.

JavaScript y Node.js

En Node.js, los patrones de fugas más comunes involucran variables globales, oyentes de emisores de eventos que se acumulan sin ser eliminados y búferes/flujos que no se cierran correctamente.

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()

Detección: instantáneas del montón de Chrome DevTools (para JavaScript del navegador), node --inspect con Chrome DevTools (para Node.js), clinic.js para la creación de perfiles de Node.js en producción y heapdump Paquete npm para capturar y comparar instantáneas del montón a lo largo del tiempo.

Patrones de prevención en los distintos idiomas de GC

En lugar de depurar las fugas después de que se manifiestan en producción, estos patrones estructurales previenen las causas más comunes:

Referencias débiles Permitir que se haga referencia a un objeto sin impedir su recolección. Cuando el recolector de basura necesita memoria, los objetos con referencias débiles son elegibles para la recolección incluso si existen referencias a ellos. Usar WeakReference<T> en Java y C#, weakref.ref() en Python y WeakRef En JavaScript, para cachés y registros de observadores, el objeto que hace referencia no debe extender la vida útil del objeto referenciado.

Cachés con límite de tamaño y desalojo reemplazar crudo HashMap or dict con implementaciones de caché diseñadas específicamente: Guava CacheBuilder En Java, functools.lru_cache or cachetools en Python, IMemoryCache con límites de tamaño en C#, y node-lru-cache en Node.js.

Gestión explícita del ciclo de vida garantiza que los objetos con oyentes registrados, recursos abiertos o estado local del hilo implementen un close(), dispose(), o un método equivalente que realice la limpieza, y que los llamantes siempre invoquen, a través de try-with-resources En Java, with declaraciones en Python, using declaraciones en C#, y defer en Ir.

Cancelación de gorutina en Go utiliza context.Context con cancelación para asegurar que cada goroutine tenga una ruta de terminación. Goroutines que seleccionan en ctx.Done() Se puede indicar su salida, evitando así la acumulación.

Cómo el análisis estático detecta posibles fugas antes de la producción.

El análisis del rendimiento de la memoria detecta las fugas de memoria una vez que se manifiestan. El análisis estático encuentra los patrones estructurales que las provocan antes de que se ejecute el código.

SMART TS XL, análisis de código estático Examina las características estructurales de las bases de código en Java, Python, C#, JavaScript y otros lenguajes, identificando patrones asociados con la retención no intencionada: colecciones estáticas sin desalojo documentado, registros de oyentes de eventos sin la correspondiente cancelación de registro, uso de ThreadLocal sin limpieza y asignaciones de recursos sin la correspondiente eliminación.

La capacidad de mapeo de dependencias de la aplicación pone de manifiesto las largas cadenas de referencias que suelen implicar las fugas de memoria en los lenguajes de recolección de basura, objetos a los que se puede acceder desde la raíz del programa a través de varios niveles de indirección, donde la ruta a la raíz pasa por un contenedor de larga duración que estaba destinado a contener objetos temporalmente.

Para las organizaciones que gestionan grandes bases de código heredadas donde los patrones de gestión de memoria se establecieron hace años o décadas, SMART TS XL, análisis de impacto Esta capacidad permite que la remediación sea sistemática y tenga un alcance definido: se identifican todas las ubicaciones donde se produce un antipatrón específico (caché estática ilimitada, oyente no eliminado), se enumeran los componentes afectados y se planifica la remediación en orden de riesgo, en lugar de descubrir instancias incidente por incidente en producción.