동기화(Synchronization)
여러 스레드가 한 리소스를 사용하려 할 때
사용하려는 스레드 하나를 제외한 나머지 스레드들은 리소스를 사용하지 못하도록 막는 것을 말한다.
💡 이것을 Thread-safe라고 한다.
Synchronized
Thread-safe를 위해 하나의 스레드만 일할 수 있도록 임계 영역(critical section)을 지정하는 키워드
둘 이상의 스레드가 동시에 접근해서는 안 되는 공유 리소스(자료 구조 또는 장치)를 접근하는 코드의 일부를 말한다.
아래처럼 메서드 자체를 synchronized로 선언하거나
public class Calculator {
private int amount;
public Calculator() {
amount = 0;
}
public synchronized void plus(int value) {
amount += value;
}
public synchronized void minus(int value) {
amount -= value;
}
}
아래처럼 메서드 내의 특정 문장만 synchronized로 감싸는 synchronized 블록이 있다.
public class Calculator {
private int amount;
private int interest;
public static Object interestLock = new Object();
public Calculator() {
amount = 0;
}
public void addInterest(int value) {
synchronized (interestLock) {
interest += value;
}
}
public void plus(int value) {
synchronized (this) {
amount += value;
}
}
public void minus(int value) {
synchronized (this) {
amount -= value;
}
}
}
모든 객체에는 락(lock)이 하나씩 있는데 이 락(lock)을 가지고 있는 스레드만 해당 객체의 임계 영역 코드와 관련된 작업을 할 수 있다.
임계 영역은 멀티스레드 프로그래밍의 성능을 좌우하기 때문에 가능하면 메서드 전체에 락(lock)을 거는 것보다 synchronized 블록으로 임계 영역을 최소화해 효율적인 프로그램이 되도록 해야 한다.
이를 위해 필요한 것이 있는데 이를 모니터(Monitor)라고 한다.
모니터(Monitor)의 개념
- 하나의 데이터(객체)마다 하나의 모니터를 결합할 수 있으며, 모니터는 그것이 결합된 데이터(객체)가 동시에 두 개 이상의 스레드에 의해 접근할 수 없도록 막는 락(lock) 기능을 제공함으로써 동기화를 수행한다.
- 즉, 데이터(객체)에 모니터를 결합하면 하나의 스레드가 그 데이터를 사용하는 동안에는 다른 스레드들이 그 데이터를 사용할 수 없게 된다.
- 자바에서는 synchronized 메서드가 선언된 객체와 synchronized 블록에 의해 동기화되는 모든 객체에 고유한 모니터가 결합이 되어 동기화 작업을 수행하게 된다.
💡 모니터에 대한 개념을 더 자세히 알고 싶다면 이 링크를 참고하기 바란다.
Atomic
래퍼 클래스(Wrapper Class)의 일종으로 참조 자료형(Reference type)과 기본 자료형(Primitive type) 두 종류의 변수에 모두 적용 가능하다.
💡 래퍼 클래스(Wrapper Class)는 기본 자료형(Primitive type)을 객체로 다루기 위해서 사용하는 클래스를 말한다.
사용 시 내부적으로 CAS(Compare-And-Swap) 알고리즘을 사용해 락(lock) 없이 동기화 처리를 할 수 있다.
Compare And Swap
멀티 스레드 환경, 멀티 코어 환경에서 각 CPU는 메인 메모리에서 변숫값을 참조하는 게 아닌 각 CPU의 캐시 영역에서 메모리를 값을 참조하게 된다.
이때, 메인 메모리에 저장된 값과 CPU 캐시에 저장된 값이 다른 경우가 있다. 이를 가시성 문제라고 한다.
그래서 사용되는 것이 CAS 알고리즘이다.
현재 스레드에 저장된 값과 메인 메모리에 저장된 값을 비교하여
일치하는 경우 새로운 값으로 교체, 일치하지 않는 다면 실패하고 재시도를 한다.
이렇게 처리되면 CPU 캐시에서 잘못된 값을 참조하는 가시성 문제가 해결된다.
java.util.concurrent.atomic 패키지에 정의된 클래스들은 Atomic으로 시작한다.
단어 뜻 그대로 통상적으로 알려진 가장 작은 단위인 원자에 빗댄 것이다.
대표적으로 AtomicInteger, AtomicIntegerArray, AtomicLong, AtomicDouble, AtomicBoolean DoubleAdder 등이 있다.
Volatile
자바 변수를 메인 메모리에 저장하겠다고 명시하는 키워드이다.
매번 변수의 값을 읽을 때마다 CPU 캐시에 저장된 값이 아니라 메인 메모리에서 읽는 것이며,
또한 변수의 값을 쓸 때마다 메인 메모리에 작성하는 것이다.
이 또한 앞서 설명한 가시성 문제를 해결하는 방법으로 볼 수 있다.
락(lock)
synchronized 부분에서 설명했듯, 모든 객체에는 락(lock)이 하나씩 있는데 이 락(lock)을 가지고 있는 스레드만 해당 객체의 임계 영역 코드와 관련된 작업을 할 수 있다.
그렇지만 여러 스레드가 경쟁 상태(Race Condition)에 있을 때 어떤 스레드가 진입권한을 획득할지 순서를 보장하진 않는다. 이를 '암시적(Implicit) 락'이라고 한다.
Lock 클래스는 lock() 메서드와 unlock() 메서드를 호출함으로써 어떤 스레드가 먼저 락을 획득하게 될지 순서를 지정할 수 있다. 이를 '명시적(explicit) 락'이라고 한다.
Lock 클래스는 java.util.concurrent.locks 패키지에서 제공한다.
💡 경쟁 상태 (Race Condition)
공유하는 자원이 있는데 공유하는 자원에 접근하는 여러 스레드 중 어떤 것이 먼저 접근하냐에 따라 결과가 달라질 수 있는 경우가 있다. 이를 경쟁 상태(Race Condition)에 의해 발생되었다고 하기도 한다.
synchronized와 Lock의 차이점 - 공정성(Fairness)
synchronized와 Lock을 구분 짓는 키워드는 공정성(Fairness)이다.
공정성이란 모든 스레드가 자신의 작업을 수행할 기회를 공평하게 갖는 것을 의미한다.
공정한 방법에선 큐 안에서 스레드들이 무조건 순서를 지켜가며 락을 확보한다.
불공정한 방법에선 만약 특정 스레드에 락이 필요한 순간 release가 발생하면 대기열을 건너뛰는 새치기 같은 일이 벌어지게 된다.
💡 Starvation(기아 상태)
다른 스레드들에게 우선순위가 밀려 자원을 계속해서 할당받지 못하는 스레드가 존재하는 상황을 말하며, 이 기아 상태를 해결하기 위해 공정성이 필요하다.
synchronized는 공정성을 지원하지 않아서 후순위인 스레드의 실행이 안될 수 있는 반면에
ReentrantLock은 생성자의 boolean 인자를 통해 공정/불공정을 설정할 수 있다.
ReentrantLock
가장 일반적인 락이며 재진입이 가능한 락이다.
'Reentrant(재진입할 수 있는)'이라는 단어가 앞에 붙은 이유는
특정 조건에서 락을 풀고 나중에 다시 락을 얻고 임계 영역으로 들어와서 작업을 수행할 수 있기 때문이다.
ReentrantReadWriteLock
읽기에는 공유적이고, 쓰기에는 배타적인 락이다.
ReentrantReadWriteLock은 이름에서 알 수 있듯 읽기를 위한 락과 쓰기를 위한 락을 제공한다.
ReentrantLock은 배타적인 락이라서 무조건 락이 있어야만 임계 영역의 코드를 수행할 수 있으나,
ReentrantReadWriteLock은 읽기 락이 걸려있으면 다른 스레드가 읽기 락을 중복해서 걸고 읽기를 수행할 수 있다.
읽기는 내용을 변경하지 않음으로 동시에 여러 스레드가 읽어도 문제 되지 않는다.
그래서 읽기 락이 걸린 상태에서 쓰기 락은 허용되지 않는다.
StampedLock
ReentrantReadWriteLock에 '낙관적 읽기 락(Optimistic Reading Lock)'을 추가한 것이다.
StampedLock은 Java 8부터 추가되었으며, 다른 락과 달리 Lock 클래스를 구현하지 않았다.
일반적으론 읽기 락이 걸려있으면 쓰기 락을 얻기 위해서는 읽기 락이 풀릴 때까지 기다려야 하는데,
낙관적 읽기 락은 쓰기 락에 의해 바로 풀린다.
그래서 낙관적 읽기에 실패하면 읽기 락을 얻어서 다시 읽어와야 한다.
무조건 읽기 락을 걸지 않고 쓰기와 읽기가 충돌할 때만 쓰기가 끝난 후에 읽기 락을 거는 것이다.
StampedLock은 락을 걸거나 해지할 때 '스탬프(long 타입의 정수 값)'를 사용한다.
스탬프에 해당하는 값이 둘다 long형의 숫자고 사전 연산에서 받아놓은 값이 이후 연산에서 비교 해봤을 때 변경되었다면 이후 연산은 실패가 된다.
출처
동기화(Synchronization)
docs.oracle.com/javase/tutorial/essential/concurrency/sync.html
CAS(Compare And Swap) 알고리즘
https://javaplant.tistory.com/23
모니터(Monitor)
https://happy-coding-day.tistory.com/8
ReentrantLock, ReentrantReadWriteLock, StampedLock
edeepakjain.blogspot.com/2019/05/quick-refresh-lock-in-java.html
'JAVA > 스레드' 카테고리의 다른 글
스레드(Thread) - 멀티스레드의 동시성(Concurrency)과 병렬성(Parallelism) (0) | 2021.04.15 |
---|---|
스레드(Thread) - 스레드 관련 메서드 (0) | 2021.04.15 |
스레드(Thread) - 개념, 사용이유, 프로세스와의 비교, 상태, 우선순위, 종류 (2) | 2021.01.24 |