본문 바로가기
Language/Java

[Java] ConcurrentHashMap에 대해 알아보자

by jaee_ 2021. 8. 28.

멀티스레드 환경에서 고려해야 할 사항은 바로 동시성이다. 동시성이란, 하나의 CPU에서 여러개의 작업이 일어나는 것처럼 보이는 것이다. 동시성을 고려하지 않으면 멀티쓰레드 환경에서 하나의 변수에 여러개의 쓰레드가 동시에 접근할 경우 그 변수의 값은 원하는 값으로 반환되지 않는다.

 

자바에서는 이런 멀티스레드 환경에서의 동시성을 위해 다양한 방법을 제공하는데 대표적인 것이 Atomic Type, volatile, synchronyzed 이렇게 세가지이다.

volatile 이해를 도울 사진

<volatile>
각기 다른 CPU1, CPU2에서 쓰레드를 실행시키고 공유자원인 변수를 연산할 때 메인메모리의 값을 읽어오는 것이 아니라 각 CPU내 캐시메모리에 값을 옮기고 그 값으로 연산을 한 뒤 메인메모리에 반영을 한다. 서로 다른 CPU내 캐시메모리는 다른 CPU내의 캐시메모리값을 알지 못한다. 이 때문에 멀티쓰레드 환경에서는 공유자원인 변수가 원하는 값이 나오지 않는 경우가 발생한다.

volatile은 이러한 문제를 해결할 수 있는데 공유자원으로 가져올 변수앞에 volatile을 선언하면 CPU에서 연산을 한 뒤 바로 메인메모리에 쓴다. (가시성보장)

volatile은 멀티쓰레드 환경에서 오직 한개의 쓰레드에서 쓰기 & 읽기 작업을 할때, 그리고 다른 쓰레드들은 읽기 작업만 할 때 안정성을 보장한다. 

 

<Automic>
Compare and Swap(CAS)란 변수의 값을 변경하기 전에 기존의 값이 내가 예상한 값인지 확인하여 같은 경우에만 값을 할당하는 알고리즘 기법이다. Thread-safe를 위해 자바에서 제공하는 방법 중 Atomic Type 클래스가 CAS을 기반으로 하여 동기화 문제를 해결한다. (값을 할당하기 전 한 번 더 검사함)

 

앞서 위의 내용을 말한 이유는 ConcurrentHashMap의 이해를 위해서 필요한 개념이기 때문이다. 

 


 

자, 이제 ConcurrentHashMap이 뭔지 알아보자.

ConcurrentHashMapThread-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 : 동시 업데이트 스레드의 예상 수. 구현에서 이 값을 크기 조정 힌트로 사용할 수 있다.

 

댓글