Программирование JavaМногопоточностьJava-разработчик серверных приложений

В коде ниже определите причину зависания при попытке перейти от чтения к записи: пример с кодом Какой механ...

В коде ниже определите причину зависания при попытке перейти от чтения к записи:

import java.util.concurrent.locks.ReentrantReadWriteLock;

class Demo {
    public static void main(String[] args) {
        ReentrantReadWriteLock lock = new ReentrantReadWriteLock();
        lock.readLock().lock();
        try {
            lock.writeLock().lock();
            System.out.println("updated");
        } finally {
            lock.writeLock().unlock();
            lock.readLock().unlock();
        }
    }
}

Какой механизм приводит к зависанию и как корректно спроектировать такую операцию?

Проходите собеседования с ИИ помощником Hintsage

Краткий ответ

Код зависает на writeLock().lock(): ReentrantReadWriteLock не поддерживает безопасное повышение read lock до write lock. Поток продолжает удерживать read lock, а запись может начаться только после освобождения всех read lock, включая удерживаемый этим же потоком.

Нельзя решить проблему простым изменением порядка unlock(): перед захватом write lock нужно сначала освободить read lock, после чего повторно проверить условие под write lock. Если повторная проверка невозможна или операция часто изменяет данные, следует сразу захватывать write lock.

Исторический контекст

Обычный synchronized предоставляет взаимоисключение: одновременно внутри защищённой области находится только один поток. Для структур с большим числом чтений и редкими изменениями это может излишне ограничивать параллелизм.

ReentrantReadWriteLock разделяет доступ на два режима: несколько потоков могут одновременно читать, но запись требует исключительного доступа. Такая модель решает проблему избыточной сериализации чтений, однако усложняет переход между режимами блокировки.

Постановка проблемы

Read lock совместим с другими read lock, но несовместим с write lock. Пока поток удерживает read lock, его попытка получить write lock не может завершиться: для выдачи write lock необходимо, чтобы число читателей стало равно нулю.

Это создаёт самоблокировку даже в однопоточном примере. В многопоточной программе ситуация ещё сложнее: другие читатели могут продолжать работать, поэтому поток, ожидающий запись, не может определить, когда безопасно продолжить.

Подробное решение

ReentrantReadWriteLock является реентерабельным для одного и того же режима: поток, уже владеющий write lock, может захватить его повторно. Но реентерабельность не означает поддержку повышения read lock до write lock.

Безопасный общий шаблон выглядит так:

import java.util.concurrent.locks.ReentrantReadWriteLock; class Cache { private final ReentrantReadWriteLock lock = new ReentrantReadWriteLock(); private String value; void updateIfMissing() { lock.writeLock().lock(); try { if (value == null) value = "loaded"; } finally { lock.writeLock().unlock(); } } }

Если сначала требуется дешёвая проверка под read lock, поток должен освободить его, захватить write lock и выполнить проверку заново. Повторная проверка обязательна: после освобождения read lock другой поток мог изменить состояние. Поэтому схема с повторным захватом может привести к лишней работе или требует дополнительной проверки версии данных.

Режим справедливости блокировки не устраняет проблему повышения: он влияет на порядок предоставления lock ожидающим потокам, но не делает несовместимые режимы совместимыми. tryLock() позволяет избежать бесконечного ожидания, однако отказ от захвата нужно обработать, а не считать успешным переходом.

Обратный переход — понижение write lock до read lock — поддерживается. Сначала поток захватывает read lock, пока ещё держит write lock, и только затем освобождает write lock. Благодаря этому между режимами не возникает окна, в котором другой поток мог бы изменить данные.

Ситуация из практики

В кэше поток под read lock проверяет наличие записи. При отсутствии записи он пытается загрузить значение и записать его в кэш. Прямое повышение блокировки приводит к зависанию, а простое снятие read lock с последующим захватом write lock позволяет нескольким потокам одновременно начать одну и ту же загрузку.

Варианты решения:

  • Сразу использовать write lock для операции «проверить и при необходимости создать». Это проще и корректнее, но сериализует даже те вызовы, которым запись не понадобилась.
  • Освободить read lock, захватить write lock и повторить проверку. Это безопасно, но требует повторного чтения и может вызвать дублирование внешней работы до входа в критическую секцию.
  • Использовать StampedLock с попыткой преобразования read stamp в write stamp. Это может уменьшить блокировки, но API сложнее, преобразование может не удаться, а код обязан корректно обрабатывать stamp и повторять проверку.

Для обычного кэша с короткой критической секцией разумный выбор — повторная проверка под write lock, а внешнюю долгую загрузку организовать отдельно с дедупликацией запросов. Такой подход не удерживает блокировку во время медленного I/O и не допускает запись без повторной проверки состояния.

Что кандидаты часто упускают

  1. Устранит ли проблему справедливый режим new ReentrantReadWriteLock(true)?

Нет. Справедливый режим регулирует очередность ожидающих потоков и снижает риск голодания, но поток всё равно удерживает read lock, несовместимый с требуемым write lock. Освобождение собственного read lock и повторный захват write lock остаются обязательными.

  1. Можно ли после снятия read lock захватить write lock без повторной проверки условия?

Обычно нельзя. Между двумя захватами другой поток может изменить данные, поэтому исходное решение уже не гарантирует корректность. Правильный шаблон — захватить write lock и заново проверить условие; иначе возможны потеря обновления, перезапись более нового значения или дублирование операции.

  1. Как корректно выполнить понижение write lock до read lock?

Поток должен сначала захватить read lock, продолжая удерживать write lock, затем освободить write lock и оставить read lock до окончания чтения. Если сначала освободить write lock, а потом пытаться получить read lock, другой поток может изменить состояние между этими действиями, и атомарность перехода будет потеряна.