Як працюють AtomicInteger, AtomicLong
Їхня робота базується на двох ключових принципах:
🟢 Junior Level
Коротка відповідь для співбесіди (30 секунд)
AtomicInteger та AtomicLong — це потокобезпечні класи-обгортки над примітивними числовими типами int та long із пакета java.util.concurrent.atomic. Вони забезпечують атомарне (неподільне) читання, запис та математичну модифікацію значень без використання важких блокувань (lock-free).
Їхня робота базується на двох ключових принципах:
- Значення змінної зберігається в полі з модифікатором
volatile, гарантуючи негайну видимість змін усіма потоками процесора (Java Memory Model Happens-Before). - Модифікації виконуються через оптимістичні цикли з процесорними інструкціями CAS (
Compare-And-Swap), виключаючи переведення потоків у станBLOCKEDта системні виклики ядра операційної системи.
Проблема атомарності примітивів
Звичайні операції інкременту count++ або count += 5 складаються з трьох окремих машинних команд:
- Завантаження значення з пам’яті в регістр CPU (
read). - Модифікація в регістрі (
modify). - Збереження регістра назад у пам’ять (
write).
Якщо два потоки виконують count++ одночасно, вони можуть прочитати одне й те саме початкове значення і перезаписати результат один одного, втративши один інкремент (Race Condition).
Основні методи та їх застосування на Java 21
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.AtomicLong;
public class AtomicUsageDemo {
private final AtomicInteger counter = new AtomicInteger(0);
private final AtomicLong globalIdSeq = new AtomicLong(1000L);
public void demoOperations() {
// 1. Інкремент і декремент
int next = counter.incrementAndGet(); // Префіксний інкремент: ++i (поверне 1)
int prev = counter.getAndIncrement(); // Постфіксний інкремент: i++ (поверне 1, стане 2)
counter.decrementAndGet(); // Префіксний декремент: --i (поверне 1)
// 2. Арифметичні операції
counter.addAndGet(10); // i += 10 (поверне 11)
int oldVal = counter.getAndAdd(5); // поверне 11, значення стане 16
// 3. Атомарне порівняння із заміною (CAS)
boolean updated = counter.compareAndSet(16, 100); // true, якщо було 16
// 4. Функціональне атомарне оновлення (Java 8+)
// Застосовує чисту лямбда-функцію в циклі CAS
int doubled = counter.updateAndGet(x -> x * 2);
// 5. Акумуляція з бінарним оператором
int maxVal = counter.accumulateAndGet(250, Math::max);
}
}
🟡 Middle Level
Внутрішня будова: volatile + VarHandle (Java 9+)
Історично до Java 9 класи AtomicInteger та AtomicLong використовували низькорівневий sun.misc.Unsafe. Починаючи з Java 9, вони переведені на типобезпечний механізм java.lang.invoke.VarHandle.
Фрагмент внутрішньої архітектури AtomicInteger у HotSpot JVM:
public class AtomicInteger extends Number implements java.io.Serializable {
// 1. Сховище значення — завжди volatile!
private volatile int value;
// 2. Дескриптор доступу до поля в оперативній пам'яті
private static final VarHandle VALUE;
static {
try {
MethodHandles.Lookup l = MethodHandles.lookup();
VALUE = l.findVarHandle(AtomicInteger.class, "value", int.class);
} catch (ReflectiveOperationException e) {
throw new ExceptionInInitializerError(e);
}
}
public final int incrementAndGet() {
// Транслюється безпосередньо в асемблер lock xadd на x86
return (int) VALUE.getAndAdd(this, 1) + 1;
}
}
Неатомарність 64-бітних long на 32-бітних JVM
Специфікація мови Java (JLS §17.7 Non-Atomic Treatment of double and long) стверджує:
Операції читання та запису звичайних (non-volatile) змінних типів
longіdoubleможуть розглядатися віртуальною машиною як дві окремі 32-бітні операції.
Проблема «розірваного читання» (Word Tearing):
На 32-бітній архітектурі процесор не може за один такт записати 64 біти. Запис long розбивається на запис молодших 32 бітів і старших 32 бітів:
Потік 1 пише: 0x00000001_00000000L
Потік 2 пише: 0x00000000_FFFFFFFFL
Потік 3 може прочитати половину від Потоку 1 і половину від Потоку 2:
Результат: 0x00000001_FFFFFFFFL (пошкоджені дані!)
AtomicLong повністю вирішує цю проблему:
- Навіть на 32-бітних JVM читання та запис гарантовано атомарні завдяки
volatile-семантиці та використанню спеціальних інструкцій процесора (наприклад,CMPXCHG8Bна x86-32).
Режими запису: set() проти lazySet()
AtomicInteger надає різні способи запису значення з різною вартістю для шини процесора:
| Метод | Семантика JMM | Процесорні бар’єри | Видимість для інших ядер |
|---|---|---|---|
set(v) |
volatile store |
StoreStore + StoreLoad |
Миттєва видимість усім потокам |
lazySet(v) |
ordered / release store |
Тільки StoreStore |
Відкладена видимість (eventual), без затримки CPU на StoreLoad |
compareAndSet(e, u) |
Full Fence (CAS) | Двонаправлений бар’єр (StoreLoad) | Миттєво, атомарно при збігу |
AtomicInteger flag = new AtomicInteger(0);
// lazySet ідеальний при звільненні ресурсів або скиданні прапорців у lock-free чергах:
// Ми гарантуємо порядок інструкцій, але не платимо за скидання Store Buffer у шину
flag.lazySet(1);
🔴 Senior Level
Апаратна трансляція: lock xadd проти CAS-циклу
При компіляції JIT-компілятор C2 оптимізує методи AtomicInteger:
1. Операція getAndAdd / incrementAndGet:
Замість дорогого циклу з перевіркою і повтором JIT компілює incrementAndGet() на архітектурі x86 в одну атомарну асемблерну інструкцію:
; rcx = вказівник на поле value
; eax = delta (наприклад, 1)
lock xadd [rcx], eax
; Значення в пам'яті атомарно збільшується, а в eax повертається старе значення
Інструкція LOCK XADD виконується за фіксовану кількість тактів і не потребує циклу повторів, що робить її значно швидшою за класичний CAS-цикл при відсутності колізій.
2. Операція updateAndGet:
Використовує класичний оптимістичний спін-цикл із викликом користувацької функції:
public final int updateAndGet(IntUnaryOperator updateFunction) {
int prev = get(), next = 0;
for (;;) {
next = updateFunction.applyAsInt(prev);
if (compareAndSet(prev, next))
return next;
prev = get(); // Повторне читання при невдалому CAS
}
}
[!CAUTION] Функція в
updateAndGetзобов’язана бути чистою (Side-Effect Free)!
При конкуренції функція може бути викликана кілька разів для однієї й тієї самої операції. Якщо помістити в неї мережевий запит або інкремент зовнішнього лічильника, виникне важковловимий дефект багаторазового виконання.
Деградація при високій конкуренції: перехід на LongAdder
Коли кількість паралельно пишучих потоків перевищує кількість ядер процесора:
- Інструкції
lock xaddтаlock cmpxchgбезперервно блокують один і той самий 64-байтний рядок кешу L1/L2. - Виникає Cache Bouncing між сокетами CPU системною шиною когерентності кешів.
- Пропускна здатність (Throughput)
AtomicLongпадає експоненціально.
// При 100+ потоках на запис:
AtomicLong badCounter = new AtomicLong(); // Падіння швидкості, 100% CPU
// Архітектурне рішення:
java.util.concurrent.atomic.LongAdder goodCounter = new java.util.concurrent.atomic.LongAdder();
goodCounter.increment(); // Розподіляє запис по масиву Cell із захистом від False Sharing
long total = goodCounter.sum();
Патерн ID Range Allocation (Генерація ID під навантаженням)
Пряме використання AtomicLong.incrementAndGet() як розподіленого генератора ідентифікаторів при 100 000+ RPS створює вузьке горлечко масштабованості.
Рішення: Пакетне виділення діапазонів (Range Allocation)
public class BatchIdGenerator {
private final AtomicLong globalSequence = new AtomicLong(1);
private static final long BATCH_SIZE = 10_000;
// Кожен робочий потік тримає свій локальний діапазон
private static final ThreadLocal<LocalRange> localRange =
ThreadLocal.withInitial(() -> new LocalRange(0, 0));
private record LocalRange(long current, long max) {}
public long nextId() {
LocalRange range = localRange.get();
if (range.current < range.max) {
localRange.set(new LocalRange(range.current + 1, range.max));
return range.current;
}
// Локальний діапазон вичерпано: звертаємося до глобального AtomicLong один раз на 10 000 ID!
long newStart = globalSequence.getAndAdd(BATCH_SIZE);
localRange.set(new LocalRange(newStart + 1, newStart + BATCH_SIZE));
return newStart;
}
}
Такий підхід знижує кількість звернень до спільної пам’яті в $10\,000$ разів, повністю усуваючи затримки на шині процесора.
🎯 Шпаргалка для інтерв’ю
1. 30-секундна квінтесенція (Elevator Pitch)
AtomicInteger та AtomicLong — потокобезпечні неблокуючі (lock-free) обгортки над числовими примітивами. Вони гарантують атомарність складених операцій інкременту та модифікації завдяки volatile-видимості та апаратним процесорним інструкціям (lock xadd та lock cmpxchg на x86). На 32-бітних JVM AtomicLong виключає проблему пошкодження даних при читанні 64-бітних значень (Word Tearing). При екстремальній конкуренції на запис скалярні лічильники мають замінюватися на шардований LongAdder.
2. 4 каверзних запитання з глибокими відповідями
Запитання 1: Чому в 32-бітній JVM читання звичайного long може повернути пошкоджене значення («розірване читання»), а AtomicLong гарантує цілісність?
Відповідь:
Специфікація JLS (§17.7) дозволяє JVM на 32-бітних системах виконувати читання та запис звичайних long і double як дві незалежні 32-бітні операції (молодші та старші 4 байти). Якщо потік $A$ записав першу половину, а потік $B$ паралельно прочитав змінну цілком, потік $B$ отримає 64-бітне число, що складається з половини старого і половини нового значень («Word Tearing»). В AtomicLong внутрішнє поле оголошено як volatile, що зобов’язує віртуальну машину використовувати спеціальні атомарні інструкції (наприклад, CMPXCHG8B або операції FPU/SSE-регістрів), які гарантують неподільність 64 бітів навіть на 32-бітних архітектурах.
Запитання 2: У чому різниця в машинному коді між incrementAndGet() та updateAndGet(x -> x + 1)?
Відповідь:
incrementAndGet()оптимізується компілятором JIT C2 в одну атомарну процесорну інструкціюlock xadd(на архітектурі x86). Процесор виконує додавання і повернення старого значення апаратно за один такт без програмного циклу повторів.updateAndGet(fn)змушений викликати переданий функціональний інтерфейсIntUnaryOperatorі тому транслюється в класичний CAS-цикл (do-whileз інструкцієюlock cmpxchg). За наявності конкуренції викликupdateAndGetвиконує повторні спроби з багаторазовим викликом лямбда-виразу.
Запитання 3: Чому виклик методу з побічними ефектами (side effects) всередині updateAndGet() або accumulateAndGet() є неприпустимим?
Відповідь:
Всередині updateAndGet() використовується оптимістичний цикл повторних спроб CAS. Якщо паралельний потік встиг оновити значення раніше, CAS завершується невдачею, і передана лямбда-функція викликається повторно з новим базовим значенням. Якщо лямбда здійснює побічні ефекти (наприклад, надсилає повідомлення в чергу, робить HTTP-запит або змінює зовнішні колекції), ці дії виконаються непередбачувану кількість разів, призводячи до дублювання операцій і порушення цілісності системи.
Запитання 4: Як працює і яку проблему вирішує метод lazySet() в AtomicInteger?
Відповідь:
Метод lazySet() встановлює значення із семантикою Ordered Store (Release Barrier). Компілятор вставляє бар’єр пам’яті StoreStore, гарантуючи, що всі попередні записи потоку завершаться до встановлення нового значення, але не генерує дорогий бар’єр StoreLoad. Завдяки цьому процесор не чекає на спустошення буфера запису (Store Buffer) у системну шину пам’яті, виконуючи операцію в кілька разів швидше за set(). Зворотний бік — інші процесорні ядра побачать нове значення з невеликою затримкою (eventual consistency). Це ідеальна оптимізація для очищення посилань у неблокуючих чергах і кільцевих буферах.
3. Типові помилки та Red Flags
❌ “AtomicInteger захищає ланцюжок із кількох атомарних операцій: if (atom.compareAndSet(0, 1)) { atom.incrementAndGet(); }.”
Правильно: Кожна операція в AtomicInteger атомарна сама по собі, але їх комбінація не атомарна. Між викликом compareAndSet та incrementAndGet стан може бути змінений третім потоком. Для атомарності складених дій потрібно використовувати updateAndGet або явні блокування.
❌ “Для поля в AtomicIntegerFieldUpdater можна опустити модифікатор volatile.”
Правильно: Поле зобов’язане бути оголошено як volatile int і мати модифікатори доступу, що дозволяють звертатися до нього з updater-класу (не private, якщо updater розташований в іншому контексті).
❌ “AtomicLong завжди масштабується краще, ніж LongAdder.”
Правильно: При активній конкуренції на запис понад 4–8 потоків AtomicLong упирається в інвалідацію кешів (Cache Bouncing). LongAdder розподіляє запис за кількома комірками і працює в десятки разів швидше.