자바를 사용하다 보면 “왜 HashMap은 멀티스레드에서 문제가 생길까?”라는 질문이 생긴다.
같은 Map인데 왜 이렇게 차이가 날까?
이런 궁금증이 있다면 이 글을 끝까지 읽어보자.
처음 자바를 배울 때 이런 코드를 써봤을 것이다.
Map<String, Integer> map = new HashMap<>();
map.put("key1", 100);
map.put("key2", 200);
System.out.println(map.get("key1")); // 100
그런데 멀티스레드 환경에서는 문제가 생긴다.
여러 스레드가 동시에 HashMap을 조작하면 예상치 못한 결과가 나온다.
// 멀티스레드에서 HashMap 사용 시 문제
HashMap<String, Integer> map = new HashMap<>();
// 스레드 1
Thread t1 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
map.put("key" + i, i);
}
});
// 스레드 2
Thread t2 = new Thread(() -> {
for (int i = 0; i < 1000; i++) {
map.put("key" + i, i * 2);
}
});
t1.start();
t2.start();
// 결과를 예측할 수 없다!
“왜 같은 Map인데 멀티스레드에서는 문제가 생길까?”
많은 개발자들이 이 차이점을 헷갈려한다.
HashMap과 ConcurrentHashMap의 차이는 동기화에 있다.
HashMap은 단일 스레드를 위해 만들어진 녀석이다.
동기화란 게 전혀 없다. 그래서 빠르다.
하지만 여러 스레드가 동시에 건드리면 문제가 생긴다.
HashMap<String, Integer> map = new HashMap<>();
// 내부적으로 동기화 처리가 전혀 없다
반면 ConcurrentHashMap은 멀티스레드를 염두에 두고 설계되었다.
전체를 잠그지 않고 부분적으로만 동기화한다.
안전성과 성능을 모두 잡으려고 노력한 결과물이다.
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// 내부적으로 세그먼트별 동기화 처리
HashMap은 단순하다. 배열과 연결리스트로만 구성되어 있다.
HashMap:
┌─────────────────────────────────────────┐
│ [0] → Node → Node → null │
│ [1] → null │
│ [2] → Node → null │
│ [3] → Node → Node → Node → null │
│ ... │
│ [15] → null │
└─────────────────────────────────────────┘
모든 연산이 동기화 없이 수행된다.
여러 스레드가 동시에 접근하면 데이터가 꼬인다.
ConcurrentHashMap은 더 복잡하다. 세그먼트로 나누어 관리한다.
ConcurrentHashMap:
┌─────────────────────────────────────────┐
│ Segment[0] → [0] → Node → Node → null │
│ Segment[1] → [1] → null │
│ Segment[2] → [2] → Node → null │
│ Segment[3] → [3] → Node → Node → Node │
│ ... │
│ Segment[15] → [15] → null │
└─────────────────────────────────────────┘
각 세그먼트는 독립적으로 동기화된다.
전체를 잠그지 않고 필요한 부분만 잠근다.
HashMap을 멀티스레드에서 사용하면 여러 문제가 생긴다.
HashMap<Integer, String> map = new HashMap<>();
// 스레드 1: put 연산
Thread t1 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
map.put(i, "value" + i);
}
});
// 스레드 2: put 연산
Thread t2 = new Thread(() -> {
for (int i = 0; i < 100000; i++) {
map.put(i, "value" + i);
}
});
왜 무한 루프가 발생할까?
HashMap의 내부 배열이 확장될 때 리사이징이 발생한다.
여러 스레드가 동시에 리사이징을 시도하면 연결리스트가 꼬인다.
순환 참조가 생기면서 무한 루프에 빠진다.
HashMap<String, Integer> map = new HashMap<>();
// 스레드 1
Thread t1 = new Thread(() -> {
map.put("key", 100);
});
// 스레드 2
Thread t2 = new Thread(() -> {
map.put("key", 200);
});
왜 데이터가 손실될까?
두 스레드가 동시에 같은 키에 값을 저장하면
하나의 값이 덮어써지거나 아예 사라질 수 있다.
동기화가 없어서 원자성이 보장되지 않는다.
HashMap<String, Integer> map = new HashMap<>();
map.put("key", null); // 정상 동작
ConcurrentHashMap<String, Integer> concurrentMap = new ConcurrentHashMap<>();
concurrentMap.put("key", null); // NullPointerException!
왜 ConcurrentHashMap은 null을 허용하지 않을까?
null 값을 허용하면 “값이 없음”과 “값이 null”을 구분할 수 없다.
멀티스레드 환경에서 이 구분이 중요하다.
따라서 ConcurrentHashMap은 null 값을 완전히 금지한다.
ConcurrentHashMap은 여러 기법을 사용해서 문제를 해결했다.
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// 여러 스레드가 동시에 접근해도 안전
Thread t1 = new Thread(() -> {
map.put("key1", 100);
});
Thread t2 = new Thread(() -> {
map.put("key2", 200);
});
ConcurrentHashMap은 전체를 잠그지 않는다.
세그먼트별로 독립적인 락을 사용한다.
서로 다른 세그먼트에 접근하는 스레드들은 동시에 실행될 수 있다.
// Compare-And-Swap 연산
// 기대값과 현재값을 비교하여 같으면 새 값으로 교체
if (currentValue == expectedValue) {
currentValue = newValue;
return true;
} else {
return false;
}
CAS 연산은 락 없이도 원자성을 보장한다.
하드웨어 레벨에서 지원하는 원자적 연산이다.
락보다 훨씬 빠르고 효율적이다.
// HashMap 읽기
HashMap<String, Integer> hashMap = new HashMap<>();
long start = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
hashMap.get("key" + i);
}
long end = System.currentTimeMillis();
// ConcurrentHashMap 읽기
ConcurrentHashMap<String, Integer> concurrentMap = new ConcurrentHashMap<>();
long start2 = System.currentTimeMillis();
for (int i = 0; i < 1000000; i++) {
concurrentMap.get("key" + i);
}
long end2 = System.currentTimeMillis();
단일 스레드에서는 HashMap이 더 빠르다.
동기화 오버헤드가 없기 때문이다.
ConcurrentHashMap은 약간의 오버헤드가 있다.
// HashMap 쓰기 (단일 스레드)
HashMap<String, Integer> hashMap = new HashMap<>();
for (int i = 0; i < 1000000; i++) {
hashMap.put("key" + i, i);
}
// ConcurrentHashMap 쓰기 (멀티스레드)
ConcurrentHashMap<String, Integer> concurrentMap = new ConcurrentHashMap<>();
// 여러 스레드가 동시에 안전하게 쓰기 가능
멀티스레드 환경에서는 ConcurrentHashMap이 훨씬 빠르다.
HashMap은 동시성 문제로 인해 성능이 급격히 떨어진다.
ConcurrentHashMap은 병렬 처리가 가능하다.
public class HashMapExample {
private static HashMap<String, Integer> map = new HashMap<>();
public static void main(String[] args) throws InterruptedException {
// 여러 스레드가 동시에 접근
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
final int threadId = i;
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
String key = "thread" + threadId + "_key" + j;
map.put(key, j);
}
});
}
for (Thread thread : threads) {
thread.start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println("HashMap size: " + map.size());
// 예상: 10000, 실제: 예측 불가능
}
}
public class ConcurrentHashMapExample {
private static ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
public static void main(String[] args) throws InterruptedException {
// 여러 스레드가 동시에 접근
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
final int threadId = i;
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
String key = "thread" + threadId + "_key" + j;
map.put(key, j);
}
});
}
for (Thread thread : threads) {
thread.start();
}
for (Thread thread : threads) {
thread.join();
}
System.out.println("ConcurrentHashMap size: " + map.size());
// 예상: 10000, 실제: 10000 (정확함)
}
}
// 단일 스레드에서 사용
public class DataProcessor {
private HashMap<String, Object> cache = new HashMap<>();
public void processData(List<String> data) {
// 한 번에 하나의 스레드만 실행
for (String item : data) {
cache.put(item, processItem(item));
}
}
}
// 멀티스레드에서 사용
public class ConcurrentDataProcessor {
private ConcurrentHashMap<String, Object> cache = new ConcurrentHashMap<>();
public void processDataConcurrently(List<String> data) {
data.parallelStream().forEach(item -> {
// 여러 스레드가 동시에 안전하게 접근
cache.put(item, processItem(item));
});
}
}
// 잘못된 예
private static HashMap<String, Integer> map = new HashMap<>();
public void addData(String key, Integer value) {
map.put(key, value); // 멀티스레드에서 위험!
}
// 올바른 예
private static ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
public void addData(String key, Integer value) {
map.put(key, value); // 멀티스레드에서 안전
}
// 잘못된 예
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
map.put("key", null); // NullPointerException!
// 올바른 예
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
if (value != null) {
map.put("key", value);
} else {
map.remove("key"); // null 대신 remove 사용
}
// 잘못된 생각
// "ConcurrentHashMap이 항상 느리다"
// 실제로는 상황에 따라 다름
// 단일 스레드: HashMap이 빠름
// 멀티스레드: ConcurrentHashMap이 훨씬 빠름
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// 원자적으로 값 증가
map.compute("counter", (key, value) -> value == null ? 1 : value + 1);
// 조건부 업데이트
map.computeIfAbsent("key", k -> 0);
map.computeIfPresent("key", (k, v) -> v + 1);
ConcurrentHashMap<String, Integer> map = new ConcurrentHashMap<>();
// 병렬로 모든 값 처리
map.forEach(1, (key, value) -> {
System.out.println(key + " = " + value);
});
// 병렬로 검색
String result = map.search(1, (key, value) -> {
return value > 100 ? key : null;
});
HashMap과 ConcurrentHashMap의 차이를 이해하는 건 자바의 핵심이다.
단일 스레드와 멀티스레드 환경의 특성을 알고, 상황에 맞게 선택하는 것이 중요하다.
기억하자