JAVA

[Java] ConcurrentHashMap 정리

배씌 2025. 3. 19. 16:21

ConcurrentHashMap 클래스는 아래 두 클래스와 유사하지만, 세부적으로 보면 차이점을 알 수 있다. 결론부터 말하자면, ConcurrentHashMap 클래스는 멀티 쓰레드 환경에서의 동기화 문제를 해결하는데 사용된다. 아래 두 클래스와의 차이를 비교해보겠다.

  1. Hashtable 클래스
  2. 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 블록 내부에서 탐색 수행