Definition
- Cache line – the smallest unit of data transfer between CPU cache and main memory, typically 64 bytes on modern CPUs.
- False sharing – two variables fit on the same 64-byte cache line. Thread-1 writes variable-A on Core-1. The CPU invalidates the entire cache line on Core-2, even though Thread-2 only uses variable-B (which was not modified). Thread-2 must reload the line from L3 cache or main memory. This ping-pong happens on every write.
- Manual padding – insert dummy fields (7 longs = 56 bytes) between hot variables to push them onto separate cache lines.
- @Contended –
@jdk.internal.vm.annotation.Contendedtells the JVM to insert padding automatically (128 bytes by default). Requires-XX:-RestrictContendedor internal module access. - LongAdder – uses
@Contendedon its internalCellclass so each thread's cell occupies its own cache line, which is why LongAdder is 10x faster than AtomicLong under high contention.
Two tellers at National Bank of India sit at separate desks with their own calculators, but the calculators are placed on the same small shared shelf. When Rajesh presses a button on his calculator, the entire shelf vibrates, resetting Priya's calculator display. Priya must re-read her number. They are not sharing data, they are sharing the shelf (cache line). The fix is to give each teller their own shelf (padding). No more vibration, no more interference.
Understanding the Concepts
What is false sharing?
False sharing is a hardware-level performance problem. When two threads on different CPU cores write to different variables that happen to reside on the same cache line, each write invalidates the entire cache line on the other core. The other core must reload the line from L3 cache or main memory before it can write its own variable. This back-and-forth invalidation is called cache-line ping-pong.
The performance impact is severe: each write that would normally take 1-5 nanoseconds (L1 hit) takes 40-100 nanoseconds (L3 or main memory access) instead. Under high contention, this means 10-100x slower throughput for operations that appear completely independent.
How does it work at the hardware level?
Modern CPUs use the MESI protocol (Modified, Exclusive, Shared, Invalid) to maintain cache coherence across cores. When Core-1 writes to a cache line, the protocol changes the line's state to Modified on Core-1 and Invalid on all other cores. Any subsequent read or write by Core-2 to any byte on the same line requires Core-2 to fetch the updated line from Core-1's cache.
package com.bank.concurrency;
public class FalseSharingDemo {
// WRONG: both counters on the SAME cache line
static volatile long buyCounter; // 8 bytes, offset 16 (after object header)
static volatile long sellCounter; // 8 bytes, offset 24
// Both fit within one 64-byte cache line!
// Every write to buyCounter invalidates sellCounter on other core
public static void main(String[] args) throws InterruptedException {
long start = System.nanoTime();
Thread buyer = new Thread(() -> {
for (long i = 0; i < 100_000_000L; i++) buyCounter++;
}, "BuyCounter");
Thread seller = new Thread(() -> {
for (long i = 0; i < 100_000_000L; i++) sellCounter++;
}, "SellCounter");
buyer.start();
seller.start();
buyer.join();
seller.join();
long elapsed = (System.nanoTime() - start) / 1_000_000;
System.out.println("Time with false sharing: " + elapsed + " ms");
}
}
How do you fix false sharing with manual padding?
Insert dummy long fields between the two hot variables. Seven longs (7 x 8 = 56 bytes) plus the 8-byte variable itself equals 64 bytes, ensuring each variable occupies its own cache line.
package com.bank.concurrency;
public class PaddedCountersDemo {
// CORRECT: each counter on its own cache line
static volatile long buyCounter;
static long p1, p2, p3, p4, p5, p6, p7; // 56 bytes padding
static volatile long sellCounter;
// buyCounter and sellCounter are now on DIFFERENT cache lines
// No false sharing: writes are independent
public static void main(String[] args) throws InterruptedException {
long start = System.nanoTime();
Thread buyer = new Thread(() -> {
for (long i = 0; i < 100_000_000L; i++) buyCounter++;
}, "BuyCounter");
Thread seller = new Thread(() -> {
for (long i = 0; i < 100_000_000L; i++) sellCounter++;
}, "SellCounter");
buyer.start();
seller.start();
buyer.join();
seller.join();
long elapsed = (System.nanoTime() - start) / 1_000_000;
System.out.println("Time without false sharing: " + elapsed + " ms");
}
}
Time with false sharing: 2850 ms
Time without false sharing: 320 ms (roughly 9x faster)
How does @Contended work?
The @jdk.internal.vm.annotation.Contended annotation (previously @sun.misc.Contended) tells the JVM to insert padding around the annotated field automatically. The default padding is 128 bytes (two cache lines) to account for hardware prefetching of adjacent lines.
// @Contended usage (requires --add-opens or -XX:-RestrictContended)
// import jdk.internal.vm.annotation.Contended;
//
// public class ContendedCounters {
// @Contended volatile long buyCounter;
// @Contended volatile long sellCounter;
// }
// JVM inserts 128 bytes of padding around each field
// Each field on its own cache line (and its own prefetch line)
How does LongAdder use @Contended internally?
LongAdder achieves its high throughput by striping increments across an array of Cell objects. Each Cell is annotated with @Contended, which ensures it occupies its own cache line. This means threads incrementing different cells do not cause false sharing.
// Inside java.util.concurrent.atomic.Striped64:
// @Contended static final class Cell {
// volatile long value;
// // ... padding added by @Contended annotation
// }
// Each Cell on its own cache line: no false sharing!
// This is WHY LongAdder is 10x faster than AtomicLong under contention.
How is false sharing different from true sharing?
True sharing occurs when two threads write to the same variable. False sharing occurs when two threads write to different variables that happen to share the same cache line. The symptoms are similar (cache invalidation, performance degradation), but the fix is different.
| Aspect | True sharing | False sharing |
|---|---|---|
| Same variable? | Yes | No (different variables) |
| Data dependency? | Yes | No |
| Visible in code? | Yes (shared field) | No (layout-dependent) |
| Fix | Lock, Atomic, CAS | Padding, @Contended |
| Profiler detection | Lock contention shown | Invisible (only cache counters) |
How do you detect false sharing?
False sharing is invisible in standard profilers and thread contention tools. The only way to detect it is through hardware performance counters that track cache misses and cache-to-cache transfers.
On Linux, use perf stat -e cache-misses,cache-references to compare runs with and without padding. In JMH benchmarks, use @AuxCounters to measure L1 cache miss rates. A high cache miss rate on variables that should be thread-local is a strong indicator of false sharing.
What should a senior engineer know?
Some server CPUs use 128-byte cache lines (prefetching pairs of 64-byte lines), which is why @Contended uses 128 bytes of padding by default. Object layout in Java is controlled by the JVM, and the order of fields in the source code does not necessarily match the order in memory. Use -XX:+PrintFieldLayout or JOL (Java Object Layout) tool to inspect actual field offsets.
What are common interview follow-up questions?
- Does false sharing affect read-only workloads? No. False sharing only occurs when at least one thread writes, which invalidates the cache line on other cores.
- Can false sharing occur between two different objects? Yes, if the objects are allocated adjacently on the heap and their hot fields happen to be on the same cache line.
Visual Overview
False Sharing: Two Variables, One Cache Line ════════════════════════════════════════════ Without padding (false sharing): ┌──────────────────── Cache Line (64 bytes) ────────────────────┐ │ [object header 16B] [buyCounter 8B] [sellCounter 8B] [pad] │ └──────────────────────────────────────────────────────────────-┘ Core-1 writes buyCounter → INVALIDATES entire line on Core-2 Core-2 writes sellCounter → INVALIDATES entire line on Core-1 Constant ping-pong! 10-100x slower! With padding (no false sharing): ┌──────────── Cache Line 1 (64 bytes) ─────────────┐ │ [object header 16B] [buyCounter 8B] [padding 40B] │ └───────────────────────────────────────────────────┘ ┌──────────── Cache Line 2 (64 bytes) ─────────────┐ │ [sellCounter 8B] [padding 56B] │ └───────────────────────────────────────────────────┘ Core-1 writes buyCounter → Line 2 NOT affected Core-2 writes sellCounter → Line 1 NOT affected Independent! Full speed!
Key Takeaway
False sharing is a hardware-level performance trap where two threads writing to independent variables on the same 64-byte cache line cause constant cross-core cache invalidation. Each write forces the other core to reload the entire line, turning a 1-5 nanosecond L1 cache hit into a 40-100 nanosecond L3 or main memory access. The result is 10-100x slower throughput for operations that appear completely independent in the source code.
The fix is padding: insert enough dummy bytes between hot variables to ensure each one occupies its own cache line. Manual padding uses 7 long fields (56 bytes) between variables. The @Contended annotation tells the JVM to insert 128 bytes of padding automatically. LongAdder is fast because its internal Cell class uses @Contended, placing each thread's counter on its own cache line.
False sharing is invisible in standard profilers because there is no lock contention to measure. It can only be detected with hardware cache miss counters. At a high-frequency trading desk at National Bank of India, buy and sell counters on adjacent fields ran at 50 million operations per second. After padding, throughput jumped to 480 million, a nearly 10x improvement.
Remember one sentence: false sharing happens when independent variables share a cache line, and the fix is padding them apart so each variable has its own 64-byte line.
---