장난감 연구소
[Java] Java에서의 동기화 기법 : synchronized, Lock 본문
데이터베이스 트랜잭션에 동시성 처리를 맡기면서, 데이터베이스가 없는 상황에서의 동기화 문제를 간과하고 있었습니다. 이를 계기로 운영체제 책과 인터넷 글들을 다시 읽으며, Java에서의 동기화 기법에 대해 내용을 정리해보았습니다.
synchronized (Java Monitor)
Java에서 동기화(Synchronization)는 공유 자원이나 임계 영역에, 한번에 하나의 스레드만 접근할 수 있도록 보장하여, 데이터 손상과 불일치를 예방한다.
Java는 스레드 동기화를 위한 모니터(Monitor) 기법을 제공한다. 모니터는 락과 조건 변수를 함께 관리하여, 스레드 간 상호 배제를 보장하고 안전한 동기화를 지원하는 고수준 동기화 구조이다.
두 가지 방식으로 synchronized
키워드를 사용하여 동기화를 구현할 수 있다. 메서드를 synchronized
메서드로 만들거나, synchronized
블록을 사용하여 동기화가 필요한 특정 코드만 감쌀 수 있다.
// 방법 1
public synchronized void synchronizedMethod() {
// Code that requires synchronization
}
// 방법 2
public void someMethod() {
// Code executed without synchronization
synchronized (sharedObject) { // 동기화에 사용할 객체 인스턴스나 클래스를 명시
// Code that requires synchronization
}
// Code executed without synchronization
}
작동 방식
자바의 모든 객체는 하나의 락과 연결되어 있다. 메서드가 synchronized
로 선언되면, 메서드를 호출할 때 그 객체와 연결된 락을 획득해야 한다.
public class Counter {
private int count = 0;
public synchronized void increment() {
count++;
}
public synchronized void decrement() {
count--;
}
public synchronized int getCount() {
return count;
}
}
위 코드에서 increment()
와 같은 메서드를 호출하려면 Counter 클래스의 객체와 연결된 락을 획득해야 한다. 다른 스레드가 이미 락을 소유한 경우 synchronized
메서드로 호출한 스레드는 봉쇄되어 객체의 락에 설정된 진입 집합(entry set)에 추가된다. 진입 집합은 락이 가용해지기까지 기다리는 스레드 집합으로, 락이 가용해지기를 기다리는 스레드 집합이다.
락이 가용하면 호출 스레드는 락의 소유자가 되어 메서드를 실행하며, 종료하면 락이 해제된다. 락이 해제될 때 진입 집합이 비어 있지 않으면, JVM이 집합에서 락 소유자가 될 스레드를 임의로 선택한다. JVM의 구현 방식에 따라 달라지는데 보통 FIFO 방식으로 스레드를 선택한다고 한다.
출처
Synchronization in Java: A Comprehensive Guide to How It Works | Medium
silberschatz, A., Galvin, P. B., & Gagne, greg. (2020). 운영체제 (10th ed.). 퍼스트북.
Lock (락)
synchronized
기법은 처음부터 제공되었으나 Java API는 Java 1.5 이후부터 훨씬 더 융통성 있고 강력한 Lock 인터페이스를 제공한다.
Lock과 synchronized의 차이점
- synchronized는 한 메서드 안에서만 사용할 수 있다. Lock는 서로 다른 메서드에서 lock(), unlock() 할 수 있다.
- synchronized는 JVM이 락 소유자가 될 스레드를 어떤 규칙으로 선택할지 보장이 없으나, Lock API는 공정성 매개변수 설정 기능을 제공한다.
- synchronized는 접근할 수 없다면 스레드가 대기하나, Lock API는 tryLock() 메서드를 제공한다. Lock API를 사용하면 다른 스레드가 락을 소유하여 접근이 불가능할 때도 계속 대기하지 않고 다른 작업을 할 수 있다.
- synchronized에서는 대기 중인 스레드가 인터럽트될 수 없지만, Lock API에서는 lockInterruptibly() 메서드를 통해 대기 상태에도 인터럽트될 수 있다.
ReentrantLock(재진입 락)
ReentrantLock
클래스는 synchronized
와 비슷하게 공유 자원에 대한 상호 배타적 액세스를 제공하는데 사용된다.
재진입 이라는 용어의 의미는 스레드가 이미 락을 소유하고 있을 때, 그 락이 필요한 코드에 한번 더 접근시 통과할 수 있다는 걸 의미한다. 재진입이 불가능하다면 자기 자신이 락을 소유하고 있어 봉쇄된다.
Lock lock = new ReentrantLock();
lock.lock();
try {
/* critical section */
}
finally {
lock.unlock();
}
스레드는 lock() 메서드를 호출하여 ReentrantLock
락을 획득한다. 락을 사용할 수 있거나 lock()을 호출한 스레드가 이미 락을 소유하고 있는 경우 lock()은 호출 스레드에게 락 소유권을 주고 제어를 반환한다. 락을 사용할 수 없는 경우 호출 스레드는 소유자가 unlock()을 호출하여 락이 배정될 때까지 봉쇄된다.
ReentrantReadWriteLock
ReentrantLock
클래스는 상호 배제를 제공하지만 여러 스레드가 공유 데이터를 읽기만 하고 쓰지 않을 때 비효율적인 전략일 수 있다. 이러한 점을 완화하기 위해 ReentrantReadWriteLock
클래스를 제공한다.
ReentrantReadWriteLock
클래스에서는 Read Lock과 Write Lock이 별도로 존재하여, reader는 여러 스레드일 수 있고, writer는 반드시 하나인 락이다.
코드 예시는 아래와 같다.
public class SynchronizedHashMapWithReadWriteLock {
Map<String,String> syncHashMap = new HashMap<>();
ReadWriteLock lock = new ReentrantReadWriteLock();
// ...
Lock writeLock = lock.writeLock(); // write lock
Lock readLock = lock.readLock(); // read lock
public void put(String key, String value) {
try {
writeLock.lock();
syncHashMap.put(key, value);
} finally {
writeLock.unlock();
}
}
public String remove(String key){
try {
writeLock.lock();
return syncHashMap.remove(key);
} finally {
writeLock.unlock();
}
}
public String get(String key){
try {
readLock.lock();
return syncHashMap.get(key);
} finally {
readLock.unlock();
}
}
public boolean containsKey(String key) {
try {
readLock.lock();
return syncHashMap.containsKey(key);
} finally {
readLock.unlock();
}
}
//...
}
StampedLock
StampedLock
클래스는 Java 8에 추가된 클래스로, read lock과 write lock를 지원한다.
그러나 락 획득시 락 해제와 유효성을 확인하기 위한 스탬프를 반환해주며, 재진입이 불가능하다.
public class StampedLockDemo {
Map<String,String> map = new HashMap<>();
private StampedLock lock = new StampedLock();
public void put(String key, String value){
long stamp = lock.writeLock();
try {
map.put(key, value);
} finally {
lock.unlockWrite(stamp);
}
}
public String get(String key) throws InterruptedException {
long stamp = lock.readLock();
try {
return map.get(key);
} finally {
lock.unlockRead(stamp);
}
}
}
또한, StampedLock
은 낙관적 락(Optimistic Lock) 기능을 제공한다. 읽기 작업시 자원에 락 없이 접근하고, 끝나기 전에 다른 스레드가 바꿨는지 검증하는 방식으로, 충돌이 드문 환경에서 성능이 좋은 방식이라 한다.
public String readWithOptimisticLock(String key) {
long stamp = lock.tryOptimisticRead();
String value = map.get(key); // 락 없이 읽음
if(!lock.validate(stamp)) { // 다른 스레드가 건들였다면
stamp = lock.readLock(); // 락 걸고 다시 읽기
try {
return map.get(key);
} finally {
lock.unlock(stamp);
}
}
return value;
}
Condition (조건 변수)
Condition
클래스는 임계 구역을 실행하는 동안 특정 조건이 충족될 때까지 기다릴 수 있는 기능을 제공한다. 스레드가 락을 획득해 임계 구역에 들어왔지만, 작업을 수행하기 위한 조건이 만족되지 않을 때 사용될 수 있다.
자바는 스레드간 통신을 위해 wait(), notify(), notifyAll() 메서드를 제공한다. Condition은 이러한 방식과 비슷하지만 여러 조건을 나눠서 제어할 수 있다.
Lock lock = new ReentrantLock();
Condition notEmpty = lock.newCondition();
// consumer 스레드
void comsume() {
lock.lock();
try {
while (queue.isEmpty()) {
notEmpty.await(); // 큐가 비어 있으면 기다림
}
// 큐에서 데이터 소비
} finally {
lock.unlock();
}
}
// producer 스레드
void produce() {
lock.lock();
try {
queue.offer(item); // 큐에 데이터 추가
notEmpty.signal(); // 대기 중인 스레드 하나 깨우기
} finally {
lock.unlock();
}
}
사용 사례
Lock API를 사용해야 했던 상황은 @Transactional
없이 동시성 문제를 예방하고 싶을 때였다. synchronized
를 사용한다면 메서드의 매개변수에 상관없이 한번의 하나의 스레드만 method()를 실행할 수 있었다.
그러나 method()에서 하는 작업이 한 user의 데이터에 대해서만 수행되는 개별적인 작업이었기에, 서로 다른 user라면 메서드가 동시에 실행되어도 괜찮다고 판단하였다. 따라서 ConcurrentHashMap
에 각 userId 별로 ReentrantLock
을 만들어 락을 걸 수 있었다.
public class SomeService {
private final ConcurrentHashMap<Long, ReentrantLock> lockMap = new ConcurrentHashMap<Long, ReentrantLock>();
public void method(long userId, SomeDto request) {
ReentrantLock lock = lockMap.computeIfAbsent(userId, id -> new ReentrantLock());
lock.lock();
try {
// user 각각에 대해 lock을 통해
// 여러 스레드로 인한 동시성 문제 없이 한 user에 대한 작업 수행
} finally {
lock.unlock();
}
}
출처
1차 출처 Guide to java.util.concurrent.Locks | Baeldung
2차 출처(번역) java.util.concurrent.Locks - [루닥스 블로그] 연습만이 살길이다
silberschatz, A., Galvin, P. B., & Gagne, greg. (2020). 운영체제 (10th ed.). 퍼스트북.
이미지 출처
Multithreading - Difference between lock and monitor in java