ConcurrentHashMap — 고성능 동시성 맵
ConcurrentHashMap의 Java 7 세그먼트 락과 Java 8 버킷 단위 CAS/synchronized 구조, null 불허 이유, putIfAbsent/computeIfAbsent/merge 원자 연산, Hashtable 대비 성능 비교, 그리고 올바른 동시성 패턴
지난 글에서 TreeMap의 NavigableMap API와 범위 쿼리를 살펴봤다. 이번에는 **ConcurrentHashMap**을 다룬다. 멀티스레드 환경에서 HashMap의 빠른 성능을 유지하면서 스레드 안전을 보장하는 Java 최선의 Map 구현체다.
HashMap이 멀티스레드에서 위험한 이유
HashMap은 동기화가 없다. 여러 스레드가 동시에 쓰기 작업을 수행하면:
- Java 7 이하: resize 중 순환 체인 형성 →
get이 무한 루프 - Java 8 이상: 데이터 손실 및 비일관성 가능
Hashtable은 모든 메서드에 synchronized를 붙여 스레드 안전을 보장하지만, 읽기·쓰기 모두 단일 락이므로 멀티스레드 환경에서 심각한 병목이 된다.
ConcurrentHashMap 내부 구조
Java 7: Segment 배열(기본 16개)로 분할하고 각 세그먼트가 독립 락을 보유. 최대 16개 스레드가 동시에 쓰기 가능.
Java 8+: 세그먼트를 제거하고 버킷(노드) 단위로 더 세밀하게 동기화.
- 빈 버킷: CAS(Compare-And-Swap) 연산으로 락 없이 삽입
- 체인이 있는 버킷: 버킷 head 노드에만
synchronized적용 - 읽기:
volatile로 선언된 노드를 락 없이 읽음
결과적으로 서로 다른 버킷에 접근하는 스레드들은 전혀 경합하지 않는다.
원자 연산 메서드
import java.util.concurrent.ConcurrentHashMap;
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// check-then-put 원자적 수행 (별도 synchronized 불필요)
map.putIfAbsent("key", 1);
// 없으면 계산하여 삽입 (한 번만 계산 보장)
map.computeIfAbsent("userId", id -> fetchFromDB(id));
// 동시성 안전 카운터 (read-modify-write 원자적)
map.merge("word", 1, Integer::sum);
// 조건부 교체
map.replace("key", oldVal, newVal); // CAS 방식
putIfAbsent, computeIfAbsent, merge는 내부적으로 원자적으로 수행된다. 수동으로 synchronized 블록을 추가할 필요가 없다.
잘못된 패턴 vs 올바른 패턴
ConcurrentHashMap<String, Integer> freq = new ConcurrentHashMap<>();
// 잘못된 패턴: check-then-act 비원자적
if (!freq.containsKey("x")) {
freq.put("x", 1); // 두 스레드가 동시에 통과 가능!
}
// 올바른 패턴 1: putIfAbsent
freq.putIfAbsent("x", 1);
// 올바른 패턴 2: merge (값 갱신 포함)
freq.merge("x", 1, Integer::sum);
// 올바른 패턴 3: compute
freq.compute("x", (k, v) -> v == null ? 1 : v + 1);
null 불허
ConcurrentHashMap은 키와 값 모두 null을 허용하지 않는다.
map.put(null, "value"); // NullPointerException
map.put("key", null); // NullPointerException
이유: 동시성 환경에서 get("key") == null이 **“키가 없음”**인지 **“값이 null”**인지 구분할 수 없어 모호성이 생기기 때문이다. HashMap에서는 단일 스레드이므로 containsKey로 확인할 수 있지만, 멀티스레드 환경에서는 두 연산 사이에 상태가 바뀔 수 있다.
size() vs mappingCount()
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// ...
int s = map.size(); // int — 2^31 이상에서 잘림
long c = map.mappingCount(); // long — 정확한 카운트 (Java 8+)
size()는 여러 카운터 셀의 합산이므로 근사값일 수 있다. 큰 맵에서 정확한 수를 얻으려면 mappingCount()를 사용한다.
동시성 Map 비교
벌크 연산
Java 8부터 병렬 처리 메서드가 추가됐다.
// 병렬 forEach
map.forEach(2, (k, v) -> process(k, v));
// 병렬 reduce
int total = map.reduceValues(2, Integer::sum);
// 병렬 search (첫 번째 매칭 반환)
String found = map.searchKeys(2, k -> k.startsWith("user"));
첫 번째 파라미터 parallelismThreshold가 원소 수보다 크면 단일 스레드로 수행되고, 작으면 ForkJoinPool.commonPool()에서 병렬 처리한다.
지난 글: TreeMap — 정렬과 범위 쿼리를 지원하는 맵
다음 글: Queue와 Deque — 큐와 양방향 큐 인터페이스
읽어주셔서 감사합니다. 😊