자바는 1.0부터 존재한 synchronized와 "BLOCKED" 상태를 통한 통한 임계 영역 관리의 한계를 극복하기 위해 자바 1.5부터 Lock 인터페이스와 ReentrantLock 구현체를 제공한다.
synchronized 단점
- 무한 대기: "BLOCKED" 상태의 스레드는 락이 풀릴 때까지 무한 대기한다.
- 특정 시간까지만 대기하는 타임아웃 X
- 중간에 인터럽트 X
- 공정성: 락이 돌아왔을 때 "BLOCKED" 상태의 여러 스레드 중에 어떤 스레드가 락을 획득할지 알 수 없다. 최악의 경우 특정 스레드가 너무 오랜 기간 락을 획득하지 못할 수 있다.
public interface Lock {
/**
* Acquires the lock.
*
* <p>If the lock is not available then the current thread becomes
* disabled for thread scheduling purposes and lies dormant until the
* lock has been acquired.
*
* <p><b>Implementation Considerations</b>
*
* <p>A {@code Lock} implementation may be able to detect erroneous use
* of the lock, such as an invocation that would cause deadlock, and
* may throw an (unchecked) exception in such circumstances. The
* circumstances and the exception type must be documented by that
* {@code Lock} implementation.
*/
void lock();
/**
* Acquires the lock unless the current thread is
* {@linkplain Thread#interrupt interrupted}.
*
* <p>Acquires the lock if it is available and returns immediately.
*
* <p>If the lock is not available then the current thread becomes
* disabled for thread scheduling purposes and lies dormant until
* one of two things happens:
*
* <ul>
* <li>The lock is acquired by the current thread; or
* <li>Some other thread {@linkplain Thread#interrupt interrupts} the
* current thread, and interruption of lock acquisition is supported.
* </ul>
*
* <p>If the current thread:
* <ul>
* <li>has its interrupted status set on entry to this method; or
* <li>is {@linkplain Thread#interrupt interrupted} while acquiring the
* lock, and interruption of lock acquisition is supported,
* </ul>
* then {@link InterruptedException} is thrown and the current thread's
* interrupted status is cleared.
*
* <p><b>Implementation Considerations</b>
*
* <p>The ability to interrupt a lock acquisition in some
* implementations may not be possible, and if possible may be an
* expensive operation. The programmer should be aware that this
* may be the case. An implementation should document when this is
* the case.
*
* <p>An implementation can favor responding to an interrupt over
* normal method return.
*
* <p>A {@code Lock} implementation may be able to detect
* erroneous use of the lock, such as an invocation that would
* cause deadlock, and may throw an (unchecked) exception in such
* circumstances. The circumstances and the exception type must
* be documented by that {@code Lock} implementation.
*
* @throws InterruptedException if the current thread is
* interrupted while acquiring the lock (and interruption
* of lock acquisition is supported)
*/
void lockInterruptibly() throws InterruptedException;
/**
* Acquires the lock only if it is free at the time of invocation.
*
* <p>Acquires the lock if it is available and returns immediately
* with the value {@code true}.
* If the lock is not available then this method will return
* immediately with the value {@code false}.
*
* <p>A typical usage idiom for this method would be:
* <pre> {@code
* Lock lock = ...;
* if (lock.tryLock()) {
* try {
* // manipulate protected state
* } finally {
* lock.unlock();
* }
* } else {
* // perform alternative actions
* }}</pre>
*
* This usage ensures that the lock is unlocked if it was acquired, and
* doesn't try to unlock if the lock was not acquired.
*
* @return {@code true} if the lock was acquired and
* {@code false} otherwise
*/
boolean tryLock();
/**
* Acquires the lock if it is free within the given waiting time and the
* current thread has not been {@linkplain Thread#interrupt interrupted}.
*
* <p>If the lock is available this method returns immediately
* with the value {@code true}.
* If the lock is not available then
* the current thread becomes disabled for thread scheduling
* purposes and lies dormant until one of three things happens:
* <ul>
* <li>The lock is acquired by the current thread; or
* <li>Some other thread {@linkplain Thread#interrupt interrupts} the
* current thread, and interruption of lock acquisition is supported; or
* <li>The specified waiting time elapses
* </ul>
*
* <p>If the lock is acquired then the value {@code true} is returned.
*
* <p>If the current thread:
* <ul>
* <li>has its interrupted status set on entry to this method; or
* <li>is {@linkplain Thread#interrupt interrupted} while acquiring
* the lock, and interruption of lock acquisition is supported,
* </ul>
* then {@link InterruptedException} is thrown and the current thread's
* interrupted status is cleared.
*
* <p>If the specified waiting time elapses then the value {@code false}
* is returned.
* If the time is
* less than or equal to zero, the method will not wait at all.
*
* <p><b>Implementation Considerations</b>
*
* <p>The ability to interrupt a lock acquisition in some implementations
* may not be possible, and if possible may
* be an expensive operation.
* The programmer should be aware that this may be the case. An
* implementation should document when this is the case.
*
* <p>An implementation can favor responding to an interrupt over normal
* method return, or reporting a timeout.
*
* <p>A {@code Lock} implementation may be able to detect
* erroneous use of the lock, such as an invocation that would cause
* deadlock, and may throw an (unchecked) exception in such circumstances.
* The circumstances and the exception type must be documented by that
* {@code Lock} implementation.
*
* @param time the maximum time to wait for the lock
* @param unit the time unit of the {@code time} argument
* @return {@code true} if the lock was acquired and {@code false}
* if the waiting time elapsed before the lock was acquired
*
* @throws InterruptedException if the current thread is interrupted
* while acquiring the lock (and interruption of lock
* acquisition is supported)
*/
boolean tryLock(long time, TimeUnit unit) throws InterruptedException;
/**
* Releases the lock.
*
* <p><b>Implementation Considerations</b>
*
* <p>A {@code Lock} implementation will usually impose
* restrictions on which thread can release a lock (typically only the
* holder of the lock can release it) and may throw
* an (unchecked) exception if the restriction is violated.
* Any restrictions and the exception
* type must be documented by that {@code Lock} implementation.
*/
void unlock();
/**
* Returns a new {@link Condition} instance that is bound to this
* {@code Lock} instance.
*
* <p>Before waiting on the condition the lock must be held by the
* current thread.
* A call to {@link Condition#await()} will atomically release the lock
* before waiting and re-acquire the lock before the wait returns.
*
* <p><b>Implementation Considerations</b>
*
* <p>The exact operation of the {@link Condition} instance depends on
* the {@code Lock} implementation and must be documented by that
* implementation.
*
* @return A new {@link Condition} instance for this {@code Lock} instance
* @throws UnsupportedOperationException if this {@code Lock}
* implementation does not support conditions
*/
Condition newCondition();
}
- Lock 인터페이스는 동시성 프로그래밍에서 쓰이는 안전한 임계 영역을 위한 락을 구현하는 데 사용된다.
- Lock 인터페이스는 다음과 같은 메서드를 제공한다. 대표적인 구현체로 ReentrantLock 이 있다.
- void lock()
- 락을 획득한다. 만약 다른 스레드가 이미 락을 획득했다면, 락이 풀릴 때까지 현재 스레드는 대기("WAITING")한다. 이 메서드는 인터럽트에 응답하지 않는다.
- 예) 맛집에 한번 줄을 서면 끝까지 기다린다. 친구가 다른 맛집을 찾았다고 중간에 연락해도 포기하지 않고 기다린다.
- void lockInterruptibly()
- 락 획득을 시도하되, 다른 스레드가 인터럽트 할 수 있도록 한다. 만약 다른 스레드가 이미 락을 획득했다면, 현재 스레드는 락을 획득할 때까지 대기한다. 대기 중에 인터럽트가 발생하면 InterruptedException 이 발생하며 락 획득을 포기한다.
- 예) 맛집에 한번 줄을 서서 기다린다. 다만 친구가 다른 맛집을 찾았다고 중간에 연락하면 포기한다.
- boolean tryLock()
- 락 획득을 시도하고, 즉시 성공 여부를 반환한다. 만약 다른 스레드가 이미 락을 획득했다면 false를 반환하고, 그렇지 않으면 락을 획득하고 true를 반환한다.
- 예) 맛집에 대기 줄이 없으면 바로 들어가고, 대기 줄이 있으면 즉시 포기한다.
- boolean tryLock(long time, TimeUnit unit)
- 주어진 시간 동안 락 획득을 시도한다. 주어진 시간 안에 락을 획득하면 true를 반환한다. 주어진 시간이 지나도 락을 획득하지 못한 경우 false를 반환한다. 이 메서드는 대기 중 인터럽트가 발생하면 InterruptedException 이 발생하며 락 획득을 포기한다.
- 예) 맛집에 줄을 서지만 특정 시간만큼만 기다린다. 특정 시간이 지나도 계속 줄을 서야 한다면 포기한다. 친구가 다른 맛집을 찾았다고 중간에 연락해도 포기한다.
- 주어진 시간 동안 락 획득을 시도한다. 주어진 시간 안에 락을 획득하면 true를 반환한다. 주어진 시간이 지나도 락을 획득하지 못한 경우 false를 반환한다. 이 메서드는 대기 중 인터럽트가 발생하면 InterruptedException 이 발생하며 락 획득을 포기한다.
- void unlock()
- 락을 해제한다. 락을 해제하면 락 획득을 대기 중인 스레드 중 하나가 락을 획득할 수 있게 된다.
- 락을 획득한 스레드가 호출해야 하며, 그렇지 않으면 IllegalMonitorStateException 이 발생할 수 있다.
- 예) 식당 안에 있는 손님이 밥을 먹고 나간다. 식당에 자리가 하나 난다. 기다리는 손님께 이런 사실을 알려주어야 한다. 기다리던 손님 중 한 명이 식당에 들어간다.
- Condition newCondition()
- Condition 객체를 생성하여 반환한다. Condition 객체는 락과 결합되어 사용되며, 스레드가 특정 조건을
기다리거나 신호를 받을 수 있도록 한다. 이는 Object 클래스의 wait , notify , notifyAll 메서드와 유사한 역할을 한다.
- Condition 객체를 생성하여 반환한다. Condition 객체는 락과 결합되어 사용되며, 스레드가 특정 조건을
주의!
여기서 사용하는 락은 객체 내부에 있는 모니터 락이 아니다! Lock 인터페이스와 ReentrantLock 이 제공하는 기 능이다!
모니터 락과 "BLOCKED" 상태는 synchronized에서만 사용된다.
이 메서드들을 사용하면 고수준의 동기화 기법을 구현할 수 있다. Lock 인터페이스는 synchronized 블록보다 더 많은 유연성을 제공하며, 특히 락을 특정 시간만큼만 시도하거나, 인터럽트 가능한 락을 사용할 때 유용하다.
이 메서드들을 보면 알겠지만 다양한 메서드를 통해 synchronized의 단점인 무한 대기 문제도 깔끔하게 해결할 수 있다.
참고: lock() 메서드는 인터럽트에 응하지 않는다고 되어있다. 이 메서드의 의도는 인터럽트가 발생해도 무시하고 락을 기다리는 것이다.
앞서 대기("WAITING") 상태의 스레드에 인터럽트가 발생하면 대기 상태를 빠져나온다고 배웠다. 그런데 lock() 메서드의 설명을 보면 대기("WAITING") 상태인데 인터럽트에 응하지 않는다고 되어있다. 어떻게 된 것일까?
lock()을호출해서 락을 얻기 위해 대기 중인 스레드에 인터럽트가 발생하면 순간 대기 상태를 빠져나오는 것은 맞다. 그래서 아주 짧지만 "WAITING" -> "RUNNABLE"이 된다. 그런데 lock() 메서드 안에서 해당 스레드를 다시 "WAITING" 상태로 강제로 변경해 버린다. 이런 원리로 인터럽트를 무시하는 것이다. 참고로 인터럽트가 필요하면 lockInterruptibly()를 사용하면 된다. 새로운 Lock 은 개발자에게 다양한 선택권을 제공한다.
공정성
"Lock" 인터페이스가 제공하는 다양한 기능 덕분에 synchronized의 단점인 무한 대기 문제가 해결되었다. 그런데 공정성에 대한 문제가 남아있다.
synchronized 단점
공정성: 락이 돌아왔을 때 "BLOCKED" 상태의 여러 스레드 중에 어떤 스레드가 락을 획득할지 알 수 없다. 최악의 경우 특정 스레드가 너무 오랜 기간 락을 획득하지 못할 수 있다.
Lock 인터페이스의 대표적인 구현체로 ReentrantLock이 있는데, 이 클래스는 스레드가 공정하게 락을 얻을 수 있는 모드를 제공한다.
import java.util.concurrent.locks.ReentrantLock;
public class ReentrantLockEx { // 비공정 모드 락
private final Lock nonFairLock = new ReentrantLock(); // 공정 모드 락
private final Lock fairLock = new ReentrantLock(true);
public void nonFairLockTest() {
nonFairLock.lock();
try {
// 임계 영역 } finally {
nonFairLock.unlock();
}
}
public void fairLockTest() {
fairLock.lock();
try {
// 임계 영역
} finally {
fairLock.unlock();
}
}
}
- ReentrantLock 락은 공정성(fairness) 모드와 비공정(non-fair) 모드로 설정할 수 있으며, 이 두 모드는 락을 획득하는 방식에서 차이가 있다.
비공정 모드 (Non-fair mode)
비공정 모드는 ReentrantLock의 기본 모드이다. 이 모드에서는 락을 먼저 요청한 스레드가 락을 먼저 획득한다는 보장이 없다. 락을 풀었을 때, 대기 중인 스레드 중 아무나 락을 획득할 수 있다. 이는 락을 빨리 획득할 수 있지만, 특정 스레드가 장기간 락을 획득하지 못할 가능성도 있다.
비공정 모드 특징
- 성능 우선: 락을 획득하는 속도가 빠르다.
- 선점 가능: 새로운 스레드가 기존 대기 스레드보다 먼저 락을 획득할 수 있다.
- 기아 현상 가능성: 특정 스레드가 계속해서 락을 획득하지 못할 수 있다.
공정 모드 (Fair mode)
생성자에서 `true` 를 전달하면 된다. 예) new ReentrantLock(true)
공정 모드는 락을 요청한 순서대로 스레드가 락을 획득할 수 있게 한다. 이는 먼저 대기한 스레드가 먼저 락을 획득하게 되어 스레드 간의 공정성을 보장한다. 그러나 이로 인해 성능이 저하될 수 있다.
공정 모드 특징
- 공정성 보장: 대기 큐에서 먼저 대기한 스레드가 락을 먼저 획득한다.
- 기아 현상 방지: 모든 스레드가 언젠가 락을 획득할 수 있게 보장된다.
- 성능 저하: 락을 획득하는 속도가 느려질 수 있다.
비공정, 공정 모드 정리
- 비공정 모드는 성능을 중시하고, 스레드가 락을 빨리 획득할 수 있지만, 특정 스레드가 계속해서 락을 획득하지 못할 수 있다.
- 공정 모드는 스레드가 락을 획득하는 순서를 보장하여 공정성을 중시하지만, 성능이 저하될 수 있다.
정리**
Lock 인터페이스와 ReentrantLock 구현체를 사용하면 synchronized 단점인 무한 대기와 공정성 문제를 모두 해결할 수 있다.
ReentrantLock 활용



- private final Lock lock = new ReentrantLock()을 사용하도록 선언한다.
- synchronized(this) 대신에 lock.lock()을 사용해서 락을 건다.
- lock() -> unlock()까지는 안전한 임계 영역이 된다.
- 임계 영역이 끝나면 반드시! 락을 반납해야 한다. 그렇지 않으면 대기하는 스레드가 락을 얻지 못한다.
- 따라서 lock.unlock()은 반드시 finally 블럭에 작성해야 한다. 이렇게 하면 검증에 실패해서 중간에 return을 호출해도 또는 중간에 예상치 못한 예외가 발생해도 lock.unlock()이 반드시 호출된다.
주의!
여기서 사용하는 락은 객체 내부에 있는 모니터 락이 아니다! Lock 인터페이스와 ReentrantLock이 제공하는 기능이다! 모니터 락과 "BLOCKED" 상태는 synchronized에서만 사용된다.

- t1 , t2 가 출금을 시작한다. 여기서는 t1 이 약간 먼저 실행된다고 가정하겠다.
- ReenterantLock 내부에는 락과 락을 얻지 못해 대기하는 스레드를 관리하는 대기 큐가 존재한다.
- 여기서 이야기하는 락은 객체 내부에 있는 모니터 락이 아니다. ReentrantLock이 제공하는 기능이다

- t1 : ReenterantLock에 있는 락을 획득한다.
- 락을 획득하는 경우 "RUNNABLE" 상태가 유지되고, 임계 영역의 코드를 실행할 수 있다.

- t1 : 임계 영역의 코드를 실행한다.

- t2 : ReenterantLock에 있는 락의 획득을 시도한다. 하지만 락이 없다.

- t2 : 락을 획득하지 못하면 WAITING 상태가 되고, 대기 큐에서 관리된다.
- LockSupoort.park()가 내부에서 호출된다.
- 참고로 tryLock(long time, TimeUnit unit)와 같은 시간 대기 기능을 사용하면 "TIMED_WAITING"이 되고, 대기 큐에서 관리된다.

- t1 : 임계 영역의 수행을 완료했다. 이때 잔액은 balance=200 이 된다.

- t1 : 임계 영역을 수행하고 나면 lock.unlock()을 호출한다.
- t1: 락을 반납한다.
- t1: 대기 큐의 스레드를 하나 깨운다. LockSupoort.unpark(thread)가 내부에서 호출된다.
- t2: "RUNNABLE" 상태가 되면서 깨어난 스레드는 락 획득을 시도한다.
- 이때 락을 획득하면 lock.lock()을 빠져나오면서 대기 큐에서도 제거된다.
- 이때 락을 획득하지 못하면 다시 대기 상태가 되면서 대기 큐에 유지된다.
- 참고로 락 획득을 시도하는 잠깐 사이에 새로운 스레드가 락을 먼저 가져갈 수 있다.
- 공정 모드의 경우 대기 큐에 먼저 대기한 스레드가 먼저 락을 가져간다.

- t2 : 락을 획득한 t2 스레드는 "RUNNABLE" 상태로 임계 영역을 수행한다.

- t2 : 잔액[200]이 출금액[800]보다 적으므로 검증 로직을 통과하지 못한다. 따라서 검증 실패이다. return false 가 호출된다.
- 이때 finally 구문이 있으므로 finally 구문으로 이동한다.

- t2 : lock.unlock()을 호출해서 락을 반납하고, 대기 큐의 스레드를 하나 깨우려고 시도한다. 대기 큐에 스레드가 없으므로 이때는 깨우지 않는다.

- 완료 상태
참고: volatile를 사용하지 않아도 Lock을 사용할 때 접근하는 변수의 메모리 가시성 문제는 해결된다.
ReentrantLock - 대기 중단
ReentrantLock을 사용하면 락을 무한 대기하지 않고, 중간에 빠져나오는 것이 가능하다. 심지어 락을 얻을 수 없다
면 기다리지 않고 즉시 빠져나오는 것도 가능하다. 다음 기능들을 어떻게 활용하는지 알아보자.
boolean tryLock()
- 락 획득을 시도하고, 즉시 성공 여부를 반환한다. 만약 다른 스레드가 이미 락을 획득했다면 false를 반환하고, 그렇지 않으면 락을 획득하고 true를 반환한다.
- 예) 맛집에 대기 줄이 없으면 바로 들어가고, 대기 줄이 있으면 즉시 포기한다.
boolean tryLock(long time, TimeUnit unit)
- 주어진 시간 동안 락 획득을 시도한다. 주어진 시간 안에 락을 획득하면 true를 반환한다. 주어진 시간이 지나도 락을 획득하지 못한 경우 false를 반환한다. 이 메서드는 대기 중 인터럽트가 발생하면 InterruptedException 이 발생하며 락 획득을 포기한다.
- 예) 맛집에 줄을 서지만 특정 시간만큼만 기다린다. 특정 시간이 지나도 계속 줄을 서야 한다면 포기한다. 친구가 다른 맛집을 찾았다고 중간에 연락해도 포기한다.
tryLock()



- t1 : 먼저 락을 획득하고 임계 영역을 수행한다.
- t2 : 락이 없다는 것을 확인하고 lock.tryLock()에서 즉시 빠져나온다. 이때 false가 반환된다.
- t2 : "[진입 실패] 이미 처리중인 작업이 있습니다."를 출력하고 false를 반환하면서 메서드를 종료한다.
- t1 : 임계 영역의 수행을 완료하고 거래를 종료한다. 마지막으로 락을 반납한다.
tryLock(시간)



- lock.tryLock(500, TimeUnit.MILLISECONDS) : 락이 없을 때 락을 대기할 시간을 지정한다. 해당 시간이 지나도 락을 얻지 못하면 false를 반환하면서 해당 메서드를 빠져나온다. 여기서는 0.5초를 설정했다.
- 스레드의 상태는 대기하는 동안 "TIMED_WAITING" 이 되고, 대기 상태를 빠져나오면 "RUNNABLE"이 된다.
- t1 : 먼저 락을 획득하고 임계 영역을 수행한다.
- t2 : lock.tryLock(0.5초)을 호출하고 락 획득을 시도한다. 락이 없으므로 0.5초간 대기한다.
- 이때 t2는 "TIMED_WAITING" 상태가 된다.
- 내부에서는 LockSupport.parkNanos(시간) 이 호출된다.
- t2 : 대기 시간인 0.5초간 락을 획득하지 못했다. lock.tryLock(시간)에서 즉시 빠져나온다. 이때 false가 반환된다.
- 스레드는 "TIMED_WAITING" -> "RUNNABLE"이 된다.
- t2 : "[진입 실패] 이미 처리중인 작업이 있습니다."를 출력하고 false를 반환하면서 메서드를 종료한다.
- t1 : 임계 영역의 수행을 완료하고 거래를 종료한다. 마지막으로 락을 반납한다.
정리
자바 1.5에서 등장한 Lock 인터페이스와 ReentrantLock 덕분에 synchronized의 단점인 무한 대기와 공정성 문제를 극복하고, 또 더욱 유연하고 세밀한 스레드 제어가 가능하게 되었다.
'멀티스레드와 동시성' 카테고리의 다른 글
| Ch08. 생산자 소비자 문제 - 예제1(생산자, 소비자 우선) (0) | 2024.08.20 |
|---|---|
| Ch08. 생산자 소비자 문제 - 소개 (0) | 2024.08.20 |
| Ch07. 고급 동기화(concurrent.Lock) - LockSupport (0) | 2024.08.15 |
| Ch06. 동기화 - Synchronized 정리 (0) | 2024.08.12 |
| Ch06. 동기화 - 문제와 풀이 (0) | 2024.08.12 |