코딩관계론

Synchronized Collection VS Concurrent Collection 본문

개발/Java

Synchronized Collection VS Concurrent Collection

개발자_티모 2024. 6. 19. 21:19
반응형

Synchronized Collection

Synchronized Collection은 멀티스레드 환경에서 컬렉션을 안전하게 사용할 수 있도록 동기화된 메서드나 블록을 사용하여 구현됩니다. 주로 Collections.synchronizedXXX() 메서드를 사용하여 기존 컬렉션을 동기화된 버전으로 감싸서 반환합니다.

 

Synchronized Collection의 클래스 코드를 보시면 인스턴스 변수로 락을 위한 Object mutext가 존제합니다.

    static class SynchronizedCollection<E> implements Collection<E>, Serializable {
        @java.io.Serial
        private static final long serialVersionUID = 3053995032091335093L;

        @SuppressWarnings("serial") // Conditionally serializable
        final Collection<E> c;  // Backing Collection
        @SuppressWarnings("serial") // Conditionally serializable
        final Object mutex;     // Object on which to synchronize

Synchronized List

Synchronized list는 sync collection을 상속 받아서 구현하게 됩니다. 

 

Synchronized list 클래스를 보면 다음과 같이 synchronized구문에 부모 객체의 lock을 넘겨주면서 쓰레드 간의 동기화를 보장합니다.

public int size() {
    synchronized (mutex) {return c.size();}
}
public boolean isEmpty() {
    synchronized (mutex) {return c.isEmpty();}
}
public boolean contains(Object o) {
    synchronized (mutex) {return c.contains(o);}
}

 

그럼 그 전 시간에 synchronized함수에 대해서 알아봤으니 아 이 함수를 호출할 때는 원자성이 보장될 수 있겠다고 생각하게 된다.

하지만 만약 이런 경우라면 원자성이 보장이 될까? 정답은 안된다. 

    List<String> synchronizedList = Collections.synchronizedList(new ArrayList<String>());

    public static void main(String[] args) {
        Sync sync = new Sync();

        Thread thread1 = new Thread(() -> {
            if (sync.synchronizedList.size() <= 0) {
                sync.synchronizedList.add("test");
                System.out.println(sync.synchronizedList.get(0));

            }
        });

        Thread thread2 = new Thread(() -> {
            if (sync.synchronizedList.size() <= 0) {
                sync.synchronizedList.add("test2");
                System.out.println(sync.synchronizedList.get(0));

            }
        });

        thread1.start();
        thread2.start();
    }

 

앞서 봤듯이 함수단위의 락이 존제한다. 즉 thread1의 size를 호출할 때 lock이 걸리고, thread2의 size를 호출할 때 lock이 풀리게 되면 동시에 true가 가능해진다. 

 

따라서 우리는 기존에 봤던 sychronized를 이용해 외부동기화를 진행해야 한다. 예를들면 다음과 같다.

        Thread thread1 = new Thread(() -> {
            synchronized (sync.synchronizedList) {
                if (sync.synchronizedList.size() <= 0) {
                    sync.synchronizedList.add("test");
                    System.out.println(sync.synchronizedList.get(0));
                }
            }
        });

        Thread thread2 = new Thread(() -> {
            synchronized (sync.synchronizedList) {
                if (sync.synchronizedList.size() <= 0) {
                    sync.synchronizedList.add("test2");
                    System.out.println(sync.synchronizedList.get(0));
                }
            }
        });

 

또한 collection list로 iterator를 사용하게 되면 에러가 발생할 가능성이 높다. 그 이유는 함수 호출 당시에는 동기화가 진행되지만 다음 단계에서는 동기화가 진행되지 않기 때문에 다른 쓰레드가 해당 리스트를 수정할 가능성이 있기 때문이다.

 

구체적으로는 아래의 예시를 한 번 실행시켜보면 좋을 것이다.

    public static void main(String[] args) throws InterruptedException {
        List<String> synchronizedList = Collections.synchronizedList(new ArrayList<>());
        synchronizedList.add("A");
        synchronizedList.add("B");
        synchronizedList.add("C");

        // 반복자 생성
        Iterator<String> iterator = synchronizedList.iterator();

        // 반복자 사용 스레드
        Thread iteratorThread = new Thread(() -> {
            System.out.println("Iterator thread started.");
            while (iterator.hasNext()) {
                System.out.println("Iterator value: " + iterator.next());
                try {
                    Thread.sleep(100); // 잠시 대기
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                }
            }
        });

        // 리스트 수정 스레드
        Thread modifyingThread = new Thread(() -> {
            System.out.println("Modifying thread started.");
            synchronizedList.add("D");
            System.out.println("Added 'D' to list.");
        });

        iteratorThread.start();
        modifyingThread.start();

        iteratorThread.join();
        modifyingThread.join();

        System.out.println("Final list: " + synchronizedList);
    }

 

Concurrent Collection

Concurrent Collection은 java.util.concurrent 패키지에 포함되어 있으며, 보다 세밀하고 효율적인 동시성 제어 메커니즘을 제공합니다. 여러 스레드가 동시에 읽기 작업을 수행할 수 있으며, 일부 구현체는 일부 쓰기 작업도 동시에 처리할 수 있습니다(ComcurrentHashMap).

 

코드 상에서도 다른 특징점이 있습니다. 아래의 코드를 확인해보시면 lock을 얻어오는 것은 같지만 volatile변수를 사용해서 항상 메인메모리에 접근해 최신의 값을 가져오도록 되어 있습니다.

final transient Object lock = new Object();
/** The array, accessed only via getArray/setArray. */
private transient volatile Object[] array;

 

CopyOnWriteArrayList

Concurrent하게 list를 구현한 것입니다. syncronized와의 큰 차이점은 다음과 같이 두 가지가 있었습니다.

1. 리스트를 읽을 때는 lock을 사용하지 않는다.

    public E get(int index) {
        return elementAt(getArray(), index);
    }

 

2. 리스트를 write하는 경우 List 자체를 copy 후 copy리스트에 작업을 복사하고 작업을 완료한 객체를 업로드한다는 차이점이 있습니다.

    •  
    public boolean add(E e) {
        synchronized (lock) {
            Object[] es = getArray();
            int len = es.length;
            es = Arrays.copyOf(es, len + 1); //원본 복사후 
            es[len] = e;   // 복사 배열에 작업 
            setArray(es);  // 복사 배열을 다시 원본으로 
            return true;
        }
    }

 

 

차이점

  • Synchronized Collection은 전체 메서드 블록이 동기화되므로 동시성이 떨어질 수 있습니다. 따라서 동시성이 높은 환경에서는 성능 저하가 발생할 수 있습니다.
  • Concurrent Collection은 읽기 작업이 많은 환경에서 성능이 우수하지만, 쓰기 작업이 많은 경우에는 실제 성능이 어떻게 되는지 구체적인 상황에 따라 달라집니다.

 

 

깊게 생각해보기

1. concurrent가 syncronized보다 빠르다고 했는데 항상 빠름을 보장할 수 있는가.

정답은 항상 그런것은 아닙니다. 그 예시로 ConcurrentList만을 확인하시더라도, 객체를 복사하는 작업이 있습니다. 이렇게 되면 배열이 커질 수록 시간 소모가 많이 들기 때문에 항상 syncronized보다 성능이 우수한 것은 아닙니다. 하지만 읽기작업을 더 많이 수행한다고 했을 땐  concurrent가 훨씬 빠릅니다.

 

 

 

 

반응형