Exercises: Memory & Garbage Collection (Day 3-4)
Exercise 1: Observing GC Generations
Track how objects move through GC generations and measure collection counts.
Requirements
public class GenerationTracker
{
// TODO: Create objects and track their generations
// TODO: Force collections and observe promotion
// TODO: Count collections per generation
public void TrackObjectLifecycle()
{
// Your implementation here
throw new NotImplementedException();
}
public void DemonstrateLOH()
{
// TODO: Show difference between small and large objects
throw new NotImplementedException();
}
}
Expected Output
Initial object generation: 0
After Gen 0 collection: 1
After Gen 1 collection: 2
Small object (84,000 bytes): Gen 0
Large object (85,000 bytes): Gen 2
Gen 0 collections: 5
Gen 1 collections: 2
Gen 2 collections: 1
Hints
- Use
GC.GetGeneration(obj)to check generation - Use
GC.Collect(generation)to force collection - Use
GC.CollectionCount(generation)for counts - Create byte arrays of different sizes to test LOH threshold
Exercise 2: Proper Dispose Pattern
Implement the full IDisposable pattern with both managed and unmanaged resources.
Requirements
public class ResourceManager : IDisposable
{
private FileStream _managedResource; // Managed resource
private IntPtr _unmanagedResource; // Unmanaged resource
private bool _disposed;
public ResourceManager(string filePath)
{
_managedResource = File.OpenWrite(filePath);
_unmanagedResource = Marshal.AllocHGlobal(1024);
}
// TODO: Implement Dispose pattern
public void Dispose()
{
throw new NotImplementedException();
}
protected virtual void Dispose(bool disposing)
{
throw new NotImplementedException();
}
~ResourceManager()
{
// TODO: Implement finalizer
throw new NotImplementedException();
}
public void DoWork()
{
if (_disposed)
throw new ObjectDisposedException(nameof(ResourceManager));
// Use resources
}
}
Expected Behavior
// Proper disposal
using (var manager = new ResourceManager("test.txt"))
{
manager.DoWork();
} // Dispose called, finalizer suppressed
// Forgotten disposal
{
var manager = new ResourceManager("test2.txt");
manager.DoWork();
} // Finalizer will eventually clean up unmanaged resource
GC.Collect();
GC.WaitForPendingFinalizers();
Console.WriteLine("Cleanup complete");
Hints
- Call
GC.SuppressFinalize(this)in Dispose - Check
_disposedflag to prevent double disposal - Dispose managed resources only when
disposing == true - Always cleanup unmanaged resources
- Set resources to null after disposal
Exercise 3: IAsyncDisposable Implementation
Implement async disposal for resources that require async cleanup.
Requirements
public class AsyncResourceManager : IAsyncDisposable, IDisposable
{
private readonly FileStream _stream;
private readonly HttpClient _httpClient;
private bool _disposed;
public AsyncResourceManager(string filePath)
{
_stream = File.OpenWrite(filePath);
_httpClient = new HttpClient();
}
// TODO: Implement IAsyncDisposable
public async ValueTask DisposeAsync()
{
throw new NotImplementedException();
}
// TODO: Implement IDisposable (for compatibility)
public void Dispose()
{
throw new NotImplementedException();
}
protected virtual async ValueTask DisposeAsyncCore()
{
throw new NotImplementedException();
}
protected virtual void Dispose(bool disposing)
{
throw new NotImplementedException();
}
}
Expected Behavior
// Async disposal
await using (var manager = new AsyncResourceManager("test.txt"))
{
// Use manager
} // DisposeAsync called
// Sync disposal (fallback)
using (var manager = new AsyncResourceManager("test2.txt"))
{
// Use manager
} // Dispose called
Hints
- Implement both
DisposeAsync()andDispose() - Use
DisposeAsyncCore()for actual async cleanup - Call
GC.SuppressFinalize(this)in both methods - Flush streams asynchronously in
DisposeAsyncCore() - Set
_disposedflag to prevent double disposal
Exercise 4: ArrayPool Buffer Management
Replace heap allocations with ArrayPool for temporary buffers.
Requirements
public class BufferProcessor
{
// ❌ Before: Creates many allocations
public byte[] ProcessWithAllocations(int iterations)
{
// TODO: Implement version that allocates new arrays
throw new NotImplementedException();
}
// ✅ After: Uses ArrayPool
public byte[] ProcessWithArrayPool(int iterations)
{
// TODO: Implement version using ArrayPool
throw new NotImplementedException();
}
public void CompareMethods()
{
// TODO: Measure GC pressure difference
throw new NotImplementedException();
}
}
Expected Behavior
var processor = new BufferProcessor();
// Measure allocations
var before = GC.GetTotalMemory(true);
processor.ProcessWithAllocations(1000);
var afterAllocations = GC.GetTotalMemory(true);
var before2 = GC.GetTotalMemory(true);
processor.ProcessWithArrayPool(1000);
var afterPool = GC.GetTotalMemory(true);
Console.WriteLine($"Allocations: {afterAllocations - before} bytes");
Console.WriteLine($"ArrayPool: {afterPool - before2} bytes");
// ArrayPool should be significantly less!
Hints
- Use
ArrayPool<byte>.Shared.Rent(size) - Always return buffers in
finallyblock - Use
ArrayPool<byte>.Shared.Return(buffer) - Consider clearing sensitive data before returning
- Rented buffer may be larger than requested size
Exercise 5: Weak Reference Cache
Implement a cache using weak references that allows GC under memory pressure.
Requirements
public class WeakReferenceCache<TKey, TValue> where TValue : class
{
private readonly Dictionary<TKey, WeakReference<TValue>> _cache = new();
// TODO: Add item to cache
public void Add(TKey key, TValue value)
{
throw new NotImplementedException();
}
// TODO: Try get item from cache
public bool TryGet(TKey key, out TValue value)
{
throw new NotImplementedException();
}
// TODO: Get or create item
public TValue GetOrAdd(TKey key, Func<TKey, TValue> factory)
{
throw new NotImplementedException();
}
// TODO: Clean dead references
public void Cleanup()
{
throw new NotImplementedException();
}
}
Expected Behavior
var cache = new WeakReferenceCache<int, byte[]>();
// Add large object
cache.Add(1, new byte[10_000]);
// Retrieve from cache
if (cache.TryGet(1, out var data1))
{
Console.WriteLine("Cache hit!");
}
// Force GC
GC.Collect();
GC.WaitForPendingFinalizers();
GC.Collect();
// May be collected
if (cache.TryGet(1, out var data2))
{
Console.WriteLine("Still in cache");
}
else
{
Console.WriteLine("Collected by GC");
}
Hints
- Store
WeakReference<TValue>instead ofTValue - Use
TryGetTarget()to retrieve value - Return false if target was collected
- Remove dead references in Cleanup()
- Consider thread safety with locks
Exercise 6: Memory Pressure Monitoring
Track and respond to memory pressure in your application.
Requirements
public class MemoryMonitor
{
// TODO: Track memory allocations
public void MonitorAllocations(Action action)
{
throw new NotImplementedException();
}
// TODO: Report GC statistics
public GCStats GetGCStats()
{
throw new NotImplementedException();
}
// TODO: Demonstrate LOH fragmentation
public void DemonstrateLOHFragmentation()
{
throw new NotImplementedException();
}
}
public class GCStats
{
public long TotalMemory { get; set; }
public int Gen0Collections { get; set; }
public int Gen1Collections { get; set; }
public int Gen2Collections { get; set; }
public Dictionary<int, int> ObjectsByGeneration { get; set; }
}
Expected Output
Before: 1.2 MB
After: 15.8 MB
Allocated: 14.6 MB
Gen 0 collections: 10
Gen 1 collections: 3
Gen 2 collections: 1
Gen 0 objects: 1523
Gen 1 objects: 412
Gen 2 objects: 89
Hints
- Use
GC.GetTotalMemory(false)to measure memory - Use
GC.CollectionCount(generation)for collection counts - Track before/after memory for allocations
- Create and release large objects to show fragmentation
- Consider using
GCSettings.LargeObjectHeapCompactionMode
Exercise 7: Finalization Queue Impact
Measure the performance impact of finalizers.
Requirements
public class FinalizerBenchmark
{
// Class with finalizer
class WithFinalizer
{
private byte[] _data = new byte[1000];
~WithFinalizer()
{
// Cleanup
}
}
// Class without finalizer
class WithoutFinalizer
{
private byte[] _data = new byte[1000];
}
// TODO: Measure allocation/collection with finalizers
public TimeSpan MeasureWithFinalizer()
{
throw new NotImplementedException();
}
// TODO: Measure allocation/collection without finalizers
public TimeSpan MeasureWithoutFinalizer()
{
throw new NotImplementedException();
}
public void Compare()
{
// TODO: Compare performance
throw new NotImplementedException();
}
}
Expected Output
With finalizers: 250ms
Without finalizers: 50ms
Overhead: 5x slower
Hints
- Create 10,000 objects in a loop
- Use
Stopwatchto measure time - Force GC to measure collection impact
- Call
GC.WaitForPendingFinalizers()for accurate measurement - Objects with finalizers live longer (survive to Gen 1)
Exercise 8: Custom Dispose Pattern
Create a base class with proper dispose pattern that derived classes can extend.
Requirements
public abstract class DisposableBase : IDisposable
{
private bool _disposed;
public void Dispose()
{
// TODO: Implement base Dispose
throw new NotImplementedException();
}
protected virtual void DisposeManagedResources()
{
// Override in derived classes
}
protected virtual void DisposeUnmanagedResources()
{
// Override in derived classes
}
protected void ThrowIfDisposed()
{
if (_disposed)
throw new ObjectDisposedException(GetType().Name);
}
}
public class DatabaseConnection : DisposableBase
{
private SqlConnection _connection;
// TODO: Override dispose methods
}
Expected Behavior
using (var connection = new DatabaseConnection())
{
connection.Execute("SELECT * FROM Users");
} // Properly disposed
var disposed = new DatabaseConnection();
disposed.Dispose();
disposed.Execute("SELECT"); // Throws ObjectDisposedException
Hints
- Call
GC.SuppressFinalize(this)in base Dispose - Call virtual dispose methods from base
- Set
_disposed = trueafter cleanup - Implement
ThrowIfDisposed()check in methods - Don't throw exceptions from Dispose
Bonus Exercise: GC Mode Comparison
Compare Workstation vs Server GC performance.
Requirements
Create two console applications:
- One with Workstation GC
- One with Server GC
Measure throughput and latency under load.
<!-- Workstation GC -->
<PropertyGroup>
<ServerGarbageCollection>false</ServerGarbageCollection>
<ConcurrentGarbageCollection>true</ConcurrentGarbageCollection>
</PropertyGroup>
<!-- Server GC -->
<PropertyGroup>
<ServerGarbageCollection>true</ServerGarbageCollection>
</PropertyGroup>
Validation Tests
[Fact]
public void ObjectPromotesToGen1AfterGen0Collection()
{
var obj = new object();
Assert.Equal(0, GC.GetGeneration(obj));
GC.Collect(0);
Assert.Equal(1, GC.GetGeneration(obj));
}
[Fact]
public void LargeObjectGoesToGen2()
{
var large = new byte[85_000];
Assert.Equal(2, GC.GetGeneration(large));
}
[Fact]
public void ArrayPoolReducesAllocations()
{
var before = GC.GetTotalMemory(true);
for (int i = 0; i < 1000; i++)
{
var buffer = ArrayPool<byte>.Shared.Rent(1024);
ArrayPool<byte>.Shared.Return(buffer);
}
var after = GC.GetTotalMemory(false);
var allocated = after - before;
// Should be minimal allocation
Assert.True(allocated < 10_000);
}
[Fact]
public void WeakReferenceAllowsCollection()
{
var weak = new WeakReference<byte[]>(new byte[1000]);
GC.Collect();
GC.WaitForPendingFinalizers();
Assert.False(weak.TryGetTarget(out _));
}
[Fact]
public async Task AsyncDisposableDisposesAsync()
{
var disposed = false;
var resource = new TestAsyncDisposable(() => disposed = true);
await using (resource)
{
// Use resource
}
Assert.True(disposed);
}
Learning Objectives
By completing these exercises, you should be able to:
✅ Track objects through GC generations ✅ Implement proper Dispose and finalization patterns ✅ Use IAsyncDisposable for async cleanup ✅ Leverage ArrayPool to reduce allocations ✅ Create memory-aware caches with weak references ✅ Monitor and measure memory pressure ✅ Understand finalizer performance impact ✅ Create reusable dispose base classes
Next Steps
After completing these exercises:
- Profile with dotMemory or PerfView
- Measure GC pause times
- Experiment with GC modes
- Test LOH compaction
- Move to Day 5-6 exercises on Span<T> and Memory<T>