자바의 컬렉션들은 대부분 Thread Safe(여러 스레드가 동시에 접근해도 안전한가) 하지 않다. 컬렉션 내부의 메서드들은 대부분 원자적 연산이 아니기 때문이다.
일반적으로 사용하는 컬렉션을 멀티스레드 상황에서 사용하면 어떻게 되는지 보자.
public class ProxyTest {
public static void main(String[] args) throws InterruptedException {
List<String> list = new ArrayList<>();
test(list);
System.out.println("list = " + list + " size = " + list.size());
}
public static void test(Collection<String> collection) throws InterruptedException {
List<Thread> threads = new ArrayList<>();
for (int i = 1; i <= 10; i++) {
AddTask addTask = new AddTask(collection, "data" + i);
Thread thread = new Thread(addTask);
threads.add(thread);
thread.start();
}
for (Thread thread : threads) {
thread.join();
}
}
static class AddTask implements Runnable {
private final Collection<String> collection;
private final String data;
public AddTask(Collection<String> collection, String data) {
this.collection = collection;
this.data = data;
}
@Override
public void run() {
try {
Thread.sleep(500);
collection.add(data);
} catch (InterruptedException e) {
System.out.println("인터럽트 발생!");
}
}
}
}
출력 결과
list = [data10, data1, data5, data6, data8] size = 5
분명 10개의 스레드가 데이터를 넣었는데 실제 출력된 값은 5개만 값이 들어가 있다는 것을 확인할 수 있다.
위 출력 결과는 매 실행마다 달라지는데 이로써 일반적인 컬렉션은 Thread Safe 하지 않다는 것을 알 수 있다.
따라서 ArrayList, HashMap, HashSet 등의 많은 자료구조들은 동기화해서 사용해야 한다.
자바는 프록시 패턴을 적용하여 이런 자료구조들을 멀티 스레드 상황에서도 안전하게 사용할 수 있도록 한다.
프록시를 이용한 동기화
대리자라는 뜻으로 어떠한 요청을 대신 처리해주는 것을 의미한다.
즉 위 상황에서는 대신 동기화를 해주는 기능을 지원하는 역할을 한다고 생각하면 된다.
위 코드를 프록시 패턴으로 바꾸어 보겠다.
public class MyProxyCollection<T> implements Collection<T> {
private final Collection<T> collection;
public MyProxyCollection(Collection<T> collection) {
this.collection = collection;
}
@Override
public synchronized int size() {
return collection.size();
}
@Override
public synchronized boolean add(T t) {
return collection.add(t);
}
@Override
public synchronized String toString() {
return collection.toString();
}
...
}
컬렉션을 동기화하는 기능을 지원하기 위해 해당 프록시 역할 클래스는 같은 Collection 인터페이스를 구현한다.
그리고 내부에 실제로 호출이 되는 컬렉션을 외부에서 주입받도록 한다. → (의존관계 주입 DI)
그리고 오버라이딩 된 메서드에서 실제 컬렉션의 메서드를 호출하는데 여기서 중요한 것은 메서드 블록에 synchronized를 걸어 동기화한다는 것이다.
위 코드에서 내가 만든 프록시 역할 클래스에 실제 컬렉션을 주입하여 사용한다.
public static void main(String[] args) throws InterruptedException {
// List<String> list = new ArrayList<>();
Collection<String> list = new MyProxyCollection<>(new ArrayList<>());
test(list);
System.out.println("list = " + list + " size = " + list.size());
}
출력 결과
list = [data1, data4, data8, data7, data9, data6, data10, data2, data3, data5] size = 10
스레드 실행 순서에 따라 넣은 순서는 달라지겠지만 10개의 데이터는 계속 추가가 되는 것을 확인할 수 있다.
MyProxyCollection와 같이 중간에서 실제 객체의 대리인 역할을 하는 것이 프록시 패턴이다.
같은 컬렉션을 구현하였으므로 해당 컬렉션을 구현한 다른 자료구조들 역시 MyProxyCollection 프록시 클래스 하나로 동기화를 적용할 수 있다!
자바는 위에서 우리가 구현한 MyProxyCollection과 같이 컬렉션을 위한 동기화 프록시 기능을 지원한다.
public static void main(String[] args) throws InterruptedException {
// List<String> list = new ArrayList<>();
// Collection<String> list = new MyProxyCollection<>(new ArrayList<>());
List<String> list = Collections.synchronizedList(new ArrayList<>());
test(list);
System.out.println("list = " + list + " size = " + list.size());
}
Collections.synchronizedXxx 라는 유틸리티 메서드로 동기화 프록시 기능을 제공한다.
동기화 프록시 방식의 단점
synchronized - 메서드에 synchronized 선언하면 그 메서드가 포함된 객체에 lock이 걸림
→ 세부적인 동기화가 힘들고 잠금 범위가 넓어 락을 얻기 위해 대기하는 스레드들이 많아져 병렬처리도 힘들고 성능이 저하
java.util.concurrent 패키지 컬렉션
위 문제 해결을 위해 자바는 다양한 동기화 기법을 적용해서 성능을 최적화한 동시성 컬렉션들을 제공.
List
- CopyOnWriteArrayList → ArrayList 동시성 컬렉션
Set
- CopyOnWriteArraySet → HashSet 동시성 컬렉션
- ConcurrentSkipListSet → TreeSet 동시성 컬렉션
Map
- ConcurrentHashMap → HashMap 동시성 컬렉션
- ConcurrentSkipListMap → TreeMap 동시성 컬렉션
Queue
- ConcurrentLinkedQueue → 동시성 큐로 비차단(non-blocking) 큐
Deque
- ConcurrentLinkedDeque → 동시성 덱으로 비차단(non-blocking) 큐
스레드를 차단하는 블로킹 큐 (BlockingQueue) 구현체
- ArrayBlockingQueue
- 크기가 고정된 블로킹 큐로 공정 모드 사용이 가능하지만 사용 시 성능이 저하될 수 있음.
- LinkedBlockingQueue
- 크기가 고정된 블로킹 큐 뿐만 아니라 무한한 크기의 블로킹 큐도 만들 수 있음.
- PriorityBlockingQueue
- 우선순위에 따라 처리하는 블로킹 큐
- SynchronousQueue
- 데이터를 저장하지 않는 블로킹 큐로 생산자가 데이터를 추가하면 소비자가 해당 데이터를 받을 때까지 대기함.
- 즉 중간에 큐 없이 생산자, 소비자가 직접 데이터를 주고 받음.
- DelayQueue
- 지연된 요소를 처리하는 블로킹 큐
- 각 요소는 지정된 시간이 지난 후에야 소비가 가능.
- 주로 일정 시간이 지나고 작업을 처리해야 하는 스케줄링 작업에 사용.
LinkedHashSet, LinkedHashMap과 같이 입력 순서를 유지하는 동시성 컬렉션은 제공X
위에서 설명한 동시성 프록시 기능을 통해 만들어야 함.
Blocking vs Non-Blocking
Blocking | Non-Blocking | |
의미 | 작업이 완료될 때 까지 대기 | 작업 요청 후 즉시 반환하여 다른 작업을 수행 |
작업 처리 | 순차 처리하여 CPU 사용률이 낮아질 수 있음 | 비동기로 처리되며 다른 작업과 병행이 가능하여 CPU 사용률이 높음 |
응답성 | 느릴 수 있음 | 빠름 |
구현성 | 구현이 간단 | 구현이 복잡하며 일관성을 유지하기 위해 노력이 필요 |
사용 예 | 파일 입출력, 데이터베이스 쿼리 | 비동기 I/O, 이벤트 기반 프로그래밍 |
'JAVA' 카테고리의 다른 글
[자바] I/O 스트림 (0) | 2024.12.25 |
---|---|
[자바] 스레드 풀, Executor (0) | 2024.12.25 |
[자바] 원자적 연산, 동기화 (0) | 2024.12.25 |
[자바] Producer-Consumer Problem, BlockingQueue (0) | 2024.12.25 |
[자바] 동기화 락 (0) | 2024.12.25 |