본문 바로가기
프로그래밍 놀이터/안드로이드, Java

[Java Concurrency] 구성 단위 #1

by 돼지왕 왕돼지 2017. 4. 20.
반응형

 [Java Concurrency] 구성 단위 #1


arrayblockingqueue, arraydeque, ArrayList, blocking deque, blocking queue, BlockingQueue, clone, clone 동기화, collection, Collections, ConcurrentHashMap, ConcurrentMap, ConcurrentModificationException, concurrentskiplistmap, concurrentskiplistset, conditional remove, containsall, containsKey, copyonwritearraylist, copyonwritearrayset, CPU, cpu 사용량, DeadLock, deque, equals, fail-fast, FIFO, for each, for-each, Get, hashcode, HashMap, Hashtable, hasnext, hidden iterator, IO, isempty, iterator, iterator.remove, java concurrency, linkedblockingdeque, LinkedList, lock striping, lonkedblockingqueue, next, Offer, Poll, priorityblockingqueue, producer consumer pattern, producer-consumer, Public, put, put-if-absent, putifabsent, Queue, reatinall, remove, remove-if-equal, removeall, replace, replace-if-equal, serial thread confinement, Size, sortedmap, sortedset, Starvation, synchronized, synchronousqueue, Take, toString, TreeMap, TreeSet, Vector, work stealing, [Java Concurrency] 구성 단위 #1, 가변 객체, 각자의 덱, 값 변경 횟수, 공간, 구성단위, 기능, 내부적으로 iterator, 네트워크, 단일 스레드, 단일 연산, 대기, 데드락, 덱, 동기화, 동기화 기법, 동기화된 컬렉션, 동기화된 컬렉션 클래스, 디스크, 락, 락 스트라이핑, 맵 독점 사용, 메모리 오류, 미약한 일관성, 반복문, 반복문 내부, 변경 횟수 동기화, 변경될 때마다 복사, 병렬 컬렉션, 병렬성, 보장, 복사본, 블로킹 큐, 블로킹 큐와 프로듀서 컨슈머 패턴, 설계, 성능, 성능 향상, 소모상태, 소유자 경쟁, 스레드, 스레드 안정성, 스레드 큐, 스레드 풀, 스테일, 안전 공개, 오류, 우선 순위, 인터페이스, 자바 동시성, 작업 가로채기, 직렬 스레드 한정, 추정값, 카운트, 캡슐화, 컨슈머, 컨슈머 덱, 컨슈머 프로듀서, 컬렉션 클래스 자체 락, 컴파일, 큐의 크기 제한, 클라이언트 측 락, 프로듀서, 확장성


5.1. 동기화된 컬렉션 클래스


-
동기화되어 있는 컬렉션 클래스의 대표 주자는 Vector 와 Hashtable 이다.


-
JDK 1.2 부터는 Collections.synchronizedXxx 메소드를 사용해 이와 비슷하게 동기화되어 있는 몇 가지 클래스를 만들어 사용할 수 있게 됐다.
이와 같은 클래스는 모두 public 으로 선언된 모든 메소드를 클래스 내부에 캡슐화해 내부의 값을 한 번에 한 스레드만 사용할 수 있도록 제어하면서 스레드 안전성을 확보하고 있다.


-
동기화된 컬렉션 클래스는 스레드 안전성을 확보하고 있기는 하다.
하지만 여러 개의 연산을 묶어 하나의 단일 연산처럼 활용해야 할 필요성이 항상 발생한다.


-
동기화된 컬렉션 클래스는 대부분 클라이언트 측 락을 사용할 수 있도록 만들어져 있기 때문에
컬렉션 클래스가 사용하는 락을 함께 사용한다면 새로 추가하는 기능을 컬렉션 클래스에 들어 있는 다른 메소드와 같은 수준으로 동기화시킬 수 있다.
동기화된 컬렉션 클래스는 컬렉션 클래스 자체를 락으로 사용해 내부의 전체 메소드를 동기화시키고 있다.


-
Collection 클래스에 들어 있는 값을 차례로 반복시켜 읽어내는 가장 표준적인 방법은 바로 Iterator 를 사용하는 방법이다.
for each 문이 iterator 방법과 동일한 방법이다.


-
Iterator 를 사용해 컬렉션 내부의 값을 차례로 읽어다 사용한다 해도
반복문이 실행되는 동안 다른 스레드가 컬렉션 클래스 내부의 값을 추가하거나 제거하는 등의 변경 작업을 시도할 때 발생할 수 있는 문제를 막아주지는 못한다.
해당 사항이 발생하면 즉시 ConcurrentModificationException 예외를 발생시키고 멈춘다.


-
컬렉션 클래스는 내부에 값 변경 횟수를 카운트하는 변수를 마련해두고, 반복문이 실행되는 동안 변경 횟수 값이 바뀌면 hasNext 나 next 메소드에서 ConcurrentModificationException 을 발생시킨다.
더군다나 변경 횟수를 확인하는 부분이 적절하게 동기화되어 있지 않기 때문에
반복문에서 변경 횟수를 세는 과정에서 스테일 값을 사용하게 될 가능성도 있고,
따라서 변경 작업이 있었다는 것을 모를 수도 있다는 말이다.
이렇게 구현한 모습이 문제가 있기는 하지만 전체적인 성능을 떨어뜨릴 수 있기 때문에 변경 작업이 있었다는 상황을 확인하는 기능에 정확한 동기화 기법을 적용하지 않았다고 볼 수 있다.


-
단일 스레드 환경의 프로그램에서도 ConcurrentModificationException 이 발생할 수 있다.
반복문 내부에서 iterator.remove 등의 메소드를 사용하지 않고 해당하는 컬렉션의 값을 직접 제거하는 등의 작업을 하려 하면 예외 상황이 발생한다.


-
for-each 반복문을 사용해 컬렉션 클래스의 값을 차례로 읽어들이는 코드는
컴파일 할 때 자동으로 Iterator 를 사용하면서 hasNext 나 next 메소드를 매번 호출하면서 반복하는 방법으로 변경된다.
따라서 ConcurrentModificationException 이 발생하지 않도록 미연에 방지하는 방법은 반복문 전체를 적절한 락으로 동기화시키는 방법밖에 없다.


-
반복문을 실행하는 코드 전체를 동기화시키는 방법이 그다지 훌륭한 방법이 아닐 수 있다.
컬렉션에 엄청나게 많은 수의 값이 들어 있거나 값마다 반복하면서 실행해야 하는 작업이 시간이 많이 소모되는 작업일 경우가 특히 그렇다.
이런 경우에는 컬렉션 클래스 내부의 값을 사용하고자 하는 스레드가 상당히 오랜 시간을 대기 상태에서 기다려야 한다는 말이다.
만약 반복문 안에서 실행하는 코드가 또 다른 락을 확보해야 한다면, 데드락 발생 가능성도 높아진다.
소모상태(starvation)이나 데드락의 위험이 있는 상태에서 컬렉션 클래스를 오랜 시간 동안 락으로 막아두고 있는 상태는 전체 앱의 확장성을 해친다.
반복문에서 락을 오래 잡고 있으면 있을수록, 락을 확보하고자 하는 스레드가 대기 상태에서 많이 쌓일 수 있고,
대기 상태에 스레드가 적체되면 될수록 CPU 사용량이 급격하게 증가할 가능성이 있다.


-
반복문을 실행하는 동안 컬렉션 클래스에 들어 있는 내용에 락을 걸어둔 것과 비슷한 효과를 내려면 clone 메소드로 복사본을 만들어 복사본을 대상으로 반복문을 사용할 수 있다.
이렇게 clone 메소드로 복사한 사본은 특정 스레드에 한정되어 있으므로 반복문이 실행되는 동안 다른 스레드에서 컬렉션 사본을 건드리기 어렵기 때문에 ConcurrentModificationException 이 발생하지 않는다.
물론 최소한 clone 메소드를 실행하는 동안에는 컬렉션의 내용을 변경할 수 없도록 동기화시켜야 한다.


어찌됐건 clone 메소드로 복사본을 만드는 작업에도 시간은 필요하기 때문에 반복문에서 사용할 목적으로 복사본을 만드는 방법도 컬렉션에 들어 있는 항목의 갯수, 반복문에서 개별 항목마다 실행해야 할 작업이 얼마나 오래 걸리는지,
컬렉션의 여러 가지 기능에 비해 반복 기능을 얼마나 빈번하게 사용하는지, 응답성과 실행 속도 등의 여러가지 요구 사항을 충분히 고려해서 적절하게 적용해야 한다.


-
컬렉션의 toString 메소드는 컬렉션 클래스의 iterator 메소드를 호출해, 보관하고 있는 개별 클래스의 toString 메소드를 호출해 출력 문자열을 만들어낸다.
이런 녀석을 hidden iterator 라고 한다.
따라서 명시적으로 Iterator 를 가져다 쓰지 않는 경우에도 언제든지 ConcurrentModificationException 이 날 수 있다.


-
클래스 내부에서 필요한 변수를 모두 캡슐화하면 그 상태를 보존하기가 훨씬 편리한 것처럼
동기화 기법을 클래스 내부에 캡슐화하면 동기화 정책을 적용하기가 쉽다.


-
toString 뿐만 아니라 컬렉션 클래스의 hashCode, equals 메소드도 내부적으로 iterator 를 사용한다.
containsAll, removeAll, retainAll 등의 메소드, 컬렉션 클래스를 넘겨받는 생성 메소드 등도 모두 내부적으로 iterator 를 사용한다.
이렇게 내부적으로 iterator 를 사용하는 모든 메소드에서 ConcurrentModificationException 이 발생할 가능성이 있다.



5.2. 병렬 컬렉션


-
동기화된 컬렉션 클래스는 컬렉션의 내부 변수에 접근하는 통로를 일련화하여 스레드 안전성을 확보한다.
그러다 보니 여러 스레드가 한꺼번에 동기화된 컬렉션을 사용하려고 하면 동시 사용성은 상당 부분 손해를 볼 수밖에 없다.


-
병렬 컬렉션은 여러 스레드에서 동시에 사용할 수 있도록 설계되어 있다.


-
기존에 사용하던 동기화 컬렉션 클래스를 병렬 컬렉션으로 교체하는 것만으로도 별다른 위험 요소 없이 전체적인 성능을 상당히 끌어 올릴 수 있다.


-
HashMap 을 대치하면서 병렬성을 확보한 것이 ConcurrentHashMap
ConcurrentMap interface 는 put-if-absent 연산, replace 연산, conditional remove 연산 등이 정의되어 있다.


-
ConcurrentSkipListMap 과 ConcurrentSkipListSet 은 각각 SortedMap 과 SortedSet 클래스의 병렬성을 높이도록 발전된 형태이다.
SortedMap 과 SortedSet 은 TreeMap 과 TreeSet 을 synchronizedMap 으로 동기화시킨 컬렉션이다.


-
ConcurrentHashMap 은 hash 를 기반으로 하는 Map 이다.
하지만 이전에 사용하던 것과 전혀 다른 동기화 기법을 채택해 병렬성과 확장성이 더 좋아졌다.
이전에는 모든 연산에서 하나의 락을 사용했기 때문에 특정 시점에 하나의 스레드만이 해당 컬렉션을 사용할 수 있었다.
하지만 ConcurrentHashMap 은 락스트라이핑이라 부르는 굉장히 세밀한 동기화 방법을 사용해 여러 스레드에서 공유하는 상태에 훨씬 잘 대응할 수 있다.
값을 읽어가는 연산은 많은 수의 스레드라도 얼마든지 동시 처리 가능하고,
읽기 연산과 쓰기 연산도 동시에 처리할 수 있으며, 쓰기 연산은 제한된 개수만큼 동시에 수행할 수 있다.
속도를 보자면 여러 스레드가 동시에 동작하는 환경에서 일반적으로 훨씬 높은 성능 결과를 볼 수 있으며, 단일 스레드 환경에서도 성능상의 단점을 찾아볼 수 없다.


-
ConcurrentHashMap 이 만들어 낸 Iterator 는 ConcurrentModificationException 을 발생시키지 않는다.
따라서 ConcurrentHashMap 의 항목을 대상으로 반복문을 실행하는 경우 따로 락을 걸어 동기화할 필요가 없다.
이 Iterator 는 즉시 멈춤(fail-fast) 대신 미약한 일관성 전략을 취한다.
미약한 일관성 전략은 반복문과 동시에 컬렉션의 내용을 변경해도 Iterator 를 만들었던 시점의 상황대로 반복을 계속할 수 있다.
Iterator 를 만든 시점 이후에 변경된 내용을 반영해 동작할 수도 있다. 하지만 이 부분은 반드시 보장되지 않는다.





-
발전한 와중에 주의할 사항도 추가되었다.
병렬성 문제때문에 Map 의 모든 하위 클래스에서 공통적으로 사용하는 size 나 isEmpty 메소드의 의미가 약간 약해졌다.
결과를 리턴하는 시점에 이미 실제 객체의 수가 바뀌었을 수 있기 때문에 추정 값이라고 보면 된다.
하지만 실제로 size, isEmpty 결과가 추정값이라 해도 그다지 문제되지는 않는다.
get, put, containsKey, remove 등의 핵심 연산의 병렬성 성능을 높이는 것이 더 좋다.


-
동기화된 Map 에서는 지원하지만 ConcurrentHashMap 에서는 지원하지 않는 기능이 있는데, 바로 맵을 독점적으로 사용할 수 있도록 막는 기능이다.


-
ConcurrentHashMap 을 사용하면 Hashtable 이나 synchronizedMap 메소드를 사용하는 것에 비해 단점이 있기도 하지만,
훨씬 많은 장점을 얻을 수 있기 때문에 대부분의 경우 hash table 이나 synchronizedMap 을 사용하던 부분에 ConcurrentHashMap 을 대신 사용하기만 해도 별 문제 없이 많은 장점을 얻을 수 있다.
만약 작업 중인 애플리케이션에서 특정 Map 을 완전 독점해서 사용하는 경우가 있다면,
그 부분에 ConcurrentHashMap 을 적용할 때는 충분히 신경을 기울여야 한다.

ConcurrentHashMap 클래스에는 일반적으로 많이 사용하는 put-if-absent 연산, remove-if-equal 연상, replace-if-equal 연산과 같이 자주 필요한 몇 가지 연산이 이미 구현되어 있다.
이미 구현되어 있지 않은 기능을 사용해야 한다면, ConcurrentHashMap 보다는 ConcurrentMap 을 사용해 보는 편이 낫다.


-
CopyOnWriteArrayList 는 동기화된 List 클래스보다 병렬성을 훨씬 높이고자 만들어졌다.
병렬성이 향상되었고, 특히 List 에 들어 있는 값을 Iterator 로 불러다 사용하려 할 때 LIst 전체에 락을 걸거나 List 를 복제할 필요가 없다. ( CopyOnWriteArraySet 도 매한가지 )


"변경할 때마다 복사"하는 컬렉션 클래스는 불변 객체를 외부에 공개하면 여러 스레드가 동시에 사용하려는 환경에서도 별다른 동기화 작업이 필요 없다는 개념을 바탕으로 스레드 안전성을 확보한다.
Iterator 를 사용하면, Iterator 를 사용하는 시점의 컬렉션 데이터의 복사본을 사용하기 때문에 변경에 문제가 없다.
반복문에서 락을 걸어야 할 필요가 있기는 하지만 반복할 대상 전체를 한번에 거는 대신 개별 항목마다 가시성을 확보하려는 목적으로 잠깐씩 락을 거는 정도면 충분하다.(? 이해불가..)
변경할 때마다 복사하는 컬렉션에서 뽑아낸 Iterator 를 사용할 때는 ConcurrentModificiationException 이 발생하지 않는다.


컬렉션의 데이터가 변경될 때마다 복사본을 만들어내기 때문에 성능 측면에서 손해를 볼 수 있고,
특히나 컬렉션에 많은 양의 자료가 들어 있다면 손실이 클 수 있다.

따라서 변경할 때마다 복사하는 컬렉션은 변경 작업보다 반복문으로 읽어내는 일이 훨씬 빈번한 경우에 효과적이다.
예를 들면 event listener 관리하는 곳에 적합하다



5.3. 블로킹 큐와 프로듀서-컨슈머 패턴


-
블로킹 큐(blocking queue)는 put 과 take 라는 핵심 메소드를 가지고 있다.
더불어 offer 와 poll 이라는 메소드도 갖고 있다.
만약 큐가 가득 차 있으면 put 메소드는 값을 추가할 공간이 생길 때까지 대기한다.
반대로 큐가 비어 있는 상태라면 take 메소드는 뽑아낼 값이 들어올 때까지 대기한다.
큐는 그 크기를 제한할 수도 있고 제한하지 않을 수도 있다.


-
블로킹 큐는 프로듀서-컨슈머(producer-consumer) 패턴을 구현할 때 사용하기에 좋다.
프로듀서-컨슈머 패턴은 "해야 할 일" 목록을 가운데 두고 작업을 만들어 내는 주체와 작업을 처리하는 주체를 분리시키는 설계 방법이다.
프로듀서-컨슈머 패턴을 사용하면 작업을 만들어 내는 부분과 작업을 처리하는 부분을 완전히 분리할 수 있기 때문에 개발 과정을 좀 더 명확하게 단순화시킬 수 있다.


-
프로듀서-컨슈머 패턴을 적용해 프로그램을 구현할 때 블로킹 큐를 사용하는 경우가 많다.
블로킹 큐를 사용하면 여러 개의 프로규서와 여러 개의 컨슈머가 작동하는 프로듀서-컨슈머 패턴을 손쉽게 구현할 수 있다.
큐와 함께 스레드 풀을 사용하는 경우가 바로 프로듀서-컨슈머 패턴을 활용하는 가장 흔한 경우이다.


-
프로듀서가 컨슈머가 감당할 수 있는 것보다 많은 양의 작업을 만들어 내면 해당 애플리케이션의 큐에는 계속해서 작업이 누적되어 결국에는 메모리 오류가 발생하게 된다.
하지만 큐의 크기에 제한을 두면 큐에 빈 공간이 생길 때까지 put 메소드가 대기하기 떄문에 프로규서 코드를 작성하기가 훨씬 간편해진다.
그러면 컨슈머가 작업을 처리하는 속도에 프로듀서가 맞춰야 하며, 컨슈머가 처리하는 양보다 많은 작업을 만들어 낼 수 없다.


-
블로킹 큐의 offer 메소드는 큐에 값을 넣을 수 없을 때 대기하지 않고 바로 공간이 모자라 추가할 수 없다는 오류를 알려준다.
offer 메소드를 잘 활용하면 프로듀서가 작업을 많이 만들어 과부하에 이르는 상태를 좀 더 효과적으로 처리할 수 있다.


-
블로킹 큐는 애플리케이션이 안정적으로 동작하도록 만들고자 할 때 요긴하게 사용할 수 있는 고수이다.
블로킹 큐를 사용하면 처리할 수 있는 양보다 훨씬 많은 작업이 생겨 부하가 걸리는 상황에서 작업량을 조절해 애플리케이션이 안정적으로 동작하도록 유도할 수 있다.


-
BlockingQueue 인터페이스를 구현한 클래스 몇 가지가 있는데,
LinkedBlockingQueue 와 ArrayBlockingQueue 는 FIFO 형태의 큐이며, 각각 LinkedList 와 ArrayList 에 대응된다.
병렬 프로그램 환경에서는 LinkedList 나 ArrayList 에서 동기화된 List 인스턴스를 뽑아 사용하는 것보다 성능이 좋다.

PriorityBlockingQueue 클래스는 우선 순위를 기준으로 동작하는 큐이고, FIFO 가 아닌 다른 순서로 큐의 항목을 처리해야 하는 경우에 손쉽게 사용할 수 있다.


-
SynchronousQueue 클래스도 BlockingQueue 인터페이스를 구현하는데,
큐에 항목이 쌓이지 않으며, 따라서 큐 내부에 값을 저장할 수 있도록 공간을 할당하지도 않는다.
대신 큐에 값을 추가하려는 스레드나 값을 읽어가려는 스레드의 큐를 관리한다.
SynchronousQueue 는 데이터를 넘겨 받을 수 있는 충분한 개수의 컨슈머가 대기하고 있는 경우에 사용하기 좋다.


-
프로듀서의 작업은 디스크나 네트웍 I/O 에 시간을 많이 소모하고, 컨슈머는 CPU 를 많이 사용하는 특성이 있다면 프로듀서와 컨슈머의 기능을 단일 스레드에서 순차적으로 실행하는 것보다 성능이 크게 높아질 수 있다.


-
프로듀서-컨슈머 패턴과 블로킹 큐는 가변 객체를 사용할 때 객체의 소유권을 프로듀서에서 컨슈머로 넘기는 과정에서 직렬 스레드 한정(serial thread confinement)기법을 사용한다.
스레드에 한정된 객체는 특정 스레드 하나만이 소유권을 가질 수 있는데, 객체를 안전한 방법으로 공개하면 객체에 대한 소유권을 이전할 수 있다.
이렇게 소유권을 이전하고 나면 이전받은 컨슈머 스레드가 객체에 대한 유일한 소유권을 가지며,
프로듀서 스레드는 이전된 객체에 대한 소유권을 완전히 잃는다.


-
자바 6.0 에는 두 가지 컬렉션이 추가되었다.
Deque(덱)과 Blocking Deque 이다.
Deque과 BlockingDeque 는 각각 Queue와 BlockingQueue 를 상속받은 인터페이스이다.
Deque 은 앞과 뒤 어느 쪽에도 객체를 쉽게 삽입하거나 제거할 수 있도록 준비된 큐이다.
Deque 을 상속받은 실제 클래스는 ArrayDeque 과 LinkedBlockingDeque 가 있다.


-
작업 가로채기(work stealing) 이라는 패턴을 적용할 때에는 덱을 그대로 가져다 사용할 수 있다.
작업 가로채기 패턴에서는 모든 컨슈머가 각자의 덱을 갖는다.
만약 특정 컨슈머가 자신의 덱에 들어 있던 작업을 모두 처리하고 나면 다른 컨슈머의 덱에 쌓여있는 작업 가운데 맨 뒤에 추가된 작업을 가로채 가져올 수 있다.
작업 가로채기 패턴은 그 특성상 컨슈머가 하나의 큐를 바라보면서 서로 작업을 가져가려고 경쟁하지 않기 때문에 일반적인 프로듀서-컨슈머 패턴보다 규모가 큰 시스템을 구현하기에 적당하다.
더군다나 컨슈머가 다른 컨슈머의 큐에서 작업을 가져오려 하는 경우에도 앞이 아닌 맨 뒤의 작업을 가져오기 때문에 맨 앞의 작업을 가져가려는 원래 소유자와 경쟁이 일어나지 않는다.


-
작업 가로채기 패턴은 또한 컨슈머가 프로듀서의 역할도 갖고 있는 경우에 적용하기에 좋다.
이를테면 하나의 작업을 처리하고 나면 더 많은 작업이 생길 수 있는 상황이 그렇다.
스레드가 작업을 진행하는 도중에 새로 처리해야 할 작업이 생기면 자신의 덱에 새로운 작업을 추가한다.
만약 자신의 덱이 비었다면 다른 작업 스레드의 덱을 살펴보고 밀린 작업이 있다면 가져다 처리해 자신의 덱이 비었다고 쉬는 스레드가 없도록 관리한다.







반응형

댓글