摘要:Stream流式計算,本文講解了Stream流式計算的概念,具體的使用步驟以及源碼實現,最後講解了使用Stream過程中需要注意的事項。Stream在公司項目中被頻繁使用,在性能優化上具有廣泛的使用場景,通過少量的代碼即可優雅地實現並行計算。參考資料:劉超的《java性能調優實戰》,公衆號《java之間》
文章目錄
1、什麼是 Stream?
在 Java8 之前,我們通常是通過 for 循環或者 Iterator 迭代來重新排序合併數據,又或者通過重新定義 Collections.sorts 的 Comparator 方法來實現,這兩種方式對於大數據量系統來說,效率並不是很理想。
Java8 中添加了一個新的接口類 Stream,他和我們之前接觸的字節流概念不太一樣,Java8 集合中的 Stream 相當於高級版的 Iterator,他通過 Lambda 表達式對集合進行各種非常便利、高效的聚合操作(Aggregate Operation),或者大批量數據操作 (Bulk Data Operation)。
Stream原理:將要處理的元素看做一種流,流在管道中傳輸,並且可以在管道的節點上處理,包括過濾篩選、去重、排序、聚合等。元素流在管道中進過中間操作的處理,最後由最終操作得到前面處理的結果。
例子1:需求是過濾分組一所中學裏身高在 160cm 以上的男女同學
- 方法1:使用傳統的迭代方式來實現
Map<String, List<Student>> stuMap = new HashMap<String, List<Student>>();
for (Student stu: studentsList) {
if (stu.getHeight() > 160) { // 如果身高大於 160
if (stuMap.get(stu.getSex()) == null) { // 該性別還沒分類
List<Student> list = new ArrayList<Student>(); // 新建該性別學生的列表
list.add(stu);// 將學生放進去列表
stuMap.put(stu.getSex(), list);// 將列表放到 map 中
} else { // 該性別分類已存在
stuMap.get(stu.getSex()).add(stu);// 該性別分類已存在,則直接放進去即可
}
}
}
- 方法2:Java8 中的 Stream API 進行實現
- stream() - 爲集合創建串行流
Map<String, List<Student>> stuMap = stuList.stream().filter((Student s) -> s.getHeight() > 160) .collect(Collectors.groupingBy(Student ::getSex));
- parallelStream() - 爲集合創建並行流
Map<String, List<Student>> stuMap = stuList.parallelStream().filter((Student s) -> s.getHeight() > 160) .collect(Collectors.groupingBy(Student ::getSex));
2、Stream 如何優化遍歷?
2.1.Stream 操作分類
Stream 的操作分爲兩大類:中間操作(Intermediate operations)和終結操作(Terminal operations)。我們通常還會將中間操作稱爲懶操作,也正是由這種懶操作結合終結操作、數據源構成的處理管道(Pipeline),實現了 Stream 的高效。
操作類型 | 詳情 | 備註 |
---|---|---|
中間操作 | 只對操作進行了記錄,即只會返回一個流,不會進行計算操作 | 可以分爲無狀態(Stateless)與有狀態(Stateful)操作,前者是指元素的處理不受之前元素的影響,後者是指該操作只有拿到所有元素之後才能繼續下去。 |
終結操作 | 終結操作是實現了計算操作 | 可以分爲短路(Short-circuiting)與非短路(Unshort-circuiting)操作,前者是指遇到某些符合條件的元素就可以得到最終結果,後者是指必須處理完所有元素才能得到最終結果。 |
- 操作分類詳情如下圖所示
舉例說明:
1、新建一個Student類
@Data
public class Student {
private Long id;
private String name;
private int age;
private String address;
public Student() {}
public Student(Long id, String name, int age, String address) {
this.id = id;
this.name = name;
this.age = age;
this.address = address;
}
@Override
public String toString() {
return "Student{" + "id=" + id + ", name='" + name + '\'' +
", age=" + age + ", address='" + address + '\'' + '}';
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
Student student = (Student) o;
return age == student.age &&
Objects.equals(id, student.id) &&
Objects.equals(name, student.name) &&
Objects.equals(address, student.address);
}
@Override
public int hashCode() {
return Objects.hash(id, name, age, address);
}
}
2、執行Stream操作
public static void main(String [] args) {
Student s1 = new Student(1L, "肖戰", 15, "浙江");
Student s2 = new Student(2L, "王一博", 15, "湖北");
Student s3 = new Student(3L, "楊紫", 17, "北京");
Student s4 = new Student(4L, "李現", 17, "浙江");
List<Student> students = new ArrayList<>();
students.add(s1);
students.add(s2);
students.add(s3);
students.add(s4);
List<Student> streamStudents = testFilter(students);
streamStudents.forEach(System.out::println);
}
/**
* 1、集合的篩選
* @param students
* @return
*/
private static List<Student> testFilter(List<Student> students) {
//篩選年齡大於15歲的學生
//return students.stream().filter(s -> s.getAge()>15).collect(Collectors.toList());
//篩選住在浙江省的學生
return students.stream().filter(s ->"浙江".equals(s.getAddress())).collect(Collectors.toList());
}
/**
* 2、集合轉換
* @param students
* @return
*/
private static void testMap(List<Student> students) {
//在地址前面加上部分信息,只獲取地址輸出
List<String> addresses = students.stream().map(s ->"住址:"+s.getAddress()).collect(Collectors.toList());
addresses.forEach(a ->System.out.println(a));
}
/**
* 3、集合去重(基本類型)
*/
private static void testDistinct1() {
//簡單字符串的去重
List<String> list = Arrays.asList("111","222","333","111","222");
list.stream().distinct().forEach(System.out::println);
}
/**
* 3、集合去重(引用對象)
* 兩個重複的“肖戰”同學進行了去重,這不僅因爲使用了distinct()方法,而且因爲Student對象重寫了equals和hashCode()方法,否則去重是無效的
*/
private static void testDistinct2() {
//引用對象的去重,引用對象要實現hashCode和equal方法,否則去重無效
Student s1 = new Student(1L, "肖戰", 15, "浙江");
Student s2 = new Student(2L, "王一博", 15, "湖北");
Student s3 = new Student(3L, "楊紫", 17, "北京");
Student s4 = new Student(4L, "李現", 17, "浙江");
Student s5 = new Student(1L, "肖戰", 15, "浙江");
List<Student> students = new ArrayList<>();
students.add(s1);
students.add(s2);
students.add(s3);
students.add(s4);
students.add(s5);
students.stream().distinct().forEach(System.out::println);
}
/**
* 4、集合排序(默認排序)
*/
private static void testSort1() {
List<String> list = Arrays.asList("333","222","111");
list.stream().sorted().forEach(System.out::println);
}
/**
* 4、集合排序(指定排序規則)
* 先按照學生的id進行降序排序,再按照年齡進行降序排序
*
*/
private static void testSort2() {
Student s1 = new Student(1L, "肖戰", 15, "浙江");
Student s2 = new Student(2L, "王一博", 15, "湖北");
Student s3 = new Student(3L, "楊紫", 17, "北京");
Student s4 = new Student(4L, "李現", 17, "浙江");
List<Student> students = new ArrayList<>();
students.add(s1);
students.add(s2);
students.add(s3);
students.add(s4);
students.stream()
.sorted((stu1,stu2) ->Long.compare(stu2.getId(), stu1.getId()))
.sorted((stu1,stu2) -> Integer.compare(stu2.getAge(),stu1.getAge()))
.forEach(System.out::println);
}
/**
* 5、集合limit,返回前幾個元素
*/
private static void testLimit() {
List<String> list = Arrays.asList("333","222","111");
list.stream().limit(2).forEach(System.out::println);
}
/**
* 6、集合skip,刪除前n個元素
*/
private static void testSkip() {
List<String> list = Arrays.asList("333","222","111");
list.stream().skip(2).forEach(System.out::println);
}
/**
* 7、集合reduce,將集合中每個元素聚合成一條數據
*/
private static void testReduce() {
List<String> list = Arrays.asList("歡","迎","你");
String appendStr = list.stream().reduce("北京",(a,b) -> a+b);
System.out.println(appendStr);
}
/**
* 8、求集合中元素的最小值
* 求所有學生中年齡最小的一個
*/
private static void testMin() {
Student s1 = new Student(1L, "肖戰", 14, "浙江");
Student s2 = new Student(2L, "王一博", 15, "湖北");
Student s3 = new Student(3L, "楊紫", 17, "北京");
Student s4 = new Student(4L, "李現", 17, "浙江");
List<Student> students = new ArrayList<>();
students.add(s1);
students.add(s2);
students.add(s3);
students.add(s4);
Student minS = students.stream().min((stu1,stu2) ->Integer.compare(stu1.getAge(),stu2.getAge())).get();
System.out.println(minS.toString());
}
/**
* 9、anyMatch/allMatch/noneMatch(匹配)
* anyMatch:Stream 中任意一個元素符合傳入的 predicate,返回 true
allMatch:Stream 中全部元素符合傳入的 predicate,返回 true
noneMatch:Stream 中沒有一個元素符合傳入的 predicate,返回 true
*/
private static void testMatch() {
Student s1 = new Student(1L, "肖戰", 15, "浙江");
Student s2 = new Student(2L, "王一博", 15, "湖北");
Student s3 = new Student(3L, "楊紫", 17, "北京");
Student s4 = new Student(4L, "李現", 17, "浙江");
List<Student> students = new ArrayList<>();
students.add(s1);
students.add(s2);
students.add(s3);
students.add(s4);
Boolean anyMatch = students.stream().anyMatch(s ->"湖北".equals(s.getAddress()));
if (anyMatch) {
System.out.println("有湖北人");
}
Boolean allMatch = students.stream().allMatch(s -> s.getAge()>=15);
if (allMatch) {
System.out.println("所有學生都滿15週歲");
}
Boolean noneMatch = students.stream().noneMatch(s -> "楊洋".equals(s.getName()));
if (noneMatch) {
System.out.println("沒有叫楊洋的同學");
}
}
}
2.2 Stream 源碼實現
- Stream相關的類
BaseStream 和 Stream 爲最頂端的接口類。BaseStream 主要定義了流的基本接口方法,例如,spliterator、isParallel 等;Stream 則定義了一些流的常用操作方法,例如,map、filter 等
ReferencePipeline 是一個結構類,他通過定義內部類組裝了各種操作流。他定義了 Head、StatelessOp、StatefulOp 三個內部類,實現了 BaseStream 與 Stream 的接口方法
Sink 接口是定義每個 Stream 操作之間關係的協議,他包含 begin()、end()、cancellationRequested()、accept() 四個方法
ReferencePipeline 最終會將整個 Stream 流操作組裝成一個調用鏈,而這條調用鏈上的各個 Stream 操作的上下關係就是通過 Sink 接口協議來定義實現的
2.3 Stream 操作疊加
管道結構通常是由 ReferencePipeline 類實現的,前面講解 Stream 包結構時,我提到過 ReferencePipeline 包含了 Head、StatelessOp、StatefulOp 三種內部類。
名詞 | 作用 |
---|---|
Head | 主要用來定義數據源操作,在我們初次調用 names.stream() 方法時,會初次加載 Head 對象,此時爲加載數據源操作 |
StatelessOp | 無狀態中間操作 |
StatefulOp | 有狀態中間操作 |
Stage | 在 JDK 中每次的中斷操作會以使用階段(Stage)命名 |
AbstractPipeline | 用於生成一箇中間操作 Stage 鏈表 |
中間操作之後,此時的 Stage 並沒有執行,而是通過 AbstractPipeline 生成了一箇中間操作 Stage 鏈表;當我們調用終結操作時,會生成一個最終的 Stage,通過這個 Stage 觸發之前的中間操作,從最後一個 Stage 開始,遞歸產生一個 Sink 鏈。
例子2:需求是查找出一個長度最長,並且以“張”爲姓氏的名字
List<String> names = Arrays.asList(" 張三 ", " 李四 ", " 王老五 ", " 李三 ", " 劉老四 ", " 王小二 ", " 張四 ", " 張五六七 ");
String maxLenStartWithZ = names.stream()
.filter(name -> name.startsWith(" 張 "))
.mapToInt(String::length)
.max()
.toString();
執行流程 | 流程 | 是否正確 |
---|---|---|
流程: | 首先遍歷一次集合,得到以“張”開頭的所有名字;然後遍歷一次 filter 得到的集合,將名字轉換成數字長度;最後再從長度集合中找到最長的那個名字並且返回。 | 錯誤 |
流程: | 1、names.stream() 方法將會調用集合類基礎接口 Collection 的 Stream 方法; 2、Stream 方法就會調用 StreamSupport 類的 Stream 方法,方法中初始化了一個 ReferencePipeline 的 Head 內部類對象; 3、 再調用 filter 和 map 方法,這兩個方法都是無狀態的中間操作,所以執行 filter 和 map 操作時,並沒有進行任何的操作,而是分別創建了一個 Stage 來標識用戶的每一次操作;4、new StatelessOp 將會調用父類 AbstractPipeline 的構造函數,這個構造函數將前後的 Stage 聯繫起來,生成一個 Stage 鏈表:5、當執行 max 方法時,會調用 ReferencePipeline 的 max 方法,此時由於 max 方法是終結操作,所以會創建一個 TerminalOp 操作,同時創建一個 ReducingSink,並且將操作封裝在 Sink 類中。6、Java8 中的 Spliterator 的 forEachRemaining 會迭代集合,每迭代一次,都會執行一次 filter 操作,如果 filter 操作通過,就會觸發 map 操作,然後將結果放入到臨時數組 object 中,再進行下一次的迭代。完成中間操作後,就會觸發終結操作 max。 | 正確 |
names.stream() 方法將會調用集合類基礎接口 Collection 的 Stream 方法;
default Stream<E> stream() {
return StreamSupport.stream(spliterator(), false);
}
public static <T> Stream<T> stream(Spliterator<T> spliterator, boolean parallel) {
Objects.requireNonNull(spliterator);
return new ReferencePipeline.Head<>(spliterator, StreamOpFlag.fromCharacteristics(spliterator), parallel);
}
ReferencePipeline 的 filter 方法和 map 方法:
@Override
public final Stream<P_OUT> filter(Predicate<? super P_OUT> predicate) {
Objects.requireNonNull(predicate);
return new StatelessOp<P_OUT, P_OUT>(this, StreamShape.REFERENCE,StreamOpFlag.NOT_SIZED) {
@Override
Sink<P_OUT> opWrapSink(int flags, Sink<P_OUT> sink) {
return new Sink.ChainedReference<P_OUT, P_OUT>(sink) {
@Override
public void begin(long size) {
downstream.begin(-1);
}
@Override
public void accept(P_OUT u) {
if (predicate.test(u))
downstream.accept(u);
}
};
}
};
}
@Override
@SuppressWarnings("unchecked")
public final <R> Stream<R> map(Function<? super P_OUT, ? extends R> mapper) {
Objects.requireNonNull(mapper);
return new StatelessOp<P_OUT, R>(this, StreamShape.REFERENCE,StreamOpFlag.NOT_SORTED | StreamOpFlag.NOT_DISTINCT) {
@Override
Sink<P_OUT> opWrapSink(int flags, Sink<R> sink) {
return new Sink.ChainedReference<P_OUT, R>(sink) {
@Override
public void accept(P_OUT u) {
downstream.accept(mapper.apply(u));
}
};
}
};
}
new StatelessOp 將會調用父類 AbstractPipeline 的構造函數,這個構造函數將前後的 Stage 聯繫起來,生成一個 Stage 鏈表:
AbstractPipeline(AbstractPipeline<?, E_IN, ?> previousStage, int opFlags) {
if (previousStage.linkedOrConsumed)
throw new IllegalStateException(MSG_STREAM_LINKED);
previousStage.linkedOrConsumed = true;
previousStage.nextStage = this;// 將當前的 stage 的 next 指針指向之前的 stage
this.previousStage = previousStage;// 賦值當前 stage 當全局變量 previousStage
this.sourceOrOpFlags = opFlags & StreamOpFlag.OP_MASK;
this.combinedFlags = StreamOpFlag.combineOpFlags(opFlags, previousStage.combinedFlags);
this.sourceStage = previousStage.sourceStage;
if (opIsStateful())
sourceStage.sourceAnyStateful = true;
this.depth = previousStage.depth + 1;
}
因爲在創建每一個 Stage 時,都會包含一個 opWrapSink() 方法,該方法會把一個操作的具體實現封裝在 Sink 類中,Sink 採用(處理 -> 轉發)的模式來疊加操作。
當執行 max 方法時,會調用 ReferencePipeline 的 max 方法,此時由於 max 方法是終結操作,所以會創建一個 TerminalOp 操作,同時創建一個 ReducingSink,並且將操作封裝在 Sink 類中。
@Override
public final Optional<P_OUT> max(Comparator<? super P_OUT> comparator) {
return reduce(BinaryOperator.maxBy(comparator));
}
最後,調用 AbstractPipeline 的 wrapSink 方法,該方法會調用 opWrapSink 生成一個 Sink 鏈表,Sink 鏈表中的每一個 Sink 都封裝了一個操作的具體實現。
@Override
@SuppressWarnings("unchecked")
final <P_IN> Sink<P_IN> wrapSink(Sink<E_OUT> sink) {
Objects.requireNonNull(sink);
for ( @SuppressWarnings("rawtypes") AbstractPipeline p=AbstractPipeline.this; p.depth > 0; p=p.previousStage) {
sink = p.opWrapSink(p.previousStage.combinedFlags, sink);
}
return (Sink<P_IN>) sink;
}
當 Sink 鏈表生成完成後,Stream 開始執行,通過 spliterator 迭代集合,執行 Sink 鏈表中的具體操作。
@Override
final <P_IN> void copyInto(Sink<P_IN> wrappedSink, Spliterator<P_IN> spliterator) {
Objects.requireNonNull(wrappedSink);
if (!StreamOpFlag.SHORT_CIRCUIT.isKnown(getStreamAndOpFlags())) {
wrappedSink.begin(spliterator.getExactSizeIfKnown());
spliterator.forEachRemaining(wrappedSink);
wrappedSink.end();
}
else {
copyIntoWithCancel(wrappedSink, spliterator);
}
}
Java8 中的 Spliterator 的 forEachRemaining 會迭代集合,每迭代一次,都會執行一次 filter 操作,如果 filter 操作通過,就會觸發 map 操作,然後將結果放入到臨時數組 object 中,再進行下一次的迭代。完成中間操作後,就會觸發終結操作 max。
2.4.Stream 並行處理
List<String> names = Arrays.asList(" 張三 ", " 李四 ", " 王老五 ", " 李三 ", " 劉老四 ", " 王小二 ", " 張四 ", " 張五六七 ");
String maxLenStartWithZ = names.stream().parallel().filter(name -> name.startsWith("張"))
.mapToInt(String::length).max().toString();
Stream 的並行處理在執行終結操作之前,跟串行處理的實現是一樣的。而在調用終結方法之後,實現的方式就有點不太一樣,會調用 TerminalOp 的 evaluateParallel 方法進行並行處理。這裏的並行處理指的是,Stream 結合了 ForkJoin 框架,對 Stream 處理進行了分片,Splititerator 中的 estimateSize 方法會估算出分片的數據量。
final <R> R evaluate(TerminalOp<E_OUT, R> terminalOp) {
assert getOutputShape() == terminalOp.inputShape();
if (linkedOrConsumed)
throw new IllegalStateException(MSG_STREAM_LINKED);
linkedOrConsumed = true;
return isParallel()
? terminalOp.evaluateParallel(this, sourceSpliterator(terminalOp.getOpFlags()))
: terminalOp.evaluateSequential(this, sourceSpliterator(terminalOp.getOpFlags()));
}
通過預估的數據量獲取最小處理單元的閥值,如果當前分片大小大於最小處理單元的閥值,就繼續切分集合。每個分片將會生成一個 Sink 鏈表,當所有的分片操作完成後,ForkJoin 框架將會合並分片任何結果集
3、合理使用 Stream
我們將對常規的迭代、Stream 串行迭代以及 Stream 並行迭代進行性能測試對比,迭代循環中,我們將對數據進行過濾、分組等操作。分別進行以下幾組測試:
測試 | 結論(迭代使用時間) |
---|---|
多核 CPU 服務器配置環境下,對比長度 100 的 int 數組的性能; | 常規的迭代 <Stream 並行迭代 <Stream 串行迭代 |
多核 CPU 服務器配置環境下,對比長度 1.00E+8 的 int 數組的性能; | Stream 並行迭代 < 常規的迭代 <Stream 串行迭代 |
多核 CPU 服務器配置環境下,對比長度 1.00E+8 對象數組過濾分組的性能; | Stream 並行迭代 < 常規的迭代 <Stream 串行迭代 |
單核 CPU 服務器配置環境下,對比長度 1.00E+8 對象數組過濾分組的性能。 | 常規的迭代 <Stream 串行迭代 <Stream 並行迭代 |
結論:在循環迭代次數較少的情況下,常規的迭代方式性能反而更好;在單核 CPU 服務器配置環境中,也是常規迭代方式更有優勢;而在大數據循環迭代中,如果服務器是多核 CPU 的情況下,Stream 的並行迭代優勢明顯。所以在平時處理大數據的集合時,應該儘量考慮將應用部署在多核 CPU 環境下,並且使用 Stream 的並行迭代方式進行處理。
4、總結
在串行處理操作中,Stream 在執行每一步中間操作時,並不會做實際的數據操作處理,而是將這些中間操作串聯起來,最終由終結操作觸發,生成一個數據處理鏈表,通過 Java8 中的 Spliterator 迭代器進行數據處理;此時,每執行一次迭代,就對所有的無狀態的中間操作進行數據處理,而對有狀態的中間操作,就需要迭代處理完所有的數據,再進行處理操作;最後就是進行終結操作的數據處理。
在並行處理操作中,Stream 對中間操作基本跟串行處理方式是一樣的,但在終結操作中,Stream 將結合 ForkJoin 框架對集合進行切片處理,ForkJoin 框架將每個切片的處理結果 Join 合併起來。最後就是要注意 Stream 的使用場景。
5、思考題
這裏有一個簡單的並行處理案例,請你找出其中存在的問題。
// 使用一個容器裝載 100 個數字,通過 Stream 並行處理的方式將容器中爲單數的數字轉移到容器 parallelList
List<Integer> integerList= new ArrayList<Integer>();
for (int i = 0; i <100; i++) {
integerList.add(i);
}
List<Integer> parallelList = new ArrayList<Integer>() ;
integerList.stream()
.parallel()
.filter(i->i%2==1)
.forEach(i->parallelList.add(i));
答案:ArrayList不是線程安全的,在並行操作時,會出現多線程操作問題,例如出現null值,有可能是在擴容時,複製出現問題。同時也會出現值被覆蓋的情況。可以換成線程安全的ArrayList。