멀티스레드 환경에서 고려해야 할 사항은 바로 동시성이다. 동시성이란, 하나의 CPU에서 여러개의 작업이 일어나는 것처럼 보이는 것이다. 동시성을 고려하지 않으면 멀티쓰레드 환경에서 하나의 변수에 여러개의 쓰레드가 동시에 접근할 경우 그 변수의 값은 원하는 값으로 반환되지 않는다.
자바에서는 이런 멀티스레드 환경에서의 동시성을 위해 다양한 방법을 제공하는데 대표적인 것이 Atomic Type, volatile, synchronyzed 이렇게 세가지이다.
<volatile>
각기 다른 CPU1, CPU2에서 쓰레드를 실행시키고 공유자원인 변수를 연산할 때 메인메모리의 값을 읽어오는 것이 아니라 각 CPU내 캐시메모리에 값을 옮기고 그 값으로 연산을 한 뒤 메인메모리에 반영을 한다. 서로 다른 CPU내 캐시메모리는 다른 CPU내의 캐시메모리값을 알지 못한다. 이 때문에 멀티쓰레드 환경에서는 공유자원인 변수가 원하는 값이 나오지 않는 경우가 발생한다.
volatile은 이러한 문제를 해결할 수 있는데 공유자원으로 가져올 변수앞에 volatile을 선언하면 CPU에서 연산을 한 뒤 바로 메인메모리에 쓴다. (가시성보장)
volatile은 멀티쓰레드 환경에서 오직 한개의 쓰레드에서 쓰기 & 읽기 작업을 할때, 그리고 다른 쓰레드들은 읽기 작업만 할 때 안정성을 보장한다.
<Automic>
Compare and Swap(CAS)란 변수의 값을 변경하기 전에 기존의 값이 내가 예상한 값인지 확인하여 같은 경우에만 값을 할당하는 알고리즘 기법이다. Thread-safe를 위해 자바에서 제공하는 방법 중 Atomic Type 클래스가 CAS을 기반으로 하여 동기화 문제를 해결한다. (값을 할당하기 전 한 번 더 검사함)
앞서 위의 내용을 말한 이유는 ConcurrentHashMap의 이해를 위해서 필요한 개념이기 때문이다.
자, 이제 ConcurrentHashMap이 뭔지 알아보자.
ConcurrentHashMap은 Thread-safety를 보장하면서 HashTable, HashMap과 동일한 기능을 가지는 클래스이다.
다만, HashMap과 다른 점은 key나 value 값에 null을 허용하지 않는다는 점과 HashTable과 쓰레드 세이프를 구성하는 방법이 다르다는 점이다.
HashTable도 Tread-safety를 보장하지만 HashTable은 객체마다 synchronized를 선언하여 lock을 가지고 있기 때문에 해당 메소드가 수행되는 동안 다른 쓰레드에서는 해당 메소드를 사용할 수 없다. (병목현상 발생)
이런 문제점을 개선한 ConcurrentHashMap은 put(쓰기)메소드 실행 시 Compare and Swap(CAS)을 사용하여 선택적으로 bucket 에 lock을 건다. 그리고 get(읽기)메소드 실행시에는 synchronized를 사용하지 않는다. 즉, 쓰기(put)은 하나의 쓰레드만, 읽기(get)에는 여러개의 쓰레드가 접근이 가능하다는 것이다.
위의 내용을 조금 다듬어서 정리하자면 아래와 같이 보면 된다.
- HashMap : Tread-safety를 보장하지 않음. null값 허용
- HashTable : Tread-safety를 보장한다. 각 메소드마다 synchronized 선언하여 성능저하를 일으킴. null값 허용하지 않음
- ConcurrentHashMap : Tread-safety를 보장한다. 선택적으로 bucket에 synchronized를 선언하여 HashTable 의 성능저하를 개선함. null값 허용하지 않음
ConcurrentHashMap의 동기화 처리 방식
직접 아래의 코드를 보고 이해를 도와보자.
public V put(K key, V value) {
// put()메소드 호출 시 putVal()메소드를 호출한다.
return putVal(key, value, false);
}
/** Implementation for put and putIfAbsent */
final V putVal(K key, V value, boolean onlyIfAbsent) {
if (key == null || value == null) throw new NullPointerException();
int hash = spread(key.hashCode());
int binCount = 0;
// table은 가변테이블로 내부적으로 관리한다.
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
// 새로운 노드를 삽입하기 위해, 해당 bucket 값을 가져와(tabAt) 비어 있는지 확인한다.(== null)
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 위의 조건을 일치한다면
// node를 담고있는 volatile 변수에 접근하여 기대값(null)을 비교하여(casTabAt)
// 같으면(null이면) 새로운 Node를 생성하고 같지않다면 다시 반복문으로 돌아간다.
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
// 새로운 Node를 생성할 땐 lock을 걸지 않는다.
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
// 이미 노드가 존재하는 경우
// synchronized 블록을 사용하여 lock을 건다.
synchronized (f) {
if (tabAt(tab, i) == f) { // f는 비어있지 않은 bucket
if (fh >= 0) {
binCount = 1;
for (Node<K,V> e = f;; ++binCount) {
K ek;
// 새로운 노드로 교체
if (e.hash == hash &&
((ek = e.key) == key ||
(ek != null && key.equals(ek)))) {
oldVal = e.val;
if (!onlyIfAbsent)
e.val = value;
break;
}
Node<K,V> pred = e;
// Separate chaining에 노드 추가
if ((e = e.next) == null) {
pred.next = new Node<K,V>(hash, key,
value, null);
break;
}
}
}
// 트리에 노드 추가
else if (f instanceof TreeBin) {
Node<K,V> p;
binCount = 2;
if ((p = ((TreeBin<K,V>)f).putTreeVal(hash, key,
value)) != null) {
oldVal = p.val;
if (!onlyIfAbsent)
p.val = value;
}
}
}
}
if (binCount != 0) {
if (binCount >= TREEIFY_THRESHOLD)
treeifyBin(tab, i);
if (oldVal != null)
return oldVal;
break;
}
}
}
addCount(1L, binCount);
return null;
}
코드 내 주석을 봐도 되지만 동기화 처리방식을 한 번 더 글로 정리를 하겠다.
1. 반복문을 실행한다.
2. 새로운 노드를 삽입하기 위해 bucket값을 가져와 비어있는지(null값) 확인한다.
3. 비어있다면 Node를 담고있는 volatile 변수에 접근하여 기댓갑(null)과 일치하는지 확인한다.
3-1. 일치한다면 Node를 삽입한다.(lock은 걸지 않는다.)
3-2. 일치하지 않는다면 다시 1번으로 간다. (재시도)
4. 비어있지 않다면 synchronized를 사용하여 lock을 건다. 이외의 내부적인 구현은 HashMap과 동일하다.
위 내용 중 자세히 봐야할 것은 bucket 값이 비어있을 경우 volatile 변수에 접근(가시성 보장)하여 기대값(null)과 일치하는지 한 번 더 확인하여 일치할 경우에만 노드를 생성한다. (원자성 보장) 으로 이와 같이 동시성을 처리함으로 Thread-safety를 보장한다고 할 수 있다.
또한, 같이 이미 노드가 존재하는 경우에 서로 다른 스레드가 해시 bucket에 접근할 때만 해당 블록이 잠긴다(synchronized). 이는 잠금(synchronized)이 필요하지 않을 경우엔 synchronized를 사용하지 않고 꼭 필요할 경우에만 synchronized를 사용한다는 것으로 쓰레드끼리의 경합을 최소화했다고 볼 수 있다.
🔍참고 : 멀티쓰레드 프로세스에서 쓰레드들이 서로 자원을 차지하기 위해 경합하는 상태를 Race Condition이라고 한다. 위에서 말한 쓰레드 경합이 Race Condition를 말하는 것이다. 즉, 두 개 이상의 쓰레드가 하나의 자원을 놓고 서로 사용하려고 경쟁하는 상황이라고 보면 된다.
ConcurrentHashMap의 생성자
생성자에서는 초기 해시테이블의 사이즈를 결정한다. DEFAULT_CAPACITY는 16으로 지정되어 있어 사이즈를 지정하지 않았을 경우 16으로 해시테이블이 생성된다. ConcurrentHashMap의 파라미터는 총 3가지가 있다.
- initailCapactiy : 초기 용량. 구현은 지정된 loadFactor를 고려하여 이렇게 많은 요소를 수용하기 위해 내부 크기를 수행한다.
- loadFactor : 초기 테이블의 크기를 설정하기 위한 부하 계수(테이블 밀도)
- concurencyLevel : 동시 업데이트 스레드의 예상 수. 구현에서 이 값을 크기 조정 힌트로 사용할 수 있다.
'Language > Java' 카테고리의 다른 글
[Java] 불변객체(Immutable Object)에 대해 알아보자 (0) | 2021.08.29 |
---|---|
[Java]equals()와 hashCode()에 대해서 알아보자(동등성 동일성) (0) | 2021.08.29 |
[Java] String literal 과 new String() 의 차이 (0) | 2021.08.22 |
[Java] final 을 알아보자 (0) | 2021.08.22 |
[Java]추상클래스와 인터페이스(차이,공통점) (0) | 2021.08.21 |
댓글