스트림(Stream)
스트림
스트림이란 컬렉션 및 배열의 요소를 반복 처리하기 위한 기능(반복자)이다. 특징은 다음과 같다.
- 스트림은 내부 반복자이므로 처리 속도가 빠르고 병렬 처리에 효율적이다.
- 스트림은 람다식을 활용하여 다양한 처리를 수행할 수 있다.
- 스트림은 중간 처리와 최종 처리를 순차적으로 처리할 수 있는 파이프 라인을 형성할 수 있다.
스트림은 하나 이상 연결될 수 있다.
- 첫 시작은 컬렉션 또는 배열의 오리지널 스트림이다. 기존의 요소를 그대로 가지고 있는 스트림이다.
- 그 다음은 중간 스트림이 올 수 있다. 중간 스트림은 필터링, 매핑 등을 통해 요소들에 대한 중간 처리를 할 수 있다.
- 마지막으로 최종 처리를 할 수 있는 메소드를 통해 최종 값을 반환하고 스트림은 종료된다.
스트림 파이프라인
체이닝을 통해 오리지널 스트림, 중간 스트림, 최종 처리 메소드 등 여러 스트림이 연결된 것을 스트림 파이프라인이라고 한다.
스트림 파이프라인을 구성할 때 주의할 점은 파이프라인 맽 끝에 반드시 최종 처리 메소드가 있어야 한다는 것이다.
최종 처리 메소드가 없을 경우, 예외가 발생하지는 않지만 오리지널 및 중간 스트림은 동작하지 않는다.
오리지널 스트림 획득
java.util.stream 패키지에는 스트림 인터페이스들이 정의되어있다.
- BaseStream이 가장 상위 인터페이스이며, 모든 스트림 인터페이스에서 사용할 수 있는 공통 메소드들이 정의되어있다.
- Stream 인터페이스는 객체 요소를 처리하는 스트림이다.
- IntStream, LongStrema, DoubleStream은 기본 타입 요소를 처리하는 스트림이다.
- 각 스트림 인터페이스의 구현체는 컬렉션, 배열 등 리소스로부터 얻을 수 있다.
컬렉션으로부터 오리지널 스트림 획득
- java.util.Collection 인터페이스는 stream(), pallelStream()을 갖고 있다.
- 따라서 Collection의 하위 인터페이스인 List, Set 인터페이스의 모든 구현체에서 객체 스트림을 얻을 수 있다.
아래의 예시 코드는 컬렉션으로부터 오리지널 스트림을 얻는 방법을 보여준다.
Collection.stream()을 통해 오리지널 스트림을 얻을 수 있다.
public class Product {
private int pno;
private String name;
private String company;
private int price;
...
}
/////////////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////////////
/**
* java.util.Collection 인터페이스는 stream(), parallelStream()을 갖고 있다.
* 때문에 자식 인터페이스인 List, Set 인터페이스를 구현한 모든 컬렉션에서 오리지널 스트림을 얻을 수 있다.
*/
public class StreamExampleForCollection {
public static void main(String[] args) {
// list 컬렉션 생성
ArrayList<Product> list = new ArrayList<>();
for (int i = 0; i < 5; i++) {
list.add(new Product(i, "상품" + i, "회사" + i, (int) (100000*Math.random())));
}
// 컬렉션으로부터 객체 스트림(오리지널) 획득
Stream<Product> stream = list.stream();
stream.forEach(product -> System.out.println(product.toString()));
}
}
배열로부터 오리지널 스트림 획득
java.util.Arrays를 통해 다양한 종류의 배열로부터 스트림을 얻을 수 있다.
아래는 String, int 배열로부터 오리지널 스트림을 얻는 예시 코드이다.
Arrays.stream(arr)를 통해 오리지널 스트림을 얻을 수 있다.
/**
* java.util.Arrays를 통해 다양한 종류의 배열로부터 스트림을 얻을 수 있다.
*/
public class StreamExampleForArray {
public static void main(String[] args) {
String[] names = {"홍길동", "신용권", "김미나"};
Stream<String> stream1 = Arrays.stream(names);
stream1.forEach(n -> System.out.print(n + " "));
System.out.println();
int[] nums = {1, 2, 3, 4, 5, 6, 7, 8, 9, 10};
IntStream stream2 = Arrays.stream(nums);
stream2.forEach(n -> System.out.print(n + " "));
System.out.println();
}
}
정수 타입 오리지널 스트림 획득
IntStream, LongStream의 정적 메소드인 range(), rageClosed() 메소드를 통해 특정 범위의 정수 스트림을 얻을 수 있다.
아래는 정수 스트림을 얻는 예시 코드이다.
/**
* IntStream, LongStream의 정적 메소드인 range(), rangeClosed() 메소드를 통해 특정 범위의 정수를 얻을 수 있다.
*/
public class StreamExampleForNumber {
public static int sum;
public static void main(String[] args) {
IntStream stream = IntStream.rangeClosed(1, 100); // 1 ~ 100의 정수 스트림을 얻는다.
stream.forEach(a -> sum += a);
System.out.println("sum = " + sum);
}
}
중간 처리
필터링
필터링은 조건에 따라 요소를 걸러내서 중간 스트림을 반환하는 중간 처리 기능이다.
필터링은 distinct(), filter()를 통해 필터링을 할 수 있다.
distinct()는 중복 요소를 제거한다.
객체 스트림일 경우 equals()의 리턴 값이 true면 동일 요소로 판단한다.
filter()는 함수형 인터페이스인 Predicate 익명 구현 객체를 파라미터로 가진다.
Predicate는 파라미터를 확인하는 test() 추상 메소드를 갖고 있다.
스트림의 각 요소를 test()로 조건에 일치하는지 판단한다.
조건에 일치하는 요소에 대해서만 true를 반환하며, 그 요소들만 스트림에 담기게 된다.
아래는 distinct(), filter()를 사용하는 예시 코드이다.
/**
* 필터링은 요소를 걸러내는 중간 처리 기능이다.
* distinct()와 filter()를 통해 필터링을 할 수 있다.
*/
public class FilteringExample {
public static void main(String[] args) {
ArrayList<String> list = new ArrayList<>();
list.add("홍길동");
list.add("신용권");
list.add("김자바");
list.add("신용권");
list.add("신민철");
// 중복 요소 제거
// distinct()는 중복 요소를 제거한다.
// 객체 스트림일 경우 equals()의 리턴 값이 true이면 동일 요소로 판단한다.
list.stream()
.distinct()
.forEach(n -> System.out.println(n));
System.out.println();
// 신으로 시작하는 요소만 필터링
// filter()는 주어진 Predicate가 true를 리턴하는 요소만 필터링한다.
// Predicate는 함수형 인터페이스이며, 파라미터를 확인하는 test() 추상메소드를 갖고 있다.
list.stream()
.filter(n -> n.startsWith("신"))
.forEach(n -> System.out.println(n));
System.out.println();
// 중복 요소 제거 -> 신으로 시작하는 요소 필터링
list.stream()
.distinct()
.filter(n -> n.startsWith("신"))
.forEach(n -> System.out.println(n));
}
}
매핑
매핑은 기존 요소를 다른 요소로 변환한 중간 스트림을 반환하는 중간 처리 기능이다.
map***(), boxed(), flatMap***()를 통해 매핑 작업을 실시할 수 있다.
map***()은 파라미터로 함수형 인터페이스인 Function의 익명 구현 객체를 사용한다.
Function은 요소를 다른 요소로 변환하여 리턴하는 추상 메소드 apply***()를 갖고 있다.
아래는 map***()을 사용하여 요소를 다른 요소로 변환하는 예시 코드이다.
public class Student {
private String name;
private int score;
...
}
/////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////
/**
* mapXxx()는 요소를 다른 요소로 변환한 새로운 스트림을 리턴한다.
* mapXxx()는 파라미터로 Function을 사용한다.
* Function은 함수형 인터페이스로 요소를 다른 요소로 변환하여 리턴하는 추상 메소드를 갖고 있다.
*/
public class MapXxxExample {
public static void main(String[] args) {
ArrayList<Student> studentList = new ArrayList<>();
studentList.add(new Student("홍길동", 85));
studentList.add(new Student("홍길동", 92));
studentList.add(new Student("홍길동", 87));
// Student 스트림을 Student.score 스트림으로 변환
studentList.stream()
.mapToInt(s -> s.getScore())
.forEach(s -> System.out.println(s));
System.out.println();
///////////////////////////////////////////////////////////////////////
int[] ints = {1, 2, 3, 4, 5};
/**
* 기본 타입 -> 기본 타입, 기본 타입 -> 래퍼 객체 변환은 아래와 같은 메소드를 사용하면 간단하다.
* asLongStream(), asDoubleStream(), boxed()
*/
IntStream intStream = Arrays.stream(ints);
intStream
.asDoubleStream()
.forEach(d -> System.out.println(d));
System.out.println();
intStream = Arrays.stream(ints);
intStream
.boxed()
.forEach(o -> System.out.println(o.intValue()));
}
}
flatMap***()은 하나의 요소를 다수의 요소들로 변환한 중간 스트림을 리턴한다.
- a, b -> a1, a2, b1, b2
아래는 flatMap***()을 사용한 예시 코드이다.
/**
* flatMapXxx()은 하나의 요소를 복수 개의 요소들로 변환한 새로운 스트림을 리턴한다.
* a, b -> a1, a2, b1, b2
*/
public class FlatMapXxxExample {
public static void main(String[] args) {
// 문장 스트림을 단어 스트림으로 변환
List<String> list1 = new ArrayList<>();
list1.add("this is java");
list1.add("i am a best developer");
list1.stream()
.flatMap(data -> Arrays.stream(data.split(" ")))
.forEach(word -> System.out.println(word));
// 문자열 숫자 목록 스트림을 숫자 스트림으로 변환
List<String> list2 = Arrays.asList("10, 20, 30", "40, 50");
list2.stream()
.flatMapToInt(data -> {
String[] strArr = data.split(",");
int[] intArr = new int[strArr.length];
for (int i = 0; i < strArr.length; i++) {
intArr[i] = Integer.parseInt(strArr[i].trim());
}
return Arrays.stream(intArr);
})
.forEach(n -> System.out.println(n));
}
}
정렬
정렬은 요소를 오름차순 또는 내림차순으로 정렬하여 중간 스트림을 반환하는 중간 처리 기능이다.
sorted()를 통해 요소를 정렬할 수 있다.
요소가 객체일 경우, 객체가 Comparable을 구현하고 있어야 sorted() 메소드를 사용할 수 있다.
만약 그렇지 않다면, ClassCastException이 발생한다.
아래는 Comparable을 구현한 객체 요소를 정렬하는 예시 코드이다.
public class Student implements Comparable<Student> {
private String name;
private int score;
...
@Override
public int compareTo(Student o) {
return Integer.compare(score, o.score);
}
}
//////////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////////
/**
* sorting은 요소를 오름차순 또는 내림차순으로 정렬하는 중간 처리 기능이다.
*/
public class SortingExample {
/**
* 요소가 객체일 경우, 객체가 Comparable을 구현하고 있어야만 sorted() 메소드를 사용하여 정렬할 수 있다.
* 그렇지 않다면 classCastException이 발생한다.
*/
public static void main(String[] args) {
List<Student> students = new ArrayList<>();
students.add(new Student("홍길동", 30));
students.add(new Student("신용권", 10));
students.add(new Student("유미선", 20));
// 점수를 기준으로 오름차순 정렬 스트림 획득
students.stream()
.sorted()
.forEach(s -> System.out.println(s.getName() + ":" + s.getScore()));
// 점수를 기준으로 내림차순 정렬 스트림 획득
// sorted의 파라미터로 Comparator.reverseOrder()를 사용하면 된다.
students.stream()
.sorted(Comparator.reverseOrder())
.forEach(s -> System.out.println(s.getName() + ":" + s.getScore()));
}
}
만약 요소 객체가 Comparable을 구현하고 있지 않다면, 비교자를 사용하여 요소를 정렬할 수 있다.
비교자는 Comparator 인터페이스를 구현한 객체를 말한다. 간단하게 람다식으로 작성할 수 있다.
아래는 Comparable을 구현하지 않은 객체 요소를 정렬하는 예시 코드이다.
public class Student {
private String name;
private int score;
...
}
/////////////////////////////////////////////////////////////////////////////////
/////////////////////////////////////////////////////////////////////////////////
public class SortingExample {
/**
* 요소 객체가 Comparable을 구현하고 있지 않다면, 비교자를 사용하여 요소를 정렬할 수 있다.
* 비교자는 Comparator 인터페이스를 구현한 객체를 말한다. 간단하게 람다식으로 작성할 수 있다.
*/
public static void main(String[] args) {
List<Student> students = new ArrayList<>();
students.add(new Student("홍길동", 30));
students.add(new Student("신용권", 10));
students.add(new Student("유미선", 20));
// sorted((o1, o2) -> { ... })
// o1 < o2 = 음수
// o1 == o2 = 0
// o1 > o2 = 양수
// 정수간 비교는 Integer.compare(o1, o2)
// 실수간 비교는 Double.compare(o1, o2) 메소드를 사용하면 편하다.
// 점수를 기준으로 오름차순 정렬 스트림 획득
students.stream()
.sorted((s1, s2) -> Integer.compare(s1.getScore(), s2.getScore()))
.forEach(s -> System.out.println(s.getName() + ":" + s.getScore()));
// 점수를 기준으로 내림차순 정렬 스트림 획득
students.stream()
.sorted((s1, s2) -> Integer.compare(s2.getScore(), s1.getScore()))
.forEach(s -> System.out.println(s.getName() + ":" + s.getScore()));
}
}
루핑
루핑은 요소를 하나씩 반복해서 가져와서 그 요소에 대해 처리하고, 처리된 요소들로 중간 스트림을 만들어 반환하는 중간 처리 기능이다.
루핑은 peek(), forEach() 메소드를 통해 실행할 수 있다.
두 메소드의 차이점은 peek()는 중간 처리이고, forEach() 최종 처리라는 것이다.
두 메소드는 함수형 인터페이스인 Consumer의 익명 구현 객체를 파라미터로 사용한다.
그리고 Consumer는 요소를 파라미터로 받아 처리하는 추상 메소드 accept()가 정의되어 있다.
아래는 루핑의 예시 코드이다.
/**
* 루핑은 요소를 하나씩 반복해서 가져오고, 그 요소에 대해 처리하는 것을 말한다.
* 루핑 메소드에는 peek()과 forEach()가 있다. 전자는 중간 처리이며, 후자는 최종 처리이다.
*/
public class LoopingExample {
public static void main(String[] args) {
int[] ints = {1, 2, 3, 4, 5};
Arrays.stream(ints)
.filter(a -> a%2 == 0)
.peek(n -> System.out.println(n)); // 최종 처리가 없으므로 동작 X
int total = Arrays.stream(ints)
.filter(a -> a % 2 == 0)
.peek(n -> System.out.println(n)) // 중간 처리
.sum(); // 최종 처리
System.out.println("total = " + total);
Arrays.stream(ints)
.filter(a -> a%2 == 0)
.forEach(n -> System.out.println(n)); // 최종 처리
}
}
집계(최종 처리)
기본 집계
집계는 요소들을 추합해 하나의 값을 산출하는 최종 처리 기능이다.
스트림은 다양한 기본 집계 메소드를 제공한다.
count(), sum()을 제외한 집계 메소드들은 Optional*** 객체를 반환한다.
Optional*** 객체는 집계값을 저장하는 객체이다.
get(), getAsDouble(), getAsInt(), getAsLong()으로 최종값을 얻을 수 있다.
아래는 기본 집계 메소드 사용 예시 코드이다.
public class AggregateExample {
public static void main(String[] args) {
int[] ints = {1, 2, 3, 4, 5};
// 카운팅
long count = Arrays.stream(ints)
.filter(n -> n % 2 == 0)
.count();
System.out.println("count = " + count);
// 총합
int sum = Arrays.stream(ints)
.filter(n -> n % 2 == 0)
.sum();
System.out.println("sum = " + sum);
// 평균
double avg = Arrays.stream(ints)
.filter(n -> n % 2 == 0)
.average() // optional 객체
.getAsDouble();
System.out.println("avg = " + avg);
// 최대값
int max = Arrays.stream(ints)
.filter(n -> n % 2 == 0)
.max() // optional 객체
.getAsInt();
System.out.println("max = " + max);
// 최소값
int min = Arrays.stream(ints)
.filter(n -> n % 2 == 0)
.min() // optional 객체
.getAsInt();
System.out.println("min = " + min);
// 2의 배수 중 첫 번째 요소
int first = Arrays.stream(ints)
.filter(n -> n % 2 == 0)
.findFirst() // optional 객체
.getAsInt();
System.out.println("first = " + first);
}
}
Optional***
Optional*** 객체는 단순히 집계값만 저장하지 않는다.
집계값의 여부를 확인할 수 있으며, 집계값이 없을 경우 반환하는 디폴트 값을 등록하거나, 집계값이 있을 경우에만 추가적인 처리를 시도할 수도 있다.
이처럼 Optional*** 클래스에 집계값 여부 확인과 관련된 메소드가 많은 이유는,
집계값을 산출할 수 없을 때, 집계값에 접근하여 발생하는 NoSuchElementException을 예방하기 위함이다.
아래는 Optional*** 클래스의 메소드를 통해 NoSuchElementException을 예방하는 예시 코드이다.
public class OptionalExample {
public static void main(String[] args) {
List<Integer> list = new ArrayList<>();
// 방법 1. isPresent() 메소드가 true를 리턴할 때만 집계값을 얻는다.
OptionalDouble optional = list.stream()
.mapToInt(Integer::intValue)
.average();
if (optional.isPresent()) {
System.out.println("true = " + optional.getAsDouble());
} else {
System.out.println("false = " + 0.0);
}
// 방법 2. orElse() 메소드로 집계값이 없을 경우를 대비해서 디폴트 값을 정해놓는다.
double avg = list.stream()
.mapToInt(Integer::intValue)
.average()
.orElse(0.0);
System.out.println("avg = " + avg);
// 방법 3. 집계값이 있을 경우에만 동작하는 ifPresent()에 Consumer 람다식을 제공한다.
list.stream()
.mapToInt(Integer::intValue)
.average()
.ifPresent(d -> System.out.println("d = " + d));
}
}
reduce()
스트림은 기본 집계 메소드들을 제공하지만, 보다 다양한 집계 결과물을 만들수 있도록 reduce() 메소드를 제공한다.
reduce()의 파라미터로는 함수형 인터페이스 BinaryOperator의 익명 구현 객체가 사용된다.
BinaryOperator는 두 개의 파라미터를 받아 하나의 값을 리턴하는 추상 메소드 apply()를 갖고 있다.
아래는 reduce() 사용 예시 코드이다.
public class Student {
private String name;
private int score;
...
}
//////////////////////////////////////////////////////////////////////////////////
//////////////////////////////////////////////////////////////////////////////////
public class ReductionExample {
public static void main(String[] args) {
List<Student> students = Arrays.asList(new Student("홍길동", 92), new Student("신용권", 95),
new Student("김자바", 88));
int sum = students.stream()
.map(Student::getScore)
.reduce(0, (a, b) -> a + b); // 92 + 95 = x, x + 88 = sum
System.out.println("sum = " + sum); // print: 275
}
}
수집(최종 처리)
스트림은 중간 처리를 거친 요소들을 컬렉션에 수집하여, 그 컬렉션을 반환하는 collect() 메소드를 제공한다.
collect()는 인터페이스 Collector<T, A, R>의 구현체를 파라미터로 가진다.
T는 요소, A는 누적지, R은 요소가 저장될 컬렉션이다. T 요소를 A 누적기가 R 컬렉션에 저장하는 것이다.
Collector의 구현체는 Collectors 클래스의 정적 메소드들로 얻을 수 있다.
요소 그룹핑
collect()는 요소들을 그룹핑해서 key : value(List)의 Map 객체를 생서해서 반환하는 기능도 제공한다.
이 기능을 위해서는 Collectors.groupingBy()를 통해 얻을 수 있는 Collector 구현체를 collect()의 파라미터로 사용하면 된다.
수집 예시 코드
public class Student {
private String name;
private String sex;
private int score;
...
}
////////////////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////////////////
public class CollectExample {
public static void main(String[] args) {
List<Student> students = new ArrayList<>();
students.add(new Student("홍길동", "남", 92));
students.add(new Student("김수영", "여", 87));
students.add(new Student("김자바", "남", 95));
students.add(new Student("오해영", "여", 93));
// 남자학생들의 이름을 요소로 하는 list 생성
List<String> maleList = students.stream()
.filter(s -> s.getSex().equals("남"))
.map(s -> s.getName())
.collect(Collectors.toList());
maleList.forEach(m -> System.out.println("m = " + m));
System.out.println();
// key(이름) : value(점수) map 생성
Map<String, Integer> map = students.stream()
.collect(Collectors.toMap(
s -> s.getName(),
s -> s.getScore()
));
System.out.println(map);
System.out.println();
/**
* collect()는 요소 그룹핑이라는 기능을 제공한다.
* 요소 그룹핑은 컬렉션의 요소들을 그룹핑 해서 Map 객체를 생성하는 기능이다.
*/
// 성별을 key로 하는 Map 객체 생성
Map<String, List<Student>> groupMap = students.stream()
.collect(
Collectors.groupingBy(s -> s.getSex())
);
List<Student> maleList2 = groupMap.get("남");
maleList2.stream().forEach(s -> System.out.println("s = " + s.getName()));
List<Student> femaleList = groupMap.get("여");
femaleList.stream().forEach(s -> System.out.println("s = " + s.getName()));
}
}
스트림과 병렬 처리
요소 병렬 처리란 멀티코어 CPU 환경에서 전체 요소를 분할하고, 각각의 코어가 병렬적으로 처리하는 것을 말한다.
자바는 요소 병렬 처리를 위해 병렬 스트림을 제공한다.
동시성과 병렬성
멀티 스레드는 동시성 또는 병렬성으로 실행된다.
동시성
동시성은 멀티 스레드를 하나의 코어에서 번갈아가며 실행하는 것이다.
한 시점에 하나의 스레드만 처리하지만, 실행되는 스레드가 전환되는 속도가 워낙 빠르다보니 동시에 처리되는 것 처럼 보인다.
병렬성
병렬성은 멀티 스레드를 다수의 코어가 실행하는 것이다.
실제로 동시에 여러 스레드를 처리할 수 있기 때문에 동시성보다 좋은 성능을 낸다.
데이터 병렬성과 작업 병렬성
병렬성은 데이터 병렬성과 작업 병렬성으로 구분할 수 있다.
데이터 병렬성
데이터 병렬성은 전체 데이터를 분할해서 서브 데이터셋을 만들고, 이 서브 데이터셋들을 병렬 처리해서 작업을 빨리 끝내는 것이다.
자바의 병렬 스트림은 데이터 병렬성을 구현한 것이다.
작업 병렬성
작업 병렬성은 서로 다른 작업을 병렬 처리하는 것이다.
작업 병렬성의 대표적인 예로는 서버가 있다. 서버는 다수의 클라이언트 요청을 병렬 처리한다.
포크조인 프레임워크
자바의 병렬 스트림은 포크조인 프레임워크를 사용한다.
포크 단계에서 전체 요소들을 서브 요소셋으로 분할한다. 그리고 각 서브 요소셋을 멀티 코어들이 병렬 처리한다.
조인 단계에서는 서브 결과를 결합해 전체 결과를 만들어 낸다.
병렬 스트림 사용
병렬 스트림은 parallelStream(), parallel() 메소드로 얻을 수 있다.
parallelStream()은 컬렉션으로부터 병렬 스트림을 바로 리턴한다.
parallel()는 기존 스트림을 병렬 스트림으로 변환한다.
병렬 스트림의 성능
병렬 스트림의 성능이 순차 스트림의 성능보다 항상 좋은 것은 아니다.
요소의 수와 요소당 처리 시간
전체 요소의 수가 적고, 요소당 처리시간이 짧으면 순차 스트림이 병렬 스트림보다 빠를 수 있다.
병렬 처리로 얻는 이득보다 포크 및 조인 단계, 스레드풀 생성으로 인한 손해가 더 클 수 있기 때문이다.
스트림 소스의 종류
ArrayList와 배열은 인덱스로 관리되기 때문에 포크 단계에서 요소를 쉽게 분할할 수 있어 병렬 처리에 효과적이다.
반면에 HashSet, TreeSet, LinkedList는 요소 분할에 보다 많은 시간이 소요되어 상대적으로 효과적이지 못하다.
코어의 수
CPU 코어의 수가 많을 수록 병렬 스트림의 성능이 좋아진다.