Вопрос 21 · Раздел 9

Как избежать race condition?

В отличие от файла 21 (что такое race condition), этот файл описывает конкретные стратегии предотвращения. Ключевая идея: race condition возникает, когда минимум один поток чита...

Версии по языкам: English Russian Ukrainian

В отличие от файла 21 (что такое race condition), этот файл описывает конкретные стратегии предотвращения. Ключевая идея: race condition возникает, когда минимум один поток читает, минимум один пишет, и нет синхронизации между ними. Все стратегии сводятся к трём подходам: синхронизировать доступ, изолировать данные или устранить изменяемое общее состояние.


Junior уровень

Базовое понимание

Race condition возникает, когда несколько потоков одновременно обращаются к общим данным и хотя бы один из них изменяет данные. Чтобы избежать race condition, нужно обеспечить атомарность или изолировать данные.

Что значит “атомарность” на практике: операция выглядит для других потоков как мгновенная — либо она ещё не началась, либо уже завершилась. Промежуточное состояние никогда не наблюдается. synchronized делает это через блокировку, Atomic классы — через CAS (Compare-And-Swap) инструкции процессора.

Что значит “изолировать” данные: каждый поток работает со своей копией, и результаты объединяются только после завершения. Это дороже по памяти, но полностью устраняет конкуренцию.

Стратегия 1: synchronized

public class SafeCounter {
    private int count = 0;

    // Только один поток может выполнять этот метод одновременно
    public synchronized void increment() {
        count++;
    }

    public synchronized int getCount() {
        return count;
    }
}

Стратегия 2: Atomic классы

public class SafeCounter {
    private AtomicInteger count = new AtomicInteger(0);

    // Атомарная операция — нет race condition
    public void increment() {
        count.incrementAndGet();
    }

    public int getCount() {
        return count.get();
    }
}

Стратегия 3: Immutable Objects

// Immutable объект — нельзя изменить, race condition невозможен
public final class Config {
    private final String value;

    public Config(String value) {
        this.value = value;
    }

    public String getValue() {
        return value; // Всегда безопасно
    }
}

Стратегия 4: ThreadLocal

// Каждый поток имеет свою копию — нет общего состояния
ThreadLocal<SimpleDateFormat> dateFormat = ThreadLocal.withInitial(
    () -> new SimpleDateFormat("yyyy-MM-dd")
);

public String formatDate(Date date) {
    return dateFormat.get().format(date); // Безопасно
}

Сравнение подходов

Метод Плюсы Минусы
synchronized Надёжно, просто Тяжело при конкуренции
Atomic Быстро (lock-free) Только для простых операций
Immutability Идеально для масштабирования Нагрузка на GC
ThreadLocal Нулевая конкуренция Риск утечек в пулах

Middle уровень

Стратегия Синхронизации (Pessimistic)

Блокировки (Locking)

// synchronized — критическая секция
synchronized(lock) {
    // Только один поток — остальные BLOCKED
    counter++;
    total += counter;
}

// ReentrantLock — больше возможностей
ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
    counter++;
} finally {
    lock.unlock(); // Всегда в finally!
}

Атомарные переменные (Optimistic)

AtomicInteger counter = new AtomicInteger(0);

// updateAndGet — атомарная check-then-act
counter.updateAndGet(current -> {
    if (current < MAX) {
        return current + 1;
    }
    return current; // Не увеличиваем
});

// computeIfPresent — атомарная операция на мапе
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.computeIfPresent("key", (k, v) -> v + 1);

Стратегия Изоляции (Optimistic/Design)

Immutable Objects

// Вместо изменения — создаём новый объект
public class State {
    private final int count;
    private final String name;

    public State(int count, String name) {
        this.count = count;
        this.name = name;
    }

    // Copy-on-write
    public State withCount(int newCount) {
        return new State(newCount, this.name);
    }
}

// Использование:
State current = new State(0, "test");
current = current.withCount(1); // Новый объект — нет race condition

ThreadLocal

ThreadLocal<UserContext> context = new ThreadLocal<>();

// Важно: всегда очищайте в пуле потоков!
executor.submit(() -> {
    try {
        context.set(new UserContext("user123"));
        process();
    } finally {
        context.remove(); // ОБЯЗАТЕЛЬНО!
    }
});

Concurrent Collections

// Вместо HashMap + synchronized:
ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();

map.putIfAbsent("key", "value");  // Атомарно
map.compute("key", (k, v) -> ...); // Атомарно
map.merge("key", 1, Integer::sum); // Атомарно

// Сегментированные блокировки — много потоков пишут одновременно

Safe Publication паттерн

Race condition часто возникает при создании объекта. Способы безопасной публикации:

// 1. Static инициализация (гарантируется ClassLoader)
public static final Config INSTANCE = new Config();

// 2. final поля
public class Safe {
    public final String value;
    public Safe(String v) { this.value = v; }
}

// 3. volatile поле
private volatile Config config;
config = new Config();

// 4. synchronized
synchronized(lock) {
    config = new Config();
}

Сравнение подходов для Highload

Метод Плюсы Минусы Когда
synchronized Надёжно, просто Context switch, deadlock Простые случаи
Atomic/CAS Очень быстро CPU при высокой конкуренции Счётчики, флаги
Immutability Масштабируется GC от копирования Функциональный стиль
ThreadLocal Нет конкуренции Утечки в пулах Контекст запроса

Senior уровень

Under the Hood: Как JVM обеспечивает атомарность

synchronized → Monitor Enter/Exit

monitorenter:
  1. Попытка захвата монитора
  2. При конкуренции — поток в EntryList (BLOCKED)
  3. При выходе — все изменения сбрасываются в память

monitorexit:
  1. Освобождение монитора
  2. Invalidация кэшей других потоков

Atomic → CAS на уровне CPU

lock cmpxchg [memory], new_value
; Атомарно на уровне шины памяти
; Другие ядра не могут получить доступ к этой ячейке

Lock-free паттерны

// Lock-free linked list — упрощённо
class LockFreeStack<T> {
    private AtomicReference<Node<T>> head = new AtomicReference<>(null);

    public void push(T value) {
        Node<T> newNode = new Node<>(value);
        Node<T> oldHead;
        do {
            oldHead = head.get();
            newNode.next = oldHead;
        } while (!head.compareAndSet(oldHead, newNode));
    }
}

ConcurrentHashMap внутренне

Java 7: Segment-уровень блокировки (16 сегментов)
Java 8+: CAS + synchronized на уровне bucket (node)

get():       Почти lock-free (volatile reads)
put():       CAS для нового bucket, synchronized для collision
compute():   synchronized на конкретном bucket — не на всей мапе

Диагностика

FindBugs / SpotBugs

// SpotBugs найдёт:
public class RaceCondition {
    private int data = 0; // ⚠️ Not synchronized, accessed from multiple threads

    public void write() {
        data = 42; // Race condition!
    }

    public int read() {
        return data; // Race condition!
    }
}

Stress Testing на ARM/M1

Race condition любят архитектуры с расслабленной моделью памяти:

x86: Сильно упорядочен — race condition проявляется реже
ARM: Слабо упорядочен — race condition проявляется чаще

Тестируйте на ARM (M1/M2) — больше шансов поймать гонки!

ThreadSanitizer

# Для Java (через external tools)
# Запускает код с инструментацией и ловит гонки

Паттерны предотвращения

1. Confinement (Изоляция)

// Данные не покидают метод — нет race condition
public int process() {
    int local = 0; // Локальная переменная — только в этом потоке
    for (int i = 0; i < 100; i++) {
        local += i;
    }
    return local;
}

2. Immutability

// Immutable = thread-safe по определению
public record User(String name, int age) {}

3. Thread-safe Collections

// CopyOnWriteArrayList — для редкой записи, частого чтения
List<String> list = new CopyOnWriteArrayList<>();
list.add("item"); // Копирует весь массив — дорого для частой записи

// ConcurrentLinkedQueue — lock-free очередь
Queue<String> queue = new ConcurrentLinkedQueue<>();
queue.offer("item"); // CAS — lock-free

Best Practices

  1. Избегайте изменяемого общего состояния — лучший способ
  2. Используйте Atomic для простых счётчиков и флагов
  3. Используйте ConcurrentHashMap вместо HashMap + synchronized
  4. Immutable объекты — thread-safe по определению
  5. ThreadLocal — очищайте всегда в finally
  6. Safe Publication — final, volatile, или synchronized при создании
  7. Тестируйте на многопроцессорных системах — ARM/M1 агрессивнее x86
  8. Static Analysis — SpotBugs, Error Prone найдут типичные гонки

Когда НЕ нужно применять эти стратегии

  • Нет общего состояния — если потоки не обмениваются данными, race condition невозможен
  • Данные immutable — неизменяемые объекты thread-safe по определению, синхронизация не нужна
  • Только read-операции — если все потоки только читают, race condition невозможен
  • Один поток пишет, остальные читают после — если запись происходит до запуска чтения (например, инициализация перед стартом потоков), гонки нет
  • Толерантность к потере обновлений — для метрик и статистики иногда допустимо потерять 1-2 из миллиона обновлений (approximate counters)

synchronized vs Atomic vs ConcurrentHashMap: что выбрать?

Ситуация Выбор Почему
Простой счётчик (increment) AtomicLong CAS быстрее блокировки, нет оверхеда монитора
Несколько операций как одна транзакция synchronized Нужно атомарно выполнить группу операций
Кэш/мапа с конкурентным доступом ConcurrentHashMap Сегментированные блокировки, выше параллелизм
Контекст запроса (user session) ThreadLocal Полная изоляция, ноль конкуренции
Объект создаётся один раз и не меняется Immutable (final поля) Thread-safe без синхронизации, бесплатно

🎯 Шпаргалка для интервью

Обязательно знать:

  • 4 основные стратегии: synchronized, Atomic-классы, Immutable Objects, ThreadLocal
  • synchronized = pessimistic locking (блокируем на входе), Atomic = optimistic/CAS (пытаемся без блокировки)
  • ConcurrentHashMap использует CAS + synchronized на уровне bucket, а не на всей мапе
  • Safe Publication: static инициализация, final поля, volatile, или synchronized
  • ThreadLocal — всегда очищайте в finally, иначе утечка в пулах потоков

Частые уточняющие вопросы:

  • Когда выбрать Atomic вместо synchronized? — Для простых операций (increment, set, compare-and-swap). Для группы операций как транзакция — synchronized
  • Почему CopyOnWriteArrayList дорог для частой записи? — Каждый add копирует весь массив, подходит только для редкой записи/частого чтения
  • Чем x86 отличается от ARM в контексте гонок? — x86 сильно упорядочен, ARM слабо упорядочен — race condition проявляется чаще на ARM/M1

Красные флаги (НЕ говорить):

  • ❌ “volatile решает все проблемы многопоточности” — volatile только для visibility, не для atomicity
  • ❌ “ConcurrentHashMap полностью lock-free” — get() почти lock-free, но put()/compute() используют synchronized на bucket
  • ❌ “ThreadLocal не может вызвать утечку памяти” — в пулах потоков ThreadLive живёт дольше задачи, без remove() копия остаётся навсегда
  • ❌ “Immutability бесплатна” — создание множества копий создаёт GC pressure

Связанные темы:

  • [[19. Какие условия необходимы для возникновения deadlock]]
  • [[21. Что такое race condition]]
  • [[23. Что такое Virtual Threads в Java 21]]
  • [[27. В чём разница между Thread и Runnable]]