Published on

Optimizing Java Chap12

Authors
  • avatar
    Name
    ywj9811
    Twitter

들어가며

과거에는 용량 문제가 발생하면 그저 새로 출시된, 더 큰 장비에 소프트웨어를 옮기는 방식으로 문제를 해결하곤 했었다. 하지만 현재는 멀티코어 프로세서가 일반화한 시대이고 따라서 멀티코어에 어플리케이션 부하를 고루 분산시켜 처리할 수 있다. 이러한 과정 속에서 자바 개발자는 최소한 동시성이란 무엇인지, 그것이 어플리케이션 성능에 끼치는 영향은 무엇인지 최소한 배경지식은 갖고 있어야 한다.

병렬성이란?

요즘의 멀티코어 세상에서는 암달의 법칙이 연산 태스크의 실행 속도를 향상 시키는 핵심 요소이다.

  • 암달의 법칙 : 태스크는 병렬 실행 가능 파트 + 반드시 순차 실행 파트로 구성되기에 성능 향상에는 한계가 있다.
순차 실행 파트를 S, 총 태스크 소요 시간을 T, 프로세서 개수가 N
T(N) = S + (T-S)/N
이렇게 프로세서 개수별 총 태스크 소요 시간을 구할 수 있다.
즉, 프로세서를 무한히 증가시켜도 총 소요 시간은 순차 작업 시간보다는 줄일 수 없다.

만약 병렬 태스크나 다른 순차 태스크 간에 소통할 필요가 전혀 없을 경우에는 이론적으로 속도는 무한히 높일 수 있다. 이러한 상황에서는 동시 처리는 사실상 식은 죽 먹기일 것이다.

하지만, 보통 스레드끼리 상태나 데이터를 공유하기에 워크로드는 점차 복잡해지면서 결국 어쩔 수 없이 일부 태스크를 순차 처리하게 되고 통신 오버헤드가 발생하게 된다.

즉, 상태를 공유하는 워크로드는 무조건 정교한 보호/제어 장치가 필요하며, 자바 플랫폼은 JVM에서 실행되는 워크로드에 JMM이라는 메모리 보증 세트를 제공한다.

자바 동시성 기초

public class Counter {
	private int i = 0;

	public int increment() {
		return i = i+1;
	}
}

이 경우 카운터를 락으로 적절히 보호하지 않은 상태로 멀티스레드 환경에서 이 코드를 실행하면, 다른 스레드가 저장하기 이전에 로드 작업이 일어날 가능성이 있다.

따라서 서로 다른 스레드가 increment() 함수를 호출하여 총 2번 호출하여도 +2가 아닌 +1이 되는 결과가 나올 수 있는 것이다.

이러한 문제는 synchronized 로 감싸서 int같은 단순 값의 업데이트를 제어하면 해결할 수 있다.

자바 5 이전에는 이 방법이 유일하기도 했다.

하지만 동기화를 사용할 때에는 아주 신중하게 설계하고 미리 잘 따져봐야 한다는 부감이 따른다. 경우에 따라 프로그램이 빨라지기는 커녕 더 느려질 수 있기 때문이다.

이렇게 처리율 향상은 동시성을 부여하는 전체 목표와 상충되기에 코드 베이스를 병렬화 하는 작업을 진행할 때는 복잡도가 늘어난 대가로 얻은 혜택을 충분히 입증할 수 있도록 성능 테스트가 수반되어야 한다.

JMM의 이해

JMM은 메모리 모델로 자바 명세서에서 이를 수학적으로 기술된 메모리 형태로 등장시킨다.

이는 많은 개발자가 자바 명세의 넘사벽으로 인정한다고 하는데, 어떤 질문에 답을 찾는 모델일까.

  • 두 코어가 같은 데이터를 액세스하면 어떻게 되는가?
  • 언제 두 코어가 같은 데이터를 바라본다고 장담할 수 있는가?
  • 메모리 캐시는 위 두 질문의 답에 어떤 영향을 미치는가?

자바 플랫폼은 공유 상태를 어디서 액세스하든지 JMM이 약속한 내용을 반드시 이행하는데, 약속의 내용은 다음과 같다.

  • 순서에 관한 보장
  • 여러 스레드에 대한 업데이트 가시성 보장

고수준에서 JMM 같은 메모리 모델은 두가지 방식으로 접근한다.

  • 강한 메모리 모델
    • 전체 코어가 항상 같은 값을 바라본다.
  • 약한 메모리 모델
    • 코어마다 다른 값을 바라볼 수 있고 그 시점을 제어하는 특별한 캐시 규칙이 있다.

멀티코어 시스템에서 강한 메모리 모델을 구현한다면 사실상 메모리를 후기록 하는 것과 같다.

💡후기록(write-back)
	데이터 변경시, 동시 기록(write-through)는 메모리 및 캐시에 모두 쓰는 것이고 
	후기록(write-back)은 캐시에만 반영하고 메모리는 쓰지 않는다.

캐시 무효화 알림이 메모리 버스를 잠식하고 실제 메인 메모리 전송률은 급락할 것이다.

즉, 코어를 늘리는 건 상황을 더욱 악화시키는 것이니 이런 방법은 근본적으로 멀티코어 체제에 맞지 않다.

이어서 JMM을 살펴보면, JMM은 다음 기본 개념을 기반으로 어플리케이션을 보호한다.


Happens-Before (~보다 먼저 발생)

한 이벤트는 무조건 다른 이벤트보다 먼저 발생한다.

Synchronizes-With (~와 동기화)

이벤트가 객체 뷰를 메인 메모리와 동기화 시킨다.

As-If-Serial (순차적인 것처럼)

실행 스레드 밖에서는 명령어가 순차 실행되는 것처럼 보인다.

Release-Before-Acquire (획득하기 전에 해제)

한 스레드에 걸린 락을 다른 스레드가 그 락을 획득하기 전에 해제한다.


동기화를 통한 락킹은 가변 상태를 공유하는 가장 중요한 기법으로 동시성을 다루는 자바의 근본적인 관점을 대변한다.

자바에서 스레드는 객체 상태 정보를 스스로 들고 다니며 스레드가 변경한 내용은 메인 메모리에 곧장 반영되고 같은 데이터를 액세스하는 다른 스레드가 다시 읽는 구조이다.

이러한 맥락에서 synchronized 키워드가 나타내는 의미는 ‘모니터를 장악한 스레드의 로컬 뷰가 메인 메모리와 동기화 되었다.’ 라는 뜻이다.

따라서 동기화 메소드, 동기화 블록은 스레드가 반드시 동기를 맞춰야 할 접점에 해당하며, 다른 동기화 메소드/블록이 시작되기 전에 반드시 완료되어야 할 코드 블록을 정의해 놓은 것이다.

또한, JMM은 동기화하지 않은 액세스에 대해서는 아무 것도 보장하지 않는다.

따라서 한 스레드가 변경한 부분을 다른 스레드가 언제 바라볼 수 있는지를 보장하기 위해서는 쓰기 액세스를 동기화 블록으로 감사 캐시된 값을 메모리에 후기록 해야 하며 읽기 액세스도 동기화 코드 섹션 내부에 넣어서 강제로 메모리를 다시 읽도록 해야 한다.

하지만 synchronized 의 한계점이 여럿 노출되었다.

  • 락이 걸린 객체에서 일어나는 동기화 작업은 모두 균등하게 취급된다.
  • 락 획득/해제는 반드시 메소드 수준이나 메소드 내부의 동기화 블록 안에서 이루어져야 한다.
  • 락을 얻지 못한 스레드는 블로킹된다. 락을 얻지 못할 경우, 락을 얻어 처리를 계속하려고 시도하는것 조차 불가능하다.

동시성 라이브러리 구축

자바 5부터는 언어 수준에서 지원하는 기능에서 탈피하여 고급 동시성 라이브러리와 툴을 자바 클래스 라이브러리의 일부로 표준화하려는 움직임이 확산되는 추세이다.

java.util.concurrent 패키지는 멀티스레드 어플리케이션을 자바로 쉽게 개발할 수 있게 설계된 라이브러리이다.

  • 락, 세마포어(semaphore)
  • 아토믹스(atomics)
  • 블로킹 큐
  • 래치
  • 실행자(executor)

이러한 몇가지 일반 카테고리로 분류된다.

일반적으로 라이브러리는 OS 품이 아닌 유저 공간에서 더 많은 일을 하려고 한다.

하지만 일부 라이브러리(락, 아토믹스)는 비교해서 바꾸기(CAS)라는 기법을 구현하기 위해 저수준 프로세서 명령어 및 OS별 특성을 활용한다.

CAS는 ‘예상되는 현재 값’ 과 ‘원하는 새 값’, 그리고 메모리의 위치를 전달받아

  1. 예상되는 현재 값을 메모리 위치에 있는 콘텐츠와 비교
  2. 두 값이 일치하면 현재 값을 원하는 새 값으로 교체

이러한 두가지 일을 하는 아토믹 유닛이다.

이러한 CAS는 여러가지 중요한 고수준의 동시성 기능을 구성하는 기본 요소이다.

Unsafe

최신 프로세서가 꽃힌 대부분의 하드웨어가 CAS 기능이 구현되어 있지만 JMM 또는 자바 플랫폼 명세서에는 CAS 이야기가 나오지 않는다.

사실 CAS는 구현체별 확장 기능이라고 볼 수 있으므로 CAS 하드웨어는 sun.misc.Unsafe 클래스를 통해 액세스 한다.

⚠️Unsafe는 공식적으로 지원하지 않는 내부 API라서 언제라도 유저 어플리케이션을 배려하지 않은 채
	없어지거나 변경될 수 있다.
	자바9 부터는 jdk.unsupported 패키지로 위치를 옮겼다.

이는 내부 구현 클래스로, 표준 자바 플랫폼 API가 아니며 또한, 클래스명에 걸맞게 어플리케이션 개발자가 이 클래스를 직접 사용할 일은 거의 없다.

하지만 어쩌다 보니 JVM의 표준 로직을 무너뜨리는 수단인 Unsafe는 거의 모든 주요 프레임워크의 구현 핵심부를 차지하게 되었는데, Unsafe를 통해 다음 일을 할 수 있다.

  • 객체는 할당하지만 생성자는 실행하지 않는다.
  • 원메모리(raw memory)(자료형이 따로 없이 바이트 배열 단위로 취급하는 메모리 블록)에 액세스하고 포인터 수준의 연산을 수행
  • 프로세서별 하드웨어 특성(Ex. CAS)를 이용한다.

따라서 다음과 같은 고수준의 프레임워크 기능을 구현할 수 있다.

  • 신속한 (역)직렬화
  • Thread-safe 네이티브 메모리 액세스
  • 아토믹 메모리 연산
  • 효율적인 객체/메모리 레이아웃
  • 커스텀 메모리 펜스(memory fence)(memory barrior라고도 하며, 연산의 실행 순서를 CPU나 컴파일러가 함부로 바꾸지 못하도록 강제하는 기능)
  • 네이티브 코드와의 신속한 상호 작용
  • JNI에 관한 다중 운영체제 대체물
  • 배열 원소에 volatile하게 액세스

Unsafe는 공식 표준은 아니지만, 필요한 특성을 담아두는 보관 장소로 쓰이게 됐다.

하지만, 자바9 부터 영향을 받게 됐고 차기 자바 버전 몇개에 걸쳐 크게 변모할 가능성이 높다.

아토믹스와 CAS

아토믹스는 값을 더하고 증감하는 복합 연산을 하며 get() 으로 계산한 결과 값을 돌려받는다.

아토믹 변수는 volatile 확장판이라 할 수 있지만, volatile보다 더 유연해서 상태 의존적 업데이트를 안전하게 수행할 수 있다.

또한 아토믹스는 자신이 감싸고 있는 베이스 타입을 상속하지 않고 직접 대체하는 것도 허용되지 않는다.

Unsafe로 단순 아토믹 호출을 구현하는 원리를 알아보자.

public class AtomicIntegerExample extends Number {

    private volatile int value;

    // Unsafe.compareAndSwapInt로 업데이트하기 위해 설정
    private static final Unsafe unsafe = Unsafe.getUnsafe();
    private static final long valueOffset;

    static {
        try {
            valueOffset = unsafe.objectFieldOffset(
                AtomicIntegerExample.class.getDeclaredField("value"));
        } catch (Exception ex) {
            throw new Error(ex);
        }
    }

    public final int get() {
        return value;
    }

    public final void set(int newValue) {
        value = newValue;
    }

    public final int getAndSet(int newValue) {
        return unsafe.getAndSetInt(this, valueOffset, newValue);
    }
    // 생략
}

Unsafe에 있는 메소드(아래 getAndSetInt())를 사용했으며 JVM을 호출하는 네이티브 코드가 핵심이다.

public final int getAndSetInt(Object o, long offset, int newValue) {
    int v;
    do {
        v = getIntVolatile(o, offset);
    } while (!compareAndSwapInt(o, offset, v, newValue));
    return v;
}

public native int getIntVolatile(Object o, long offset);

public final native boolean compareAndSwapInt(Object o, long offset, 
                                              int expected, int x);

Unsafe 내부에서 루프를 이용해 CAS 작업을 반복적으로 재시도 한다.

아토믹은 데드락이 존재할 수 없다. 다만, 변수를 업데이트하기 위해 여러 차례 재시도를 해야 한다면 성능이 나빠질 수 있기에 처리율을 높은 수준으로 유지하기 위해 경합 수준을 잘 모니터링 해야 한다.

락과 스핀락

블로킹된 스레드를 CPU에 활성 상태로 놔두고 아무 일도 시키지 않은 채 락을 손에 넣을 때까지 ‘CPU를 태워가며’ 계속 재시도 하는 방법, 이것이 스핀락이다.

이는 완전히 상호 배타적인 락보다 가볍게 쓰자는 것이다.

최신 시스템은 대부분 하드웨어가 지원하리라 가정하고 CAS로 스핀락을 구현한다.

이에 대한 구현의 핵심 개념은 다음과 같다.

  • ‘테스트하고 세팅’ 하는 작업은 반드시 아토믹해야 한다.
  • 스핀락에 경합이 발생하면 대기중인 프로세서는 tight loop(명령어는 별로 없지만 아주 여러번 반복되는 루프)를 실행하게 된다.

CAS는 예상한 값이 정확할 경우 한 명령어로 값을 안전하게 업데이트하며, 락의 구성 요소를 형성하는 데 한몫을 한다.

동시 라이브러리 정리

표준 라이브러리에서 이전까지 살펴본 기능을 어떻게 활용하여 완전한 범용 제품 라이브러리를 구축하는가.

java.util.concurrent 락

락은 자바5 부터 개편되어 좀 더 일반화한 락 인터페이스가 java.util.concurren.locks.Lock 에 추가됐다.

lock()

기존 방식대로 락을 획득하고 락을 사용할 수 있을때 까지 블로킹한다.

newCondition()

락 주위에 조건을 설정해 좀 더 유연하게 락을 활용하며 락 내부에서 관심사를 분리할 수 있다.

tryLock()

락을 획득하려고 시도하며(타임아웃 옵션 설정 가능) 덕분에 스레드가 락을 사용할 수 없는 경우에도 계속 처리를 진행할 수 있다.

unlock()

락을 해제하며 lock() 에 대응되는 후속 호출이다.


ReetrantLock은 Lock의 주요 구현체로, 내부적으로 int 값으로 compareAndSwap() 을 한다.

따라서 경합이 없는 경우에는 락을 획득하는 과정이 락-프리 하다. 그래서 락 경합이 별로 없는 시스템은 성능이 매우 좋아지고 다양한 락킹 정책을 적용 가능한 유연성 또한 얻게 된다.

compareAndSwap() 을 호출하고 Unsafe를 사용한 코드는 AbstractQueuedSynchronizer를 확장한 정적 서브클래스 Sync에 있으며 AbstractQueuedSynchronizer는 LockSupport클래스를 활용한다.

읽기/쓰기 락

읽기는 상태를 바꾸지 않지만 쓰기는 상태를 바꾼다.

하지만 여러 읽기 쓰레드가 하나의 쓰기 스레드에 달려드는 상황에서 어느 한 읽기 스레드 때문에 나머지 읽기 스레드를 블로킹하느라 불필요한 시간을 허비할 가능성이 있다.

이럴 때 ReentrantReadWriteLock 클래스의 ReadLock과 WriteLock을 활용하면 읽기와 쓰기에 락을 분리하여 여러 스레드가 읽기 작업을 하는 도중에도 다른 읽기 스레드를 블로킹 하지 않을 수 있다.

세마포어

세마포어는 풀 스레드나 DB 접속 객체 등 여러 리소스의 액세스를 허용하는 독특한 기술을 제공한다.

‘최대 N개 객체까지만 액세스를 허용한다.’라는 전제하에 정해진 수량의 Permit으로 액세스를 제어한다.

// Permit은 2개, 공정 모드로 설정된 세마포어 생성
private Semaphore poolPermits = new Semaphore(2, true);

Semaphore클래스의 acquire() 는 사용 가능한 Permit 수를 하나씩 줄이며 제공하며, 더이상 줄일 수 없다면 블로킹한다. 또한, release() 는 반납을 하고 대기 중인 스레드 중에서 하나에게 반납한 Permit을 제공한다.

동시 컬렉션

Map 구현체 ConcurrentHashMap은 버킷 혹은 세그먼트로 분할된 구조를 최대한 활용하여 실질적인 성능 개선 효과를 얻는다.

Iterator는 일종의 스냅샷으로 획득하기 때문에 ConcurrentModificationException이 발생할 일이 없다는 사실이 중요한데, 충돌이 많을 경우 테이블을 동적으로 팽창한다.

하지만 이러한 작업은 비용이 많이 들기 마련이라 코드를 작성할 때 대략 예상되는 크기를 미리 지정하는 것이 좋다.

또한 CopyOnWriteArrayList, CopyOnWriteArraySet이 자바5 부터 도입되어 때때로 멀티스레드 성능이 향상될 수 있다.

래치와 배리어

래치와 배리어는 스레드 세트의 실행을 제어하는 유용한 기법이다.

워커 스레드로 다음과 같은 작업을 한다는 가정을 해보자.

  1. API로 데이터를 조회 후 파싱한다.
  2. 그 결과를 DB에 쓴다.
  3. 끝으로, SQL 쿼리로 결괏값을 계산한다.

시스템이 그냥 전체 스레드를 실행하면 이벤트 순서는 어떻게 될지 알 수 없다. 모든 스레드가 #1 → #2 → #3 순서로 태스크가 진행되는 것이 이상적이라면 래치를 쓰기에 딱 좋은 경우일 것이다.

public class LatchExample implements Runnable {

    private final CountDownLatch latch;

    public LatchExample(CountDownLatch latch) {
        this.latch = latch;
    }

    @Override
    public void run() {
				//API 호출
        System.out.println(Thread.currentThread().getName() + "Done API Call");
        try {
            latch.countDown();
            latch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() + " Continue processing");
    }

    public static void main(String[] args) throws InterruptedException {
        CountDownLatch apiLatch = new CountDownLatch(5);

        ExecutorService pool = Executors.newFixedThreadPool(5);
        for (int i = 0; i < 5; i++) {
            pool.submit(new CountdownLatchEx(apiLatch));
        }

        System.out.println(Thread.currentThread().getName() + " about to await on main");
        apiLatch.await();
        System.out.println(Thread.currentThread().getName() + " done awaiting on main");
        pool.shutdown();
        try {
            pool.awaitTermination(5, TimeUnit.SECONDS);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("API Processing Complete");
    }
	}
}

위의 코드를 살펴보면 래치 카운트를 처음에 5로 세팅하고 각 스레드가 countdown() 을 호출할 때마다 카운트 값은 1씩 감소한다. 따라서 카운트가 0이 되면 래치가 열리고 await() 함수 때문에 매여 있던 스레드가 모두 해제되어 처리를 재개한다.

하지만 이런 유형의 래치는 단 한번밖에 사용할 수 없다는 점을 유의해야 한다. (리셋이라는 개념이 없다.)

리셋이 가능한 CyclicBarier를 사용하는 방법 또한 있지만 이는 관리가 복잡하기 때문에, 파이프라인 각 단계마다 배리어/래치를 적용하는 것이 일반적인 Best Practice이다.

실행자와 태스크 추상화

스레딩 문제가 거의 없는 추상화 수준은 동시 태스크, 즉 현재 실행 컨텍스트 내에서 동시 실행해야 하는 코드나 작업 단위로 기술할 수 있다. 일의 단위를 태스크로 바라보면 태스크를 실행하는 실제 스레드의 수명주기를 개발자가 일일이 신경 쓸 필요가 없어지기에 동시 프로그래밍을 단순화 할 수 있다.

비동기 실행이란?

자바에서 태스크를 추상화 하는 방법은 값을 반환하는 태스크를 Callable<V> 인터페이스로 나타내는 것이다.

이는 call() 메소드 하나밖에 없는 제네릭 인터페이스로 call() 은 V형 값을 반환하되 결과값을 계산할 수 없으면 예외를 던진다.

이는 어떻게 실행될까?

ExecutorService 는 관리되는 스레드 풀에서 태스크 실행 매커니즘을 규정한 인터페이스로 이 인터페이스를 구현한 코드는 풀에 담긴 스레드를 어떻게 관리하고 개수를 몇개까지 둘지를 정의한다.

ExecutorServicesubmit() 메소드를 통해 Runnable 혹은 Callable 객체를 받는다.

Executors는 헬퍼 클래스로, 선택한 로직에 따라서 서비스 및 기반 스레드 풀을 생성하는 new* 팩토리 메소드 시리즈를 제공한다.

  • newFixedThreadPool(int nThreads)
    • 크기가 고정된 스레드 풀을 지닌 ExecutorService를 생성
  • newCachedThreadPool()
    • 필요한 만큼 스레드를 생성하되 가급적 스레드를 재사용하는 ExecutorService를 생성
  • newSingleThreadExcecutor()
    • 스레드 하나만 가동되는 ExecutorService를 생성
  • newScheduleThreadPool(int conrePoolSize)
    • 미래 특정 시점에 태스크를 실행시킬 수 있도록 Callable과 지연 시간을 전달받는 메소드들이 있다.

ExecutorServicesubmit() 메소드를 호출하면 Future<V> 가 반환되고, 여기서 get() 혹은 타임아웃을 명시한 get() 으로 블로킹하거나, 일반적인 방식대로 isDone() 으로 논블로킹 호출을 한다.

ExcecutorService 선택하기

올바른 ExecutorService 를 선택하면 비동기 프로세스를 적절히 잘 제어할 수 있고, 풀 스레드 개수를 정확히 잘정하면 성능이 뚜렷이 향상될 수 있다.

직접 ExecutorService 를 작성하는 것은 가능하지만 그럴 일은 별로 없고, 커스터마이징하는 데 유용한 옵션은 사실 ThreadFactory 하나뿐이다.

포크/조인

자바7 부터 등장한 포크/조인 프레임워크는 멀티 프로세서 환경에서 효율적으로 작동하는 새로운 API를 제공한다.

이 프레임워크는 ForkJoinPool 이라는 새로운 ExecutorService 구현체에 기반한다.

이는 관리되는 스레드 풀을 제공하며 다음과 같은 두가지 특징이 있다.

  • 하위 분할 태스크(subdivided task)를 효율적으로 처리할 수 있다.
  • 작업 빼앗기(work-stealing) 알고리즘을 구현한다.

ForkJoinPool 는 실행자에서 적은 수의 실제 스레드가 아주 많은 태스크/서브태스크를 담당해야 하는 유스케이스에 주로 사용된다. 이것의 핵심은 자신을 더 작은 서브태스크로 분할하는 능력이다.

또한 작업 배앗기 알고리즘은 태스크 하위 분할과 독립적으로 운용할 수 있으며 이를 통해 여러 스레드가 수행중인 작업량의 균형을 다시 맞추는 건 간단하지만 효과 만점인 영리한 발상이다.

ForkJoinPool에 있는 commonPool() 이라는 정적 메소드는 전체 시스템풀의 레퍼런스를 반환한다.

덕분에 개발자가 직접 자체 풀을 생성해서 공유할 필요가 없고, 공용 풀은 지연 초기화 되므로 필요한 시점에 생성된다.

자바8 이후, 내부적으로 공용 포크/조인 풀을 사용하는 parallelStream() 때문에 포크/조인 활용 범위는 크게 확대되었다.

최신 자바 동시성

스레드는 현대 어플리케이션 개발에 있어서 자바 개발자가 익숙한 코딩 수준에 비해 훨씬 더 저수준으로 추상화한 산물이 됐다.

다행히, 현대 자바는 언어 및 표준 라이브러리에 내장된 추상화를 이용해 성능을 크게 높일 수 있는 환경을 제공한다.

스트림과 병렬 스트림

자바8 부터 추가된 기능인 스트림은 데이터 소스에서 원소를 퍼 나르는 불편 데이터 시퀀스로, 모든 타입의 데이터 소스(Ex. 컬렉션, I/O)에서 추출할 수 있다.

스트림은 람다 표현식, 또는 데이터를 가공하는 함수 객체를 받는 map() 같은 함수를 교묘히 잘 활용하는데 외부 Iteration(기존 루프)을 내부 Iteration(스트림)으로 변경했기 때문에 데이터를 병렬화 하거나 복잡한 표현식의 평가를 지연시킬 수 있다.

모든 컬렉션은 Collection 인터페이스에 있는 stream() 미소드를 제공한다.

두번째 메소드 parallelStream() 을 이용하면 병렬로 데이터를 작업 후 그 결과를 재조합할 수 있다.

이 메소드를 호출하면 내부적으로 Spliterator를 써서 작업을 분할하고 공용 포크/조인 풀에서 연산을 수행한다.

하지만 모든 경우에 parallelStream() 이 더 좋은 것은 아니다.

여느 병렬 연산처럼 태스크를 찢어 여러 스레드에 분배하고 그 결과를 취합하는 과정은 피할 수 없을테니 말이다.

따라서 작은 컬렉션에서는 오히려 직렬 연산이 병렬 연산보다 훨신 빠르다.

락-프리 기법

락-프리 기법은 블로킹이 처리율에 악영향을 미치고 성능을 저하시킬 수 있다는 전제하에 시작한다.

블로킹의 문제점은 스레드를 컨텍스트 교환할 기회가 있다는 사실을 OS에 의지해 나타낸다는 것이다.

몇가지 경우에서 락-프리한 동시성이 얼마나 성능을 향상시킬 수 있는지 확인할 수 있는데 이런 결과의 일등 공신은 바로 스핀락이다.

두 스레드간 동기화는 (모든 스레드가 바라볼 수 있도록) volatile 변수를 통해 효과적으로 수동 제어한다.

CPU 코어를 계속 스피닝 하는 것은 데이터를 받자마자 컨텍스트 교환 없이 즉시 해당 코어에서 작업할 준비를 한다는 것이지만, CPU 코어를 차지하는 것은 사용률, 전력 소비 측면에서 비용이 들기에 고려할 부분이 몇가지 존재한다.

액터 기반 기법

액터는 그 자체로 고유한 상태와 로직을 갖고 있는데, 동시에 다른 액터와 소통하는 메일 박스 체계를 갖춘 작고 독립적인 처리 단위이다.

액터는 가변적인 상태는 일체 공유하지 않고 오직 불변 메시지를 통해서만 상호 통신함으로써 상태를 관리한다.

액터 간 통신은 비동기적이며, 액터는 메시지 수신에 반응하여 정해진 일을 한다.

JVM계열 언어에서는 아카(Akka)라는 액터 기반 시스템 개발용 프레임워크가 널리 알려져 있는데, 스칼라 언어로 작성되었지만 자바 API도 제공하므로 자바 및 다른 JVM언어에서도 사용된다.

아카가 전통적인 락킹 체계보다 더 좋은 이유는 다음과 같다.

  • 도메인 모델 내에서 가변 상태를 캡슐화 하는 건 의외로 까다로운 일이다. 특히, 객체 내부 요소를 가리키는 레퍼런스가 언제라도 제어권 밖으로 벗어날 수 있기 때문에 더욱 그렇다.
  • 상태를 락으로 보호하면 처리율이 크게 떨어질 수 있다.
  • 락을 쓰면 데드락을 비롯한 별별 문제가 유발될 수 있다.

액터 모델은 대체로 동시성 어플리케이션 개발자에게 유용한 툴인 것은 확실하지만 그렇다고 다른 기법 전체를 대체할 수 있는 범용 툴은 아니다.

따라서 액터 방식이 잘 맞는 유스케이스에서는 최상이지만, 다른 경우에는 액터 외의 방식을 찾는 것 또한 현명할 것이다.