Lock은 멀티스레드 애플리케이션에서 코드의 임계 구역에 대한 독점적 액세스를 제공하는 동기화 메커니즘입니다. Oracle, 2024에 따르면, Lock 인터페이스는 기존의 synchronized 블록에 비해 더 유연한 동기화 제어를 제공하며, 타임아웃이 있는 획득 시도와 여러 대기 큐 지원을 포함합니다.
주요 포인트
Lock은 java.util.concurrent.locks 패키지의 인터페이스로, 데이터 액세스를 동기화하기 위한 명시적인 잠금 및 잠금 해제 작업을 제공합니다. synchronized와 달리, Lock은 개발자에게 잠금 메커니즘에 대한 완전한 제어권을 제공합니다.
Lock 인터페이스는 Java 5에서 내장된 synchronized 메커니즘의 대안으로 도입되었습니다. 주요 메서드는 lock, unlock, tryLock 및 lockInterruptibly입니다. 잠금은 멀티스레드 환경에서 안전한 데이터 액세스를 구성하고, 경쟁 조건과 데이터 손상을 방지합니다.
synchronized에 비해 Lock의 주요 장점은 유연성입니다. 개발자는 타임아웃으로 잠금 획득을 시도하거나, 차단 없이 가용성을 확인하거나, 여러 대기 큐를 서로 다른 우선순위로 구성할 수 있습니다.
Java 5에서 Lock 인터페이스가 등장하기 전에는 유일한 동기화 방법이 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 잠금 큐)를 사용하며, 각 대기 스레드는 노드로 표현됩니다. 잠금이 해제되면 큐의 헤드 노드가 깨어납니다. 공정 모드는 FIFO 순서를 보장하는 반면, 불공정 모드는 처리량 향상을 위해 새 스레드가 대기 스레드보다 먼저 잠금을 획득하도록 허용합니다.
최신 Java 생태계에는 특정 시나리오에 최적화된 여러 잠금 구현이 있습니다. 올바른 잠금을 선택하는 것은 멀티스레드 애플리케이션의 성능과 신뢰성에 직접적인 영향을 미칩니다.
ReentrantLock은 기본적이고 가장 많이 사용되는 Lock 구현입니다. 동일한 스레드에 의한 재획득을 지원합니다. 스레드가 이미 잠금을 보유하고 있는 경우 lock()을 다시 호출해도 차단되지 않습니다. 이는 재귀 호출에서 데드락을 방지합니다.
ReadWriteLock은 잠금을 읽기와 쓰기의 두 가지 모드로 분리합니다. 여러 스레드가 동시에 읽기 잠금을 보유할 수 있지만, 쓰기에는 독점적 액세스가 필요합니다. 이는 빈번한 읽기와 드문 쓰기 상황에서 성능을 크게 향상시킵니다.
StampedLock은 Java 8에서 도입된 최신 구현입니다. 쓰기, 읽기 및 낙관적 읽기의 세 가지 모드를 지원합니다. 낙관적 읽기는 다른 스레드를 차단하지 않고 읽기 후 데이터의 유효성을 검증하여 ReadWriteLock보다 10-20%의 성능 향상을 제공합니다.
| 잠금 | Java 버전 | 모드 | 성능 |
|---|---|---|---|
| ReentrantLock | Java 5 | 독점 | 높음 |
| ReadWriteLock | Java 5 | 읽기 + 쓰기 | 중간 |
| StampedLock | Java 8 | 읽기 + 쓰기 + 낙관적 | 매우 높음 |
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() 메서드는 synchronized 블록의 wait() 및 notify()를 대체했지만, 여러 큐를 지원합니다.
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를 추가합니다. 이 모드는 다른 스레드를 차단하지 않고 상태의 스탬프만 기록합니다. 읽기 후 개발자는 validate(stamp)를 호출하여 읽는 동안 데이터가 변경되었는지 확인합니다. 데이터가 변경된 경우 작업을 다시 시도해야 합니다.
모바일 애플리케이션에서 잠금은 스레드 간 공유 데이터 액세스를 조정하는 데 사용됩니다. 그러나 제한된 장치 리소스와 UI 응답성 유지 필요성으로 인해 사용에 특별한 주의가 필요합니다.
Android에서 ReentrantLock은 Room, 캐시 및 파일 작업 시 유용합니다. 중요한 사항: 메인 스레드에서 절대 잠금을 획득하지 마세요. 비동기 코드의 경우 kotlinx.coroutines의 코루틴과 Mutex가 선호됩니다. 스레드를 차단하는 대신 코루틴을 일시 중단합니다.
iOS에서는 표준 NSLock이 덜 자주 사용됩니다. 개발자는 배리어 플래그가 있는 DispatchQueue 또는 os_unfair_lock을 선호합니다. Swift 5.7+는 액터를 통해 최신 동기화 메커니즘을 제공하여 상태를 자동으로 보호합니다.
import Foundation
actor DataStore {
private var items: [String] = []
func add(_ item: String) {
items.append(item)
}
func getAll() -> [String] {
items
}
}
데드락을 방지하려면 프로젝트 전체에서 일관된 잠금 순서를 따르세요. 장기 차단이 가능한 경우 lock() 대신 타임아웃이 있는 tryLock을 사용하세요. 전통적인 잠금 대신 Lock-Free 알고리즘(AtomicReference, ConcurrentHashMap) 사용을 고려하세요.
Lock을 사용하려면 데드락과 성능 저하를 방지하기 위한 규율과 규칙 준수가 필요합니다. 이러한 관행은 java.util.concurrent 패키지를 20년간 사용하면서 Java 커뮤니티에 의해 개발되었습니다.
가장 중요한 패턴은 finally에서 lock 해제입니다. 임계 구역이 성공적으로 완료되든 예외를 던지든 관계없이 잠금은 해제되어야 합니다. 이는 단일 오류로 인해 다른 스레드가 영원히 차단되지 않도록 보장합니다. Kotlin에서는 이 패턴이 withLock 확장 함수를 통해 우아하게 해결됩니다.
임계 구역은 가능한 한 짧아야 합니다. 잠금 내에서 I/O, 네트워크 요청 또는 긴 계산을 절대 수행하지 마세요. 서버에서 데이터를 읽어야 하는 경우 먼저 데이터를 가져온 다음 공유 상태를 업데이트하기 위해서만 잠금을 획득하세요. 이는 경합을 줄이고 시스템 처리량을 향상시킵니다.
여러 잠금으로 작업할 때 데드락을 방지하려면 프로젝트 전체에 전역 잠금 순서를 설정하세요. 먼저 lockA를 획득한 다음 lockB를 획득하는 경우, 역순은 코드 검토 규칙에 의해 금지되어야 합니다. 자동 검증을 위해 SpotBugs 및 IntelliJ Inspections과 같은 정적 분석기를 사용하세요.
자주 묻는 질문
Lock은 타임아웃과 인터럽트 가능 대기를 지원하는 명시적 인터페이스입니다. synchronized는 자동으로 모니터를 획득 및 해제하지만 tryLock, lockInterruptibly 또는 여러 Conditions을 사용할 수 없습니다. Lock은 더 유연하지만 finally에서 수동 해제가 필요합니다.
공정 잠금은 FIFO 순서를 보장합니다. 가장 오래 기다린 스레드가 먼저 잠금을 받습니다. 불공정 잠금은 대기 스레드보다 먼저 새 스레드에 액세스를 허용할 수 있으며, 이는 처리량을 증가시키지만 대기 스레드의 기아를 유발할 수 있습니다.
모든 잠금 획득에 대해 고정된 순서를 따르고, 무조건적인 lock() 대신 타임아웃이 있는 tryLock을 사용하며, 동시에 보유하는 잠금 수를 최소화하세요. Lock-Free 데이터 구조를 사용하는 것도 데드락 위험을 줄입니다.
Condition은 Lock에 대한 wait/notify의 유사체로, 여러 독립적인 대기 큐를 가능하게 합니다. 각 newCondition() 호출은 별도의 큐를 생성하여 synchronized의 단일 큐에 비해 스레드 깨우기에 대한 더 정밀한 제어를 제공합니다.
코루틴을 사용하는 Android의 경우 kotlinx.coroutines의 Mutex를 사용하세요. 스레드를 차단하는 대신 코루틴을 일시 중단합니다. Swift 5.7+를 사용하는 iOS의 경우 상태 액세스를 자동으로 동기화하는 액터가 선호됩니다. ReentrantLock은 레거시 코드와 저수준 시나리오에 남겨두세요.
요약
턴키 방식의 모바일 애플리케이션을 개발해 드립니다
IT Sectr는 2017년부터 스타트업과 기업을 위한 iOS 및 Android 애플리케이션을 만듭니다. 저희가 상담해 드리고 최적의 솔루션을 제안하겠습니다.