1. Що таке спільний ресурс
Зі спільними ресурсами ви вже стикалися. Дім, у якому живе родина, — спільний ресурс цієї родини. Офісний холодильник — спільний ресурс для всіх колег. Гадаю, ідею ви зрозуміли.
У програмуванні спільний ресурс — це змінна, об’єкт або структура даних, до яких одночасно можуть звертатися кілька потоків. Це може бути:
- Змінна-лічильник кількості оброблених замовлень.
- Список заявок, який заповнюють одні потоки, а обробляють інші.
- Відкритий файл, у який пишуть кілька потоків.
- Підключення до бази даних, з яким працюють різні частини програми.
У Java будь-який об’єкт або змінна, до яких можуть звернутися кілька потоків, стає потенційно «спільним ресурсом».
Приклад спільного ресурсу: глобальний лічильник
public class Counter {
public int value = 0;
}
Якщо кілька потоків збільшуватимуть цей лічильник, вони звертатимуться до тієї самої змінної value — ось вам і спільний ресурс.
2. Проблеми одночасного доступу
В однопотоковій програмі все просто: один потік — один виконавець — він іде по коду, як потяг по рейках. Але щойно в справу вступають кілька потоків, починається справжній «танець із шаблями»: потоки можуть втручатися в роботу одне одного в найнесподіваніших місцях.
Race condition (стан гонки)
Race condition — це ситуація, коли результат роботи програми залежить від того, як саме «перемішалися» дії потоків. Тобто якщо ви кілька разів запустите одну й ту саму програму, результат може бути різним — і це не баг, це «фіча» багатопотоковості.
Класичний приклад: два потоки збільшують лічильник
Спробуймо змоделювати просту ситуацію: у нас є спільний лічильник, і два потоки по тисячі разів збільшують його значення.
public class Counter {
public int value = 0;
}
public class CounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable incrementTask = () -> {
for (int i = 0; i < 1000; i++) {
counter.value++; // НЕБЕЗПЕЧНЕ МІСЦЕ!
}
};
Thread t1 = new Thread(incrementTask);
Thread t2 = new Thread(incrementTask);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Очікуване значення: 2000");
System.out.println("Реальне значення: " + counter.value);
}
}
Що побачимо на екрані?
Іноді — 2000, іноді — 1985, іноді — 1937… Чому? Тому що операція counter.value++ не атомарна! Вона складається з трьох кроків:
- Прочитати поточне значення counter.value.
- Збільшити його на 1.
- Записати назад.
Якщо два потоки одночасно прочитають те саме значення, обидва його збільшать, а потім обидва запишуть результат — у підсумку одне збільшення «загубиться». Це і є lost update — «втрата оновлення».
Неконсистентний стан об’єкта
Якщо у вас є складний об’єкт, що складається з кількох полів, і потоки одночасно змінюють різні поля, об’єкт може опинитися в «дивному» або неконсистентному стані. Наприклад, сума на рахунку зменшилася, але історія операцій не оновилася — клієнт у паніці, бухгалтер у шоці.
3. Навіщо потрібна синхронізація
Синхронізація — це спосіб сказати програмі: «Стоп, цей фрагмент коду має виконуватися лише одним потоком одночасно! Решта — зачекають!» Це як табличка «Прибирання! Не входити!» на дверях туалету: поки одна людина всередині, інші чекають зовні (і подумки проклинають того, хто довго не виходить).
Гарантія цілісності даних
Якщо ми хочемо, щоб наш лічильник завжди збільшувався правильно, потрібно заборонити одночасну зміну його значення кількома потоками.
Приклад: синхронізація збільшення лічильника
public class Counter {
public int value = 0;
public synchronized void increment() {
value++;
}
}
Тепер, якщо два потоки викличуть increment(), лише один із них зможе виконати цей метод у даний момент часу. Другий чекатиме, доки перший не завершить.
Схема: що відбувається під час синхронізації
+-------------------+
| Потік 1 | --\
+-------------------+ \
| \
V \
+-------------------+ > [ synchronized increment() ]
| Потік 2 | --/ /
+-------------------+ / /
| / /
V / /
+-------------------+ / /
| Потік 3 | --/ /
+-------------------+ /
| /
V /
+-------------------+ /
| Потік N |/
+-------------------+
Усі потоки стають у чергу на виконання захищеної ділянки коду. Лише один потік може бути всередині «критичної секції» (synchronized-блоку) одночасно.
4. Короткий вступ до способів синхронізації
Синхронізація в Java — це не якийсь один спосіб, а цілий «арсенал» інструментів, які дають змогу захистити спільний ресурс від одночасного доступу.
Ключове слово synchronized
Це основний інструмент синхронізації в Java. Його можна використовувати двома способами:
Синхронізований метод
public synchronized void increment() {
value++;
}
Синхронізований блок
public void increment() {
synchronized (this) {
value++;
}
}
Тут this — об’єкт, на якому відбувається блокування. Поки один потік виконує цей блок, інші потоки, що хочуть увійти до такого самого блоку з тим самим об’єктом, чекатимуть.
Спеціалізовані класи з java.util.concurrent
- Lock, ReentrantLock — більш гнучка альтернатива synchronized.
- ReadWriteLock — для поділу блокувань на читання й запис.
- Semaphore — обмеження кількості потоків, що одночасно виконують код.
- CountDownLatch, CyclicBarrier та інші — для координації роботи потоків.
Важливо: сьогодні ми лише знайомимося з основами — про ці класи поговоримо трохи пізніше.
5. Практичний приклад: застосунок із багатопотоковим лічильником
Припустімо, ми пишемо статистику звернень користувачів до певного сервісу. Кожен потік — це окремий користувач, який збільшує спільний лічильник.
Без синхронізації
public class Counter {
public int value = 0;
}
public class MultiThreadCounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable user = () -> {
for (int i = 0; i < 10000; i++) {
counter.value++;
}
};
Thread t1 = new Thread(user);
Thread t2 = new Thread(user);
Thread t3 = new Thread(user);
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("Очікуване значення: 30000");
System.out.println("Реальне значення: " + counter.value);
}
}
Результат: Майже завжди менше 30000. Іноді — набагато менше! Чому? Тому що потоки «перебивають» один одного.
Синхронізація: виправляємо помилку
public class Counter {
public int value = 0;
public synchronized void increment() {
value++;
}
}
public class MultiThreadCounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable user = () -> {
for (int i = 0; i < 10000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(user);
Thread t2 = new Thread(user);
Thread t3 = new Thread(user);
t1.start();
t2.start();
t3.start();
t1.join();
t2.join();
t3.join();
System.out.println("Очікуване значення: 30000");
System.out.println("Реальне значення: " + counter.value);
}
}
Результат: Завжди 30000. Ура, синхронізація працює!
6. Корисні нюанси
Візуалізація: як виглядає race condition
Намалюймо невелику таблицю, щоб показати, як два потоки можуть «загубити» інкремент:
| Крок | Потік 1 | Потік 2 | Значення value |
|---|---|---|---|
| 1 | Читає value=0 | 0 | |
| 2 | Читає value=0 | 0 | |
| 3 | Збільшує до 1 | 0 | |
| 4 | Збільшує до 1 | 0 | |
| 5 | Записує 1 | 1 | |
| 6 | Записує 1 | 1 |
Коли потрібна синхронізація
Синхронізація потрібна не завжди. Коли змінна живе у своєму маленькому світі й із нею працює лише один потік — можна розслабитися. Але варто тільки поділитися нею з іншими потоками, як без синхронізації вже нікуди. Навіть якщо здається, що все обійдеться — не вірте. Помилка гонки підступна: вона може довго ховатися, а потім раптово вистрілити в найнесприятливіший момент.
Запас на майбутнє: які ще бувають засоби синхронізації
Сьогодні ми познайомилися лише з базовим інструментом — synchronized. У наступних лекціях розглянемо:
- Як працює монітор об’єкта та які бувають типи блокувань.
- Що таке статичні синхронізовані методи (static + synchronized).
- Як працює ключове слово volatile і навіщо воно потрібне.
- Які існують сучасні класи для синхронізації (Lock, Semaphore тощо).
7. Типові помилки під час роботи зі спільними ресурсами
Помилка № 1: Ігнорування багатопоточності.
Одна з найпоширеніших помилок — не замислюватися над тим, що змінна може бути доступною з кількох потоків. Навіть якщо зараз програма однопотокова, пізніше хтось додасть потоки — і баги з’являться «нізвідки».
Помилка № 2: Недостатня або надмірна синхронізація.
Якщо не синхронізувати доступ до спільного ресурсу — отримаєте race condition і неконсистентні дані. Якщо ж синхронізувати все підряд, програма «задихнеться» від блокувань і стане повільною. Завжди намагайтеся синхронізувати лише те, що справді потрібно.
Помилка № 3: Синхронізація на неправильному об’єкті.
Якщо синхронізувати доступ на різних об’єктах (наприклад, на локальних змінних або рядкових літералах), це не захистить спільний ресурс. Усі потоки мають синхронізуватися на одному й тому самому об’єкті.
Помилка № 4: Очікування атомарності від неатомарних операцій.
Операція i++ не атомарна! Навіть якщо змінну оголошено як volatile, це не робить інкремент атомарним. Для таких операцій потрібна синхронізація.
Помилка № 5: «Мені пощастило, в мене все працює».
Race condition може не проявлятися на вашому комп’ютері, але обов’язково проявиться на сервері або в користувача. Ніколи не покладайтеся на «авось» у багатопотокових програмах!
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ