동시성 컬렉션이 필요한 이유 : 시작
java.util 패키지의 컬렉션 프레임워크는 원자적 연산을 제공할까?
예를 들어 하나의 ArrayList 인스턴스에 여러 스레드가 동시에 접근해도 괜찮을까? (= 스레드 세이프 할까?)
이를 이해하기 위해 간단한 컬렉션을 직접 만들어보자.
SimpleList : 인터페이스
package thread.collection.simple.list;
public interface SimpliList {
int size();
void add(Object e);
Object get(int index);
}
BasicList.java
package thread.collection.simple.list;
import java.util.Arrays;
import static util.ThreadUtils.sleep;
public class BasicList implements SimpliList {
private static final int DEFAULT_CAPACITY = 5;
private Object[] elementData;
private int size = 0;
public BasicList() {
elementData = new Object[DEFAULT_CAPACITY];
}
@Override
public int size() {
return size;
}
@Override
public void add(Object e) {
elementData[size] = e;
sleep(100); // 동시성 문제를 쉽게 확인하기 위해, size++;이 너무 빨리 호출되지 않도록
size++;
}
@Override
public Object get(int index) {
return elementData[index];
}
@Override
public String toString() {
return Arrays.toString(Arrays.copyOf(elementData, size)) + " size=" + size + ", capacity=" + elementData.length;
}
}
-> 여기서 add 메서드는 단순히 데이터를 하나 추가하는 기능을 제공하기에 원자적인 것처럼 보이나 원자적이지 않다.
-> 내부의 배열에 데이터를 추가해야 하고, size도 함께 증가시켜야 하고 size++ 연산 자체도 원자적이지 않다.
멀티스레드를 사용해 실제 어떤 문제가 발생하는지 확인해보자.
SimpleListMainV2.java
package thread.collection.simple.list;
import static util.MyLogger.log;
public class SimpleListMainV2 {
public static void main(String[] args) throws InterruptedException {
test(new BasicList());
}
private static void test(SimpliList list) throws InterruptedException {
log(list.getClass().getSimpleName());
Runnable addA = new Runnable() {
@Override
public void run() {
list.add("A");
log("Thread-1 : list.add(A)");
}
};
Runnable addB = new Runnable() {
@Override
public void run() {
list.add("B");
log("Thread-2 : list.add(B)");
}
};
Thread thread1 = new Thread(addA, "Thread-1");
Thread thread2 = new Thread(addB, "Thread-2");
thread1.start();
thread2.start();
thread1.join();
thread2.join();
log(list);
}
}
실행 결과

-> size는 2인데, 데이터는 B 하나만 입력되어 있음
-> 스레드 1, 2가 add 메서드의 elementData[size] = 0을 동시에 수행해 elementData[0] = A -> elementData[0] = B가 되어 최종적으로 B가 된다.
=> 컬렉션 프레임워크 대부분은 스레드 세이프 하지 않다.
=> 겉으로는 원자적 연산처럼 느껴지나 내부에서 수 많은 연산들이 함께 사용되고 있음
동시성 컬렉션이 필요한 이유 : 프록시 도입
SyncProxyList.java : 모든 메서드에 synchronized를 걸어주는 역할, target과 같은 기능 호출
package thread.collection.simple.list;
public class SyncProxyList implements SimpliList {
private SimpliList target;
public SyncProxyList(SimpliList target) {
this.target = target;
}
@Override
public synchronized int size() {
return target.size();
}
@Override
public synchronized void add(Object e) {
target.add(e);
}
@Override
public Object get(int index) {
return target.get(index);
}
@Override
public String toString() {
return target.toString() + " + by " + this.getClass().getSimpleName();
}
}
실행 결과


프록시 구조 분석
1. 정적 의존 관계

> test()가 클라이언트라고 가정하면 test()는 SimpleList라는 인터페이스에만 의존
> SimpleList 인터페이스 구현체인 BasicList, SyncList, SyncProxyList 중 어떤 것을 사용하던 클라이언트 코드는 변경하지 않아도 됨
2. 런타임 의존 관계

프록시 정리
1. 프록시인 SyncProxyList는 원본인 BasicList와 똑같은 SimpleList 구현
2. 클라이언트 입장에서는 프록시는 원본과 똑같이 생겼고, 호출할 메서드도 똑같음
3. 프록시는 내부에 원본을 가지고 있음 -> 필요한 일부의 일을 처리하고, 그 다음 원본을 호출하는 구조
4. 프록시가 동기화를 적용하고 원본을 호출하기에 원본 코드도 동기화가 적용된 상태로 호출됨
프록시 패턴(Proxy Pattern)
객체지향 디자인 패턴 중 하나로, 어떤 객체에 대한 접근을 제어하기 위해 그 객체의 대리인 또는 인터페이스 역할을 하는 객체를 제공하는 패턴
> 접근 제어
> 성능 향상
> 부가 기능 제공
자바 동시성 컬렉션 1 - synchronized
자바는 컬렉션을 위한 프록시 기능을 제공한다.
SynchronizedListMain.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) {
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);
}
}
실행 결과

Collections.synchronizedList(target)
: synchronized를 추가하는 프록시 역할
Collections.synchronizedList(new ArrayList<>()
안에 구현된 add() 메서드를 보면 synchronized 코드 블록을 적용하고 원본 대상의 add()를 호출하는 것을 볼 수 있다.

Collections는 다양한 synchronized 동기화 메서드를 지원한다.
> synchronizedList()
> synchronizedCollection()`
> synchronizedMap()
> synchronizedSet()`
> synchronizedNavigableMap()
> synchronizedNavigableSet()
> synchronizedSortedMap()
> synchronizedSortedSet()
synchronized 프록시 방식의 단점
1. 동기화 오버헤드 발생
: 메서드 호출 시마다 동기화 비용이 추가됨
2. 자바 컬렉션에 대해 동기화가 이뤄지기 때문에 잠금 범위가 넓어짐
: 모든 메서드에 동기화를 적용하다 보면, 특정 스레드가 컬렉션을 사용하고 있을 때 다른 스레드들이 대기해야 하는 상황이 빈번해짐
3. 정교한 동기화가 불가능
: 특정 부분이나 메서드에 대한 선택적 동기화가 어려움
=> 이 방식은 단순 무식하게 모든 메서드에 synchronized를 걸어버리는 것
=> 동기화 최적화가 이뤄지지 않음
=> 자바는 이를 보완하기 위해 java.util.concurrent 패키지에 대해 동시성 컬렉션을 제공한다.
자바 동시성 컬렉션 2 - 동시성 컬렉션
java.util.concurrent 패키지에는 고성능 멀티스레드 환경을 지원하는 다양한 동시성 컬렉션 클래스들을 제공한다.
더 정교한 잠금 메커니즘을 사용해 동시 접근을 효율적으로 처리하며, 필요한 경우 일부 메서드에 대해서만 동기화를 적용하는 등 유연한 동기화 전략을 제공한다.
동시성 컬렉션 종류
1. List
> CopyOnWriteArrayList -> ArrayList 대안
2. Set
> CopyOnWriteArraySet -> HashSet 대안
> ConcurrentSkipLisstSet -> TreeSet 대안
3. Map
> ConcurrentHashMap : HashMap 대안
> ConcurrentSkipListMap : TreeMap 대안
4. Queue
> ConcurrentLinkedQueue : 동시성 큐, 비 차단 큐
5. Deque
> ConcurrentLinkedDeque : 동시성 데크, 비 차단 큐
스레드를 차단하는 블로킹 큐(BlockingQueue)
1. ArrayBlockingQueue
> 크기가 고정된 블로킹 큐
> 공정 모드를 사용할 수 있음, 공정 모드 사용 시 성능이 저하될 수 있음
2. LinkedBlockingQueue
> 크기가 무한하거나 고정된 블로킹 큐
3. PriorityBlockingQueue
> 우선순위가 높은 요소를 먼저 처리하는 블로킹 큐
4. SynchronousQueue
> 데이터를 저장하지 않는 블로킹 큐
> 생산자가 데이터를 추가하면 소비자가 그 데이터를 받을 때까지 대기
> 생산자 - 소비자 간 직접적인 핸드오프 메커니즘 제공
5. DelayQueue
> 지연된 요소를 처리하는 블로킹 큐
> 각 요소는 지정된 지연 시간이 지난 후에야 소비될 수 있음
> 일정 시간이 지난 후 작업을 처리해야 하는 스케줄링 작업에 사용
=> 자바에서 제공하는 동시성 컬렉션은 멀티스레드 상황에 최적의 성능을 낼 수 있도록 다양한 최적화 기법이 적용되어 있다.
=> 단 동시성은 결국 성능과 트레이드 오프가 있기에, 단일 스레드가 컬렉션을 사용하는 경우에는 일반 컬렉션을 사용해야 한다.
'인프런 > 김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성' 카테고리의 다른 글
| [인프런] 김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 / 11. 스레드 풀과 Executor 프레임워크 2 (0) | 2025.04.02 |
|---|---|
| [인프런] 김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 / 11. 스레드 풀과 Executor 프레임워크 1 (0) | 2025.04.01 |
| [인프런] 김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 / 9. CAS - 동기화와 원자적 연산 (0) | 2025.03.31 |
| [인프런] 김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 / 8. 생산자 소비자 문제 2 (0) | 2025.03.27 |
| [인프런] 김영한의 실전 자바 - 고급 1편, 멀티스레드와 동시성 / 7. 생산자 소비자 문제 1 (0) | 2025.03.27 |