Skip to main content
PRISM

False sharing

advanced · occasionally asked

Two threads, two separate counters, one cache line — and adding the second thread makes it slower than one. Pad the struct and watch it recover.

Computing the curve…

The problem it solves

Two threads. Two separate counters. Neither touches the other’s. Each increments its own as fast as it can.

Adding the second thread makes the program slower than it was with one.

Nothing is shared, logically. The variables are distinct, no lock is involved, no atomic is contended. What is shared is the sixty-four bytes they happen to sit in — because cache coherence works in lines, not variables, and the hardware has no idea that your two counters are unrelated.

This is false sharing, and it is worth a page for three reasons: the slowdown is an order of magnitude rather than a few percent, nothing in the source code hints at it, and the fix is a memory layout change with no effect on what the program means.

The mechanism

A cache line is the unit of coherence, typically 64 bytes. When core A writes to any byte in a line, the protocol invalidates every other core’s copy of that whole line. Core B, which cares about a different variable in the same line, must now re-fetch it from A’s cache before it can proceed — and then B’s write invalidates A’s copy, and the line bounces back.

Each of those transfers is roughly the cost of a cache miss that has to go to another core’s cache: on the order of 80 nanoseconds, against about 1 nanosecond for an L1 hit. Two threads writing to the same line do not run twice as fast; they serialise on the line, and each pays a coherence round trip per write.

The consequence is counter-intuitive and worth stating plainly: throughput on a shared line is roughly constant regardless of thread count, at about one write per coherence round trip. Adding cores adds contention without adding capacity.

The fix is padding: place each variable in its own cache line, so the invalidations never collide. Wasteful of memory by construction — you are deliberately leaving fifty-six bytes empty — and worth it by orders of magnitude when the variables are hot.

What the enumeration shows

This is a model page rather than an interleaving space, because every ordering gives the same throughput; what varies is a layout.

At one thread, both lines sit at the same value — nothing to contend with. At two, the shared-line series drops below the single-thread figure, which is the result worth staring at. The second core did not add throughput; it added coherence traffic that both now pay.

Drag the thread count up and the shared line stays flat while the padded line climbs linearly. At eight threads the gap is roughly 640×. That number is not a typo and it is not a micro-optimisation: it is the difference between using your cores and not.

The constants are named in the source — 1ns for an L1 hit, 80ns for a coherence miss — so the curve is reproducible rather than asserted.

The numbers worth carrying

  • 64 bytes is the cache line on x86 and ARM. Apple Silicon uses 128 for some caches; some prefetchers work in 128-byte pairs, which is why conservative padding uses 128.
  • L1 hit ~1ns; coherence miss ~80ns. Roughly an 80× difference per operation.
  • Eight threads, shared line versus padded: ~640× throughput difference in this model.
  • An array of per-thread counters — long[] counts indexed by thread id — puts eight counters in one line on a 64-bit machine. This is the single most common accidental instance, and it looks like the obvious way to write it.

Where it breaks down

It only matters for writes. Multiple cores can hold a line in shared state and read it simultaneously with no traffic at all. False sharing is a write-invalidation phenomenon, which is why read-mostly data does not suffer from it.

Padding costs memory. 64 bytes per counter instead of 8 is an eightfold increase. For sixteen thread-local counters that is a kilobyte and obviously worth it; for a million objects it is not, and the answer there is usually to restructure so the hot fields are grouped rather than to pad every object.

True sharing looks identical in a profile. If the threads genuinely contend on the same variable, padding fixes nothing — the line bounces because the data bounces. Distinguishing the two requires knowing whether the addresses differ, and that is what the hardware counters tell you.

It moves. Padding is a fact about layout, and layout is decided by the compiler, the allocator and the JIT. A JVM may rearrange fields; an allocator may place two padded objects adjacently anyway. This is why @Contended and alignas exist rather than manual dummy fields — they express the intent to the thing that actually decides.

What people get wrong

“Nothing is shared, so there is no contention.” The variables are not shared. The cache line is. That distinction is the whole page.

“It is a micro-optimisation.” An 80× per-operation penalty on a hot path is not micro. It is the reason a parallel loop can be slower than a sequential one.

“Adding threads cannot make it slower.” It can, and this is the cleanest example. Two threads on a shared line are worse than one.

“I will pad with a few extra fields.” The compiler may reorder or eliminate them. Use the platform’s mechanism — @jdk.internal.vm.annotation.Contended in Java (with -XX:-RestrictContended), alignas(64) in C++, #[repr(align(64))] in Rust, _ [64]byte padding in Go structs.

In production

Where it appears in real code: per-thread statistics arrays, where the natural counts[threadId] layout packs eight counters into one line; lock objects stored adjacently, so threads taking different locks still contend — which is a real hazard when striping a lock as on the lock granularity page; head and tail pointers of a queue, touched by producer and consumer respectively and traditionally declared next to each other; and hot fields beside cold ones in an object, where a frequently written counter shares a line with immutable configuration read by everybody.

The tooling: perf c2c on Linux is built specifically for this and will identify the contended line and the offending offsets. VTune has a false-sharing analysis. Failing those, the diagnosis by symptom is “parallel version is slower than sequential, CPU is high, no lock is contended”, and the experiment is to pad and re-measure.

The libraries that already solved it are worth reaching for first: LongAdder in Java is a padded, striped counter and is dramatically faster than an AtomicLong under contention; Disruptor pads its sequence numbers and documents why; crossbeam’s CachePadded in Rust is a wrapper type.

The follow-up questions

“Two threads, two separate counters, and it is slower than one thread. Why?” — Same cache line. If the answer is “contention”, the follow-up is “on what?”, and the answer is the line rather than the data.

“How do you fix it?” — Pad to a cache line, using the platform mechanism rather than dummy fields. Say what it costs in memory.

“How would you confirm it?” — perf c2c, or pad and measure. Guessing is how people pad things that were never contended.

“Where does it show up by accident?” — Per-thread counter arrays, adjacent locks, queue head and tail. Naming one unprompted is a strong signal.

In an interview

A real interview question and a real profiling story: the fix is a memory layout change with no effect on what the code means.

  • false sharing
  • cache line
  • padding
  • coherence

Run these next

The rest of memory and hardware