금일은 java.util 패키지에 소속되어 있는 컬렉션 프레임워크에 대한 원자적인 연산에 대해 알아보겠습니다.
컬렉션 프레임워크가 제공하는 대부분의 연산은 원자적인 연산이 아닙니다.
private Object[] elementData;
@Override
public void add(Object e) {
elementData[size] = e;
sleep(100); // 멀티스레드 문제를 쉽게 확인하는 코드
size++;
}
해당코드에서의 배열이 add() 메서드를 호출할때 원자적이지 않은 연산이기 때문에 멀티스레드 상황에 안전하게 사용하려면 synchraonized , Lock 등을 사용해서 동기화를 해야 합니다.
멀티스레드 환경에서 배열의 동시성 문제에 대한 예시를 만들어 보겠습니다.
private static void test(SimpleList list) throws InterruptedException {
// A를 리스트에 저장하는 코드
Runnable addA = new Runnable() {
@Override
public void run() {
list.add("A");
log("Thread-1: list.add(A)");
}
};
// B를 리스트에 저장하는 코드
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();
}
@Override
public String toString() {
return Arrays.toString(Arrays.copyOf(elementData, size)) +
" size=" + size + ", capaity=" + elementData.length;
}
list 의 add() 메서드는 앞서의 함수이며 결과로는 [A, null] or [null, B] 값이 나오며 size 값은 2가 나오게 된다.
해당 이유로는 elementData[0] 값이 A 가 된다면 다음 스레드로 인해 elementData[0] 값이 B가 되는 것이다. 또한 size++ 해당 코드는 공유 변수이며 원자적 연산이 아니기 때문에 2로 갱신되는 것이다.
데이터의 정합성이 깨지게 되는것이고 sleep() 코드를 제거하도 여전히 동시성 문제는 발생합니다. 해당 문제를 쉽게 확인하기 위해 적용하였습니다.
따라서 대부분의 컬렉션 프레임워크는 스레드 세이프 하지 않습니다.
여러 스레드가 접근해야 한다면 syncronized, Lock 등을 통해 임계영억을 만들어 주면 된다.
@Override
public synchronized void add(Object e) {
elementData[size] = e;
sleep(100);
size++;
}
@Override
public synchronized String toString() {
return Arrays.toString(Arrays.copyOf(elementData, size)) +
" size=" + size + ", capaity=" + elementData.length;
}
다음 syncronized 해당 키워드를 통해서 멀티 스레드 환경에서 스레드가 락을 획득하기 위해 BLOCKED 상태로 대기 하게 됩니다.
컬렉션 프레임워크안의 코드들을 멀티스레드 상황에 동기화가 필요할때 syncronized 기능 대신에 사용하는 것이 프록시 이다.
즉 프록시가 동기화의 기능을 처리해주는 것이다.
프록시 역할을 하는 클래스 코드를 작성해 보겠습니다.
SyncProxyList
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 e) {
target.add(e);
}
@Override
public String toString() {
return target.toString() + " by " + this.getClass().getSimpleName();
}
}
BasicList
public class BasicList implements SimpleList {
private static final int DEFAULT_CAPATICY = 5;
private Object[] elementData;
private int size = 0;
public BasicList() {
elementData = new Object[DEFAULT_CAPATICY];
}
@Override
public int size() {
return size;
}
@Override
public void add(Object e) {
elementData[size] = e;
sleep(100); // 멀티스레드 문제를 쉽게 확인하는 코드
size++;
}
@Override
public String toString() {
return Arrays.toString(Arrays.copyOf(elementData, size)) +
" size=" + size + ", capaity=" + elementData.length;
}
}
Main method
public static void main(String[] args) throws InterruptedException {
test(new SyncProxyList(new BasicList()));
}
...
SyncProxyList 는 BasicList 와 같은 SimpleList 인터페이스를 구현한다.
SyncProxyList 는 생성자를 통해 SimpleList target 을 주입 받게 되고 해당 클래스의 역할은 모든 메서드에 synchronized 를 걸어주는 일 뿐이다. 그런 다음에 target 에 있는 같은 기능을 호출하게 됩니다.
Main methjod 에 아래의 코드 호출을 통해 synchronized 키워드가 없는 BasicList 도 동기화 문제를 해결 가능한 것이다.
test(new SyncProxyList(new BasicList()));
test() 함수는 SimpleList 추상화에 의존하게 되며 런타임 의존관계를 가집니다. 위의 코드를 분석해보면 다음과 같습니다.
앞서 만든 BasicList(x001) 의 참조를 SyncProxyList 의 생성자에 전달하며 SyncProxyList(x002) 가 만들어진다.
내부에는 원본 대상을 가르키는 target 변수를 포함하며 이 변수는 BasicList(x001) 의 참조를 보관한다.
즉 test() 메서드는 SyncProxyList(x002) 인스턴스를 사용하게 됩는 것이다. target 원본대상을 가지고 있기 때문에 BasicList 의 add() 를 호출하게 되는 것이다.
BasicList 를 전혀 손대지 않고, 프록시인 SyncProxyList 를 통해 동기화 기능을 적용 가능한 것이다.
객체 지향 디자인 페턴 중 하나로 proxy pattern 은 어떤 객체에 대한 접근을 제어하기 위해 그 객체의 대리인 또는 인터페이스 역학을 하는 객체를 제공하는 패턴입니다.
프록시 객체는 실제 객체에 대한 참조를 유지하면서, 그 객체에 접근하거나 행동을 수행하기 전에 추가적인 처리를 가능하게 합니다.
Spring 환경에서 aop 기능이 하나의 예시입니다. 주요 목적으로는 다음과 같습니다.
자바는 컬렉션을 위한 프록시 기능을 제공합니다.
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);
}
}
해당 코드의 실행결과로 아래와 같은 결과를 볼 수 있다.
class java.util.Collections$SynchronizedRandomAccessList
list = [data1, data2, data3]
Collections.synchronizedList(target)
public static <T> List<T> synchronizedList(List<T> list) {
return new SynchronizedRandomAccessList<>(list);
}
SynchronizedRandomAccessList 는 synchronized 를 추가하는 프록시 역할을 한다.
해당 메서드를 통해서 List, Collection, Map, Set 등 다양한 동기화 프록시를 만들 수 있다.
자바는 이러한 단점을 보안하기 위해 java.util.concurrent 패키지에 동시성 컬렉션을 제공합니다.
고성능 멀티스레드 환경을 지원하는 다양한 동시성 컬렉션 클래스들이 있고 종류는 아래와 같습니다.
List
Set
Map
Queue
Deque
참고로 입력 순서를 유지하는 LinkedHashSet, LinkedHashMap 처럼 동시에 멀티스레드 환경에서 사용할 수 있는 Set, Map 구현체는 제공하지 않는다.
다음으로 스레드를 차단하는 BlockingQueue 에 대해 알아 보겠습니다.
생산자와 소비자 간의 데이터를 주고 받을 때 사용합니다.
BlockingQueue
ArrayBlockingQueue
크기가 고정된 블로킹 큐
공정(fair) 모드를 사용할 수 있다. 공정(fair) 모드를 사용하면 성능이 저하될 수 있다.
LinkedBlockingQueue
크기가 무한하거나 고정된 블로킹 큐
PriorityBlockingQueue
우선순위가 높은 요소를 먼저 처리하는 블로킹 큐
SynchronousQueue
데이터를 저장하지 않는 블로킹 큐로, 생산자가 데이터를 추가하면 소비자가 그 데이터를 받을 때까지 대기한다.
생산자-소비자 간의 직접적인 핸드오프(hand-off) 메커니즘을 제공한다. 쉽게 이야기해서
중간에 큐 없이 생산자, 소비자가 직접 거래한다.
DelayQueue
지연된 요소를 처리하는 블로킹 큐로, 각 요소는 지정된 지연 시간이 지난 후에야 소비될 수 있다. 일
정 시간이 지난 후 작업을 처리해야 하는 스케줄링 작업에 사용된다.
동시성은 성능과 트레이드 오프가 있다 단일 스레드가 컬렉션을 사용하는 경우에는 일반 컬렉션을 사용해야 합니다.