Как избежать 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. 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
- Избегайте изменяемого общего состояния — лучший способ
- Используйте 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]]