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, а 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 року. Ми проконсультуємо вас і запропонуємо найкраще рішення.
Читайте також