ConcurrentHashMap

김운채·2023년 6월 5일
0

TIL

목록 보기
19/22

ConcurrentHashMap 요놈은 또 뭔지 차근차근 알아보자.

Hashtable, HashMap

Hashtable 클래스

Hashtable 클래스은 HashMap과 비슷한 Collection이지만, Thread-safe 한 특징이 있다.

Thread-safe 하게 동작을 보장하려면 여러가지 방법이 있지만, 그 중에서 synchronized block을 통해 객체 lock을 걸어 동기화를 보장하는 방법을 사용하고 있다.

Hashtable 클래스 API를 대충 보면 위와 같이 메소드 전체에 synchronized 키워드가 존재하는 것을 볼 수 있다. 즉, 동기화를 제공함으로써 멀티스레드 환경에서 데이터 일관성 문제를 방지할 수 있다.

일단 메소드에 접근하게 되면 다른 쓰레드는 Lock을 얻을 때까지 기다려야 하기 때문에 여러개의 동시작업에서 병목현상이 발생할 수 있다.

그래서 Hashtable 클래스는 Thread-safe 하다는 특징이 있긴 하지만, 위와 같은 특징 때문에 멀티쓰레드 환경에서 사용하기에도 살짝 느리다는 단점을 가지고 있기 때문에 잘 쓰이지 않는다.

HashMap

HashMap의 경우, HashTable와는 달리 동기화 (Synchronized) 처리를 하지 않기 때문에 데이터의 무결성을 보장하지 않지만 속도가 빠르다.

Lock을 걸지 않기 때문에 성능이 제일 좋지만, 멀티 스레드 환경에서는 문제가 발생할 수 있기 때문에 단일 쓰레드 환경에서 사용하기 적합하다.

그래서 hashMap 도 hashtable 의 완벽한 대안이 될 수 없다.

그럼 hachtable을 더 빠르게, hashMap을 Thread safe 하게 쓸 방법이 없을까?

이것을 보완하고자 나온것이 ConcurrentHashMap 이다.

ConcurrentHashMap

ConcurrentHashMap은 멀티스레드 환경에서 안전하게 사용할 수 있는 해시 테이블 구조다. HashMap과 비슷하지만, 동기화를 적용하는 대신 세분화된 락(lock)을 사용하여 성능을 향상시킨 것이다.

get()

ConcurrentHashMap에서의 get을 살펴보면, synchroized keyword를 발견할 수 없다는 것을 알 수 있다.
ConcurrentHashMap이 조회의 경우엔 항상 동시성을 보장하지않는다는 것이다.

그래서 put 작업이랑 겹칠 수 있기 때문에, get 작업에서는 가장 최근에 완료된 업데이트 작업의 결과가 나온다.

put()

반면에, 저장하는 함수는 메소드에 syncronized를 걸어서 동시성을 보장한다.(다른 방법도 사용한다. 여기서는 간단한 설명을 위해서 syncronized만 언급한당)

Map에 원소를 넣는 put(key, value) 를 호출하면 ConcurrentHashMap 내부적으로 putVal(key, value, onlyIfAbsent)로 연결된다.

코드를 살펴보면 해당 버킷에 대한 노드가 이미 존재할 때 분기처리 되는 부분에서만 synchronized가 적용되는 것을 확인할 수 있다.

ConcurrentHashMap에는 Hashtable 과는 다르게 synchronized 키워드가 메소드 전체에 붙어 있지 않는다. get() 메소드에는 아예 synchronized가 존재하지 않고, put() 메소드에는 중간에 synchronized 키워드가 존재하는 것을 볼 수 있다.

👉 ConcurrentHashMap은 읽기 작업에는 여러 쓰레드가 동시에 읽을 수 있지만, 쓰기 작업에는 특정 세그먼트 or 버킷에 대한 Lock을 사용한다는 것이다.

버켓? 세그먼트? 출처
ConcurrentHashMap는 내부적으로 여러 개의 해시 버킷을 여러 개의 세그먼트(segment)로 분할하여 관리한다. 각 세그먼트는 자체적으로 동기화되어 있으므로, 여러 스레드에서 동시에 접근해도 안전하게 데이터를 처리할 수 있다.

해시버킷:
해시 테이블은 각각의 Key값에 해시함수를 적용해 배열의 고유한 index를 생성하고, 이 index를 활용해 값을 저장하거나 검색하게 된다. 여기서 실제 값이 저장되는 장소를 버킷 또는 슬롯이라고 한다.

여러 개의 세그먼트(segment)로 분할:
ConcurrentHashMap은 내부적으로 세그먼트(segment)라는 작은 해시 테이블을 여러 개 만들어 관리한다. 전체 해시 테이블을 하나의 큰 해시 버킷으로 관리하는 것이 아니라, 작은 세그먼트 단위로 분할하여 병렬성(parallelism)을 높이는 것.

각각의 세그먼트는 자신만의 해시 버킷 배열을 가지고 있으며, 세그먼트 내부의 각 버킷은 엔트리(entry) 객체로 구성된다.

ConcurrentHashMap 클래스를 보면 위와 같이 DEFAULT_CAPACITY, DEFAULT_CONCURRENCY_LEVEL가 16으로 설정되어 있는걸 볼 수 있다.

  • DEFAULT_CAPACITY: 버킷의 수
  • DEFAULT_CONCURRENCY_LEVEL: 동시에 작업 가능한 쓰레드 수

버킷의 수가 동시작업 가능한 쓰레드 수와 같은 이유는 위에서 말했던 것처럼 ConcurrentHashMap은 버킷 단위로 lock을 사용하기 때문에 같은 버킷만 아니라면 Lock을 기다릴 필요가 없다는 특징이 있다.

즉, 여러 쓰레드에서 ConcurrentHashMap 객체에 동시에 데이터를 삽입, 참조하더라도 그 데이터가 다른 세그먼트에 위치하면 서로 경쟁하지 않는다는 것.

이말은 밑에 사진을 보며 이해해보자.

HashMap & ConcurrentHashMap

우선 HashMap은 Thread safe 하게 사용하려면, 요놈은 동기화 관련 코드가 없기에 Multi-thread 환경에서 사용한다면 다음과 같이 전체에 lock을 걸어야 한다.

하지만, ConcurrentHashMap은 각각의 Bucket 별로 동기화를 진행하기에 다른 Bucket에 속해있을 경우엔 별도의 lock없이 운용한다.

  • 세그먼트(Segment) 락

위 그림처럼 ConcurrentHashMap은 내부적으로 여러 개의 세그먼트(Segment)로 나뉘어져 있다. 각 세그먼트는 자체적으로 락을 가지고 있으며, 서로 독립적으로 작동한다. 이때문에 여러 스레드가 동시에 접근해도 서로 다른 세그먼트에서 작업하므로, 락 충돌이 발생할 확률을 줄일 수 있다.


❗ ConcurrentHashmap의 내부 구조가 JAVA 8 전, 후로 크게 바뀌었다고 한다.

JAVA 8 이전에는 Entry를 사용하던 것이 JAVA 8 부터는 Node로 바뀌게 되었다는데, 이는 LinkedList 에서 TreeNode(red black tree)로 변경되었다는 것이다. (Segment 대신 Node 배열과 CAS(Compare And Swap) 연산을 사용한 구현체로 변경)
이로 인해, 성능이 많이 개선되었다고 한다.

0개의 댓글