Lock — это механизм синхронизации, обеспечивающий эксклюзивный доступ к критическим секциям кода в многопоточных приложениях. По данным Oracle, 2024, интерфейс Lock предоставляет более гибкий контроль синхронизации по сравнению с традиционными synchronized-блоками, включая попытки захвата с таймаутом и поддержку нескольких очередей ожидания.
Главное
Lock — это интерфейс из пакета java.util.concurrent.locks, предоставляющий явные операции захвата и освобождения для синхронизации доступа к данным. В отличие от synchronized, Lock даёт разработчику полный контроль над механизмом блокировки.
Интерфейс Lock появился в Java 5 как альтернатива встроенному механизму synchronized. Основные методы — lock, unlock, tryLock и lockInterruptibly. Блокировки позволяют организовать безопасный доступ к данным в многопоточной среде, предотвращая состояния гонки и повреждение данных.
Главное преимущество Lock перед synchronized — гибкость. Разработчик может попытаться захватить блокировку с таймаутом, проверить её занятость без блокировки или организовать несколько очередей ожидания с разными приоритетами.
До появления интерфейса Lock в Java 5 единственным способом синхронизации был synchronized, который страдал от ограничений: отсутствие таймаутов, невозможность прервать ожидание и единая очередь. Doug Lea спроектировал пакет java.util.concurrent, включив в него Lock как фундаментальный строительный блок.
Блокировка управляет доступом через внутренний флаг состояния и очередь ожидания. Когда поток вызывает lock(), механизм проверяет, свободна ли блокировка, и либо захватывает её, либо помещает поток в очередь до освобождения.
В основе любой блокировки лежит атомарная операция сравнения и установки (CAS). При вызове lock() поток пытается атомарно установить флаг занятости. Если флаг уже установлен, поток блокируется. При unlock() флаг сбрасывается, и пробуждается один из ожидающих потоков.
import java.util.concurrent.locks.ReentrantLock
val lock = ReentrantLock()
fun performTask() {
lock.lock()
try {
// критическая секция
println("Работает поток ${Thread.currentThread().name}")
} finally {
lock.unlock()
}
}
ReentrantLock использует внутри двустороннюю очередь (CLH lock queue), где каждый ожидающий поток представлен узлом. Когда блокировка освобождается, головной узел очереди пробуждается. Режим fair (честный) гарантирует порядок FIFO, a unfair — допускает захват новым потоком перед ожидающими для повышения пропускной способности.
В современном Java-стеке существует несколько реализаций блокировок, каждая из которых оптимизирована под конкретные сценарии. Выбор правильной блокировки напрямую влияет на производительность и надёжность многопоточного приложения.
ReentrantLock — базовая и наиболее часто используемая реализация Lock. Она поддерживает повторный захват тем же потоком: если поток уже владеет блокировкой, повторный вызов lock() не блокирует его. Это предотвращает deadlock при рекурсивных вызовах.
ReadWriteLock разделяет блокировки на два режима: чтение и запись. Несколько потоков могут одновременно удерживать блокировку чтения, но запись требует эксклюзивного доступа. Это значительно повышает производительность при частом чтении и редкой записи.
StampedLock — новейшая реализация, появившаяся в Java 8. Она поддерживает три режима: запись, чтение и оптимистичное чтение. Оптимистичное чтение не блокирует другие потоки и проверяет валидность данных после чтения, что даёт прирост производительности на 10-20% по сравнению с ReadWriteLock.
| Блокировка | Версия Java | Режимы | Производительность |
|---|---|---|---|
| ReentrantLock | Java 5 | исключительный | высокая |
| ReadWriteLock | Java 5 | чтение + запись | средняя |
| StampedLock | Java 8 | чтение + запись + optimistic | очень высокая |
ReentrantLock — самая популярная реализация Lock, предоставляющая ряд возможностей, недоступных в synchronized. Понимание её особенностей необходимо для эффективной работы с многопоточностью.
Конструктор ReentrantLock принимает параметр fair. При true блокировка гарантирует FIFO-порядок доступа, при false возможен захват новым потоком раньше ожидающих. Честный режим предотвращает голодание, но снижает пропускную способность на 10-20% из-за дополнительных накладных расходов на поддержание очереди.
В отличие от synchronized, ReentrantLock поддерживает tryLock с таймаутом. Если блокировку не удалось захватить за указанное время, поток продолжает выполнение, а не блокируется бесконечно. Метод lockInterruptibly позволяет прервать ожидающий поток через Thread.interrupt().
val lock = ReentrantLock()
fun tryTask() {
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
try {
println("Блокировка захвачена")
} finally {
lock.unlock()
}
} else {
println("Не удалось захватить блокировку")
}
}
ReentrantLock поддерживает несколько условных переменных через метод newCondition(). Каждая Condition имеет собственную очередь ожидания, что позволяет организовать сложные сценарии пробуждения. Методы await() и signal() заменили wait() и notify() из synchronized-блоков, но с поддержкой нескольких очередей.
ReadWriteLock и StampedLock решают задачу оптимизации доступа при преобладании операций чтения над записью. Они значительно эффективнее ReentrantLock в сценариях, где чтение происходит чаще записи.
Интерфейс ReadWriteLock содержит два метода: readLock() и writeLock(). Блокировка чтения может удерживаться несколькими потоками одновременно, блокировка записи — только одним. Типичный пример — потокобезопасный кэш: множество потоков читают данные, и лишь один периодически обновляет их.
class SafeCache<K, V> {
private val map = mutableMapOf<K, V>()
private val rwLock = ReentrantReadWriteLock()
fun get(key: K): V? {
rwLock.readLock().lock()
return try { map[key] } finally { rwLock.readLock().unlock() }
}
fun put(key: K, value: V) {
rwLock.writeLock().lock()
return try { map[key] = value } finally { rwLock.writeLock().unlock() }
}
}
StampedLock добавляет третий режим — tryOptimisticRead. Этот режим не блокирует другие потоки, а лишь запоминает штамп (stamp) состояния. После чтения разработчик вызывает validate(stamp), проверяя, не изменились ли данные за время чтения. Если данные изменились, операцию нужно повторить.
В мобильных приложениях блокировки используются для координации доступа к общим данным между потоками. Однако их применение требует особой осторожности из-за ограниченных ресурсов устройства и необходимости сохранять отзывчивость интерфейса.
На Android ReentrantLock полезен при работе с Room, кэшами и файлами. Важно помнить: никогда не захватывайте блокировку на главном потоке. Для асинхронного кода предпочтительнее корутины и Mutex из kotlinx.coroutines, не блокирующие поток, а приостанавливающие корутину.
В iOS стандартный Lock из NSLock используется реже — разработчики предпочитают DispatchQueue с barrier-флагами или операционные блокировки os_unfair_lock. Swift 5.7+ предоставляет современные механизмы синхронизации через actors, которые автоматически защищают состояние.
import Foundation
actor DataStore {
private var items: [String] = []
func add(_ item: String) {
items.append(item)
}
func getAll() -> [String] {
items
}
}
Чтобы избежать deadlock, соблюдайте единый порядок захвата всех блокировок в проекте. Используйте tryLock с таймаутом вместо lock() везде, где возможна блокировка на длительное время. Рассмотрите применение Lock-Free алгоритмов (AtomicReference, ConcurrentHashMap) вместо традиционных блокировок.
Применение Lock требует дисциплины и соблюдения нескольких правил, которые предотвращают deadlock и падение производительности. Эти практики выработаны сообществом Java за 20 лет использования пакета java.util.concurrent.
Самый важный паттерн — lock в finally. Независимо от того, завершилась критическая секция успешно или с исключением, блокировка должна быть освобождена. Это гарантирует, что другие потоки не заблокируются навсегда из-за одной ошибки. В Kotlin этот паттерн элегантно решается через экстеншн withLock.
Критическая секция должна быть максимально короткой. Никогда не выполняйте внутри блокировки ввод-вывод, сетевые запросы или длительные вычисления. Если нужно прочитать данные с сервера, сначала получите их, а затем захватите блокировку только для обновления разделяемого состояния. Это снижает конкуренцию и повышает пропускную способность системы.
Для предотвращения deadlock при работе с несколькими Lock фиксируйте глобальный порядок захвата по всему проекту. Если сначала захватывается lockA, затем lockB — любая обратная последовательность должна быть запрещена правилами code review. Для автоматической проверки используйте статические анализаторы, такие как SpotBugs и IntelliJ Inspections.
Часто задаваемые вопросы
Lock — явный интерфейс с возможностью таймаута и прерываемого ожидания. synchronized автоматически захватывает и освобождает монитор, но не позволяет использовать tryLock, lockInterruptibly и несколько Condition. Lock гибче, но требует ручного освобождения в finally.
Честная блокировка гарантирует FIFO-порядок доступа: поток, который дольше всех ожидает, первым получает блокировку. Нечестная блокировка может отдать доступ новому потоку в обход очереди, что повышает пропускную способность, но может вызвать голодание ожидающих потоков.
Соблюдайте фиксированный порядок захвата всех блокировок, используйте tryLock с таймаутом вместо безусловного lock, и минимизируйте количество одновременно удерживаемых блокировок. Применение Lock-Free структур данных также снижает риск deadlock.
Condition — аналог wait/notify для Lock, позволяющий организовать несколько независимых очередей ожидания. Каждый вызов newCondition() создаёт отдельную очередь, что даёт более точный контроль над пробуждением потоков по сравнению с единой очередью synchronized.
Для Android с корутинами используйте Mutex из kotlinx.coroutines — он приостанавливает корутину, а не блокирует поток. Для iOS с Swift 5.7+ предпочтительны actors, которые автоматически синхронизируют доступ к состоянию. ReentrantLock оставьте для legacy-кода и низкоуровневых сценариев.
Итоги
Мы разработаем мобильное приложение под ключ
IT Sectr создаёт приложения для iOS и Android для стартапов и бизнеса с 2017 года. Мы проконсультируем вас и предложим наилучшее решение.
Читайте также