1. Класс ReentrantLock: гибкая блокировка
Ключевое слово synchronized отлично подходит для базовых случаев: быстро и просто можно защитить метод или блок кода. Но иногда хочется большего:
- Явно управлять блокировкой (например, попытаться захватить её, а если не удалось — не ждать).
- Разделять права на «чтение» и «запись» к ресурсу.
- Прерывать ожидание блокировки.
- Диагностировать, кто и когда захватил или освободил блокировку.
Для таких задач и придуманы классы ReentrantLock и ReadWriteLock. Они дают больше контроля и возможностей, чем старый добрый synchronized.
Что же это такое?
ReentrantLock — это класс, реализующий интерфейс Lock. Он работает примерно как synchronized, но с дополнительными «наворотами». Главное отличие — управление блокировкой становится явным: вы сами вызываете методы lock() и unlock().
Интересный момент — слово reentrant значит, что поток может захватить один и тот же замок несколько раз подряд, не создавая взаимной блокировки. Это полезно, если метод вызывает сам себя рекурсивно или работает с общей блокировкой в цепочке вызовов.
Синтаксис использования
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class Counter {
private int value = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock(); // Захватываем блокировку
try {
value++;
} finally {
lock.unlock(); // Обязательно освобождаем блокировку!
}
}
public int getValue() {
lock.lock();
try {
return value;
} finally {
lock.unlock();
}
}
}
Обратите внимание:
Вызов lock() и unlock() всегда должен сопровождаться конструкцией try...finally. Если забыть вызвать unlock(), то никакой другой поток не сможет войти в защищённый блок — получите «вечную пробку».
Возможности ReentrantLock
Попытка захвата:
Можно попробовать захватить блокировку, но не ждать бесконечно:
if (lock.tryLock()) {
try {
// Работаем
} finally {
lock.unlock();
}
} else {
// Не удалось захватить — делаем что-то другое
}
Ожидание с таймаутом:
if (lock.tryLock(100, TimeUnit.MILLISECONDS)) {
// Захватили за 100 мс
}
Проверка, захвачена ли блокировка:
if (lock.isLocked()) { ... }
Диагностика очереди ожидания, «справедливость» блокировки и прочие плюшки.
3. Пример: инкремент счётчика с ReentrantLock
Давайте развиваем наше консольное приложение (например, имитируем обработку заказов из разных потоков). Сравним, как выглядит работа с synchronized и с ReentrantLock.
Пример с synchronized
public class OrderCounter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized int getCount() {
return count;
}
}
Аналогичный пример с ReentrantLock
import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;
public class OrderCounter {
private int count = 0;
private final Lock lock = new ReentrantLock();
public void increment() {
lock.lock();
try {
count++;
} finally {
lock.unlock();
}
}
public int getCount() {
lock.lock();
try {
return count;
} finally {
lock.unlock();
}
}
}
В чём профит?
- Можно попытаться захватить блокировку и не ждать бесконечно (tryLock()).
- Можно реализовать сложную логику: например, захватывать несколько блокировок в определённом порядке (актуально для сложных структур данных).
- Можно «разблокировать» из другого места (но делать это нужно очень аккуратно — всегда помнить про unlock()!).
4. ReadWriteLock: блокировка для чтения и записи
Что это такое?
ReadWriteLock — это не просто замок, а умный распределитель доступа. Его основная реализация — ReentrantReadWriteLock, и она делит блокировки на две категории: для чтения и для записи.
Когда потоки только читают данные и никто ничего не меняет, они могут спокойно работать вместе — чтение не мешает чтению. Но как только кто-то решает внести правку, все остальные должны подождать: запись допускает лишь одного участника и требует тишины.
Такой подход особенно полезен там, где чтений много, а изменений мало — например, в каталоге товаров, который пользователи постоянно просматривают, но обновляют лишь изредка.
Синтаксис использования
import java.util.concurrent.locks.ReadWriteLock;
import java.util.concurrent.locks.ReentrantReadWriteLock;
public class ProductCatalog {
private final Map<String, String> products = new HashMap<>();
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
public void addProduct(String id, String name) {
rwLock.writeLock().lock();
try {
products.put(id, name);
} finally {
rwLock.writeLock().unlock();
}
}
public String getProduct(String id) {
rwLock.readLock().lock();
try {
return products.get(id);
} finally {
rwLock.readLock().unlock();
}
}
}
Пример использования в нашем приложении
Допустим, у нас есть база заказов, которую читают все потоки (например, для поиска заказа), но время от времени поступают новые заказы (операция записи).
import java.util.*;
import java.util.concurrent.locks.*;
public class OrderDatabase {
private final List<String> orders = new ArrayList<>();
private final ReadWriteLock rwLock = new ReentrantReadWriteLock();
// Добавление заказа (требует writeLock)
public void addOrder(String order) {
rwLock.writeLock().lock();
try {
orders.add(order);
} finally {
rwLock.writeLock().unlock();
}
}
// Получение копии всех заказов (можно читать параллельно)
public List<String> getOrders() {
rwLock.readLock().lock();
try {
// Возвращаем копию, чтобы не было гонки
return new ArrayList<>(orders);
} finally {
rwLock.readLock().unlock();
}
}
}
Что происходит?
- Пока никто не пишет, хоть тысяча потоков может одновременно читать список заказов.
- Как только один поток начинает добавлять заказ — чтения блокируются, чтобы не получить «размазанные» данные.
5. Сравнение: когда что использовать?
| Сценарий | synchronized | ReentrantLock | ReadWriteLock |
|---|---|---|---|
| Простая синхронизация | ✔ | ✔ | ✖ (избыточно) |
| Нужен таймаут/попытка захвата | ✖ | ✔ | ✔ |
| Много операций чтения, мало записи | ✖ | ✖ | ✔ (значительный прирост) |
| Необходима диагностика/статистика | ✖ | ✔ | ✔ |
| Рекурсивная блокировка | ✔ | ✔ (реентрабельность) | ✔ |
Вывод:
- Для простых случаев — используйте synchronized.
- Для гибкости — ReentrantLock.
- Для сценариев «читаем часто, пишем редко» — ReadWriteLock.
6. Визуализация: схема работы ReadWriteLock
flowchart LR
subgraph Чтение
T1[Поток 1] -- Чтение --> Orders
T2[Поток 2] -- Чтение --> Orders
T3[Поток 3] -- Чтение --> Orders
end
subgraph Запись
T4[Поток 4] -- Запись (addOrder) --> Orders
end
Orders[Список заказов]
style Orders fill:#f9f,stroke:#333,stroke-width:2px
Пока ни один поток не пишет, все могут читать одновременно. Как только появляется запись, остальные потоки ждут завершения writeLock.
7. Особенности реализации и нюансы
«Честность» (fairness)
У ReentrantLock и ReentrantReadWriteLock можно включить «честный» режим (fair mode): потоки обслуживаются в порядке очереди, а не «кто успел, тот и съел». Это предотвращает «голодание» потоков, но может снизить производительность.
Lock fairLock = new ReentrantLock(true); // true — честный режим
ReadWriteLock fairRWLock = new ReentrantReadWriteLock(true);
Потенциальные ловушки
- Забытый unlock: Если не вызвать unlock(), получите вечную блокировку. Всегда используйте try...finally.
- Исключения внутри блокировки: Даже если в блоке кода случилось исключение, блокировка должна быть освобождена!
- Избыточное использование ReadWriteLock: Для маленьких коллекций или если почти всегда только запись — смысла в ReadWriteLock мало, а код становится сложнее.
8. Типичные ошибки
Ошибка №1: забыли вызвать unlock()
Самая частая и коварная ошибка — забыть вызвать unlock() после захвата блокировки. В результате — вечная блокировка, потоки «висят». Всегда используйте try...finally, даже если кажется, что «тут ничего не сломается».
Ошибка №2: использовать ReadWriteLock там, где не нужно
Если у вас почти нет параллельных чтений, а запись — частая, ReadWriteLock только усложнит код и снизит производительность. Используйте его только там, где реально много одновременных читателей.
Ошибка №3: захватили несколько блокировок в разном порядке
Если ваш код захватывает несколько Lock (например, для нескольких объектов), всегда делайте это в одном и том же порядке во всех потоках. Иначе можно получить deadlock — потоки будут ждать друг друга вечно.
Ошибка №4: попытка заменить synchronized на ReentrantLock «просто так»
Не стоит бездумно менять все synchronized на Lock — это не всегда ускоряет программу и может сделать код менее читаемым.
Ошибка №5: забыли про reentrancy
Если один и тот же поток вызывает lock() несколько раз подряд — это нормально для ReentrantLock, но не забывайте, что unlock() надо вызвать столько же раз!
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ