스레드의 동기화
- 멀티스레드 프로세스의 경우 여러 스레드가 같은 프로세스 내의 자원을 공유해서 작업하기 때문에 서로의 작업에 영향을 주게 된다
- 한 스레드가 특정 작업을 끝마치기 전까지 다른 스레드에 의해 방해받지 않도록 하는 것이 필요
- 임계 영역과 잠금이라는 개념이 도입됨
- 공유 데이터를 사용하는 코드 영역을 임계 영역으로 지정해 놓고 공유 데이터(객체)가 가지고 있는 lock을 획들한 단 하나의 스레드만 이 영역 내의 코드를 수행할 수 있게 한다
- 해당 스레드가 임계 영역 내의 모든 코드를 수행하고 벗어나서 lock을 반납해야만 다른 스레드가 반납된 lock을 획득하여 임계 영역의 코드를 수행할 수 있게 된다
- 한 스레드가 진행 중인 작업을 다른 스레드가 간섭하지 못하도록 막는 것을 스레드의 동기화라고 한다
synchronized를 이용한 동기화
public synchronized void calcSum() {
}
synchronized(객체의 참조변수) {
}
wait()과 notify()
- 특정 스레드가 객체의 락을 가진 상태로 오랜 시간을 보내지 않도록 하는 것도 중요
- 동기화된 임계 영역의 코드를 수행하다가 작업을 더 이상 진행할 상황이 아니면 wait()를 호출하여 스레드가 락을 반납하고 기다리게 한다
- 작업을 진행할 수 있는 상황이 되면 notify()를 호출하여 중단했던 스레드가 다시 락을 얻어 작업할 수 있게 한다
- 매개변수가 있는 wait()은 지정된 시간동안만 기다린다
기아 현상과 경쟁 상태
- 통지를 받지 못하고 오랫동안 기다리게 되는 것을 기아 현상이라 한다
- 이 현상을 막으려면 notify() 대신 notifyAll()을 사용해야 한다
- 모든 스레드에게 통지를 하도록 함
- notifyAll()로 기아 현상은 막았지만 lock을 얻기 위해 waiting pool의 모든 스레드가 경쟁하게 된다
- 이것을 경쟁 상태라고 함
- 경쟁 상태를 개선하기 위해 스레드를 구별해서 통지하는 것이 필요
Lock과 Condition을 이용한 동기화
- 동기화 할 수 있는 방법은 lock 클래스들을 이용하는 방법도 있다
- synchronized 블럭은 동기화를 하면 자동적으로 lock이 잠기고 풀리기 때문에 편리
- 같은 메서드 내에서만 lock을 걸 수 있다는 제약이 불편함
- 이럴 때 lock 클래스 사용
- ReentrantLock : 재진입이 가능한 lock
- 가장 일반적
- ReentrantReadWriteLock : 읽기에는 공유적이고 쓰기에는 배타적인 lock
- 읽기를 위한 lock과 쓰기를 위한 lock을 제공
- 읽기 lock은 중복해서 걸고 읽을 수 있음
- 읽기 lock이 걸린 상태에서 쓰기 lock은 수행할 수 없다
- 쓰기 lock이 걸린 상태에서 읽기 lock은 수행할 수 없다
- StampedLock :ReentrantReadWriteLock에 낙관적인 lock의 기능을 추가
- lock을 걸거나 해지할 때 스탬프(long 타입의 정수값)를 사용
- 읽기와 쓰기를 위한 lock 외에 낙관적 읽기 lock이 추가된 것
- 낙관적 읽기 lock은 쓰기 lock에 의해 바로 풀린다
- 낙관적 읽기에 실패하면 읽기 lock을 얻어서 다시 읽어와야 한다
- 즉 쓰기와 읽기가 충돌할 때만 쓰기가 끝난 후에 읽기 lock을 건다
ReentrantLock의 생성자
ReentrantLock()
ReentrantLock(boolean fair)
- 생성자의 매개변수를 true로 주면 lock이 풀렸을 때 가장 오래 기다린 스레드가 lock을 획득할 수 있게, 즉 공정하게 처리한다
- 하지만 공정하게 처리하려면 어떤 스레드가 가장 오래 기다렸는지 확인해야 하기 때문에 성능이 떨어진다
ReentrantLock과 Condition
- 스레드를 구분해서 통지하지 못한다는 단점을 해결하기 위해 Condition이 존재한다
- 스레드의 종류를 구분하지 않고 공유 객체의 waiting pool에 같이 몰아넣는 대신, 스레드의 conditon을 만들어서 각각의 waiting pool에서 따로 기다리도록 한다
private Condition forCook = lock.newCondition();
private Condition forCust = lock.newCondition();
- 기아 현상이나 경쟁 상태가 개선되었으나 스레드의 종류에 따라 구분하여 통지할 수 있게 된 것일 뿐 특정 스레드를 선택할 수는 없기 때문에 같은 종류의 스레드 간의 기아 현상이나 경쟁 상태는 발생할 가능성이 남아있다
volatile
- 멀티 코어 프로세스는 코어마다 별도의 캐시를 가지고 있다
- 코어는 메모리에서 읽어온 값을 캐시에 저장하고 캐시에서 값을 읽어서 작업한다
- 같은 값을 읽어올 때는 먼저 캐시에 있는지 확인하고 엇을 때만 메모리에서 읽어온다
- 도중에 메모리에 저장된 변수의 값이 바뀌었는데 캐시에 저장된 값이 갱신되지 않아서 메모리에 저장된 값이 다른 경우가 발생한다
- 변수 앞에 volatile을 붙이면 코어가 변수의 값을 읽어올 때 캐시가 아닌 메모리에서 읽어오기 때문에 캐시와 메모리 간의 값의 불일치가 해결된다
- synchronized 블럭을 사용해도 같은 효과를 얻을 수 있다
원자화
- 작업을 더 이상 나눌 수 없게 한다는 의미
- volitile은 해당 변수에 대한 읽거나 쓰기를 원자화 한다
- 하나의 명령어는 더 이상 나눌 수 없는 최소의 작업 단위
- JVM은 데이터를 4byte 단위로 처리하기 때문에 intdhk int보다 작은 타입들은 한 번에 읽거나 쓰는 것이 가능하다(하나의 명령어로 작업 완료 가능)
- long과 double은 8byte이기 때문에 하나의 명령어로 값을 읽거나 쓸 수 없기 때문에 변수의 값을 읽는 과정에 다른 스레드가 끼어들 여지가 있다
- 다른 스레드가 끼어들지 못하게 하려고 변수를 읽고 쓰는 모든 문장을 synchronized블럭으로 감쌀수도 있지만 변수를 선언할 때 volatile을 붙일수도 있다
- 상수는 변하지 않는 값이므로 멀티 스레드에 안전하기 때문에 volatile을 붙일 필요가 없다
fork & join 프레임웍
- 하나의 작업을 작은 다위로 나눠서 여러 스레드가 동시에 처리하는 것을 쉽게 해준다
- RecursiveAction : 반환값이 없는 작업 구현
- RecursiveTask : 반환값이 있는 작업 구현
- 두 클래스 모두 compute()라는 추상 메서드를 가지고 있는데 상속을 통해 추상 메서드를 구현한다
- fork()는 작업을 스레드의 작업 큐에 넣는 것이다
- 작업 큐에 들어간 작업은 더 이상 나눌 수 없을 때까지 compute()로 나뉜다
- 작업의 결과는 join()을 호출해서 얻을 수 있다
- 작업 큐에 추가된 작업도 나눌 수 있다
- 이것을 통해 한 스레드에 작업이 몰리지 않고 여러 스레드가 골고루 작업을 나누어 처리할 수 있게 한다
- fork()는 비동기 메서드이고 join은 동기 메서드이다
- 비동기 메서드는 메서드를 호출만 할 뿐 결과를 기다리지 않는다