📁 Sangam Dhakal - Portfolio v2.0 - Netscape Navigator 4.0
_
□
×

๐Ÿ“š Interview question

A Dictionary-based cache in a .NET service gets slower and slower and sometimes "loses" entries that were definitely added. What could be wrong with the key type, and how would you debug and fix it?

Debugging hash-based collection failures: constant or poor GetHashCode, mutable keys, inconsistent Equals, culture-sensitive comparisons, and thread-unsafe access to Dictionary.

Senior Debugging Advanced concurrency data-structures debugging dictionary gethashcode hash-table

๐ŸŽฏ Key points

  • Constant or partial hashes (for example hashing only TenantId) make keys collide, so chains grow and lookups degrade towards O(n) as the dictionary fills: 'slower and slower'
  • Mutating a field used by Equals or GetHashCode after insertion leaves the entry in its old bucket, so lookups miss it while it still holds memory: 'lost entries'
  • Equals and GetHashCode must use the same fields and the same comparison rules (for example both OrdinalIgnoreCase); implement IEquatable<T> so lookups avoid boxing and use the typed Equals
  • A plain Dictionary is safe for many readers only when nobody writes; concurrent writes can corrupt buckets and entries, causing lost entries, exceptions or threads spinning forever
  • Diagnose with CPU profiles showing time in FindValue or TryInsert, a hash-distribution report over real keys, and a memory dump showing threads stuck in dictionary methods
  • Fix keys with readonly record structs or records (value equality and a combined hash generated for you) or hand-written HashCode.Combine over every equality field
  • Fix sharing with ConcurrentDictionary, a lock, or immutable or frozen snapshots swapped atomically; remember AddOrUpdate and GetOrAdd delegates can run more than once under contention, so keep them side-effect free

โฑ 30-second answer

Likely causes, in order:

  1. Poor or constant GetHashCode (e.g. returns 0, or hashes only a low-variety field): all keys collide, chains grow, lookups become O(n), so it gets slower as it grows.
  2. Mutable keys: a field used in the hash changes after insertion, so the entry sits in the old bucket and lookups miss.
  3. Equals/GetHashCode inconsistency: equal keys produce different hashes (e.g. case-insensitive Equals with case-sensitive hash).
  4. Concurrent writes to Dictionary from multiple threads, which can corrupt the internal arrays, lose entries, or even cause infinite loops.

Debug by measuring hash distribution, checking the key type's Equals/GetHashCode and mutability, and looking for shared dictionaries without locks. Fix with immutable keys (records), consistent comparers, and ConcurrentDictionary or proper locking.

๐Ÿ—ฃ Full answer (senior)

Symptom-to-cause map

Symptom Likely cause
Gets slower as it grows; CPU in FindValue / TryInsert Many collisions (bad hash)
ContainsKey false right after Add succeeded Key mutated after insert, or Equals/hash inconsistent
Duplicates that should be the same key Equals not overridden, or comparer mismatch (case, culture, whitespace)
Random IndexOutOfRangeException, lost entries, 100% CPU in a dictionary method Concurrent writes to a non-thread-safe Dictionary

Production example

A rate limiter stored counters in Dictionary<ClientKey, Counter> shared across requests. ClientKey was a class with TenantId and ApiKey, and its GetHashCode returned TenantId.GetHashCode(). One tenant had 40,000 API keys, so they all landed in the same bucket chain, and lookups slowed to milliseconds. Worse, the dictionary was used from many threads with only reads "protected", and under load a pod hit 100% CPU with threads stuck inside Dictionary.FindValue, a sign of a corrupted chain forming a loop.

Fix: changed ClientKey to readonly record struct ClientKey(Guid TenantId, string ApiKey) (value equality, combined hash), and replaced the dictionary with ConcurrentDictionary using AddOrUpdate. p99 latency of the limiter dropped from 30 ms to under 50 ยตs, and the CPU spikes stopped.

๐Ÿ” What the interviewer is checking

A debugging question that checks deep understanding of hashing, equality and thread safety.

Green flags

  • Connects "slower over time" with collisions and "lost entries" with mutable keys or inconsistent equality.
  • Mentions concurrency corruption of Dictionary.
  • Proposes concrete diagnostics (profiling, hash distribution, dumps).
  • Fixes with immutable keys, consistent comparers and thread-safe collections.

Red flags

  • Blames the GC or the runtime.
  • Suggests clearing the cache periodically as the fix.

๐Ÿ“š Learn in depth

1. What a constant hash does

Good hash:                       Constant hash (always 7):
bucket 0: A                      bucket 0:
bucket 1: B โ†’ F                  bucket 1:
bucket 2: C                      ...
bucket 3: D                      bucket 7: A โ†’ B โ†’ C โ†’ D โ†’ E โ†’ F โ†’ ... (n items)
bucket 4: E                      lookups compare against every key: O(n)

Inserting n items becomes O(nยฒ) in total because each insert checks the chain for duplicates.

2. Mutable key failure

key = new OrderKey { Region = "AU", Id = 1 };   hash โ†’ bucket 2
dict.Add(key, order);
key.Region = "NZ";                               hash would now โ†’ bucket 5
dict.ContainsKey(key);   โ†’ looks in bucket 5 โ†’ not found
// The entry is still in bucket 2, unreachable, and still holds memory.

3. Inconsistent comparers

// Equals ignores case but GetHashCode does not
public override bool Equals(object? o) => o is Sku s && string.Equals(Code, s.Code, StringComparison.OrdinalIgnoreCase);
public override int GetHashCode() => Code.GetHashCode();   // โœ˜ "abc" and "ABC" hash differently
// Fix: StringComparer.OrdinalIgnoreCase.GetHashCode(Code)

4. Concurrency

Dictionary<TKey,TValue> supports multiple concurrent readers only if there are no writers. A write during a read or another write can corrupt buckets/entries. Symptoms are non-deterministic and can include infinite loops. Options:

Pattern Use
ConcurrentDictionary Frequent concurrent reads and writes
lock around a Dictionary Low contention, complex multi-step updates
FrozenDictionary / ImmutableDictionary swapped atomically Read-mostly, rebuilt occasionally

5. How to debug

  1. Profile: CPU samples inside Dictionary.FindValue/TryInsert with growing counts point to collisions.
  2. Measure distribution: group keys by GetHashCode() and count distinct hashes vs keys.
  3. Review the key type: Is it immutable? Are Equals and GetHashCode based on the same fields and the same comparison rules? Does it implement IEquatable<T>?
  4. Check sharing: Is the dictionary static or a singleton? Who writes to it? Take a memory dump and look at threads stuck in dictionary methods.
  5. Write a property test: for random pairs, a.Equals(b) implies equal hashes.

6. Try it yourself

  1. Insert 100,000 keys with GetHashCode() => 1 and time it.
  2. Reproduce a mutable key miss.
  3. Run 8 threads writing to a plain Dictionary in a loop (in a test project) and observe exceptions or hangs; then switch to ConcurrentDictionary.

๐Ÿ’ป Code

// Diagnose hash quality for a set of real keys
static void ReportHashQuality<T>(IEnumerable<T> keys, IEqualityComparer<T>? comparer = null)
{
    comparer ??= EqualityComparer<T>.Default;
    var list = keys.ToList();
    var groups = list.GroupBy(k => comparer.GetHashCode(k!)).ToList();
    Console.WriteLine($"keys: {list.Count:N0}, distinct hashes: {groups.Count:N0}, " +
                      $"largest collision group: {groups.Max(g => g.Count()):N0}");
}
// Before: mutable class, weak hash, shared Dictionary without locks
public class ClientKey
{
    public Guid TenantId { get; set; }
    public string ApiKey { get; set; } = "";
    public override bool Equals(object? o) => o is ClientKey k && k.TenantId == TenantId && k.ApiKey == ApiKey;
    public override int GetHashCode() => TenantId.GetHashCode();   // all keys of a tenant collide
}
private static readonly Dictionary<ClientKey, Counter> Counters = new();   // written from many threads

// After: immutable value key + thread-safe map + immutable value
public readonly record struct ClientKey(Guid TenantId, string ApiKey);  // IEquatable<ClientKey>, combined hash
public sealed record Counter(long Count);

private static readonly ConcurrentDictionary<ClientKey, Counter> SafeCounters = new();

public Counter Hit(Guid tenant, string apiKey) =>
    SafeCounters.AddOrUpdate(new ClientKey(tenant, apiKey),
        static _ => new Counter(1),
        // May run more than once under contention, so it must not mutate shared state:
        // it returns a new value and ConcurrentDictionary keeps the one that wins.
        static (_, existing) => existing with { Count = existing.Count + 1 });
// Property-style test: equal keys must have equal hashes
[Theory]
[InlineData("abc", "ABC")]
[InlineData("Sku-1", "sku-1")]
public void Equal_keys_have_equal_hashes(string a, string b)
{
    var x = new Sku(a);
    var y = new Sku(b);
    Assert.True(x.Equals(y));
    Assert.Equal(x.GetHashCode(), y.GetHashCode());
}

โš  Common mistakes

  • Mistake: Hashing only part of the key, or returning a constant. Why it is wrong: Keys collide, chains grow and lookups degrade to O(n), which looks like a slow memory leak or gradual CPU creep. Instead: Include every field used by Equals with HashCode.Combine, or use records.

  • Mistake: Mutating key fields after inserting. Why it is wrong: The entry stays in the bucket for its old hash, so it becomes unreachable but stays in memory. Instead: Make keys immutable (records, readonly struct, init properties) or never change fields used for equality.

  • Mistake: Using different comparison rules in Equals and GetHashCode. Why it is wrong: Case-insensitive equality with a case-sensitive hash puts "abc" and "ABC" in different buckets, so lookups miss and duplicates appear. Instead: Use the same comparer for both, for example StringComparer.OrdinalIgnoreCase.GetHashCode(Code).

  • Mistake: Sharing a Dictionary across threads with writes. Why it is wrong: A concurrent insert or resize corrupts the internal arrays, causing lost entries, exceptions or infinite loops under load. Instead: Use ConcurrentDictionary, a lock, or immutable or frozen snapshots.

  • Mistake: Putting side effects in GetOrAdd or AddOrUpdate delegates. Why it is wrong: Under contention the delegate can run more than once, so a mutating Increment() can double count. Instead: Return new immutable values, use Interlocked on a field inside a stored object, or wrap the value in Lazy<T> for expensive creation.

  • Mistake: Fixing by restarting the service or clearing the cache on a timer. Why it is wrong: The problem returns as the dictionary refills, and the root cause stays hidden. Instead: Measure hash distribution and fix the key contract or the concurrency issue.

๐Ÿ› Senior insight: trade-offs

When a hash-based collection misbehaves, I look at three contracts: the hash contract (well distributed, consistent with Equals), the immutability contract (keys do not change while stored) and the concurrency contract (no unsynchronised writers). Almost every incident breaks one of these, and each is cheap to check.

Symptom Contract broken Quick check Fix
Gets slower as it grows Hash distribution Distinct hashes versus keys Hash every equality field
ContainsKey false after Add Immutability or consistency Is any key field settable? Same comparer in both methods? Records, readonly struct, one comparer
Duplicates of "the same" key Equality Is Equals overridden or IEquatable<T> implemented? Implement both with the same rules
100% CPU, threads in FindValue Concurrency Dump: several threads in dictionary methods ConcurrentDictionary, lock or snapshots

Prevention is mostly about defaults: prefer readonly record struct or record for composite keys (they implement IEquatable<T> and a combined hash), use explicit comparers for strings, wrap shared mutable state in ConcurrentDictionary or behind a lock, and add tests that equal keys hash equally. In code review, a hand-written GetHashCode that does not mirror Equals, or a static Dictionary written from request code, is an immediate flag.

๐Ÿ”— Follow-ups the interviewer may ask

  1. When: Candidate is unsure how buckets and chains work Why: Checks hash table internals
    How to answer

    A hash table turns a key into an integer hash, maps it to a bucket (hash % bucketCount), and stores the entry there. Lookups compute the same hash, go straight to the bucket, and compare keys with Equals. Average cost is O(1); worst case (everything in one bucket) is O(n).

    .NET's Dictionary<TKey,TValue> keeps an int[] buckets and an Entry[] entries array; colliding entries form a chain via a next index inside the entries array. When full, it resizes to roughly double (a prime size) and rehashes.

    Contract: if a.Equals(b) then a.GetHashCode() == b.GetHashCode(), and a key's hash must not change while it is in the dictionary. Good hashes spread keys evenly.

  2. When: Candidate suggests ConcurrentDictionary Why: Probes ConcurrentDictionary caveats and alternatives
    How to answer

    A map (associative array, dictionary) stores key โ†’ value pairs with unique keys. Choose the implementation by access pattern:

    • Dictionary<K,V>: hash table, O(1) average, unordered. The default.
    • SortedDictionary<K,V>: red-black tree, O(log n), sorted by key, fast inserts/removes.
    • SortedList<K,V>: two sorted arrays, O(log n) lookup but O(n) insert/remove; compact, indexable. Good for small or build-once sorted maps.
    • OrderedDictionary<K,V> (.NET 9+): keeps insertion order with index access.
    • ConcurrentDictionary<K,V>: thread-safe, lock-free reads.
    • FrozenDictionary<K,V> (.NET 8+): built once, fastest reads.
    • ImmutableDictionary<K,V>: immutable snapshots with cheap modified copies.
    • ILookup<K,V> / Dictionary<K, List<V>>: multi-map (one key, many values).

๐Ÿ›  Real-world problems: how you handle them

Rate limiter slows down and spikes CPU due to colliding, shared keys
Situation
[API gateway rate limiter] on .NET [10] storing per-client counters in a static `Dictionary<ClientKey, Counter>`; one tenant had [40,000] API keys.
Signals
Limiter p99 rising from microseconds to [30 ms] over the day; occasional pods at 100% CPU with threads stuck in `Dictionary.FindValue`.
Investigation
Profiler and a dump showed long time in dictionary lookups; a hash report found [40,000] keys sharing one hash because `GetHashCode` used only TenantId. The dictionary was written from many threads without locks.
Resolution
Changed the key to `readonly record struct ClientKey(Guid TenantId, string ApiKey)` and moved to `ConcurrentDictionary.AddOrUpdate`.
Prevention
Analyser rule for shared mutable static dictionaries, property tests for custom equality, and a benchmark for the limiter in CI.

How to tell it (STAR)

**S:** Our [gateway rate limiter] got slower through the day and some pods pinned a CPU. **T:** I was asked to find the cause. **A:** A dump showed threads stuck in dictionary lookups. The key's hash used only the tenant ID, so one large tenant's [40,000] keys collided into a single chain, and the dictionary was also written by many threads without locks. I made the key an immutable record and switched to `ConcurrentDictionary`. **R:** Limiter p99 dropped from [30 ms] to under [50 µs] and the CPU spikes disappeared.

โ†ฉ Usually asked after

More in Data Structures

Last updated 23 Oct 2026.

๐ŸŒ Sangam's Webring

โช Previous ๐Ÿ  Main Next โฉ
๐Ÿ’ฌ Chat with me