⏱️ Lectura: 17 min
Cada vez que creás un objeto en JavaScript, Python, Java o Go, alguien tiene que liberar esa memoria cuando ya no la necesitás. Ese alguien es el garbage collector: un componente del runtime que rastrea qué datos siguen en uso y borra automáticamente los que quedaron huérfanos, sin que el programador escriba un solo free().
📑 En este artículo
- TL;DR
- Qué es el garbage collection y por qué importa
- Cómo funciona por dentro: de conteo de referencias a mark-sweep
- Generaciones, fragmentación y el problema de los ciclos
- Ejemplos prácticos
- Cómo empezar: inspeccionar y afinar el GC en tu runtime
- Casos de uso reales
- Errores comunes y buenas prácticas
- Comparativa con alternativas
- Profundizando
- Preguntas frecuentes
- ¿El garbage collection existe en todos los lenguajes?
- ¿El garbage collector libera memoria al sistema operativo inmediatamente?
- ¿Puedo forzar una recolección manual?
- ¿Por qué mi aplicación en Node.js se congela unos milisegundos de repente?
- ¿El conteo de referencias detecta ciclos por sí solo?
- ¿ARC de Swift u Objective-C es garbage collection?
- Referencias
Entender cómo funciona el garbage collection por dentro —qué algoritmo usa, cuándo se activa y qué compromisos hace entre pausa y rendimiento— cambia la forma en que se depuran fugas de memoria y picos de latencia en producción.
TL;DR
- El garbage collector automatiza la liberación de memoria que en C se hace manualmente con free() y malloc().
- V8 (Chrome y Node.js) usa el recolector generacional Orinoco, que separa objetos jóvenes y viejos en heaps distintos.
- Go usa un recolector concurrente mark-sweep no generacional, activado por defecto cuando el heap crece según GOGC=100.
- Java usa G1GC por defecto desde JDK 9; ZGC (estable desde JDK 15) apunta a pausas sub-milisegundo en heaps de terabytes.
- Python usa conteo de referencias como mecanismo principal, más un recolector generacional adicional para romper ciclos.
- Un ciclo de objetos que se referencian entre sí nunca llega a cero por conteo de referencias puro; hace falta trazado (tracing).
- El algoritmo tricolor (blanco, gris, negro) es la base formal de casi todo recolector de trazado moderno y concurrente.
Qué es el garbage collection y por qué importa
Antes de que existiera la gestión automática de memoria, un programa en C tenía que pedir memoria con malloc() y devolverla con free(). Si el programador olvidaba llamar a free(), la memoria quedaba reservada para siempre: eso es una fuga de memoria (memory leak). Si llamaba a free() dos veces sobre el mismo puntero, o seguía usando un puntero después de liberarlo, el programa corrompía su propia memoria: eso es un use-after-free, una de las categorías de bugs más peligrosas en seguridad informática.
El garbage collection resuelve este problema delegando la decisión al runtime. En vez de que el programador diga explícitamente cuándo liberar un objeto, el recolector determina automáticamente qué objetos ya no son alcanzables desde el programa y los libera. La idea nació en 1959 con Lisp, de la mano de John McCarthy, y hoy es el mecanismo por defecto en JavaScript, Java, Python, Go, C#, Ruby y la gran mayoría de lenguajes de propósito general. Los que no lo tienen —C, C++, Rust— lo evitan a propósito: Rust, por ejemplo, resuelve el mismo problema en tiempo de compilación con un borrow checker, sin pagar el costo de un recolector en tiempo de ejecución.
La importancia práctica es doble. Por un lado, elimina categorías enteras de bugs (use-after-free, double-free, dangling pointers). Por otro, introduce un nuevo tipo de costo: el recolector necesita tiempo de CPU y, en algunos diseños, pausas donde el programa se detiene por completo. Ese costo es el que hay que entender para diagnosticar por qué un servicio Java tiene una latencia de cola (p99) alta, o por qué una pestaña de Chrome se congela un instante.
Cómo funciona por dentro: de conteo de referencias a mark-sweep
Hay dos familias principales de algoritmos. La primera es conteo de referencias (reference counting): cada objeto lleva un contador de cuántas variables o estructuras lo referencian. Cuando ese contador llega a cero, el objeto se libera inmediatamente. CPython (el intérprete de referencia de Python) usa este mecanismo como base: podés verlo con sys.getrefcount(obj). Es simple y predecible, pero tiene un defecto estructural: no puede liberar ciclos de referencias, es decir, dos o más objetos que se apuntan entre sí y nunca llegan a cero aunque nada externo los referencie.
La segunda familia es el trazado (tracing), y su algoritmo clásico es mark-sweep. Funciona en dos fases: primero, el recolector parte de un conjunto de raíces (variables globales, el stack de cada hilo, registros de CPU) y marca como “vivo” todo objeto alcanzable siguiendo referencias desde esas raíces. Después, en la fase de barrido (sweep), recorre todo el heap y libera cualquier objeto que no quedó marcado. A diferencia del conteo de referencias, mark-sweep sí detecta ciclos: si dos objetos se apuntan mutuamente pero ninguna raíz los alcanza, ninguno se marca y ambos se liberan.
flowchart TD
A["Raices: stack y variables globales"] --> B["Marcar objetos alcanzables"]
B --> C{"Objeto ya visitado?"}
C -->|"No"| D["Marcar como vivo y visitar sus referencias"]
D --> C
C -->|"Si, todo visitado"| E["Barrer el heap completo"]
E --> F["Liberar objetos no marcados"]
El costo de mark-sweep es proporcional al tamaño total del heap, no solo a la basura generada: por eso, cuanto más grande el heap, más caro resulta cada ciclo completo. Ese costo es exactamente lo que la generación y la concurrencia intentan reducir.
Generaciones, fragmentación y el problema de los ciclos
La observación empírica que sostiene a casi todos los recolectores modernos se llama hipótesis generacional débil (weak generational hypothesis): la mayoría de los objetos mueren jóvenes. Una variable temporal dentro de una función, un objeto de request HTTP, un nodo intermedio de un cálculo: casi todos dejan de ser alcanzables casi inmediatamente después de crearse. Solo una fracción pequeña sobrevive lo suficiente como para volverse “vieja”.
Por eso los recolectores generacionales dividen el heap en al menos dos zonas: una generación joven (a veces llamada Eden o nursery) donde se crean todos los objetos nuevos, y una generación vieja donde se promueven los que sobreviven varias recolecciones seguidas. La generación joven se recolecta con muchísima más frecuencia porque es pequeña y casi todo en ella es basura; la generación vieja se recolecta rara vez porque casi todo en ella sigue vivo. V8 llama a este proceso Scavenge para la generación joven y Mark-Compact para la vieja.
flowchart TD
A["Objeto nuevo"] --> B["Generacion joven (Eden)"]
B --> C{"Sobrevive a la recoleccion?"}
C -->|"No"| D["Liberado"]
C -->|"Si"| E["Generacion joven (Survivor)"]
E --> F{"Sobrevive varias veces?"}
F -->|"Si"| G["Promovido a generacion vieja"]
F -->|"No"| E
subgraph Heap
B
E
G
end
Un problema adicional que resuelve el mark-compact es la fragmentación: después de muchas rondas de liberar objetos dispersos, el heap queda con huecos pequeños entre objetos vivos, y puede fallar una asignación grande aunque haya memoria libre en total. Los recolectores compactantes mueven los objetos vivos para dejarlos contiguos, cerrando esos huecos; el costo es que hay que actualizar todos los punteros que apuntaban a la posición vieja.
📌 Nota: el conteo de referencias de Python no detecta ciclos por sí solo. Por eso CPython agrega un recolector generacional adicional (módulo
gc) que corre periódicamente y sí puede romper ciclos, usando un algoritmo de trazado similar a mark-sweep.
Ejemplos prácticos
Empecemos con el caso más simple: un objeto que se vuelve basura apenas termina una función, en JavaScript.
function crearUsuario(nombre) {
const usuario = { nombre, creadoEn: new Date() };
return usuario.nombre;
}
crearUsuario('Ana');
// El objeto { nombre: 'Ana', creadoEn: ... } queda sin referencias
// apenas termina la funcion: es candidato a Scavenge en la proxima pasada.
Ese objeto nunca sale de la función con una referencia externa, así que apenas termina la ejecución queda aislado en la generación joven, esperando la próxima pasada de Scavenge.
El segundo ejemplo muestra el conteo de referencias de Python y su límite con ciclos:
import sys, gc
class Nodo:
def __init__(self, nombre):
self.nombre = nombre
self.siguiente = None
a = Nodo('a')
b = Nodo('b')
a.siguiente = b
b.siguiente = a # ciclo: a referencia a b, b referencia a a
print(sys.getrefcount(a)) # mayor a 1 por el propio ciclo
del a, b
# ninguno llega a refcount 0: quedan huerfanos hasta que
# el recolector generacional (gc.collect()) los detecte y libere.
print(gc.collect()) # numero de objetos inalcanzables liberados
Aquí a y b se referencian mutuamente, así que borrar las variables locales no baja su contador a cero: hace falta el recolector generacional de Python (el módulo gc) para detectar el ciclo y liberarlo.
El tercer ejemplo es un caso más realista en Go, donde se puede leer el estado del recolector concurrente en tiempo de ejecución:
package main
import (
"fmt"
"runtime"
)
func procesarLote(datos []int) []int {
resultado := make([]int, 0, len(datos))
for _, v := range datos {
resultado = append(resultado, v*2)
}
return resultado
}
func main() {
var m runtime.MemStats
for i := 0; i < 1000; i++ {
procesarLote(make([]int, 10000))
}
runtime.ReadMemStats(&m)
fmt.Printf("NumGC ejecutados: %d, HeapAlloc: %d bytes\n", m.NumGC, m.HeapAlloc)
}
Cada slice temporal creado dentro de procesarLote deja de ser alcanzable en cuanto la función retorna; runtime.MemStats permite leer cuántos ciclos de GC (NumGC) se dispararon y cuánta memoria sigue reservada (HeapAlloc) para confirmar que el recolector efectivamente actuó.
Cómo empezar: inspeccionar y afinar el GC en tu runtime
Cada runtime expone una forma de observar al recolector en acción. Estos son los comandos exactos para los cuatro más comunes:
Node.js / V8: correr con la flag de traza imprime cada pasada de Scavenge y Mark-Compact a stderr.
node --trace-gc index.js
Cada línea muestra el tipo de recolección (Scavenge o Mark-compact), el tamaño del heap antes y después, y la duración de la pausa. Para forzar una recolección manual en un script de diagnóstico, se necesita exponer la función global:
node --expose-gc -e "global.gc(); console.log(process.memoryUsage())"
Java (G1GC o ZGC): el logging unificado de GC se activa con -Xlog.
java -Xlog:gc*:file=gc.log:time,uptime,level,tags -XX:+UseG1GC -jar app.jar
El archivo gc.log registra cada pausa “Pause Young” o “Pause Full” con su duración. Para confirmar qué recolector está activo en un proceso corriendo, se usa jcmd:
jcmd <PID> VM.flags | grep -i gc
Go: la variable de entorno GODEBUG activa la traza del recolector concurrente en cada ejecución.
GODEBUG=gctrace=1 go run main.go
Cada línea que empieza con gc reporta el número de ciclo, el tiempo transcurrido desde el arranque y la duración de cada fase (stop-the-world de marcado, marcado concurrente, stop-the-world de barrido). El umbral que dispara un nuevo ciclo se ajusta con la variable GOGC (por defecto 100, es decir, cuando el heap vivo se duplica desde la última recolección).
Python: el módulo estándar gc permite tanto depurar como forzar recolecciones.
import gc
gc.set_debug(gc.DEBUG_STATS)
liberados = gc.collect()
print(f"Objetos inalcanzables liberados: {liberados}")
💡 Tip: antes de tocar cualquier flag de tuning en producción, corré primero con la traza activada (–trace-gc, gctrace=1, -Xlog:gc*) en un entorno de staging con tráfico realista. Ajustar el recolector sin ver primero su comportamiento real es adivinar a ciegas.
Casos de uso reales
En servidores Node.js de larga duración, la causa más común de una fuga de memoria no es un bug del recolector sino listeners de EventEmitter que nunca se remueven: cada listener mantiene vivo el closure que lo contiene, y ese closure mantiene vivas referencias que deberían haber muerto jóvenes.
En sistemas Java de baja latencia —trading, matching de órdenes, servidores de juegos en tiempo real— una pausa “stop-the-world” de cientos de milisegundos es inaceptable, y por eso se migra de G1GC a ZGC: ZGC está diseñado específicamente para mantener las pausas por debajo del milisegundo sin importar el tamaño del heap, usando punteros coloreados y barreras de carga en vez de detener todos los hilos para recorrer el heap completo.
En Go, servicios con mucho tráfico ajustan GOGC hacia arriba (por ejemplo a 200 o 400) cuando prefieren gastar más memoria RAM a cambio de que el recolector se ejecute con menos frecuencia y consuma menos CPU total; es un compromiso explícito entre memoria y CPU, no una mejora gratis.
En el navegador, V8 ejecuta el recolector de forma incremental y concurrente con hilos auxiliares específicamente para que el hilo principal —el que pinta la interfaz y ejecuta el JavaScript de la página— nunca se bloquee lo suficiente como para producir el efecto visual conocido como “jank” (saltos perceptibles en la animación o el scroll).
Errores comunes y buenas prácticas
El error más frecuente es asumir que un lenguaje con garbage collection no puede tener fugas de memoria. Es falso: si una referencia sigue viva en algún lugar alcanzable —una caché sin límite de tamaño, un listener nunca removido, un closure capturado en un timer que nunca se cancela— el recolector no puede liberar ese objeto, porque desde su perspectiva sigue siendo válido.
Otro error es forzar recolecciones manuales dentro de rutas calientes del código (gc.collect() en un loop, System.gc() en cada request). Forzar una recolección completa fuera de su ciclo natural suele ser más costoso que dejar que el recolector decida cuándo actuar según su propia heurística.
Un tercer error es esperar que la memoria liberada por el recolector vuelva inmediatamente al sistema operativo. Muchos runtimes retienen la memoria reservada en forma de “arenas” para reutilizarla en futuras asignaciones, así que ver un proceso con memoria residente alta no siempre significa una fuga: puede ser memoria libre que el runtime decidió no devolver todavía al SO.
Por último, ignorar la hipótesis generacional al diseñar estructuras de datos de larga vida —por ejemplo, insertar constantemente en una lista global que nunca se limpia— fuerza a que muchos objetos se promuevan a la generación vieja, que se recolecta con mark-compact completo y es la fase más cara de todas.
Comparativa con alternativas
| Runtime / lenguaje | Algoritmo principal | Maneja ciclos | Diseño de pausas |
|---|---|---|---|
| V8 (JS / Node.js) | Generacional: Scavenge (joven) + Mark-Compact (vieja), incremental y concurrente (Orinoco) | Sí, vía trazado | Pausas cortas por diseño incremental |
| CPython | Conteo de referencias + recolector generacional de 3 generaciones para ciclos | Sí, el recolector generacional rompe ciclos | Recolección de ciclos configurable por umbrales |
| Go (runtime) | Mark-sweep concurrente, no generacional | Sí, vía trazado | Fases stop-the-world breves, marcado y barrido concurrentes |
| Java G1GC (default JDK 9+) | Mark-compact regional generacional | Sí | Objetivo de pausa configurable con -XX:MaxGCPauseMillis |
| Java ZGC (estable JDK 15+) | Mark-compact concurrente con punteros coloreados | Sí | Diseñado para pausas sub-milisegundo en cualquier tamaño de heap |
| C / Rust | Sin GC: malloc/free manual o ownership verificado en compilación | No aplica | Sin pausas de recolector, requiere gestión manual o borrow checker |
Profundizando
La base formal de casi todo recolector de trazado moderno es la abstracción tricolor, descrita originalmente por Dijkstra: cada objeto es blanco (no visitado, candidato a basura), gris (visitado pero con referencias pendientes de revisar) o negro (visitado y con todas sus referencias ya revisadas). El recolector empieza marcando las raíces como grises, las procesa moviéndolas a negro mientras marca sus referencias como grises, y termina cuando ya no quedan objetos grises: todo lo que sigue blanco es basura.
sequenceDiagram
participant R as Raices
participant W as Objeto blanco
participant G as Objeto gris
participant N as Objeto negro
R->>W: referencia inicial
W->>G: se mueve a gris al ser visitado
G->>N: se marca negro tras visitar sus hijos
Note over W,N: los objetos blancos restantes son basura
El problema de correr esto concurrentemente con el programa (el “mutator”, en la jerga de GC) es que el programa puede modificar referencias mientras el recolector está marcando, y potencialmente esconder un objeto vivo detrás de un puntero que el recolector ya no va a revisar. Para evitar esto se usan write barriers: pequeños fragmentos de código que se ejecutan cada vez que el programa escribe un puntero, y que re-marcan objetos para mantener la invariante tricolor. Las dos variantes más conocidas son la barrera de Dijkstra (ennegrece el objeto que recibe la nueva referencia) y la barrera de Yuasa (marca el objeto que pierde la referencia antes de sobrescribirla). Go y Java usan variantes de estas barreras para permitir que su marcado corra concurrentemente sin detener el programa por completo.
Otra decisión de diseño relevante es compactante vs no compactante. Un recolector compactante mueve los objetos vivos para eliminar la fragmentación, pero paga el costo de actualizar cada puntero que apuntaba a la vieja dirección. Un recolector no compactante (como muchos recolectores mark-sweep simples) es más rápido en la fase de barrido, pero deja huecos en el heap que con el tiempo pueden impedir asignaciones grandes aunque haya memoria libre en total. La generación joven de V8, por ejemplo, usa un algoritmo de copia (semispace): divide el espacio en dos mitades, copia los objetos vivos de una mitad a la otra, y descarta toda la mitad de origen de una sola vez, sin necesidad de barrer objeto por objeto.
📖 Resumen en Telegram: Ver resumen.
Preguntas frecuentes
¿El garbage collection existe en todos los lenguajes?
No. C y C++ no lo tienen: la gestión de memoria es manual con malloc/free o new/delete. Rust tampoco usa un recolector en tiempo de ejecución; resuelve el mismo problema en tiempo de compilación con un borrow checker.
¿El garbage collector libera memoria al sistema operativo inmediatamente?
No siempre. Muchos runtimes retienen la memoria reservada como “arenas” para reutilizarla en asignaciones futuras, en vez de devolverla al sistema operativo apenas un objeto se libera.
¿Puedo forzar una recolección manual?
Sí: gc.collect() en Python, System.gc() en Java, o global.gc() con la flag --expose-gc en Node.js. No se recomienda en rutas calientes de producción, solo para diagnóstico puntual.
¿Por qué mi aplicación en Node.js se congela unos milisegundos de repente?
Probablemente es una pausa de Mark-Compact en la generación vieja del heap. Correr con node --trace-gc confirma si coincide en el tiempo con esas congeladas.
¿El conteo de referencias detecta ciclos por sí solo?
No. Un ciclo de objetos que se referencian entre sí nunca llega a refcount cero. Python complementa el conteo de referencias con un recolector generacional adicional (módulo gc) específicamente para romper ciclos.
¿ARC de Swift u Objective-C es garbage collection?
No es un recolector de trazado: es conteo de referencias automático insertado en tiempo de compilación. Tampoco resuelve ciclos por sí solo; requiere marcar referencias como weak o unowned explícitamente para evitarlos.
Referencias
- V8 Blog — Trash Talk: the Orinoco garbage collector — arquitectura del recolector generacional, incremental y concurrente de V8.
- Go GC Guide — documentación oficial sobre el recolector concurrente mark-sweep de Go y la variable GOGC.
- Oracle — Java Garbage Collection Tuning Guide — guía oficial de tuning de G1GC y otros recolectores de la JVM.
- MDN — Garbage collection — definición y glosario de referencia sobre gestión automática de memoria.
📱 ¿Te gusta este contenido? Únete a nuestro canal de Telegram @programacion donde publicamos a diario lo más relevante de tecnología, IA y desarrollo. Resúmenes rápidos, contenido fresco todos los días.
0 Comentarios