컬렉션 프레임워크는 원자적인 연산을 제공하여 하나의 ArrayList 인스턴스에 여러 스레드가 동시에 접근할 수 있습니다. 원자적인 연산처럼 느껴지기 때문에 안전하게 데이터가 저장될 것 같지만 컬렉션 프레임워크가 제공하는 대부분의 연산은 원자적인 연산이 아닙니다.
그래서 간단하게 synchronized
를 구현체에 넣어주면 임계 영역이 만들어져 한 번에 하나의 스레드만 add()
메서드를 수행합니다.
단일 스레드라면 그냥 구현하면 되지만 멀티 스레드라면 또 하나의 클래스를 만들어서 synchronized
키워드를 넣어주고 List도 만들어줘야 합니다. -> 모든 컬렉션을 다 복사해서 새로 만듦.
즉 단일 스레드를 해결하는 클래스를 만들었는데 멀티스레드 환경으로 있는 다른 스레드들을 위해 synchronized
를 추가한 클래스를 복사하면 그 안에 있는 컬렉션 프레임워크들도 똑같이 추가가 됩니다.
멀티스레드 환경에서 구현이 변경될 때 같은 모양의 코드를 2곳에서 변경해야 하는 경우가 생기기 때문에 기존 코드를 그대로 사용하면서 synchronized
만 살짝 넣어서 추가하고 싶을 때 사용합니다.
프록시가 대신 동기화 기능을 처리해줄 수 있습니다.
public class SyncProxyList implements SimpleList {
private SimpleList target;
public SyncProxyList(SimpleList target) {
this.target = target;
}
@Override
public synchronized int size() {
return target.size();
}
@Override
public synchronized void add(Object o) {
target.add(o);
}
@Override
public synchronized Object get(int index) {
return target.get(index);
}
@Override
public String toString() {
return target.toString() + " + by " + this.getClass().getSimpleName();
}
}
기존과 다른 점은 target이 있냐 없느냐의 차이와 기존 거는 size, List 등을 필드로 생성했음.
실행 결과
11:23:10.826 [ main] list = SyncProxyList
11:23:10.933 [ Thread-2] Thread-2: list.add(B)
11:23:11.038 [ Thread-1] Thread-1: list.add(A)
11:23:11.047 [ main] list = [B, A] size = 2, capacity = 5 + by SyncProxyList
변경 구조 : 클라이언트 -> SyncProxyList(프록시) -> BasicList(서버)
로직을 실행시키는 test()
를 클라이언트라고 하면 클라이언트는 SimpleList 인터페이스에만 의존하고(추상화에 의존) 구현체인 BasicList
, SyncList
, SyncProxyList
중에 어떤 것을 사용하든, 클라이언트인 test()
코드는 전혀 변경하지 않아도 됩니다.
그래서 유연하게 어떤 구현체든지 다 받아들일 수 있습니다. -> 어떤 구현체가 넘어오는지 test()
입장에서는 모르기 때문에.
test()
입장에서는 그냥 SimpleList의 구현체 중에 하나가 전달되었다고 생각될 뿐입니다. 인터페이스가 같아서 proxy는 원본과 같고 호출할 메서드도 똑같게 됩니다.
프록시가 동기화를 적용하고 원본을 호출하기 때문에 원본 코드도 이미 동기화가 적용된 상태로 호출됩니다.
SyncProxyList
이 프록시 하나 SimpleList
인터페이스의 모든 구현체를 동기화할 수 있습니다.
과정 순서
처음부터 모든 자료구조에 동기화를 사용하면 성능과 트레이드 오프가 있습니다. 항상 컬렉션이 멀티스레드 환경에서 사용되는 것도 아니고 미리 동기화를 한다면 단일 스레드에서 사용할 때 동기화로 인해 성능이 저하됩니다. 단일 스레드인데 동기화를 한다면 락을 흭득, 반납 과정이 생기기 때문에
그래서 컬렉션의 주요 인터페이스를 구현해서 synchronized
를 적용할 수 있는 프록시를 만들면 됩니다.
public static void main(String[] args) {
// 동기화가 된 synchronized가 적용된 컬렉션
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);
}
new SynchronizedRandomAccessList<>(new ArrayList())
이것과 같은 코드인데 SynchronizedRandomAccessList
는 동기화를 추가하는 proxy역할을 합니다. 그래서 synchronized
를 걸고 그 다음에 ArrayList를 호출합니다. -> 따로 구현체로 만들어줬던 add()
메서드와 같은 역할을 함
이렇게 해서 편리하게 코드 한 줄로 스레드를 안전한 컬렉션으로 변경해서 사용할 수가 있습니다.
1. 동기화 오버헤드
synchronized
를 걸고 add()
하기 때문에 이 메서드를 최적화할 수가 없다.synchronized
를 안 걸어도 되는 부분에도 다 걸어버림synchronized
영역을 줄이는 것이 불가능프록시에서의 단점때문에 자바에서는 java.util.concurrent
패키지에 동시성 컬렉션을 제공합니다.
디테일하게 하나씩 컬렉션마다 별도의 컬렉션을 제공합니다. -> 성능을 최대한 최적화한 컬렉션들
ConcurrentHashMap
, CopyOnWriteArrayList
, BlockingQueue
등이 이에 해당합니다. 일부 메서드에 대해서만 동기화를 적용하는 등 유연한 동기화 전략이 가능.