백엔드 개발자라면 동시성 문제를 마주하게 되는 순간이 찾아옵니다. 이러한 문제를 해결하다 보면 자연스럽게 ConcurrentHashMap을 접하게 됩니다. 사용한 적이 없다해도, 들어본적은 있는 ConcurrentHashMap!! 단순히 이 자료구조를 사용하는 데 그치지 않고, 어떻게 동작하는지, 왜 효율적인지를 깊이 이해한 후 활용하고 싶었습니다.
목차는 다음과 같습니다!
HashMap은 배열 + 연결 리스트 + 트리 구조로 이루어져 있습니다.
배열: 데이터를 저장할 버킷(bucket)을 관리.
각 버킷은 특정 해시 값을 가진 데이터를 저장.
배열의 크기(tableSize)는 해시 함수에 의해 데이터가 분산되도록 함.
연결 리스트: 동일한 해시 값을 가진 데이터를 관리.
충돌(collision)이 발생하면 연결 리스트에 데이터 추가.
트리 구조:
연결 리스트에 저장된 데이터가 많아지면 Red-Black Tree로 변환.
탐색 성능이 O(log n)으로 향상.
트리 구조는 부모-자식 관계로 이루어진 계층적 데이터 구조입니다.
HashMap과 ConcurrentHashMap에서는 Red-Black Tree를 사용:
항상 균형을 유지하여 최악의 경우에도 O(log n)의 삽입, 삭제, 검색 성능을 보장. 노드 삽입/삭제 시 자동으로 균형을 맞춤.
Java 8 이상에서는 버킷의 데이터 개수가 8개 이상일 때 Red-Black Tree로 전환.
버킷은 동일한 해시 값을 가진 데이터가 저장되는 공간입니다.
초기에는 비어 있다가 데이터가 추가되면 연결 리스트로 관리.
해시 충돌이 많아져 데이터 개수가 8개 이상이 되면 트리 구조로 변환.
버킷 인덱스 계산:
(hashCode % tableSize)로 버킷 위치를 결정.
실제로는 비스마스킹이나 등등을 이용해 더 많은 버킷 위치를 결정한다.
버킷에 데이터 저장:
충돌 발생 시 연결 리스트로 관리.
데이터 개수가 많으면 Red-Black Tree로 전환.
데이터 검색:
버킷을 통해 데이터 접근.
연결 리스트 순회 또는 트리 탐색을 통해 데이터 검색.
아까 알아보았듯, 해시 맵은 배열구조 로 관리하고,
배열 안에는 노드 객체 또는 트리 노드 객체로 존재하는 것입니다.
다음은 코드로 확인해 보겠습니다.
static class Node<K, V> implements Map.Entry<K, V> {
final int hash; // 키의 해시 값
final K key; // 키
V value; // 값
Node<K, V> next; // 다음 노드 (연결 리스트 형태로 연결)
Node(int hash, K key, V value, Node<K, V> next) {
this.hash = hash;
this.key = key;
this.value = value;
this.next = next;
}
public final K getKey() {
return key;
}
public final V getValue() {
return value;
}
public final String toString() {
return key + "=" + value;
}
public final int hashCode() {
return Objects.hashCode(key) ^ Objects.hashCode(value);
}
public final V setValue(V newValue) {
V oldValue = value;
value = newValue;
return oldValue;
}
public final boolean equals(Object o) {
if (o == this) return true;
if (o instanceof Map.Entry) {
Map.Entry<?, ?> e = (Map.Entry<?, ?>) o;
if (Objects.equals(key, e.getKey()) &&
Objects.equals(value, e.getValue()))
return true;
}
return false;
}
}
static final class TreeNode<K, V> extends LinkedHashMap.Entry<K, V> {
TreeNode<K, V> parent; // 부모 노드
TreeNode<K, V> left; // 왼쪽 자식
TreeNode<K, V> right; // 오른쪽 자식
TreeNode<K, V> prev; // 연결 리스트의 이전 노드
boolean red; // 노드 색상 (Red-Black Tree에서 사용)
TreeNode(int hash, K key, V value, Node<K, V> next) {
super(hash, key, value, next);
}
// 트리 변환 및 탐색 관련 메서드 포함
}
transient Node<K, V>[] table; // 데이터를 저장하는 배열
transient int size; // 현재 저장된 데이터의 개수
final float loadFactor; // 로드 팩터 (배열 크기 확장 기준)
int threshold; // 배열 크기 확장 임계치
로드 팩터(loadFactor): 배열이 얼마나 차면 크기를 확장할지를 결정하는 값으로, 기본값은 0.75입니다.
임계치(threshold): 배열 확장의 기준이 되는 데이터 개수로, capacity * loadFactor로 계산됩니다.
예: 초기 배열 크기 16, 로드 팩터 0.75라면 임계치는 16 * 0.75 = 12가 됩니다.
HashMap
은 단일 스레드 환경에서 효율적으로 동작하지만, 멀티스레드 환경에서는 동시성 문제가 발생합니다. 반면, ConcurrentHashMap
은 멀티스레드 환경에서도 안전하게 동작하도록 설계된 Map 자료구조입니다. 두 자료구조의 차이점을 비교해 보겠습니다.
특징 | HashMap | ConcurrentHashMap |
---|---|---|
스레드 안전성 | 제공하지 않음 | 제공 (락 분할과 CAS 사용) |
락 사용 여부 | 없음 | 버킷 단위 락, CAS 활용 |
Null 허용 여부 | Null 키와 값 허용 | Null 키와 값 허용하지 않음 |
구조 | 배열 + 연결 리스트 + 트리 구조 | 배열 + 연결 리스트 + 트리 구조 |
성능 | 단일 스레드 환경에서 빠름 | 멀티스레드 환경에서 효율적 |
ConcurrentHashMap은 volatile, CAS(Compare-And-Swap), 그리고 버킷 단위 락을 활용하여 동시성을 보장합니다. 각 기술의 동작 원리를 살펴보겠습니다.
volatile
은 Java 메모리 모델에서 변수의 최신 값을 모든 스레드가 즉시 확인할 수 있도록 보장하는 키워드입니다.
volatile
변수의 값을 변경하면, 변경된 값이 CPU 캐시가 아닌 메인 메모리에 기록됩니다.volatile
로 선언된 변수(value
, next
) 덕분에 가시성을 보장하며, 읽기 작업에서 락 없이 최신 데이터를 확인할 수 있습니다.예를 들어, Node 클래스의 주요 필드인 value
와 next
는 다음과 같이 volatile
로 선언되어 있습니다:
static class Node<K, V> {
final int hash;
final K key;
volatile V value; // 최신 값을 읽기 위해 volatile 선언
volatile Node<K, V> next; // 연결된 다음 노드
}
CAS는 락 없이 데이터를 원자적으로 변경하는 비차단 동시성 제어 기법입니다.
CAS 동작 원리:
ConcurrentHashMap에서 CAS 사용:
static final boolean casTabAt(Node<K, V>[] tab, int i, Node<K, V> c, Node<K, V> v) {
return U.compareAndSwapObject(tab, ((long) i << ASHIFT) + ABASE, c, v);
}
ConcurrentHashMap은 락을 전체 맵이 아닌 버킷(bucket) 단위로 적용합니다. 이를 통해 동시 접근 시에도 성능을 유지합니다.
동작 원리:
읽기 작업에 영향을 주지 않는 이유:
volatile
로 선언된 최신 값을 읽을 수 있습니다.synchronized (bin) {
Node<K, V> p = table[index];
// 데이터 추가 또는 수정 작업
}
Lock
인터페이스의 구현체로, 더 세부적인 락 제어를 가능하게 합니다.ReentrantLock lock = new ReentrantLock();
lock.lock();
try {
// 락이 걸린 상태에서 작업 수행
} finally {
lock.unlock(); // 락 해제
}
특징 | HashMap | ConcurrentHashMap |
---|---|---|
스레드 안전성 | 스레드 안전하지 않음 | 스레드 안전 (멀티스레드 환경에서 사용 가능) |
락 사용 여부 | 락을 사용하지 않음 | 버킷 단위로 락을 사용 (락 분할 + CAS) |
Null 허용 여부 | Null 키와 Null 값 허용 | Null 키와 Null 값 허용하지 않음 |
구조 | 배열 + 연결 리스트 + 트리 구조 | 배열 + 연결 리스트 + 트리 구조 |
락의 범위 | 락 없음 | 버킷 단위 락으로 특정 버킷만 잠금 |
읽기 작업 | 단일 스레드 환경에서 빠름 | 락 없이 수행 (volatile로 가시성 보장) |
쓰기 작업 | 멀티스레드 환경에서 충돌 발생 가능 | CAS와 락 분할을 사용해 쓰기 작업을 안전하게 수행 |
성능 | 단일 스레드 환경에서 빠름 | 멀티스레드 환경에서 성능 최적화 |
해시 충돌 처리 방식 | 연결 리스트 및 Red-Black Tree로 관리 | 연결 리스트 및 Red-Black Tree로 관리 |
멀티스레드 환경 | 멀티스레드 환경에서 동작 불안정 | 멀티스레드 환경에서 안정적으로 동작 |
null
키와 값을 허용.null
키와 값을 허용하지 않음. (왜냐하면 null
값은 동시성에서 키가 존재하지 않는 것과 혼동될 가능성이 있기 때문.)volatile
을 사용해 최신 값을 읽어옴. 따라서 읽기 작업은 멀티스레드 환경에서도 안전하고 빠르게 동작.이렇게 ConcurrentHashMap을 HashMap과 비교해보며 동작 원리를 파악해 볼 수 있었습니다. 여러분들의 동시성 문제를 해결할 수 있기를 바라면서, 글을 마치겠습니다!!
HashMap:
해시맵 구조
https://www.geeksforgeeks.org/load-factor-in-hashmap-in-java-with-examples/
ConcurrentHashMap :
구조 (이게 도움이 많이 되었습니다.):
https://www.scaler.com/topics/hashmap-vs-concurrenthashmap/
모던 자바 인 액션에서 오늘 ConcurrentHashMap을 봤는데 이런 우연이... 내부 구조는 매우 어렵군요.. 좋은 글 감사합니다