Memory, the Garbage Collector and IDisposable – C# | Lesson 55
Introduction
Every object in this course, from Student to the 12,012 marks, took memory, and you never once
freed it by hand. .NET's garbage collector (GC) finds objects that nothing uses any more and
reclaims their memory automatically.
Automatic does not mean careless. Some objects hold things the GC does not manage, such as open files and database connections, which must be released on time.
And an object that is still referenced, even by accident, is never collected. That is how memory leaks happen in .NET.
By the end of this lesson, you will be able to:
- Explain the stack, the heap, and what the garbage collector does.
- Describe GC generations and the large object heap.
- Implement
IDisposable, and useusingso resources are released. - Explain finalizers and why
Disposeis preferred. - Recognise and fix a memory leak caused by events.
Why this matters
A Web API runs for weeks without restarting. A leak of a few kilobytes per request becomes gigabytes over a term, until the server slows and finally crashes, usually during exam results, when traffic is highest.
Forgetting to dispose a database connection is worse: the connection pool runs out, and every new
request waits and then fails. In Module 9, every connection is opened inside a using, for exactly
the reason this lesson demonstrates.
The concept
Example: stack and heap
| Stack | Heap | |
|---|---|---|
| Holds | Local variables of value types, and references | Objects: class instances, arrays, strings |
| Freed | Automatically when the method returns | By the garbage collector, when unused |
| Speed | Very fast | Fast to allocate; collection has a cost |
| SMS example | decimal mark inside a method | A Student object, the list of marks |
A variable such as Student aarav lives on the stack and holds a reference; the student object
itself lives on the heap (Lesson 37). When nothing refers to an object any more, it becomes garbage.
Example: how the garbage collector works
From time to time, the GC pauses the program briefly, finds every object still reachable from running code, and frees the rest. It then moves the surviving objects together, so new objects can be placed quickly in one free block.
Think of the school's lost-and-found room. Now and then the caretaker clears out everything nobody has claimed. Items claimed often stay on the front shelf; long-unclaimed items go to the back storeroom, which is checked less often.
Example: generations
| Generation | Holds | Collected |
|---|---|---|
| 0 | New objects | Very often; most objects die young |
| 1 | Objects that survived one collection | Less often |
| 2 | Long-lived objects, and large objects | Rarely; the most expensive collection |
Most objects, such as the text built for one message, live for a moment. Checking young objects often and old ones rarely makes collection cheap. Objects of 85,000 bytes or more go straight to the large object heap, which is counted as generation 2.
Example: IDisposable
The GC manages memory, not files, network connections, or database connections. A class that holds
such a resource implements IDisposable, with one method, Dispose, that releases it.
using calls Dispose automatically at the end of a block, even when an exception is thrown. It
is the same as a try...finally that calls Dispose in the finally (Lesson 36), written in one
line.
Apply it to the SMS
Step 1: a disposable export
MarksExport owns a StreamWriter, so it implements IDisposable and closes the writer in
Dispose:
// Lesson 55: a class that owns a file, so it implements IDisposable and closes the file in Dispose.
class MarksExport : IDisposable
{
private readonly StreamWriter _writer;
private bool _disposed;
public int Rows { get; private set; }
public MarksExport(string path)
{
_writer = new StreamWriter(path);
_writer.WriteLine("AdmissionNumber,Exam,Subject,MarksObtained");
}
public void Write(MarkRecord mark)
{
ObjectDisposedException.ThrowIf(_disposed, this);
_writer.WriteLine($"{mark.AdmissionNumber},{mark.Exam},{mark.Subject},{mark.MarksObtained}");
Rows++;
}
public void Dispose()
{
if (_disposed)
{
return; // calling Dispose twice must be harmless
}
_writer.Dispose(); // flushes the buffer and closes the file
_disposed = true;
}
}
Two details matter. Calling Dispose twice must be harmless, hence the _disposed flag. Using the
object after disposal throws ObjectDisposedException, through
ObjectDisposedException.ThrowIf.
Step 2: the program
Show src/SchoolApp.Console/Program.cs
// Lesson 55: memory and the garbage collector.
using System.Runtime.CompilerServices;
using SchoolApp.Core.Entities;
// 1. Loading data takes memory on the heap; the garbage collector frees what is no longer used.
long before = GC.GetTotalMemory(forceFullCollection: true);
List<MarkRecord> marks = DataFiles.ReadMarks().ToList();
long after = GC.GetTotalMemory(forceFullCollection: true);
Console.WriteLine($"12,012 marks use about {(after - before) / 1024} KB of heap memory");
// 2. Generations: objects that survive collections move from generation 0 to 1 to 2.
Student aarav = new Student("ADM-2017-0001", "Aarav", "Sharma", new DateOnly(2011, 4, 12), SchoolApp.Core.Enums.Gender.Male);
Console.Write($"Aarav's object: generation {GC.GetGeneration(aarav)}");
GC.Collect();
Console.Write($", then {GC.GetGeneration(aarav)}");
GC.Collect();
Console.WriteLine($", then {GC.GetGeneration(aarav)}");
// 3. Large objects (85,000 bytes or more) start on the large object heap, counted as generation 2.
byte[] scannedReportCard = new byte[100_000];
Console.WriteLine($"A 100,000-byte array starts in generation {GC.GetGeneration(scannedReportCard)}");
// 4. IDisposable: without Dispose, buffered data never reaches the file.
string forgotten = Path.Combine(Path.GetTempPath(), "marks-forgotten.csv");
string disposed = Path.Combine(Path.GetTempPath(), "marks-disposed.csv");
WriteWithoutDispose(forgotten, marks);
using (MarksExport export = new MarksExport(disposed))
{
foreach (MarkRecord mark in marks.Take(100))
{
export.Write(mark);
}
} // Dispose runs here, even if an exception is thrown inside
Console.WriteLine($"Without Dispose: {new FileInfo(forgotten).Length} bytes; with using: {new FileInfo(disposed).Length} bytes, {File.ReadAllLines(disposed).Length} lines");
// 5. An event keeps its subscribers alive: a classic memory leak.
FeeInvoice invoice = new FeeInvoice("INV-2026-000002", 45500m);
WeakReference stillSubscribed = OpenDashboard(invoice, closeProperly: false);
WeakReference unsubscribed = OpenDashboard(invoice, closeProperly: true);
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
Console.WriteLine($"Dashboard left subscribed is still in memory: {stillSubscribed.IsAlive}; unsubscribed one: {unsubscribed.IsAlive}");
GC.KeepAlive(invoice);
// NoInlining keeps each dashboard local to its own method, so the demo behaves the same in Debug and Release.
[MethodImpl(MethodImplOptions.NoInlining)]
static WeakReference OpenDashboard(FeeInvoice invoice, bool closeProperly)
{
FeeDashboard dashboard = new FeeDashboard();
invoice.PaymentRecorded += dashboard.OnPayment;
if (closeProperly)
{
invoice.PaymentRecorded -= dashboard.OnPayment;
}
return new WeakReference(dashboard); // watches the object without keeping it alive
}
[MethodImpl(MethodImplOptions.NoInlining)]
static void WriteWithoutDispose(string path, List<MarkRecord> marks)
{
StreamWriter writer = new StreamWriter(path);
writer.WriteLine("AdmissionNumber,Exam,Subject,MarksObtained");
writer.WriteLine(marks[0].AdmissionNumber);
// no Dispose: the text stays in the writer's memory buffer
}
class FeeDashboard
{
public void OnPayment(object? sender, PaymentRecordedEventArgs e) => Console.WriteLine($"Dashboard: {e.Amount}");
}
12,012 marks use about 2818 KB of heap memory
Aarav's object: generation 0, then 1, then 2
A 100,000-byte array starts in generation 2
Without Dispose: 0 bytes; with using: 4505 bytes, 101 lines
Dashboard left subscribed is still in memory: True; unsubscribed one: False
The same output came from a Debug build and a Release build.
Example: measuring memory
GC.GetTotalMemory(true) before and after loading showed the 12,012 mark records using about 2.8
MB. That is about 240 bytes per mark: the record itself, its strings, and the list holding them.
Measurements like this guide decisions. Loading all marks of one class is fine; loading every mark of every class for every request is not, and Lesson 49's streaming becomes the better design.
Example: watching generations
Aarav's new Student object started in generation 0. It survived a collection, because the program
still used it, and moved to generation 1, then to generation 2 after the next one.
The 100,000-byte array, perhaps a scanned report card, started directly in generation 2: it is on the large object heap. Large objects are expensive to move, so the GC leaves them in place and collects them rarely. Creating many short-lived large arrays is therefore a common performance problem.
GC.Collect() forces a collection. It appears here only to make generations visible; in real code,
leave the timing to the garbage collector.
Example: the empty file
StreamWriter keeps text in a memory buffer and writes it to the disk in larger pieces. Without
Dispose, the buffer was never written: the file has 0 bytes, although the code wrote two lines
to it.
With using, Dispose ran at the closing brace, flushed the buffer, and closed the file: 101 lines,
4,505 bytes. No error, no warning in the forgotten version; just a missing export that a parent or
the board would discover later.
Example: a real memory leak
OpenDashboard creates a dashboard and subscribes it to the invoice's PaymentRecorded event
(Lesson 51). The method then returns, and nothing else refers to the dashboard.
Yet after a full collection, the dashboard left subscribed is still in memory. The invoice's event holds a delegate, and the delegate holds the dashboard. The dashboard that unsubscribed was collected.
A WeakReference watches an object without keeping it alive, which is how the demo checks
IsAlive. In a real system, the "invoice" might be a long-lived service, and every screen that
subscribed and never unsubscribed would stay in memory until the server restarts.
Example: finalizers
A finalizer, written ~ClassName(), runs when the GC collects an object. It was designed as a
last-chance cleanup for resources outside .NET.
Finalizers run at an unknown time, on a special thread, and they delay the object's collection.
Dispose releases resources at a known moment instead. Modern code almost never writes finalizers;
.NET classes such as SafeHandle already contain the ones you need.
Common mistakes
- Not disposing files, streams, and connections. Data is lost, or resources run out. Fix:
usingeveryIDisposable. - Subscribing to long-lived events without unsubscribing. Subscribers can never be collected.
Fix: unsubscribe when done, or dispose the subscriber and unsubscribe in
Dispose. - Static collections that only grow. A static list or cache keeps every item forever. Fix: size limits and expiry (Module 12 caching).
- Calling
GC.Collect()in production code. It usually makes performance worse. Fix: let the GC choose its timing. - Many short-lived large arrays. They burden generation 2. Fix: reuse buffers, or stream.
In real projects
A school API's memory grew by about 50 MB a day and needed a restart every weekend. Memory snapshots showed thousands of old report objects, all still referenced.
A singleton notification service had an event, and every report request subscribed a handler to it and never unsubscribed. Each request's whole report stayed reachable through that one event.
The fix was three lines: unsubscribe in Dispose. Since then, whenever I see += on an object that
lives longer than the subscriber, I look for the matching -=.
Best practices
- Wrap every
IDisposableinusing. - Implement
IDisposablewhen your class owns a disposable resource. - Make
Disposesafe to call twice, and refuse use after disposal. - Unsubscribe from events on objects that outlive the subscriber.
- Measure memory before optimising, and avoid
GC.Collect()outside experiments.
Recap
- Objects live on the heap; the garbage collector frees those nothing references.
- Generations 0, 1, and 2 let the GC check young objects often and old ones rarely.
- Objects of 85,000 bytes or more start on the large object heap, in generation 2.
IDisposableandusingrelease files and connections at a known moment.- Without
Dispose, the export file stayed empty: 0 bytes instead of 4,505. - An event subscription kept an otherwise unused object alive: a real leak.
Homework
Library module task: Write a LibraryResource class that prints "Open" when created and
"Close" in Dispose. Open a barcode scanner and then a receipt printer with using declarations
in one method, and watch the order in which they close. Then dispose a cash drawer twice.
A correct result looks like:
Open barcode scanner
Open receipt printer
Issuing The Jungle Book to Kabir Singh
Close receipt printer
Close barcode scanner
Open cash drawer
Close cash drawer
cash drawer already closed
Show the solution
// Library homework, Lesson 55: IDisposable, using declarations, and the order of Dispose.
// Run with: dotnet run library-assignment/055-library-dispose.cs
RunDesk();
static void RunDesk()
{
using LibraryResource scanner = new LibraryResource("barcode scanner");
using LibraryResource printer = new LibraryResource("receipt printer");
Console.WriteLine("Issuing The Jungle Book to Kabir Singh");
} // both are disposed here, in reverse order: last opened, first closed
LibraryResource drawer = new LibraryResource("cash drawer");
drawer.Dispose();
drawer.Dispose(); // a second Dispose must do nothing
class LibraryResource : IDisposable
{
private readonly string _name;
private bool _disposed;
public LibraryResource(string name)
{
_name = name;
Console.WriteLine($"Open {_name}");
}
public void Dispose()
{
if (_disposed)
{
Console.WriteLine($"{_name} already closed");
return;
}
_disposed = true;
Console.WriteLine($"Close {_name}");
}
}
using LibraryResource scanner = ...; without braces is a using declaration: the object is
disposed at the end of the enclosing block. Resources close in reverse order, last opened first
closed, so nothing is closed while something opened after it still depends on it.
Quiz
When does the garbage collector free an object?
Show answer
Answer: B. When nothing references it any more and a collection runs Unreachable objects are reclaimed during a collection.
Which generation does a new small object start in?
Show answer
Answer: A. 0 New objects start in generation 0; survivors move up.
The export written without Dispose had 0 bytes. Why?
Show answer
Answer: B. The text stayed in the writer's buffer and was never flushed Dispose flushes the buffer and closes the file.
Why was the subscribed dashboard not collected?
Show answer
Answer: B. The invoice's event still referenced it through a delegate An event holds references to its subscribers.
Why prefer Dispose over a finalizer?
Show answer
Answer: B. Dispose releases resources at a known moment; finalizers run at an unknown time Deterministic cleanup is what files and connections need.
Interview questions
Questions are grouped by experience level. Click a question to see a model answer. All questions: Advanced C# interview questions.
Fresher (0-2 yrs)
F What is the difference between the stack and the heap? concept
The stack holds method-local value-type variables and references, freed automatically when a method returns. The heap holds objects such as class instances, arrays, and strings, freed by the garbage collector when nothing references them.
F What does the garbage collector do? concept
It periodically finds objects that running code can no longer reach and reclaims their memory, then compacts the surviving objects. Developers do not free managed memory by hand.
F What is IDisposable, and what does using do? concept
IDisposable has one method, Dispose, which releases resources the GC does not manage, such as
files and connections. using calls Dispose automatically at the end of a block, even if an
exception is thrown.
Mid (3-5 yrs)
M What are GC generations? concept
Generation 0 holds new objects and is collected often; survivors move to generation 1, then to generation 2, which is collected rarely. Most objects die young, so this makes collection cheap. Objects of 85,000 bytes or more start on the large object heap, counted as generation 2.
M What is the difference between Dispose and a finalizer? concept
Dispose releases resources at a known moment, called by your code or using. A finalizer runs
when the GC collects the object, at an unknown time, on a special thread, and delays collection.
Prefer Dispose; modern code rarely writes finalizers because SafeHandle covers them.
M How can events cause memory leaks? concept
An event holds delegates, and each delegate references its subscriber. If a long-lived object's
event keeps a short-lived subscriber, the subscriber cannot be collected until it unsubscribes.
Unsubscribe with -=, often in the subscriber's Dispose.
Senior (6-9 yrs)
S An API's memory grows every day until it needs a restart. How do you investigate? scenario
Watch memory and GC counters over time, take memory snapshots at two points, and compare which object types grow and what keeps them alive. Typical culprits are event subscriptions on long-lived services, static collections, unbounded caches, and undisposed resources.
How to answer: Describe a measured approach (dotnet-counters, dumps or Visual Studio snapshots, retention paths), the common causes, and verifying the fix under load.
S Should you call GC.Collect() in application code? concept
Almost never. The GC tunes its own timing, and forcing collections usually costs performance by promoting objects and pausing the program. It is useful in experiments and tests that measure memory, as in this lesson.
How to answer: Explain the cost, the rare legitimate cases, and what to do instead: fix the cause of memory growth.
Architect / Lead (10+ yrs)
A How do you design a high-traffic .NET service to be memory-efficient? design
Stream large data instead of loading it, avoid many short-lived large objects, reuse buffers (pooling), bound every cache, dispose resources promptly, avoid long-lived references to per-request objects, and monitor GC metrics in production.
How to answer: Cover allocation patterns, lifetimes, pooling, caching limits, and observability, with an example such as exporting attendance for a whole school.
Resources
- Code for this lesson: git tag
lesson-55-memory-gc(code repository coming soon) - Files:
src/SchoolApp.Console/MarksExport.cs,src/SchoolApp.Console/Program.cs,library-assignment/055-library-dispose.cs - Video: coming soon
What's next
Module 8 ends with names for patterns you have already been using: the Strategy behind grade rules, the Observer behind events, and more. Lesson 56: Design patterns
Want live sessions, code reviews, and interview preparation? Join the next batch at Sahasra Technologies.