Skip to main content

Memory, the Garbage Collector and IDisposable – C# | Lesson 55

Published / updated

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 use using so resources are released.
  • Explain finalizers and why Dispose is 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​

StackHeap
HoldsLocal variables of value types, and referencesObjects: class instances, arrays, strings
FreedAutomatically when the method returnsBy the garbage collector, when unused
SpeedVery fastFast to allocate; collection has a cost
SMS exampledecimal mark inside a methodA 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​

GenerationHoldsCollected
0New objectsVery often; most objects die young
1Objects that survived one collectionLess often
2Long-lived objects, and large objectsRarely; 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:

src/SchoolApp.Console/MarksExport.cs
// 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
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}");
}
Output
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​

  1. Not disposing files, streams, and connections. Data is lost, or resources run out. Fix: using every IDisposable.
  2. Subscribing to long-lived events without unsubscribing. Subscribers can never be collected. Fix: unsubscribe when done, or dispose the subscriber and unsubscribe in Dispose.
  3. Static collections that only grow. A static list or cache keeps every item forever. Fix: size limits and expiry (Module 12 caching).
  4. Calling GC.Collect() in production code. It usually makes performance worse. Fix: let the GC choose its timing.
  5. Many short-lived large arrays. They burden generation 2. Fix: reuse buffers, or stream.

In real projects​

From 16 years of .NET 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 IDisposable in using.
  • Implement IDisposable when your class owns a disposable resource.
  • Make Dispose safe 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.
  • IDisposable and using release 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:

Output
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-assignment/055-library-dispose.cs
// 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​

  1. When does the garbage collector free an object?

    1. When the method that created it returns
    2. When nothing references it any more and a collection runs
    3. When Dispose is called
    4. Never
    Show answer

    Answer: B. When nothing references it any more and a collection runs Unreachable objects are reclaimed during a collection.

  2. Which generation does a new small object start in?

    1. 0
    2. 1
    3. 2
    4. The large object heap
    Show answer

    Answer: A. 0 New objects start in generation 0; survivors move up.

  3. The export written without Dispose had 0 bytes. Why?

    1. Disk error
    2. The text stayed in the writer's buffer and was never flushed
    3. The GC deleted it
    4. The path was wrong
    Show answer

    Answer: B. The text stayed in the writer's buffer and was never flushed Dispose flushes the buffer and closes the file.

  4. Why was the subscribed dashboard not collected?

    1. It was in generation 2
    2. The invoice's event still referenced it through a delegate
    3. WeakReference kept it alive
    4. GC.Collect failed
    Show answer

    Answer: B. The invoice's event still referenced it through a delegate An event holds references to its subscribers.

  5. Why prefer Dispose over a finalizer?

    1. Finalizers do not exist
    2. Dispose releases resources at a known moment; finalizers run at an unknown time
    3. Finalizers are faster
    4. Dispose frees memory immediately
    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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.

See the example · Permalink

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.