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 — 사용법
가장 중요한 포인트는 원자적 복합 연산을 적극 활용하는 것입니다. get → put처럼 두 번에 나눠 호출하면 스레드 안전하지 않습니다.
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) 자료구조 기반으로 정렬된 순서를 유지하면서 동시 접근을 지원합니다. TreeMap을 Collections.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* |
❌ 낮음 |
'Language > Java' 카테고리의 다른 글
| [Java] JVM 클래스 로딩 Deep Dive 1편 — Loading & Linking (0) | 2026.05.17 |
|---|---|
| [Java] CountDownLatch 완벽 정리 — 멀티스레드 동기화의 핵심 (0) | 2026.05.17 |
| [JAVA] Java Concurrent 패키지 - Atomic 자료형 완전 정리 (1) | 2026.05.14 |
| [Java] Java 비동기 결과 처리의 진화: Callable → Future → CompletableFuture (0) | 2026.05.14 |
| [Java] Java 8 — 메서드를 일급 시민으로 (0) | 2026.03.23 |