[Java] ConcurrentHashMap 정리
ConcurrentHashMap 클래스는 아래 두 클래스와 유사하지만, 세부적으로 보면 차이점을 알 수 있다. 결론부터 말하자면, ConcurrentHashMap 클래스는 멀티 쓰레드 환경에서의 동기화 문제를 해결하는데 사용된다. 아래 두 클래스와의 차이를 비교해보겠다.
- Hashtable 클래스
- HashMap 클래스
Hashtable 클래스
public class Hashtable<K,V>
extends Dictionary<K,V>
implements Map<K,V>, Cloneable, java.io.Serializable {
private transient Entry<?,?>[] table;
private transient int count;
...
public synchronized int size() { }
...
@SuppressWarnings("unchecked")
public synchronized V get(Object key) { }
public synchronized V put(K key, V value) { }
...
}
위는 Hashtable 클래스의 일부이다. 메서드를 보면 synchronized 키워드가 보이는데, 이를 통해 임계 구역을 설정할 수 있다.
이 때문에, Thread-safe 하다는 특징은 있지만, 동시에 병목 현상일 발생할 수 밖에 없고, 멀티 쓰레드 환경에서 사용하기에도 살짝 느리다는 단점이 있다. (Collection 이 나오기 전부터 존재하던 클래스여서 최근에는 잘 사용안함)
HashMap 클래스
public class HashMap<K,V> extends AbstractMap<K,V>
implements Map<K,V>, Cloneable, Serializable {
...
public V get(Object key) {}
...
public V put(K key, V value) {}
}
이전에도 많이 봤고, 잘 알려진 HashMap 클래스이다. 보다시피, synchronized 키워드가 없기 때문에 당연히 멀티 쓰레드 환경에서는 사용할 수 없다.
위 두 클래스의 장점과 단점을 보완한 것이 ConcurrentHashMap 클래스 이다.
ConcurrentHashMap 클래스
public class ConcurrentHashMap<K,V> extends AbstractMap<K,V>
implements ConcurrentMap<K,V>, Serializable {
...
public V get(Object key) {}
public V put(K key, V value) {
return putVal(key, value, false);
}
...
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;
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable();
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
if (casTabAt(tab, i, null,
new Node<K,V>(hash, key, value, null)))
break; // no lock when adding to empty bin
}
else if ((fh = f.hash) == MOVED)
tab = helpTransfer(tab, f);
else {
V oldVal = null;
synchronized (f) {
if (tabAt(tab, i) == f) {
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;
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;
}
}
위 메서드들을 보면, get 메서드에는 synchronized가 붙어있지 않고, putVal 역시 메서드 전체가 아닌, 코드 블럭 사이에 synchronized를 지정해 놓은 것을 볼 수 있다.
이는 ConcurrentHashMap 은 읽기 작업은 상관 하지 않고, 쓰기 작업에 대해서만 동기화를 신경쓴다는 것을 의미한다.
private static final int DEFAULT_CAPACITY = 16;
...
private static final int DEFAULT_CONCURRENCY_LEVEL = 16;
위와 같이 클래스의 멤버로 버킷의 수(DEFAULT_CAPACITY)와, 동시에 실행 가능한 쓰레드의 수(DEFAULT_CONCURRENCY_LEVEL) 가 나타나있다.
또한, ConcurrentHashMap 은 내부적으로 CAS(Compare-And-Swap) 을 사용한다. CAS를 활용하면 락을 사용하지 않고 값을 안전하게 변경할 수 있다.
정리
1. Hashtable 보다 접근 속도가 빠르다.
: Hashtable 은 모든 메서드에서 synchronized 를 사용하여 전체 락(Lock)을 걸지만, ConcurrentHashMap 은 필요한 연산에만 동기화(CAS + synchronized)를 한다.
2. 읽기 연산(get) 에서는 동기화를 하지 않음
: get() 은 동기화 없이 실행되며, 여러 쓰레드가 동시에 접근해도 안전함
3. CAS 와 synchronized 를 조합하여 동기화 비용을 최소화
1) 새로운 키를 추가할 때는 CAS 연산 -> 락 없이 동기화
2) 같은 버킷에 충돌이 발생하면 필요한 경우에 synchronized 블록 사용 -> 메서드 전체 X
3) 트리화 된 버킷에서는 synchronized 블록 내부에서 탐색 수행