Если производительность внезапно падает, хотя два потока работают с разными частями одного объекта, скорее всего, проблема в так называемом false sharing.
Вот как это работает: процессор не берет данные из памяти побайтово. Он работает с cache line, обычно это 64 байта. Если два ядра процессора держат одну и ту же cache line в своих кэшах первого уровня (L1) и хотя бы одно ядро в нее пишет, запускается протокол когерентности (MESI). Этот протокол делает копию cache line недействительной в другом ядре. И это происходит, даже если ядра пишут в разные поля объекта, но эти поля физически находятся в одной cache line.
🔹 Как объекты лежат в памяти
JVM (HotSpot) раскладывает объект примерно так:
[mark word — 8 байт]
[klass pointer — 4/8 байт]
[поля по убыванию размера: long/double → int/float → short/char → byte/boolean → ссылки]
[padding до выравнивания на 8 байт]
Порядок полей в исходнике не соответствует порядку в памяти. JVM перегруппировывает их для минимизации выравнивания. Проверить реальный layout можно через JOL:
javaSystem.out.println(ClassLayout.parseClass(MyClass.class).toPrintable());
Для класса с `long`, `int` и `boolean` JOL покажет что-то вроде:
OFFSET SIZE TYPE
0 4 (object header: mark)
4 4 (object header: mark)
8 4 (object header: class)
12 4 int value
16 8 long counter
24 1 boolean flag
25 7 (alignment/padding)
Итого 32 байта — меньше cache line. Два таких объекта рядом в массиве легко попадут в одну линию.
🔹 Классический сценарий
class Counters {
volatile long a;
volatile long b;
}Поля a и b занимают по 8 байт. Заголовок объекта – примерно 12-16 байт. Вероятно, оба поля попадают в одну 64-байтную строку кэша. Если два потока независимо увеличивают a и b, они постоянно будут делать строку недействительной друг для друга. На многоядерной системе это легко может привести к падению производительности в 5-10 раз по сравнению с независимыми счетчиками.
Решение – добавление отступов (padding). До Java 8 это делали руками, добавляя фиктивные поля:
class PaddedCounter {
volatile long value;
long p1, p2, p3, p4, p5, p6, p7; // 56 байт padding
}Уродливо и ненадёжно. Плюс JIT мог выкинуть «мёртвые» поля.
С Java 8 появилась аннотация @Contended (пакет jdk.internal.vm.annotation):
@Contended
volatile long a;
@Contended
volatile long b;
В JVM автоматически добавляется padding в 128 байт вокруг помеченного поля. Это сделано для защиты от prefetcher'а (размер двойной cache line). Чтобы это работало вне классов JDK, нужно использовать опцию -XX:-RestrictContended.
Аннотация @Contended стоит на полях внутри Thread (например, для thread-local random), ForkJoinPool и Striped64, который является базовым классом для LongAdder и LongAccumulator.
🔹 LongAdder как способ решения проблемы
LongAdder показывает, как можно решить проблему не просто добавлением отступов, а с помощью особого подхода к проектированию. Вместо одного volatile long используется массив Cell[], где у каждой ячейки стоит @Contended. Каждый поток пишет в свою ячейку, итоговое значение получается через sum(). При большой нагрузке это работает намного быстрее, чем AtomicLong.
За это приходится платить: sum() не является атомарной операцией. Если нужна полная точность в момент чтения, LongAdder не подойдет.
🔹 О массивах примитивных типов
Тут есть свои нюансы с long[]. Элементы располагаются один за другим, без каких-либо дополнительных данных между ними. Восемь элементов long занимают ровно одну cache line. Если несколько потоков одновременно пишут в соседние ячейки, false sharing практически гарантирован. Именно поэтому в Striped64 используется массив объектов Cell[], а не long[]: каждый объект с @Contended изолирован от других.
══════ Навигация ══════
Вакансии • Задачи • Собесы
🐸 Библиотека джависта
#CoreJava