1. Передумови появи атомарних операцій

Давай розберемо цей приклад, який допоможе зрозуміти роботу атомарних операцій:


public class Counter {
    int count;

    public void increment() {
        count++;
    }
}

Коли у нас один потік, все працює класно, але якщо ми додаємо багатопоточку, отримуємо неправильні результати, а все тому, що операція інкременту складає не одну операцію, а три: запит на отримання поточного значення count, потім – збільшення її на 1 і запис знову до count.

І коли два потоки захочуть збільшити змінну, швидше за все, ти втратиш дані. Тобто обидва потоки одержують 100, в результаті обидва запишуть 101 замість очікуваного значення 102.

І як це вирішити? Потрібно використати блокування. Ключове слово synchronized допомагає вирішити цю проблему. Використання цього слова дає тобі гарантію, що один потік буде звертатися до методу в один момент часу.


public class SynchronizedCounterWithLock {
    private volatile int count;

    public synchronized void increment() {
        count++;
    }
}

Плюс треба додавати ключове слово volatile, яке забезпечує коректну видимість посилань серед потоків. Ми розбирали його роботу вище.

Але все ж таки є мінуси. Найбільший – це продуктивність. В той момент, коли багато потоків намагаються отримати блокування і один отримує можливість для запису, решта потоків буде або заблокована, або призупинена до моменту звільнення потоку.

Усі ці процеси, блокування, перехід до іншого статусу дуже дорогі для продуктивності системи.

2. Атомарні операції

Алгоритм використовує низькорівневі машинні інструкції, як-от порівняння та заміна (CAS, compare-and-swap, що забезпечує цілісність даних, і про них вже існує велика кількість досліджень).

Типова операція CAS працює з трьома операндами:

  • Місце в пам'яті для роботи (M)
  • Існуюче очікуване значення (A) змінної
  • Нове значення (B), яке необхідно встановити

CAS атомарно оновлює M до B, але лише якщо значення M збігається з A – інакше жодні дії не робляться.

У першому та другому випадку повернуть значення М. Це дозволяє об'єднати три кроки, а саме – отримання значення, порівняння значення та його оновлення. І це все перетворюється на одну операцію на машинному рівні.

У той час, коли багатопотоковий застосунок звертається до змінної, намагається оновити його і застосовується CAS, один із потоків отримає його і зможе оновити. Але на відміну від блокувань інші потоки просто отримають помилки про те, що їм не вдалося оновити значення. Потім вони перейдуть до подальшої роботи, а перемикання повністю виключено за такого типу роботи.

Водночас логіка стає важчою через те, що ми маємо обробити ситуацію, коли операція CAS неуспішно відпрацювала. Ми просто змоделюємо код таким чином, щоб він не рухався далі, доки операція не пройде успішно.

3. Знайомство з атомарними типами

Уяви, коли тобі потрібно налаштувати синхронізацію для найпростішої змінної типу int.

Як це зробити? Перший спосіб, який ми вже розібрали, – це використання volatile + synchronized. Але ще є спеціальні класи Atomic*.

Якщо у нас використовується CAS, операції працюють швидше в порівнянні з першим способом. І на додаток у нас є спеціальні та дуже зручні методи для додавання значення та операції інкременту і декременту.

AtomicBoolean,AtomicInteger,AtomicLong,AtomicIntegerArray,AtomicLongArray – класи, в яких операції атомарні. Давай розберемо роботу з ними.

4. AtomicInteger

Клас AtomicInteger надає операції зі значенням int, які можуть бути прочитані та записані атомарно, на додаток містить розширені атомарні операції.

Він має методи get і set, які працюють як читання і запис по змінним.

Тобто "відбувається до (happens-before)" з будь-яким подальшим отриманням тієї ж змінної, про яку ми говорили раніше. У атомарного методу compareandset також є ці особливості узгодженості пам'яті.

Усі операції, які повертають нове значення, виконуються атомарно:

int addAndGet (int delta) Додає певне значення до поточного значення.
boolean compareAndSet (очікуване int, оновлення int) Встановлює значення для оновленого значення, якщо поточне значення збігається з очікуваним значенням.
int decrementAndGet() Зменшує поточне значення на одиницю.
int getAndAdd (int delta) Додає це значення до поточного значення.
int getAndDecrement() Зменшує поточне значення на одиницю.
int getAndIncrement() Збільшує поточне значення на одиницю.
int getAndSet (int newValue) Встановлює вказане значення та повертає старе значення.
int incrementAndGet () Збільшує поточне значення на одиницю.
lazySet (int newValue) Зрештою встановлюється на задане значення.
boolean weakCompareAndSet (очікуване, оновлення int) Встановлює значення для оновленого значення, якщо поточне значення збігається з очікуваним значенням.

Приклад:


ExecutorService executor = Executors.newFixedThreadPool(5);
IntStream.range(0, 50).forEach(i -> executor.submit(atomicInteger::incrementAndGet));
executor.shutdown();
executor.awaitTermination(Long.MAX_VALUE, TimeUnit.HOURS);

System.out.println(atomicInteger.get()); // виведе 50