Cache coherence · Performance Lab

False Sharing

Two threads write to two completely independent counters. No lock, no shared invariant, no data race — and throughput collapses anyway, because the hardware moved both counters in and out of cache as one indivisible block.

Performance question and hypothesis

Question: why can independent counters destroy throughput when they occupy the same cache line?

Hypothesis: coherence traffic — not logical sharing — causes the collapse; padding or ownership partitioning restores scalability.

What would disprove it: if the throughput gap between adjacent and padded counters persisted with both threads on the same core (no cross-core coherence traffic possible), or if separating the counters onto different cache lines did not restore throughput while everything else stayed fixed, the coherence-traffic explanation would be wrong. The benchmark matrix below is designed so each alternative explanation — the atomic operations themselves, memory-ordering guarantees, scheduler effects — is separable from the layout variable.

Learning objective

Explain why two threads writing to independent, unrelated variables can destroy each other's throughput, and be able to tell that failure mode apart from a data race or lock contention — then know when padding is worth its memory cost and when it isn't.

Prerequisites

  • Comfortable reading Java or Rust concurrent code (atomics, threads).
  • A basic idea of what a CPU cache is — a fast copy of main memory kept close to a core. You do not need prior knowledge of coherence protocols; this lab builds that from scratch.

Theory

Cache-line theory

CPUs do not move memory between cache and RAM one byte, or one variable, at a time. They move it in fixed-size blocks called cache lines — commonly 64 bytes on current x86-64 and ARM64 parts, but the exact size is a hardware detail, not an architectural guarantee (query CPUID or sysctl hw.cachelinesize at runtime if it matters to you). This lab treats 64 bytes as a common example, never a universal constant.

When a core reads a memory address, the whole line containing it is pulled into that core's cache. If two independent long counters happen to sit inside the same 64-byte line — because they're adjacent fields in the same object, or elements of the same small array — the hardware has no way to know they are logically unrelated. As far as the coherence protocol is concerned, there is exactly one cache line, and only one core may hold it writable at a time.

False sharing is what happens when two threads on different cores repeatedly write to different variables that live on that same line. Each write by core A forces core B's copy of the line to be invalidated — even though core B never touched core A's variable — and vice versa. The line ping-pongs between cores, and every write pays the cost of a cross-core transfer instead of hitting a warm local cache.

A simplified coherence model

Real coherence protocols (MESI and its variants — MESIF, MOESI) have more states and more nuance than this lab models. The interactive visualisation below uses three states, which is enough to see the mechanism:

StateMeaning
SharedThe line holds a clean, read-only copy. Other cores may also hold it.
ModifiedThis core has the only copy, and it has been written since the last fetch.
InvalidThis core's copy is stale and must be re-fetched before use.

The rule that produces false sharing: a write always forces every other core's copy of that line to Invalid, regardless of which bytes in the line were actually written.

False sharing vs. a data race

A data race is a correctness bug: two threads access the same memory location without synchronization, and at least one is a write. The language memory model (JMM, Rust's) gives no guarantee about what value is observed. Fixing it requires synchronization — locks, atomics, happens-before edges.

False sharing is a performance bug on code that is already correct. The two threads write to genuinely independent variables — there is no shared logical state and no synchronization needed for correctness. The cost is purely the extra coherence traffic caused by physical layout. Fixing it requires separating the variables onto different cache lines, not adding synchronization. A data race can exist without false sharing, and false sharing can exist without any race — the two are orthogonal.

False sharing vs. lock contention

Lock contention happens when threads want the same critical section at the same time and must serialize — one waits while another holds the lock. The cost is explicit: queuing, context switches, or spin-wait cycles, visible as threads blocked on the same lock in a profiler or thread dump.

False sharing requires no lock at all. Both threads run concurrently and never wait for each other in the logical sense. The threads make forward progress the whole time; the cost is hidden inside a slower memory subsystem, not visible as blocking — a profiler has to look at cache-miss counters or coherence-traffic hardware counters, not lock wait time, to see it. The two can compound (a lock's own state can itself be a false-sharing victim), but eliminating one does not eliminate the other.

Limitations of this model: cache-line size is not universal, real coherence protocols have more states and directory-based coherence on many-socket systems, and padding has a real memory cost — see "When not to use padding" below.

Try it

Interactive cache-line model

Conceptual model

A simplified three-state view of MESI-like ownership (Shared / Modified / Invalid), not a cycle-accurate coherence simulation. See "A simplified coherence model" above for what this leaves out.

Both counters live on one cache line. Every write from either CPU invalidates the other's copy — alternate CPU 0 write / CPU 1 write and watch invalidations climb.

CPU 0

Shared

Last operation: idle

CPU 1

Shared

Last operation: idle

Scenario
Shared line
Step
0 of 0
Owner
none
Invalidations
0
Transfers
0

Event log

  1. Initial state — both lines shared.

Java and Rust

Shared counters (the bug)

public class SharedCounters {
    // Adjacent fields of the same object are very likely to land on the
    // same 64-byte cache line — the JVM makes no promise either way, but
    // in practice contiguous long fields with no padding between them do.
    public volatile long counterA;
    public volatile long counterB;
}

Thread 1 spins on counterA++, thread 2 spins on counterB++. Neither thread ever touches the other's field — no data race, no lock, no shared invariant. But every write to counterA invalidates core 2's cached copy of the line holding counterB, and vice versa.

Manual padding (the fix)

public class PaddedCounters {
    public volatile long counterA;
    // 7 longs = 56 bytes of padding. Combined with the 8-byte counterA,
    // that pushes counterB onto the next 64-byte line on a machine with
    // that line size — a documented assumption, not a guarantee.
    public long p1, p2, p3, p4, p5, p6, p7;
    public volatile long counterB;
}

Two risks: the JIT could in principle eliminate unread padding fields (HotSpot does not currently do this for instance fields shaped like this, but the spec doesn't forbid it), and the JVM is free to reorder fields for alignment, so declaration-order padding is a practical technique, not a guarantee. @Contended avoids both.

@Contended (the supported fix)

import jdk.internal.vm.annotation.Contended;

public class ContendedCounters {
    @Contended
    public volatile long counterA;
    @Contended
    public volatile long counterB;
}

Caveat: @Contended lives in jdk.internal.vm.annotation, an internal package. Application code needs --add-exports java.base/jdk.internal.vm.annotation=ALL-UNNAMED on the JVM command line to use it outside the JDK itself, and OpenJDK gives no cross-version compatibility promise for internal packages. It pads to a JVM-internal group size (-XX:ContendedPaddingWidth, default 128 bytes), not the value in source.

JMH benchmark

import org.openjdk.jmh.annotations.*;
import java.util.concurrent.TimeUnit;

@State(Scope.Group)
@BenchmarkMode(Mode.Throughput)
@OutputTimeUnit(TimeUnit.MILLISECONDS)
@Fork(value = 1, jvmArgsAppend = {"-XX:-RestrictContended"})
@Warmup(iterations = 5, time = 1)
@Measurement(iterations = 10, time = 1)
public class FalseSharingBenchmark {

    private final SharedCounters shared = new SharedCounters();
    private final PaddedCounters padded = new PaddedCounters();

    @Benchmark
    @Group("shared")
    public void writeA_shared() { shared.counterA++; }

    @Benchmark
    @Group("shared")
    public void writeB_shared() { shared.counterB++; }

    @Benchmark
    @Group("padded")
    public void writeA_padded() { padded.counterA++; }

    @Benchmark
    @Group("padded")
    public void writeB_padded() { padded.counterB++; }
}

@Group with Scope.Group is what makes JMH run writeA_* and writeB_* concurrently on separate threads within the same group — without it the benchmark never reproduces the cross-core invalidation traffic being measured. JMH assigns one thread per @Benchmark method in a group by default.

A fourth variant — per-thread shards + reduction (ShardedCounters) — removes the problem instead of padding around it: every thread owns one 64-byte-strided shard in a long[] and nobody else ever writes it; readers reduce over the shards. Because each shard has exactly one writer, the owner needs no atomic read-modify-write at all — a plain read plus a VarHandle setRelease write suffices, paired with getAcquire on the reducing side. That weaker, cheaper contract is only correct because the single-writer invariant holds, and the correctness suite asserts exactly that invariant's observable result against the shared fixture. The executable benchmark and correctness suite are used internally to produce the evidence shown by this laboratory.

Benchmark methodology

The shared and padded layouts are measured in separate JVM invocations with JMH (independent forks, one writer per counter, each writer pinned to its own physical core with verified placement), field layouts are verified with JOL rather than assumed, and hardware-counter evidence (perf stat, perf c2c) is collected per variant; a correctness gate runs before any timing is trusted.

Awaiting native-Linux measurement

The benchmark implementation and correctness checks exist, but no canonical native-Linux evidence has been imported and reviewed. No publication-grade performance numbers are shown yet. Canonical results for this laboratory are collected on the dedicated native-Linux benchmark host with explicit physical-CPU placement, full environment capture, correctness gates, independent JVM forks, and profiler evidence where required.

Profiler and counter evidence

The mechanism claim above — that the collapse is coherence traffic — has a direct hardware signature: cache-to-cache transfers (HITM, "hit in another core's modified line") in perf c2c, and a large gap in cache-miss counters between the shared and padded layouts in perf stat. Native Linux evidence is required for this lab's cache-coherence analysis. macOS is used only for development, correctness testing and benchmark smoke validation; emulated environments, containers on foreign architectures, shared CI runners and synthetic examples are never publication evidence.

Status: awaiting-native-linux-measurement

The repository provides an executable evidence runner, scripts/performance-lab/run-linux-evidence.sh, which runs the shared and padded variants separately on explicitly selected physical CPU cores (validated to be distinct physical cores on one socket/NUMA node — SMT siblings are rejected, cross-socket requires an explicit flag) and captures JMH result artifacts, host and CPU topology, toolchain configuration, perf stat counters, perf c2c recordings and reports, correctness-gate results and cryptographic hashes for every artifact. This section is populated only from artifacts captured on a supported, native Linux host and imported through the canonical result pipeline (scripts/performance-lab/import-evidence.sh). Until those artifacts have been collected and reviewed, the laboratory remains implemented but not evidence-verified.

What the imported report will be read for: a false-sharing victim appears as one cache line with a high HITM count whose accesses come from two different data addresses inside that line (offsets 0 and 8 for these two counters), each written by a different thread; the padded run shows the same two addresses on different lines with the HITM count collapsed. The evidence-interpretation exercise below uses a clearly labeled synthetic example to teach that reading — synthetic output is educational only and cannot support this lab's performance conclusions.

Common mistakes and benchmark traps

Four traps this lab is explicitly designed to avoid — check for each before trusting any false-sharing measurement, including this lab's:

  • Using thread count without topology. "Two threads" says nothing about where they run: SMT siblings share an L1 and never generate cross-core coherence traffic; cross-socket threads pay far more per transfer. A result that doesn't state thread placement is not reproducible — the placement matrix above marks which placements this lab's host cannot even express.
  • Padding local objects that are not adjacent. Padding only does anything when the two hot variables would otherwise share a line. Padding thread-local objects, or objects the allocator already placed apart, changes nothing — and "the padded version measured the same" then gets misread as "false sharing doesn't matter". Verify adjacency first.
  • Claiming volatile itself is the cause. The counters are volatile/atomic in every variant — shared, padded and sharded alike — and the collapse appears only in the shared-line layout. Dropping volatile "to fix false sharing" trades a performance bug for a correctness bug and leaves the layout problem in place.
  • Using different memory-order guarantees across compared variants. A Java volatile increment and a Rust fetch_add(Relaxed) are different contracts with different costs on weakly-ordered hardware. This lab keeps ordering constant within each language's variant set and discloses the cross-language difference instead of pretending it away.

Further mistakes worth knowing:

  • Padding by declaration order alone, without @Contended or an explicit alignment attribute, and trusting it survives optimization or field reordering across every JVM/compiler version.
  • Assuming a universal 64-byte cache line — query it or state the assumption explicitly.
  • Diagnosing false sharing as "just make it volatile" or adding a lock — neither addresses layout, and a lock introduces contention that wasn't there before.
  • Padding everything defensively instead of only identified hot, independently-written fields.
  • Benchmarking on one thread — false sharing is invisible in a single-threaded microbenchmark; it only appears with genuinely concurrent access from different cores.

When to use padding

Use padding when

  • Counters/flags are hot (frequently written) and written by different threads running on different cores.
  • Fields have no other reason to be adjacent — they aren't part of one atomically updated struct.

Avoid padding when

  • Data is read-mostly — reads don't invalidate other cores' copies the way writes do, so the cost padding solves barely exists.
  • Access is single-threaded or confined to one core — there is no false sharing to fix.
  • Applied indiscriminately — extra padding inflates struct size, hurting cache footprint and prefetching across arrays of structs.

Investigation task

Using the lab's Java or Rust implementation and a profiler or hardware counter tool available to you:

  1. Run the shared-counters benchmark and the padded-counters benchmark and record throughput for both.
  2. Look for a cache-miss or coherence-event counter (e.g. perf stat -e cache-misses, or perf c2c on Linux for cache-to-cache transfer detection) and compare it between the two runs.
  3. Reduce the benchmark to a single thread and confirm the shared-vs-padded gap disappears — this isolates the effect to concurrent cross-core access, not the layout alone.
  4. Write down your CPU model and core topology (are the two threads pinned to different physical cores, or could they land on SMT siblings of the same core?), your measured numbers, and explain any difference from the disclosed numbers above.

Exercises

Three exercises, one per skill this lab teaches: diagnosing the mechanism, fixing it with code, and reading the hardware's own evidence. Hints and solutions are collapsed — attempt each before opening them, and verify against the stated success criteria. The executable benchmark and correctness suite are used internally to produce the evidence shown by this laboratory.

1. Diagnosis — a deliberately flawed benchmark

A colleague "reproduces" this lab with @State(Scope.Thread), two plain (ungrouped) @Benchmark methods over non-volatile fields, runs -t 2, measures no difference between padded and unpadded — and concludes false sharing is a myth. Identify every reason this benchmark cannot observe false sharing, regardless of padding (there are at least three independent flaws). Success criteria: you can name each flaw, explain why it hides the effect (which line is or isn't shared, which threads write it), and state the minimal fix.

Hint

Look at (1) what Scope.Thread does to the object instances, (2) which fields the two ungrouped methods write and from which threads, and (3) whether a register-held counter generates per-increment line traffic at all.

Solution sketch

Scope.Thread gives each thread its own instance (nothing is shared, falsely or otherwise — padding non-adjacent locals changes nothing); without @Group the two methods never run concurrently against one state object in the same measurement; and non-volatile counters can be register-allocated so the loop rarely touches memory. Fix: Scope.Group + @Group methods + volatile fields kept identical across variants — exactly the lab benchmark's shape. Bonus: thread placement is still uncontrolled without topology awareness.

2. Implementation — restructure a hot status struct

A WorkerStatus struct mixes a hot worker-owned counter (~1M writes/s), a cold worker-owned heartbeat (1 write/s), and a once-ever shutdown flag written by another thread and read hot by all workers, stored in a Vec<WorkerStatus>. Restructure it so no hot write can invalidate a line another thread reads or writes — without padding every field. Success criteria (measure, don't assert): counts stay exact after join; two-worker throughput of the hot counter is within noise of one-worker throughput on a Criterion harness modeled on this lab's; total size grew by less than a cache line per field.

Hint

Group fields by (writer thread, write rate). Hot worker-owned data wants a line per worker; cold fields of different workers can share lines with each other far more cheaply than with any hot field; a written-once flag settles into every reader's cache in Shared state and needs no padding at all.

3. Evidence interpretation — which run is the shared layout?

Given two perf stat outputs from the same cycle budget — one with IPC 0.20, 512M L1-dcache-load-misses and 498M memory-ordering machine clears; the other with IPC 0.78, 11M misses and 142K clears — decide which run used the shared layout, justify it from at least three separate counters, and state one conclusion the numbers cannot support. (The full exercise text shows the complete output; it is a clearly-labeled synthetic example in perf stat format, educational only — it is never measurement evidence and never supports this lab's conclusions; the real counter evidence comes exclusively from the native-Linux evidence runner. The counter relationships model the published x86-64 false-sharing signature.) Success criteria: correct identification; each cited counter's reasoning mentions invalidation→refetch; and your "cannot conclude" statement is genuinely unsupported by the data rather than merely cautious.

Solution sketch

The IPC-0.20 run is the shared layout: same cycles, ~4× less work retired; ~46× more L1 misses (refetch after invalidation); ~3,500× more memory-ordering machine clears (speculative work discarded when the line is yanked mid-flight). The data cannot attribute the effect to any particular variable or prove padding caused the other run's improvement — the labels were removed and nothing in the counters shows layout; line-level attribution is what perf c2c's HITM report adds.

Accessibility notes

  • The interactive model is fully keyboard operable: the scenario selector and the Java/Rust code tabs are roving-tabindex tablists (arrow keys, Home/End); every write/read/step/reset control is a native <button>.
  • Every state change is announced to screen readers via an aria-live="polite" region, in addition to the visible state badges and the state inspector table — state is never colour-only.
  • The coherence-bus pulse is the only animation on this page; it plays once per action and is fully collapsed under prefers-reduced-motion: reduce (this page has no auto-playing or looping animation to separately gate on that preference).
  • With JavaScript disabled, this page's theory, Java and Rust code, benchmark methodology, trade-offs and sources remain fully readable — only the interactive model and the graded quiz require script.

Review questions

Self-check before or after the graded quiz below:

  1. Why does writing to counterA ever affect a thread that only ever reads or writes counterB?
  2. What observable difference distinguishes false sharing from a data race, in both symptom and fix?
  3. Why does the JMH example need @Group/Scope.Group rather than two independent single-threaded benchmarks?
  4. Give one situation where adding padding would make performance worse, not better.

Quiz

Sources