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 уже потокобезопасен, а сам метод не работает с общими ресурсами.
Где это критично?
Если вы синхронизируете методы, которые вызываются часто и не требуют защиты, вы резко снижаете производительность программы. Потоки выстраиваются в очередь, хотя могли бы работать параллельно.
Best practice:
Синхронизируйте только то, что действительно необходимо. Критическая секция должна быть как можно меньше.
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?
Для простых флагов (например, «завершить работу»), когда не требуется атомарность.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ