Desde la primera línea de código de este curso, .NET ha gestionado automáticamente la memoria
de cada objeto creado con new: nunca has tenido que reservarla ni liberarla a mano, como sí
haría falta en lenguajes como C o C++. Esa comodidad tiene un mecanismo detrás, el recolector
de basura (Garbage Collector, GC), y unos límites claros: hay recursos —ficheros, conexiones
de red o de base de datos— que el GC no sabe liberar por sí solo, y que dependen del patrón
IDisposable/using ya visto en el Módulo 5. Esta lección explica cómo funciona el GC por
dentro, retoma IDisposable con su forma completa (el patrón Dispose/finalizador), y expone
una fuente de fugas de memoria muy real en C#: los eventos con suscriptores que nunca se
desuscriben.
Contenido
- Pila (stack) y montón (heap): dónde vive cada dato
- El recolector de basura: generaciones 0, 1 y 2
IDisposableyusing/using var: repaso desde la lección de E/S de Archivos- El patrón
Dispose/finalizador completo GC.Collect(): por qué casi nunca se debe llamar manualmente- Fugas de memoria comunes en C#: eventos no desuscritos
- Ejemplo:
IDisposablecompleto sobre una conexión SQLite de BiblioTech
- Pila (stack) y montón (heap): dónde vive cada dato
.NET reserva memoria en dos zonas con reglas de vida muy distintas:
| Pila (stack) | Montón (heap) | |
|---|---|---|
| Qué vive ahí | Variables locales de tipo valor (int, bool, struct...) y referencias a objetos |
Los objetos en sí, de cualquier class (Libro, Socio, List<T>...) |
| Cómo se libera | Automáticamente al salir del método o bloque que la declaró | El recolector de basura decide cuándo, según se explica en el apartado 2 |
| Velocidad de reserva/liberación | Muy rápida (basta con mover un puntero) | Más lenta, gestionada por el recolector de basura |
| Ejemplo en BiblioTech | La variable local Libro libro1 (la referencia) |
El objeto Libro en sí (el que libro1 referencia) |
Es importante distinguir la variable de lo que referencia: Libro libro1 = new Libro(...);
guarda en la pila una referencia (una dirección de memoria) que apunta al objeto Libro
real, creado en el montón por new. Cuando el método termina, esa referencia de la pila
desaparece de inmediato; el objeto Libro del montón, en cambio, sigue existiendo hasta que el
recolector de basura decide que ya no lo necesita nadie —tema del siguiente apartado. Los tipos
valor (struct, como los vistos en la lección de Structs y Records del Módulo 3) sí pueden
vivir enteramente en la pila cuando son variables locales, sin pasar por el montón en absoluto.
- El recolector de basura: generaciones 0, 1 y 2
El recolector de basura de .NET libera automáticamente los objetos del montón que ya no tienen
ninguna referencia accesible desde el programa —es decir, a los que ya no se puede llegar desde
ninguna variable en uso—. Para no tener que examinar todo el montón cada vez, organiza los
objetos en tres generaciones, basándose en una observación empírica: la mayoría de objetos
mueren jóvenes (variables temporales de un método), y los que sobreviven tienden a vivir mucho
tiempo (el catálogo completo de Biblioteca, por ejemplo):
| Generación | Qué contiene | Frecuencia de recolección |
|---|---|---|
| Generación 0 | Objetos recién creados (la mayoría de Prestamo, string temporales...) |
Muy frecuente y muy rápida |
| Generación 1 | Objetos que sobrevivieron a una recolección de la generación 0 | Intermedia |
| Generación 2 | Objetos de larga vida (el Catalogo de Biblioteca, mientras dure la aplicación) |
Poco frecuente, más costosa |
Cuando el GC recolecta la generación 0 y encuentra que un objeto sigue teniendo referencias activas, lo promociona a la generación 1; si sobrevive también a una recolección de la generación 1, pasa a la generación 2. Esta estrategia —revisar con frecuencia lo que probablemente ya murió, y con poca frecuencia lo que probablemente seguirá vivo mucho tiempo— es lo que permite que el recolector de basura de .NET sea, en la práctica, muy eficiente sin intervención manual del programador.
flowchart LR
A["new Prestamo(...)"] --> B["Generacion 0"]
B -->|Sobrevive a una recoleccion| C["Generacion 1"]
C -->|Sobrevive de nuevo| D["Generacion 2"]
B -->|Sin referencias activas| E["Memoria liberada"]
C -->|Sin referencias activas| E
D -->|Sin referencias activas| E
IDisposable y using/using var: repaso desde la lección de E/S de Archivos
IDisposable y using/using var: repaso desde la lección de E/S de ArchivosEl recolector de basura resuelve la memoria gestionada (los objetos .NET del montón), pero
hay recursos que .NET no puede liberar solo con recolectar memoria: un fichero abierto por el
sistema operativo, una conexión de red o de base de datos —recursos no gestionados—.
IDisposable, ya presentado en la lección de E/S de Archivos (Módulo 5), es el contrato que
esos tipos implementan para liberar ese recurso de forma determinista, es decir, en un
momento exacto y predecible, en vez de esperar a que el GC decida actuar (que podría tardar un
tiempo indeterminado, dejando el fichero o la conexión abiertos mientras tanto):
using SqliteConnection conexion = new SqliteConnection("Data Source=bibliotech.db");
conexion.Open();
// ... trabajo con la conexion ...
// conexion.Dispose() se llama automaticamente aqui, al final del bloque/metodoLa regla práctica fijada entonces sigue vigente sin cambios: cualquier objeto que implemente
IDisposable se declara con using (o using var), sin excepciones —StreamReader,
SqliteConnection, BibliotecaDbContext (Módulo 5) y, como se verá en el apartado 7, cualquier
clase propia que envuelva un recurso no gestionado.
- El patrón
Dispose/finalizador completo
Dispose/finalizador completoLa lección de Constructores y Destructores (Módulo 3) introdujo el finalizador (~Libro())
como un método que el GC invoca antes de liberar un objeto, y adelantó que hoy en día se prefiere
IDisposable por su liberación determinista. Ahora que se conocen ambos mecanismos con más
profundidad, se pueden combinar en el patrón completo que Microsoft recomienda para cualquier
clase que administre un recurso no gestionado directamente:
class RecursoConFinalizador : IDisposable
{
private bool _liberado = false;
// Punto de entrada publico: liberacion determinista, invocada explicitamente por quien usa la clase
public void Dispose()
{
Liberar(disposing: true);
GC.SuppressFinalize(this); // ya se libero explicitamente: el finalizador no debe repetir el trabajo
}
// Finalizador: red de seguridad, solo actua si Dispose() nunca se llamo
~RecursoConFinalizador()
{
Liberar(disposing: false);
}
protected virtual void Liberar(bool disposing)
{
if (_liberado)
{
return;
}
if (disposing)
{
// Liberar aqui recursos GESTIONADOS (otros objetos IDisposable que este objeto posea)
}
// Liberar aqui recursos NO GESTIONADOS (handles de fichero, conexiones, memoria nativa...)
_liberado = true;
}
}Cada pieza cumple un papel concreto:
| Miembro | Papel |
|---|---|
Dispose() público |
Camino normal: quien usa la clase con using lo invoca de forma determinista |
~RecursoConFinalizador() (finalizador) |
Red de seguridad: si alguien olvida el using, el GC llama al finalizador antes de liberar la memoria, evitando que el recurso quede abierto para siempre |
GC.SuppressFinalize(this) |
Le dice al GC "ya liberé el recurso manualmente, no hace falta que ejecutes también el finalizador" —evita liberar dos veces y acelera la recolección de este objeto |
_liberado (bandera) |
Evita liberar el mismo recurso dos veces, si Dispose() se llamara más de una vez por error |
Parámetro disposing |
Distingue si la llamada viene de Dispose() (true, seguro tocar otros objetos gestionados) o del finalizador (false, en ese momento el GC ya puede haber recolectado otros objetos, así que solo es seguro liberar recursos no gestionados directos) |
En la práctica, la inmensa mayoría de clases que implementan IDisposable en C# moderno no
necesitan un finalizador propio: basta con Dispose() cuando la clase solo posee otros objetos
IDisposable ya gestionados (como SqliteConnection), delegando en ellos la parte más
delicada. El finalizador completo solo es necesario cuando la clase gestiona directamente un
recurso no administrado por ningún otro objeto IDisposable intermedio —un caso menos habitual,
pero importante de reconocer si aparece en código existente.
GC.Collect(): por qué casi nunca se debe llamar manualmente
GC.Collect(): por qué casi nunca se debe llamar manualmente.NET expone GC.Collect(), que fuerza una recolección de basura inmediata. Es tentador pensar
que llamarlo "ayuda" al rendimiento, pero en la inmensa mayoría de casos consigue justo lo
contrario:
- El recolector de basura ya decide, con su propia heurística de generaciones, cuándo es el momento más eficiente de recolectar; forzarlo manualmente suele interrumpir esa heurística en un momento subóptimo.
- Una recolección completa (generación 2) es la más costosa de las tres; llamarla con frecuencia desde código de aplicación puede degradar el rendimiento en vez de mejorarlo.
GC.Collect()no libera recursos no gestionados (ficheros, conexiones): eso sigue siendo responsabilidad exclusiva deIDisposable/using, no del recolector de basura.
Los únicos escenarios donde GC.Collect() tiene una justificación real son muy específicos
(por ejemplo, inmediatamente después de liberar una cantidad enorme y puntual de memoria, en
una herramienta de diagnóstico, o en pruebas de rendimiento que miden el propio comportamiento
del GC) y quedan fuera del alcance de este curso. La regla general para BiblioTech y cualquier
aplicación normal: confía en el recolector de basura automático y concéntrate en liberar
correctamente, con using, los recursos no gestionados —eso sí está bajo tu control directo.
- Fugas de memoria comunes en C#: eventos no desuscritos
Aunque el GC libera automáticamente la memoria gestionada, en C# es perfectamente posible sufrir
una fuga de memoria (objetos que deberían poder liberarse pero que nunca se liberan): la
causa más común es una suscripción a un evento que nunca se desuscribe. La lección de
Delegados y Eventos (Módulo 4) definió Biblioteca.PrestamoRegistrado:
class PanelDeNotificaciones
{
public PanelDeNotificaciones(Biblioteca biblioteca)
{
biblioteca.PrestamoRegistrado += MostrarAviso; // se suscribe, pero nunca se desuscribe
}
private void MostrarAviso(Prestamo prestamo)
{
Console.WriteLine($"Nuevo prestamo: {prestamo.Libro.Titulo}");
}
}El problema: mientras biblioteca.PrestamoRegistrado mantenga esa suscripción
(biblioteca.PrestamoRegistrado += MostrarAviso), biblioteca conserva internamente una
referencia al objeto PanelDeNotificaciones suscrito —a través del delegado apuntando a
MostrarAviso—. Si el código de la aplicación descarta su propia referencia a un
PanelDeNotificaciones concreto (por ejemplo, al cerrar una ventana en una futura aplicación de
escritorio, Módulo 7) sin desuscribirlo primero, ese PanelDeNotificaciones sigue vivo
—inalcanzable para el resto del programa, pero referenciado por biblioteca—, y el recolector
de basura nunca podrá liberarlo mientras biblioteca siga viva. Si Biblioteca es una instancia
de larga vida (como suele serlo, durante toda la ejecución de la aplicación) y se crean muchos
PanelDeNotificaciones a lo largo del tiempo sin desuscribirlos nunca, la memoria ocupada por
paneles ya "descartados" crece sin límite: una fuga de memoria clásica en aplicaciones C# con
eventos, mucho más habitual de lo que parece a primera vista.
class PanelDeNotificaciones : IDisposable
{
private readonly Biblioteca _biblioteca;
public PanelDeNotificaciones(Biblioteca biblioteca)
{
_biblioteca = biblioteca;
_biblioteca.PrestamoRegistrado += MostrarAviso;
}
private void MostrarAviso(Prestamo prestamo)
{
Console.WriteLine($"Nuevo prestamo: {prestamo.Libro.Titulo}");
}
public void Dispose()
{
_biblioteca.PrestamoRegistrado -= MostrarAviso; // desuscripcion explicita: rompe la referencia
}
}Convertir PanelDeNotificaciones también en IDisposable, con -= en su Dispose(), resuelve
el problema: al desuscribirse explícitamente, biblioteca deja de mantener ninguna referencia a
ese panel concreto, y el GC puede liberarlo con normalidad en cuanto el resto del programa deje
de usarlo. La regla general: cualquier suscripción a un evento de larga vida debe tener su
-= correspondiente en algún punto del ciclo de vida del suscriptor, igual que todo Open()
necesita su Dispose().
- Ejemplo:
IDisposable completo sobre una conexión SQLite de BiblioTech
IDisposable completo sobre una conexión SQLite de BiblioTechUniendo el patrón del apartado 4 con SqliteConnection (Módulo 5), así queda una clase propia
de BiblioTech que envuelve la conexión y garantiza su liberación:
using Microsoft.Data.Sqlite;
class RepositorioSqlite : IDisposable
{
private readonly SqliteConnection _conexion;
private bool _liberado = false;
public RepositorioSqlite(string cadenaConexion)
{
_conexion = new SqliteConnection(cadenaConexion);
_conexion.Open();
}
public List<Libro> ObtenerLibrosDisponibles()
{
List<Libro> libros = new List<Libro>();
using SqliteCommand comando = _conexion.CreateCommand();
comando.CommandText = "SELECT Titulo, Autor, Isbn FROM Libros WHERE Disponible = 1";
using SqliteDataReader lector = comando.ExecuteReader();
while (lector.Read())
{
libros.Add(new Libro(lector.GetString(0), lector.GetString(1), lector.GetString(2)));
}
return libros;
}
public void Dispose()
{
Liberar(disposing: true);
GC.SuppressFinalize(this);
}
~RepositorioSqlite()
{
Liberar(disposing: false);
}
protected virtual void Liberar(bool disposing)
{
if (_liberado)
{
return;
}
if (disposing)
{
_conexion.Dispose(); // SqliteConnection es IDisposable: solo es seguro tocarla si disposing == true
}
_liberado = true;
}
}using RepositorioSqlite repositorio = new RepositorioSqlite("Data Source=bibliotech.db");
List<Libro> disponibles = repositorio.ObtenerLibrosDisponibles();
foreach (Libro libro in disponibles)
{
Console.WriteLine(libro.Titulo);
}
// repositorio.Dispose() se llama automaticamente aqui, cerrando _conexion a su vezRepositorioSqlite es, en sí mismo, un IDisposable que posee otro IDisposable
(_conexion): un caso donde, en la práctica, bastaría con Dispose() sin finalizador (porque
SqliteConnection ya tiene su propio finalizador de respaldo). Se muestra aquí el patrón
completo, con finalizador incluido, precisamente para dejar visible la estructura entera —la
misma que usan internamente clases del propio .NET como SqliteConnection— y que sepas
reconocerla si la encuentras en código de terceros.
Errores Comunes y Consejos
- Olvidar
usingsobre unIDisposablepropio o ajeno: sinusing, el recurso no gestionado (fichero, conexión) permanece abierto hasta que el finalizador se ejecute —en un momento indeterminado, decidido por el GC, no por el programador—, lo que puede agotar recursos del sistema operativo bajo carga. - Suscribirse a un evento de larga vida sin desuscribirse nunca: como se ha visto con
PrestamoRegistrado, es la causa más común de fugas de memoria en aplicaciones C#; toda suscripción+=de larga duración necesita su-=correspondiente. - Añadir un finalizador a una clase que no gestiona directamente ningún recurso no
gestionado: un finalizador tiene coste (el GC necesita al menos dos ciclos de recolección
para liberar del todo un objeto con finalizador); si la clase solo posee otros
IDisposableya gestionados, basta conDispose(), sin finalizador propio. - Llamar a
GC.Collect()pensando que "limpia" recursos no gestionados: no lo hace; solo actúa sobre memoria gestionada, y en la mayoría de aplicaciones normales empeora el rendimiento en vez de mejorarlo. - Consejo: para detectar fugas de memoria por eventos no desuscritos en una aplicación real, las herramientas de perfilado de memoria (memory profilers) permiten ver qué objetos siguen vivos y por qué cadena de referencias —a menudo, la respuesta es exactamente un delegado de evento olvidado.
Ejercicios
-
Explica, en un párrafo breve, la diferencia entre la pila y el montón, y en cuál de las dos zonas vive el objeto
Librocreado porLibro libro1 = new Libro("Rayuela", "Julio Cortazar", "978-84-376-0495-4");frente a la variablelibro1en sí. -
Escribe una clase
ConexionSimulada : IDisposablecon un campobool _abierta = truey un métodoDispose()que ponga_abiertaafalsee imprima"Conexion cerrada.". Úsala conusingdentro de un bloque y comprueba, tras el bloque, que la conexión ya no está abierta. -
Retomando
PanelDeNotificacionesdel apartado 6: escribe una versión de esa clase que implementeIDisposabley se desuscriba correctamente deBiblioteca.PrestamoRegistradoen suDispose(). Suscríbela, registra un préstamo conRegistrarPrestamo(...)(Módulo 4) para comprobar que recibe el aviso, llama aDispose(), y registra un segundo préstamo comprobando que ya no se muestra ningún aviso.
Soluciones
La pila almacena variables locales de vida corta, ligadas al método que las declara —incluida
la referencia libro1, que no es más que una dirección de memoria—; se libera
automáticamente al salir del método. El montón almacena los objetos en sí, creados con new
—aquí, el objeto Libro con sus propiedades Titulo, Autor, Isbn—; ese objeto vive en el
montón hasta que el recolector de basura determina que ya no hay ninguna referencia accesible
hacia él desde el programa.
class ConexionSimulada : IDisposable
{
private bool _abierta = true;
public bool EstaAbierta => _abierta;
public void Dispose()
{
_abierta = false;
Console.WriteLine("Conexion cerrada.");
}
}
ConexionSimulada? referenciaExterna;
using (ConexionSimulada conexion = new ConexionSimulada())
{
referenciaExterna = conexion;
Console.WriteLine(conexion.EstaAbierta); // True
} // Dispose() se llama aqui automaticamente
Console.WriteLine(referenciaExterna.EstaAbierta); // False
class PanelDeNotificaciones : IDisposable
{
private readonly Biblioteca _biblioteca;
public PanelDeNotificaciones(Biblioteca biblioteca)
{
_biblioteca = biblioteca;
_biblioteca.PrestamoRegistrado += MostrarAviso;
}
private void MostrarAviso(Prestamo prestamo)
{
Console.WriteLine($"Nuevo prestamo: {prestamo.Libro.Titulo}");
}
public void Dispose()
{
_biblioteca.PrestamoRegistrado -= MostrarAviso;
}
}
Biblioteca biblioteca = new Biblioteca();
Libro libro1 = new Libro("Rayuela", "Julio Cortazar", "978-84-376-0495-4");
Socio socio1 = new Socio(1, "Ana Martinez");
biblioteca.AgregarMaterial(libro1);
biblioteca.AgregarSocio(socio1);
PanelDeNotificaciones panel = new PanelDeNotificaciones(biblioteca);
libro1.Prestar();
biblioteca.RegistrarPrestamo(new Prestamo(libro1, socio1)); // el panel muestra el aviso
panel.Dispose(); // desuscripcion explicita
Libro libro2 = new Libro("Ficciones", "Jorge Luis Borges", "978-84-376-0496-1");
biblioteca.AgregarMaterial(libro2);
libro2.Prestar();
biblioteca.RegistrarPrestamo(new Prestamo(libro2, socio1)); // ya no se muestra ningun aviso
Conclusión
En esta lección has visto cómo gestiona .NET la memoria: la diferencia entre pila y montón, cómo
el recolector de basura organiza los objetos en generaciones para recolectar de forma eficiente,
y por qué casi nunca conviene llamar a GC.Collect() manualmente. También has completado el
patrón IDisposable/finalizador iniciado en el Módulo 3, aplicado sobre una conexión SQLite de
BiblioTech, y has visto una fuente muy real de fugas de memoria en C#: los eventos suscritos que
nunca se desuscriben, con PrestamoRegistrado como ejemplo concreto.
La última lección de este módulo, Multihilo y Programación Paralela, cambia de plano: en vez de
memoria, se ocupa de tiempo de CPU. Retoma la asincronía del Módulo 4 (async/await,
pensada para esperar E/S sin bloquear) y la contrasta con el paralelismo real —varios hilos de
CPU trabajando literalmente a la vez—, con Thread, Task.Run, Parallel.For/Parallel.ForEach
y las técnicas necesarias para proteger datos compartidos entre hilos, cerrando así el Módulo 6
antes de pasar, en el Módulo 7, a construir por fin una interfaz para BiblioTech.
Curso de Programación en C#
Módulo 1: Introducción a C#
- Introducción a C#
- Configuración del Entorno de Desarrollo
- Programa Hola Mundo
- Sintaxis y Estructura Básica
- Variables y Tipos de Datos
- Arrays y Cadenas de Texto
Módulo 2: Estructuras de Control
Módulo 3: Programación Orientada a Objetos
- Clases y Objetos
- Métodos
- Constructores y Destructores
- Herencia
- Polimorfismo
- Encapsulamiento
- Abstracción
- Structs y Records: Tipos por Valor y por Referencia
Módulo 4: Conceptos Avanzados de C#
- Interfaces
- Delegados y Eventos
- Pattern Matching y Características Modernas de C#
- Genéricos
- Colecciones
- LINQ (Consulta Integrada en el Lenguaje)
- Programación Asíncrona
Módulo 5: Trabajando con Datos
- Entrada/Salida de Archivos
- Serialización
- Conectividad con Bases de Datos
- Entity Framework
- Trabajo con JSON y Consumo de APIs REST
Módulo 6: Temas Avanzados
- Reflexión
- Atributos
- Programación Dinámica
- Gestión de Memoria y Recolección de Basura
- Multihilo y Programación Paralela
Módulo 7: Construcción de Aplicaciones
- Formularios de Windows
- WPF (Windows Presentation Foundation)
- ASP.NET Core
- Blazor
- Xamarin y .NET MAUI
Módulo 8: Mejores Prácticas y Patrones de Diseño
- Estándares de Codificación y Mejores Prácticas
- Patrones de Diseño
- Inyección de Dependencias e Inversión de Control
- Pruebas Unitarias
- Revisión y Refactorización de Código
