Ir al contenido
learn

Lección 2: El recolector de basura

La lección 1 contó los bytes de cada asignación. Esta lección sigue a esos bytes después de su asignación: qué montón los recibe, cuándo vuelve a mirarlos el recolector de basura (GC), qué hace con los objetos que sobreviven y cómo su configuración cambia todo eso. Cada comportamiento se observa desde dentro del programa con la clase GC, y después se miden con BenchmarkDotNet las asignaciones de código real de GA.

Los enlaces de GA apuntan al commit a826864; los enlaces del runtime apuntan al commit 4271d88 de dotnet/runtime, con la etiqueta v10.0.12.

Ventana de terminal
bash code/csharp-advanced/check.sh # cada lección, comparada con expected/
dotnet run --project code/csharp-advanced/Advanced -c Release -- l2 # solo esta lección, después de check.sh

El código está en Advanced/Lesson2.cs. Las líneas que empiezan por # dependen de la máquina (tamaños de montón, tiempos de pausa, número de procesadores): la lección las cita tal como salen en la máquina del autor, un Intel Core Ultra 9 285K con 24 núcleos y 64 GB de memoria, y en los runners de la CI cuando difieren de forma interesante.

El GC de .NET es generacional: supone que la mayoría de los objetos mueren jóvenes, y recolecta los objetos jóvenes con mucha más frecuencia que los viejos (Fundamentos de la recolección de elementos no utilizados). Los objetos nuevos van a la generación 0. Un objeto que sigue referenciado cuando se recolecta su generación se promueve a la siguiente, hasta la generación 2. Una recolección gen0 solo examina gen0 (más las referencias que los objetos viejos tienen hacia los jóvenes, registradas por la barrera de escritura); una recolección gen2, también llamada recolección completa, lo examina todo.

El programa asigna un acorde de tres notas y llama a GC.Collect(), que fuerza una recolección completa y bloqueante (Lesson2.cs#L58-L73):

== Generations: an object that survives a collection is promoted
new int[3] generation 0
after GC.Collect() generation 1
after a second one generation 2
after a third one generation 2
GC.Collect(0) then new int[3]: generation 0

Dos recolecciones bastaron para que el acorde envejeciera. Esa es la trampa de llamar a GC.Collect() en el código de una aplicación: no libera memoria antes de ninguna forma útil, pero promueve todos los objetos vivos, que después esperan a la siguiente recolección completa, la más cara, para liberarse. El programa del curso solo lo llama para hacer visible el comportamiento del GC.

Los objetos de 85,000 bytes o más van al montón de objetos grandes (LOH), que solo se recolecta con la generación 2 y no se compacta de forma predeterminada (El montón de objetos grandes). La documentación dice «85,000 bytes»; el programa encuentra el límite exacto.

== Large object heap: 85,000 bytes and more, counted with the object header
new byte[ 84,975] object size 85,000 generation 0
new byte[ 84,976] object size 85,000 generation 2
new byte[1,000,000] object size 1,000,024 generation 2
new double[10,622] object size 85,000 generation 2

GC.GetGeneration indica que los objetos del LOH son de generación 2. Los dos primeros arrays cuestan 85,000 bytes cada uno, pero solo el segundo es grande. El runtime decide antes de redondear: en gchelpers.cpp#L644-L659, el totalSize de un array es su número de elementos multiplicado por el tamaño de un elemento, más el tamaño base de 24 bytes, y el array va al LOH cuando totalSize >= LARGE_OBJECT_SIZE, que vale 85,000 según gc.h#L105. 24 + 84,975 = 84,999 bytes sigue siendo pequeño, y después se redondea a 85,000. 24 + 84,976 = 85,000 es grande. Un array de 10,622 double llega exactamente a 85,000.

El umbral se puede configurar con GCLOHThreshold (gcconfig.h#L82, configuración del GC), y el programa lee su valor actual en la sección siguiente. Los búferes grandes que se asignan y se descartan en un bucle son una causa clásica de recolecciones gen2; ArrayPool<T>.Shared existe precisamente para reutilizarlos.

El código nativo, o una operación de E/S en curso, puede necesitar un array que el GC no debe mover mientras compacta el montón. Fijar un array normal con fixed o con un GCHandle bloquea la compactación a su alrededor y fragmenta la generación 0. Desde .NET 5, GC.AllocateArray<T>(length, pinned: true) asigna en su lugar en un montón aparte, el montón de objetos fijados (POH), que, como el LOH, se recolecta con la generación 2.

== Pinned object heap: GC.AllocateArray(pinned: true)
pinned byte[1024] generation 2
ordinary byte[1024] generation 0
heaps in GCGenerationInfo: 5 (gen0, gen1, gen2, LOH, POH)

GCMemoryInfo.GenerationInfo describe cinco montones: las tres generaciones, el LOH y el POH. En la máquina del autor, después de la recolección, el POH contenía 9,232 bytes: el propio runtime ya había colocado ahí 8,184 bytes antes del kilobyte del programa.

Objetos alcanzables: referencias débiles y finalizadores

Sección titulada «Objetos alcanzables: referencias débiles y finalizadores»

El GC libera un objeto cuando ya nada alcanzable hace referencia a él: ninguna variable local de un método en ejecución, ningún campo estático, ningún campo de otro objeto alcanzable. Una WeakReference apunta a un objeto sin mantenerlo vivo (Lesson2.cs#L99-L135). Los objetos se crean en un método aparte que no se inserta en línea: en el método que ejecuta GC.Collect(), una variable local podría seguir conteniéndolos, sobre todo en código sin optimizar.

== Reachability: a weak reference does not keep an object alive
only weakly reachable: IsAlive False
still referenced: IsAlive True (4 notes)

Una clase con un finalizador (~Finalizable()) sigue un camino más largo. Cuando el GC encuentra inalcanzable uno de sus objetos, todavía no puede liberarlo: lo mueve a una cola, y un subproceso finalizador dedicado ejecuta su finalizador. El objeto, de nuevo alcanzable desde esa cola, lo libera una recolección posterior. Una referencia débil corta deja de seguir al objeto en cuanto es inalcanzable; una referencia débil larga, creada con trackResurrection: true, lo sigue hasta que desaparece de verdad.

== Finalizers: a finalizable object is freed one collection later
after 1 collection: finalized 1, short weak IsAlive False, long weak IsAlive True
after 2 collections: finalized 1, short weak IsAlive False, long weak IsAlive False
bytes of new Finalizable(): 24, of new object(): 24

El objeto finalizable no cuesta más bytes, pero sobrevive a una recolección más, con la posibilidad de ser promovido por el camino, y su registro en la cola de finalización hace más lenta la asignación. Por eso las clases IDisposable que tienen un finalizador llaman a GC.SuppressFinalize(this) en Dispose(), y por eso la mayoría de las clases no deberían tener ningún finalizador: envuelve los identificadores nativos en un SafeHandle, que ya tiene uno.

GC de estación de trabajo, de servidor y concurrente

Sección titulada «GC de estación de trabajo, de servidor y concurrente»

El runtime incluye un único GC con varios modos (Recolección de elementos no utilizados de estación de trabajo y de servidor):

  • El GC de estación de trabajo, el predeterminado para las aplicaciones de consola y de escritorio, usa un solo montón y recolecta en el subproceso que desencadenó la recolección.
  • El GC de servidor, el predeterminado para ASP.NET Core, usa un montón y un subproceso de GC por procesador lógico, con presupuestos de gen0 mucho mayores: más capacidad de proceso, más memoria. Desde .NET 9, DATAS (adaptación dinámica al tamaño de las aplicaciones) está activado de forma predeterminada con el GC de servidor y ajusta el número de montones a la carga.
  • El GC concurrente (en segundo plano), activado de forma predeterminada en ambos, ejecuta la mayor parte de una recolección gen2 mientras la aplicación sigue ejecutándose.

Los eliges en el archivo del proyecto (<ServerGarbageCollection>, <ConcurrentGarbageCollection>), en runtimeconfig.json (System.GC.Server, System.GC.Concurrent) o con variables de entorno (configuración del GC). check.sh ejecuta el programa tres veces: con los valores predeterminados, con DOTNET_gcServer=1 y con DOTNET_gcConcurrent=0. GC.GetConfigurationVariables() devuelve la configuración que usa realmente el GC.

== GC mode
GCSettings.IsServerGC: False
GCSettings.LatencyMode: Interactive
GC.MaxGeneration: 2
GC.GetConfigurationVariables()["ServerGC"]: False
GC.GetConfigurationVariables()["ConcurrentGC"]: True
GC.GetConfigurationVariables()["LOHThreshold"]: 85000
GC.GetConfigurationVariables()["GCDynamicAdaptationMode"]: 1
== GC mode
GCSettings.IsServerGC: True
GCSettings.LatencyMode: Interactive
GC.MaxGeneration: 2
GC.GetConfigurationVariables()["ServerGC"]: True
GC.GetConfigurationVariables()["ConcurrentGC"]: True
GC.GetConfigurationVariables()["LOHThreshold"]: 85000
GC.GetConfigurationVariables()["GCDynamicAdaptationMode"]: 1
== GC mode
GCSettings.IsServerGC: False
GCSettings.LatencyMode: Batch
GC.MaxGeneration: 2
GC.GetConfigurationVariables()["ServerGC"]: False
GC.GetConfigurationVariables()["ConcurrentGC"]: False
GC.GetConfigurationVariables()["LOHThreshold"]: 85000
GC.GetConfigurationVariables()["GCDynamicAdaptationMode"]: 1

GCSettings.LatencyMode refleja la concurrencia: Interactive con el GC en segundo plano, Batch sin él. GCDynamicAdaptationMode vale 1 en las tres ejecuciones: la opción está activada, pero solo se aplica cuando lo está el GC de servidor. Las líneas que dependen de la máquina muestran lo que cuesta en memoria el GC de servidor:

Máquina Procesadores Montones de estación de trabajo GCGen0MaxBudget de estación de trabajo Montones de servidor GCGen0MaxBudget de servidor
Autor (Windows, x64) 24 1 18,874,368 24 209,715,200
CI Linux (x64) 4 1 16,777,216 4 209,715,200
CI Windows (x64) 4 1 25,165,824 4 209,715,200
CI macOS (Arm64) 3 1 6,291,456 3 209,715,200

El presupuesto de gen0 es la cantidad de memoria asignada tras la cual empieza una recolección gen0; con el GC de estación de trabajo, el runtime lo deriva del tamaño de la caché del procesador, así que cambia de una máquina a otra. El GC de estación de trabajo no concurrente indicó 134,217,728 bytes en las cuatro máquinas.

GC.GetGCMemoryInfo() describe la última recolección: su generación, si compactó, sus tiempos de pausa y el tamaño de cada montón antes y después. El programa fuerza una recolección gen2 bloqueante y con compactación mediante GC.Collect(2, GCCollectionMode.Forced, blocking: true, compacting: true), y luego la lee:

== GC.GetGCMemoryInfo after an induced, blocking, compacting collection
Generation 2, Compacted True, Concurrent False
# Index 11, PauseDurations[0] 0.156 ms, PauseTimePercentage 4.3%
# HeapSizeBytes 275,648, FragmentedBytes 528, TotalCommittedBytes 1,495,040
# TotalAvailableMemoryBytes 68,403,589,120, HighMemoryLoadThresholdBytes 61,563,230,208
# gen0: before 4,200, after 560
# gen1: before 416, after 0
# gen2: before 277,504, after 266,904
# LOH: before 0, after 0
# POH: before 8,184, after 8,184

Esta fue la undécima recolección del programa (Index), pausó el proceso durante 0.16 ms y dejó un montón de 276 KB: un programa pequeño, incluso con las tablas estáticas de GA cargadas. TotalAvailableMemoryBytes es la memoria física que el GC cree que puede usar, o el límite del contenedor cuando lo hay; por encima de HighMemoryLoadThresholdBytes (90% de forma predeterminada), recolecta de forma más agresiva. Los mismos valores se exportan como métricas del runtime, como dotnet.gc.last_collection.heap.size, que un panel de supervisión puede seguir en producción; la lección 19 las usa.

Los objetos de vida corta son baratos, hasta que dejan de serlo

Sección titulada «Los objetos de vida corta son baratos, hasta que dejan de serlo»

La hipótesis generacional hace baratos los objetos de vida corta: una recolección gen0 solo examina los objetos vivos, y saltarse los muertos no cuesta nada. El programa asigna un millón de arrays pequeños, y cada uno se conserva solo hasta que los dieciséis siguientes lo reemplazan (Lesson2.cs#L160-L177):

== Allocation budget: many short-lived objects, few collections
allocated 32,000,152 bytes for 1,000,000 arrays of 2 ints, 16 still referenced

32 MB de basura provocaron una recolección gen0 en la máquina del autor, en la CI de Linux y en la CI de Windows, y cinco en la CI de macOS, cuyo presupuesto de gen0 es de 6 MB. No se promovió ninguno. El coste vuelve cuando los objetos de vida corta se hacen grandes (el LOH), o viven justo lo suficiente para ser promovidos a gen1 o gen2: una caché con ámbito de solicitud, un búfer que sobrevive a un await. Entonces el GC tiene que copiarlos y, tarde o temprano, ejecutar una recolección completa para liberarlos.

GC.GetAllocatedBytesForCurrentThread() sirve para una llamada. Para código que se ejecuta millones de veces, el [MemoryDiagnoser] de BenchmarkDotNet informa, por operación, de los bytes asignados y del número de recolecciones gen0, gen1 y gen2 por cada 1,000 operaciones. Benchmarks/AllocationBenchmarks.cs mide las asignaciones de la lección 1, incluido PitchClassSetId.ItemsSpan de GA frente a un array en caché:

[MemoryDiagnoser]
public class AllocationBenchmarks
{
private static readonly PitchClassSetId[] CachedIds = [.. PitchClassSetId.Items];
[Benchmark(Baseline = true)]
public int GaPitchClassSetIdItemsSpan() => PitchClassSetId.ItemsSpan.Length;
[Benchmark]
public int CachedArraySpan() => new ReadOnlySpan<PitchClassSetId>(CachedIds).Length;
Ventana de terminal
cd code/csharp-advanced
dotnet run -c Release --project Benchmarks -- --filter "*AllocationBenchmarks*"

En la máquina del autor, sin nada más en ejecución:

BenchmarkDotNet v0.15.8, Windows 11 (10.0.26200.9445/25H2/2025Update/HudsonValley2)
Intel Core Ultra 9 285K 3.70GHz, 1 CPU, 24 logical and 24 physical cores
.NET SDK 10.0.112
[Host] : .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v3
DefaultJob : .NET 10.0.12 (10.0.12, 10.0.1226.42308), X64 RyuJIT x86-64-v3
Method Mean Error StdDev Median Ratio RatioSD Gen0 Allocated Alloc Ratio
GaPitchClassSetIdItemsSpan 1,158.0046 ns 70.7784 ns 208.6918 ns 1,173.2269 ns 1.035 0.28 0.8698 16408 B 1.000
CachedArraySpan 0.0899 ns 0.0292 ns 0.0860 ns 0.0770 ns 0.000 0.00 - - 0.000
GaPitchClassItemsSpan 0.0543 ns 0.0258 ns 0.0761 ns 0.0130 ns 0.000 0.00 - - 0.000
VoicingWithSplit 60.0266 ns 1.2168 ns 2.3151 ns 60.1405 ns 0.054 0.01 0.0114 216 B 0.013
VoicingWithSpans 46.0624 ns 0.9406 ns 2.2536 ns 46.1613 ns 0.041 0.01 - - 0.000
Format 19.1902 ns 0.4152 ns 0.8759 ns 19.1456 ns 0.017 0.00 0.0030 56 B 0.003
Interpolate 12.4961 ns 0.3073 ns 0.7479 ns 12.6991 ns 0.011 0.00 0.0017 32 B 0.002

Los tiempos proceden de una sola máquina y una sola ejecución, y la CI nunca los compara. Las asignaciones no dependen de la máquina:

  • GaPitchClassSetIdItemsSpan asigna 16,408 bytes en cada llamada, la copia de 4,096 identificadores que encontró la lección 1, y provoca 0.87 recolecciones gen0 por cada mil llamadas: un bucle que lee ItemsSpan mil veces causa casi una recolección. Además tarda alrededor de un microsegundo, y sus tiempos están dispersos (BenchmarkDotNet advierte de que la distribución es multimodal), porque algunas llamadas pagan una recolección y otras no.
  • CachedArraySpan y el propio PitchClass.ItemsSpan de GA, que devuelve un array en caché, no asignan nada. Sus medias, una décima de nanosegundo, están por debajo de lo que BenchmarkDotNet puede medir: indica ZeroMeasurement, «indistinguible del método vacío». El JIT los redujo a la lectura de una longitud.
  • Analizar el voicing "x 3 2 0 1 0" con string.Split asigna 216 bytes (el array y seis cadenas); la versión con spans no asigna nada y es alrededor de una cuarta parte más rápida.
  • Format asigna 56 bytes, el objeto con boxing y la cadena, e Interpolate 32, solo la cadena, como se midió en la lección 1. La columna Gen0 convierte esos bytes en recolecciones: 3 por millón de llamadas para Format.

Lee primero Allocated: es exacto y reproducible. Lee Mean con su Error y su StdDev, y con las advertencias que BenchmarkDotNet imprime debajo de la tabla. La lección 4 trata de cómo obtener tiempos en los que puedas confiar.

Las dos plataformas son generacionales, ambas sustituyeron la mayoría de sus opciones de ajuste por ergonomía, y no comparten casi nada de vocabulario. La tabla conecta lo que esta lección midió con lo que leerías en una guía de ajuste del GC.

.NET HotSpot
generaciones 0, 1 y 2 una generación joven (edén y supervivientes) y una generación antigua; G1 asigna a cada región uno de esos papeles
el montón de objetos grandes, desde 85 000 bytes los objetos humongous de G1, «larger or equal the size of half a region», asignados en regiones contiguas de la generación antigua. El tamaño de región es ergonómico: unas 2 048 regiones, de hasta 32 MB cada una
el montón de objetos fijados, un montón aparte nada equivalente. La fijación es transitoria y se limita a una región crítica de JNI; desde la JEP 423 (JDK 22), G1 fija la región en vez de desactivar la recolección, y registra un fallo de evacuación con el motivo Pinned
GC de estación de trabajo o de servidor, elegido por configuración un recolector entre G1 —el predeterminado, y desde la JEP 523 en todos los entornos, no solo en máquinas de tipo servidor—, Parallel, Serial y ZGC
DATAS dimensiona el montón según la carga la ergonomía de cada recolector, más el -XX:SoftMaxHeapSize de ZGC: un límite blando que intenta respetar, «but is still allowed to grow beyond this limit up to the maximum heap size»
las duraciones de pausa leídas en GC.GetGCMemoryInfo() un objetivo declarado por recolector: el de ZGC es «Pause times should not exceed 1 millisecond» (JEP 439), más estricto que los 10 ms de su primera JEP
los finalizadores, y el hilo de finalización finalize(), obsoleto y marcado para eliminación desde el JDK 18 (JEP 421) y todavía activo por defecto; Cleaner y PhantomReference en su lugar
WeakReference<T>, ConditionalWeakTable WeakReference, PhantomReference, y SoftReference, «cleared at the discretion of the garbage collector in response to memory demand» — .NET no tiene referencia blanda
GC.GetGCMemoryInfo() y GC.GetConfigurationVariables(), desde dentro del proceso GarbageCollectorMXBean con getCollectionCount() y getCollectionTime(), -Xlog:gc, y JDK Flight Recorder
[MemoryDiagnoser]: bytes y recolecciones de gen0 por operación el -prof gc de JMH, cuyo gc.alloc.rate.norm son los bytes por operación

Una fila merece cuidado, porque la lección 1 la midió aquí. .NET 9 y 10 asignan en la pila las cajas y los objetos pequeños que no escapan. El análisis de escape de HotSpot está documentado de forma más estrecha: para un objeto que «does not escape», el compilador de servidor «eliminates the scalar replaceable object allocations and the associated locks from generated code» — el objeto se descompone en sus campos en vez de trasladarse a la pila. El resultado medido es el mismo, cero bytes asignados; la redacción de la garantía, no.

Para un servicio reactivo, el número en el que insiste esta lección —los bytes por operación— importa aún más que aquí. Un pipeline de Reactor asigna un objeto suscriptor por operador en cada suscripción, y un servidor WebFlux se suscribe una vez por petición, así que las asignaciones crecen con el tráfico de un modo que un benchmark aislado no muestra. Reactor no publica cifras de asignación propias; -prof gc sobre tu propio pipeline es la manera de obtenerlas.

  1. ¿Cuál es el char[] más pequeño que va al montón de objetos grandes?
  2. GC.TryStartNoGCRegion pide al GC que no recolecte mientras una sección crítica asigna menos de una cantidad dada. Inicia una región de 1 MB, asigna mil arrays de 100 bytes e indica el modo de latencia y el número de recolecciones gen0 dentro de la región.
  3. GC.GetGeneration("C major") no devuelve ni 0, ni 1, ni 2. ¿Qué devuelve y por qué? Prueba también con typeof(PitchClass).
Soluciones
  1. Un char ocupa 2 bytes, y el tamaño base del array es de 24 bytes: 24 + 2 × n ≥ 85,000 da n = 42,488. Con un elemento menos, el array se queda en la generación 0.

    1. new char[42,487] generation 0, new char[42,488] generation 2
  2. La región se inicia, el modo de latencia pasa a ser NoGCRegion, y mil arrays de 128 bytes cada uno (24 + 100, redondeado) caben en el 1 MB reservado: ninguna recolección. EndNoGCRegion restaura el modo anterior. Asignar más de la cantidad solicitada termina la región en silencio, y entonces EndNoGCRegion lanza InvalidOperationException.

    2. TryStartNoGCRegion(1 MB) True, LatencyMode NoGCRegion, gen0 collections for 1,000 arrays 0
    after EndNoGCRegion: LatencyMode Interactive, 16 arrays kept
  3. Devuelve int.MaxValue. Desde .NET 8, los literales de cadena, los objetos RuntimeType y algunos otros objetos que el runtime sabe que vivirán para siempre se asignan en un montón congelado, ajeno al GC, que el GC nunca examina (nota sobre el cambio importante). Una cadena construida en tiempo de ejecución con los mismos caracteres es un objeto gen0 normal. El código que usaba la generación como índice de un array falla con estos objetos.

    3. GC.GetGeneration("C major") 2147483647, of new string('C', 7) 0, of typeof(PitchClass) 2147483647
  • Los objetos nuevos empiezan en la generación 0; cada recolección a la que sobreviven los promueve, hasta la generación 2. GC.Collect() en el código de una aplicación sobre todo promueve objetos vivos.
  • Un array va al montón de objetos grandes cuando su tamaño sin redondear, 24 bytes más sus elementos, llega a 85,000 bytes: byte[84,976], char[42,488], double[10,622]. El LOH y el montón de objetos fijados se recolectan con la generación 2.
  • Un finalizador retrasa la liberación al menos una recolección. Prefiere IDisposable y SafeHandle.
  • El GC de servidor cambia memoria por capacidad de proceso: un montón por procesador y un presupuesto de gen0 de 200 MB, ajustado por DATAS desde .NET 9. Los presupuestos del GC de estación de trabajo dependen de la máquina.
  • GC.GetGCMemoryInfo() y GC.GetConfigurationVariables() muestran lo que hizo el GC y cómo está configurado, desde dentro del proceso.
  • [MemoryDiagnoser] convierte «esto asigna memoria» en bytes y recolecciones por operación. PitchClassSetId.ItemsSpan de GA aparece ahí como 16,408 bytes por llamada y casi una recolección gen0 por cada mil llamadas.