Як уникнути race condition?
На відміну від файлу 21 (що таке race condition), цей файл описує конкретні стратегії запобігання. Ключова ідея: race condition виникає, коли мінімум один потік читає, мінімум о...
На відміну від файлу 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
- Уникайте змінюваного спільного стану — найкращий спосіб
- Використовуйте Atomic для простих лічильників та флагів
- Використовуйте ConcurrentHashMap замість HashMap + synchronized
- Immutable об’єкти — thread-safe за визначенням
- ThreadLocal — очищуйте завжди у finally
- Safe Publication — final, volatile, або synchronized при створенні
- Тестуйте на багатопроцесорних системах — ARM/M1 агресивніші за x86
- 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]]