Fundamentos de la recolección de elementos no utilizados

En el Common Language Runtime (CLR), el recolector de elementos no utilizados (GC) actúa como administrador de memoria automático. El recolector de elementos no utilizados administra la asignación y liberación de la memoria de una aplicación. Por lo tanto, los desarrolladores que trabajan con código administrado no tienen que escribir código para realizar tareas de administración de memoria. La administración automática de memoria puede eliminar problemas comunes, como olvidarse de liberar un objeto y provocar una pérdida de memoria o intentar acceder a la memoria libre de un objeto que ya se ha liberado.

En este artículo se describen los conceptos básicos de la recolección de elementos no utilizados.

Ventajas

El recolector de elementos no utilizados proporciona las siguientes ventajas:

  • Exime a los desarrolladores de tener que liberar memoria manualmente.

  • Asigna con eficacia los objetos del montón administrado.

  • Reclama los objetos que ya no se utilizan, borra la memoria correspondiente y mantiene la memoria disponible para asignaciones futuras. Los objetos administrados obtienen automáticamente contenido limpio desde el principio, de modo que sus constructores no tienen que inicializar todos los campos de datos.

  • Proporciona seguridad de memoria asegurándose de que un objeto no puede usar para sí mismo la memoria asignada para otro objeto.

Fundamentos de memoria

En la lista siguiente se resumen los conceptos importantes de memoria clR:

  • Cada proceso tiene propio espacio de direcciones virtuales independiente. Todos los procesos del mismo equipo comparten la misma memoria física y el archivo de página, si hay uno.

  • De forma predeterminada, en los equipos de 32 bits, cada proceso tiene un espacio de direcciones virtuales en modo usuario de 2 GB.

  • Como desarrollador de aplicaciones, solo trabaja con el espacio de direcciones virtuales y nunca manipula la memoria física directamente. El recolector de elementos no utilizados asigna y libera memoria virtual en el montón administrado.

    Si está escribiendo código nativo, use funciones de Windows para trabajar con el espacio de direcciones virtuales. Estas funciones asignan y liberan memoria virtual en pilas nativas.

  • La memoria virtual puede estar en tres estados:

    Estado Descripción
    Gratuito El bloque de memoria no tiene ninguna referencia a ella y está disponible para su asignación.
    Reservada El bloque de memoria está disponible para su uso y no se puede usar para ninguna otra solicitud de asignación. Sin embargo, no puede almacenar datos en este bloque de memoria hasta que se confirme.
    Confirmado El bloque de memoria se asigna al almacenamiento físico.
  • El espacio de direcciones virtuales se puede fragmentar, lo que significa que hay bloques libres conocidos como agujeros en el espacio de direcciones. Cuando se solicita una asignación de memoria virtual, el administrador de memoria virtual tiene que buscar un único bloque libre lo suficientemente grande como para satisfacer la solicitud de asignación. Aunque tenga 2 GB de espacio disponible, una asignación que necesite 2 GB será incorrecta a menos que todo ese espacio disponible esté en un único bloque de direcciones.

  • Puede quedarse sin memoria si no hay suficiente espacio de direcciones virtuales para reservar o espacio físico para confirmar.

    El archivo de página se usa incluso si la presión de memoria física (demanda de memoria física) es baja. La primera vez que la presión de memoria física es alta, el sistema operativo debe dejar espacio en la memoria física para almacenar datos y hace una copia de seguridad de algunos de los datos que están en memoria física en el archivo de página. Los datos no se paginan hasta que son necesarios, por lo que es posible encontrar paginación en situaciones en las que la presión de memoria física es baja.

Asignación de memoria

Cuando se inicializa un nuevo proceso, el motor en tiempo de ejecución reserva una región contigua de espacio de direcciones para el proceso. Este espacio de direcciones reservado se denomina montón administrado. El montón administrado mantiene un puntero a la dirección a la que se asignará el siguiente objeto del montón. Inicialmente, este puntero se establece en la dirección base del montón administrado. Todos los tipos de referencia se asignan en el montón administrado. Cuando una aplicación crea el primer tipo de referencia, se le asigna memoria en la dirección base del montón administrado. Cuando la aplicación crea el siguiente objeto, el recolector de elementos no utilizados le asigna memoria en el espacio de direcciones que sigue inmediatamente al primer objeto. Siempre que haya espacio de direcciones disponible, el recolector de elementos no utilizados continúa asignando espacio a los objetos nuevos de este modo.

La asignación de memoria desde el montón administrado es más rápida que la asignación de memoria no administrada. Como el tiempo de ejecución asigna memoria a los objetos agregando un valor a un puntero, este método es casi tan rápido como la asignación de memoria desde la pila. Además, puesto que los nuevos objetos que se asignan consecutivamente se almacenan uno junto a otro en el montón administrado, la aplicación puede acceder rápidamente a los objetos.

Liberación de memoria

El motor de optimización del recolector de elementos no utilizados determina cuál es el mejor momento para realizar una recolección basándose en las asignaciones realizadas. Cuando el recolector de elementos no utilizados lleva a cabo una recolección, libera la memoria de los objetos que ya no usa la aplicación. Determina qué objetos ya no se usan examinando las raíces de la aplicación. Las raíces de una aplicación incluyen campos estáticos, variables locales en la pila de un subproceso, registros de la CPU, identificadores de recolección de elementos no utilizados y la cola de finalización. Cada raíz hace referencia a un objeto del montón administrado, o bien se establece en null. El recolector de elementos no utilizados puede solicitar estas raíces al resto del entorno de ejecución. El recolector de elementos no utilizados usa esta lista para crear un gráfico que contiene todos los objetos a los que se puede acceder desde las raíces.

Los objetos que no están en el gráfico no son accesibles desde las raíces de la aplicación. El recolector de elementos no utilizados considera elementos no utilizados los objetos inalcanzables y libera la memoria que tienen asignada. Durante una recolección, el recolector de elementos no utilizados examina el montón administrado y busca los bloques de espacio de direcciones que ocupan los objetos que no se pueden alcanzar. Cuando detecta cada uno de los objetos inalcanzables, usa una función de copia de memoria para compactar los objetos alcanzables en la memoria y libera los bloques de espacios de direcciones asignados a los objetos no alcanzables. Una vez que se ha compactado la memoria de los objetos alcanzables, el recolector de elementos no utilizados hace las correcciones de puntero necesarias para que las raíces de la aplicación señalen a los objetos en sus nuevas ubicaciones. También sitúa el puntero del montón administrado después del último objeto alcanzable.

La memoria solo se compacta si, durante una recolección, se detecta un número significativo de objetos inalcanzables. Si todos los objetos del montón administrado sobrevive a una colección, no es necesario compactar memoria.

Para mejorar el rendimiento, el tiempo de ejecución asigna memoria a los objetos grandes en un montón aparte. El recolector de elementos no utilizados libera la memoria para los objetos grandes automáticamente. Pero, para no mover objetos grandes en la memoria, normalmente dicha memoria no se compacta.

Condiciones para la recolección de elementos no utilizados

La recolección de elementos no utilizados se produce cuando se cumple alguna de las siguientes condiciones:

  • El sistema tiene poca memoria física. El tamaño de memoria se detecta mediante la notificación de memoria baja del sistema operativo o memoria baja, tal como indica el host.

  • La memoria que utilizan los objetos asignados del montón administrado supera un umbral aceptable. Este umbral se ajusta continuamente a medida que se ejecuta el proceso.

  • Se llama al método GC.Collect . En casi todos los casos, no es necesario llamar a este método porque el recolector de elementos no utilizados se ejecuta continuamente. Este método se utiliza principalmente para pruebas y situaciones singulares.

Montón administrado

Una vez que CLR inicializa el recolector de elementos no utilizados, asigna un segmento de memoria para almacenar y administrar objetos. Esta memoria se denomina montón administrado, y se diferencia del montón nativo del sistema operativo.

Hay un montón administrado para cada proceso administrado. Todos los subprocesos del proceso asignan memoria a los objetos del mismo montón.

Para reservar memoria, el recolector de elementos no utilizados llama a la función VirtualAlloc de Windows y reserva un segmento de memoria cada vez para las aplicaciones administradas. El recolector de elementos no utilizados también reserva segmentos según sea necesario y libera los segmentos de vuelta al sistema operativo (después de borrarlos de cualquier objeto) llamando a la función Windows VirtualFree .

Importante

El tamaño de los segmentos asignados por el recolector de elementos no utilizados es específico de la implementación y está sujeto a cambios en cualquier momento, incluso en las actualizaciones periódicas. La aplicación nunca debe realizar suposiciones sobre el tamaño de un sector determinado ni depender de él, y tampoco debe intentar configurar la cantidad de memoria disponible para las asignaciones de segmentos.

Cuantos menos objetos se asignen al montón, menos trabajo tendrá que hacer el recolector de elementos no utilizados. Al asignar objetos, no use valores redondeados que superen sus necesidades; por ejemplo, no asigne una matriz de 32 bytes si solo necesita 15 bytes.

Cuando se desencadena una recolección de elementos no utilizados, el recolector de elementos no utilizados reclama la memoria ocupada por objetos muertos. El proceso de reclamación compacta los objetos dinámicos para que se muevan juntos y se quite el espacio inactivo, lo que hace que el montón sea más pequeño. Este proceso garantiza que los objetos asignados juntos permanezcan juntos en el montón administrado para conservar su localidad.

La tendencia a la intrusión (frecuencia y duración) de las recolecciones de elementos no utilizados es el resultado del volumen de asignaciones y la cantidad de memoria que sobrevivió en el montón administrado.

El montón considerarse una acumulación de dos montones: el montón de objetos grandes y el montón de objetos pequeños. El montón de objetos grandes contiene objetos de 85 000 bytes o más, que normalmente son matrices. Es raro que un objeto de instancia sea extra grande.

Sugerencia

Puede configurar el tamaño de umbral para que los objetos se dirijan al montón de objetos grandes.

Generaciones

El algoritmo de GC se basa en varias consideraciones:

  • Es más rápido compactar la memoria de una parte del montón administrado que la de todo el montón.
  • Los objetos más recientes tienen una duración más corta y los objetos más antiguos tienen una duración más larga.
  • Los objetos más recientes suelen estar relacionados unos con otros y la aplicación accede a ellos más o menos al mismo tiempo.

La recolección de elementos no utilizados se produce principalmente con la reclamación de objetos de corta duración. Para optimizar el rendimiento del recolector de elementos no utilizados, el montón administrado se divide en tres generaciones: 0, 1 y 2, de forma que pueda manipular por separado los objetos de corta duración y los de larga duración. El recolector de elementos no utilizados almacena los nuevos objetos en la generación 0. Los objetos creados en las primeras etapas de la duración de la aplicación y que sobreviven a las recolecciones se promueven y se almacenan en las generaciones 1 y 2. Como es más rápido compactar una parte del montón administrado que todo el montón, este esquema permite que el recolector de elementos no utilizados libere la memoria en una generación específica en lugar de liberarla para todo el montón administrado cada vez que realiza una recolección.

  • Generación 0: esta generación es la más joven y contiene objetos de corta duración. Un ejemplo de objeto de corta duración es una variable temporal. La recolección de elementos no utilizados se produce con mayor frecuencia en esta generación.

    Los objetos recién asignados constituyen una nueva generación de objetos y son colecciones de la generación 0, implícitamente. Sin embargo, si son objetos grandes, pasan por el montón de objetos grandes (LOH), que a veces se conoce como generación 3. La generación 3 es una generación física que se recopila de forma lógica como elemento de la generación 2.

    La mayoría de los objetos se reclaman para la recolección de elementos no utilizados en la generación 0 y no sobreviven a la generación siguiente.

    Si una aplicación intenta crear un nuevo objeto cuando la generación 0 está llena, el recolector de elementos no utilizados realiza una colección para liberar espacio de direcciones para el objeto. Primero examina los objetos de la generación 0 y no todos los objetos del montón administrado. Una recolección de tan sólo la generación 0 a menudo recupera suficiente memoria para que la aplicación pueda continuar creando objetos.

  • Generación 1: esta generación contiene objetos de corta duración y actúa como búfer entre objetos de corta duración y objetos de larga duración.

    Una vez que el recolector de elementos no utilizados realiza una recolección de la generación 0, compacta la memoria de los objetos que se pueden alcanzar y los promueve a la generación 1. Dado que los objetos que sobreviven a las recolecciones suelen tener una duración más larga, es lógico promoverlos a una generación superior. El recolector de elementos no utilizados no tiene que volver a examinar los objetos de las generaciones 1 y 2 cada vez que realiza una recolección en la generación 0.

    Si una colección de generación 0 no reclama suficiente memoria para que la aplicación cree un nuevo objeto, el recolector de elementos no utilizados puede realizar una colección de generación 1 y, a continuación, la generación 2. Los objetos de la generación 1 que sobreviven a las recolecciones se promueven a la generación 2.

  • Generación 2: esta generación contiene objetos de larga duración. Un ejemplo de objeto de larga duración es un objeto de una aplicación de servidor que contiene datos estáticos que están activos mientras dura el proceso.

    Los objetos de la generación 2 que sobreviven a una colección permanecen en la generación 2 hasta que se determina que no son accesibles en una colección futura.

    Los objetos del montón de objetos grandes (a veces denominado generación 3) también se recopilan en la generación 2.

Las recolecciones de elementos no utilizados se producen en generaciones específicas como condiciones. La recolección de una generación significa recolectar los objetos de esa generación y de todas las generaciones anteriores. Una recolección de elementos no utilizados de generación 2 también se conoce como una recolección de elementos no utilizados completa porque reclama objetos en todas las generaciones (es decir, todos los objetos del montón administrado).

Supervivencia y promociones

Los objetos que no se reclaman en una recolección de elementos no utilizados se conocen como supervivientes y se promueven a la próxima generación:

  • Los objetos que sobreviven a una recolección de elementos no utilizados de la generación 0 se promueven a la generación 1.
  • Los objetos que sobreviven a una recolección de elementos no utilizados de la generación 1 se promueven a la generación 2.
  • Los objetos que sobreviven a una recolección de elementos no utilizados de la generación 2 permanecen en la generación 2.

Cuando el recolector de elementos no utilizados detecta que la tasa de supervivencia es alta en una generación, aumenta el umbral de asignaciones para esa generación. La colección siguiente obtiene un tamaño sustancial de memoria reclamada. El CLR equilibra continuamente dos prioridades: no permitir que el espacio de trabajo de una aplicación adquiera un tamaño excesivo al retrasar la recolección de elementos no utilizados y no permitir que la recolección de elementos no utilizados se ejecute con mucha frecuencia.

Generaciones y segmentos efímeros

Dado que los objetos de las generaciones 0 y 1 son de corta duración, estas generaciones se denominan generaciones efímeras.

Las generaciones efímeras se asignan en el segmento de memoria denominado segmento efímero. Cada nuevo segmento adquirido por el recolector de elementos no utilizados se convierte en el nuevo segmento efímero y contiene los objetos que sobrevivieron a una recolección de elementos no utilizados de la generación 0. El segmento efímero anterior se convierte en el nuevo segmento de la generación 2.

El tamaño del segmento efímero varía en función de si un sistema es de 32 o 64 bits y en el tipo de recolector de elementos no utilizados que se está ejecutando (estación de trabajo o GC del servidor). En la tabla siguiente se muestran los tamaños predeterminados del segmento efímero:

GC de servidor/estación de trabajo 32 bits 64 bits
Estación de trabajo de catálogo global 16 MB 256 MB
Servidor de catálogo global 64 MB 4 GB
Servidor de catálogo global con > 4 CPU lógicas 32 MB 2 GB
Servidor de catálogo global con > 8 CPU lógicas 16 MB 1 GB

El segmento efímero puede incluir objetos de la generación 2. Los objetos de generación 2 pueden usar varios segmentos tantos como el proceso requiera y la memoria permita.

La cantidad de memoria liberada como consecuencia de una recolección de elementos no utilizados efímera se limita al tamaño del segmento efímero. La cantidad de memoria que se libera es proporcional al espacio ocupado por los objetos muertos.

Lo que sucede durante la recolección de elementos no utilizados

Una recolección de elementos no utilizados tiene las siguientes fases:

  • Una fase de marcado que busca y crea una lista de todos los objetos activos.

  • Una fase de reubicación, que actualiza las referencias a los objetos que se van a compactar.

  • Una fase de compactación, que reclama el espacio ocupado por los objetos muertos y compacta los objetos supervivientes. La fase de compactación mueve objetos que han sobrevivido a una recolección de elementos no utilizados hacia el extremo más antiguo del segmento.

    Debido a que las recolecciones de la generación 2 pueden ocupar varios segmentos, los objetos que se promueven a la generación 2 se pueden mover a un segmento anterior. Los sobrevivientes de generación 1 y 2 se pueden mover a un segmento diferente porque se promueven a la generación 2.

    Normalmente, el montón de objetos grandes (LOH) no se compacta porque copiar objetos grandes impone una penalización de rendimiento. Sin embargo, en .NET Core y en .NET Framework 4.5.1 y versiones posteriores, se puede utilizar la propiedad GCSettings.LargeObjectHeapCompactionMode para compactar el montón de objetos grandes a petición. Además, el montón de objetos grandes se compacta automáticamente cuando se establece un límite máximo mediante la especificación de:

El recolector de elementos no utilizados utiliza la siguiente información para determinar si los objetos están activos:

  • Raíces de pila: variables de pila proporcionadas por el compilador Just-In-Time (JIT) y el caminador de pila. Las optimizaciones de JIT pueden prolongar o acortar regiones de código en las que se notifican variables de pila al recolector de elementos no utilizados.

  • Identificadores de recolección de elementos no utilizados: controla ese punto a objetos administrados y que se pueden asignar mediante código de usuario o Common Language Runtime.

  • Datos estáticos: objetos estáticos en dominios de aplicación que podrían hacer referencia a otros objetos. Cada dominio de aplicación realiza el seguimiento de sus objetos estáticos.

Antes de que iniciarse una recolección de elementos no utilizados, todos los subprocesos administrados se suspenden salvo el subproceso que activó la recolección de elementos no utilizados.

En la ilustración siguiente se muestra un subproceso que desencadena una recolección de elementos no utilizados y hace que se suspenda el resto de subprocesos:

Captura de pantalla de cómo un subproceso desencadena una recolección de elementos no utilizados.

Recursos no administrados

Para la mayoría de los objetos que crea la aplicación, puede confiar en la recolección de elementos no utilizados para realizar automáticamente las tareas de administración de memoria necesarias. Sin embargo, los recursos no administrados requieren una limpieza explícita. El tipo más habitual de recurso no administrado es un objeto que contiene un recurso del sistema operativo, como un identificador de archivo, identificador de ventana o conexión de red. Aunque el recolector de elementos no utilizados puede realizar un seguimiento de la duración de un objeto administrado que encapsula un recurso no administrado, no tiene conocimientos específicos sobre cómo limpiar el recurso.

Cuando se define un objeto que encapsula un recurso no administrado, es recomendable proporcionar el código necesario para limpiar ese recurso en un método público Dispose. Si se proporciona un método Dispose, se permite que los usuarios del objeto liberen el recurso de manera explícita cuando hayan terminado de usarlo. Si se usa un objeto que encapsula un recurso no administrado, asegúrese de llamar a Dispose cuando sea necesario.

También debe proporcionar una forma de liberar los recursos no administrados en el caso de que un consumidor de su tipo olvide llamar a Dispose. Puede usar un controlador seguro para encapsular el recurso no administrado o invalidar el método Object.Finalize().

Para obtener más información sobre la limpieza de recursos no administrados, vea Limpieza de recursos no administrados.

Vea también