I had a lookup cache on a hot path, a ConcurrentDictionary<int, string> that maps a tenant id to a formatted key. After warmup it's basically all hits. The entry's already there, you read it, you move on. So when I saw the gen-0 collections ticking over under steady load, I went looking for the allocation somewhere else first. The cache couldn't be it. It's hits. Hits don't build anything.
Except mine did. The factory I handed to GetOrAdd was quietly allocating an object on every single call, including the millions of calls where it never ran.
Two overloads that read the same and aren't
GetOrAdd has more than one factory shape. The one everybody reaches for is GetOrAdd(key, k => Build(k)) , taking a Func<TKey, TValue> . There's another that takes Func<TKey, TArg, TValue> plus a state argument: GetOrAdd(key, (k, arg) => Build(k, arg), arg) . On the surface they do the same thing, and most comparisons of them talk about the factory invocation.
But the invocation isn't where my cost was.…