Since the very first line of code in this course, .NET has automatically managed the memory of
every object created with new: you've never had to allocate or free it by hand, as you would in
languages like C or C++. That convenience has a mechanism behind it, the garbage collector
(GC), and some clear limits: there are resources — files, network or database connections — that
the GC does not know how to free on its own, and that depend on the IDisposable/using
pattern already seen in Module 5. This lesson explains how the GC works internally, revisits
IDisposable in its complete form (the Dispose/finalizer pattern), and exposes a very real
source of memory leaks in C#: events with subscribers that never unsubscribe.
Contents
- Stack and heap: where each piece of data lives
- The garbage collector: generations 0, 1, and 2
IDisposableandusing/using var: a recap from the File I/O lesson- The complete
Dispose/finalizer pattern GC.Collect(): why you should almost never call it manually- Common memory leaks in C#: unsubscribed events
- Example: a complete
IDisposableover a BiblioTech SQLite connection
- Stack and heap: where each piece of data lives
.NET allocates memory in two areas with very different lifetime rules:
| Stack | Heap | |
|---|---|---|
| What lives there | Local value-type variables (int, bool, struct...) and references to objects |
The objects themselves, of any class (Book, Member, List<T>...) |
| How it's freed | Automatically, on leaving the method or block that declared it | The garbage collector decides when, as explained in section 2 |
| Allocation/deallocation speed | Very fast (just moving a pointer) | Slower, managed by the garbage collector |
| Example in BiblioTech | The local variable Book book1 (the reference) |
The Book object itself (the one book1 references) |
It's important to distinguish the variable from what it references: Book book1 = new Book(...);
stores in the stack a reference (a memory address) pointing to the real Book object, created
on the heap by new. When the method ends, that stack reference disappears immediately; the
Book object on the heap, by contrast, keeps existing until the garbage collector decides nobody
needs it anymore — the topic of the next section. Value types (struct, as seen in the Structs
and Records lesson, Module 3) can live entirely on the stack when they're local variables, never
touching the heap at all.
- The garbage collector: generations 0, 1, and 2
.NET's garbage collector automatically frees heap objects that no longer have any reachable
reference from the program — that is, ones no longer reachable from any variable in use. To avoid
examining the whole heap every time, it organizes objects into three generations, based on an
empirical observation: most objects die young (temporary variables inside a method), and the ones
that survive tend to live for a long time (the entire Catalog of Library, for example):
| Generation | What it contains | Collection frequency |
|---|---|---|
| Generation 0 | Newly created objects (most Loans, temporary strings...) |
Very frequent and very fast |
| Generation 1 | Objects that survived a generation 0 collection | Intermediate |
| Generation 2 | Long-lived objects (Library's Catalog, for as long as the application runs) |
Infrequent, more costly |
When the GC collects generation 0 and finds that an object still has active references, it promotes it to generation 1; if it also survives a generation 1 collection, it moves up to generation 2. This strategy — checking frequently what's probably already dead, and rarely what's probably going to stay alive for a long time — is what lets .NET's garbage collector be, in practice, very efficient with no manual intervention from the programmer.
flowchart LR
A["new Loan(...)"] --> B["Generation 0"]
B -->|Survives a collection| C["Generation 1"]
C -->|Survives again| D["Generation 2"]
B -->|No active references| E["Memory freed"]
C -->|No active references| E
D -->|No active references| E
IDisposable and using/using var: a recap from the File I/O lesson
IDisposable and using/using var: a recap from the File I/O lessonThe garbage collector solves for managed memory (.NET objects on the heap), but there are
resources .NET can't free just by collecting memory: a file opened by the operating system, a
network or database connection — unmanaged resources. IDisposable, already introduced in
the File I/O lesson (Module 5), is the contract those types implement to release that resource
deterministically — that is, at an exact, predictable moment, instead of waiting for the GC to
decide to act (which could take an indeterminate amount of time, leaving the file or connection
open in the meantime):
using SqliteConnection connection = new SqliteConnection("Data Source=bibliotech.db");
connection.Open();
// ... work with the connection ...
// connection.Dispose() gets called automatically here, at the end of the block/methodThe practical rule set back then still stands unchanged: any object that implements
IDisposable gets declared with using (or using var), no exceptions — StreamReader,
SqliteConnection, LibraryDbContext (Module 5), and, as you'll see in section 7, any custom
class that wraps an unmanaged resource.
- The complete
Dispose/finalizer pattern
Dispose/finalizer patternThe Constructors and Destructors lesson (Module 3) introduced the finalizer (~Book()) as a
method the GC invokes before freeing an object, and noted that IDisposable is nowadays preferred
for its deterministic release. Now that both mechanisms are known in more depth, they can be
combined into the complete pattern Microsoft recommends for any class that directly manages an
unmanaged resource:
class ResourceWithFinalizer : IDisposable
{
private bool _released = false;
// Public entry point: deterministic release, invoked explicitly by whoever uses the class
public void Dispose()
{
Release(disposing: true);
GC.SuppressFinalize(this); // already released explicitly: the finalizer shouldn't repeat the work
}
// Finalizer: safety net, only acts if Dispose() was never called
~ResourceWithFinalizer()
{
Release(disposing: false);
}
protected virtual void Release(bool disposing)
{
if (_released)
{
return;
}
if (disposing)
{
// Release MANAGED resources here (other IDisposable objects this object owns)
}
// Release UNMANAGED resources here (file handles, connections, native memory...)
_released = true;
}
}Each piece plays a specific role:
| Member | Role |
|---|---|
Public Dispose() |
Normal path: whoever uses the class with using invokes it deterministically |
~ResourceWithFinalizer() (finalizer) |
Safety net: if someone forgets the using, the GC calls the finalizer before freeing the memory, keeping the resource from staying open forever |
GC.SuppressFinalize(this) |
Tells the GC "I already released the resource manually, no need to also run the finalizer" — avoids releasing it twice and speeds up collecting this object |
_released (flag) |
Prevents releasing the same resource twice, if Dispose() were called more than once by mistake |
disposing parameter |
Distinguishes whether the call comes from Dispose() (true, safe to touch other managed objects) or from the finalizer (false, at that point the GC may have already collected other objects, so it's only safe to release direct unmanaged resources) |
In practice, the vast majority of classes implementing IDisposable in modern C# don't need
their own finalizer: Dispose() alone is enough when the class only owns other, already-managed
IDisposable objects (like SqliteConnection), delegating the trickiest part to them. The full
finalizer is only needed when the class directly manages an unmanaged resource with no
intermediate IDisposable object handling it — a less common case, but important to recognize if
it turns up in existing code.
GC.Collect(): why you should almost never call it manually
GC.Collect(): why you should almost never call it manually.NET exposes GC.Collect(), which forces an immediate garbage collection. It's tempting to think
that calling it "helps" performance, but in the vast majority of cases it achieves just the
opposite:
- The garbage collector already decides, with its own generational heuristics, when is the most efficient time to collect; forcing it manually usually interrupts that heuristic at a suboptimal moment.
- A full collection (generation 2) is the most expensive of the three; calling it frequently from application code can degrade performance instead of improving it.
GC.Collect()doesn't free unmanaged resources (files, connections): that remains the exclusive responsibility ofIDisposable/using, not of the garbage collector.
The only scenarios where GC.Collect() has a real justification are very specific (for instance,
immediately after freeing a huge, one-off amount of memory, in a diagnostic tool, or in
performance tests that measure the GC's own behavior) and fall outside this course's scope. The
general rule for BiblioTech and any normal application: trust the automatic garbage collector
and focus on correctly releasing, with using, the unmanaged resources — that's what's actually
under your direct control.
- Common memory leaks in C#: unsubscribed events
Even though the GC automatically frees managed memory, it's entirely possible to suffer a
memory leak in C# (objects that should be freeable but never get freed): the most common cause
is a subscription to an event that never gets unsubscribed. The Delegates and Events lesson
(Module 4) defined Library.LoanRegistered:
class NotificationPanel
{
public NotificationPanel(Library library)
{
library.LoanRegistered += ShowAlert; // subscribes, but never unsubscribes
}
private void ShowAlert(Loan loan)
{
Console.WriteLine($"New loan: {loan.Book.Title}");
}
}The problem: as long as library.LoanRegistered keeps that subscription
(library.LoanRegistered += ShowAlert), library internally holds a reference to the subscribed
NotificationPanel object — through the delegate pointing to ShowAlert. If the application code
discards its own reference to a particular NotificationPanel (for example, on closing a window
in a future desktop application, Module 7) without unsubscribing it first, that
NotificationPanel stays alive — unreachable from the rest of the program, but still referenced
by library — and the garbage collector can never free it as long as library stays alive. If
Library is a long-lived instance (as it usually is, for the entire run of the application) and
many NotificationPanels get created over time without ever unsubscribing them, the memory taken
up by already "discarded" panels grows without bound: a classic memory leak in C# applications
with events, much more common than it might seem at first glance.
class NotificationPanel : IDisposable
{
private readonly Library _library;
public NotificationPanel(Library library)
{
_library = library;
_library.LoanRegistered += ShowAlert;
}
private void ShowAlert(Loan loan)
{
Console.WriteLine($"New loan: {loan.Book.Title}");
}
public void Dispose()
{
_library.LoanRegistered -= ShowAlert; // explicit unsubscription: breaks the reference
}
}Turning NotificationPanel into an IDisposable too, with -= in its Dispose(), solves the
problem: by unsubscribing explicitly, library stops holding any reference to that particular
panel, and the GC can free it normally as soon as the rest of the program stops using it. The
general rule: any subscription to a long-lived event must have its matching -= somewhere in
the subscriber's lifecycle, just as every Open() needs its Dispose().
- Example: a complete
IDisposable over a BiblioTech SQLite connection
IDisposable over a BiblioTech SQLite connectionCombining the pattern from section 4 with SqliteConnection (Module 5), here's a custom
BiblioTech class that wraps the connection and guarantees its release:
using Microsoft.Data.Sqlite;
class SqliteRepository : IDisposable
{
private readonly SqliteConnection _connection;
private bool _released = false;
public SqliteRepository(string connectionString)
{
_connection = new SqliteConnection(connectionString);
_connection.Open();
}
public List<Book> GetAvailableBooks()
{
List<Book> books = new List<Book>();
using SqliteCommand command = _connection.CreateCommand();
command.CommandText = "SELECT Title, Author, Isbn FROM Books WHERE Available = 1";
using SqliteDataReader reader = command.ExecuteReader();
while (reader.Read())
{
books.Add(new Book(reader.GetString(0), reader.GetString(1), reader.GetString(2)));
}
return books;
}
public void Dispose()
{
Release(disposing: true);
GC.SuppressFinalize(this);
}
~SqliteRepository()
{
Release(disposing: false);
}
protected virtual void Release(bool disposing)
{
if (_released)
{
return;
}
if (disposing)
{
_connection.Dispose(); // SqliteConnection is IDisposable: only safe to touch if disposing == true
}
_released = true;
}
}using SqliteRepository repository = new SqliteRepository("Data Source=bibliotech.db");
List<Book> available = repository.GetAvailableBooks();
foreach (Book book in available)
{
Console.WriteLine(book.Title);
}
// repository.Dispose() gets called automatically here, closing _connection in turnSqliteRepository is itself an IDisposable that owns another IDisposable (_connection):
a case where, in practice, Dispose() alone would be enough (because SqliteConnection already
has its own backup finalizer). The complete pattern is shown here, finalizer included, precisely
to keep the full structure visible — the same one used internally by .NET's own classes like
SqliteConnection — so you can recognize it if you find it in third-party code.
Common Mistakes and Tips
- Forgetting
usingon a custom or third-partyIDisposable: withoutusing, the unmanaged resource (file, connection) stays open until the finalizer runs — at an indeterminate moment, decided by the GC, not by the programmer — which can exhaust operating system resources under load. - Subscribing to a long-lived event and never unsubscribing: as seen with
LoanRegistered, this is the most common cause of memory leaks in C# applications; every long-lived+=subscription needs its matching-=. - Adding a finalizer to a class that doesn't directly manage any unmanaged resource: a
finalizer has a cost (the GC needs at least two collection cycles to fully free an object with a
finalizer); if the class only owns other, already-managed
IDisposableobjects,Dispose()alone is enough, with no finalizer of its own. - Calling
GC.Collect()thinking it "cleans up" unmanaged resources: it doesn't; it only acts on managed memory, and in most normal applications it worsens performance instead of improving it. - Tip: to detect memory leaks caused by unsubscribed events in a real application, memory profiling tools let you see which objects are still alive and through what chain of references — often, the answer is exactly a forgotten event delegate.
Exercises
-
Explain, in a short paragraph, the difference between the stack and the heap, and in which of the two areas the
Bookobject created byBook book1 = new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4");lives, versus thebook1variable itself. -
Write a class
SimulatedConnection : IDisposablewith a fieldbool _open = trueand aDispose()method that sets_opentofalseand prints"Connection closed.". Use it withusinginside a block and check, after the block, that the connection is no longer open. -
Revisiting
NotificationPanelfrom section 6: write a version of that class that implementsIDisposableand correctly unsubscribes fromLibrary.LoanRegisteredin itsDispose(). Subscribe it, register a loan withRegisterLoan(...)(Module 4) to check that it receives the alert, callDispose(), and register a second loan checking that no alert is shown anymore.
Solutions
The stack stores short-lived local variables, tied to the method that declares them — including
the reference book1, which is nothing more than a memory address — and it's freed
automatically on leaving the method. The heap stores the objects themselves, created with new
— here, the Book object with its Title, Author, Isbn properties — and that object lives
on the heap until the garbage collector determines there's no longer any reachable reference to
it from the program.
class SimulatedConnection : IDisposable
{
private bool _open = true;
public bool IsOpen => _open;
public void Dispose()
{
_open = false;
Console.WriteLine("Connection closed.");
}
}
SimulatedConnection? externalReference;
using (SimulatedConnection connection = new SimulatedConnection())
{
externalReference = connection;
Console.WriteLine(connection.IsOpen); // True
} // Dispose() is called here automatically
Console.WriteLine(externalReference.IsOpen); // False
class NotificationPanel : IDisposable
{
private readonly Library _library;
public NotificationPanel(Library library)
{
_library = library;
_library.LoanRegistered += ShowAlert;
}
private void ShowAlert(Loan loan)
{
Console.WriteLine($"New loan: {loan.Book.Title}");
}
public void Dispose()
{
_library.LoanRegistered -= ShowAlert;
}
}
Library library = new Library();
Book book1 = new Book("Hopscotch", "Julio Cortazar", "978-84-376-0495-4");
Member member1 = new Member(1, "Ana Martinez");
library.AddItem(book1);
library.AddMember(member1);
NotificationPanel panel = new NotificationPanel(library);
book1.Lend();
library.RegisterLoan(new Loan(book1, member1)); // the panel shows the alert
panel.Dispose(); // explicit unsubscription
Book book2 = new Book("Ficciones", "Jorge Luis Borges", "978-84-376-0496-1");
library.AddItem(book2);
book2.Lend();
library.RegisterLoan(new Loan(book2, member1)); // no alert is shown anymore
Conclusion
In this lesson you've seen how .NET manages memory: the difference between the stack and the heap,
how the garbage collector organizes objects into generations to collect efficiently, and why it's
almost never a good idea to call GC.Collect() manually. You've also completed the
IDisposable/finalizer pattern started in Module 3, applied over a BiblioTech SQLite connection,
and seen a very real source of memory leaks in C#: subscribed events that never get unsubscribed,
with LoanRegistered as a concrete example.
The last lesson of this module, Multithreading and Parallel Programming, shifts focus: instead of
memory, it deals with CPU time. It picks back up asynchrony from Module 4 (async/await,
meant for waiting on I/O without blocking) and contrasts it with real parallelism — several CPU
threads working literally at the same time — with Thread, Task.Run, Parallel.For/
Parallel.ForEach, and the techniques needed to protect shared data across threads, closing out
Module 6 before moving on, in Module 7, to finally build an interface for BiblioTech.
C# Programming Course
Module 1: Introduction to C#
- Introduction to C#
- Setting Up the Development Environment
- Hello World Program
- Basic Syntax and Structure
- Variables and Data Types
- Arrays and Strings
Module 2: Control Structures
Module 3: Object-Oriented Programming
- Classes and Objects
- Methods
- Constructors and Destructors
- Inheritance
- Polymorphism
- Encapsulation
- Abstraction
- Structs and Records: Value Types and Reference Types
Module 4: Advanced C# Concepts
- Interfaces
- Delegates and Events
- Pattern Matching and Modern C# Features
- Generics
- Collections
- LINQ (Language Integrated Query)
- Asynchronous Programming
Module 5: Working with Data
- File I/O
- Serialization
- Database Connectivity
- Entity Framework
- Working with JSON and Consuming REST APIs
Module 6: Advanced Topics
- Reflection
- Attributes
- Dynamic Programming
- Memory Management and Garbage Collection
- Multithreading and Parallel Programming
Module 7: Building Applications
Module 8: Best Practices and Design Patterns
- Coding Standards and Best Practices
- Design Patterns
- Dependency Injection and Inversion of Control
- Unit Testing
- Code Review and Refactoring
