Lock to mechanizm synchronizacji zapewniający ekskluzywny dostęp do sekcji krytycznych kodu w aplikacjach wielowątkowych. Według Oracle, 2024, interfejs Lock zapewnia bardziej elastyczną kontrolę synchronizacji w porównaniu z tradycyjnymi blokami synchronized, w tym próby przechwycenia z timeoutem i obsługę wielu kolejek oczekiwania.
Najważniejsze
Lock to interfejs z pakietu java.util.concurrent.locks, zapewniający jawne operacje przechwytywania i zwalniania w celu synchronizacji dostępu do danych. W przeciwieństwie do synchronized, Lock daje programiście pełną kontrolę nad mechanizmem blokady.
Interfejs Lock pojawił się w Javie 5 jako alternatywa dla wbudowanego mechanizmu synchronized. Główne metody to lock, unlock, tryLock i lockInterruptibly. Blokady umożliwiają zorganizowanie bezpiecznego dostępu do danych w środowisku wielowątkowym, zapobiegając stanom wyścigu i uszkodzeniu danych.
Główna zaleta Lock w porównaniu z synchronized to elastyczność. Programista może spróbować przechwycić blokadę z timeoutem, sprawdzić jej zajętość bez blokowania lub zorganizować wiele kolejek oczekiwania z różnymi priorytetami.
Przed pojawieniem się interfejsu Lock w Javie 5 jedynym sposobem synchronizacji był synchronized, który cierpiał na ograniczenia: brak timeoutów, niemożność przerwania oczekiwania i pojedyncza kolejka. Doug Lea zaprojektował pakiet java.util.concurrent, włączając do niego Lock jako fundamentalny blok konstrukcyjny.
Blokada zarządza dostępem poprzez wewnętrzny flagę stanu i kolejkę oczekiwania. Gdy wątek wywołuje lock(), mechanizm sprawdza, czy blokada jest wolna, i albo ją przechwytuje, albo umieszcza wątek w kolejce do czasu zwolnienia.
U podstaw każdej blokady leży atomowa operacja porównania i ustawienia (CAS). Przy wywołaniu lock() wątek próbuje atomowo ustawić flagę zajętości. Jeśli flaga jest już ustawiona, wątek zostaje zablokowany. Przy unlock() flaga jest resetowana i budzony jest jeden z oczekujących wątków.
import java.util.concurrent.locks.ReentrantLock
val lock = ReentrantLock()
fun performTask() {
lock.lock()
try {
// sekcja krytyczna
println("Działa wątek ${Thread.currentThread().name}")
} finally {
lock.unlock()
}
}
ReentrantLock wewnętrznie używa dwukierunkowej kolejki (CLH lock queue), gdzie każdy oczekujący wątek jest reprezentowany przez węzeł. Gdy blokada zostaje zwolniona, budzony jest główny węzeł kolejki. Tryb fair (sprawiedliwy) gwarantuje kolejność FIFO, a unfair pozwala na przechwycenie przez nowy wątek przed oczekującymi w celu zwiększenia przepustowości.
W nowoczesnym stosie Javy istnieje kilka implementacji blokad, z których każda jest zoptymalizowana pod konkretne scenariusze. Wybór odpowiedniej blokady bezpośrednio wpływa na wydajność i niezawodność aplikacji wielowątkowej.
ReentrantLock — podstawowa i najczęściej używana implementacja Lock. Obsługuje ponowne przechwycenie przez ten sam wątek: jeśli wątek już posiada blokadę, ponowne wywołanie lock() go nie blokuje. Zapobiega to deadlockowi przy wywołaniach rekurencyjnych.
ReadWriteLock dzieli blokady na dwa tryby: odczyt i zapis. Wiele wątków może jednocześnie utrzymywać blokadę odczytu, ale zapis wymaga ekskluzywnego dostępu. Znacznie zwiększa to wydajność przy częstym odczycie i rzadkim zapisie.
StampedLock — najnowsza implementacja, która pojawiła się w Javie 8. Obsługuje trzy tryby: zapis, odczyt i optymistyczny odczyt. Optymistyczny odczyt nie blokuje innych wątków i sprawdza ważność danych po odczycie, co daje wzrost wydajności o 10-20% w porównaniu z ReadWriteLock.
| Blokada | Wersja Java | Tryby | Wydajność |
|---|---|---|---|
| ReentrantLock | Java 5 | ekskluzywny | wysoka |
| ReadWriteLock | Java 5 | odczyt + zapis | średnia |
| StampedLock | Java 8 | odczyt + zapis + optimistic | bardzo wysoka |
ReentrantLock — najpopularniejsza implementacja Lock, zapewniająca szereg możliwości niedostępnych w synchronized. Zrozumienie jej cech jest niezbędne do efektywnej pracy z wielowątkowością.
Konstruktor ReentrantLock przyjmuje parametr fair. Przy true blokada gwarantuje kolejność FIFO dostępu, przy false możliwe jest przechwycenie przez nowy wątek przed oczekującymi. Tryb sprawiedliwy zapobiega zagłodzeniu, ale obniża przepustowość o 10-20% z powodu dodatkowych narzutów na utrzymanie kolejki.
W przeciwieństwie do synchronized, ReentrantLock obsługuje tryLock z timeoutem. Jeśli nie udało się przechwycić blokady w określonym czasie, wątek kontynuuje wykonanie, zamiast blokować się w nieskończoność. Metoda lockInterruptibly pozwala przerwać oczekujący wątek poprzez Thread.interrupt().
val lock = ReentrantLock()
fun tryTask() {
if (lock.tryLock(500, TimeUnit.MILLISECONDS)) {
try {
println("Blokada przechwycona")
} finally {
lock.unlock()
}
} else {
println("Nie udało się przechwycić blokady")
}
}
ReentrantLock obsługuje wiele zmiennych warunkowych poprzez metodę newCondition(). Każda Condition ma własną kolejkę oczekiwania, co pozwala organizować złożone scenariusze budzenia. Metody await() i signal() zastąpiły wait() i notify() z bloków synchronized, ale z obsługą wielu kolejek.
ReadWriteLock i StampedLock rozwiązują zadanie optymalizacji dostępu przy przewadze operacji odczytu nad zapisem. Są znacznie wydajniejsze niż ReentrantLock w scenariuszach, gdzie odczyt występuje częściej niż zapis.
Interfejs ReadWriteLock zawiera dwie metody: readLock() i writeLock(). Blokada odczytu może być utrzymywana przez wiele wątków jednocześnie, blokada zapisu — tylko przez jeden. Typowym przykładem jest bezpieczny wątkowo cache: wiele wątków czyta dane, a tylko jeden okresowo je aktualizuje.
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 dodaje trzeci tryb — tryOptimisticRead. Ten tryb nie blokuje innych wątków, a jedynie zapamiętuje znacznik (stamp) stanu. Po odczycie programista wywołuje validate(stamp), sprawdzając, czy dane nie zmieniły się podczas odczytu. Jeśli dane się zmieniły, operację należy powtórzyć.
W aplikacjach mobilnych blokady są używane do koordynacji dostępu do współdzielonych danych między wątkami. Jednak ich stosowanie wymaga szczególnej ostrożności ze względu na ograniczone zasoby urządzenia i konieczność zachowania responsywności interfejsu.
Na Androidzie ReentrantLock jest przydatny przy pracy z Room, cache'ami i plikami. Ważne jest, aby nigdy nie przechwytywać blokady na głównym wątku. Do kodu asynchronicznego preferowane są korutyny i Mutex z kotlinx.coroutines, które nie blokują wątku, a zawieszają korutynę.
W iOS standardowy Lock z NSLock jest rzadziej używany — programiści preferują DispatchQueue z flagami barrier lub blokady operacyjne os_unfair_lock. Swift 5.7+ zapewnia nowoczesne mechanizmy synchronizacji poprzez actors, które automatycznie chronią stan.
import Foundation
actor DataStore {
private var items: [String] = []
func add(_ item: String) {
items.append(item)
}
func getAll() -> [String] {
items
}
}
Aby uniknąć deadlocka, przestrzegaj jednolitego porządku przechwytywania wszystkich blokad w projekcie. Używaj tryLock z timeoutem zamiast lock() wszędzie tam, gdzie możliwe jest długotrwałe blokowanie. Rozważ zastosowanie algorytmów Lock-Free (AtomicReference, ConcurrentHashMap) zamiast tradycyjnych blokad.
Stosowanie Lock wymaga dyscypliny i przestrzegania kilku zasad, które zapobiegają deadlockowi i spadkowi wydajności. Te praktyki zostały wypracowane przez społeczność Java przez 20 lat używania pakietu java.util.concurrent.
Najważniejszy wzorzec — lock w finally. Niezależnie od tego, czy sekcja krytyczna zakończyła się sukcesem czy wyjątkiem, blokada musi zostać zwolniona. Gwarantuje to, że inne wątki nie zostaną zablokowane na zawsze z powodu jednego błędu. W Kotlin ten wzorzec jest elegancko rozwiązany poprzez rozszerzenie withLock.
Sekcja krytyczna powinna być maksymalnie krótka. Nigdy nie wykonuj wewnątrz blokady operacji wejścia-wyjścia, zapytań sieciowych ani długotrwałych obliczeń. Jeśli potrzebujesz odczytać dane z serwera, najpierw je pobierz, a następnie przechwyć blokadę tylko do aktualizacji współdzielonego stanu. Zmniejsza to rywalizację i zwiększa przepustowość systemu.
Aby zapobiec deadlockowi przy pracy z wieloma Lock, ustal globalny porządek przechwytywania w całym projekcie. Jeśli najpierw przechwytywany jest lockA, a następnie lockB — każda odwrotna sekwencja powinna być zabroniona przez zasady code review. Do automatycznego sprawdzania używaj statycznych analizatorów, takich jak SpotBugs i IntelliJ Inspections.
Często zadawane pytania
Lock — jawny interfejs z możliwością timeoutu i przerywalnego oczekiwania. synchronized automatycznie przechwytuje i zwalnia monitor, ale nie pozwala na użycie tryLock, lockInterruptibly i wielu Condition. Lock jest bardziej elastyczny, ale wymaga ręcznego zwalniania w finally.
Sprawiedliwa blokada gwarantuje kolejność FIFO dostępu: wątek, który czeka najdłużej, pierwszy otrzymuje blokadę. Niesprawiedliwa blokada może oddać dostęp nowemu wątkowi z pominięciem kolejki, co zwiększa przepustowość, ale może spowodować zagłodzenie oczekujących wątków.
Przestrzegaj stałego porządku przechwytywania wszystkich blokad, używaj tryLock z timeoutem zamiast bezwarunkowego lock i minimalizuj liczbę jednocześnie utrzymywanych blokad. Stosowanie struktur danych Lock-Free również zmniejsza ryzyko deadlocka.
Condition — odpowiednik wait/notify dla Lock, umożliwiający zorganizowanie wielu niezależnych kolejek oczekiwania. Każde wywołanie newCondition() tworzy oddzielną kolejkę, co daje dokładniejszą kontrolę nad budzeniem wątków w porównaniu z pojedynczą kolejką synchronized.
Dla Androida z korutynami używaj Mutex z kotlinx.coroutines — zawiesza on korutynę, a nie blokuje wątku. Dla iOS z Swift 5.7+ preferowane są actors, które automatycznie synchronizują dostęp do stanu. ReentrantLock pozostaw dla starszego kodu i niskopoziomowych scenariuszy.
Podsumowanie
Opracujemy aplikację mobilną pod klucz
IT Sectr tworzy aplikacje na iOS i Androida dla startupów i firm od 2017 roku. Doradzimy Ci i zaproponujemy najlepsze rozwiązanie.
Przeczytaj również