|

Domina la Recolección de Basura en 3 Lenguajes

El garbage collection en Java, Go y Python resuelve un problema que probablemente no pensaste que tenías: quién limpia la memoria cuando tu programa ya no necesita un objeto. Go usa mark-and-sweep concurrente con latencias bajas, Python apuesta por reference counting con detector de ciclos, y Java ofrece G1GC (pausas de 20ms) hasta ZGC (50 microsegundos) según tu caso de uso. La diferencia entre elegir el recolector equivocado puede costarte 400x más en pausas, como Netflix descubrió migrando de G1 a ZGC.

En 30 segundos

  • Qué es: garbage collection es el mecanismo automático que libera memoria cuando objetos dejan de usarse, evitando memory leaks. Java, Go y Python lo tienen; Rust no.
  • Algoritmo base: mark-and-sweep identifica qué objetos son alcanzables desde el programa, luego borra el resto. McCarthy describió esto en 1960 cuando inventó Lisp.
  • El problema: pausas. Mientras el GC corre, tu aplicación se detiene. ZGC en Java logra pausas de 50 microsegundos; G1GC te da 20 milisegundos o más.
  • Las opciones: G1GC es el default en Java moderno (balanceado). ZGC para servicios ultra-low-latency (trading, gaming). Go tiene concurrent marking, Python reference counting.
  • Por qué importa: en servicios de baja latencia (p99 < 10ms), el GC puede destrozar tu SLA. En batch jobs, no te importa.

¿Qué es garbage collection? Conceptos fundamentales

Garbage collection es el mecanismo automático que identifica y libera memoria ocupada por objetos que tu programa ya no usa. Sin GC tenés que administrar la memoria manualmente como en C o C++ (malloc/free), lo que es un dolor de cabeza y fuente de bugs. Con GC, el runtime se encarga. La contrapartida: pausas impredecibles donde tu código se detiene mientras el recolector trabaja.

El problema que GC resuelve es real. Ponele que creás mil objetos en un loop, cada uno reserva memoria, y cuando el loop termina, alguien tiene que limpiar ese quilombo. En C tenés que acordarte manualmente. En Java, el GC lo hace automático.

Rust no tiene GC. Es un lenguaje compilado que resuelve esto en tiempo de compilación con un sistema de ownership (el borrow checker). Es más seguro pero más estricto. Java, Go y Python eligieron GC: automático, más fácil de programar, pero con pausas ocasionales.

Historia: de McCarthy (1960) a los colectores modernos

McCarthy inventó Lisp en 1960 y metió un GC adentro casi de casualidad. Necesitaba una forma de administrar memoria para las expresiones simbólicas de Lisp, y describe mark-and-sweep en su paper como un detalle de implementación. Ese paper es histórico porque Lisp, pero el GC es lo que encendió toda la rama.

Luego, en 1992, Wilson sacó un survey llamado “Uniprocessor Garbage Collection Techniques” que organizó todas las técnicas en una taxonomía limpia. Si lees ese survey, los colectores modernos—G1GC, ZGC, el concurrent collector de Go, el hybrid approach de CPython—son todos variaciones sobre ideas que Wilson ya había categorizado.

La evolución fue así: mark-and-sweep básico → generacionales (porque objetos jóvenes mueren rápido) → concurrent (mientras tu programa corre) → low-latency (ZGC, pausas casi invisibles). Cada innovación respondió a un problema específico.

Algoritmo mark-and-sweep: el mecanismo base

Mark-and-sweep funciona en dos fases. Primera: el GC arranca desde las “raíces”—variables globales, stack local, registros—y marca como alcanzables todos los objetos que puede llegar a tocar (directa o indirectamente). Es como una búsqueda en grafo desde esos puntos de entrada.

Segunda: barre toda la memoria, y lo que no fue marcado lo libera. Objeto marcado = sigue vivo. Objeto sin marcar = nadie lo usa, borralo.

El problema es que esto detiene el programa. Mientras el GC marca y barre, tu aplicación no corre. En un programa de 1 gigabyte con millones de objetos, esa pausa puede ser de cientos de milisegundos. Malo si estás sirviendo requests de usuarios (¿quién espera 200ms?). Relacionado: como discutimos en nuestra guía de seguridad.

Generacionales y compactación: optimizaciones que achicaron pausas

Los diseñadores de GC notaron algo: objetos jóvenes mueren mucho más rápido que objetos viejos. Un string temporal creado en un request probablemente se libere dentro de ese mismo request. Una conexión a base de datos que lleva abierta un mes probablemente siga abierta.

La hipótesis generacional aprovecha eso: dividís la memoria en espacios por edad (young, old, permanent). Cada vez que tu programa aloca memoria, van al espacio joven. Cuando el GC corre, primero limpia young (es chico, pausas cortas). Si young llena, promovés los sobrevivientes a old. Los objetos viejos se barren con menos frecuencia.

Java usa esto agresivamente. G1GC divide el heap en regiones, algunas jóvenes, algunas viejas. ZGC también. El resultado: pausas más cortas porque no barrés todo cada vez.

Compactación es el bonus: después de barrer, reagrupás los objetos vivos para que la memoria quede contigua. Reduce fragmentación y hace más rápido acceder a datos (mejor cache locality). Trade-off: copiar objetos también consume CPU y pausas, así que los colectores modernos hacen compactación selectiva.

G1GC, ZGC y Shenandoah: recolectores modernos en Java

Java pasó de Parallel GC (pausas 200ms+) a G1GC alrededor de 2012. G1GC permite configurar MaxGCPauseMillis (el tiempo máximo que tolerás que se pause el programa). Típicamente se configura a 20ms y el GC respeta ese target. Es un cambio enorme: predecibilidad.

ZGC llegó en Java 11 (2018) y cambió el juego. Pausas consistentes menores a 1 milisegundo—típicamente 50 microsegundos, incluso con heaps de 16GB. Netflix migró de G1 a ZGC en la mitad de sus servicios y reportó reducción masiva en tail latencies (p99, p999). Eso es 400x menos pausa que G1.

¿Cómo? ZGC usa load barriers (barreras al leer memoria) y heap basado en regiones. Mientras marca, no compacta todo de una. Compacta regiones selectivas en paralelo. Load barriers son un costo—CPU overhead—pero valen la pena si la latencia es crítica.

Shenandoah es similar pero usa write barriers en vez de load barriers. Ambas opciones son low-latency. La diferencia técnica no importa para la mayoría—el punto es: si necesitás pausas < 1ms, ZGC o Shenandoah. Si puede ser 20ms, G1GC es más económico en CPU.

Garbage collection en Go vs Python vs Java

Go eligió simplidad: concurrent mark-and-sweep con tricolor marking. Mientras tu programa corre, el GC marca objetos con tres colores (blanco = no visitado, gris = visitado pero hijos no explorados, negro = visitado completamente). Las pausas son cortas porque no para completamente—marking es concurrente. Go apunta a pausas de 1-2ms en aplicaciones típicas.

Python es distinto. CPython (la implementación estándar) usa reference counting: cada objeto tiene un contador de referencias, cuando llega a cero se libera inmediatamente. Rápido, predecible, casi sin pausas. El problema: ciclos de referencias (objeto A apunta a B, B apunta a A) nunca se liberan. Python complementa con un cycle detector que corre periódicamente (y sí genera pausas). Pero en general, Python GC es más lightweight que Java.

Java tiene opciones: G1GC (default desde Java 9), ZGC, Shenandoah, Parallel. Eso da control pero requiere decisión. Go y Python eligieron un camino y lo optimizaron a muerte. Java eligió flexibilidad. Para más detalles técnicos, mirá según nuestro análisis de optimización de rendimiento.

Write barriers, concurrencia y pausas de GC

Un write barrier es una instrucción que corre cada vez que tu programa escribe una referencia en memoria. Suena costoso (porque es otro check), pero es clave para GC concurrente.

Imaginate que el GC está marcando objetos en el thread 1, y tu programa en el thread 2 crea una nueva referencia a un objeto que el GC ya revisó. Sin tracking, GC cree que el objeto no es alcanzable, lo borra, y boom: null pointer. Con write barrier, el thread 2 le avisa al GC “oye, acabo de crear una referencia a esto”, y GC lo remarca.

Go usa write barriers para tricolor marking. ZGC usa load barriers (el inverso: check cuando LEES memoria). Ambos tienen overhead, pero permiten que marking sea concurrente—tu programa corre casi sin pausas visibles, solo paga el costo de los barriers.

Por eso ZGC es caro en CPU: los load barriers no son gratis. Pero p99 latency mejora dramáticamente. En un trading system donde 50 microsegundos importan, ese tradeoff es obvio. En un script batch, desperdiciás CPU al pedo.

Impacto real en aplicaciones: throughput vs latencia

Las pausas GC no importan en todas partes igual. En un job batch que procesa logs toda la noche, un GC que pausa 500ms es aceptable. En un servicio que atiende 10 mil requests por segundo y necesita p99 < 50ms, una pausa de 100ms te rompe el SLA y te cuesta dinero.

Eso sí, cuanto menor latencia, mayor CPU overhead. Parallel GC tira pausas pero usa todos los cores agresivamente—throughput máximo. ZGC reduce pausas pero los load barriers consumen CPU constantemente. La elección depende de tu métrica: si optimizás para throughput (total de trabajo procesado), Parallel. Si optimizás para latencia (percentiles p99, p999), ZGC.

Los números reales: G1GC típicamente 20ms p999, ZGC 50 microsegundos p999. Eso es 400x menos. Netflix lo vio en producción—después de migrar, el p99 de query latency bajó porque no tenías más las pausas que mataban ciertos requests.

En aplicaciones normales (CRUD web, microservicios), G1GC con tuning mínimo es suficiente. En low-latency (fintech, gaming, real-time), ZGC es la opción seria.

Errores comunes en GC

Creer que GC es “gratis”

No. GC tiene costo: pausas o CPU overhead. Tenés que elegir. Un programa sin GC (Rust, C) no paga ni pausas ni overhead—pero vos garantizas memoria segura. Java elige automatización a cambio de tradeoffs.

No monitorear pausas GC

Muchos equipos debuggean latencias altas sin pensar en GC. Abren JFR (Java Flight Recorder), ven pausas de 200ms, y recién ahí se dan cuenta que el culpable es un full GC. Monitoreá GC desde el inicio: dump de GC logs en producción, alertas si pausas exceden X threshold.

Tuning cargo-cult

Copiar configuración GC de otro equipo sin entender tu workload. “Netflix usa ZGC, yo también” sin verificar si tu latencia importa. G1GC con MaxGCPauseMillis=50ms puede ser más que suficiente (y menos CPU que ZGC) si tu p99 es de 500ms. En en nuestro estudio sobre modelos optimizados profundizamos sobre esto.

Memory leaks “porque GC debería limpiar”

GC no limpia memory leaks. Si tu código aún referencia un objeto (porque lo dejaste colgando en un static map, o en caché sin límite), el GC no puede borrarlo. Memory leaks con GC siguen siendo memory leaks—solo que el GC no es el culpable.

Tabla comparativa de recolectores

RecolectorLenguajePausas típicasCPU overheadMejor paraPeor para
G1GCJava20-200ms (configurable)Bajo-medioAplicaciones generales, webUltra-low-latency
ZGCJava 11+<1ms (50μs típico)AltoTrading, gaming, real-timeBatch processing
ShenandoahJava 12+<10msAltoLow-latency, grandes heapsBatch processing
Concurrent Mark-Sweep (Go)Go1-2msMedioMicroservicios, APIsBatch jobs
Reference Counting (CPython)Python<1ms (ciclos: 10-100ms ocasional)BajoScripts, automatizaciónWorkloads con muchos ciclos
Parallel GCJava200-1000msBajo (máximo throughput)Batch, data processingServicios online
recolección de basura diagrama explicativo

Ejemplos concretos

Ejemplo 1: Microservicio web en Java. Tenés un endpoint que recibe un request, lo procesa (genera strings, objetos temporales), devuelve JSON. Todo eso sucede en milisegundos, la mayoría de objetos mueren en ese millisecond. G1GC con MaxGCPauseMillis=20ms anda perfecto—pausas cortas, overhead bajo. ZGC sería overkill.

Ejemplo 2: Trading system en Java. Procesas órdenes, cada delay de 100 microsegundos es dinero. Migraste de G1 (pausas 20ms) a ZGC (pausas 50μs). El p99 latency bajó 400x. Los load barriers de ZGC consumén 5-10% CPU extra, pero vale la pena porque cada pausazo se ve inmediatamente en PnL (profit and loss).

Ejemplo 3: Backend en Go. API que atiende requests HTTP. Go’s concurrent GC mantiene pausas bajas (1-2ms) sin tuning agresivo. Escribís código, deployás, funciona. El precio: CPU modesto (write barriers), pero el valor es que no tenés que pensar mucho.

Ejemplo 4: Script Python que procesa archivos CSV. Cargás 1 gigabyte de datos, hacés transformaciones, escribís resultado. Python’s reference counting libera memoria inmediatamente cuando las variables salen de scope. Cada tanto, el cycle detector corre (pausas ocasionales de decenas de ms), pero es transparente. No configurás nada.

Preguntas Frecuentes

¿Cómo funciona exactamente el garbage collection?

El GC identifica qué objetos en memoria tu programa puede alcanzar desde el stack, variables globales y registros, luego barre lo demás. Mark-and-sweep es el algoritmo base: marca objetos alcanzables (fase marking), barre objetos no marcados (fase sweep). Generacionales optimizan esto cleaneando más frecuente objetos jóvenes. Concurrent marking permite que el programa siga corriendo durante marking (con write barriers para sincronización).

¿Puedo evitar pausas GC?

No completamente en lenguajes con GC. Podés minimizarlas con recolectores low-latency (ZGC, Shenandoah) o lenguajes como Go que diseñaron pausas bajas nativamente. Si necesitás latencia absoluta crítica (microsegundos), necesitás un lenguaje sin GC (Rust, C) o GC offline (Java con GC pausado).

¿ZGC siempre es mejor que G1GC?

No. ZGC baja pausas pero incrementa CPU overhead (load barriers) entre 5-15%. Si tu aplicación no necesita pausas < 20ms (G1GC típico), ZGC desperdicia recursos. G1GC es más eficiente en CPU. Elegí según tus métricas: latencia critical → ZGC. Throughput o recursos limitados → G1GC.

¿Por qué Python tiene reference counting si tiene GC también?

Porque reference counting es más simple y predecible: objetos se liberan inmediatamente cuando nadie los referencia, sin pausas. CPython usa reference counting como primario y un cycle detector (GC tradicional) como backup para ciclos de referencias. Es un hybrid approach que minimiza pausas.

¿Cómo monitoreo pausas GC en producción?

En Java, activa GC logs (`-Xlog:gc*:file=gc.log`) o Java Flight Recorder (JFR). Exportá a Prometheus/Datadog y alertá si pausas exceden threshold. En Go, `runtime.ReadMemStats()` da info de GC. En Python, `gc.get_stats()` muestra pauses del cycle detector. Lo importante: monitoreá desde el inicio, no cuando ya hay problema.

Conclusión

El garbage collection es el tradeoff fundamental entre seguridad de memoria y control. Subís el modelo de IA, lo probás en local, funciona bárbaro, lo mandás a producción y de repente un GC pause cada 5 segundos destruye tu p99 latency. O al revés: configurás ZGC caro en CPU cuando G1 te alcanzaba sobrado.

Las lecciones que importan: primero, medí. No hagas tuning GC ciegos. Segundo, entiendé tu workload. Si sos batch, Parallel GC o ZGC (throughput máximo). Si sos online, G1GC o ZGC según si la latencia importa. Tercero, conocé las opciones—Java tiene varias, Go eligió una buena, Python tiene hybrid. No es lo mismo.

Y si un día debuggeás una latencia altas y te dicen “es el GC”, al menos ahora sabés qué está pasando adentro.

Fuentes

Te puede interesar...