Memory Management & GC Internals
Reviewed & published by Brayan K
By the end of this lesson you'll understand where your data actually lives — stack vs heap — how .NET's garbage collector reclaims it across generations, and how to clean up resources on time with IDisposable and using instead of leaving leaks behind.
Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
What You'll Learn
- Tell value types from reference types, and stack from heap
- Explain how the generational GC (Gen 0 / 1 / 2) reclaims memory
- Know what the Large Object Heap is and why it matters
- Release resources on time with IDisposable and using blocks
- Choose IDisposable over finalizers for deterministic cleanup
- Spot and fix managed leaks from event handlers and static refs
💡 Real-World Analogy
Think of the garbage collector as an automatic tidy-up crew for a busy office. Your desk is Gen 0 — the crew sweeps it constantly, because most things on a desk (a coffee cup, a sticky note) are short-lived. Items that have hung around get filed into the Gen 1 cabinet, and anything that survives even longer moves to the Gen 2 archive room, which is cleaned out rarely because reorganising it is expensive. Oversized items — a filing cabinet itself — go straight to the warehouse (the Large Object Heap), which the crew almost never rearranges. The crew is brilliant, but it only collects what nobody is still holding on to. If you keep a string tied to an old box (an undetached event handler, a static list), the crew leaves it exactly where it is — that's a memory leak. And for things the crew can't see (a running tap, an open door — files, sockets), you have to close them yourself with IDisposable.
How .NET Manages Memory
In C# you almost never call free or delete. The runtime tracks every object you allocate, and the garbage collector (GC) automatically reclaims any object that your code can no longer reach. "Reachable" means there's still a path of references to it from a live variable, field, or static — once that path is gone, the object is eligible for collection.
The GC is fast precisely because of a key observation: most objects die young. So instead of scanning the whole heap every time, it sorts objects into generations and collects the youngest ones far more often. This lesson walks through where objects live, how generations work, and where automatic management stops — the resources you must release yourself.
- 🧠 Stack vs heap — value types vs reference types (this lesson)
- ♻️ Generational GC — Gen 0, Gen 1, Gen 2, and the LOH
- 🧹 Deterministic cleanup — IDisposable and using beat finalizers
- 🚱 Avoiding leaks — detach events, bound your static collections
📊 GC Generations & the Large Object Heap
| Region | Contains | Collected | Cost |
|---|---|---|---|
| Gen 0 | Newly allocated objects | Very frequently | Cheap & fast |
| Gen 1 | Survived one Gen 0 collection | Less often | Moderate |
| Gen 2 | Long-lived objects | Rarely | Expensive (scans heap) |
| LOH | Objects ≥ 85,000 bytes | With Gen 2 | Expensive; not compacted by default |
The big takeaway: keep most objects small and short-lived so they die in cheap Gen 0 collections. Long-lived and large objects push work into expensive Gen 2 / LOH collections.
1. Stack vs Heap, Value vs Reference
Every variable is one of two kinds. A value type (int, double, bool, a struct) stores its data directly; for a local variable that lives on the stack — a fast, self-cleaning region that unwinds automatically when a method returns. A reference type (class, array, string) stores a reference — an address — pointing at the real object, which lives on the heap and is what the GC manages. The practical consequence: copying a value type copies the data, but copying a reference type copies only the arrow, so two variables can end up changing one shared object. Read this worked example and run it.
using System;
class Program
{
// A class is a REFERENCE type — its objects live on the heap.
class Box { public int Value; }
static void Main()
{
// VALUE types (int, double, bool, struct) hold their data DIRECTLY.
// For a local like this, the value sits on the STACK — fast, automatic.
int a = 10;
int b = a; // b gets a COPY of a's value
b = 99; // changing b does NOT touch a
Console.WriteLine($"a = {a}, b = {b}"); // a = 10, b = 99
// REFERENCE types (class, array, string) hold a REFERENCE (an address).
// The object itself lives on the HEAP; the variable just points at it.
Box box1 = new Box { Value = 10 }; // object allocated on the heap
Box box2 = box1; // box2 copies the REFERENCE, not the object
box2.Value = 99; // both point at the SAME object
Console.WriteLine($"box1 = {box1.Value}, box2 = {box2.Value}"); // box1 = 99, box2 = 99
// This is THE classic gotcha: copying a value type copies the data;
// copying a reference type copies only the arrow to one shared object.
}
}
// ✅ Expected output:
// a = 10, b = 99
// box1 = 99, box2 = 992. How the Generational GC Works
The GC reclaims unreachable objects in three phases — mark (find everything still reachable), sweep (free the rest), and compact (shuffle survivors together to remove gaps). To avoid scanning the whole heap each time, it groups objects into generations: new objects start in Gen 0, and anything that survives a collection is promoted to Gen 1, then Gen 2. Because Gen 0 is small and collected constantly, short-lived objects are cleaned up almost for free. This example forces collections so you can watch an object get promoted.
using System;
class Program
{
static void Main()
{
// The GC sorts objects into 3 GENERATIONS by how long they survive.
// Gen 0 = brand new, Gen 1 = survived once, Gen 2 = long-lived.
var obj = new byte[1000]; // a fresh object starts in Gen 0
Console.WriteLine($"Just allocated: Gen {GC.GetGeneration(obj)}"); // Gen 0
// A Gen 0 collection promotes survivors to Gen 1.
GC.Collect(0);
Console.WriteLine($"After Gen 0 GC: Gen {GC.GetGeneration(obj)}"); // Gen 1
// A Gen 1 collection promotes survivors to Gen 2 (the oldest).
GC.Collect(1);
Console.WriteLine($"After Gen 1 GC: Gen {GC.GetGeneration(obj)}"); // Gen 2
// CollectionCount tells you how many times each generation was collected.
Console.WriteLine($"Gen 0 collections: {GC.CollectionCount(0)}");
Console.WriteLine($"Gen 2 collections: {GC.CollectionCount(2)}");
// KEY IDEA: Gen 0 is collected often and cheaply; Gen 2 rarely and
// expensively. Short-lived objects are the GC's happy path.
}
}
// ✅ Expected output:
// Just allocated: Gen 0
// After Gen 0 GC: Gen 1
// After Gen 1 GC: Gen 2
// Gen 0 collections: 2
// Gen 2 collections: 0Your turn. GC.GetTotalMemory(false) reports how many bytes the GC is currently tracking, so reading it before and after an allocation shows the cost. Fill in the two ___ blanks and run it.
using System;
class Program
{
static void Main()
{
// 🎯 YOUR TURN — measure how much memory a big array costs.
// GC.GetTotalMemory(false) returns the bytes the GC currently tracks.
// 1) Read the memory BEFORE allocating anything.
long before = GC.GetTotalMemory(false); // 👉 already done for you
Console.WriteLine($"Before: {before / 1024} KB");
// 2) Allocate an array of 1,000,000 ints (about 4 MB).
int[] big = new int[___]; // 👉 replace ___ with 1000000
// 3) Touch the array so the compiler cannot optimise it away.
big[0] = 1;
// 4) Read the memory AFTER the allocation.
long after = GC.GetTotalMemory(___); // 👉 replace ___ with false
Console.WriteLine($"After: {after / 1024} KB");
Console.WriteLine($"Grew by ~{(after - before) / 1024} KB");
// ✅ Expected output (numbers will vary, but it grows by ~4000 KB):
// Before: 100 KB
// After: 4100 KB
// Grew by ~4000 KB
}
}3. The Large Object Heap (LOH)
Any object of 85,000 bytes or more — typically a big array or buffer — is allocated on the Large Object Heap rather than the normal (small object) heap. The LOH is collected only during a Gen 2 collection and, by default, is not compacted, so allocating and freeing big arrays repeatedly leaves gaps that fragment memory. The lesson here is to reuse large buffers instead of churning new ones. Run this to see a large array report as Gen 2.
using System;
class Program
{
static void Main()
{
// Objects of 85,000 bytes OR MORE go on the LARGE OBJECT HEAP (LOH).
// The LOH is collected only with Gen 2 and is NOT compacted by default,
// so it can fragment — leave gaps that waste memory.
// A small array stays on the normal heap, starting in Gen 0.
var small = new byte[1000];
Console.WriteLine($"Small array (1 KB): Gen {GC.GetGeneration(small)}"); // Gen 0
// A large array (>= 85 KB) lands on the LOH, which reports as Gen 2.
var large = new byte[100_000];
Console.WriteLine($"Large array (100 KB): Gen {GC.GetGeneration(large)}"); // Gen 2
// WHY YOU CARE: churning large arrays triggers costly Gen 2 collections
// and fragments the LOH. The fix is to REUSE buffers rather than
// allocate fresh ones — e.g. with ArrayPool (next lesson's territory).
Console.WriteLine($"Gen 2 collections so far: {GC.CollectionCount(2)}");
}
}
// ✅ Expected output:
// Small array (1 KB): Gen 0
// Large array (100 KB): Gen 2
// Gen 2 collections so far: 04. Deterministic Cleanup: IDisposable & using
The GC handles memory, but it knows nothing about unmanaged resources — open files, network sockets, database connections, OS handles. Those must be released promptly, and you can't wait for an unpredictable GC. The contract for this is IDisposable: implement a Dispose() method that frees the resource, then wrap usage in a using block so Dispose() is called automatically at the closing brace — even if an exception is thrown. Read this worked example, then you'll write one.
using System;
// IDisposable is the contract for DETERMINISTIC cleanup of resources the GC
// can't see (files, sockets, DB connections). You decide exactly WHEN it runs.
class FileLogger : IDisposable
{
public FileLogger(string path)
{
Console.WriteLine($"Opened log file: {path}"); // pretend we opened a file
}
public void Write(string message)
{
Console.WriteLine($"LOG: {message}");
}
// Dispose() is where you release the resource right away.
public void Dispose()
{
Console.WriteLine("Closed log file (Dispose ran)");
}
}
class Program
{
static void Main()
{
// A 'using' block calls Dispose() AUTOMATICALLY at the closing brace —
// even if an exception is thrown inside. This is the right way to clean up.
using (var logger = new FileLogger("app.log"))
{
logger.Write("Application started");
logger.Write("Doing work...");
} // <-- Dispose() runs HERE, deterministically
Console.WriteLine("After the using block");
// ✅ Output order proves Dispose runs before the last line:
// Opened log file: app.log
// LOG: Application started
// LOG: Doing work...
// Closed log file (Dispose ran)
// After the using block
}
}
// ✅ Expected output:
// Opened log file: app.log
// LOG: Application started
// LOG: Doing work...
// Closed log file (Dispose ran)
// After the using blockNow you try. Make DbConnection implement IDisposable, finish its Dispose(), and wrap it in a using block. Fill in the three ___ blanks:
using System;
// 🎯 YOUR TURN — make this class clean up properly, then use it safely.
class DbConnection : ___ // 👉 implement the interface IDisposable
{
public DbConnection()
{
Console.WriteLine("Connection opened");
}
// 1) Implement Dispose() so it prints the close message.
public void Dispose()
{
Console.WriteLine(___); // 👉 print "Connection closed (Dispose ran)"
}
}
class Program
{
static void Main()
{
// 2) Wrap the connection in a 'using' block so Dispose runs on exit.
___ (var db = new DbConnection()) // 👉 the keyword is using
{
Console.WriteLine("Running a query...");
}
Console.WriteLine("Done");
// ✅ Expected output:
// Connection opened
// Running a query...
// Connection closed (Dispose ran)
// Done
}
}5. Finalizers vs IDisposable
A finalizer (written ~ClassName()) is a method the GC runs when it collects the object. The problem: you have no control over when that is — it could be seconds or minutes later, or not before your program exits. So a finalizer is the wrong tool for timely cleanup; it's only a safety net for when someone forgets to dispose. Best practice is the dispose pattern: do the real cleanup in Dispose(), and call GC.SuppressFinalize(this) so the GC skips the now-unnecessary finalizer. This example shows both paths side by side.
using System;
// A FINALIZER (~ClassName) runs when the GC collects the object — but you
// CANNOT control WHEN that happens, so it's the wrong tool for timely cleanup.
class Resource : IDisposable
{
private bool disposed = false;
public void Use() => Console.WriteLine("Using the resource");
// The dispose pattern: free things NOW, and skip the finalizer afterwards.
public void Dispose()
{
if (disposed) return;
Console.WriteLine("Dispose: cleaned up immediately");
disposed = true;
GC.SuppressFinalize(this); // no need to finalize — we already cleaned up
}
// The finalizer is only a SAFETY NET for when someone forgets Dispose().
~Resource()
{
Console.WriteLine("Finalizer: GC had to clean up (you forgot Dispose!)");
}
}
class Program
{
static void Main()
{
// The RIGHT way: deterministic cleanup via using.
using (var r = new Resource())
r.Use();
// -> "Using the resource" then "Dispose: cleaned up immediately"
// The WRONG way: leave it to the finalizer (runs whenever the GC feels like it).
var leaky = new Resource();
leaky.Use();
leaky = null; // now unreachable; finalizer runs LATER, not now
GC.Collect(); // forcing it here only to DEMONSTRATE the finalizer
GC.WaitForPendingFinalizers();
Console.WriteLine("Notice the finalizer ran far from where we 'finished' with it");
}
}
// ✅ Expected output:
// Using the resource
// Dispose: cleaned up immediately
// Using the resource
// Notice the finalizer ran far from where we 'finished' with it🔎 Deep Dive: GC.Collect and GC.GetTotalMemory are for learning, not production
You've seen GC.Collect() in the examples above. It's there only to demonstrate generations and finalizers on demand. In real code you should almost never call it: the GC is heavily tuned to decide the best moment to run, and a manual GC.Collect() forces an expensive full collection at the wrong time, often hurting performance and pausing every thread.
GC.GetTotalMemory(forceFullCollection) is similar — handy in a tutorial to see allocation cost, but it's an approximation, not a precise accounting tool. Pass false for a quick snapshot; passing true forces a collection first.
// ✅ Fine in a lesson / quick experiment:
long bytes = GC.GetTotalMemory(false); // approximate snapshot
GC.Collect(); // force a collection to observe behaviour
// ❌ In production, prefer to let the GC run itself, and measure with
// proper tools (dotnet-counters, dotnet-trace, a profiler) instead.Rule of thumb: if you're reaching for GC.Collect() to "fix" a memory problem, the real fix is almost always to stop holding references you no longer need.
6. Avoiding Managed Memory Leaks
A garbage-collected language can still leak memory — not because the GC fails, but because you keep objects reachable so it's not allowed to free them. The two classic culprits: undetached event handlers (subscribing to an event makes the publisher hold a reference to the subscriber, so the subscriber can't be collected until you unsubscribe), and static collections (anything you add to a static list lives for the whole program unless you remove it). Run this to see both leaks — and their fixes.
using System;
// "Managed memory leak": an object stays reachable, so the GC never frees it.
// The two classic causes are undetached event handlers and static collections.
class Publisher
{
public event EventHandler? OnTick; // long-lived publisher
public void Tick() => OnTick?.Invoke(this, EventArgs.Empty);
}
class Subscriber
{
public Subscriber(Publisher p)
{
// The publisher now holds a reference to THIS subscriber via the handler.
// If we never detach, the subscriber can't be collected while p lives. LEAK.
p.OnTick += HandleTick;
}
private void HandleTick(object? s, EventArgs e) => Console.WriteLine("tick handled");
}
class Program
{
// A STATIC collection lives for the whole program — anything you add to it
// is kept alive forever unless you remove it. A very common leak.
static System.Collections.Generic.List<byte[]> cache = new();
static void Main()
{
var pub = new Publisher();
var sub = new Subscriber(pub); // wired up; pub -> sub reference exists
pub.Tick(); // tick handled
// FIX for the event leak: detach when you're done (or use a weak event).
// pub.OnTick -= sub.HandleTick; // (would need HandleTick to be accessible)
// FIX for the static leak: don't grow a static cache without bounds.
cache.Add(new byte[1_000_000]); // kept alive until you cache.Clear()/Remove
Console.WriteLine($"Static cache holds {cache.Count} item(s) — alive forever");
cache.Clear(); // release the references so the GC can reclaim
Console.WriteLine("Cleared the static cache; objects are now collectable");
}
}
// ✅ Expected output:
// tick handled
// Static cache holds 1 item(s) — alive forever
// Cleared the static cache; objects are now collectablePro Tips
- 💡 Prefer using for anything disposable: the C# 8+ using declaration using var f = new FileLogger(...); disposes automatically at the end of the enclosing scope — no braces needed.
- 💡 Keep objects short-lived: the GC's happy path is Gen 0. Allocating in tight loops and discarding quickly is fine; long-lived churn is what hurts.
- 💡 Don't call GC.Collect() in production: let the runtime decide. Forcing collections almost always makes things slower, not faster.
- 💡 Always unsubscribe from events you no longer need (publisher.OnTick -= Handler;), or you'll keep subscribers alive — a top cause of leaks in long-running apps.
- 💡 Bound your caches: a static List/Dictionary that only grows is a leak. Use a size limit, eviction policy, or WeakReference entries.
- 💡 Implement the full dispose pattern when you hold unmanaged resources: a public Dispose(), GC.SuppressFinalize(this), and a finalizer only as a safety net.
Common Errors (and the fix)
- Relying on a finalizer for timely cleanup: a finalizer runs whenever the GC gets around to it — possibly minutes later, possibly never. Put real cleanup in Dispose() and call it via using; keep the finalizer only as a backstop.
- Forgetting to dispose: not disposing a file, socket, or DB connection leaks the underlying OS handle and can exhaust the pool ("too many open files"). Wrap every IDisposable in a using, or analyzers will warn "CA2000: Dispose objects before losing scope."
- Memory leak from an undetached event handler: publisher.OnTick += sub.Handler; without a matching -= keeps the subscriber alive as long as the publisher lives. Always unsubscribe, or use a weak event pattern.
- Memory leak from a static collection: a static List that you only ever Add to grows forever — its items can never be collected. Remove items you're done with, or bound the collection's size.
- Calling GC.Collect() in production "to free memory": this forces a full, expensive collection at a bad time and usually hurts throughput. Remove it and fix the real cause — references you're still holding.
- "System.ObjectDisposedException: Cannot access a disposed object": you used an object after its using block already disposed it. Move the work inside the using, or don't dispose until you're truly finished.
📋 Quick Reference
| Task | Code | Notes |
|---|---|---|
| Which generation? | GC.GetGeneration(obj) | 0, 1, or 2 |
| Tracked memory | GC.GetTotalMemory(false) | Approx bytes (learning) |
| Force a collection | GC.Collect() | Demos only, not production |
| Collections so far | GC.CollectionCount(2) | Per generation |
| Deterministic cleanup | using (var x = ...) { ... } | Calls Dispose() on exit |
| using declaration | using var x = ...; | Disposes at scope end |
| Skip finalizer | GC.SuppressFinalize(this) | After Dispose cleans up |
| LOH threshold | ≥ 85,000 bytes | Goes to Large Object Heap |
Frequently Asked Questions
Q: If C# has a garbage collector, why do I need IDisposable at all?
The GC only reclaims managed memory. It doesn't know about files, sockets, or database connections — those are unmanaged resources the OS hands out. IDisposable lets you release them the moment you're done, instead of waiting for an unpredictable GC.
Q: Should I ever call GC.Collect()?
Almost never in production. It forces an expensive full collection at a time the GC didn't choose, usually hurting performance. It's fine in a tutorial or micro-benchmark to observe behaviour, which is the only reason it appears in these examples.
Q: Can a garbage-collected program leak memory?
Yes — a "managed leak." If something stays reachable (an event subscription you never detach, a static list you only add to), the GC isn't allowed to free it. The fix is to stop holding the reference: unsubscribe, clear, or bound the collection.
Q: What's the difference between a finalizer and Dispose()?
Dispose() is deterministic — you (or a using block) call it at a known moment. A finalizer runs whenever the GC collects the object, which you can't predict. Use Dispose() for cleanup; keep a finalizer only as a safety net.
Q: Why does the Large Object Heap matter?
Objects ≥ 85,000 bytes live on the LOH, which is collected only with expensive Gen 2 passes and isn't compacted by default, so churning big arrays fragments memory. Reuse large buffers rather than allocating fresh ones.
Mini-Challenge: A Disposable Buffer
No blanks this time — just a brief and an outline. Build a Buffer class that implements IDisposable, allocates an array in its constructor, and prints when it's disposed. In Main, measure memory before and after a using block to show that disposing frees the buffer. Run it and check your output against the comments.
using System;
// 🎯 MINI-CHALLENGE: A disposable buffer that reports its size
// 1. Make a class 'Buffer' that implements IDisposable.
// 2. In the constructor, take an int 'sizeKB' and allocate
// a byte[] of that many kilobytes (sizeKB * 1024).
// Print: "Allocated {sizeKB} KB".
// 3. In Dispose(), print "Buffer disposed".
// 4. In Main, read GC.GetTotalMemory(false) BEFORE and AFTER the using block,
// and print how much was freed once Dispose has run.
//
// ✅ Expected output (numbers vary, but Dispose runs and memory drops):
// Allocated 4096 KB
// Buffer disposed
// Freed ~4096 KB after the using block
class Buffer // : IDisposable
{
// your field, constructor and Dispose() here
}
class Program
{
static void Main()
{
// long before = ...
// using (...) { ... }
// long after = ...
// print the difference
}
}🎉 Lesson Complete
- ✅ Value types hold data directly (stack); reference types hold an address to a heap object
- ✅ The GC uses generations — Gen 0 (cheap, frequent) to Gen 2 (expensive, rare)
- ✅ Objects ≥ 85 KB live on the Large Object Heap, collected with Gen 2 and not compacted
- ✅ IDisposable + using give deterministic, on-time cleanup of resources
- ✅ Finalizers run unpredictably — a safety net, never your primary cleanup path
- ✅ Managed leaks come from undetached events and unbounded static refs — release them
- ✅ GC.Collect / GC.GetTotalMemory are for learning and diagnostics, not production code
Practice quiz
Where does a value type (like an int local) typically store its data?
- On the heap
- In a static field
- On the stack, holding the data directly
- On the Large Object Heap
Answer: On the stack, holding the data directly. A value type stores its data directly; for a local variable that lives on the fast, self-cleaning stack.
When you copy a reference type variable, what gets copied?
- Only the reference (address), so both variables point at the same object
- The whole object is deep-copied
- Nothing — it throws
- A frozen snapshot of the object
Answer: Only the reference (address), so both variables point at the same object. Copying a reference type copies only the arrow to one shared object, so changing it through one variable is visible through the other.
In which generation does a brand-new object start?
- Gen 1
- Gen 2
- The Large Object Heap
- Gen 0
Answer: Gen 0. New objects start in Gen 0, which is collected often and cheaply. Survivors are promoted to Gen 1, then Gen 2.
Which generation is the most expensive to collect?
- Gen 0
- Gen 2
- Gen 1
- All are equal cost
Answer: Gen 2. Gen 0 is cheap and frequent; Gen 2 (long-lived objects) is collected rarely and expensively because it scans the heap.
At what size does an object go on the Large Object Heap?
- 85,000 bytes or more
- 8,500 bytes or more
- 850,000 bytes or more
- Any array
Answer: 85,000 bytes or more. Objects of 85,000 bytes or more go on the LOH, which is collected only with Gen 2 and is not compacted by default, so it can fragment.
What is IDisposable for?
- Reclaiming managed memory automatically
- Making objects immutable
- Deterministic cleanup of resources the GC can't see (files, sockets, connections)
- Forcing a Gen 2 collection
Answer: Deterministic cleanup of resources the GC can't see (files, sockets, connections). The GC handles managed memory but not unmanaged resources. IDisposable's Dispose() lets you release those promptly, on time.
When does a using block call Dispose()?
- When the GC next runs
- At the closing brace of the block, even if an exception is thrown
- Only if you call it manually
- When the program exits
Answer: At the closing brace of the block, even if an exception is thrown. A using block calls Dispose() automatically at the closing brace, including when an exception is thrown inside it.
Why is a finalizer (~ClassName) the wrong tool for timely cleanup?
- It runs too early
- It is slower than Dispose by a fixed amount
- It cannot access fields
- You can't control when it runs — it fires whenever the GC collects the object
Answer: You can't control when it runs — it fires whenever the GC collects the object. A finalizer runs whenever the GC collects the object, which is unpredictable. It's only a safety net; Dispose() gives deterministic cleanup.
What does GC.SuppressFinalize(this) do, called inside Dispose()?
- Forces an immediate collection
- Tells the GC to skip the now-unnecessary finalizer since cleanup already ran
- Reopens the resource
- Promotes the object to Gen 2
Answer: Tells the GC to skip the now-unnecessary finalizer since cleanup already ran. After Dispose() cleans up, GC.SuppressFinalize(this) tells the GC the finalizer is no longer needed, avoiding extra finalization work.
What is a common cause of a managed memory leak?
- Using a using block
- Disposing objects too early
- An undetached event handler or an unbounded static collection keeping objects reachable
- Allocating short-lived objects
Answer: An undetached event handler or an unbounded static collection keeping objects reachable. If something stays reachable — an event subscription you never detach, or a static list you only add to — the GC isn't allowed to free it.
Continue this course
- Previous: Parallel Programming with PLINQ & Parallel.For
- Next: Unsafe Code, Span<T>, and High-Performance C# — Use Span<T>, Memory<T>, and unsafe code for zero-allocation hot paths
- Quick reference: C# cheat sheet