염몽 개발일지
article thumbnail
Published 2024. 8. 31. 00:01
Java. 동시성 컬렉션 Study/Java

동시성 컬렉션

동시성 컬렉션이 필요한 이유

package thread.collection.simple.list;

public interface SimpleList {
    int size();

    void add(Object e);

    Object get(int index);
}
package thread.collection.simple.list;

import java.util.Arrays;

import static util.ThreadUtils.sleep;

public class BasicList implements SimpleList {

    private static final int DEFAULT_CAPACITY = 5;
    private Object[] elementData;
    private int size = 0;

    public BasicList() {
        this.elementData = new Object[DEFAULT_CAPACITY];
    }

    @Override
    public int size() {
        return size;
    }

    @Override
    public void add(Object e) {
        elementData[size] = e;
        sleep(100); // 멀티스레드 문제를 쉽게 확인하는 코드
        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;
    }
}

위의 코드는 예전에 직접 ArrayList 클래스를 파악하기 위해 구현해본 것의 아주 일부 기능만 구현해보았다.

package thread.collection.simple.list;

import util.MyLogger;

import static util.MyLogger.log;

public class SimpleListMainV2 {
    public static void main(String[] args) throws InterruptedException {
        //test(new BasicList());
        //test(new SyncList());
        test(new SyncProxyList(new BasicList()));
    }

    private static void test(SimpleList list) throws InterruptedException {
        log(list.getClass().getSimpleName());

        // 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();

        log(list);

    }
}

동시에 여러 스레드가 값을 넣으려고 하면 동시성 문제로 size의 문제가 생기는 것은 물론 값의 대입도 문제가 발생한다.
이를 위해 synchronized와 같은 동기화를 도입하면 된다.
하지만, 클래스를 또 하나 더 만들어서 동기화가 적용된 클래스를 만들어야할까?

아니다. 프록시 패턴을 도입하면 된다!

package thread.collection.simple.list;

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 synchronized Object get(int index) {
        return target.get(index);
    }

    @Override
    public synchronized String toString() {
        return target.toString() + " by " + this.getClass().getSimpleName();
    }
}
test(new SyncProxyList(new BasicList()));

김영한의 실전 자바 고급 1편 - 인프런

SimpleList의 인터페이스를 모두 사용하기 때문에, 단지 SyncProxyList 클래스만 추가하면 된다.
이것이 바로 객체지향의 장점이다.

 

프록시 패턴

프록시 패턴(Proxy Pattern)은 객체에 대한 접근을 제어하기 위해 대리 객체를 제공하는 객체지향 디자인 패턴이다. 프록시 객체는 실제 객체에 대한 참조를 유지하면서, 접근이나 행동 전에 추가적인 처리를 수행할 수 있다.

주요 목적:

  1. 접근 제어: 실제 객체에 대한 접근을 제한하거나 통제할 수 있다.
  2. 성능 향상: 객체 생성을 지연시키거나 캐싱하여 성능을 최적화할 수 있다.
  3. 부가 기능 제공: 실제 객체에 로깅, 인증, 동기화 등 추가 기능을 투명하게 제공할 수 있다.

이제 자바에서는 어떻게 위의 방법을 적용했을까?
자바에서는 동시성 문제를 해결하기 위해 프록시 기법을 적용한 Collections.synchronizedXxx()를 제공한다.

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);


    }
}

 

원본을 사용하되, 프록시로 synchronized를 적용하고, 원본의 기능을 사용한다.

하지만, 모든 메소드에 synchronized 를 걸어버리기 때문에, 성능이 좋지 않다.

 

이를 해결한 것이 바로 concurrent 패키지의 동시성 컬렉션이다.

이 컬렉션은 synchronized, Lock(ReentrantLock), CAS, 분할 잠금 기술(segment lock) 등 다양한 기법을 사용한다.

동시성 컬렉션의 종류

List:

  • CopyOnWriteArrayList: ArrayList의 대안

Set:

  • CopyOnWriteArraySet: HashSet의 대안
  • ConcurrentSkipListSet: TreeSet의 대안 (정렬된 순서 유지, Comparator 사용 가능)

Map:

  • ConcurrentHashMap: HashMap의 대안
  • ConcurrentSkipListMap: TreeMap의 대안 (정렬된 순서 유지, Comparator 사용 가능)

Queue:

  • ConcurrentLinkedQueue: 동시성 큐, 비 차단(non-blocking) 큐이다.

Deque:

  • ConcurrentLinkedDeque: 동시성 데크, 비 차단(non-blocking) 큐이다.

BlockingQueue:

  • ArrayBlockingQueue: 크기가 고정된 블로킹 큐. 공정(fair) 모드 사용 가능.
  • LinkedBlockingQueue: 크기가 무한하거나 고정된 블로킹 큐.
  • PriorityBlockingQueue: 우선순위가 높은 요소를 먼저 처리하는 블로킹 큐.
  • SynchronousQueue: 데이터를 저장하지 않는 블로킹 큐, 생산자와 소비자가 직접 데이터를 교환.
  • DelayQueue: 지연된 요소를 처리하는 블로킹 큐, 일정 시간이 지난 후 작업을 처리하는 데 사용.

참고로 LinkedHashSet, LinkedHashMap처럼 입력 순서를 유지하는 동시성 컬렉션은 제공하지 않으며, 필요한 경우 Collections.synchronizedXXX()를 사용해야 한다.

profile

염몽 개발일지

@염몽이

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!