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

📚 Interview question

What are the object header and the sync block in .NET, and how do lock and GetHashCode use them?

Header bits for thin locks and hash codes, inflation to the sync block table, Monitor internals, and System.Threading.Lock.

Senior Theory Expert clr locking monitor object-header sync-block

🎯 Key points

  • The object header holds a thin lock, an identity hash code or a sync block index
  • Uncontended Monitor locks are thin locks in the header
  • Contention, Wait/Pulse or a hash plus a lock inflate the object to a sync block
  • Default GetHashCode stores the identity hash in the header
  • System.Threading.Lock (.NET 9+) has its own state and does not use the header
  • Diagnose with lock contention counters and SOS syncblk
  • Lock on private objects and never hold a lock across I/O

⏱ 30-second answer

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.

🗣 Full answer (senior)

How I would answer this in an interview

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.

1. What the header can hold

[ 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.

2. How lock (Monitor) uses it

lock (_sync) { _balance -= amount; }   // Monitor.Enter / Monitor.Exit
  1. Uncontended fast path (thin lock): 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.
  2. Contention or extra needs: if another thread holds the lock (after spinning briefly), or recursion exceeds what fits, or the header already contains a hash code, or someone calls 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.
  3. Waiting threads block on an OS event inside the sync block; Monitor.Exit wakes them.

Sync blocks are reused when objects die (cleaned up during GC).

3. How GetHashCode uses it

object.GetHashCode() (not overridden) and RuntimeHelpers.GetHashCode(obj) return the identity hash:

  1. If the header is empty, the runtime generates a pseudo-random hash and stores it in the header.
  2. If the header holds a thin lock, the hash can't fit too → the runtime inflates to a sync block that stores both.
  3. Later calls read the hash from the header or sync block, so it's stable for the object's lifetime even though the GC moves the object.

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.

4. Practical implications

  • Uncontended lock is cheap (tens of nanoseconds); contention is what's expensive (spinning, inflation, kernel waits, context switches).
  • Lock on private, dedicated objects: 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.
  • Don't lock on value types: lock requires a reference type; Monitor.Enter(int) would box a new object each time → no mutual exclusion.
  • .NET 9 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.
  • Many locked objects (e.g. per-entity locks on millions of objects) can create many sync blocks; prefer striped locks or concurrent collections.
  • Diagnostics: dotnet-dump analyze → syncblk shows sync blocks with owners and waiters — invaluable for deadlock analysis.

5. A production example

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.

🔍 What the interviewer is checking

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.

📚 Learn in depth

1. Header bits (64-bit, simplified)

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.
  • GC-reserved bits (e.g. BIT_SBLK_FINALIZER_RUN, BIT_SBLK_GC_RESERVE).
  • Thin lock: thread ID bits + recursion level bits.

2. Sync block table

A process-wide table of SyncBlock entries; each has:

  • AwareLock (monitor state: owner thread, recursion, waiters, event),
  • hash code storage,
  • COM interop data (RCW/CCW info on Windows),
  • weak reference back to the object.

The GC sweeps the table to reclaim entries for dead objects.

3. Monitor spinning and fairness

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.

4. Monitor.Wait/Pulse

Condition-variable semantics require the waiting queue in the sync block — always inflates. Modern code prefers SemaphoreSlim, Channel<T>, TaskCompletionSource or ManualResetEventSlim.

5. Identity hash vs overridden GetHashCode

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.

6. System.Threading.Lock

.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.

Try it yourself

  1. Create a deadlock with two locks in a test app, take a dump, and run syncblk and clrstack.
  2. Benchmark uncontended lock on object vs System.Threading.Lock.
  3. Call RuntimeHelpers.GetHashCode on an object, then lock it, and observe (via syncblk) a sync block being created.
  4. Try lock on an int variable and read the compiler error; then see what Monitor.Enter((object)i) would do.

💻 Code

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());
}

⚠ Common mistakes

1. Locking on public or shared objects

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.

2. Locking on boxed value types

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.

3. Assuming identity hash is the address

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.

4. Long critical sections

What happens: I/O inside lock. Why it is wrong: contention, inflation, thread blocking. Do instead: minimise locked regions; use async-friendly primitives.

5. Inconsistent lock ordering

What happens: different code paths lock A then B, and B then A. Why it is wrong: deadlocks. Do instead: enforce a global ordering.

6. Using Monitor.Wait/Pulse in new code

What happens: hand-rolled condition variables. Why it is wrong: error-prone (lost wakeups) and inflates sync blocks. Do instead: SemaphoreSlim, Channel<T>, TaskCompletionSource.

7. Converting Lock to object

What 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.

8. Not using syncblk when diagnosing hangs

What happens: guessing at deadlocks. Why it is wrong: slow investigation. Do instead: take a dump and inspect sync blocks and stacks.

🏛 Senior insight: trade-offs

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.

🔗 Follow-ups the interviewer may ask

  1. When: Candidate says the header sits before the MethodTable pointer Why: Checks object layout knowledge
    How to answer

    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).

  2. When: Candidate mentions default GetHashCode Why: Probes equality and hashing contracts
    How to answer

    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.

🛠 Real-world problems: how you handle them

Hung service traced to a lock held across a synchronous HTTP call
Situation
A pricing service stopped responding under load every few hours.
Signals
Thread count climbed to [several hundred], requests timed out, CPU was low.
Investigation
Collected a dump with `dotnet-dump`; `syncblk` showed one sync block with [hundreds] of waiters, and `clrstack` on the owner showed a blocking `HttpClient` call inside the lock.
Resolution
Moved the HTTP call outside the lock, cached results with a `SemaphoreSlim`-guarded refresh, and switched remaining sync locks to `System.Threading.Lock`.
Prevention
Added an alert on lock contention count and a code review rule against I/O inside locks.

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.

↩ Usually asked after

More in Runtime Execution

Last updated 31 Oct 2026.

🌐 Sangam's Webring

⏪ Previous 🏠 Main Next ⏩
💬 Chat with me