동시 접근을 버킷 단위 CAS + 부분 락으로 처리하는 맵.
- Hashtable / synchronizedMap — 맵 전체에 락 하나 → 사실상 직렬화
- ConcurrentHashMap — 버킷 단위로만 경합 → 읽기는 대부분 락 없음
읽기는 volatile 읽기로 락 없이 진행하고, 쓰기는 해당 버킷 헤드에 CAS 를 시도한 뒤 실패하면 synchronized 로 넘어간다
원자 연산을 조합하면 원자적이지 않다
// 잘못 — 두 연산 사이에 다른 스레드가 끼어든다
if (!map.containsKey(k)) map.put(k, v);
// 올바르게 — 한 번의 원자 연산
map.putIfAbsent(k, v);
map.computeIfAbsent(k, key -> expensive(key));
map.merge(k, 1, Integer::sum); // 카운팅
computeIfAbsent 재귀는 무한 루프가 된다
map.computeIfAbsent("a", k -> {
return map.computeIfAbsent("b", k2 -> 1); // 같은 맵에 재진입
});
// 두 키가 같은 버킷이면 자기 자신의 락을 기다린다 → 영원히 멈춘다
Java 9+ 는 감지되면 IllegalStateException 을 던지지만, 버킷이 다르면 감지되지 않고 자료구조가 깨질 수도 있다
- 매핑 함수 안에서 같은 맵을 건드리지 않는다. 이것이 규칙이다
- 재귀적 캐시가 필요하면 먼저 계산하고 넣는다
크기와 순회의 성질
-
size() — 근사값이다. 순회 중 변할 수 있다
-
순회 — weakly consistent — 예외를 던지지 않지만(fail-safe)
- 순회 시작 이후의 변경이 보일 수도, 안 보일 수도 있다
-
HashMap 의 fail-fast 순회와 정반대 성질이다
null 을 못 넣는 이유
get(k) 가 null 을 돌려줬을 때
-
"키가 없다" 인지 "값이 null 이다" 인지 구분할 수 없다 단일 스레드라면 containsKey 로 확인하면 되지만, 동시 환경에서는 그 사이에 값이 바뀌어 확인 자체가 무의미하다
-
아예 금지했다
캐시로 쓸 때의 함정 — 무한 성장
CHM 은 만료도 크기 제한도 없다 "간단한 캐시" 로 쓰다가 키가 무한히 늘어 OOM 이 나는 것이 자바에서 가장 흔한 메모리 누수 형태 중 하나다
-
사용자 ID · 요청 ID 처럼 카디널리티가 열린 키
-
예외 메시지 · URL 을 키로 쓰는 경우
-
캐시가 필요하면 Caffeine 처럼 만료·크기 제한이 있는 것을 쓴다
- WeakHashMap 은 값이 키를 강하게 참조하면 무력화되므로 해법이 아니다
락 분할에서 CAS 로 — 구현의 변화
- Java 7 까지 — Segment 배열 (기본 16개) 로 락을 쪼갰다
- 동시성 수준이 생성 시 고정됐다
- Java 8 부터 — Segment 제거. 버킷 헤드에 직접 CAS
- 비어 있으면 CAS 로 삽입, 충돌 시에만 헤드에 synchronized
- 경합이 없을 때 락이 아예 없다
같은 시기에 버킷이 길어지면 링크드 리스트 → 레드-블랙 트리로 바뀐다 (HashMap 과 동일한 트리화. 해시 충돌 공격 방어이기도 하다)
면접 함정
- ❌ "CHM을 쓰면 동시성 문제가 없다" → 개별 연산만 원자적이다.
- ❌ "size()는 정확하다" → 근사값이다.