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: забули про реентрантність
Якщо один і той самий потік викликає lock() кілька разів поспіль — це нормально для ReentrantLock, але не забувайте, що unlock() треба викликати стільки ж разів!
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ