1. Забутий unlock/release: пастка для неуважних
Одна з найпідступніших помилок під час використання сучасних інструментів синхронізації, таких як ReentrantLock або Semaphore, — забути викликати unlock() або release(). Якщо ви не звільните блокування, то інші потоки чекатимуть його звільнення… вічно. Програма зависне, і ви довго дивитиметеся на екран, намагаючись зрозуміти, чому нічого не відбувається.
Розгляньмо приклад із ReentrantLock:
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
// Ой! Забули unlock() — тепер усі зависнуть!
count++;
}
}
Усе виглядає невинно, але якщо викликати increment() кілька разів із різних потоків, після першого виклику решта потоків чекатиме звільнення блокування безкінечно.
Щоб уникнути цієї ситуації, використовуйте конструкцію try-finally:
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
Тепер, навіть якщо посеред методу станеться виняток, блокування буде гарантовано звільнено.
Це якби хтось зайняв вбиральню (замкнувся зсередини), а потім забув відчинити двері й вийшов через вікно. Решта чекатиме, доки ця людина не вийде… Не робіть так!
2. Синхронізація на неправильному об’єкті: «Ох, не туди замок повісив!»
У Java ключове слово synchronized може блокувати доступ до певного об’єкта. Але якщо ви оберете неправильний об’єкт для блокування, синхронізація не спрацює так, як ви очікуєте.
Помилка № 1: синхронізація на локальній змінній
public void doSomething() {
Object lock = new Object();
synchronized (lock) {
// Щоразу новий об’єкт — жодної синхронізації!
// Потоки не чекають одне одного.
// Критична секція не захищена!
}
}
Тут кожен потік створює свій власний об’єкт lock. У результаті жодного реального блокування не відбувається — потоки заходять до критичної секції одночасно.
Правильно:
private final Object lock = new Object();
public void doSomething() {
synchronized (lock) {
// Тепер усі потоки використовують один і той самий об’єкт lock
// і справді чекають одне одного.
}
}
Помилка № 2: синхронізація на рядковому літералі
public void doSomething() {
synchronized ("lock") {
// Рядкові літерали інтерновані: різні частини програми можуть
// випадково синхронізуватися на одному й тому самому рядку!
}
}
Висновок:
Синхронізуйтеся лише на приватних, спеціально створених для цього об’єктах, які більше ніде не використовуються.
3. Взаємне блокування (deadlock): «Ти мені — я тобі, і обидва стали»
Deadlock (взаємне блокування) — це класика жанру. Два (або й більше) потоки по черзі захоплюють різні блокування й чекають одне одного, доки програма не зависне.
Приклад:
public class DeadlockExample {
private final Object lockA = new Object();
private final Object lockB = new Object();
public void method1() {
synchronized (lockA) {
// Трохи зачекаємо заради чистоти експерименту
try { Thread.sleep(50); } catch (InterruptedException e) {}
synchronized (lockB) {
// ...
}
}
}
public void method2() {
synchronized (lockB) {
try { Thread.sleep(50); } catch (InterruptedException e) {}
synchronized (lockA) {
// ...
}
}
}
}
Якщо один потік викличе method1(), а інший — method2(), то перший потік захопить lockA і чекатиме lockB, а другий — навпаки. У результаті обидва чекатимуть одне одного до скону віків.
Як уникнути?
- Завжди захоплюйте блокування в однаковому порядку в усіх потоках.
- Мінімізуйте кількість одночасно утримуваних блокувань.
- Використовуйте засоби діагностики (наприклад, jstack), якщо програма зависла.
Аналогія:
Це якби двоє людей зустрілися у вузькому коридорі, і кожен вирішив поступитися дорогою, але лише якщо інший спершу поступиться йому. У підсумку обидва стоять і чекають, доки хтось перший не здасться.
4. Надмірна синхронізація: «Краще перестрахуватися, ніж недострахуватися?» — не завжди!
Іноді розробники, остерігаючись помилок, синхронізують усе підряд. У результаті продуктивність падає, а користі — нуль.
Приклад:
public synchronized void add(int value) {
// Тут лише один рядок, який не потребує синхронізації!
System.out.println("Додано: " + value);
}
У цьому випадку синхронізація не потрібна: виведення на екран через System.out.println уже потокобезпечне, а сам метод не працює зі спільними ресурсами.
Де це критично?
Якщо ви синхронізуєте методи, які викликаються часто й не потребують захисту, ви різко знижуєте продуктивність програми. Потоки шикуються в чергу, хоча могли б працювати паралельно.
Найкраща практика:
Синхронізуйте лише те, що справді необхідно. Критична секція має бути якомога меншою.
5. Неправильне використання volatile: «Видимість є, атомарності немає!»
Модифікатор volatile у Java гарантує, що зміни змінної будуть видимі всім потокам. Але він не гарантує атомарність операцій.
Помилка:
private volatile int counter = 0;
public void increment() {
counter++; // Не атомарно!
}
Операція counter++ складається з читання значення, збільшення та запису назад. Якщо два потоки одночасно виконують цей код, підсумкове значення може бути меншим за очікуване.
Правильно:
Для атомарних операцій використовуйте synchronized, AtomicInteger або інші потокобезпечні класи.
import java.util.concurrent.atomic.AtomicInteger;
private final AtomicInteger counter = new AtomicInteger();
public void increment() {
counter.incrementAndGet();
}
Коли використовувати volatile?
Для простих прапорців (наприклад, «завершити роботу»), коли атомарність не потрібна.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ