JavaRush /Курси /JAVA 25 SELF /ReentrantLock і ReadWriteLock: відмінності, приклади

ReentrantLock і ReadWriteLock: відмінності, приклади

JAVA 25 SELF
Рівень 52 , Лекція 2
Відкрита

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() треба викликати стільки ж разів!

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ