자바 언어·플랫폼 용어 사전
컬렉션CHM · computeIfAbsent · 동시성 컬렉션

ConcurrentHashMap

락 분할과 CAS로 동시 접근을 처리하는 맵. 원자 연산의 조합은 여전히 원자적이지 않다.

동시 접근을 버킷 단위 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()는 정확하다" → 근사값이다.

함께 보면 좋은 용어

노트에서 맥락과 함께 보기 — 컬렉션·예외·리소스