> 本文是一篇介紹代碼性能的文章,由于筆者知識面有限,無法談及架構設計上的性能優化,只能描述一些`代碼層次`的優化方法。
[TOC]
談到性能優化,筆者認為沒有別的捷徑,唯一的辦法就是**測試,修改,再測試**。這里筆者使用 JMH(Java Microbenchmark Harness)來測試代碼的性能,它是一款微基準測試工具(`org.openjdk.jmh`,通過 Maven 引入即可)。
字符串(String),即字符數組(char[]),只要有可讀數據的傳輸,就有字符串的身影;雖然還有很多可優化的例子可以提及,但是本文只針對 **Java 字符串操作方法間的差異** 進行介紹。
同時,為了解釋清楚產生性能差異的原因,文中不得不貫穿一些 Java 前端編譯優化(javac)和后端編譯優化(JIT)的技術點,為了避免內容過泛,也無法具體地敘述。
## 1 JMH 主要參數的含義
本節將通過一個demo,向讀者介紹 JMH 的常見參數。
以下是 3 個用于字符串格式化(format)操作的方法,讀者先根據經驗判斷一下哪個方法最快。
> 字符串格式化是比較常用的字符串操作,例如日志等,下面三個方法分別基于String#format,StringBuilder#append,MessageFormat#format實現
```java
@Benchmark
public String byStringFormat() {
return String.format("a: %s, b: %s, c: %s", a, b, c);
}
@Benchmark
public String byStringBuilder() {
return "a: " + a + ", b: " + b + ", c: " + c;
}
@Benchmark
public String byMessageFormat() {
return MessageFormat.format("a: {0}, b: {1}, c: {2}", a, b, c);
}
```
并且加入了"對照組",empty 方法會直接返回一個新的 String 實例:
```java
@Benchmark
public String empty() {
return new String("a: 1234, b: 56.78, c: abcd");
}
```
----
以下是 JMH 的運行結果,結果表示:
- 共測試了 4 個方法
- Mode=avgt,表示運行模式為平均運行時間(還可以設置為事務數)
- Cnt表示迭代10次(默認每次迭代運行1秒)
- Score和Error分別表示分數和誤差,單位是Units,即納秒/每操作
- 由于Mode=avgt,因此 Score 值越低,表示性能越好
Benchmark | Mode | Cnt | Score & Error | Units
---- | ---- | ---- | ----: | ----
StringFormatMethod.empty | avgt | 10 | 9.716 ± 0.256 | ns/op
StringFormatMethod.byStringBuilder | avgt | 10 | 171.630 ± 2.517 | ns/op
StringFormatMethod.byStringFormat | avgt | 10 | 1474.086 ± 36.624 | ns/op
StringFormatMethod.byMessageFormat | avgt | 10 | 2946.144 ± 71.755 | ns/op
顯而易見,通過+號拼接實現的字符串格式化,性能遠遠高于 String#format 和 MessageFormat#format,原因如下:
- javac 在處理+號拼接的操作時,new 出一個StringBuilder實例,對每個+號操作,依次調用其 StringBuilder#append 方法,連接各個 String 實例,最后調用 StringBuilder#toString 方法返回結果
- String#format 底層使用正則表達式,雖然已經提前編譯好了 Pattern ,但是模式匹配時,仍然引入了相當多的指令操作
- MessageFormat#format 底層雖然通過有限狀態機(說直白些,就是通過for和if)優化了解析模板和渲染結果的過程,但是需要考慮的數據類型太多,還是不可避免地引入了許多耗時操作
因此在沒有特殊的格式化需求(更具體地說,只有拼接字符串的需求),直接使用+號即可,例如:
```java
Log.d("a: " + a + ", b: " + b + ", c: " + c);
```
----
本節最后,筆者將本次 JMH 輸出結果的前幾行拿到最后來介紹:
- 前4行分別表示 JMH 版本號,Java虛擬機版本、目錄、參數
- Warmup 表示預熱時間,Measurement 表示方法運行時間;即10次迭代(iterations),每次1秒;測試結果都是 Measurement 中的
- Benchmark mode為平均運行時間,以及計算單位。
- Threads 為1,將同步執行 iterations
```
# JMH version: 1.19
# VM version: JDK 1.8.0_41, VM 25.40-b25
# VM invoker: /home/zhaoxuyang03/bin/jdk/java-se-8u41-ri/jre/bin/java
# VM options: -javaagent:/home/zhaoxuyang03/bin/idea/lib/idea_rt.jar=35083:/home/zhaoxuyang03/bin/idea/bin -Dfile.encoding=UTF-8
# Warmup: 10 iterations, 1 s each
# Measurement: 10 iterations, 1 s each
# Timeout: 10 min per iteration
# Threads: 1 thread, will synchronize iterations
# Benchmark mode: Average time, time/op
# Benchmark: net.zhaoxuyang.jmh.StringFormatMethod.byMessageFormat
```
## 2 盡可能地減少內存申請操作
### 2.1 字符串遍歷操作中,toCharArray 性能慢于 charAt
以下有兩種遍歷String的方法,toCharArray方法會通過String#toCharArray方法返回一個新的char[]實例,而charAt會通過str.charAt來遍歷String中的每個元素(charAt方法中含有try-catch語句):
```
/** JMH會為value中的每一項生成一組測試 */
@Param(value = {"short", "This is a long sentence..........................."})
String str;
/**
* 通過{@link String#toCharArray}方法生成char數組后,遍歷字符串
*/
@Benchmark
public int toCharArray() {
char[] charArray = str.toCharArray();
int res = 0;
for (int i = 0; i < charArray.length; i++) {
res += charArray[i];
}
return res;
}
/**
* 在循環中通過調用charAt方法,遍歷字符串
*/
@Benchmark
public int charAt() {
int res = 0;
for (int i = 0; i < str.length(); i++) {
res += str.charAt(i);
}
return res;
}
```
以下是基準測試結果:
- toCharArray 方法的性能明顯地比 charAt 方法的差,主要體現在 `str.toCharArray` 開辟內存的耗時
- 雖然 charAt 中使用了 try-catch 包裹,但還是沒有申請內存的操作來得耗時,因此在使用中,是否可以考慮預先創建一個臨時的char[]數組,反復使用?
Benchmark | (str) | Mode | Cnt | Score & Error | Units
----|----|----|----|----:|----
ToCharArrayOrCharAt.charAt | short | avgt | 10 | 8.118 ± 0.273 | ns/op
ToCharArrayOrCharAt.charAt | This is a long sentence........................... | avgt | 10 | 23.122 ± 0.611 | ns/op
ToCharArrayOrCharAt.toCharArray | short | avgt | 10 | 15.477 ± 1.176 | ns/op
ToCharArrayOrCharAt.toCharArray | This is a long sentence........................... | avgt | 10 | 49.854 ± 2.164 | ns/op
### 2.2 通過預緩存來減少實際操作中的內存申請
本節會舉兩個例子,一個是 android.text.TextUtils#sTemp 字段,一個是目前許多模板引擎對 Integer#toString 方法的優化。
----
第一個例子是 android.text.TextUtils#sTemp 這個字段,提供給 TextUtils 內部使用,其主要目的就是為了減少內存申請操作,原理如下:
- 每當方法體中需要申請 char[] 類型的局部變量時(例如 TextUtils#indexOf 方法),會調用 obtain(len) 方法返回一個臨時數組sTemp,長度不夠才重新申請內存
- 操作完成后,再調用設置 recycle 方法設置回 sTemp
```
/* package */ static char[] obtain(int len) {
char[] buf;
synchronized (sLock) {
buf = sTemp;
sTemp = null;
}
if (buf == null || buf.length < len)
buf = ArrayUtils.newUnpaddedCharArray(len);
return buf;
}
/* package */ static void recycle(char[] temp) {
if (temp.length > 1000)
return;
synchronized (sLock) {
sTemp = temp;
}
}
```
----
第二個例子是一個常見的優化手段,由于許多模板引擎會有整型轉字符串的需求,因此考慮到性能,在符合應用場景的前提下,會預先緩存 Integer#toString 的結果,實現方法如下:
```
/**
* 提供toString方法的緩存工具類
*/
public static class Util {
/** 緩存范圍為 [0, CACHE_SIZE) */
private static final int CACHE_SIZE = 2048;
/** 緩存內容 */
private static final String[] INT_CACHE;
/* 預先生成toString結果 */
static {
INT_CACHE = new String[CACHE_SIZE];
for (int i = 0; i < INT_CACHE.length; i++) {
INT_CACHE[i] = Integer.toString(i);
}
}
/** 不可實例化 */
private Util() {
}
/**
* int轉String,超出緩存范圍則通過 {@link Integer#toString} 生成結果
*/
public static String intToString(int i) {
return (i >= 0 && i < CACHE_SIZE) ? INT_CACHE[i] : Integer.toString(i);
}
}
```
也就是說當入參的范圍為[0, 2048)時,不再執行 Integer#toString 方法,直接返回預先計算的結果。
對于以下的測試用例:
```
/**
* JMH會為value中的每一項生成一組測試
*/
@Param(value = {"100", "1000", "10000"})
int value;
/**
* 無緩存的toString方法
*/
@Benchmark
public String nonCache() {
return Integer.toString(value);
}
/**
* 預緩存的toString方法
*/
@Benchmark
public String cache() {
return Util.intToString(value);
}
```
其基準測試結果如下:
- 當入參(value) 為 100 或 1000 時,可以走緩存,性能是未緩存時的8~9倍
- 當入參(value) 為 10000 時,未走緩存,性能與未緩存時持平(由于字節碼指令較多,必然稍遜于后者)
Benchmark | (value) | Mode | Cnt | Score & Error | Units
----|----|----|----|----:|----
PreCache.cache | 100 | avgt | 10 | 4.303 ± 0.113 | ns/op
PreCache.cache | 1000 | avgt | 10 | 4.255 ± 0.098 | ns/op
PreCache.cache | 10000 | avgt | 10 | 36.420 ± 0.697 | ns/op
PreCache.nonCache | 100 | avgt | 10 | 31.996 ± 7.915 | ns/op
PreCache.nonCache | 1000 | avgt | 10 | 36.673 ± 7.452 | ns/op
PreCache.nonCache | 10000 | avgt | 10 | 36.419 ± 1.337 | ns/op
## 3 使用語法糖時清楚實際運行的代碼
### 3.1 不要使用+=來拼接字符串
Java 語法中,沒有操作符重載的概念,但是編譯器會為對象之間使用`+`號、`+=`號進行處理,下面只介紹 String 相關的運算符操作:
- 通過`+`號連接的String(其他類型會通過 String#valueOf 方法轉換成String)實例,運行時會創建一個StringBuilder實例,通過append方法連接各String,最后通過 StringBuilder#toString 方法返回一個新實例
- 通過`+`號連接的String常量,編譯期間javac會直接將該表達式改為一個常量(`常量折疊`)。
- `a += b; a+=c; `操作會導致運行時創建一個StringBuilder對象連接a與b,再將toString結果賦值給a;再創建一個StringBuilder對象,連接a與c,再將toString結果賦值給a
以下是一個使用 `+=` 的bad case:
```java
String a = "a";
String b = "b";
String c = "c";
/**
* 通過+號拼接字符串
*/
@Benchmark
public String plus() {
String res = a + b + c;
return res;
}
/**
* 通過StringBuilder的append方法拼接字符串
*/
@Benchmark
public String byStringBuilder() {
String res = new StringBuilder().append(a).append(b).append(c).toString();
return res;
}
/**
* 通過+=形式拼接字符串
*/
@Benchmark
public String plusEquals() {
String res = a;
a += b;
a += c;
return res;
}
```
其基準測試結果如下:
- plusEquals 方法中使用了+=符號連接字符,雖然只進行了3個字符串實例的連接,但是其性能已經遠遠低于byStringBuilder和plus —— 如果放在一個長循環中使用,將造成更加嚴重的性能損耗
- 另一方面,可以看出 byStringBuilder 方法與 plus 方法性能相當 —— 其實兩個方法的字節碼完全一樣(`javap -c *.class`)
Benchmark | Mode | Cnt | Score & Error | Units
----|----|----|----:|----
PlusOperator.byStringBuilder | avgt | 10 | 27.065 ± 1.003 | ns/op
PlusOperator.plus | avgt | 10 | 27.178 ± 0.854 | ns/op
PlusOperator.plusEquals | avgt | 10 | 318331.676 ± 68204.792 | ns/op
### 3.2 switch(String) 的代替方案
Java 7 中,switch塊里添加了對String類型的支持,例如:
```java
/** 鍵 */
String key = "code_1";
/**
* 通過switch(String)語法糖來選擇
*
* @return {@link #key} 的匹配結果
*/
@Benchmark
public int bySwitch() {
String key = this.key;
switch (key) {
case "code_0":
return 0;
case "code_1":
return 1;
case "code_2":
return 2;
default:
return -1;
}
}
```
javac 解語法糖后,變成以下的等價形式:
```
/**
* switch 解語法糖后的等價形式:通過 {@link String#hashCode} 與 {@link String#equals} 來選擇
*
* @return {@link #key} 的匹配結果
*/
@Benchmark
public int bySwitchByteCode() {
String key = this.key;
int hashCode = key.hashCode();
switch (hashCode) {
case -1355091362: // "code_0".hashCode()
if ("code_0".equals(key)) {
return 0;
}
case -1355091361: // "code_1".hashCode()
if ("code_1".equals(key)) {
return 1;
}
case -1355091360: // "code_2".hashCode()
if ("code_2".equals(key)) {
return 2;
}
default:
return -1;
}
}
```
通過 if 語句塊的等價實現如下:
```java
/**
* 通過 if 語句塊的等價實現
*
* @return {@link #key} 的匹配結果
*/
@Benchmark
public int byIfEquals() {
String key = this.key;
if ("code_0".equals(key)) {
return 0;
} else if ("code_1".equals(key)) {
return 1;
} else if ("code_2".equals(key)) {
return 2;
} else {
return -1;
}
}
```
基于上述原理,可以預先計算"code_0"、"code_1"、"code_2"的hashCode,來實現代碼性能的提升,實現如下:
```java
/** 預先計算好的"code_0"的hashCode */
final int PRE_HASH_CODE_0 = "code_0".hashCode();
/** 預先計算好的"code_1"的hashCode */
final int PRE_HASH_CODE_1 = "code_1".hashCode();
/** 預先計算好的"code_2"的hashCode */
final int PRE_HASH_CODE_2 = "code_2".hashCode();
/**
* 通過預先計算 {@link String#hashCode()} 來選擇
*
* @return {@link #key} 的匹配結果
*/
@Benchmark
public int byIfPreCache() {
String key = this.key;
int hashCode = key.hashCode();
if (hashCode == PRE_HASH_CODE_0 && "code_0".equals(key)) {
return 0;
} else if (hashCode == PRE_HASH_CODE_1 && "code_1".equals(key)) {
return 1;
} else if (hashCode == PRE_HASH_CODE_2 && "code_2".equals(key)) {
return 2;
} else {
return -1;
}
}
```
以下是基準測試結果:
- bySwitchByteCode 比 bySwitch 稍快,是因為 javac 在編譯期提前算好了字符串常量的hashCode(因為switch語句塊中只能case常量),最后輸出了byteCode
- byIfEquals 方法直接通過 equals 方法進行選擇,將直接進入 String#equals 方法中;而 byIfPreCache 中會先比較hashCode,避免了直接進入 String#equals 方法
- 另外,byIfPreCache 使用了提前計算好的 "code_1", "code_2","code_3" 的 hashCode,不用再在方法中重復計算,相比最初的 bySwitch 方法,提升了 33% 的性能
Benchmark | Mode | Cnt | Score & Error | Units
----|----|----|----:|----
SwitchString.byIfEquals | avgt |10 | 8.966 ± 0.172 | ns/op
SwitchString.byIfPreCache | avgt | 10 | 4.842 ± 0.169 | ns/op
SwitchString.bySwitch | avgt | 10 | 6.436 ± 0.142 | ns/op
SwitchString.bySwitchByteCode | avgt | 10 | 6.108 ± 0.092 | ns/op
## 4 對 StringBuilder 的補充說明
### 4.1 鏈式調用 StringBuidler#append 方法性能更好
本節主要建議(僅是一個建議)在方法內提前計算好局部變量的值,拼接字符串時,通過`append(str1).append(str2).append(str3).toStirng();` 的形式一次性輸出結果。
以下是測試用例,empty 方法為對照組,chainAppend 方法為鏈式調用,nonChainAppend 方法為非鏈式調用的形式。
```
String a = "a";
int b = 10;
char c = 'c';
boolean d = false;
/**
* 對照組
*/
@Benchmark
public String empty() {
StringBuilder sb = new StringBuilder();
return sb.toString();
}
/**
* 鏈式調用append方法
*/
@Benchmark
public String chainAppend() {
StringBuilder sb = new StringBuilder();
sb.append(a).append(b).append(c).append(d);
return sb.toString();
}
/**
* 非鏈式調用append方法
*/
@Benchmark
public String nonChainAppend() {
StringBuilder sb = new StringBuilder();
sb.append(a);
sb.append(b);
sb.append(c);
sb.append(d);
return sb.toString();
}
```
以下是基準測試結果:
- 結果表明,鏈式調用的性能略高于非鏈式調用
- 原因是這種非鏈式操作的append,每次都會從操作數棧中彈出,再從局部變量中裝載引用類型值入棧
Benchmark | Mode | Cnt | Score & Error | Units
----|----|----|----:|----
AppendMode.empty | avgt | 10 | 18.203 ± 2.581 | ns/op
AppendMode.chainAppend | avgt | 10 | 51.673 ± 8.233 | ns/op
AppendMode.nonChainAppend |avgt | 10 | 59.500 ± 18.410 | ns/op
### 4.2 StringBuffer 不比 StringBuidler 慢多少
StringBuffer 是線程安全的,而 StringBuilder 非線程安全;既然前者通過 synchronized 修飾了方法,性能必然沒StringBuilder好,但是其實沒有差多少。
以下 case 會分別對 StringBuffer 和 StringBuilder 的實例進行十、百、萬、百萬次字符串拼接操作:
```
@Param(value = {"10", "100", "10000", "1000000"})
int size;
@Benchmark
public String builder() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < size; i++) {
sb.append("name").append(i).append('\n');
}
return sb.toString();
}
@Benchmark
public String buffer() {
StringBuffer sb = new StringBuffer();
for (int i = 0; i < size; i++) {
sb.append("name").append(i).append('\n');
}
return sb.toString();
}
```
以下是基準測試結果:
- 結果表明在十至百萬次拼接中,StringBuffer的性能相對于StringBuilder性能,只低了1% ~ 6%
- StringBuffer 跟 StringBuilder和相比性能并不差多少,得益于JIT C2階段的逃逸分析和鎖消除(對象只在方法內部使用,可以消除synchronized)
- 逃逸分析:-XX:+DoEscapeAnalysis
- 鎖消除:-XX:+EliminateLocks
- 而實際上,方法內部局部變量以及方法參數是[線程私有](http://wiki.baidu.com/pages/viewpage.action?pageId=1312774340)的,即不存在線程安全問題,此時編譯器會直接提示開發者使用StringBuilder替換StringBuffer
Benchmark |(size) | Mode | Cnt | Score & Error | Units
----|----:|----|----|----:|----
StringBuilderBuffer.buffer | 10 | avgt | 10 | 231.920 ± 5.211 | ns/op
StringBuilderBuffer.buffer | 100 | avgt | 10 | 3655.676 ± 97.173 | ns/op
StringBuilderBuffer.buffer | 10000 | avgt | 10 | 531097.767 ± 19279.096 | ns/op
StringBuilderBuffer.buffer | 1000000 | avgt | 10 | 74592493.486 ± 1504365.581 | ns/op
StringBuilderBuffer.builder | 10 | avgt | 10 | 228.170 ± 7.743 | ns/op
StringBuilderBuffer.builder | 100 | avgt | 10 | 3275.142 ± 173.263 | ns/op
StringBuilderBuffer.builder | 10000 | avgt | 10 | 492880.005 ± 7956.828 | ns/op
StringBuilderBuffer.builder | 1000000 | avgt | 10 | 70098295.407 ± 1517437.435 | ns/op
## 附錄
### 附錄A JMH 配置信息
```java
@BenchmarkMode(Mode.AverageTime) // 使用模式為運行時間,默認是Mode.Throughput,表示吞吐量
@Warmup(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS) // 預熱
@Measurement(iterations = 10, time = 1, timeUnit = TimeUnit.SECONDS) // 運行
@Threads(1) // 同時執行的線程數
@Fork(1) // 為每個方法啟動一個進程
@OutputTimeUnit(TimeUnit.NANOSECONDS) // 統計結果的時間單元
@State(Scope.Benchmark) // 對象的生命周期
public class BenchmarkTest {
public static void main(String[] args) throws RunnerException {
Options opt = new OptionsBuilder()
.include(MethodHandles.lookup().lookupClass().getSimpleName())
.forks(1)
.build();
new Runner(opt).run();
}
}
```
- 空白目錄
- 精簡版Spring的實現
- 0 前言
- 1 注冊和獲取bean
- 2 抽象工廠實例化bean
- 3 注入bean屬性
- 4 通過XML配置beanFactory
- 5 將bean注入到bean
- 6 加入應用程序上下文
- 7 JDK動態代理實現的方法攔截器
- 8 加入切入點和aspectj
- 9 自動創建AOP代理
- Redis原理
- 1 Redis簡介與構建
- 1.1 什么是Redis
- 1.2 構建Redis
- 1.3 源碼結構
- 2 Redis數據結構與對象
- 2.1 簡單動態字符串
- 2.1.1 sds的結構
- 2.1.2 sds與C字符串的區別
- 2.1.3 sds主要操作的API
- 2.2 雙向鏈表
- 2.2.1 adlist的結構
- 2.2.2 adlist和listNode的API
- 2.3 字典
- 2.3.1 字典的結構
- 2.3.2 哈希算法
- 2.3.3 解決鍵沖突
- 2.3.4 rehash
- 2.3.5 字典的API
- 2.4 跳躍表
- 2.4.1 跳躍表的結構
- 2.4.2 跳躍表的API
- 2.5 整數集合
- 2.5.1 整數集合的結構
- 2.5.2 整數集合的API
- 2.6 壓縮列表
- 2.6.1 壓縮列表的結構
- 2.6.2 壓縮列表結點的結構
- 2.6.3 連鎖更新
- 2.6.4 壓縮列表API
- 2.7 對象
- 2.7.1 類型
- 2.7.2 編碼和底層實現
- 2.7.3 字符串對象
- 2.7.4 列表對象
- 2.7.5 哈希對象
- 2.7.6 集合對象
- 2.7.7 有序集合對象
- 2.7.8 類型檢查與命令多態
- 2.7.9 內存回收
- 2.7.10 對象共享
- 2.7.11 對象空轉時長
- 3 單機數據庫的實現
- 3.1 數據庫
- 3.1.1 服務端中的數據庫
- 3.1.2 切換數據庫
- 3.1.3 數據庫鍵空間
- 3.1.4 過期鍵的處理
- 3.1.5 數據庫通知
- 3.2 RDB持久化
- 操作系統
- 2021-01-08 Linux I/O 操作
- 2021-03-01 Linux 進程控制
- 2021-03-01 Linux 進程通信
- 2021-06-11 Linux 性能優化
- 2021-06-18 性能指標
- 2022-05-05 Android 系統源碼閱讀筆記
- Java基礎
- 2020-07-18 Java 前端編譯與優化
- 2020-07-28 Java 虛擬機類加載機制
- 2020-09-11 Java 語法規則
- 2020-09-28 Java 虛擬機字節碼執行引擎
- 2020-11-09 class 文件結構
- 2020-12-08 Java 內存模型
- 2021-09-06 Java 并發包
- 代碼性能
- 2020-12-03 Java 字符串代碼性能
- 2021-01-02 ASM 運行時增強技術
- 理解Unsafe
- Java 8
- 1 行為參數化
- 1.1 行為參數化的實現原理
- 1.2 Java 8中的行為參數化
- 1.3 行為參數化 - 排序
- 1.4 行為參數化 - 線程
- 1.5 泛型實現的行為參數化
- 1.6 小結
- 2 Lambda表達式
- 2.1 Lambda表達式的組成
- 2.2 函數式接口
- 2.2.1 Predicate
- 2.2.2 Consumer
- 2.2.3 Function
- 2.2.4 函數式接口列表
- 2.3 方法引用
- 2.3.1 方法引用的類別
- 2.3.2 構造函數引用
- 2.4 復合方法
- 2.4.1 Comparator復合
- 2.4.2 Predicate復合
- 2.4.3 Function復合
- 3 流處理
- 3.1 流簡介
- 3.1.1 流的定義
- 3.1.2 流的特點
- 3.2 流操作
- 3.2.1 中間操作
- 3.2.2 終端操作
- 3.3.3 構建流
- 3.3 流API
- 3.3.1 flatMap的用法
- 3.3.2 reduce的用法
- 3.4 collect操作
- 3.4.1 collect示例
- 3.4.2 Collector接口