1. Почему i++ не работает в многопоточности?
Начнём с классической задачи: у нас есть переменная‑счётчик, например, количество обработанных заявок или число скачанных файлов. Мы хотим, чтобы несколько потоков увеличивали этот счётчик. Что может пойти не так, если просто написать i++?
Пример: гонка данных на инкременте
public class Counter {
public int count = 0;
public void increment() {
count++; // Не атомарно!
}
}
Представим, что два потока одновременно вызывают increment(). Оба потока читают старое значение, оба увеличивают его на 1, и оба записывают… одно и то же новое значение! В результате один инкремент «теряется». Если повторять это много раз, итоговое значение будет меньше ожидаемого.
Почему так происходит?
Операция i++ на самом деле состоит из трёх шагов:
- Прочитать значение переменной (например, 5).
- Увеличить это значение на 1.
- Записать новое значение обратно в память.
А в многопоточной среде другой поток может успеть изменить переменную между этими шагами. Итог — «гонка данных» (race condition).
Что такое атомарные операции?
Атомарная операция — это действие, которое либо выполняется полностью, либо не выполняется вовсе, и никакой другой поток не может «вклиниться» в середину этой операции.
В Java есть набор классов, предоставляющих такие операции для примитивов и ссылок. Они расположены в пакете java.util.concurrent.atomic. Самые популярные:
- AtomicInteger — атомарный целочисленный тип.
- AtomicLong — атомарный long.
- AtomicBoolean — атомарный boolean.
- AtomicReference<T> — атомарная ссылка на объект любого типа.
2. AtomicInteger: потокобезопасный счётчик
Объявление и базовое использование
import java.util.concurrent.atomic.AtomicInteger;
public class AtomicCounter {
private final AtomicInteger count = new AtomicInteger(0);
public void increment() {
count.incrementAndGet(); // Атомарное увеличение
}
public int get() {
return count.get();
}
}
Здесь incrementAndGet() выполняет «увеличить и вернуть новое значение» как одну неделимую операцию. Даже если 100 потоков одновременно вызовут этот метод, ни один инкремент не потеряется.
Полезные методы:
| Метод | Описание |
|---|---|
|
Получить текущее значение |
|
Установить значение |
|
Увеличить на 1 и вернуть новое значение |
|
Вернуть текущее значение и увеличить на 1 |
|
Увеличить на delta и вернуть новое значение |
|
Если текущее значение равно expect, установить update (CAS) |
Пример: многопоточный счётчик
Пусть у нас есть класс, который считает количество обработанных сообщений в чате.
public class MessageStatistics {
private final AtomicInteger messageCount = new AtomicInteger(0);
public void onMessageReceived() {
int newCount = messageCount.incrementAndGet();
System.out.println("Всего сообщений: " + newCount);
}
public int getMessageCount() {
return messageCount.get();
}
}
Внутренности: как работает AtomicInteger?
Внутри AtomicInteger использует специальную инструкцию процессора — CAS (Compare-And-Swap, «сравнить‑и‑подменить»). Это атомарная операция, которая сравнивает текущее значение переменной с ожидаемым, и если они совпадают — записывает новое значение. Если другой поток успел изменить переменную — операция не выполняется, и попытка повторяется.
Схема работы:
1. Читаем текущее значение (например, 5)
2. Сравниваем с ожидаемым (5)
3. Если совпадает — записываем новое значение (6)
4. Если не совпадает — повторяем попытку
Всё это происходит очень быстро и без блокировок (lock‑free). Поэтому атомарные классы часто быстрее, чем synchronized, особенно при большом количестве потоков.
3. AtomicReference: атомарная ссылка на объект
AtomicReference<T> — это универсальный атомарный контейнер для любого объекта. Он позволяет безопасно менять ссылку на объект из разных потоков.
Пример: потокобезопасное обновление ссылки
import java.util.concurrent.atomic.AtomicReference;
public class AtomicReferenceExample {
private final AtomicReference<String> latestMessage = new AtomicReference<>("");
public void updateMessage(String message) {
latestMessage.set(message);
}
public String getLatestMessage() {
return latestMessage.get();
}
}
Применение compareAndSet
Самая интересная операция — compareAndSet(expected, newValue). Она позволяет обновить значение только если оно не изменилось с момента последнего чтения.
public void safeUpdate(String oldValue, String newValue) {
boolean success = latestMessage.compareAndSet(oldValue, newValue);
if (success) {
System.out.println("Обновление прошло успешно!");
} else {
System.out.println("Кто-то уже изменил значение, попробуйте ещё раз.");
}
}
Это основа неблокирующих алгоритмов: от очередей и стеков до кэшей, где важно избежать лишних блокировок.
4. Примеры использования в приложении
Пример 1: многопоточный счётчик сообщений
public class ChatRoom {
private final AtomicInteger messageCount = new AtomicInteger(0);
public void receiveMessage(String message) {
// ... обработка сообщения ...
int count = messageCount.incrementAndGet();
System.out.println("Новое сообщение: " + message + ". Всего сообщений: " + count);
}
}
Пример 2: безопасное обновление ссылки на последнее сообщение
public class ChatRoom {
private final AtomicReference<String> lastMessage = new AtomicReference<>("");
public void receiveMessage(String message) {
lastMessage.set(message);
// ... обработка ...
}
public String getLastMessage() {
return lastMessage.get();
}
}
Если нужно обновлять ссылку только если последнее сообщение не изменилось (чтобы избежать «потери» при одновременных обновлениях), используйте compareAndSet.
5. Ограничения и подводные камни
Когда атомарные классы — не панацея?
Атомарные переменные отлично подходят для простых операций: инкремент, установка значения, проверка и замена. Но если нужно обновить несколько переменных одновременно, атомарность уже не гарантируется. Например, если у вас два счётчика и вы хотите увеличить их оба как одну операцию — здесь нужен synchronized или другой механизм синхронизации.
Пример неправильного использования
// НЕ атомарно!
if (ref.get() == null) {
ref.set("Hello");
}
Между get() и set(...) другой поток может изменить значение, и условие уже будет неверным. Для таких случаев используйте compareAndSet.
Атомарные классы ≠ потокобезопасные объекты
Если объект, на который указывает AtomicReference, сам не потокобезопасен, то замена ссылки будет атомарной, но изменение полей объекта — нет. Например, если вы храните в AtomicReference<List<String>> обычный ArrayList, то сам список не станет thread‑safe.
6. Продвинутые атомарные классы
В пакете java.util.concurrent.atomic есть и другие полезные классы:
- AtomicLong, AtomicBoolean — для long и boolean.
- AtomicIntegerArray, AtomicReferenceArray — атомарные операции над массивами.
- LongAdder, LongAccumulator — для высоконагруженных счётчиков.
LongAdder и LongAccumulator
Если у вас очень много потоков и обычный AtomicInteger становится «узким горлышком» (все потоки конкурируют за одну переменную), используйте LongAdder. Он разбивает счётчик на несколько внутренних ячеек и суммирует их при запросе значения, что даёт выигрыш при высокой конкуренции.
import java.util.concurrent.atomic.LongAdder;
public class FastCounter {
private final LongAdder adder = new LongAdder();
public void increment() {
adder.increment();
}
public long getCount() {
return adder.sum();
}
}
7. Типичные ошибки при работе с атомарными переменными
Ошибка №1: Ожидание атомарности сложных операций.
Если нужно выполнить несколько действий над значением, атомарные классы не спасут — между шагами другой поток может изменить данные. Для составных операций используйте compareAndSet или синхронизацию.
Ошибка №2: Игнорирование thread‑safety вложенных объектов.
Если в AtomicReference лежит обычный объект, его методы и поля не становятся потокобезопасными. Атомарна лишь замена ссылки.
Ошибка №3: Использование атомарных классов без необходимости.
В однопоточном коде атомарные типы избыточны и немного медленнее обычных переменных из‑за дополнительных проверок.
Ошибка №4: Преждевременная оптимизация.
Иногда проще и надёжнее использовать synchronized, особенно когда логика сложная и затрагивает несколько переменных сразу. Не всегда оправдано строить lock‑free решения.
Ошибка №5: Забыли про ABA‑проблему.
Редкий, но важный случай: значение меняется с A на B и снова на A — compareAndSet «думает», что ничего не менялось. Для таких сценариев используйте спецклассы вроде AtomicStampedReference (или AtomicMarkableReference).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ