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.
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:
| State | Meaning |
|---|---|
| Shared | The line holds a clean, read-only copy. Other cores may also hold it. |
| Modified | This core has the only copy, and it has been written since the last fetch. |
| Invalid | This 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.
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
SharedLast operation: idle
CPU 1
SharedLast operation: idle
- Scenario
- Shared line
- Step
- 0 of 0
- Owner
- none
- Invalidations
- 0
- Transfers
- 0
Event log
- 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. The runnable Maven/JMH project is at content/labs/false-sharing/code/java/ in this site's repository.
Adjacent atomics (the bug)
use std::sync::atomic::AtomicU64;
#[repr(C)]
pub struct SharedCounters {
pub counter_a: AtomicU64,
pub counter_b: AtomicU64,
}
impl SharedCounters {
pub fn new() -> Self {
Self { counter_a: AtomicU64::new(0), counter_b: AtomicU64::new(0) }
}
}
#[repr(C)] fixes declaration order — Rust's default repr(Rust) layout is otherwise unspecified. With two adjacent 8-byte AtomicU64s only 16 bytes apart, both are comfortably inside one 64-byte line on the common case. Ordering::Relaxed is sufficient for correctness here — this is not an ordering bug, the slowdown is purely coherence traffic.
Aligned wrapper (the fix)
use std::sync::atomic::AtomicU64;
#[repr(align(64))]
pub struct CacheLineAligned<T>(pub T);
pub struct PaddedCounters {
pub counter_a: CacheLineAligned<AtomicU64>,
pub counter_b: CacheLineAligned<AtomicU64>,
}
impl PaddedCounters {
pub fn new() -> Self {
Self {
counter_a: CacheLineAligned(AtomicU64::new(0)),
counter_b: CacheLineAligned(AtomicU64::new(0)),
}
}
}
#[repr(align(64))] is an alignment guarantee the compiler enforces — unlike the Java manual-padding fields, this cannot be optimized away, because alignment is part of the type's layout contract. Each field starts a fresh 64-byte-aligned line if the target's actual cache line is 64 bytes or a divisor of it — document that assumption; some ARM parts use 128-byte lines, some embedded targets 32.
Ordering: Ordering::Relaxed is enough because nothing else in the program depends on the order in which the two counters' updates become visible relative to other memory operations — only that each individual fetch_add is atomic. If real counters guard or publish other state, that needs Acquire/Release, an orthogonal decision from the alignment fix here.
Criterion benchmark
use criterion::{criterion_group, criterion_main, Criterion};
use std::sync::atomic::Ordering;
use std::sync::Arc;
use std::thread;
fn run_concurrent_increments<F: Fn() + Send + Sync + 'static>(iters: u64, op_a: Arc<F>, op_b: Arc<F>) {
let a = op_a.clone();
let b = op_b.clone();
let t1 = thread::spawn(move || { for _ in 0..iters { a(); } });
let t2 = thread::spawn(move || { for _ in 0..iters { b(); } });
t1.join().unwrap();
t2.join().unwrap();
}
fn bench_shared(c: &mut Criterion) {
c.bench_function("shared_counters", |b| {
b.iter(|| {
let counters = Arc::new(SharedCounters::new());
let c1 = counters.clone();
let c2 = counters.clone();
run_concurrent_increments(
100_000,
Arc::new(move || { c1.counter_a.fetch_add(1, Ordering::Relaxed); }),
Arc::new(move || { c2.counter_b.fetch_add(1, Ordering::Relaxed); }),
);
});
});
}
criterion_group!(benches, bench_shared);
criterion_main!(benches);
The runnable Cargo/Criterion project is at content/labs/false-sharing/code/rust/ in this site's repository — it includes both the shared and padded benchmark functions in full.
Benchmark methodology
Measured
JMH 1.37, OpenJDK 26.0.1 (HotSpot), Apple M1 Max (10 cores, no SMT), macOS, arm64. Rust: Criterion 0.5.1, rustc 1.88.0, same machine. Java: 1 fork, 3 warmup + 5 measurement iterations of 1 second each. Rust: 1 s warm-up, 30 samples targeting ~3 s collection. Single developer machine running inside a sandboxed environment, not a dedicated thermally-stable rig — treat absolute numbers as illustrative of the effect's shape, not a portable performance claim.
Method: two threads, each incrementing its own dedicated counter in a tight loop, for the shared, padded, and (Java only) @Contended layouts above. JMH @Group/Scope.Group (Java) and two joined std::thread::spawn handles (Rust) ensure both counters are written concurrently from separate OS threads — a single-threaded run of the same code shows no difference between layouts and is not a valid reproduction (see the investigation task below, step 3).
Java (JMH, ops/ms, higher is better):
| Layout | Throughput | 99.9% CI |
|---|---|---|
| Shared counters | 68,869 | ±4,506 |
| Padded counters (manual) | 255,796 | ±32,251 |
| @Contended counters | 325,893 | ±7,616 |
Rust (Criterion, time per 100,000-increment batch, lower is better):
| Layout | Median | [min, max] |
|---|---|---|
| Adjacent atomics (shared line) | 1.0017 ms | [984.80 µs, 1.0228 ms] |
| align(64) counters (padded) | 263.44 µs | [262.21 µs, 265.07 µs] |
On this run: padding gave roughly a 3.7× throughput improvement in Java and a 3.8× reduction in time-per-batch in Rust; @Contended slightly outperformed manual padding in Java. These ratios — not the absolute numbers — are the part worth trusting across machines. The runnable projects (content/labs/false-sharing/code/java/, content/labs/false-sharing/code/rust/) each emit raw per-iteration samples (JMH -rf json; Criterion's target/criterion/**/new/raw.csv plus its generated HTML report) — inspect those, and replace this table with your own environment's numbers, before treating any of it as authoritative for a design decision.
Common mistakes
- Padding by declaration order alone, without
@Contendedor 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 Java or Rust project in code/, and a profiler or hardware counter tool available to you:
- Run the shared-counters benchmark and the padded-counters benchmark and record throughput for both.
- Look for a cache-miss or coherence-event counter (e.g.
perf stat -e cache-misses, orperf c2con Linux for cache-to-cache transfer detection) and compare it between the two runs. - 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.
- 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.
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:
- Why does writing to
counterAever affect a thread that only ever reads or writescounterB? - What observable difference distinguishes false sharing from a data race, in both symptom and fix?
- Why does the JMH example need
@Group/Scope.Grouprather than two independent single-threaded benchmarks? - Give one situation where adding padding would make performance worse, not better.
Quiz
Sources
- Java Concurrency in Practice — Goetz et al., Addison-Wesley, 2006.
- Intel 64 and IA-32 Architectures Software Developer's Manual, Vol. 3A §11 (Memory Cache Control) — intel.com/sdm
- A Primer on Memory Consistency and Cache Coherence — Sorin, Hill, Wood, Morgan & Claypool, 2011.
- JEP 142: Reduce Cache Contention on Specified Fields (@Contended) — openjdk.org/jeps/142
- The Rust Reference — Type Layout (repr(align)) — doc.rust-lang.org/reference/type-layout
- JMH Samples — JMHSample_22_FalseSharing — github.com/openjdk/jmh
- Criterion.rs User Guide — bheisler.github.io/criterion.rs