JavaRush /Курси /JAVA 25 SELF /AtomicInteger, AtomicReference: атомарні операції

AtomicInteger, AtomicReference: атомарні операції

JAVA 25 SELF
Рівень 53 , Лекція 3
Відкрита

1. Чому i++ не працює в багатопоточності?

Почнемо з класичної задачі: маємо змінну‑лічильник, наприклад, кількість оброблених заявок або число завантажених файлів. Ми хочемо, щоб кілька потоків збільшували цей лічильник. Що може піти не так, якщо просто написати i++?

Приклад: гонка даних на інкременті

public class Counter {
    public int count = 0;

    public void increment() {
        count++; // Неатомарно!
    }
}

Уявімо, що два потоки одночасно викликають increment(). Обидва потоки читають старе значення, обидва збільшують його на 1 і обидва записують… одне й те саме нове значення! У результаті один інкремент «втрачається». Якщо повторювати це багато разів, підсумкове значення буде меншим за очікуване.

Чому так відбувається?
Операція i++ насправді складається з трьох кроків:

  1. Прочитати значення змінної (наприклад, 5).
  2. Збільшити це значення на 1.
  3. Записати нове значення назад у пам’ять.

А в багатопоточному середовищі інший потік може встигнути змінити змінну між цими кроками. Підсумок — «гонка даних» (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 потоків одночасно викличуть цей метод, жоден інкремент не загубиться.

Корисні методи:

Метод Опис
get()
Отримати поточне значення
set(int value)
Встановити значення
incrementAndGet()
Збільшити на 1 і повернути нове значення
getAndIncrement()
Повернути поточне значення й збільшити на 1
addAndGet(int delta)
Збільшити на delta й повернути нове значення
compareAndSet(expect, update)
Якщо поточне значення дорівнює 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, то сам список не стане потокобезпечним.

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: Ігнорування потокобезпечності вкладених об’єктів.
Якщо в AtomicReference лежить звичайний об’єкт, його методи й поля не стають потокобезпечними. Атомарною є лише заміна посилання.

Помилка № 3: Використання атомарних класів без потреби.
В однопотоковому коді атомарні типи зайві й трохи повільніші за звичайні змінні через додаткові перевірки.

Помилка № 4: Передчасна оптимізація.
Іноді простіше й надійніше використовувати synchronized, особливо коли логіка складна й зачіпає кілька змінних одночасно. Не завжди виправдано будувати lock‑free рішення.

Помилка № 5: Забули про проблему ABA.
Рідкісний, але важливий випадок: значення змінюється з A на B і знову на A — compareAndSet «вважає», що нічого не змінювалося. Для таких сценаріїв використовуйте спецкласи на кшталт AtomicStampedReference (або AtomicMarkableReference).

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ