Skip to content

Commit

Permalink
[7회차] 아이템 48, 53 - 켬미(김경미) (#69)
Browse files Browse the repository at this point in the history
* 아이템 48. 스트림 병렬화는 주의해서 적용하라

* 아이템 53. 가변인수는 신중히 사용하라

* Rename 스트림_병렬화는_주의해서_적용하라_켬미.md to 07장/아이템_48/스트림_병렬화는_주의해서_적용하라_켬미.md

* Update 스트림_병렬화는_주의해서_적용하라_켬미.md

* Update 스트림_병렬화는_주의해서_적용하라_켬미.md
  • Loading branch information
kyum-q authored Jun 6, 2024
1 parent 420838c commit 31820f7
Show file tree
Hide file tree
Showing 2 changed files with 355 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,240 @@
# 자바에서 병렬 처리 방식

(자바 5부터)
자바에 동시성 컬렉션 'java.util.concurrent' 라이브러리
실행자(Executor) 프레임워크 지원

(자바 7부터)
고성능 병렬 분해(parallel decom-position) 프레임워크인 포크-조인(fork-join) 패키지

(자바 8부터)
parallel 메서드만 한 번 호출하면 파이프라인을 병렬 실행할 수 있는 스트림을 지원

<br>

# 잘 사용해야겠죠?

> 동시성 프로그래밍 할 때 안전성과 응답 가능 상태를 유지하기 위해 애써야 한다.
```java
public static void main(String [] args) {
primes().map(p -> TWO.pow(p.intValueExact()).subtract(ONE))
.filter(mersenne -> marsenne.isProbablePrime(50))
.limit(20)
.forEach(Sysrem.out::println);
}

static Stream<BigInteger> primes() {
return Stream.iterate(TWO, BigInteger::nextProbablePrime);
}
```

> 그냥 실행 : 12.5초
> 스트림 파이프라인의 parallel()을 호출해서 병렬처리 실행 : 응답 없음 진짜 오래 걸림
<br>

### 스트림 라이브러리가 이 파이프라인을 병렬화로는 성능 개선을 기대할 수 없다.

환경이 아무리 좋아도 데이터 소스가 `Stream.iterate`거나 중간 연산자로 `limit`를 쓰면 파이프라인 병렬화로는 성능 개선을 기대할 수 없다.

<br>

### Stream.iterate 는 왜?

iterate 연산은 순서에 의존하는 연산이기 때문에 스트림 원소를 분할하기 어렵다.

#### limit 왜?

> 가정 : limit를 다룰 때 CPU 코어가 남는다면 원소를 몇개 더 처리한 후 제한된 개수 이후의 결과를 버려도 아무런 해가 없다.
이 코드의 경우 새롭게 메르센 소수를 찾을 때마다 그 전 소수를 찾을 때보다 두 배 정도 오래거린다.
즉, 원소 하나를 계산한느 비용이 대략 그 이전까지의 원소 전부를 계산한 비용을 합친 것만큼 든다는 뜻


![](https://i.imgur.com/3raz8Jx.png)

> 그래서 파이프라인은 자동 병렬화 알고리즘이 제 기능을 못하게 마비시키다.
### 그럼 더 안좋아지고 이런데 어떻게 해?

스트림 파이프라인을 마구잡이로 병렬화하면 안된다.

**성능이 오히려 나빠질 수 있다.**

<br>

# 그럼 어떻게 사용하면 좋아요?

<br>

## 생성/중간 연산 좋은 예

- 스트림의 소스 : `ArrayList`, `HashMap`, `HashSet`, `ConcurrentHashMap`의 인스턴스
- 배열 범위 : int 범위, long 범위

**병렬화 효과 굿 !**

<br>

### 왜 좋은데?

<br>

#### 분배가 편해요

이 자료구조들은 모두 데이터를 **원하는 크기로 정확하고 손쉽게 나눌 수 있어**서 일을 **다수의 스레드에 분배하기에 좋다**는 특징이 있다.

작업 나누기는 `Spliterator` 가 담당
- `Spliterator` 객체는 `Stream` 이나 `Iterable``spliterator` 메서드로 얻어올 수 있음

<br>

#### 참조 지역성이 뛰어나요

**이웃한 원소의 참조들이 메모리에 연속**해서 **저장**되어 있다는 뜻
참조들이 가리키는 실제 객체가 메모리에서 서로 떨어져 있을 수 있는데, 그러면 참조 지역성이 나빠진다.

![](https://i.imgur.com/E3ioJbu.png)


참조 지역성이 낮으면 데이터가 주 메모리에서 캐시 메모리로 전송되어 오기를 기다리며 대부분 시간을 멍하니 보내게 된다.

그래서 참조 지역성은 다량의 데이터를 처리하는 벌크 연산을 병렬화 할 때 아주 중요한 요소로 작용한다.

<br>

## 종단 연산 좋은 예

<br>

### 축소(reduction) 이 접합하다

- Stream.reduce
- min, max, count, sum (완성된 형태로 제공되는 메서드)
- anyMatch, allMatch, noneMatch (조건에 맞으면 바로 반환되는 메서드)

<br>

### 가변 축소 (mutable reduction) 을 수행하는 stream의 collect 메서드는 병렬화에 적합하지 않다

Why? 컬렉션들을 합치는 부담이 크기 때문


> 직접 구현한 Stream, Iterable, Collection 이 병렬화의 이점을 제대로 누리게 하고 싶다면?
>
> - `spliterator` 메서드를 반드시 재정의하고 결과 스트림의 병렬화 성능을 강도 높게 테스트하라
<br>

# 그러니까 주의하자 ~

### 성능 더 나빠지거나 결과가 잘못될 수 있음

스트림을 잘못 병렬화하면 (응답 불가를 포함해) 성능이 나빠질 뿐만 아니라 결과 자체가 잘못되거나 예상 못한 동작이 발생할 수 있다.

> 안전 실패 : 결과가 잘못되거나 오동작하는 것
안전 실패는 병렬화한 파이프라인이 사용하는 mappers, filters, 혹은 프로그래머가 제공한 다른 함수 객체가 명세대로 동작하지 않을 때 벌어질 수 있다.
Stream 명세는 이때 사용되는 함수 객체에 관한 엄중한 규약을 정의해놨다.

Stream의 reduce 연산에 건네지는 accumulator(누적기) 와 combiner(결합기) 함수는 다음 요건을 따라야한다.

- associative : 반드시 결합 법칙을 만족한다.
- non-interfering : 간섭받지 않는다.
- stateless : 상태를 갖지 않아야한다.


물론 이 요구사항을 지키지 못하는 상태라도 파이프라인을 순차적으로만 수행한다면야 올바른 결과 GET

BUT 요구사항도 안지키고, 병렬로 수행하면 결과 처참 !


그러니 위에 메르센 소수도 올바르게 출력하고 싶으면 종단 연산 `forEach`, `forEachOrdered`로 바꿔주면 된다.

> -> 병렬 스트림들을 순회하며 소수를 발견한 순서대로 출력되도록 보장해줌
<br>

### 성능 향상이 정말 되는지 확인해봐, 병렬화는 성능 최적화 수단일 뿐!

파이프라인이 수행하는 진짜 작업이 병렬화에 드는 추가 비용을 상쇄하지 못한다면 성능 향상은 미미할 수 있다.

> 실제로 성능이 향상될지를 추정해보는 간단한 방법
>
> 스트림 안에 원소 수와 원소당 수행되는 코드 줄 수를 곱해봐라.
> (이 값이 최소 수십만은 되어야 성능 향상을 맛볼 수 있다.)
#### 성능 테스트 하세요

다른 최적화와 마찬가지로 **변경 전 후 반드시 성능을 테스트**하여 병렬화를 사용할 가치가 잇는지 확인 해라 !

> 이상적으로는 운영 시스템과 흡사한 환경에서 테스트하는 것이 좋다
<br>

#### 스레드 문제도 일으킬 수 있어요

보통 병렬 스트림 파이프라인도 공통의 포크-조인 풀에서 수행 (같은 스레드 풀 사용)되므로, 잘못된 파이프라인 하나가 시스템의 다른 부분의 성능에까지 악영향을 줄 수 있음을 유념하자

<br>

# 그냥 안쓸까..?

<br>

### 그래도 돼

네 여러분이 스트림 파이프라인을 병렬화 할 일이 적습니다 ~

스트림을 많이 사용하는 수백만 줄짜리 코드를 여러 개 관리하다보면 그 중 스트림 병렬화가 효과가 많지 않음을 알게 됩니다 !

<br>

### 그치만 좋을 때도 있긴해 !

**하지만**
조건이 잘 갖춰지면 parallel 메서드 호출 하나로 거의 프로세서 코어 수에 비례하는 성능 향상을 만끽할 수도 있어요 !

> ex. 머신러닝, 데이터 처리
<br>

#### 효과 굿 예시

> 소수 계산 스트림 파이프라인

```java
static long pi(long n) {
return LongStream.rangeClosed(2, n)
.parallel() // 병렬
.mapToObj(BigInteger::valueOf)
.filter(i -> i.isProbablePrime(50))
.count();
}
```


> 저자 컴퓨터 (n = 100000000)
> 원래 - 31초
> 병렬 - 9.2초
> 켬미 컴퓨터 (n = 100000000)
> 원래 ![](https://i.imgur.com/EiiPMgm.png)
>
> 병렬 ![](https://i.imgur.com/Xd3X6Nm.png)

<br>

# 추가 꿀팁

무작위 수들로 이뤄진 스트림을 병렬화 하려거든 `ThreadLocalRandom` 보다는 `SplittableRandom` 인스턴스를 이용하자

> 만든 의도도 이렇다
>
> `SplittableRandom` 은 멀티 스레드 -> 병렬화 하면 성능이 선형으로 증가한다.
> `ThreadLocalRandom` 은 단일 스레드
115 changes: 115 additions & 0 deletions 08장/아이템_53/가변인수는_신중히_사용하라_켬미.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@

# 가변 인수가 뭔데?

```java
public int sum(int... args) {}
```

`원하는 타입` + `...` 을 사용하면 0개 이상의 인수를 유동적으로 입력 받을 수 있게 해주는 인수

> 그래서 인수 개수가 정해져 있지 않을 때 좋다!
<br>

# 어떻게 동작?

인수의 개수와 길이가 같은 ==배열==을 만들고 인수들을 해당 배열에 저장하여 가변인수 메서드에 전

<br>

# 문제 있어?

<br>

## 아니 동작 잘 되고 좋아~

```java
static int sum(int... args) {
int sum = 0;
for(arg : args) {
sum += arg;
}
return sum;
}
```

**Good !**

<br>
## 가끔 문제 있을 수도?

```java
static int min(int... args) {
int min = Integer.MAX_VALUE;
for(arg : args) {
if(min > arg) {
min = arg;
}
}

return min;
}
```

**Bad !**

인자가 0개 이면 가장 작은 값이 `Integer.MAX_VALUE` 인게 맞아?

<br>

### 인자가 0개 일 때, 또 따로 처리 해줘야 함

```java
static int min(int... args) {
if(args.length == 0) {
throw new IllegalArgumentException("아무것도 없는데 최소를 어떻게 구해?");
}
int min = args[0];
for(int i = 1; i < args.length; i++) {
if(min > args[i]) {
min = arg[i];
}
}

return min;
}
```

for-each 문도 사용 못하고 유효성 검사해야하고 귀찮다

그래서 인자가 0개이면 안되는 메서드는 다음과 같이 작성한다

```java
static int min(int firstArg, int... args) {
int min = firstArg;
for(arg : args) {
if(min > arg) {
min = arg;
}
}

return min;
}
```

<br>

### 성능에 민감한 경우 가변인수는 걸림돌

가변인수는 메서드 호출 때 마다 배열을 할당하고 초기화 한다

#### 그래도 난 유동적이면서 성능은 지키고 싶어 !

자주 사용되는 개수까지는 값을 받고, 그 이상은 가변인수를 쓰자

```java
public void foo() { }
public void foo(int a1) { }
public void foo(int a1, int a2) { }
public void foo(int a1, int a2, int... rest) { } // 이거 사용되는 경우는 5%라고 하면 성능 최적화됨
```

특수한 상황에서는 이렇게 하면 사막의 오아시스처럼 성능 향상에 도움을 줌

> EnumSet 은 비트 필드를 대체하면서 성능까지 유지해야해서 위처럼 사용함

0 comments on commit 31820f7

Please sign in to comment.