동시성 문제
같은 자원에 여러 쓰레드가 동시에 접근할 때 발생하는 문제
여러 쓰레드가 접근하는 자원을 공유(Shared) 자원이라고 한다. 대표적 공유 자원은 인스턴스 필드이다
쓰레드의 공유 자원에 대한 접근을 동기화하여 동시성 문제를 해결할 수 있다
Critical section
임계 영역
여러 쓰레드가 동시에 접근하면 데이터 불일치나 예상치 못한 동작이 발생할 수 있는 위험하고 중요한 코드 부분
여러 쓰레드가 공유 자원을 접근하거나 수정하는 부분
동기화(Syncrhonization)
여러 쓰레드가 공유 자원에 대해 일관성 있고 안전한 접근을 보장하기 위한 메커니즘
아래 문제를 해결할 수 있다
- 경합 조건(Race condition)
- 여러 쓰레드가 동시에 읽고 쓰는 데이터의 일관성
멀티 쓰레드 환경에서 필수적이지만, 과도하게 사용할 경우 성능 저하를 초래할 수 있다
메서드 동기화(synchronized 메서드)
메서드 시그니처에 synchronized 키워드를 붙이면, 해당 임계 영역이 안전해진다
모든 객체 인스턴스는 내부에 자신만의 (Monitor) Lock을 가지고 있다
쓰레드가 synchronized 메서드에 진입하려면 반드시 해당 인스턴스의 락을 가져야 한다
처리 순서
- 쓰레드 A가 객체 인스턴스에 접근하기 위해 락을 얻는다
- 쓰레드 B가 객체 인스턴스에 접근하기 위해 락을 얻으려고 하지만 락이 현재 없다. 락을 얻을 때까지 BLOCKED 상태로 대기한다
- 쓰레드 A의 작업이 진행된다
- 쓰레드 A의 작업이 완료되면 락을 반납한다
- 이제 쓰레드 B가 락을 얻는다. BLOCKED 상태가 RUNNABLE 상태로 변경된다
- 쓰레드 B의 작업이 진행된다
- 쓰레드 B의 작업이 완료되면 락을 반납한다
volatile을 사용하지 않아도 메모리 가시성 문제가 해결된다
코드 블락 동기화(synchornized 블락)
synchornized 메서드를 사용하면 메서드 단위로 블락이 되기 때문에 성능이 떨어질 수도 있다
동시에 접근할 수 없는 영역을 일부로 한정하기 위해 이 방법을 사용하는 것이 좋다
synchronized(this) {
// Do something
}
synchronized의 한계
자바 1.0에서 제공된 synchronized는 사용하기 편리하지만 제공하는 기능이 너무 단순하다
단점
- 무한 대기
- BLOCKED 상태의 쓰레드는 락이 풀릴 때까지 무한 대기한다
- 특정 시간까지만 대기하는 타임아웃 없음
- 중간에 인터럽트 불가
- BLOCKED 상태의 쓰레드는 락이 풀릴 때까지 무한 대기한다
- 공정성
- 락이 반납되었을 때 BLOCKED 상태의 여러 쓰레드 중 어떤 쓰레드가 락을 얻을지 알 수 없다
이런 문제를 해결하기 위해 자바 1.5에서 java.util.concurrent라는 동시성 문제를 해결하기 위한 패키지가 제공되었다
(synchronized를 사용하지 말라는 것이 아니다)
https://github.com/venzersiz/learn-java8/tree/master/src/test/java/concurrency/synchorize
LockSupport
synchronized의 무한 대기 문제를 해결할 수 있다
LockSupport는 쓰레드를 WAITING 상태로 변경한다
메서드
- LockSupport.park()
- 쓰레드를 WAITING 상태로 변경한다
- LockSupport.parkNanos(nanos)
- 쓰레드를 나노초 동안 TIMED_WAITING 상태로 변경한다. 시간이 경과하면 RUNNABLE 상태가 된다
- LockSupport.unpark(thread)
- WAITING 상태의 대상 쓰레드를 RUNNABLE 상태로 바꾼다
LockSupport는 저수준의 API다. 대신 Lock 인터페이스와 ReentrantLock이라는 구현체를 사용하도록 하자. ReentrantLock은 LockSupport를 활용해 synchronized의 단점을 극복하면서 매우 편리하게 임계 영역을 다룰 수 있는 기능을 제공한다
Lock
java.util.concurrent.locks.Lock
동시성 프로그래밍에서 쓰이는 안전한 임계영역을 위한 락을 구현하는 데 사용되는 인터페이스
객체 인스턴스 내부의 모니터 락과는 다른 개념이다. 모니터 락과 BLOCKED 상태는 synchronized와 관련이 있다
메서드
- void lock()
- 락을 얻는다
- 다른 쓰레드가 락을 선점했다면 락이 풀릴 때까지 WAITING 상태가 된다
- interrupt() 메서드 호출에 반응하지 않는다
- 실제로는 아주 짧지만 WAITING 상태가 RUNNABLE로 변한다. 하지만 메서드 내에서 해당 쓰레드를 다시 WAITING 상태로 강제 변경한다. 필요하면 그냥 lockInterruptibly() 메서드를 사용하자
- void lockInterruptibly() throws InterruptedException
- 위와 비슷하나 WAITING 상태에서 interrupt() 메서드가 호출되면 InterruptedException이 발생하며 락 얻기를 포기한다
- boolean tryLock()
- 락 얻기를 시도하고, 즉시 성공 여부를 반환한다
- 다른 쓰레드가 락을 가지고 있다면 false, 그렇지 않으면 true 반환
- boolean tryLock(long time, TimeUnit unit) throws InterruptedException
- 위와 비슷하나 주어진 시간동안만 시도한다
- void unlock()
- 락을 해제한다
- 락을 가지지 않은 쓰레드가 호출하면 IllegalMonitorStateException이 발생한다
- Condition newCondition()
- Condition 인스턴스를 생성하여 반환한다
- Condition은 락과 결합하여 사용되며, 쓰레드가 특정 조건을 기다리거나 신호를 받을 수 있게 한다
- Object 클래스의 wait, notify, notifyAll 메서드와 유사한 역할
ReentrantLock
snychronized와 BLOCKED 상태를 통한 임계 영역 관리의 한계를 극복하기 위한 것
쓰레드가 공정하게 락을 얻을 수 있는 방식을 제공한다
unlock() 메서드는 반드시 finally에서 호출해야 한다
Object.notify() vs Condition.signal()
- notfy()
- 대기 중인 쓰레드 중 임의의 하나를 깨운다
- 보통은 먼저 들어온 쓰레드가 깨어나지만 100% 보장할 순 없다
- synchronized 블락 내에서 모니터 락을 가지고 있는 쓰레드가 호출해야 한다
- 대기 중인 쓰레드 중 임의의 하나를 깨운다
- signal()
- 대기 중인 쓰레드를 FIFO 순서로 깨운다
- 보통 Condition의 구현은 Queue 자료구조를 사용하기 때문에 FIFO
- ReentrantLock을 가지고 있는 쓰레드가 호출해야 한다
- 대기 중인 쓰레드를 FIFO 순서로 깨운다
synchronized로 인한 대기
- 락을 얻기 위한 대기
- BLOCKED 상태
- synchronized에 진입할 때 락이 없으면 '락 대기 집합'에서 대기
- 다른 쓰레드가 synchronized를 빠져나갈 때 대기가 풀리며 락을 얻으려고 한다
- wait() 메서드 호출로 인한 대기
- WAITING 상태
- wait() 메서드를 호출했을 때 '쓰레드 대기 집합'에서 대기
- 다른 쓰레드가 notify(), notifyAll() 메서드를 호출했을 때 대기 해제
정리
자바의 객체 인스턴스는 멀티 쓰레드와 임계 영역을 다루기 위해 내부에 3가지 요소를 가진다
- 모니터 락
- 모니터 락 대기 집합
- 쓰레드 대기 집합
순서
- synchronized를 사용한 임계 영역에 들어가려면 모니터 락 필요
- 모니터 락이 없으면 모니터 락 대기 집합에서 BLOCKED 상태로 락을 기다린다
- 모니터 락이 반납되면 락 대기 집합에 있는 쓰레드 중 하나가 락을 얻어 BLOCKED -> RUNNABLE 상태로 전환
- wait() 메서드가 호출해 쓰레드 대기 집합에 들어가려면 모니터 락 필요
- 쓰레드 대기 집합에 들어가면 모니터 락 반납
- 쓰레드가 notify() 메서드를 호출하면 쓰레드 대기 집합에 있는 쓰레드 중 하나가 쓰레드 대기 집합을 빠져나온다. 그리고 모니터 락을 얻으려고 한다
- 모니터 락을 얻으면 임계 영역 수행
- 모니터 락 얻지 못하면 락 대기 집합에서 BLOCKED 상태로 락을 기다린다
ReentrantLock으로로 인한 대기
- ReentrantLock을 얻기 위한 대기
- ReentrantLock의 대기 큐에서 관리
- WAITING 상태
- lock() 메서드 호출했을 때 락이 없으면 대기
- 다른 쓰레드가 unlock() 메서드를 호출했을 때 대기가 풀리며 락을 얻으려고 한다
- 락을 얻으면 대기 큐를 빠져나감
- await() 메서드 호출로 인한 대기
- WAITING 상태
- wait() 메서드를 호출했을 때 '쓰레드 대기 집합'에서 대기
- 다른 쓰레드가 notify(), notifyAll() 메서드를 호출했을 때 대기 해제
synchronized와 달리 락을 얻기 위한 대기 큐에 대기할 때 WAITING 상태에 있게 된다
- syncrhonized
- 락 대기 집합: BLOCKED
- 쓰레드 대기 집합: WAITING
- ReentrantLock
- 락 대기 큐: WAITING
- 쓰레드 대기 집합: WAITING
'Java > Concurrency' 카테고리의 다른 글
| Java > Concurrency > 9. Concurrent Collections (0) | 2024.10.14 |
|---|---|
| Java > Concurrency > Atomic operation, CAS (0) | 2024.10.04 |
| Java > Concurrency > 6. Memory visibility (0) | 2024.09.13 |
| Java > Concurrency > 3. Thread info (0) | 2024.09.05 |
| Java > Concurrency > Thread scheduling (0) | 2024.09.03 |