How Weak References Prevent Memory Leaks in C
In C#, in-memory caching often leads to memory leaks when objects
remain referenced by cache collections long after the rest of the
application has stopped using them. This article explains how strong
references cause unbounded memory growth, how the
WeakReference<T> class allows the .NET Garbage
Collector (GC) to reclaim memory automatically, and how to properly
implement weak references to build memory-safe caches.
The Problem: Strong References and Stale Cache Entries
By default, assigning an object to a variable, list, or dictionary in C# creates a strong reference. The .NET Garbage Collector determines an object's lifetime by tracing object graphs from application roots (such as static fields, local variables, and CPU registers). As long as a strong reference exists within an active cache:
- The GC considers the object actively in use.
- The memory occupied by the cached object cannot be freed.
- Memory consumption grows continuously unless an explicit eviction policy (like TTL or LRU) manually removes the entry.
In high-throughput or long-running applications, failing to actively
evict unneeded cache items results in memory leaks, degraded GC
performance, and eventual OutOfMemoryException crashes.
How Weak References Solve the Problem
A weak reference references an object while still allowing the
Garbage Collector to collect it. In .NET, this is implemented using the
WeakReference<T> class.
When an object is only referenced through a
WeakReference<T>:
- It is not considered a root or a reachable object during a garbage collection cycle.
- If memory pressure arises or the GC initiates a collection for the generation the object resides in, the GC frees the object's memory.
- The internal pointer of the
WeakReference<T>is automatically updated to track that the target has been collected.
By storing weak references in a cache, the cache acts as an opportunist: it retains objects for fast retrieval as long as memory permits, but safely surrenders them to the GC without causing memory leaks.
Retrieving Cached Objects Safely
Because the GC can collect weakly referenced targets at any time, you
cannot access the value directly as you would with a standard reference.
Instead, you must attempt to re-establish a temporary strong reference
using the TryGetTarget method.
public class WeakCache<TKey, TValue> where TValue : class
{
private readonly Dictionary<TKey, WeakReference<TValue>> _cache = new();
public void Set(TKey key, TValue value)
{
_cache[key] = new WeakReference<TValue>(value);
}
public bool TryGetValue(TKey key, out TValue? value)
{
if (_cache.TryGetValue(key, out var weakRef))
{
// TryGetTarget creates a strong reference if the object is still alive
if (weakRef.TryGetTarget(out value))
{
return true;
}
// The object was collected; remove the stale key
_cache.Remove(key);
}
value = null;
return false;
}
}When TryGetTarget succeeds, it assigns the object to a
local variable (value), creating a strong reference that
protects the object from GC collection for the duration of that
variable's scope. Once the consumer finishes execution and the local
reference leaves scope, the object becomes eligible for collection once
again.
Managing Cache Key Overhead
While WeakReference<T> prevents the cached
values from causing memory leaks, it does not automatically
remove the keys or the wrapper instances from the underlying
dictionary.
If millions of unique keys are placed into a cache, the empty
WeakReference<T> wrappers and key objects themselves
will remain in memory. To address this:
- Prune on Access: Remove dead entries when
TryGetTargetreturnsfalseduring lookups. - Periodic Cleanup: Run a background cleanup loop to
scan the cache and remove keys whose targets have returned
false. - Use
ConditionalWeakTable<TKey, TValue>: For scenarios where keys are also reference types,System.Runtime.CompilerServices.ConditionalWeakTable<TKey, TValue>automatically removes both the key and the value once the key becomes unreachable, completely eliminating manual dictionary cleanup.