Backend
ConcurrentHashMap과 CopyOnWriteArrayList
뭉크테크
2026. 10. 26. 21:58
ConcurrentHashMap
정의
- 멀티스레드 환경에서 키-값 쌍을 안전하게 관리하며, 세분화된 락(Fine-grained locking)과 CAS 연산을 통해 락 경합을 최소화하는 해시 맵 구현체
특징
- 세분화된 락(Fine-grained locking)과 CAS: 전체 맵에 락을 거는 대신 버킷 단위 락과 원자적 연산을 사용하여 멀티스레드 환경에서 높은 처리량(Throughput)을 보장
- 읽기 연산 (get)
- 락 사용 여부: 미사용 (Lock-free)
- 동작: synchronized나 뮤텍스 등의 동기화 메커니즘을 거치지 않고, 메모리 가시성(volatile)을 활용해 직접 노드를 탐색
- 쓰기 및 구조 변경 연산 (put, remove, replace 등)
- 경로 A: 대상 버킷이 비어 있는 경우 (null)
- 락 사용 안 함 / CAS 사용
- CPU 레벨의 원자적 연산인 CAS(Compare-And-Swap)(casTabAt)을 사용하여, 빈 버킷 자리에 첫 번째 노드를 곧바로 원자적으로 밀어 넣는다. 이 과정에서 다른 스레드와 경합이 발생하면 CAS 실패 후 재시도할 뿐, 스레드가 블로킹(Blocking)되지 않는다.
- 경로 B: 대상 버킷에 이미 노드가 존재하는 경우 (해시 충돌 또는 기존 값 갱신)
- synchronized 락 사용 (버킷 헤드 노드 기준)
- CAS만으로는 복잡한 노드 체인(연결 리스트 또는 레드-블랙 트리) 조작의 정합성을 보장할 수 없다.
- 이때는 해당 버킷의 첫 번째 노드(Head Node) 객체 자체를 기준으로 synchronized 블록을 연다.
- 이로 인해 동일한 버킷에 접근하려는 다른 쓰기 스레드들만 블로킹되며, 다른 버킷에 접근하는 쓰기 스레드나 모든 읽기 스레드는 전혀 영향받지 않고 병렬로 있을 수 있다.
- 경로 A: 대상 버킷이 비어 있는 경우 (null)
- 읽기 연산 (get)
- null 허용 안 함: 일반 HashMap과 달리, 키(Key)와 값(Value) 모두에 null을 허용하지 않는다. null을 입력하면 즉시 NullPointerException이 발생
사용 방법: 원자적 복합 연산(Atomic Compound Operations)을 제공하므로, 동기화 블록 없이도 안전하게 값을 갱신
import java.util.concurrent.ConcurrentHashMap
import kotlin.concurrent.thread
fun main() {
val map = ConcurrentHashMap<String, Int>()
// 1. 기본 삽입 및 조회
map["server-01"] = 100
val val1 = map["server-01"] // 락 없이 안전하게 조회
// 2. 키가 없을 때만 삽입 (Atomic putIfAbsent)
map.putIfAbsent("server-02", 200)
// 3. 원자적 연산을 통한 값 갱신 (Atomic compute)
// 키에 해당하는 값이 존재하면 연산을 수행하고, 없으면 null 처리
map.compute("server-01") { _, currentVal ->
(currentVal ?: 0) + 50
}
// 4. 멀티스레드 환경에서의 안전한 카운터 집계 예시
val threads = (1..5).map { id ->
thread(start = true) {
repeat(100) {
map.compute("visit-count") { _, v -> (v ?: 0) + 1 }
}
}
}
threads.forEach { it.join() }
println("Final Map State: $map")
}
한계
- 약한 일관성(Weak Consistency) 기반의 반복자(Iterator)
- ConcurrentHashMap의 반복자(keySet(), values(), entrySet())는 맵 전체를 한 번에 복사해서 찍어내는 스냅샷(Snapshot)이 아니다
- 대신, 내부 배열의 버킷을 차례대로 훑어 내려가는 순회 커서(Traversal Cursor / Pointer)를 가지고 실시간으로 노드를 찾아간다.
- 순회 커서가 이미 지나간 자리 (Behind the pointer)
- 반복자가 이미 훑고 지나간 버킷에 다른 스레드가 새로운 데이터를 추가하거나 기존 데이터를 삭제하면, 해당 변경 사항은 현재 진행 중인 순회에 반영되지 않는다.
- 순회 커서가 아직 도달하지 않은 자리 (Ahead of the pointer)
- 반복자가 아직 도달하지 않은 미래의 버킷에 다른 스레드가 새로운 데이터를 추가하면, 순회 도중에 그 데이터가 튀어나와 해당 데이터도 같이 조회된다.
- 순회 중인 현재 노드의 값 변경 (In-place update)
- 현재 커서가 가리키고 있는 바로 그 노드의 값이 다른 스레드에 의해 수정되면, 최신화된 값이 그대로 반영되어 읽힐 수 있게된다.
- 순회 커서가 이미 지나간 자리 (Behind the pointer)
- size() 및 isEmpty() 연산의 근사치 성격
- 일반적인 자료구조(HashMap 등)는 싱글스레드 환경에서 동작하므로 size()를 호출하면 내부 정수 변수(size)를 곧바로 읽어와 정확한 값을 반환.
- 하지만 ConcurrentHashMap은 멀티스레드 환경에서 동작하므로 이 방식을 쓸 수 없음
- 글로벌 락(Global Lock)을 쓰지 않는 설계: 만약 모든 스레드가 동시에 요소를 넣고 빼는 상황에서 size()가 호출될 때마다 맵 전체를 잠그고 정확한 개수를 세려 한다면, ConcurrentHashMap을 쓰는 이유인 '고성능 병렬 처리'가 완전히 깨지고 성능 병목(Bottleneck)이 발생
- 내부 카운팅 방식 (CounterCell):
- 자바 8 이후의 ConcurrentHashMap은 락 경합을 줄이기 위해 카운트를 하나의 변수에 몰아넣지 않고, 내부적으로 여러 개의 CounterCell 배열에 분산해서 기록
- size()를 호출하면 이 분산된 셀들의 값을 읽어와 합산(Summation)
- 한계의 핵심: 이 합산 작업을 수행하는 순간에도 다른 스레드들은 계속해서 데이터를 넣거나 빼고 있다. 따라서 size()가 반환하는 값은 "조회한 시점의 완벽하게 동기화된 정확한 개수"가 아니라, 연산 시점에 수집된 대략적인 합산인 '근사치(Estimate)'
- 따라서 ConcurrentHashMap의 size() == 0 같은 조건문이나 정확한 카운팅이 필수적인 로직에 의존하면 논리적 오류가 발생
잘못된 사용
- 비원자적 '조회 후 갱신(Check-Then-Act)' 패턴 사용
if (!map.containsKey("config")) {
map["config"] = loadDefaultConfig()
}
- (경쟁 상태 / Race Condition):
- containsKey() 메서드 자체는 원자적
- map[...] = ... (put) 메서드 역시 개별적으로는 원자적
- "없는지 확인(containsKey)하는 순간"과 "값을 넣는(put) 순간" 사이의 찰나의 공백 시간(Gap)에는 아무런 보호 장치 없음
- 이 사이에 다른 스레드가 접근하여 수정하면 먼저 접근한 스레드가 넣은 데이터는 유실
- 이에 대한 올바른 사용
- map.putIfAbsent("config", loadDefaultConfig())
- 확인과 삽입 과정을 단일 원자적 연산으로 묶어주는 API를 쓰기