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:

  1. Thread 1 writes → cache line marked Modified (M) in Core 1
  2. Core 2 wants to write → sends Invalid (I) to Core 1
  3. Core 1 flushes line to L3/L2
  4. Core 2 gets line in Exclusive (E) → Modified (M)
  5. 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:-RestrictContended for 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.