就在今年 Java 25週歲了,可能比在座的各位中的一些少年年齡還大,但令人遺憾的是,竟然沒有我大,不禁感嘆,Java 還是太小了。(難道我會說是因爲我老了?)
而就在上個月,Java 15 的試驗版悄悄發佈了,但是在 Java 界一直有個神祕現象,那就是「你發你發任你發,我的最愛 Java 8」.
據 Snyk 和 The Java Magazine 聯合推出發佈的 2020 JVM 生態調查報告顯示,在所有的 Java 版本中,仍然有 64% 的開發者使用 Java 8。另外一些開發者可能已經開始用 Java 9、Java 11、Java 13 了,當然還有一些神仙開發者還在堅持使用 JDK 1.6 和 1.7。
儘管 Java 8 發佈多年,使用者衆多,可神奇的是竟然有很多同學沒有用過 Java 8 的新特性,比如 Lambda表達式、比如方法引用,再比如今天要說的 Stream。其實 Stream 就是以 Lambda 和方法引用爲基礎,封裝的簡單易用、函數式風格的 API。
Java 8 是在 2014 年發佈的,實話說,風箏我也是在 Java 8 發佈後很長一段時間才用的 Stream,因爲 Java 8 發佈的時候我還在 C# 的世界中掙扎,而使用 Lambda 表達式卻很早了,因爲 Python 中用 Lambda 很方便,沒錯,我寫 Python 的時間要比 Java 的時間還長。
要講 Stream ,那就不得不先說一下它的左膀右臂 Lambda 和方法引用,你用的 Stream API 其實就是函數式的編程風格,其中的「函數」就是方法引用,「式」就是 Lambda 表達式。
Lambda 表達式
Lambda 表達式是一個匿名函數,Lambda表達式基於數學中的λ演算得名,直接對應於其中的lambda抽象,是一個匿名函數,即沒有函數名的函數。Lambda表達式可以表示閉包。
在 Java 中,Lambda 表達式的格式是像下面這樣
// 無參數,無返回值
() -> log.info("Lambda")
// 有參數,有返回值
(int a, int b) -> { a+b }
其等價於
log.info("Lambda");
private int plus(int a, int b){
return a+b;
}
最常見的一個例子就是新建線程,有時候爲了省事,會用下面的方法創建並啓動一個線程,這是匿名內部類的寫法,new Thread需要一個 implements 自Runnable類型的對象實例作爲參數,比較好的方式是創建一個新類,這個類 implements Runnable,然後 new 出這個新類的實例作爲參數傳給 Thread。而匿名內部類不用找對象接收,直接當做參數。
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("快速新建並啓動一個線程");
}
}).run();
但是這樣寫是不是感覺看上去很亂、很土,而這時候,換上 Lambda 表達式就是另外一種感覺了。
new Thread(()->{
System.out.println("快速新建並啓動一個線程");
}).run();
怎麼樣,這樣一改,瞬間感覺清新脫俗了不少,簡潔優雅了不少。
Lambda 表達式簡化了匿名內部類的形式,可以達到同樣的效果,但是 Lambda 要優雅的多。雖然最終達到的目的是一樣的,但其實內部的實現原理卻不相同。
匿名內部類在編譯之後會創建一個新的匿名內部類出來,而 Lambda 是調用 JVM invokedynamic指令實現的,並不會產生新類。
方法引用
方法引用的出現,使得我們可以將一個方法賦給一個變量或者作爲參數傳遞給另外一個方法。::雙冒號作爲方法引用的符號,比如下面這兩行語句,引用 Integer類的 parseInt方法。
Function<String, Integer> s = Integer::parseInt;
Integer i = s.apply("10");
或者下面這兩行,引用 Integer類的 compare方法。
Comparator<Integer> comparator = Integer::compare;
int result = comparator.compare(100,10);
再比如,下面這兩行代碼,同樣是引用 Integer類的 compare方法,但是返回類型卻不一樣,但卻都能正常執行,並正確返回。
IntBinaryOperator intBinaryOperator = Integer::compare;
int result = intBinaryOperator.applyAsInt(10,100);
相信有的同學看到這裏恐怕是下面這個狀態,完全不可理喻嗎,也太隨便了吧,返回給誰都能接盤。
先別激動,來來來,現在咱們就來解惑,解除蒙圈臉。
Q:什麼樣的方法可以被引用?
A:這麼說吧,任何你有辦法訪問到的方法都可以被引用。
Q:返回值到底是什麼類型?
A:這就問到點兒上了,上面又是 Function、又是Comparator、又是 IntBinaryOperator的,看上去好像沒有規律,其實不然。
返回的類型是 Java 8 專門定義的函數式接口,這類接口用 @FunctionalInterface 註解。
比如 Function這個函數式接口的定義如下:
@FunctionalInterface
public interface Function<T, R> {
R apply(T t);
}
還有很關鍵的一點,你的引用方法的參數個數、類型,返回值類型要和函數式接口中的方法聲明一一對應纔行。
比如 Integer.parseInt方法定義如下:
public static int parseInt(String s) throws NumberFormatException {
return parseInt(s,10);
}
首先parseInt方法的參數個數是 1 個,而 Function中的 apply方法參數個數也是 1 個,參數個數對應上了,再來,apply方法的參數類型和返回類型是泛型類型,所以肯定能和 parseInt方法對應上。
這樣一來,就可以正確的接收Integer::parseInt的方法引用,並可以調用Funciton的apply方法,這時候,調用到的其實就是對應的 Integer.parseInt方法了。
用這套標準套到 Integer::compare方法上,就不難理解爲什麼即可以用 Comparator<Integer>接收,又可以用 IntBinaryOperator接收了,而且調用它們各自的方法都能正確的返回結果。
Integer.compare方法定義如下:
public static int compare(int x, int y) {
return (x < y) ? -1 : ((x == y) ? 0 : 1);
}
返回值類型 int,兩個參數,並且參數類型都是 int。
然後來看Comparator和IntBinaryOperator它們兩個的函數式接口定義和其中對應的方法:
@FunctionalInterface
public interface Comparator<T> {
int compare(T o1, T o2);
}
@FunctionalInterface
public interface IntBinaryOperator {
int applyAsInt(int left, int right);
}
對不對,都能正確的匹配上,所以前面示例中用這兩個函數式接口都能正常接收。其實不止這兩個,只要是在某個函數式接口中聲明瞭這樣的方法:兩個參數,參數類型是 int或者泛型,並且返回值是 int或者泛型的,都可以完美接收。
JDK 中定義了很多函數式接口,主要在 java.util.function包下,還有 java.util.Comparator 專門用作定製比較器。另外,前面說的 Runnable也是一個函數式接口。
自己動手實現一個例子
1. 定義一個函數式接口,並添加一個方法
定義了名稱爲 KiteFunction 的函數式接口,使用 @FunctionalInterface註解,然後聲明瞭具有兩個參數的方法 run,都是泛型類型,返回結果也是泛型。
還有一點很重要,函數式接口中只能聲明一個可被實現的方法,你不能聲明瞭一個 run方法,又聲明一個 start方法,到時候編譯器就不知道用哪個接收了。而用default 關鍵字修飾的方法則沒有影響。
@FunctionalInterface
public interface KiteFunction<T, R, S> {
/**
* 定義一個雙參數的方法
* @param t
* @param s
* @return
*/
R run(T t,S s);
}
2. 定義一個與 KiteFunction 中 run 方法對應的方法
在 FunctionTest 類中定義了方法 DateFormat,一個將 LocalDateTime類型格式化爲字符串類型的方法。
public class FunctionTest {
public static String DateFormat(LocalDateTime dateTime, String partten) {
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(partten);
return dateTime.format(dateTimeFormatter);
}
}
3.用方法引用的方式調用
正常情況下我們直接使用 FunctionTest.DateFormat()就可以了。
而用函數式方式,是這樣的。
KiteFunction<LocalDateTime,String,String> functionDateFormat = FunctionTest::DateFormat;
String dateString = functionDateFormat.run(LocalDateTime.now(),"yyyy-MM-dd HH:mm:ss");
而其實我可以不專門在外面定義 DateFormat這個方法,而是像下面這樣,使用匿名內部類。
public static void main(String[] args) throws Exception {
String dateString = new KiteFunction<LocalDateTime, String, String>() {
@Override
public String run(LocalDateTime localDateTime, String s) {
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(s);
return localDateTime.format(dateTimeFormatter);
}
}.run(LocalDateTime.now(), "yyyy-MM-dd HH:mm:ss");
System.out.println(dateString);
}
前面第一個 Runnable的例子也提到了,這樣的匿名內部類可以用 Lambda 表達式的形式簡寫,簡寫後的代碼如下:
public static void main(String[] args) throws Exception {
KiteFunction<LocalDateTime, String, String> functionDateFormat = (LocalDateTime dateTime, String partten) -> {
DateTimeFormatter dateTimeFormatter = DateTimeFormatter.ofPattern(partten);
return dateTime.format(dateTimeFormatter);
};
String dateString = functionDateFormat.run(LocalDateTime.now(), "yyyy-MM-dd HH:mm:ss");
System.out.println(dateString);
}
使用(LocalDateTime dateTime, String partten) -> { } 這樣的 Lambda 表達式直接返回方法引用。
Stream API
爲了說一下 Stream API 的使用,可以說是大費周章啊,知其然,也要知其所以然嗎,追求技術的態度和姿勢要正確。
當然 Stream 也不只是 Lambda 表達式就厲害了,真正厲害的還是它的功能,Stream 是 Java 8 中集合數據處理的利器,很多本來複雜、需要寫很多代碼的方法,比如過濾、分組等操作,往往使用 Stream 就可以在一行代碼搞定,當然也因爲 Stream 都是鏈式操作,一行代碼可能會調用好幾個方法。
Collection接口提供了 stream()方法,讓我們可以在一個集合方便的使用 Stream API 來進行各種操作。值得注意的是,我們執行的任何操作都不會對源集合造成影響,你可以同時在一個集合上提取出多個 stream 進行操作。
我們看 Stream 接口的定義,繼承自 BaseStream,機會所有的接口聲明都是接收方法引用類型的參數,比如 filter方法,接收了一個 Predicate類型的參數,它就是一個函數式接口,常用來作爲條件比較、篩選、過濾用,JPA中也使用了這個函數式接口用來做查詢條件拼接。
public interface Stream<T> extends BaseStream<T, Stream<T>> {
Stream<T> filter(Predicate<? super T> predicate);
// 其他接口
}
下面就來看看 Stream 常用 API。
of
可接收一個泛型對象或可變成泛型集合,構造一個 Stream 對象。
private static void createStream(){
Stream<String> stringStream = Stream.of("a","b","c");
}
empty
創建一個空的 Stream 對象。
concat
連接兩個 Stream ,不改變其中任何一個 Steam 對象,返回一個新的 Stream 對象。
private static void concatStream(){
Stream<String> a = Stream.of("a","b","c");
Stream<String> b = Stream.of("d","e");
Stream<String> c = Stream.concat(a,b);
}
max
一般用於求數字集合中的最大值,或者按實體中數字類型的屬性比較,擁有最大值的那個實體。它接收一個 Comparator<T>,上面也舉到這個例子了,它是一個函數式接口類型,專門用作定義兩個對象之間的比較,例如下面這個方法使用了 Integer::compareTo這個方法引用。
private static void max(){
Stream<Integer> integerStream = Stream.of(2, 2, 100, 5);
Integer max = integerStream.max(Integer::compareTo).get();
System.out.println(max);
}
當然,我們也可以自己定製一個 Comparator,順便複習一下 Lambda 表達式形式的方法引用。
private static void max(){
Stream<Integer> integerStream = Stream.of(2, 2, 100, 5);
Comparator<Integer> comparator = (x, y) -> (x.intValue() < y.intValue()) ? -1 : ((x.equals(y)) ? 0 : 1);
Integer max = integerStream.max(comparator).get();
System.out.println(max);
}
min
與 max 用法一樣,只不過是求最小值。
findFirst
獲取 Stream 中的第一個元素。
findAny
獲取 Stream 中的某個元素,如果是串行情況下,一般都會返回第一個元素,並行情況下就不一定了。
count
返回元素個數。
Stream<String> a = Stream.of("a", "b", "c");
long x = a.count();
peek
建立一個通道,在這個通道中對 Stream 的每個元素執行對應的操作,對應 Consumer<T>的函數式接口,這是一個消費者函數式接口,顧名思義,它是用來消費 Stream 元素的,比如下面這個方法,把每個元素轉換成對應的大寫字母並輸出。
private static void peek() {
Stream<String> a = Stream.of("a", "b", "c");
List<String> list = a.peek(e->System.out.println(e.toUpperCase())).collect(Collectors.toList());
}
forEach
和 peek 方法類似,都接收一個消費者函數式接口,可以對每個元素進行對應的操作,但是和 peek 不同的是,forEach 執行之後,這個 Stream 就真的被消費掉了,之後這個 Stream 流就沒有了,不可以再對它進行後續操作了,而 peek操作完之後,還是一個可操作的 Stream 對象。
正好藉着這個說一下,我們在使用 Stream API 的時候,都是一串鏈式操作,這是因爲很多方法,比如接下來要說到的 filter方法等,返回值還是這個 Stream 類型的,也就是被當前方法處理過的 Stream 對象,所以 Stream API 仍然可以使用。
private static void forEach() {
Stream<String> a = Stream.of("a", "b", "c");
a.forEach(e->System.out.println(e.toUpperCase()));
}
forEachOrdered
功能與 forEach是一樣的,不同的是,forEachOrdered是有順序保證的,也就是對 Stream 中元素按插入時的順序進行消費。爲什麼這麼說呢,當開啓並行的時候,forEach和 forEachOrdered的效果就不一樣了。
Stream<String> a = Stream.of("a", "b", "c");
a.parallel().forEach(e->System.out.println(e.toUpperCase()));
當使用上面的代碼時,輸出的結果可能是 B、A、C 或者 A、C、B或者A、B、C,而使用下面的代碼,則每次都是 A、 B、C
Stream<String> a = Stream.of("a", "b", "c");
a.parallel().forEachOrdered(e->System.out.println(e.toUpperCase()));
limit
獲取前 n 條數據,類似於 MySQL 的limit,只不過只能接收一個參數,就是數據條數。
private static void limit() {
Stream<String> a = Stream.of("a", "b", "c");
a.limit(2).forEach(e->System.out.println(e));
}
上述代碼打印的結果是 a、b。
skip
跳過前 n 條數據,例如下面代碼,返回結果是 c。
private static void skip() {
Stream<String> a = Stream.of("a", "b", "c");
a.skip(2).forEach(e->System.out.println(e));
}
distinct
元素去重,例如下面方法返回元素是 a、b、c,將重複的 b 只保留了一個。
private static void distinct() {
Stream<String> a = Stream.of("a", "b", "c","b");
a.distinct().forEach(e->System.out.println(e));
}
sorted
有兩個重載,一個無參數,另外一個有個 Comparator類型的參數。
無參類型的按照自然順序進行排序,只適合比較單純的元素,比如數字、字母等。
private static void sorted() {
Stream<String> a = Stream.of("a", "c", "b");
a.sorted().forEach(e->System.out.println(e));
}
有參數的需要自定義排序規則,例如下面這個方法,按照第二個字母的大小順序排序,最後輸出的結果是 a1、b3、c6。
private static void sortedWithComparator() {
Stream<String> a = Stream.of("a1", "c6", "b3");
a.sorted((x,y)->Integer.parseInt(x.substring(1))>Integer.parseInt(y.substring(1))?1:-1).forEach(e->System.out.println(e));
}
爲了更好的說明接下來的幾個 API ,我模擬了幾條項目中經常用到的類似數據,10條用戶信息。
private static List<User> getUserData() {
Random random = new Random();
List<User> users = new ArrayList<>();
for (int i = 1; i <= 10; i++) {
User user = new User();
user.setUserId(i);
user.setUserName(String.format("古時的風箏 %s 號", i));
user.setAge(random.nextInt(100));
user.setGender(i % 2);
user.setPhone("18812021111");
user.setAddress("無");
users.add(user);
}
return users;
}
filter
用於條件篩選過濾,篩選出符合條件的數據。例如下面這個方法,篩選出性別爲 0,年齡大於 50 的記錄。
private static void filter(){
List<User> users = getUserData();
Stream<User> stream = users.stream();
stream.filter(user -> user.getGender().equals(0) && user.getAge()>50).forEach(e->System.out.println(e));
/**
*等同於下面這種形式 匿名內部類
*/
// stream.filter(new Predicate<User>() {
// @Override
// public boolean test(User user) {
// return user.getGender().equals(0) && user.getAge()>50;
// }
// }).forEach(e->System.out.println(e));
}
map
map方法的接口方法聲明如下,接受一個 Function函數式接口,把它翻譯成映射最合適了,通過原始數據元素,映射出新的類型。
<R> Stream<R> map(Function<? super T, ? extends R> mapper);
而 Function的聲明是這樣的,觀察 apply方法,接受一個 T 型參數,返回一個 R 型參數。用於將一個類型轉換成另外一個類型正合適,這也是 map的初衷所在,用於改變當前元素的類型,例如將 Integer 轉爲 String類型,將 DAO 實體類型,轉換爲 DTO 實例類型。
當然了,T 和 R 的類型也可以一樣,這樣的話,就和 peek方法沒什麼不同了。
@FunctionalInterface
public interface Function<T, R> {
/**
* Applies this function to the given argument.
*
* @param t the function argument
* @return the function result
*/
R apply(T t);
}
例如下面這個方法,應該是業務系統的常用需求,將 User 轉換爲 API 輸出的數據格式。
private static void map(){
List<User> users = getUserData();
Stream<User> stream = users.stream();
List<UserDto> userDtos = stream.map(user -> dao2Dto(user)).collect(Collectors.toList());
}
private static UserDto dao2Dto(User user){
UserDto dto = new UserDto();
BeanUtils.copyProperties(user, dto);
//其他額外處理
return dto;
}
mapToInt
將元素轉換成 int 類型,在 map方法的基礎上進行封裝。
mapToLong
將元素轉換成 Long 類型,在 map方法的基礎上進行封裝。
mapToDouble
將元素轉換成 Double 類型,在 map方法的基礎上進行封裝。
flatMap
這是用在一些比較特別的場景下,當你的 Stream 是以下這幾種結構的時候,需要用到 flatMap方法,用於將原有二維結構扁平化。
- Stream<String[]>
- Stream<Set<String>>
- Stream<List<String>>
以上這三類結構,通過 flatMap方法,可以將結果轉化爲 Stream<String>這種形式,方便之後的其他操作。
比如下面這個方法,將List<List<User>>扁平處理,然後再使用 map或其他方法進行操作。
private static void flatMap(){
List<User> users = getUserData();
List<User> users1 = getUserData();
List<List<User>> userList = new ArrayList<>();
userList.add(users);
userList.add(users1);
Stream<List<User>> stream = userList.stream();
List<UserDto> userDtos = stream.flatMap(subUserList->subUserList.stream()).map(user -> dao2Dto(user)).collect(Collectors.toList());
}
flatMapToInt
用法參考 flatMap,將元素扁平爲 int 類型,在 flatMap方法的基礎上進行封裝。
flatMapToLong
用法參考 flatMap,將元素扁平爲 Long 類型,在 flatMap方法的基礎上進行封裝。
flatMapToDouble
用法參考 flatMap,將元素扁平爲 Double 類型,在 flatMap方法的基礎上進行封裝。
collection
在進行了一系列操作之後,我們最終的結果大多數時候並不是爲了獲取 Stream 類型的數據,而是要把結果變爲 List、Map 這樣的常用數據結構,而 collection就是爲了實現這個目的。
就拿 map 方法的那個例子說明,將對象類型進行轉換後,最終我們需要的結果集是一個 List<UserDto >類型的,使用 collect方法將 Stream 轉換爲我們需要的類型。
下面是 collect接口方法的定義:
<R, A> R collect(Collector<? super T, A, R> collector);
下面這個例子演示了將一個簡單的 Integer Stream 過濾出大於 7 的值,然後轉換成 List<Integer>集合,用的是 Collectors.toList()這個收集器。
private static void collect(){
Stream<Integer> integerStream = Stream.of(1,2,5,7,8,12,33);
List<Integer> list = integerStream.filter(s -> s.intValue()>7).collect(Collectors.toList());
}
很多同學表示看不太懂這個 Collector是怎麼一個意思,來,我們看下面這段代碼,這是 collect的另一個重載方法,你可以理解爲它的參數是按順序執行的,這樣就清楚了,這就是個 ArrayList 從創建到調用 addAll方法的一個過程。
private static void collect(){
Stream<Integer> integerStream = Stream.of(1,2,5,7,8,12,33);
List<Integer> list = integerStream.filter(s -> s.intValue()>7).collect(ArrayList::new, ArrayList::add,
ArrayList::addAll);
}
我們在自定義 Collector的時候其實也是這個邏輯,不過我們根本不用自定義, Collectors已經爲我們提供了很多拿來即用的收集器。比如我們經常用到Collectors.toList()、Collectors.toSet()、Collectors.toMap()。另外還有比如Collectors.groupingBy()用來分組,比如下面這個例子,按照 userId 字段分組,返回以 userId 爲key,List 爲value 的 Map,或者返回每個 key 的個數。
// 返回 userId:List<User>
Map<String,List<User>> map = user.stream().collect(Collectors.groupingBy(User::getUserId));
// 返回 userId:每組個數
Map<String,Long> map = user.stream().collect(Collectors.groupingBy(User::getUserId,Collectors.counting()));
toArray
collection是返回列表、map 等,toArray是返回數組,有兩個重載,一個空參數,返回的是 Object[]。
另一個接收一個 IntFunction<R>類型參數。
@FunctionalInterface
public interface IntFunction<R> {
/**
* Applies this function to the given argument.
*
* @param value the function argument
* @return the function result
*/
R apply(int value);
}
比如像下面這樣使用,參數是 User[]::new也就是new 一個 User 數組,長度爲最後的 Stream 長度。
private static void toArray() {
List<User> users = getUserData();
Stream<User> stream = users.stream();
User[] userArray = stream.filter(user -> user.getGender().equals(0) && user.getAge() > 50).toArray(User[]::new);
}
reduce
它的作用是每次計算的時候都用到上一次的計算結果,比如求和操作,前兩個數的和加上第三個數的和,再加上第四個數,一直加到最後一個數位置,最後返回結果,就是 reduce的工作過程。
private static void reduce(){
Stream<Integer> integerStream = Stream.of(1,2,5,7,8,12,33);
Integer sum = integerStream.reduce(0,(x,y)->x+y);
System.out.println(sum);
}
另外 Collectors好多方法都用到了 reduce,比如 groupingBy、minBy、maxBy等等。
並行 Stream
Stream 本質上來說就是用來做數據處理的,爲了加快處理速度,Stream API 提供了並行處理 Stream 的方式。通過 users.parallelStream()或者users.stream().parallel() 的方式來創建並行 Stream 對象,支持的 API 和普通 Stream 幾乎是一致的。
並行 Stream 默認使用 ForkJoinPool線程池,當然也支持自定義,不過一般情況下沒有必要。ForkJoin 框架的分治策略與並行流處理正好契合。
雖然並行這個詞聽上去很厲害,但並不是所有情況使用並行流都是正確的,很多時候完全沒這個必要。
什麼情況下使用或不應使用並行流操作呢?
- 必須在多核 CPU 下才使用並行 Stream,聽上去好像是廢話。
- 在數據量不大的情況下使用普通串行 Stream 就可以了,使用並行 Stream 對性能影響不大。
- CPU 密集型計算適合使用並行 Stream,而 IO 密集型使用並行 Stream 反而會更慢。
- 雖然計算是並行的可能很快,但最後大多數時候還是要使用 collect合併的,如果合併代價很大,也不適合用並行 Stream。
- 有些操作,比如 limit、 findFirst、forEachOrdered 等依賴於元素順序的操作,都不適合用並行 Stream。
最後
Java 25 週歲了,有多少同學跟我一樣在用 Java 8,還有多少同學在用更早的版本,請說出你的故事。