Java 컬렉션의 근본적인 특징
java.util 패키지의 컬렉션 프레임워크는 기본적으로 Thread-safe하지 않다. 그렇다면 왜 처음부터 모든 컬렉션에 synchronized를 걸어두지 않았을까? 그 이유는 동기화는 성능 비용을 수반하기 때문이다
- synchronized, Lock, CAS 등 모든 동기화 기법은 성능과 트레이드오프 관계
- 동기화를 하지 않는 것이 가장 빠름
- 컬렉션이 항상 멀티스레드 환경에서 사용되는 것은 아님
- 단일 스레드 환경에서도 동기화 비용이 발생하면 불필요한 성능 저하
Vector의 교훈
Java는 과거 이런 실수를 했다
// java.util.Vector - 모든 메서드가 synchronized
public class Vector<E> {
public synchronized boolean add(E e) { ... }
public synchronized E get(int index) { ... }
public synchronized E remove(int index) { ... }
// ... 모든 메서드에 synchronized
}
결과
- 단일 스레드 환경에서도 불필요한 동기화 비용 발생
- ArrayList에 비해 성능 저하
- 현재는 하위 호환성을 위해서만 유지
- 사용을 권장하지 않는다
교훈: 동기화의 필요성을 정확히 판단하고 필요한 경우에만 적용해야 한다
Collections.synchronizedXxx() – 프록시 방식의 동기화
Java가 제공하는 프록시 솔루션
package thread.collection.java;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class SynchronizedListMain {
public static void main(String[] args) {
// 일반 ArrayList를 동기화된 리스트로 변환
List<String> list = Collections.synchronizedList(new ArrayList<>());
list.add("data1");
list.add("data2");
list.add("data3");
System.out.println(list.getClass());
System.out.println("list = " + list);
}
}
실행 결과
class java.util.Collections$SynchronizedRandomAccessList list = [data1, data2, data3]
Collections.synchronizedList()의 동작
public static <T> List<T> synchronizedList(List<T> list) {
return new SynchronizedRandomAccessList<>(list);
}
이는 다음과 같은 구조이다
new SynchronizedRandomAccessList<>(new ArrayList<>())
프록시 패턴 적용
Client (사용자 코드) ↓ SynchronizedRandomAccessList (프록시) ↓ synchronized 적용 후 호출 ArrayList (원본)
실제 구현 예시
// SynchronizedList의 add() 메서드
public boolean add(E e) {
synchronized (mutex) { // 1. lock 획득
return c.add(e); // 2. 원본 호출
} // 3. lock 반납
}
구현했던 SyncProxyList와 완전히 동일한 패턴이다
제공되는 동기화 메서드들
// List Collections.synchronizedList(new ArrayList<>()); Collections.synchronizedList(new LinkedList<>()); // Set Collections.synchronizedSet(new HashSet<>()); Collections.synchronizedSet(new TreeSet<>()); // Map Collections.synchronizedMap(new HashMap<>()); Collections.synchronizedMap(new TreeMap<>()); // Collection Collections.synchronizedCollection(collection); // 정렬된 컬렉션 Collections.synchronizedSortedSet(new TreeSet<>()); Collections.synchronizedSortedMap(new TreeMap<>()); Collections.synchronizedNavigableSet(new TreeSet<>()); Collections.synchronizedNavigableMap(new TreeMap<>());
장점
- 코드 한 줄로 해결: 기존 코드를 전혀 수정하지 않고 동기화 적용
- 유연성: 필요할 때만 동기화 적용 가능
- 호환성: 모든 컬렉션 타입 지원
// 단일 스레드 환경 List<String> singleThreadList = new ArrayList<>(); // 멀티스레드 환경으로 전환 필요 시 List<String> multiThreadList = Collections.synchronizedList(singleThreadList);
실제 사용 예시
public class UserCache {
// 멀티스레드 환경에서 안전하게 사용
private final Map<String, User> userMap =
Collections.synchronizedMap(new HashMap<>());
public void addUser(User user) {
userMap.put(user.getId(), user);
}
public User getUser(String id) {
return userMap.get(id);
}
}
synchronized 프록시 방식의 한계
하지만 이 방식에는 치명적인 단점들이 있다. 실무에서는 이러한 이유로 다른 방법을 선호한다
동기화 오버헤드
public synchronized void add(Object e) {
// 모든 호출마다 lock 획득/반납 비용 발생
target.add(e);
}
문제점
- 각 메서드 호출마다 동기화 비용 추가
- 단순한 작업에도 오버헤드 발생
- 누적되면 상당한 성능 저하
성능 측정 예시
// 벤치마크 (100만 번 add 작업) ArrayList: 50ms SynchronizedList: 150ms (3배 느림)
잠금 범위가 너무 넓다 – 가장 큰 문제
// 프록시 방식 - 메서드 전체가 임계 영역
public synchronized void complexOperation() {
synchronized (mutex) {
// 동기화가 필요 없는 부분
prepare(); // ← 여기도 lock
// 실제로 동기화가 필요한 부분
criticalSection(); // ← 여기만 필요
// 동기화가 필요 없는 부분
cleanup(); // ← 여기도 lock
}
}
문제점
- 메서드 전체에 lock이 걸림
- 불필요한 부분까지 동기화됨
- 잠금 경합 (Lock Contention)증가
- 병렬 처리 효율성 저하
실제 영향
// Thread-1이 synchronized 메서드 실행 중 Thread-1: [====== 10초 작업 중 ======] // Thread-2, 3, 4는 모두 대기 Thread-2: [......대기......][실행] Thread-3: [......대기............][실행] Thread-4: [......대기..................][실행]
정교한 동기화 불가능
프록시는 메서드 단위로만 동기화를 적용하기 때문에 내부 최적화가 불가능하다
// 최적화된 구현을 하고 싶지만...
public void optimizedAdd(E e) {
// 동기화 불필요한 전처리
E element = validate(e);
E transformed = transform(element);
synchronized (this) {
// 꼭 필요한 부분만 동기화
internalArray[size++] = transformed;
}
// 동기화 불필요한 후처리
notifyListeners(transformed);
}
// 하지만 프록시 방식은...
public synchronized void add(E e) {
target.add(e); // 원본의 모든 작업이 lock 안에서 실행
}
한계
- 내부 최적화 불가능
- 특정 부분만 선택적으로 동기화할 수 없음
- 과도한 동기화로 성능 저하
복합 연산의 동기화 문제
개별 메서드는 동기화되어도, 복합 연산은 여전히 위험하다
Map<String, Integer> map = Collections.synchronizedMap(new HashMap<>());
// 잘못된 예 - 이 전체가 원자적이지 않음
if (!map.containsKey("count")) { // ← synchronized
map.put("count", 1); // ← synchronized
} else {
int count = map.get("count"); // ← synchronized
map.put("count", count + 1); // ← synchronized
}
// 각 메서드는 동기화되지만, 전체 로직은 동기화되지 않음
// 올바른 해결책 (추가 동기화 필요)
synchronized (map) {
if (!map.containsKey("count")) {
map.put("count", 1);
} else {
int count = map.get("count");
map.put("count", count + 1);
}
}
실제 성능 비교
// 벤치마크: 4개 스레드, 각 100만 번 작업 환경 소요 시간 처리량 ───────────────────────────────────────── 단일 스레드 ArrayList 100ms 10M ops/s SynchronizedList 800ms 1.25M ops/s (8배 느림) ConcurrentHashMap 200ms 5M ops/s (4배 빠름)
핵심 문제: 단순무식하게 모든 메서드에 synchronized를 거는 방식이라 동기화 최적화가 전혀 이루어지지 않는다
반복자(Iterator) 문제
List<String> list = Collections.synchronizedList(new ArrayList<>());
// 위험한 코드
for (String item : list) {
// 반복 중에는 명시적으로 동기화 필요
list.remove(item); // ConcurrentModificationException 발생 가능
}
// 올바른 코드
synchronized (list) {
for (String item : list) {
list.remove(item);
}
}
고성능 동시성 컬렉션
synchronized 방식의 한계를 극복하려면?
Java는 이러한 한계를 극복하기 위해 정교하게 최적화된 동시성 컬렉션을 제공한다
java.util.concurrent 패키지의 특징
정교한 잠금 메커니즘
// ConcurrentHashMap: 세그먼트별 락 전체 맵을 16개 세그먼트로 분할 ┌──┬──┬──┬──┐ │S1│S2│S3│S4│ 각 세그먼트는 독립적으로 락 └──┴──┴──┴──┘ → 최대 16개 스레드가 동시 작업 가능!
다양한 최적화 기법 조합
- synchronized
- ReentrantLock
- CAS (Compare-And-Swap)
- Volatile 변수
- 분할 잠금 (Segment Lock)
- 락-프리 (Lock-Free) 알고리즘
선택적 동기화
- 필요한 부분만 동기화
- 읽기 작업은 동기화 없이 수행 (일부)
- 최소한의 잠금 범위
성능 차이 체감하기
// Collections.synchronizedMap
Map<String, String> syncMap =
Collections.synchronizedMap(new HashMap<>());
// 전체 맵에 하나의 lock
// 한 번에 한 스레드만 접근 가능
// ConcurrentHashMap
Map<String, String> concurrentMap =
new ConcurrentHashMap<>();
// 세그먼트별 lock
// 여러 스레드가 동시에 서로 다른 세그먼트 접근 가능
// 4~8배 빠른 성능