1. Знакомство с Java Memory Model (JMM)
Проблема видимости и упорядочивания
В однопоточной программе всё просто: записал значение в переменную — и тут же можешь его прочитать. В многопоточной реальности это работает иначе. Процессоры кэшируют значения, компилятор и JVM иногда меняют порядок инструкций, и один поток может видеть «старое» значение, даже если другой поток только что его изменил.
Java Memory Model (JMM) как раз описывает, как потоки общаются через память: когда изменения одного потока становятся видны другим и в каком порядке происходят операции. Если этого не учитывать, программа может вести себя непредсказуемо, хотя на первый взгляд всё выглядит нормально.
Понимание JMM помогает понять, почему иногда поток не видит свежие данные, как правильно использовать volatile, synchronized и атомарные классы, и почему баги в многопоточном коде могут проявляться только в продакшене. Проще говоря, JMM — это правила игры памяти, и если их игнорировать, даже самый аккуратный код может сыграть против вас.
Аналогия
Представьте, что у вас есть два человека (потока), которые пишут и читают записки (переменные) на доске (память). Иногда один пишет, а другой ещё не видит новую запись — потому что смотрит на свою копию доски (кэш). JMM определяет, когда и как эти записки становятся видимы для всех.
2. happens-before: фундамент JMM
Что такое happens-before?
happens-before — это отношение между двумя действиями в программе: если действие A happens-before действие B, то все изменения, сделанные в A, гарантированно видны в B.
Важно: happens-before — это не просто «произошло раньше», а именно «гарантированно видно».
Основные правила happens-before
1. Внутри одного потока
Всё, что происходит в одном потоке, упорядочено: если вы записали в переменную, а потом её прочитали — вы увидите своё изменение.
2. Синхронизированные блоки/мониторы
Всё, что происходит до выхода из синхронизированного блока (synchronized), становится видимым для потока, который потом войдёт в этот блок.
synchronized(lock) {
sharedVar = 42; // запись
}
// ...
synchronized(lock) {
System.out.println(sharedVar); // гарантированно увидим 42
}
3. volatile-запись/чтение
Запись в volatile-поле happens-before любому последующему чтению этого поля другим потоком.
volatile boolean ready = false;
// Поток 1
data = 123;
ready = true; // volatile write
// Поток 2
if (ready) { // volatile read
System.out.println(data); // гарантированно увидим data = 123
}
4. Запуск и завершение потоков
- Вызов Thread.start() happens-before началом работы потока.
- Завершение потока happens-before возвратом из Thread.join().
5. Завершение задания в Executor
Если вы отправили задачу в Executor и дождались её завершения (Future.get()), все изменения, сделанные в задаче, видимы после get().
6. Финальные поля
Инициализация полей с модификатором final в конструкторе happens-before публикацией ссылки на объект. Это важно для immutable-объектов.
3. Безопасная публикация объектов
Проблема: «несвежий» объект
Если один поток создаёт объект и передаёт его другому потоку без синхронизации, другой поток может увидеть «сырые» значения полей (например, неинициализированные или старые значения).
class Holder {
int value;
Holder() { value = 42; }
}
Holder holder = null;
// Поток 1
holder = new Holder(); // создаём объект
// Поток 2
if (holder != null) {
System.out.println(holder.value); // может увидеть 0, а не 42!
}
Как правильно публиковать объекты?
1. Через final-поля
Если все поля объекта final и инициализируются в конструкторе, объект можно безопасно публиковать без дополнительной синхронизации.
class SafeHolder {
final int value;
SafeHolder() { value = 42; }
}
2. Через volatile-ссылку
Если ссылка на объект объявлена как volatile, то после присваивания объект гарантированно виден другим потокам.
volatile Holder holder;
// Поток 1
holder = new Holder();
// Поток 2
if (holder != null) {
System.out.println(holder.value); // гарантированно увидим 42
}
3. Через однопоточную инициализацию до публикации
Если объект создаётся и инициализируется до того, как ссылка на него становится доступна другим потокам, всё безопасно.
Holder holder = new Holder(); // только в одном потоке
// ... потом holder становится доступен другим потокам
4. Через блокировку
Если объект создаётся внутри синхронизированного блока, а ссылка на него читается только внутри такого же блока, всё безопасно.
Holder holder;
synchronized(lock) {
if (holder == null) {
holder = new Holder();
}
}
// ... в другом потоке
synchronized(lock) {
if (holder != null) {
// безопасно
}
}
4. Double-checked locking и volatile
Что такое double-checked locking?
Это паттерн для ленивой инициализации singleton-объекта:
class Singleton {
private static Singleton instance;
public static Singleton getInstance() {
if (instance == null) { // 1-я проверка
synchronized (Singleton.class) {
if (instance == null) { // 2-я проверка
instance = new Singleton();
}
}
}
return instance;
}
}
Проблема: Без volatile-ссылки на instance этот код не работает корректно! Поток может увидеть не полностью инициализированный объект.
Почему без volatile плохо?
JVM может «развернуть» инструкции так, что ссылка на объект присвоится до завершения конструктора. Другой поток увидит неинициализированный объект.
Как правильно?
Объявите instance как volatile:
private static volatile Singleton instance;
Теперь double-checked locking работает корректно: volatile гарантирует отношение happens-before между записью и чтением ссылки.
Альтернатива: статическая инициализация
Самый простой и безопасный способ сделать singleton — использовать статическую инициализацию:
class Singleton {
private static final Singleton INSTANCE = new Singleton();
public static Singleton getInstance() { return INSTANCE; }
}
Здесь JVM сама гарантирует корректную инициализацию.
5. VarHandle: современный низкоуровневый доступ
Что такое VarHandle?
VarHandle — это современный API (Java 9+), который позволяет работать с переменными на низком уровне: читать, писать, делать атомарные операции, управлять видимостью и порядком инструкций.
Зачем нужен VarHandle, если есть атомарные классы?
— VarHandle позволяет работать с любыми полями (не только с int/long/Reference).
— Позволяет явно выбирать семантику доступа: volatile, acquire/release, opaque.
— Используется для реализации высокопроизводительных структур данных.
Семантики доступа
- Volatile: полная гарантия happens-before (как у volatile-поля).
- Acquire/Release: более слабая гарантия, но быстрее (используется для lock-free структур).
- Opaque: минимальная гарантия видимости, но максимальная производительность.
Пример использования VarHandle
import java.lang.invoke.MethodHandles;
import java.lang.invoke.VarHandle;
class Counter {
int value;
static final VarHandle VALUE_HANDLE;
static {
try {
VALUE_HANDLE = MethodHandles.lookup().findVarHandle(Counter.class, "value", int.class);
} catch (Exception e) {
throw new Error(e);
}
}
}
Counter counter = new Counter();
Counter.VALUE_HANDLE.setVolatile(counter, 42);
int v = (int) Counter.VALUE_HANDLE.getVolatile(counter);
Когда использовать VarHandle?
- Для реализации своих lock-free структур данных.
- Когда нужна максимальная производительность и контроль над порядком инструкций.
- В обычных приложениях чаще всего хватает атомарных классов и synchronized.
6. Ложное разделение (false sharing) и выравнивание кэша
False sharing — это ситуация, когда два потока работают с разными переменными, но эти переменные лежат в одной кэш-линии процессора. В результате потоки мешают друг другу, потому что изменение одной переменной приводит к инвалидации кэша для другой.
Аналогия: Два человека сидят за одним столом (кэш-линия), но каждый пишет на своей половине. Если один что-то меняет, другому приходится «перечитывать» всю бумагу.
Почему это плохо?
- Производительность резко падает: процессоры тратят время на синхронизацию кэшей.
- Особенно критично для «горячих» переменных, которые часто изменяются разными потоками.
Как избежать?
Разносите «горячие» поля по разным объектам или используйте специальные аннотации/структуры для выравнивания (например, @Contended). В современных JVM можно включить опцию -XX:-RestrictContended и использовать @sun.misc.Contended (Java 8+) для выравнивания полей.
Пример:
@sun.misc.Contended
public volatile long value1;
@sun.misc.Contended
public volatile long value2;
NB: Аннотация @Contended не входит в стандартный API, но используется в JDK для оптимизации атомарных классов.
7. Практика: исправляем singleton и мини-бенчмарки JMH
Исправляем нерабочий singleton
Плохо (без volatile):
class BrokenSingleton {
private static BrokenSingleton instance;
public static BrokenSingleton getInstance() {
if (instance == null) {
synchronized (BrokenSingleton.class) {
if (instance == null) {
instance = new BrokenSingleton();
}
}
}
return instance;
}
}
Хорошо (с volatile):
class SafeSingleton {
private static volatile SafeSingleton instance;
public static SafeSingleton getInstance() {
if (instance == null) {
synchronized (SafeSingleton.class) {
if (instance == null) {
instance = new SafeSingleton();
}
}
}
return instance;
}
}
Лучше всего — статическая инициализация:
class StaticSingleton {
private static final StaticSingleton INSTANCE = new StaticSingleton();
public static StaticSingleton getInstance() { return INSTANCE; }
}
Мини-бенчмарки JMH: видимость и атомарность
Внимание: JMH — это специальный фреймворк для микробенчмарков в Java. Не пытайтесь делать выводы о производительности без JMH — результаты могут быть обманчивы!
Пример: проверка видимости volatile
public class VolatileVisibility {
volatile boolean flag = false;
public void writer() {
flag = true;
}
public void reader() {
while (!flag) {
// крутимся, пока не увидим true
}
// увидели изменение
}
}
Пример: неатомарность volatile
public class VolatileNotAtomic {
volatile int counter = 0;
public void increment() {
counter++; // не атомарно!
}
}
Несмотря на volatile, при одновременном инкременте из нескольких потоков итоговое значение будет меньше ожидаемого (операция counter++ распадается на чтение, вычисление и запись).
8. Типичные ошибки при работе с JMM, volatile и публикацией
Ошибка №1: Ожидание атомарности от volatile.
volatile гарантирует только видимость изменений, но не атомарность операций. Операция counter++ не становится атомарной, если переменная volatile.
Ошибка №2: Публикация объектов без синхронизации.
Если вы создаёте объект в одном потоке и отдаёте его другому без volatile, synchronized или final-полей — другой поток может увидеть «сырые» значения.
Ошибка №3: Double-checked locking без volatile.
Без volatile-ссылки на singleton можно получить неинициализированный объект в другом потоке.
Ошибка №4: Использование старых блокировок с виртуальными потоками.
Некоторые старые механизмы синхронизации (native monitor) могут мешать JVM эффективно управлять виртуальными потоками.
Ошибка №5: Игнорирование ложного разделения.
Если «горячие» переменные лежат рядом в памяти, потоки будут мешать друг другу из-за кэш-линий.
Ошибка №6: Делать выводы о производительности без JMH.
Микробенчмарки без JMH часто дают ложные результаты из-за оптимизаций JVM и кэшей процессора.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ