Skip to main content

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 _disposed flag 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() and Dispose()
  • Use DisposeAsyncCore() for actual async cleanup
  • Call GC.SuppressFinalize(this) in both methods
  • Flush streams asynchronously in DisposeAsyncCore()
  • Set _disposed flag 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 finally block
  • 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 of TValue
  • 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 Stopwatch to 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 = true after 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:

  1. One with Workstation GC
  2. 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:

  1. Profile with dotMemory or PerfView
  2. Measure GC pause times
  3. Experiment with GC modes
  4. Test LOH compaction
  5. Move to Day 5-6 exercises on Span<T> and Memory<T>