ConcurrentHashMap 개념과 동기화 동작 원리(Thread-safe)
ConcurrentHashMap 개념과 동기화 동작 원리(Thread-safe)
해당 포스팅은 멀티스레드(Multi-Thread) 환경에서 동시성 이슈로 인해 ConcurrentHashMap을 사용해 보면서 정리한 것으로 'ConcurrentHashMap의 개념과 해당 클래스가 Thread-safe 하게 동작할 수 있는 동작 원리'에 대한 내용입니다.
(구글링을 해보면 이미 ConcurrentHashMap에 대해 잘 정리된 글이 많지만, 직접 코드를 살펴보고 동작 원리를 파악하고 싶어 다른 글들을 참고하여 정리한 내용입니다.)
synchronized 키워드
synchronized 키워드는 아래 ConcurrentHashMap을 이해하는 데 있어서 중요한 요소 중 하나라고 생각되는데요.
멀티스레드(Multi-Thread) 환경에서는 여러 스레드가 하나의 공유 자원에 동시에 접근하지 못하도록 막는 '스레드 동기화'가 필수적입니다.
(스레드 동기화가 되지 않은 경우 여러 스레드에서 서로 공유하고 수정할 수 있는 데이터에 대한 안전성과 신뢰성을 보장할 수 없게 됩니다.)
그리고 자바에서는 synchronized 키워드를 사용하여 여러 스레드 간 동기화가 필요한 부분(임계영역, Critical Section)에 동시에 접근하는 것을 금지함으로써 공유 자원에 대한 Thread-safe를 가능하게 하고 있습니다.
***
synchronized 키워드를 사용하면 java 내부적으로 메서드나 변수를 동기화하기 위해 block/unblock 처리를 하게 되는데요.
block/unblock 처리의 경우 어느 정도의 시스템 자원이 소모되는 작업이기 때문에 synchronized 키워드를 남용하는 경우 프로그램 성능 저하의 원인이 될 수 있습니다.
Hashtable, HashMap, ConcurrentHashMap
Map 인터페이스를 구현한 Map 컬렉션 클래스에 속하는 대표적인 클래스는 HashMap<K, V> Hashtable<K, V> ConcurrentHashMap<K, V> 등이 있는데요.
아래 내용을 통해 ConcurrentHashMap<K, V>는 Hashtable, HashMap과 어떻게 다른지 먼저 살펴보고, 이어서 ConcurrentHashMap에서 스레드 간 동기화가 적용되는 동작 원리까지 살펴보겠습니다.
Hashtable Class
public class Hashtable<K,V>
extends Dictionary<K,V>
implements Map<K,V>, Cloneable, java.io.Serializable {
public synchronized V get(Object key) { ... };
public synchronized V put(K key, V value) { ... };
public synchronized V remove(Object key) { ... };
...
}
먼저 Hashtable 클래스 내부를 살펴보면 get(), put(), remove(), clear() 등 외부에서 호출될 수 있는 모든 메서드에 synchronized 키워드가 붙어 있는 것을 볼 수 있는데요.
(또는 메서드 내부적으로 synchronized 키워드가 사용되고 있는 것을 볼 수 있습니다.)
따라서 멀티스레드 환경에서 스레드 간 동기화가 동작하며, 공유 자원에 대한 Thread-safe가 가능하다는 특징이 있습니다.
하지만 위에서 언급한 것처럼 synchronized 키워드를 사용한 동기화의 경우 메서드 동작 시 java 내부적으로 block/unblock 처리를 하게 되는데요. 때문에 동일한 인스턴스에 대해 동시에 여러 요청이 들어오게 되는 경우, 각각의 스레드에서 들어온 요청을 순차적으로 하나씩 처리해야 하는 병목현상이 발생할 수 있다는 특징이 있습니다.
HashMap Class
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) { ... };
public V remove(Object key) { ... };
...
}
HashMap 클래스의 경우 Hashtable 클래스와 다르게 메서드(또는 내부적으로)에 synchronized 키워드가 존재하지 않는데요.
따라서 synchronized 키워드로 인해 발생하는 block/unblock 처리가 없기 때문에 속도 측면에서 빠르다는 특징이 있습니다.
하지만 synchronized 키워드가 없기 때문에 당연히 멀티스레드 환경에서의 스레드 간 동기화가 적용되지 않으며, 공유 데이터에 대한 안전성과 신뢰성을 보장할 수 없습니다.
ConcurrentHashMap Class
ConcurrentHashMap 클래스는 JDK 1.5 버전에서 검색과 업데이트 시 동시성 성능을 높이기 위해 나온 클래스로, Hashtable 클래스의 단점을 보완하면서도 HashMap 클래스와 다르게 멀티스레드 환경에서 사용할 수 있는 클래스입니다.
***
Hashtable의 경우 synchronized 키워드로 인해 Map 전체에 block/unblock 처리가 적용되어 thread-safe 하지만 성능적인 오버헤드를 발생시키는 반면, ConcurrentHashMap의 경우 Map의 일부분에만 block/unblock 처리가 적용되기 때문에 thread-safe라는 특징을 가져가면서도 성능까지 고려된 클래스입니다.
ConcurrentHashMap 동기화 동작 원리
get() method
ConcurrentHashMap의 get() 메서드를 살펴보면 synchronized 키워드가 존재하지 않는 것을 볼 수 있는데요.
따라서 get() 메서드를 포함한 검색 작업 요청 시에는 스레드 동기화가 적용되지 않으며 put(), remove() 등의 업데이트 작업과 동시에 수행될 수 있습니다.
때문에 get() 메서드 등의 검색 작업은 요청이 들어왔을 때, '가장 최근에 완료된 업데이트의 작업 결과를 반영'하게 됩니다.
put() method
put(key, value) 메서드 호출 시 내부적으로 putVal(key, value, onlyIfAbsent) 메서드가 실행되는데요.
putVal() 메서드를 살펴보면 메서드 자체에 synchronized 키워드가 적용된 것이 아니라, 메서드 동작 과정에서 synchronized 키워드가 사용되는 것을 볼 수 있습니다.
(코드를 살펴보면 해당 버킷에 대한 노드가 이미 존재할 때 분기처리 되는 부분에서만 synchronized가 적용되는 것을 확인할 수 있습니다.)
즉, ConcurrentHashMap의 업데이트 작업(put 외에 remove, clear 메서드 등을 포함)에서는 각각의 Table Bucket 별로 block/unblock 처리가 진행되기 때문에 멀티스레드 환경에서의 성능이 향상되는 것을 알 수 있는데요.
위 내용에 대해 조금 더 자세하게 살펴보겠습니다.
위 코드에서 1번 부분은 ConcurrentHashMap 내부적으로 관리하는 가변 배열 table을 무한 루프로 돌리는 과정인데요.
이 과정을 통해 데이터가 삽입될 bucket을 확인하게 됩니다.
여기서 volatile 키워드에 대해 간단하게 이야기하면, volatile 키워드의 경우 변수를 Main Memory에 저장하고 읽어오겠다는 것을 명시한 것인데요. 변수 값을 read 할 때 CPU Cache에 저장된 값이 아닌 Main Memory에서 읽어오고, 변수 값을 write 할 때 역시 Main Memory에 작성하는 것입니다.
(transient 키워드의 경우 객체 직렬화(Serializable) 시에 멤버 변수의 전송을 막기 위해 사용하는 키워드입니다.)
2번 부분은 tabAt() 메서드를 통해 해당 bucket을 가져오고, 해당 bucket이 null로 비어있는 경우 casTabAt() 메서드를 통해 new Node를 해당 bucket에 삽입하는 과정인데요.
이처럼 빈 해시 버킷에 node를 삽입하는 경우 Lock을 거는 것이 아니라 casTabAt() 메서드를 사용함으로써 Compare and Swap 방식이 적용되고 있습니다.
***
Compare and Swap(cas)는 멀티스레드 환경에서 동시성 처리를 위한 방법 중 하나로, 특정 메모리 위치 값이 주어진 값과 동일할 경우 해당 메모리 주소를 새로운 값으로 교체하는데요.
synchronized의 경우 block/unblock 과정을 통해 동시성 문제를 처리하는 반면, cas는 Atomic 연산으로 동시성 문제를 처리하는 Non-Blocking 방식입니다.
tabAt(), casTabAt() 메서드를 살펴보면 내부적으로 'Unsafe' 말 그대로 안전하지 않은 클래스의 인스턴스를 사용하는 것을 볼 수 있는데요. 간단하게 말하면 해당 인스턴스를 통해 memory를 직접 handling 하게 되는 것입니다.
putVal() 메서드 동작 시 Bucket에 Node가 존재하는 경우 synchronized 키워드를 이용해 하나의 thread만 해당 bucket에 접근할 수 있도록 제어하는데요. 서로 다른 thread가 같은 Hash Bucket에 접근할 때만 block이 걸리게 됩니다.
(이때 f는 비어있지 않은 Node<K,V> type의 hash bucket을 의미합니다.)
2번 과정에서는 같은 key 값의 요청이 들어온 경우 등을 판단하여 새로운 노드로의 교체 작업이 실행됩니다.
3번 과정에서는 binCount 값이 TREEIFY_THRESHOLD(== 8)과 비교하여 같거나 큰 경우 treeifyBin() 메서드를 통해 기존의 linked nodes를 tree nodes로 운용하게 됩니다.
하지만 treeifyBin() 메서드 내부 동작 과정을 살펴보면 tab.length가 MIN_TREEIFY_CAPACITY(== 64) 보다 작은 경우에는 tree nodes로 변경하는 것이 아니라 linked nodes의 크기를 2의 거듭제곱만큼 resize 하게 됩니다.
ConcurrentHashMap의 기본 생성자의 주석 부분을 살펴보면 default initial table size는 16이라는 것을 알 수 있으며, table 변수의 주석 부분을 통해 처음 node가 삽입될 때 table이 Lazily initialized 된다는 것을 알 수 있습니다.
정리하자면
ConcurrentHashMap은 Hashtable처럼 모든 요청에 대한 동시성 처리에 있어 전체 Map에 대하여 Lock을 거는 것이 아니라, 꼭 필요한 부분에서만 Table Bucket에 대한 Lock을 사용하여 성능을 높인 방식인데요.
빈 bucket에 대한 최초 node 삽입 시에도 lock이 아닌 compare and swap 방식이 사용되며, 그 외에 업데이트(삽입, 삭제, 교체 작업)에서는 해당 버킷에 대해 synchronized 키워드를 통한 block/unblock이 적용되게 됩니다.
< 참고 자료 >
https://pplenty.tistory.com/17
https://velog.io/@alsgus92/ConcurrentHashMap%EC%9D%98-Thread-safe-%EC%9B%90%EB%A6%AC