본문 바로가기

자바

Hashtable, HashMap, ConcurrentHashMap 간단 비교

HashMap, Hashtable, ConcurrentHashMap

Map 인터페이스를 구현하고 세가지의 구현체는 비슷하지만 조금씩 다른 차이점을 가지고 있다. 

Hashtable

해시 테이블은 <key, Map> 형태로 데이터를 저장하는 자료구조 중 하나이다. 
내부적으로 배열을 사용해 데이터를 저장하는데,
이 때 각각의 Key 값에 해시 함수를 적용해 이 배열의 고유한 index를 생성하고 값을 저장, 검색할 때 사용한다. 

자바에서는 java.util 패키지 안에 Map 인터페이스를 구현한 HashTable을 사용할 수 있다. 

메서드에 붙어있는 synchronized

put 메서드를 보면 null 값을 허용하지 않고 있음을 볼 수 있다. 

Hashtable 클래스에서 구현하고 있는 모든 메서드를 보면 synchronized 키워드가 붙어있다. 
때문에 멀티 스레드 환경에서는 동시에 작업을 하려할 때 요청마다 Lock을 걸기 때문에 병목현상이 발생할 수 있다. 
모든 메서드가 synchronized라 스레드 세이프하다는 특징이 있지만, 성능적으로 속도의 저하가 올 수 있다는 말이다. 
또한 JCF가 나오기 이전부터 존재하던 클래스이기 때문에 요즘에는 잘 쓰이지 않고 있다. 

HashMap

자바에서는 java.util 패키지 안에 Map 인터페이스를 구현한 HashMap을 사용할 수 있다. 

Hashtable과는 다르게 null 값에 대해 따로 예외처리를 하지 않고 있다. 즉 null 값을 넣을 수 있다. 

또한 모든 메서드에 synchronized 키워드가 붙어있지 않다. 
Lock을 걸지 않기 때문에 성능이 제일 좋지만, 멀티 스레드 환경에서는 문제가 발생할 수 있기 때문에 적합하지 않다.

ConcurrentHashMap

자바에서는 java.util.conccurrent 패키지 안에 Map 인터페이스를 구현한 ConcurrentHashMap을 사용할 수 있다. 
Hashtable 클래스의 단점을 보완하면서 멀티 스레드 환경에서도 사용가능할 수 있는 클래스이다. 
이는 JDK 1.5부터 Hashtable의 대안으로 도입되었다. 

왜 Hashtable의 대안?

특이한 점은 해당 클래스에서 구현하고 있는 메서드들을 보면 모든 메서드에 synchronized가 붙어있는 것은 아니다. 
즉 일부에만 Lock을 걸어서 좀 더 성능 오버헤드를 개선시킨다는 것이다. 

오라클 문서에 따르면,

A hash table supporting full concurrency of retrievals and high expected concurrency for updates. This class obeys the same functional specification as Hashtable, and includes versions of methods corresponding to each method of Hashtable. However, even though all operations are thread-safe, retrieval operations do not entail locking, and there is not any support for locking the entire table in a way that prevents all access. This class is fully interoperable with Hashtable in programs that rely on its thread safety but not on its synchronization details.

검색 작업에 대해서는 Lock을 걸지 않는다. 

get()

synchronized가 붙어있지 않다. 

put() 

public V put(K key, V value) {
    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;
    for (Node<K,V>[] tab = table;;) {
        Node<K,V> f; int n, i, fh; K fk; V fv;
        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)))
        break;                   // no lock when adding to empty bin
    }
        else if ((fh = f.hash) == MOVED)
        tab = helpTransfer(tab, f);
    else if (onlyIfAbsent // check first node without acquiring lock
        && fh == hash
        && ((fk = f.key) == key || (fk != null && key.equals(fk)))
        && (fv = f.val) != null)
        return fv;
    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);
                            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;
                    }
                }
                else if (f instanceof ReservationNode)
                    throw new IllegalStateException("Recursive update");
            }
        }
        if (binCount != 0) {
            if (binCount >= TREEIFY_THRESHOLD)
                treeifyBin(tab, i);
            if (oldVal != null)
                return oldVal;
            break;
        }
    }
    }
    addCount(1L, binCount);
    return null;
}

put 메서드 자체에 synchronized가 붙어있지 않고,
주석으로 표시한 것 처럼 동일한 노드에 값이 추가되는 경우에만 synchronized를 추가하였다. 
즉 이미 노드가 존재하는 경우에는 하나의 스레드만 접근할 수 있도록 제어한다.
쓰기 작업은 특정 세그먼트에 대해 Lock을 사용한다는 것이다. 

또한 DEFAULT_CAPACITY, DEFAULT_CONCURRENCY_LEVEL이 동일하게 16으로 설정되어 있다.
DEFAULT_CAPACITY는 나뉘어져 있는 세그먼트의 수이며, DEFAULT_CONCURRENCY_LEVEL는 동시에 작업 가능한 스레드 수 이다.

Map의 쓰기 작업이 많으며 멀티 스레드 환경에서 동시성 문제가 발생할 경우에 사용하자!

참고로, 읽기 작업에서는 동기화가 적용되지 않으므로, 업데이트 작업과 겹칠 수 있다.
때문에 읽기에서는 가장 최근에 완료된 업데이트 작업의 결과가 반영된다. 

더 읽어보면 좋을 자료

각각의 Map 구현체의 차이점만 간단히 정리한 글이므로,
이에 대해 좀 더 구체적으로 알아보고 싶다면 다음 글들을 더 읽어보면 좋을 것 같다.

'자바' 카테고리의 다른 글

[Java] 람다의 변수 범위  (0) 2021.08.06
[Java] Lambda, Stream API 강의 정리  (0) 2021.08.06
[Java] Functional Interface  (0) 2021.08.06
[Java] 추상 클래스와 인터페이스의 차이  (0) 2021.08.06
[Java] 상속에 대하여  (0) 2021.08.06