관리 메뉴

나구리의 개발공부기록

동시성 컬렉션, 동시성 컬렉션이 필요한 이유(시작, 동시성 문제, 동기화, 프록시 도입), 자바 동시성 컬렉션(synchronized, 동시성 컬렉션) 본문

인프런 - 실전 자바 로드맵/실전 자바 - 고급 1편, 멀티스레드와 동시성

동시성 컬렉션, 동시성 컬렉션이 필요한 이유(시작, 동시성 문제, 동기화, 프록시 도입), 자바 동시성 컬렉션(synchronized, 동시성 컬렉션)

소소한나구리 2025. 2. 14. 15:01
728x90

출처 : 인프런 - 김영한의 실전 자바 - 고급1편 (유료) / 김영한님  
유료 강의이므로 정리에 초점을 두고 코드는 일부만 인용


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

1) 시작

(1) SimpleListMainV0

  • 여러 스레드가 동시에 접근해도 괜찮은 경우를 스레드 세이프(Thread Safe)함
  • 여기서는 멀티스레드를 사용하지 않았지만 스레드1과 스레드2가 동시에 다음 코드를 실행한다고 가정하고 컬렉션에 데이터를 추가하는 add() 메서드를 생각해 보면, 단순히 컬렉션에 데이터를 하나 추가하는 것뿐이므로 연산이 하나만 있는 원자적인 연산처럼 느껴짐
  • 하지만 컬렉션 프레임워크가 제공하는 대부분의 연산은 원자적인 연산이 아니라서 스레드 세이프하지 않음
package thread.collection.simple;

public class SimpleListMainV0 {
    public static void main(String[] args) {
        List<String> list = new ArrayList<>();

        // 스레드1, 스레드2가 동시에 실행 가정
        list.add("A");
        list.add("B");
        System.out.println(list);
    }
}
/* 실행 결과
[A, B]
*/

 

(2) SimpleList

  • 크기 조회, 데이터 추가, 데이터 조회 3가지 메서드만 가지는 간단한 인터페이스
package thread.collection.simple;

public interface SimpleList {
    int size();
    void add(Object o);
    Object get(int index);
}

 

(3) BasicList

  • 가장 간단한 컬렉션의 구현으로 내부에서 배열을 사용해서 데이터를 보관하는 ArrayList의 최소 구현 버전이라고 보면 됨
  • 최대 5개의 데이터를 저장할 수 있고 Object배열로 데이터를 저장하며 저장한 데이터의 크기를 나타내는 size를 가지고 있음
  • add(): 컬렉션에 데이터를 추가하며 멀티스레드 상황에 발생하는 문제를 쉽게 확인할 수 있도록 내부에 sleep(100)을 추가함
package thread.collection.simple;

public class BasicList implements SimpleList {

    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++;
    }

    @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;
    }
}

 

(4) SimpleListMainV1

  • 일단 구현한 컬렉션이 잘 동작하는지 싱글 스레드 상황에서 확인해 보면 정상적으로 잘 동작하는 것을 확인할 수 있음
package thread.collection.simple;

public class SimpleListMainV1 {
    public static void main(String[] args) {
        SimpleList list = new BasicList();

        // 스레드1, 스레드2가 동시에 실행 가정
        list.add("A");
        list.add("B");
        System.out.println(list);
    }
}
/* 실행 결과
[A, B] size = 2, capacity = 5
*/

2) 동시성 문제

(1) SimpleList - add()

  • 직접 만든 SimpleList의 add() 메서드는 단순히 데이터를 하나 추가하는 기능이기 때문에 밖에서 보면 원자적인 것처럼 보임
  • 그러나 이 메서드는 단순히 데이터를 추가하는 것으로 끝나는 것이 아니라 내부에 있는 배열에 데이터를 추가해야 하고 size도 하나 증가시켜야 함
  • 사이즈를 증가시키는 size++ 연산 자체도 원자적이지 않음
  • 이렇게 원자적이지 않은 연산을 멀티스레드 상황에 안전하게 사용하려면 synchronized나 Lock 등을 사용해서 동기화해야 하지만 동기화하지 않고 사용하면 문제가 발생함

(2) SimpleListMainV2

  • 멀티스레드 상황을 만들어서 BasicList를 활용하여 동시에 add() 메서드가 실행되도록 코드를 작성하고 실행해 보면 size는 2인데 데이터는 B 하나만 입력되는 결과를 확인할 수 있음
  • 물론 멀티스레드 환경이므로 스레드 실행 방식에 따라 결과는 달라질 수 있으나 정상적인 방식으로 실행되지는 않음
package thread.collection.simple;

public class SimpleListMainV2 {
    public static void main(String[] args) throws InterruptedException {
        test(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);
    }
}
/* 실행 결과
00:07:41.533 [     main] BasicList
00:07:41.640 [ Thread-1] Thread-1: list.add(A)
00:07:41.640 [ Thread-2] Thread-2: list.add(B)
00:07:41.640 [     main] [B, null] size = 2, capacity = 5
*/

 

(3) 실행 과정

  • Thread-1, Thread-2가 elementData[size] = e 코드를 동시에 수행하는데 실행 결과에서는 Thread-1이 약간 빠르게 수행했음
  • Thread-1이 수행되어 elementData[0] = A가 먼저 대입되고 이후 Thread - 2가 수행되어 elementData[0] = B가 수행되어 elementData[0]의 값이 결과적으로 B가 되어 버림
  • 이후 동시성 문제를 쉽게 확인할 수 있도록 두 스레드가 sleep()에서 잠시 대기하도록 함, 이 코드를 제거하면 size++이 너무 빨리 호출되어 정상적으로 수행될 가능성이 있음(확률의 차이)
  • 이후에는 2가지 상황이 발생할 수 있음
  • 상황1: Thread-1, Thread-2가 size++ 동시에 수행하지만 둘 중 하나가 약간 빠르게 수행되면 size++이 2번 호출되므로 결과적으로 size의 값은 2가 됨
  • 상황2: 마찬가지로 동시에 수행되어 정말 거의 동시에 실행되면 size++은 size = size + 1의 연산이므로 두 스레드가 size의 값을 0으로 읽고 두 스레드가 동시에 size = 0 + 1 연산을 수행한 결과를 size에 대입하면 결과적으로 size의 값은 1이 됨
  • 실행 결과의 경우에는 상황1로 출력되었지만 상황2의 케이스가 진행되는 경우 '[B] size = 1, capacity = 5'처럼 출력이 됨

(4) 컬렉션 프레임워크 대부분은 스레드 세이프 하지 않음

  • 우리가 일반적으로 자주 사용하는 ArrayList, LinkedList, HashSet, HashMap 등 수많은 자료 구조들은 단순한 연산을 제공하는 것 처럼 보이지만 그 내부에서는 수 많은 연산들이 함께 사용됨
  • 배열에 데이터를 추가하고 사이즈를 변경하고 배열을 새로 만들어서 배열의 크기를 늘리고, 노드를 만들어서 링크에 연결하는 등의 수 많은 복잡한 연산이 함께 사용되므로 일반적인 컬렉션들은 절대로 스레드 세이프 하지 않음
  • 단일 스레드가 컬렉션에 접근하는 경우라면 아무런 문제가 없지만 멀티스레드 상황에서 여러 스레드가 동시에 컬렉션에 접근하는 경우라면 java.util 패키지가 제공하는 일반적인 컬렉션들은 사용하면 안 됨(물론 일부 예외도 있음)
  • 최악의 경우 실무에서 두 명의 사용자가 동시에 컬렉션에 데이터를 보관했는데 코드에는 아무런 문제가 없어 보였으나 한 명의 사용자 데이터가 사라질 수 있음

3) 동기화

(1) SyncList

  • 기존 메서들에 synchronized 키워드를 사용하여 동기화를 적용
package thread.collection.simple;

public class SyncList implements SimpleList {

    // ... 기존 코드 동일

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

    @Override
    public synchronized void add(Object e) {
        elementData[size] = e;
        sleep(100); // 멀티스레드 문제를 쉽게 확인하기 위해 추가
        size++;
    }

    @Override
    public synchronized Object get(int index) {
        return elementData[index];
    }

    @Override
    public synchronized String toString() {
        return Arrays.toString(Arrays.copyOf(elementData, size)) +
                " size = " + size + ", capacity = " + elementData.length;
    }
}

 

(2) SimpleListMainV2

  • SyncList를 사용하도록 코드를 수정하고 실행해 보면 정상적으로 데이터가 출력이 성공하는 것을 확인할 수 있음
  • synchronized를 통해 안전한 임계 영역을 만들었으므로 여러 연산으로 구성된 메서드에 한 번에 하나의 스레드만 접근할 수 있으므로 문제가 발생하지 않게 됨
/* 실행 결과
09:57:44.834 [     main] SyncList
09:57:44.941 [ Thread-1] Thread-1: list.add(A)
09:57:45.046 [ Thread-2] Thread-2: list.add(B)
09:57:45.046 [     main] [A, B] size = 2, capacity = 5
*/

 

(3) 문제

  • BasicList의 코드를 거의 복사해서 synchronized 기능만 추가한 SyncList를 만들었는데 이렇게 되면 모든 컬렉션을 다 복사하여 동기화용으로 새로 구현해야 함
  • 이렇게 문제를 해결하는 것은 비효율 적임

4) 프록시 도입

(1) 프록시(Proxy)

  • ArrayList, LinkedList, HashSet, HashMap 등의 코드도 모두 복사해서 만들면 이후에 구현이 변경될 때 같은 모양의 코드를 2곳에서 변경해야 함
  • 기존 코드를 그대로 사용하면서 추가적인 기능만 살짝 추가하고 싶을 때 사용하는 것이 프록시임
  • 대리자라는 뜻으로 프록시가 대신 동기화 기능을 처리하도록 할 수 있음

(2) SyncProxyList

  • 프록시 역학을 하는 클래스로 SyncProxyList는 BasicList와 같은 SimpleList 인터페이스를 구현하며 생성자를 통해 실제 호출되는 대상을 주입받음
  • 이 클래스의 역할은 모든 메서드에 synchronized를 대신 걸어주는 일뿐이며 실제 동작은 target에 있는 기능을 호출함
package thread.collection.simple;

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 String toString() {
        return target.toString() + " by " + this.getClass().getSimpleName();
    }
}

 

(3) SimpleListMainV2

  • SyncProxyList를 사용하기 위해서는 실제 대상이 필요하므로 생성자의 인자로 실제 대상인 BasicList가 필요함
  • 기존에는 BasicList나 SyncList를 직접 사용하고 있었다면 중간에 프록시를 사용하여 클라이언트 -> SyncProxyList(프록시) -> BasicList(서버)의 구조로 변경되었음
  • 실행해 보면 SyncList를 직접 사용한 것처럼 동기화가 적용되어 정상적으로 실행되는 결과를 확인할 수 있음
package thread.collection.simple;

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

    // ... 기존 코드 동일 생략
}
/* 실행 결과
10:27:41.223 [     main] SyncProxyList
10:27:41.330 [ Thread-1] Thread-1: list.add(A)
10:27:41.434 [ Thread-2] Thread-2: list.add(B)
10:27:41.435 [     main] [A, B] size = 2, capacity = 5 by SyncProxyList
*/

 

5) 프록시 구조 분석

(1) 정적 의존 관계 분석

  • 정적인 클래스의 의존 관계인 정적 의존 관계를 보면 요청을 하는 클라이언트 입장인 test()는 SimpleList라는 인터페이스에만 의존하여 추상화에 의존하고 있음
  • 덕분에 SimpleList 인터페이스의 구현체인 BasicList, SyncList, SyncProxyList 중 어떤 것을 사용하든 클라이언트 test()의 코드는 전혀 변경하지 않아도 됨
  • 클라이언트인 test() 입장에서 보면 BasicList가 넘어올지 SyncProxyList가 넘어올지 알 수 없으며 단지 SimpleList의 구현체 중의 하나가 넘어와서 실행된다는 정도만 알 수 있음
  • 그래서 클라이언트인 test()는 매우 유연하여 SimpleList의 어떤 구현체든지 다 받아들일 수 있음

(2) 런타임 의존 관계 분석 - BasicList 사용

  • 실제 런타임에 발생하는 인스턴스의 의존 관계를 런타임 의존관계라 하는데 BasicList를 직접 사용했을 때의 런타임 의존 관계를 분석
  • test(new BasicList())를 실행하면 BasicList(참조값)의 인스턴스가 만들어지면서 test() 메서드에 전달되면 test() 메서드는 BasicList 인스턴스의 참조를 알고 사용하게 됨
  • test() 메서드에서 스레드를 만들고 run()에서 list.add()를 호출하여 BasicList 인스턴스에 있는 add()가 호출됨

(3) 런타임 의존 관계 분석 - SyncProxyList 사용

  • test(new SyncProxyList(new BasicList()))로 실행하면 먼저 BasicList(참조값)의 인스턴스가 만들어지고, 만들어진 BasicList 인스턴스의 참조를 SyncProxyList의 생성자에 전달하면서 SyncProxyList(BasicList의 참조값) 인스턴스가 만들어짐
  • SyncProxyList의 내부에는 원본 대상을 가리키는 target 변수를 포함하고 있으며 이 변수에 원본 대상인 BasicList 인스턴스의 참조값을 보관함
  • 클라이언트인 test()는 SyncProxyList 인스턴스를 사용하게 되고 스레드에 있는 run()에서 list.add()를 호출하면 SyncProxyList의 add() 메서드가 호출됨
  • 프록시인 SyncProxyList는 synchronized를 적용하고 target에 있는 add()를 호출하여 원본 대상인 BasicList 인스턴스의 add()메서드가 호출되어 로직을 수행하고 결과를 반환함
  • SyncProxyList에 있는 add()로 흐름이 돌아오면 메서드를 반환하면서 synchronized를 해제하고 test()로 흐름이 돌아옴

(4) 프록시 정리

  • 프록시인 SyncProxyList는 원본인 BasicList와 똑같은 SimpleList를 구현함에 따라서 클라이언트인 test() 입장에서는 원본 구현체가 전달되든 프록시 구현체가 전달되든 아무런 상관이 없으며 단지 수많은 SimpleList의 구현체 중의 하나가 전달되었다고 생각할 뿐임
  • 클라이언트 입장에서 보면 프록시는 SimpleList의 구현체이므로 원본과 똑같이 생겼고 호출할 메서드도 같음
  • 프록시는 내부에 원본을 가지고 있으므로 프록시가 필요한 일부의 일을 처리하고 그 다음에 원본을 호출하는 구조를 만들 수 있어 프록시를 통해 synchronized를 통한 동기화를 적용하였음
  • 프록시가 동기화를 적용하고 원본을 호출하기 때문에 원본 코드도 이미 동기화가 적용된 상태로 호출됨
  • 중요한 핵심은 원본 코드인 BasicList를 전혀 손대지 않고 프록시인 SyncProxyList를 통해 동기화 기능을 적용했다는 점이며 이후에 SimpleList를 구현하는 BasicLinkedList 같은 연결 리스트를 만들더라도 서로 같은 인터페이스를 사용하기 때문에 SyncProxyList를 그대로 활용할 수 있음
  • SyncProxyList 프록시 하나로 SimpleList 인터페이스의 모든 구현체를 동기화할 수 있게 됨

(5) 프록시 패턴(Proxy Pattern)

  • 지금 구현한 것이 객체지향 디자인 패턴 중 하나인 프록시 패턴으로 어떤 객체에 대한 접근을 제어하기 위해 그 객체의 대리인 또는 인터페이스 역할을 하는 객체를 제공하는 패턴임
  • 프록시 객체는 실제 객체에 대한 참조를 유지하면서 그 객체에 접근하거나 행동을 수행하기 전에 추가적인 처리를 할 수 있도록 함
  • 프록시 패턴의 주요 목적은 아래와 같음
  • 접근 제어: 실제 객체에 대한 접근을 제한하거나 통제할 수 있음
  • 성능 향상: 실제 객체의 생성을 지연시키거나 캐싱하며 성능을 최적화할 수 있음
  • 부가 기능 제공: 실제 객체에 추가적인 기능(로깅, 인증, 동기화 등)을 투명 하게 제공할 수 있음

**  참고

  • 실무에서 프록시 패턴은 자주 사용되며 스프링의 AOP 기능은 이런 프록시 패턴을 극한으로 적용하는 예시임
  • 자세한 내용은 스프링 핵심 원리 - 고급 편에서 자세히 다룸
  • https://nagul2.tistory.com/362

2. 자바 동시성 컬렉션

1) synchronized

(1) 컬렉션 동기화 적용

  • 자바가 제공하는 java.util 패키지에 있는 컬렉션 프레임워크들은 대부분 스레드 안전하지 않음
  • 일반적으로 많이 사용하는 ArrayList, LinkedList, HashSet, HashMap 등 수많은 기본 자료 구조들은 내부에서 수 많은 연산들이 함께 사용되기 때문에 멀티스레드에서 안전하게 사용하려면 동기화가 필요함
  • 처음부터 모든 자료 구조에 synchronzied, Lock, CAS 등을 사용해서 동기화를 해둔다면 정도의 차이는 있지만 동기화를 사용하지 않는 것이 가장 빠르기 때문에 성능과 트레이드오프가 있음
  • 그리고 컬렉션이 항상 멀티스레드에서 사용되는 것이 아니기 때문에 미리 동기화를 해둔다면 단일 스레드에서 사용할 때 동기화로 인해 성능이 저하되므로 동기화의 필요성을 정확히 판단하고 꼭 필요한 경우에만 동기화를 적용해야 함
  • 좋은 대안으로는 앞서 동기화를 대신 적용해 주는 프록시를 만드는 방법이 있음
  • List, Set, Map 등 주요 인터페이스를 구현해서 동기화를 적용할 수 있는 프록시를 만들어서 사용하면 기존 코드를 유지하면서 필요한 경우에만 동기화를 적용할 수 있으며 자바는 컬렉션을 위한 프록시 기능을 제공함

** 참고

  • 과거에 자바는 이와 같은 실수를 한번 했는데 그것이 바로 java.util.Vector 클래스임
  • 이 클래스는 ArrayList와 같은 기능을 제공하는데 메서드에 synchronized를 통한 동기화가 되어있어 동기화된 ArrayList로 사용이 가능했음
  • 그러나 단일 스레드 환경에서도 불필요한 동기화로 성능이 저하되기 때문에 결과적으로 Vector는 널리 사용되지 않게 되었고 지금은 하위 호환을 위해서 남겨져있고 다른 대안이 많기 때문에 사용을 권장하지 않음

(2) SynchronizedListMain

  • Collections.synchronizedList(리스트)로 일반 컬렉션을 동기화된 컬렉션으로 만들 수 있음

 

package thread.collection.java;

public class SynchronizedMain {
    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() = " + list.getClass());
        System.out.println("list = " + list);
    }
}
/* 실행 결과
list.getClass() = class java.util.Collections$SynchronizedRandomAccessList
list = [data1, data2, data3]
*/

 

 

(2) Collections.synchronizedXxx()

// 3개의 코드는 모두 동일함
List<String> list = Collections.synchronizedList(new ArrayList<>());

public static <T> List<T> synchronizedList(List<T> list) {
    return new SynchronizedRandomAccessList<>(list);
}

new SynchronizedRandomAccessList<>(new ArrayList())
  • 위 세 개의 코드는 모두 동일하며 SynchronizedRandomAccessList는 synchronized를 추가하는 프록시 역할을 함
  • 클라이언트 -> SynchronizedRandomAccessList(프록시) -> ArrayList로 구성되어있으며 프록시 클래스의 내부 메서드를 보면 synchronized 코드 블록을 적용한 다음 원본 대상의 메서드를 호출하도록 만들어져 있음
  • Collections는 다양한 synchronized 동기화 메서드를 제공하여 이 메서드를 사용하여 다양한 컬렉션에 다양한 동기화 프록시를 만들어 낼 수 있어 편리하게 스레드 안전한 컬렉션으로 변경해서 사용할 수 있음
    • synchronizedList()
    • synchronizedCollection()
    • synchronizedMap()
    • synchronizedSet()
    • synchronizedNavigableMap()
    • synchronizedNavigableSet()
    • synchronizedSortedMap()
    • synchronizedSortedSet

(3) synchronized 프록시 방식의 단점

  • 하지만 synchronized 프록시를 사용하는 단순하고 무식한 방법으로 모든 메서드에 synchronized를 걸어버리는 방식이므로 동기화에 대한 최적화가 이루어지지 않음
  • 1. 동기화 오버헤드가 발생함
    • 비록 synchronized 키워드가 멀티스레드 환경에서 안전한 접근을 보장하지만 각 메서드 호출 시마다 동기화 비용이 추가되어 성능 저하가 발생할 수 있음
  • 2. 전체 컬렉션에 대해 동기화가 이루어지기 때문에 잠금의 범위가 넓어질 수 있음
    • 이는 잠금 경합(lock contention)을 증가시키고 병렬 처리의 효율성을 저하시키는 요인이 됨
    • 모든 메서드에 대해 동기화를 적용하다 보면 특정 스레드가 컬렉션을 사용하고 있을 때 다른 스레드들이 대기해야 하는 상황이 빈번해질 수 있음
  • 3. 정교한 동기화가 불가능함
    • synchronized 프록시를 사용 하면 컬렉션 전체에 대한 동기화가 이루어져 특정 부분이나 메서드에 대해 선택적으로 동기화를 적용하는 것이 어려워 과도한 동기화로 이루어질 수 있음
  • 자바는 이런 단점을 보완하기 위해 java.util.concurrent 패키지에 동시성 컬렉션(concurrent collection)을 제공함

2) 동시성 컬렉션

(1) 동시성 컬렉션

  • 자바 1.5부터 동시성에 대한 많이 혁신이 이루어졌으며 그중에 스레드 안전한 동시성을 위한 컬렉션도 있음
  • java.util.concurrent 패키지에는 고성능 멀티스레드 환경을 지원하는 다양한 동시성 컬렉션 클래스들을 제공함
  • 이 컬렉션들은 더 정교한 잠금 메커니즘을 사용하여 동시 접근을 효율적으로 처리하며 필요한 경우 일부 메서드에 대해서만 동기화를 적용하는 등 유연한 동기화 전략을 제공함
  • 여기에는 다양한 최적화 기법들이 적용되는데 synchronized, Lock(ReentrantLock), CAS, 분할 잠금 기술(segment lock)등 다양한 방법을 섞어서 매우 정교한 동기화를 구현하면서 성능도 최적화하였음
  • 각각의 최적화는 매우 어렵게 구현되어 있기 때문에 자세한 구현을 이해하는 것보다는 멀티스레드 환경에 필요한 동시성 컬렉션을 잘 선택해서 사용할 수 있으면 충분함

(2) 동시성 컬렉션 종류

  • List
    • CopyOnWriteArrayList: ArrayList의 대안
  • Set
    • CopyOnWriteSet: HashSet의 대안
    • ConcurrentSkipListSet: TreeMap의 대안(정렬된 순서 유지, Comparator 사용 가능)
  • Map
    • ConcurrentHashMap: HashMap의 대안
    • ConcurrentSkipListMap: TreeMap의 대안(정렬된 순서 유지, Comparator 사용 가능)
  • Queue, Deque
    • ConcurrentLinkedQueue: 동시성 큐, 비 차단(non-blocking) 큐
    • ConcurrentLinkedDeque: 동시성 데크, 비 차단 데크
  • BlockingQueue: 스레드를 차단하는 블로킹 큐
    • ArrayBlockingQueue: 크기가 고정된 블로킹 큐, 공정 모드를 사용할 수 있으며 공정 모드는 성능이 저하될 수 있음
    • LinkedBlockingQueue: 크기가 무한하거나 고정된 블로킹 큐
    • PriorityBlockingQueue: 우선순위가 높은 요소를 먼저 처리하는 블로킹 큐
    • SynchronousQueue: 데이터를 저장하지 않는 블로킹 큐로 생산자 데이터가 데이터를 추가하면 소비자 데이터가 그 데이터를 받을 때까지 대기하여 생산자-소비자 간의 직접적인 핸드오프(hand-off) 메커니즘을 제공함, 쉽게 이야기해서 중간에 큐 없이 생산자, 소비자가 직접 거래함
    • DelayQueue: 지연된 요소를 처리하는 블로킹 큐로 각 요소는 지정된 지연 시간이 지난 후에야 소비될 수 있음, 일정 시간이 지난 후 작업을 처리해야 하는 스케줄링 작업에 사용됨

** 참고

  • LinkedHashSet, LinkedHashMap처럼 입력 순서를 유지하는 동시에 멀티스레드 환경에서 사용할 수 있는 Set, Map 구현체는 제공하지 않으므로 Collections.synchronizedXxx()를 사용해야 함

(3) List 예시

  • ArrayList의 대안인 CopyOnWriteArrayList 사용 예시
package thread.collection.java;

public class ListMain {
    public static void main(String[] args) {
        List<Integer> list = new CopyOnWriteArrayList<>();
        list.add(1);
        list.add(2);
        list.add(3);
        System.out.println("list = " + list);
    }
}
/* 실행 결과
list = [1, 2, 3]
*/

 

(4) Set 예시

  • HashSet의 대안인 CopyOnWriteArraySet과 TreeSet의 대안인 ConcurrentSkipListSet을 사용
package thread.collection.java;

public class SetMain {
    public static void main(String[] args) {
        Set<Integer> copySet = new CopyOnWriteArraySet<>();
        copySet.add(1);
        copySet.add(2);
        copySet.add(3);
        System.out.println("set = " + copySet);

        Set<Integer> skipSet = new ConcurrentSkipListSet<>();
        skipSet.add(3);
        skipSet.add(1);
        skipSet.add(2);
        System.out.println("skipSet = " + skipSet);
    }
}
/* 실행 결과
set = [1, 2, 3]
skipSet = [1, 2, 3]
*/

 

(5) Map 예시

  • HashMap의 대안인 ConcurrentHashMap과 TreeMap의 대안인 ConcurrentSkipListMap을 사용
package thread.collection.java;

public class MapMain {
    public static void main(String[] args) {
        Map<Integer, String> map1 = new ConcurrentHashMap<>();
        map1.put(3, "data3");
        map1.put(2, "data2");
        map1.put(1, "data1");
        System.out.println("map1 = " + map1);

        Map<Integer, String> map2 = new ConcurrentSkipListMap<>();
        map2.put(2, "data2");
        map2.put(3, "data3");
        map2.put(1, "data1");
        System.out.println("map2 = " + map2);
    }
}
/* 실행 결과
map1 = {1=data1, 2=data2, 3=data3}
map2 = {1=data1, 2=data2, 3=data3}
*/

 

(6) 정리

  • 자바가 제공하는 동시성 컬렉션은 멀티스레드 상황에 최적의 성능을 낼 수 있도록 최적화 기법이 적용되었으므로 Collections.synchronizedXxx를 사용하는 것보다 더 좋은 성능을 제공함
  • 동시성은 결국 성능과 트레이드오프가 있기 때문에 단일 스레드가 컬렉션을 사용하는 경우에는 동시성 컬렉션이 아닌 일반 컬렉션을 사용해야 함
  • 반대로 멀티스레드 상황에서 일반 컬렉션을 사용하면 정말 해결하기 어려운 버그를 만들 수 있는데 멀티스레드로 인해 발생한 버그는 정말 어디서 손을 대야 할지 모를 정도로 해결하기 어려운 버그이므로 매우 조심해야 함
  • 멀티스레드 환경에서는 동시성 컬렉션을 적절히 활용하여 버그를 예방하고 성능을 최적화하는 것이 중요함
  • 동시성 컬렉션을 사용하면 코드의 안정성과 효율성을 높일 수 있으며 예상치 못한 동시성 문제도 방지할 수 있음
728x90