본문 바로가기

Language/Java

[Java] Java Concurrent 패키지 - Concurrent Collections 완벽 정리

java.util.concurrent 패키지의 동기화 컬렉션들을 실전 코드와 함께 알아봅니다.
synchronized 래퍼와의 차이점, ConcurrentHashMap 내부 구조, 적재적소 사용법까지.

 


1. 왜 Concurrent Collection이 필요한가?

멀티스레드 환경에서 일반 컬렉션(ArrayList, HashMap 등)을 그대로 쓰면 데이터 경쟁(race condition)이 발생합니다. Java는 크게 두 가지 방식으로 이를 해결합니다.

 

Collections.synchronizedMap() 같은 래퍼는 모든 메서드에 전체 락을 걸어 스레드 안전성을 확보하지만, 락 구간이 넓어 성능이 낮습니다. 반면 java.util.concurrent의 컬렉션들은 내부를 버킷/세그먼트 단위로 나누거나, 아예 락 없이 CAS(Compare-And-Swap) 연산을 활용해 훨씬 높은 처리량을 제공합니다.

// synchronized 래퍼 — 전체 락, 성능↓
Map<String, Integer> syncMap =
    Collections.synchronizedMap(new HashMap<>());

// Concurrent 컬렉션 — 세분화 동기화, 성능↑
Map<String, Integer> concMap = new ConcurrentHashMap<>();

 

⚠️ 주의: synchronized 래퍼는 복합 연산(check-then-act)에서 여전히 경쟁 조건이 발생합니다.
if (!map.containsKey(k)) map.put(k, v) 처럼 두 호출 사이에 다른 스레드가 끼어들 수 있기 때문입니다.
concurrent 컬렉션의 원자적 복합 메서드(putIfAbsent, compute)를 사용해야 합니다.

 


2. 주요 클래스 한눈에 보기

클래스 타입 특징
ConcurrentHashMap Map 버킷 단위 CAS + synchronized. 읽기는 락 없음. 가장 범용적.
CopyOnWriteArrayList List 쓰기 시 배열 전체 복사. 읽기 많고 쓰기 드문 경우에 적합.
CopyOnWriteArraySet Set CopyOnWriteArrayList 기반. 소규모 집합에 적합.
LinkedBlockingQueue Queue 생산자-소비자 패턴의 기본. 용량 선택적 제한 가능.
ArrayBlockingQueue Queue 고정 용량 배열 기반. 메모리 사용량 예측 가능.
ConcurrentSkipListMap Map 정렬 유지 + 동시성. TreeMap의 concurrent 버전.

 


3. ConcurrentHashMap — 사용법

가장 중요한 포인트는 원자적 복합 연산을 적극 활용하는 것입니다. getput처럼 두 번에 나눠 호출하면 스레드 안전하지 않습니다.

ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();

// 키가 없을 때만 삽입 (원자적)
map.putIfAbsent("key", 1);

// 값을 읽고 변환하는 복합 연산 (원자적)
map.compute("key", (k, v) -> v == null ? 1 : v + 1);

// 카운터 패턴의 정석 — getOrDefault + put 대신 이것을
map.merge("key", 1, Integer::sum);

// 병렬 집계 (Java 8+, 병렬 임계치 = 4)
long total = map.reduceValues(4, Integer::sum);

size()는 정확하지 않습니다. 다른 스레드의 동시 수정이 반영되지 않을 수 있어요.
정확한 카운트가 필요하다면 LongAdder를 별도로 유지하는 편이 낫습니다.

 


4. ConcurrentHashMap — 내부 구조 (Java 7 vs 8)

Java 7과 8 사이에 내부 구조가 완전히 바뀌었습니다. 두 버전의 핵심 차이는 락의 단위입니다.

 

Java 7 — 세그먼트 락

맵 전체를 16개의 Segment로 나누고, 각 Segment가 독립적인 ReentrantLock을 보유합니다. 서로 다른 세그먼트에 속하는 쓰기는 동시에 진행되지만, 같은 세그먼트 내에서는 한 스레드만 쓸 수 있습니다. 동시 쓰기의 최대 한계가 세그먼트 수(기본 16)로 고정된다는 단점이 있습니다.

 

Java 8 — 버킷 단위 CAS + synchronized

Java 8부터는 세그먼트를 완전히 제거했습니다. 버킷 상황에 따라 두 전략을 씁니다.

  • 버킷이 비어 있으면 Unsafe.compareAndSwapObject(CAS)로 락 없이 삽입
  • 이미 노드가 있으면 그 헤드 노드 하나만 synchronized로 잠가 처리

락의 단위가 세그먼트(n/16개)에서 버킷 1개로 줄어 동시 쓰기 가능 수가 극적으로 늘어났습니다.

 

 

CAS가 실패하면?

// JDK 내부 로직 (단순화)
if (f == null) {
    // 버킷이 비어 있음 → 락 없이 CAS 시도
    if (casTabAt(tab, i, null, new Node<>(hash, key, value)))
        break;  // 성공 → 종료
    // 실패 → 다른 스레드가 먼저 삽입 → 루프 재시도
} else {
    synchronized (f) {       // 헤드 노드만 잠금
        // linked list에 노드 추가
    }
}

CAS가 실패하면 OS 스케줄링 없이 루프 처음으로 돌아가 상태를 재확인합니다(스핀 방식). 버킷 수가 많을수록 충돌 확률이 낮아 CAS 성공률이 높아집니다.

 

Java 7 vs Java 8 핵심 비교

항목 Java 7 Java 8+
동기화 단위 세그먼트 (기본 16개) 버킷 헤드 노드 1개
빈 버킷 쓰기 세그먼트 락 필요 ✅ CAS — 락 불필요
동시 쓰기 상한 세그먼트 수 (16) ✅ 버킷 수 (최대 n)
락 구현 ReentrantLock synchronized + CAS
메모리 ⚠️ 세그먼트 항상 생성 버킷 레이지 초기화

 


5. CopyOnWriteArrayList / Set

쓰기 연산이 일어날 때마다 내부 배열 전체를 복사합니다. 덕분에 읽기는 완전히 락 프리(lock-free)이고 이터레이션 중 수정해도 ConcurrentModificationException이 발생하지 않습니다. 단, 쓰기 비용이 O(n)이므로 이벤트 리스너처럼 읽기가 압도적으로 많은 경우에 적합합니다.

CopyOnWriteArrayList<String> list = new CopyOnWriteArrayList<>();
list.add("A");  // 내부 배열 전체 복사 발생

// 이터레이션 중 수정해도 예외 없음 (스냅샷 기반 순회)
for (String s : list) {
    if (s.equals("A")) list.add("B");
}

// 이벤트 리스너 목록의 전형적인 패턴
CopyOnWriteArraySet<EventListener> listeners =
    new CopyOnWriteArraySet<>();

⚠️ 쓰기가 잦으면 쓰지 마세요. 요소가 1000개인 리스트에 초당 수백 번 쓰면 매번 1000개짜리 배열을 복사합니다.
쓰기가 잦다면 ConcurrentHashMap 기반 Set이나 ConcurrentLinkedQueue가 낫습니다.

 


6. BlockingQueue 계열

생산자-소비자 패턴 구현의 핵심입니다. 큐가 비어 있을 때 꺼내려 하거나, 꽉 찼을 때 넣으려 하면 자동으로 블로킹(대기)합니다. Thread.sleep()이나 수동 동기화 없이 스레드 간 작업 전달을 깔끔하게 구현할 수 있습니다.

BlockingQueue<Task> queue = new LinkedBlockingQueue<>(100);

// 생산자 — 꽉 차면 공간이 생길 때까지 대기
queue.put(task);
queue.offer(task, 1, TimeUnit.SECONDS); // 타임아웃 지정

// 소비자 — 비어 있으면 항목이 올 때까지 대기
Task t = queue.take();
Task t2 = queue.poll(500, TimeUnit.MILLISECONDS); // 타임아웃
클래스 특징 용량
LinkedBlockingQueue 노드 기반, 생산·소비 락 분리 선택적 제한
ArrayBlockingQueue 배열 기반, 단일 락 고정 필수
PriorityBlockingQueue 우선순위 정렬, 꺼내기만 블로킹 무제한
SynchronousQueue 버퍼 없음, 핸드오프 방식 0
DelayQueue 지연 시간 후에만 꺼내기 가능 무제한

 


7. ConcurrentSkipListMap / Set

스킵 리스트(Skip List) 자료구조 기반으로 정렬된 순서를 유지하면서 동시 접근을 지원합니다. TreeMapCollections.synchronizedMap()으로 감싼 것과 달리, 락을 세분화해 훨씬 높은 처리량을 냅니다.

ConcurrentSkipListMap<Integer, String> skipMap =
    new ConcurrentSkipListMap<>();

skipMap.put(3, "C");
skipMap.put(1, "A");
skipMap.put(2, "B");

skipMap.firstKey();    // 1 (정렬 유지)
skipMap.headMap(2);   // {1=A}
skipMap.tailMap(2);   // {2=B, 3=C}

 


8. 언제 무엇을 써야 할까?

상황 권장 클래스 성능
일반 캐시 / 카운터 맵 ConcurrentHashMap ✅ 높음
이벤트 리스너 목록 CopyOnWriteArrayList ⚠️ 읽기↑ 쓰기↓
생산자-소비자 작업 큐 LinkedBlockingQueue ✅ 높음
정렬된 동시 맵/셋 ConcurrentSkipListMap ⚠️ 중간
레거시 코드 빠른 패치 Collections.synchronized* ❌ 낮음