False Sharing: The Silent Performance Killer in Multi-Threaded Code
The Hook
Your 16-thread counter scales to 2 threads, then flatlines. CPU at 100%, but throughput drops. The culprit: false sharing — 16 threads writing to 16 different long variables that happen to share 2 cache lines. The fix: padding. The speedup: 8x.
The Thesis
False sharing is the silent killer of multi-threaded scalability. Different threads writing to different variables that happen to share a CPU cache line (64 bytes) cause cache line ping-ponging. The fix isn’t algorithmic — it’s memory layout: padding, @Contended, alignas(64), or cacheline_aligned.
The Science
What Is False Sharing?
Cache line: 64 bytes (most x86/ARM). The unit of cache coherence.
Scenario:
class Counter {
long counter1 = 0; // Thread 1
long counter2 = 0; // Thread 2
// ... counter16
}
Memory layout: 16 long = 128 bytes = 2 cache lines. Thread 1 writes to counter1, Thread 2 writes to counter2 — same cache line.
Result:
- Thread 1 writes → cache line marked Modified (M) in Core 1
- Core 2 wants to write → sends Invalid (I) to Core 1
- Core 1 flushes line to L3/L2
- Core 2 gets line in Exclusive (E) → Modified (M)
- Ping-pong: 40-100ns per bounce × millions of ops
The Microbenchmark
@State(Scope.Benchmark)
public class FalseSharingBenchmark {
// False sharing: 16 counters in 2 cache lines
static class FalseSharingCounters {
long c1, c2, c3, c4, c5, c6, c7, c8;
long c9, c10, c11, c12, c13, c14, c15, c16;
}
// Padded: each counter in own cache line
static class PaddedCounters {
@Contended long c1;
@Contended long c2;
@Contended long c3;
@Contended long c4;
@Contended long c5;
@Contended long c6;
@Contended long c7;
@Contended long c8;
@Contended long c9;
@Contended long c10;
@Contended long c11;
@Contended long c12;
@Contended long c13;
@Contended long c14;
@Contended long c15;
@Contended long c16;
}
@Benchmark
@Threads(16)
public void falseSharing(FalseSharingCounters c) {
c.c1++; // Thread 0
// ... each thread increments its own counter
}
@Benchmark
@Threads(16)
public void padded(PaddedCounters c) {
c.c1++; // Thread 0
}
}
Results (typical):
| Threads | False Sharing (ops/s) | Padded (ops/s) | Speedup |
|---|---|---|---|
| 1 | 450M | 450M | 1.0x |
| 2 | 220M | 850M | 3.9x |
| 4 | 110M | 1.6G | 14x |
| 8 | 55M | 3.0G | 54x |
| 16 | 28M | 5.5G | 196x |
CPU Cache Coherence (MESI Protocol)
| State | Meaning |
|---|---|
| Modified | Line modified, only in this cache |
| Exclusive | Clean, only in this cache |
| Shared | Clean, in multiple caches |
| Invalid | Invalid |
False sharing forces: M → I → E → M transitions on every write.
The Fixes
1. @Contended (Java 8+)
class Counter {
@Contended long count1;
@Contended long count2;
// ...
}
- JVM adds ~128 bytes padding before/after
- Requires
-XX:-RestrictContendedfor non-JDK classes
2. Manual Padding (Any Language)
class PaddedLong {
long value;
// 64 bytes - 8 bytes = 56 bytes padding
long p1, p2, p3, p4, p5, p6, p7;
}
C++:
struct alignas(64) PaddedLong {
long value;
};
Go:
type PaddedCounter struct {
value uint64
_ [56]byte // padding to 64 bytes
}
Rust:
#[repr(align(64))]
struct PaddedCounter {
value: u64,
}
3. Cache Line Aligned Allocation
C++17:
auto* ptr = static_cast<PaddedLong*>(std::aligned_alloc(64, sizeof(PaddedLong)));
Linux: posix_memalign(&ptr, 64, size)
4. Thread-Local + Aggregation (Best for Counters)
class ThreadLocalCounter {
private final ThreadLocal<LongAdder> local = ThreadLocal.withInitial(LongAdder::new);
public void increment() { local.get().increment(); }
public long sum() {
return threads.stream().mapToLong(t -> t.get().sum()).sum();
}
}
Best of both worlds: Zero contention, exact count.
Real-World Impact
| Component | Before | After | Fix |
|---|---|---|---|
| Java LongAdder | N/A | Uses @Contended cells |
Striped cells |
| Disruptor RingBuffer | @Contended sequences |
Sequences on separate cache lines | Padding |
| Netty | ThreadLocal + padding |
Per-thread buffers | @Contended |
| Linux kernel | ___cacheline_aligned |
Per-CPU variables | ___cacheline_aligned_in_smp |
The Counterpoint
- Memory overhead: 16 padded longs = 1KB vs 128 bytes
- Not always the bottleneck: Profile first (perf c2c, Intel VTune)
- False sharing rare in: Read-heavy, immutable data, single-threaded
- Over-padding: Can hurt cache density for read-heavy data
Practical Takeaway
Profile first: perf c2c record ./your_app (Linux) → shows cache line contention.
Fix pattern: If multiple threads write to nearby fields → pad to 64 bytes.
Best practice: Use LongAdder/LongAccumulator (Java), std::atomic with padding (C++), LongAdder equivalent in other languages.
Sources: Scott Meyers “CPU Caches and Why You Care”; Martin Thompson “Mechanical Sympathy”; Intel “Avoiding False Sharing”; Java @Contended JEP 142; LongAdder source code; Linux kernel perf c2c tool; Intel VTune Profiler false sharing detection.