1. Ключове слово synchronized: навіщо і як
У Java ключове слово synchronized — це як табличка «Зайнято!» на дверях вбиральні: поки один потік перебуває всередині «критичної секції», інші чемно чекають своєї черги. Лише коли перший вийде, наступний зможе увійти й виконати свій код.
Синтаксис: блок і метод
Синхронізований блок
synchronized (object) {
// критична секція
}
- object — це будь-який об’єкт, на який ви хочете «повісити замок». Поки один потік виконує цей блок, інші потоки, які теж хочуть увійти в блок із цим самим об’єктом, чекатимуть.
Синхронізований метод
public synchronized void increment() {
// критична секція
}
- Тут «замок» вішається на сам об’єкт (this). Тобто лише один потік за раз може виконувати будь-який синхронізований метод цього об’єкта.
Статичний синхронізований метод
public static synchronized void foo() {
// критична секція
}
- Тут блокування відбувається на рівні класу (ClassName.class), а не конкретного об’єкта.
Як це працює під капотом
Коли потік входить у синхронізований блок або метод, він захоплює «монітор» об’єкта (або класу для статичних методів). Якщо монітор уже зайнятий — потік чекає. Щойно монітор звільняється, наступний потік може увійти.
2. Приклад: інкремент лічильника з і без синхронізації
Без синхронізації
public class Counter {
private int count = 0;
public void increment() {
count++;
}
public int getCount() {
return count;
}
}
public class CounterDemo {
public static void main(String[] args) throws InterruptedException {
Counter counter = new Counter();
Runnable task = () -> {
for (int i = 0; i < 1000; i++) {
counter.increment();
}
};
Thread t1 = new Thread(task);
Thread t2 = new Thread(task);
t1.start();
t2.start();
t1.join();
t2.join();
System.out.println("Підсумкове значення: " + counter.getCount());
}
}
Очікуване значення: 2000
Реальне значення: може бути меншим (наприклад, 1995, 1987...), і під час кожного запуску — свій «сюрприз».
Чому? Тому що операція count++ не атомарна: вона розбивається на три дії — прочитати значення, збільшити, записати назад. Якщо два потоки роблять це одночасно, вони можуть «перетерти» результат один одного.
Рішення: synchronized
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public int getCount() {
return count;
}
}
Тепер лише один потік за раз може виконувати метод increment(). Підсумкове значення завжди буде 2000.
Альтернатива: синхронізований блок
public class Counter {
private int count = 0;
public void increment() {
synchronized (this) {
count++;
}
}
}
Результат буде той самий. Можна синхронізувати не весь метод, а лише потрібну частину.
3. Вступ до «монітора об’єкта»
Монітор — це «замок», вбудований у кожен об’єкт у Java. Коли ви пишете synchronized(object), потік намагається «замкнути» цей об’єкт. Якщо замок вільний — потік його отримує, якщо ні — чекає своєї черги. Щойно потік виходить із блоку, замок звільняється.
Важливо! Якщо ви синхронізуєте на різних об’єктах — потоки не чекатимуть один одного. Тому дуже важливо обрати правильний об’єкт для синхронізації.
Статичні синхронізовані методи
Іноді спільний ресурс — це не об’єкт, а щось спільне для усіх екземплярів класу (наприклад, статична змінна). У такому разі синхронізація має бути на рівні класу.
public class StaticCounter {
private static int count = 0;
public static synchronized void increment() {
count++;
}
public static int getCount() {
return count;
}
}
Це еквівалентно:
public static void increment() {
synchronized (StaticCounter.class) {
count++;
}
}
Монітор ставиться на об’єкт класу (Class), а не на конкретний екземпляр.
4. Ключове слово volatile: що це і навіщо
Проблема видимості між потоками
У Java кожен потік може кешувати значення змінних для прискорення роботи. Це означає, що якщо один потік змінив змінну, інший потік може цього «не помітити», продовжуючи читати значення зі свого локального кешу. Це особливо критично для прапорців, якими потоки сигналізують один одному.
Як працює volatile
Якщо змінна оголошена як volatile, це означає:
- Усі потоки завжди читають і пишуть її лише в основну пам’ять, минаючи кеш.
- Будь-яка зміна змінної миттєво стає видимою для всіх потоків.
Але! Операції з volatile самі по собі не атомарні (окрім простого читання/запису примітивів на кшталт boolean, int тощо). Якщо ви робите щось складніше, ніж присвоєння — потрібна синхронізація.
Приклад: прапорець завершення роботи
public class Worker extends Thread {
private volatile boolean running = true;
public void run() {
while (running) {
// робимо щось корисне
}
System.out.println("Потік завершився");
}
public void shutdown() {
running = false;
}
}
Worker w = new Worker();
w.start();
// ... через якийсь час
w.shutdown();
Без volatile потік може «не помітити» зміну прапорця і зациклитися назавжди (особливо на багатоядерних системах). З volatile — усе працює як слід.
5. Обмеження volatile: неатомарність
Багато новачків думають: «Якщо зробити volatile int, то можна писати count++ і не перейматися». На жаль, це не так:
private volatile int count = 0;
public void increment() {
count++;
}
Помилка! Операція count++ усе одно не атомарна — це три кроки: (1) прочитати, (2) збільшити, (3) записати назад. Якщо два потоки одночасно прочитають одне й те саме значення, обидва його збільшать і обидва запишуть однаковий результат — один інкремент «загубиться».
Висновок: volatile забезпечує лише видимість змін, але не захищає від гонок під час складних операцій.
6. Коли використовувати synchronized, а коли — volatile
- volatile — коли у вас є простий прапорець (наприклад, boolean), який один потік пише, а інший читає. Приклад: завершення роботи потоку, сигналізація про подію.
- synchronized — коли потрібно забезпечити атомарність складних операцій (наприклад, інкремент, зміна кількох змінних, робота зі структурами даних).
Таблиця для запам’ятовування
| Сценарій | volatile | synchronized |
|---|---|---|
| Передати сигнал між потоками | ✔ | ✔ |
| Атомарна операція (інкремент) | ✖ | ✔ |
| Кілька кроків у критичній секції | ✖ | ✔ |
| Лише видимість змін | ✔ | ✔ |
7. Типові помилки під час використання synchronized і volatile
Помилка № 1: Синхронізація на неправильному об’єкті. Якщо ви синхронізуєте на локальній змінній або на різному об’єкті в кожному потоці — жодного захисту не буде.
Object lock = new Object();
synchronized (lock) {
// ...
}
Якщо кожен потік створює свій lock — жодної користі. Потрібна єдина точка синхронізації, спільний об’єкт для всіх потоків.
Помилка № 2: Очікування атомарності від volatile. volatile гарантує видимість, а не атомарність. Операції на кшталт count++ і далі небезпечні без синхронізації.
Помилка № 3: Синхронізація занадто великої області коду. Якщо ви синхронізуєте весь метод, а потрібно — лише один рядок, ви даремно блокуєте інші потоки й втрачаєте продуктивність. Намагайтеся зменшувати «критичну секцію».
Помилка № 4: Забули зробити синхронізацію «статичною» для статичних даних. Якщо у вас статична змінна, а ви синхронізуєте на this, це не допоможе. Для статичних даних потрібна синхронізація на рівні класу: synchronized(ClassName.class).
Помилка № 5: Синхронізація на рядковому літералі. Синхронізація на рядках небезпечна, тому що однакові літерали інтернуються у JVM. Можна випадково отримати спільне блокування для різних частин програми.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ