Питання 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. Інвалідація кешів інших потоків

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]]