ConcurrentHashMap — 동시성을 보장하는 해시맵의 내부
멀티스레드 환경에서 HashMap을 쓰면 무한 루프에 빠질 수 있다는데, ConcurrentHashMap은 어떻게 이 문제를 해결할까요?
왜 HashMap은 스레드 안전하지 않은가
HashMap은 동기화를 제공하지 않습니다. 여러 스레드가 동시에 put을 호출하면 다음 문제가 발생할 수 있습니다.
- **데이터 손실 **: 두 스레드가 같은 버킷에 동시에 삽입하면 하나가 사라질 수 있습니다
- ** 무한 루프 **: (Java 7 이하) 리해싱 중 연결 리스트가 순환 구조가 되어 CPU 100%
- ConcurrentModificationException: Iterator 사용 중 다른 스레드가 수정
// 위험한 코드: 멀티스레드에서 HashMap 사용
Map<String, Integer> map = new HashMap<>();
// 스레드 1과 2가 동시에 put → 데이터 손실 또는 무한 루프 가능
Thread t1 = new Thread(() -> { for (int i = 0; i < 10000; i++) map.put("key" + i, i); });
Thread t2 = new Thread(() -> { for (int i = 0; i < 10000; i++) map.put("key" + i, i); });
Hashtable — 초기 해결책
Java 1.0의 Hashtable은 모든 메서드에 synchronized를 걸어 해결했습니다.
// Hashtable 내부 (간략화)
public synchronized V get(Object key) { ... }
public synchronized V put(K key, V value) { ... }
public synchronized V remove(Object key) { ... }
문제점은 다음과 같습니다.
- ** 전체 테이블에 단일 락 **: 읽기와 쓰기 모두 같은 락을 공유
- 스레드 A가 get()하는 동안 스레드 B는 put()도 못 함
- ** 동시성 극히 낮음 **: 사실상 단일 스레드로 동작
ConcurrentHashMap — Java 7 (세그먼트 락)
Java 7의 ConcurrentHashMap은 테이블을 16개의 세그먼트 로 나누고, 세그먼트별로 독립적인 락을 사용했습니다.
Segment 0: [락] → 버킷 0~3
Segment 1: [락] → 버킷 4~7
Segment 2: [락] → 버킷 8~11
...
- 서로 다른 세그먼트에 접근하는 스레드는 동시에 실행 가능
- 최대 16개 스레드가 동시에 쓰기 가능
- Hashtable 대비 동시성이 크게 향상
ConcurrentHashMap — Java 8+ (CAS + synchronized)
Java 8에서 완전히 재설계되었습니다. 세그먼트를 없애고 버킷 단위의 세밀한 제어 를 도입했습니다.
핵심 메커니즘
// ConcurrentHashMap 내부 동작 (간략화)
final V putVal(K key, V value, boolean onlyIfAbsent) {
int hash = spread(key.hashCode());
for (Node<K,V>[] tab = table;;) {
Node<K,V> f; int n, i, fh;
if (tab == null || (n = tab.length) == 0)
tab = initTable(); // 초기화 (CAS로 동시성 제어)
else if ((f = tabAt(tab, i = (n - 1) & hash)) == null) {
// 빈 버킷 → CAS로 원자적 삽입 (락 없음!)
if (casTabAt(tab, i, null, new Node<>(hash, key, value)))
break;
}
else {
// 버킷에 이미 노드가 있음 → 해당 버킷 헤드에 synchronized
synchronized (f) {
// 연결 리스트 또는 트리에 삽입
if (tabAt(tab, i) == f) {
// ... 삽입 로직
}
}
}
}
}
CAS (Compare-And-Swap)
// CAS: 현재 값이 예상 값과 같으면 새 값으로 교체 (원자적)
boolean casTabAt(Node<K,V>[] tab, int i, Node<K,V> c, Node<K,V> v) {
return U.compareAndSetObject(tab,
((long)i << ASHIFT) + ABASE, c, v);
}
CAS는 CPU 레벨의 원자적 연산으로, 락을 사용하지 않고 동시성을 보장합니다.
읽기 연산 — 락 없음
public V get(Object key) {
// volatile 읽기만 사용 — 락 없음!
Node<K,V>[] tab; Node<K,V> e, p; int n, eh; K ek;
int h = spread(key.hashCode());
if ((tab = table) != null && (n = tab.length) > 0 &&
(e = tabAt(tab, (n - 1) & h)) != null) {
// ... 탐색
}
return null;
}
get()은 ** 락을 전혀 사용하지 않습니다 **. Node의 val과 next가 volatile이므로 항상 최신 값을 읽습니다.
HashMap vs Hashtable vs ConcurrentHashMap
| 특성 | HashMap | Hashtable | ConcurrentHashMap |
|---|---|---|---|
| 스레드 안전 | 아니오 | 예 | 예 |
| null 키 | 허용 | 불가 | 불가 |
| null 값 | 허용 | 불가 | 불가 |
| 락 방식 | 없음 | 전체 락 | 버킷 단위 |
| 읽기 락 | - | 있음 | ** 없음** |
| 성능 | 최고 (단일 스레드) | 최악 | 좋음 |
| Iterator | fail-fast | fail-safe | weakly consistent |
null 불허 이유
// HashMap에서는 이것이 가능
map.put("key", null);
map.get("key"); // null 반환 → 값이 null인가, 키가 없는가?
map.containsKey("key"); // true로 구분 가능
// ConcurrentHashMap에서는 동시성 때문에 이 패턴이 위험
// get과 containsKey 사이에 다른 스레드가 수정할 수 있음
if (map.get("key") == null) {
// 이 사이에 다른 스레드가 값을 넣었을 수 있음!
map.put("key", value);
}
원자적 복합 연산
ConcurrentHashMap은 여러 유용한 ** 원자적 복합 연산 **을 제공합니다.
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// putIfAbsent: 키가 없을 때만 삽입
map.putIfAbsent("counter", 0);
// compute: 키에 대한 값을 원자적으로 계산
map.compute("counter", (key, value) -> value == null ? 1 : value + 1);
// merge: 기존 값과 새 값을 합치기
map.merge("counter", 1, Integer::sum);
// computeIfAbsent: 키가 없을 때만 계산하여 삽입
map.computeIfAbsent("users", k -> new ArrayList<>()).add("Alice");
// computeIfPresent: 키가 있을 때만 값 변경
map.computeIfPresent("counter", (k, v) -> v + 10);
이 메서드들은 check-then-act 패턴을 원자적으로 수행하므로, 직접 synchronized를 쓸 필요가 없습니다.
Spring에서의 활용
ConcurrentMapCache
Spring의 간단한 캐시 구현은 ConcurrentHashMap을 사용합니다.
@Configuration
@EnableCaching
public class CacheConfig {
@Bean
public CacheManager cacheManager() {
SimpleCacheManager manager = new SimpleCacheManager();
// 내부적으로 ConcurrentHashMap을 사용하는 캐시
manager.setCaches(List.of(
new ConcurrentMapCache("users"),
new ConcurrentMapCache("products")
));
return manager;
}
}
@Service
public class UserService {
@Cacheable("users")
public User getUser(Long id) {
return userRepository.findById(id).orElseThrow();
}
}
Bean 등록 시 동시성
Spring 컨테이너는 빈 정의를 관리할 때 내부적으로 ConcurrentHashMap을 사용합니다.
// DefaultListableBeanFactory 내부
private final Map<String, BeanDefinition> beanDefinitionMap =
new ConcurrentHashMap<>(256);
요청 카운터 구현
@Component
public class RequestCounter {
private final ConcurrentHashMap<String, LongAdder> counters =
new ConcurrentHashMap<>();
public void increment(String endpoint) {
// computeIfAbsent로 원자적으로 초기화 + LongAdder로 경합 최소화
counters.computeIfAbsent(endpoint, k -> new LongAdder())
.increment();
}
public long getCount(String endpoint) {
LongAdder adder = counters.get(endpoint);
return adder != null ? adder.sum() : 0;
}
public Map<String, Long> getAllCounts() {
Map<String, Long> result = new HashMap<>();
counters.forEach((k, v) -> result.put(k, v.sum()));
return result;
}
}
주의사항
forEach/search/reduce 병렬 처리
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// parallelismThreshold: 병렬 처리를 시작할 최소 요소 수
// 1이면 항상 병렬, Long.MAX_VALUE면 항상 직렬
map.forEach(1, (key, value) -> {
System.out.println(key + ": " + value);
});
// 병렬 검색
String found = map.search(1, (key, value) ->
value > 100 ? key : null
);
// 병렬 리듀스
int total = map.reduce(1,
(key, value) -> value, // 변환
Integer::sum // 합산
);
size()의 비일관성
// size()는 추정치일 수 있음
int size = map.size(); // int 범위 초과 시 Integer.MAX_VALUE 반환
// 더 정확한 메서드
long count = map.mappingCount(); // long 반환
정리
- HashMap은 스레드 안전하지 않으며, 멀티스레드에서 데이터 손실이나 무한 루프가 발생할 수 있습니다.
- Hashtable은 전체 락으로 안전하지만 동시성이 극히 낮습니다.
- ConcurrentHashMap 은 빈 버킷에는 CAS, 충돌 시 버킷 단위 synchronized 로 높은 동시성을 제공합니다.
- **읽기에는 락이 없고 **, null 키/값을 허용하지 않습니다.
computeIfAbsent,merge등 ** 원자적 복합 연산 **으로 안전한 동시성 코드를 작성할 수 있습니다.- Spring에서는 캐시, 빈 관리, 요청 카운터 등에서 ConcurrentHashMap을 활용합니다.
댓글 로딩 중...