Header bits for thin locks and hash codes, inflation to the sync block table, Monitor internals, and System.Threading.Lock.
Each object has a pointer-sized header before its MethodTable pointer. The header can hold a thin lock (owning thread id and recursion count), the object's identity hash code (from RuntimeHelpers.GetHashCode or the default object.GetHashCode), or an index into the sync block table. When an object needs more than the header can hold (contention on a lock, a hash code and a lock at the same time, or other per-object data), the runtime inflates it to a sync block that holds a full monitor. lock (obj) uses Monitor, which uses this mechanism; .NET 9's System.Threading.Lock is a separate type that does not use the object header.
Every heap object has an 8-byte (on 64-bit) object header just before its MethodTable pointer. It's a small, multipurpose word that can hold a thin lock (owner thread + recursion count), the object's identity hash code, or an index into the sync block table when more information is needed. Sync blocks are larger side structures that store a full Monitor (for contended locks and Wait/Pulse), plus the hash code, when they can't all fit in the header. lock (Monitor) and object.GetHashCode() (identity hash) both use this machinery.
[ object header (8 bytes) ][ MethodTable* ][ fields... ]
|
+-- one of:
- empty (0)
- thin lock: owning thread id + recursion level (+ spin-lock bit)
- hash code: identity hash (from RuntimeHelpers.GetHashCode)
- sync block index: pointer/index into the sync block table
+ a few bits used by the GC (e.g. marking during collection, finaliser-run flags)
The header is compact because most objects are never locked or hashed by identity.
lock (Monitor) uses itlock (_sync) { _balance -= amount; } // Monitor.Enter / Monitor.Exit
Monitor.Enter atomically writes the current thread's ID into the header (CAS). Recursive entry by the same thread increments a recursion count in the header. Exit clears it. No allocation, very fast.Monitor.Wait/Pulse, the runtime inflates the lock: it allocates a sync block (from the sync block table), stores the lock state there (an AwareLock with a waiting queue, event), and writes the sync block index into the header.Monitor.Exit wakes them.Sync blocks are reused when objects die (cleaned up during GC).
GetHashCode uses itobject.GetHashCode() (not overridden) and RuntimeHelpers.GetHashCode(obj) return the identity hash:
This is why the identity hash isn't the memory address (objects move) and why locking an object whose identity hash was taken (or vice versa) can force sync block allocation.
lock is cheap (tens of nanoseconds); contention is what's expensive (spinning, inflation, kernel waits, context switches).private readonly object _sync = new(); (or System.Threading.Lock in .NET 9). Locking on this, typeof(X), strings (interned — shared process-wide), or objects other code can access risks deadlocks and interference.lock requires a reference type; Monitor.Enter(int) would box a new object each time → no mutual exclusion.System.Threading.Lock: a dedicated lock type whose state lives in the object itself, not the header/sync block; lock (myLock) uses EnterScope(). Faster and clearer intent.dotnet-dump analyze → syncblk shows sync blocks with owners and waiters — invaluable for deadlock analysis.A service hung under load. A dump analysed with syncblk showed two threads each owning a sync block the other was waiting for: one code path locked customer then account; another locked account then customer. clrstack on both threads showed the call paths. We enforced a consistent lock ordering (by entity ID), and later replaced per-object locks with a striped SemaphoreSlim scheme because the async code needed await inside critical sections (which lock doesn't allow). The syncblk command found the deadlock in minutes.
The interviewer checks deep understanding of how locking works and how to diagnose contention.
Green flags: header states, thin lock versus sync block, inflation triggers, hash code storage, syncblk and lock contention counters, System.Threading.Lock, lock-object hygiene.
Red flags: thinks every lock creates an OS mutex; locks on this or strings; unaware of dump-based diagnosis.
On 64-bit, the header is 8 bytes but only the lower 32 bits are used for the sync block/thin lock/hash encoding; the upper bits are padding. Specific bits indicate:
BIT_SBLK_IS_HASH_OR_SYNCBLKINDEX — whether the remaining bits are a hash code or a sync block index (vs thin lock).BIT_SBLK_IS_HASHCODE — distinguishes hash code from sync block index.BIT_SBLK_SPIN_LOCK — temporary lock for header updates.BIT_SBLK_FINALIZER_RUN, BIT_SBLK_GC_RESERVE).A process-wide table of SyncBlock entries; each has:
AwareLock (monitor state: owner thread, recursion, waiters, event),The GC sweeps the table to reclaim entries for dead objects.
Monitor.Enter spins briefly before blocking (adaptive spinning), assuming critical sections are short. Monitors aren't strictly fair; threads may acquire out of order. Long critical sections with high contention degrade badly.
Monitor.Wait/PulseCondition-variable semantics require the waiting queue in the sync block — always inflates. Modern code prefers SemaphoreSlim, Channel<T>, TaskCompletionSource or ManualResetEventSlim.
If a type overrides GetHashCode, object.GetHashCode() calls the override; RuntimeHelpers.GetHashCode(obj) still uses the identity hash in the header. ReferenceEqualityComparer uses the identity hash.
.NET 9's Lock stores owner/recursion/waiters in its own fields, avoids header usage, and integrates with lock statements via EnterScope(). It cannot be used with Monitor.Wait/Pulse.
syncblk and clrstack.lock on object vs System.Threading.Lock.RuntimeHelpers.GetHashCode on an object, then lock it, and observe (via syncblk) a sync block being created.lock on an int variable and read the compiler error; then see what Monitor.Enter((object)i) would do.using System.Runtime.CompilerServices;
var gate = new object();
// Thin lock: uncontended, stored in the header
lock (gate) { Console.WriteLine("Uncontended lock taken"); }
// Identity hash code stored in the header
int hash = RuntimeHelpers.GetHashCode(gate);
Console.WriteLine($"Identity hash: {hash}");
// Lock while the header holds a hash: runtime inflates to a sync block
lock (gate) { Console.WriteLine("Lock with hash code present"); }
long before = Monitor.LockContentionCount;
Contend(gate);
Console.WriteLine($"Contentions: {Monitor.LockContentionCount - before}");
// .NET 9+: dedicated lock type, does not use the object header
var counterLock = new Lock();
int counter = 0;
Parallel.For(0, 10_000, _ => { lock (counterLock) { counter++; } });
Console.WriteLine($"Counter: {counter}");
static void Contend(object o)
{
var threads = Enumerable.Range(0, 4).Select(_ => new Thread(() =>
{
for (int i = 0; i < 1_000; i++)
lock (o) { Thread.SpinWait(200); }
})).ToList();
threads.ForEach(t => t.Start());
threads.ForEach(t => t.Join());
}
What happens: lock (this), lock (typeof(X)), lock ("key").
Why it is wrong: other code can lock the same object — deadlocks and contention; strings are interned process-wide.
Do instead: private dedicated lock objects or System.Threading.Lock.
What happens: Monitor.Enter(someInt).
Why it is wrong: boxes a new object each call — no mutual exclusion.
Do instead: lock on a reference-type field.
What happens: using hash codes as stable memory identities. Why it is wrong: objects move; hash is generated and stored. Do instead: use references or explicit IDs.
What happens: I/O inside lock.
Why it is wrong: contention, inflation, thread blocking.
Do instead: minimise locked regions; use async-friendly primitives.
What happens: different code paths lock A then B, and B then A. Why it is wrong: deadlocks. Do instead: enforce a global ordering.
What happens: hand-rolled condition variables.
Why it is wrong: error-prone (lost wakeups) and inflates sync blocks.
Do instead: SemaphoreSlim, Channel<T>, TaskCompletionSource.
Lock to objectWhat happens: storing Lock in an object field and locking it.
Why it is wrong: falls back to Monitor semantics.
Do instead: keep the Lock type.
syncblk when diagnosing hangsWhat happens: guessing at deadlocks. Why it is wrong: slow investigation. Do instead: take a dump and inspect sync blocks and stacks.
Header and sync block knowledge mainly helps when reading dumps of hung or slow processes.
| Dump finding | Likely meaning |
|---|---|
| One sync block with many waiters | Hot lock; check what the owner thread is doing |
Owner thread in I/O or .Result |
Lock held across blocking work |
| Many sync blocks | Heavy locking or many objects with hash plus lock |
| Waiters but no owner | Possible orphaned lock or Monitor.Wait pattern |
For new code, seniors prefer System.Threading.Lock and keep critical sections short and free of I/O.
Every heap object starts with a pointer to its MethodTable (the runtime's type descriptor), preceded by an 8-byte object header on 64-bit, followed by the fields. The MethodTable holds the base size, flags, parent type, the interface map and the vtable slots used for virtual dispatch; rarely used data lives in a separate EEClass. The type object is the managed System.RuntimeType instance you get from typeof(T) or GetType(); it is a normal heap object that points to the MethodTable (RuntimeTypeHandle.Value). On 64-bit the smallest object is 24 bytes. You can inspect all of this with dotnet-dump and SOS (dumpobj, dumpmt).
System.Object gives every type Equals, GetHashCode, ToString, GetType, the protected MemberwiseClone and Finalize, plus static ReferenceEquals. The review problem is the Equals/GetHashCode contract: equal objects must return equal hash codes. If Equals compares Email but GetHashCode is still identity-based, two "equal" customers land in different hash buckets, so Dictionary, HashSet, Distinct and GroupBy treat them as different. I'd implement IEquatable<Customer>, override both methods using the same immutable fields, compare emails case-insensitively in both, overload ==/!=, and seal the class. For value-like data a record generates all of this.
How to tell it (STAR)
Our [pricing service] froze every few hours. In a dump, syncblk showed one lock with [hundreds] of waiters, and the owner was waiting on HTTP. I moved the call out of the lock and used SemaphoreSlim for the async refresh, and the hangs stopped.
Last updated 31 Oct 2026.