Optimizing Java - 동시 성능 기법
1. 개요
용량 문제가 발생할때 더 좋은 장비(스케일 업)로만 해결하는 것은 한계가 있다. 이 때 멀티코어를 활용하여 부하를 고루 분산시킨다면 또다른 성능 향상을 누릴 수 있다.
2. 병렬성
암달의 법칙 T(N) = S + (T - S) / N
S는 순차 실행(병렬로 실행할 수 없는 부분), T는 해당 태스크의 총 소요시간이다. 이 수식에서 확인할 수 있듯 아무리 코어가 늘어나더라도 순차 실행 시간을 줄이지 않으면 한계가 있다.
1) 자바 동시성 기초
티 스레드에서 가장 큰 문제는 상태의 공유이다. 여러 스레드가 한 객체의 필드(힙에 위치하고 모든 스레드가 함께 공유를 사용하게 된다면 예상한 결과값과 달라지는 문제가 발생한다. 이러한 문제는 재연이 어려워 발생하게 된다면 해결이 쉽지 않다. 초기 자바에서는 이를 해결하기 위해 synchronized만 지원하였고 이후에 이를 보완하는 많은 라이브러리가 등장하였다.
2) JMM의 이해
자바 명세에 따르면 JMM은 다음과 같은 내용을 반드시 이행해야 한다.
- 순서의 보장
- 여러 스레드에 대한 업데이트 가시성 보장
JMM은 두 가지 방식으로 접근한다.
- 강한 메모리 모델: 전체 코어가 항상 같은 값을 바라본다
- 약한 메모리 모델: 코어마다 다른 값을 볼 수 있고 그 시점을 제어할 수 있는 규칙이 있다.
강한 메모리 모델이 간편하게 구현할 수 있지만 모든 코어가 최신 데이터를 유지해야 하므로 캐시 무효화가 빈번하게 발생한다. 이는 CPU 캐시를 활용하기 어렵고 자주 메모리에 접근하면서 메모리 버스 사용량이 급격하게 증가할 수 있다. 따라서 JMM은 약한 메모리 모델로 구현되어 있고 코어마다 다른 값을 볼 수 있는 문제를 해결하기 위해 JVM 구현체에서 여러 기능을 제공한다.
3. 동시성 라이브러리 근간 개념
1) Atomics와 CAS
일부 동시성 라이브러리에서는 CAS(Compare And Swap)를 구현한다. AtomicInteger, AtomicLong이 CAS를 구현하는 대표적인 카운터이다.
JVM에서는 Unsafe 클래스를 활용(공식 지원 API는 아니다)하여 CAS를 구현한다. Unsafe는 프로세서별 하드웨어 특성을 이용하거나 포인터 수준의 연산을 수행하는 등 저수준의 동작을 수행할 수 있다.
- 현재 메모리의 예상 값(처음 알고 있던 값)을 메모리의 값과 비교한다.(Unsafe의 getIntVolatile)
- 두 값이 일치하면 현재 값을 수정하여 새로운 값으로 교체한다.(unsafe의 compareAndSwapLong)
- 두 값이 일치하지 않으면 다른 스레드에서 이미 수정 했으므로 수정된 값으로 다시 CAS를 시도한다.
1 ~ 3 과정은 unsafe 내부에서 하나의 CPU 명령어로 수행된다. lock cmpxchg [address], new_value
해당 코드는 1 ~ 3 과정을 수행하는 x86 CPU의 명령어로 1~3 과정이 원자적으로 수행되기 때문에 동시성 문제를 해결할 수 있다.
2) 락과 스핀락
인트린직(Intrinsic) 락은 유저 코드에서 OS를 호출하여 블로킹이 일어난다. OS가 신호를 줄 때까지 대기 상태가 되고 신호를 받으면 컨텍스트 스위칭이 발생한다. 따라서 짧은 시간동안 락을 자주 걸어야 하는 상황에서 인트린직 락을 사용하기 위해 많은 컨텍스트 스위칭이 수행되어 성능이 저하될 수 있다.
반면 스핀락은 대기 상태로 전환되지 않고 지속적으로 리소스를 소모하며 락을 손에 넣을 때까지 재시도한다. 리소스를 계속 점유하고 있지만 컨텍스트 스위칭이 수행되지 않아 짧은 시간동안 락을 자주 걸어야 하는 상황에서는 스핀락이 효율적일 수 있다.
4. 동시 라이브러리들
1) synchronized
자바에서 원자성과 가시성 그리고 순서를 보장하는 가장 기본적인 키워드 하지만 다음과 같은 단점이 있다.
- 인트린직 락을 사용하므로 컨텍스트 스위칭이 발생해 성능 저하
- 읽기 락/쓰기 락과 같은 세밀한 제어가 불가능
- 메서드 수준과 블록 내부에서만 락 획득/해제가 가능하여 조건부 락이나 수동 해제 등의 구현은 어렵다
2) java.util.concurrent
Lock 인터페이스는 synchronized보다 더 많은 일을 할 수 있다.
- lock(): 기존 락 획득과 동일
- newCondition(): 락에 조건을 설정할 수 있다
- tryLock(): 스핀락 전환(락을 얻을 때까지 계속 시도)
- unlock(): 락 해제
ReentrantLock
Lock 인터페이스의 대표적인 구현체 내부적으로 CAS를 사용하여 경합이 없을 때는 락프리하여 좋은 성능을 보인다 경합이 발생하면 나머지 스레드는 큐에 들어가 대기해야 하므로 컨텍스트 스위칭이 발생
LockSupport
여러 개의 퍼밋(허가증)을 발급하는 세마포어와 달리 LockSupport는 단 하나의 퍼밋만을 발급한다.
- park(Object blocker): 다른 스레드가 unpark()를 호출하거나, 인터럽트되기 전까지 블로킹
- parkNanos, parkUntil: 나노초, 밀리초 단위로 블로킹 자동 해제 설정 가능
ReentrantReadWriteLock
ReadLock과 WriteLock을 활용하면 읽기 작업만이 일어나는 경우 다른 읽기 스레드를 블로킹하지 않게 할 수 있다. 또한 공정/불공정모드를 설정하여 공정 모드에서 각 스레드가 대기하는 시간을 최대한 균등하게 분배할 수 있도록 설정할 수 있다.
3) 세마포어
최대 N개의 객체까지만 액세스를 허용하도록 구현한다. 초기 퍼밋을 N개로 설정하고 acquire() 메서드를 호출할 때마다 사용가능한 퍼밋 수를 하나씩 줄인다. 사용가능한 퍼밋이 없을 경우 대기 모드로 들어가게 된다. release() 메서드를 활용하여 퍼밋을 반납하고 대기 중인 스레드 하나를 활성화할 수 있다.
이 외의 세마포어의 특징은 다음과 같다
- 본인이 소유하고 있지 않은 락을 해제할 수 있다
- 퍼밋을 여러 개 획득, 해제할 수 있으며 이 때 스레드가 고갈될 가능성이 크기 때문에 세마포어는 일반적으로 공정 모드를 사용한다
4) 동시 컬렉션
- ConcurrentHashMap
ConcurrentHashMap은 버킷, 세그먼트로 분할된 구조를 최대한 활용하여 실질적인 성능 개선 효과를 얻는다 각 세그먼트마다 자체적으로 락을 가지고 있어 전체 데이터 중 일부 세그먼트만 쓰기 락을 걸어 다른 세그먼트에서는 여전히 쓰기가 가능하도록 할 수 있다. 읽기 스레드는 일반적으로 락을 걸지 않는다.
- CopyOnWriteArrayList, CopyOnWriteArraySet
쓰기가 발생할 때마다 새로운 배열을 생성하고 변경 사항을 반영하는 쓰레드 안전 리스트이다 따라서 읽기 도중 다른 쓰레드에서 쓰기가 발생하면 읽기 쓰레드는 이전 스냅샷을 보고 있기 때문에 이전 데이터를 계속 참조할 수 있다. 만약에 어떠한 곳에서 스냅샷을 참조하지 않으면 가비지 수집 대상이 된다 읽기 횟수가 월등히 많은 쓰레드에서는 CopyOnWrite 방식이 효율적으로 동작한다
5) 래치와 배리어
전체 태스크 내의 서브 태스크에서 순서가 존재한다면 래치를 고려할 수 있다 만약 서브태스크#1 → 서브태스크#2 방식으로 서브태스크#1이 모두 완료된 후 서브태스크#2를 수행해야 한다면 이 사이 래치를 도입할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
Runnable subTask = () -> {
try {
서브태스크#1
latch.countDown();
} catch (Exception e) {}
};
//해당 서브태스크를 여러 쓰레드로 실행
//카운트가 0이 될때까지 기다림(모든 쓰레드가 실행 될때까지)
latch.await();
서브태스크#2
처음 래치를 생성할 때 초기 count를 설정할 수 있고 서브태스크#1이 끝나면 count를 1 줄이고 다른 스레드에서 작업이 끝나 count가 0일 될 때까지 대기한다.
6. Executor와 태스크 추상화
일반 자바 프로그래머는 저수준의 스레드 문제를 직접 처리하기 보다는 위의 java.util.concurrent 패키지를 적절히 사용하는 것이 좋다.
1) 비동기 실행
자바에서 동시에 수행할 수 있는 태스크를 추상화하기 위해서 Callable, Runnable 인터페이스를 활용할 수 있다. Callable은 결과값을 반환하거나 예외를 던질 수 있고 Runnable은 결과를 반환하거나 예외를 던지지 않는다. 따라서 값을 반환해야 하는 경우 Callable을 사용하는 것이 좋다.
ExecutorService는 스레드 풀에서 태스크가 실행되는 메커니즘을 규정한 인터페이스이다. Executors는 헬퍼 클래스로 다양한 스레드 풀을 생성하는 여러 new 팩토리 메서드 시리즈를 제공한다.
- newFixedThreadPool: 크기가 고정된 스레드 풀을 지닌 ExecutorService를 생성
- newCachedThreadPool: 필요한 만큼 스레드를 생성하되 가급적 스레드를 재사용하는 ExecutorService를 생성(크기가 고정되어 있지 않음)
- newSingleThreadExecutor: 스레드 하나만 가동되는 ExecutorSerivce를 생성
- newScheduledThreadPool: 지정된 시간 간격으로 작업을 실행하는 ExecutorService를 생성
2) ExecutorService 선택하기
상황에 따라 올바른 ExecutorService를 선택하고 풀 스레드 개수를 적절히 정하면 성능이 뚜렷이 향상된다 ExecutorService를 튜닝할 때 사용되는 지표는 코어 수 대비 풀 스레드 수이다. 동시 실행 스레드 개수가 프로세스 개수보다 높으면 경합이 발생하여 컨텍스트 스위칭이 자주 일어나는 문제가 있다.
3) 포크/조인
개발자가 손수 스레드를 관리하지 않도록 ForkJoinPool이라는 ExecutorService를 활용할 수 있다.
- 하위 분할 태스크를 효율적으로 처리할 수 있다.
- 작업 빼앗기 알고리즘을 구현한다.
하위 분할 태스크는 표준 자바 스레드보다 가벼워 적은 수의 실제 스레드로 아주 많은 태스크를 담당하는 유스케이스에 활용한다. 작업 빼앗기 알고리즘은 어느 스레드가 자신이 할당받은 작업을 모두 마친 후 다른 스레드에 남아있는 큐에 대기 중인 작업을 가져와 실행할 수 있다.
ForkJoinPool은 직접 자체 풀을 생성해서 공유할 필요가 없고 commonPool
메서드가 전체 시스템의 스레드 풀을 반환한다. 풀 크기는 런타임에서 가용할 수 있는 프로세서 개수 - 1 값으로 정해지며 이는 아래 플래그로 수정할 수 있지만 모든 플래그를 수정하는 작업은 플래그를 수정할 때 지켜야 할 원칙을 준수해야 한다.
1
-Djava.util.concurrent.ForkJoinPool.common.parallelism=128
7. 최신 자바 동시성
처음 동시성 개념이 자바에 도입되었을 때는 방향성이 완벽하게 논의되지 않았다. 따라서 자바의 나머지 기능은 고수준으로 추상화했음에도 동시성 프로그래밍 만은 다른 기능보다 훨씬 저수준으로 추상화되었다. 현대 자바는 이러한 문제점을 인식하여 동시성 문제를 고수준으로 추상화하였다.
1) 스트림과 병렬 스트림
자바 스트림은 불변 데이터 시퀀스로 모든 타입의 데이터소스(컬렉션, I/O)에서 추출할 수 있다. Collection 인터페이스에서 parallelStream을 이용하면 병렬로 데이터를 작업한 후 그 결과를 재조합할 수 있다. Spliterator를 써서 작업을 분할하고 ForkJoinPool에서 연산을 수행한다. 스트림은 처음부터 불변이므로 쓰기 작업이 이루어지지 않아 상태 변경으로 인한 문제를 예방할 수 있다.
하지만 parallelStream은 만능이 아니며 태스크를 분할하고 결과를 다시 취합할 때 암달의 법칙에서 이야기하는 순차 실행이 일어난다. 컬렉션이 작다면 오히려 병렬 연산보다 직렬 연산이 빠르며 parallelStream을 도입하기 위해서는 성능 측정이 필수적이다.
2) 락프리 기법
기존 인트린직 락의 문제점은 컨텍스트 교환으로 인한 성능 저하이다. 이러한 문제를 해결하기 위해 CAS를 활용한 락프리 기법이 등장하게 되었으며 락프리 기법을 적용한 이벤트 처리 패턴인 디스럽터 패턴은 실험 결과 초당 작업 처리율이 많이 향상되었다. 하지만 락프리 기법은 다른 스레드가 값을 변경하면 계속 CAS를 시도하게 되고 이 과정에서 리소스가 낭비된다는 단점이 있어 소프트웨어를 저수준까지 잘 이해하고 실행하는 능력이 필요하다.
3) 액터 기반 기법
태스크를 스레드 하나보다 더 작게 나타내려는 포크/조인과 다른 또 다른 접근 방식이다. 액터는 상태, 로직, 다른 액터와 소통하는 메일박스를 가진 독립적인 처리 단위이다. 다른 액터와 소통할 때에는 불변 메세지를 통해서만 상호 통신하여 상태를 관리한다.
JVM 계열에서는 아카(Akka)라는 액터 기반 시스템 개발 프레임워크가 유명하다. 전통적인 락 체계에 비해 아카가 유리한 점은 다음과 같다.
- 락을 사용한 블로킹이 없어 데드락을 비롯한 몇몇 문제를 예방할 수 있다.
- 액터는 독립적인 상태를 가지고 있으므로 처리율이 증가한다.(기존 상태 공유와 쓰기 잠금 방식은 쓰기 시 다른 스레드는 해당 상태를 변경하거나 읽을 수 없다)
- 기존 락 방식에서 변하는 상태를 캡슐화하는 작업은 까다롭다.(기존에는 클래스 외부에서 데이터의 레퍼런스를 변경할 수 있다)
1
2
3
4
5
6
7
8
9
10
11
private final List<String> items = new ArrayList<>();
private final ReentrantLock lock = new ReentrantLock();
public List<String> getItems() {
lock.lock();
try {
return items; //상태(데이터)가 그대로 외부에 노출
} finally {
lock.unlock();
}
}
이 때 items 레퍼런스가 변경된다면 캡슐화가 깨지고 락을 사용하더라도 해당 상태를 보호할 수 없다. 액터는 내부에서 상태를 직접 변경하고 외부에는 메세지 인터페이스만 제공하여 캡슐화를 유지할 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
class ItemActor extends AbstractActor {
private final List<String> items = new ArrayList<>();
@Override
public Receive createReceive() {
return receiveBuilder()
// 외부에 노출되지 않고 상태 변경
.match(AddItemMessage.class, msg -> items.add(msg.item))
.match(GetItemsMessage.class, msg ->
sender().tell(
new ResponseMessage(new ArrayList<>(items)), self()
)
)
.build();
}
}
8. 정리
- 상황에 따라 여러 동시 프레임워크 옵션을 고려해야 한다(스핀락 vs 인트린직락, 스레드 수보다 더 많은 작업을 수행하기 위한 포크조인 등)
- 저수준의
new Thread()
보다 java.util.concurrent나 ExecutorService 등의 여러 고수준의 추상화된 옵션을 고려하며 수동으로 락을 거는 행위는 자제하는 것이 좋다. - 항상 암달의 법칙을 고려하여 무작정 병렬 프로그래밍으로의 전환은 오히려 성능이 떨어질 수 있음을 인지한다.(성능 테스트가 필수)