Java 8 的 Lambda 表达式和 Stream API


简介

Java 8 的 Lambda 表达式提供了强大的函数化的编程能力,将函数作为参数传递进方法中。免去了使用匿名方法的麻烦,这样使可读性更好,表达更清晰。它是推动 Java 8 发布的最重要新特性。Lambda 表达式的简洁让人非常激动,但是如果第一次看到一段复杂的Lambda表达式的代码,会让你非常头疼,对于初学者来说,可能就是一段垃圾代码,因为你并不知道 Lambda 表达式到底在表达什么╮(╯▽╰)╭下面我们就举一些小例子由浅入深的了解下 Lambda 表达式。

基本语法

(parameters) -> expression 或者 (parameters) ->{ statements; }

个人理解,把 Lambda 表达式看成咱们上学的时候学的函数 f(x) = x + 1 会让你更容易理解。

以下是lambda表达式的重要特征:

  • 可选类型声明:不需要声明参数类型,编译器可以统一识别参数值。
  • 可选的参数圆括号:一个参数无需定义圆括号,但多个参数需要定义圆括号。
  • 可选的大括号:如果主体包含了一个语句,就不需要使用大括号。
  • 可选的返回关键字:如果主体只有一个表达式返回值则编译器会自动返回值,大括号需要指定明表达式返回了一个数值。

简单实例

1
2
3
4
public interface Operation {

int calc(int a, int b);
}
1
2
3
4
public interface Greeting {

void say(String msg);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
public class LambdaTest {

public static int calc(int a, int b, Operation operation) {
return operation.calc(a, b);
}

public static void say(String msg, Greeting greeting) {
greeting.say(msg);
}

public static void main(String args[]) {
// 类型声明
Operation addition = (int a, int b) -> a + b;

// 不用类型声明
Operation subtraction = (a, b) -> a - b;

// 大括号中的返回语句。
// 一般只有存在多行语句的时候才会使用,单行语句不需要使用,部分IDE会提示去掉大括号。
Operation multiplication = (int a, int b) -> {
return a * b;
};

// 没有大括号及返回语句
Operation division = (int a, int b) -> a / b;

System.out.println("10 + 5 = " + LambdaTest.calc(10, 5, addition));
System.out.println("10 - 5 = " + LambdaTest.calc(10, 5, subtraction));
System.out.println("10 x 5 = " + LambdaTest.calc(10, 5, multiplication));
System.out.println("10 / 5 = " + LambdaTest.calc(10, 5, division));

// 不用括号
Greeting sayHello = message -> System.out.println("Hello " + message);

// 用括号
Greeting sayBye = (message) -> System.out.println("Bye " + message);

LambdaTest.say("cylong", sayHello);
LambdaTest.say("cylong", sayBye);
LambdaTest.say("cylong", message -> System.out.println("Hi " + message));

// 以前的匿名类
LambdaTest.say("cylong", new Greeting() {
@Override
public void say(String msg) {
System.out.println("Hello " + msg);
}
});
}
}

以上代码的输出为:

1
2
3
4
5
6
7
8
10 + 5 = 15
10 - 5 = 5
10 x 5 = 50
10 / 5 = 2
Hello cylong
Bye cylong
Hello cylong
Hello cylong

Java 8 的 Stream API

其实目前用 Lambda 表达式最多的地方就是 Java 8 的新特性——Stream API,借助于 Lambda 表达式,极大的提高编程效率和程序可读性。同时它提供串行和并行两种模式进行汇聚操作,并发模式能够充分利用多核处理器的优势,使用 fork/join 并行方式来拆分任务和加速处理过程。Java 8 中的 Stream 是对集合(Collection)对象功能的增强,Stream 不是集合元素,它不是数据结构并不保存数据,它是有关算法和计算的。在以前的 Java API中,我们更多的是使用for或者Iterator来遍历集合,同时我们可能会对集合里的数据进行过滤,计算等等处理,导致代码量非常的多,还容易出错。而使用 Stream,用户只要给出需要对其包含的元素执行什么操作,比如 “过滤掉长度大于 10 的字符串”、“获取每个字符串的首字母”等,Stream 会隐式地在内部进行遍历,做出相应的数据转换。下面我们就看一些例子,深入了解下 Stream 的使用。

集合迭代

1
2
3
4
5
6
7
8
9
10
11
12
13
List<Integer> numList = Arrays.asList(1, 2, 3, 4, 5, 6, 7, 8, 9, 10);

// 普通的 for 循环
for (int n : numList) {
System.out.println(n);
}

// 使用 Lambda 表达式
numList.forEach(n -> System.out.println(n));

// 使用 Java 8 的方法引用
// 看起来像C++的作用域解析运算符
numList.forEach(System.out::println);

上面的例子包含普通的for循环,Lambda表达式遍历的方式,最后一种方式是方法引用,让代码量再次减少,代码更加清晰。

集合过滤

1
2
// 输出集合中所有大于5的值
numList.stream().filter(n -> n > 5).forEach(System.out::println);

上面的代码中 filter(n -> n > 5)就是获得集合中大于5的所有值,filter 的参数是 java.util.function.Predicate,返回值是一个 Stream。使用 Predicate 可以向API方法添加逻辑,用更少的代码支持更多的动态行为。上面的例子就是使用 Predicate 对集合进行过滤。在 filter() 方法中,我们可以写更多复杂的逻辑来过滤集合元素。甚至可以使用 and() 或者 or()等合并多个条件,如下面这样。

1
2
3
4
// 输出集合中所有大于5并且小于8的值
Predicate<Integer> start = n -> n >5;
Predicate<Integer> end = n -> n <8;
numList.stream().filter(start.and(end)).forEach(System.out::println);

另外,关于 filter() 方法有个常见误解。在现实生活中,做过滤的时候,通常会丢弃部分,但使用filter()方法则是获得一个新的列表,且其每个元素符合过滤原则。

1
2
3
4
5
// 创建一个新的集合,所有元素的值大于5
List<Integer> newNumList = numList.stream().filter(n -> n > 5).collect(Collectors.toList());
newNumList.forEach(System.out::println);
System.out.println();
numList.forEach(System.out::print);

输出是:

1
2
678910
12345678910

Stream 的 map 示例

1
2
// 将集合中的所有值计算平方后输出
numList.stream().map(n -> n * n).forEach(System.out::println);

本例介绍最广为人知的函数式编程概念 map。它允许你将对象进行转换。例如在本例中,我们将 n -> n * n lambda 表达式传到 map() 方法,后者将其应用到流中的每一个元素。然后用 forEach() 将列表元素打印出来。

Stream 的 Reduce 示例

1
2
3
// 将集合中的所有值求和
int result = numList.stream().reduce((sum, n) -> sum + n).get();
System.out.println(result);

在上个例子中,可以看到map将集合类(例如列表)元素进行转换的。还有一个 reduce() 函数可以将所有值合并成一个。Map和Reduce操作是函数式编程的核心操作,因为其功能,reduce 又被称为折叠操作。另外,reduce 并不是一个新的操作,你有可能已经在使用它。SQL中类似 sum()、avg() 或者 count() 的聚集函数,实际上就是 reduce 操作,因为它们接收多个值并返回一个值。流API定义的 reduce() 函数可以接受lambda表达式,并对所有值进行合并。IntStream这样的类有类似 average()、count()、sum() 的内建方法来做 reduce 操作,也有mapToLong()、mapToDouble() 方法来做转换。这并不会限制你,你可以用内建方法,也可以自己定义。

计算集合元素的最大值、最小值、总和以及平均值

1
2
3
4
5
6
// 计算集合元素的最大值、最小值、总和以及平均值
IntSummaryStatistics stats = numList.stream().mapToInt((x) -> x).summaryStatistics();
System.out.println("Highest prime number in List : " + stats.getMax());
System.out.println("Lowest prime number in List : " + stats.getMin());
System.out.println("Sum of all prime numbers : " + stats.getSum());
System.out.println("Average of all prime numbers : " + stats.getAverage());

输出:

1
2
3
4
Highest prime number in List : 10
Lowest prime number in List : 1
Sum of all prime numbers : 55
Average of all prime numbers : 5.5

并行流 parallelStream

上文有提到,Stream API 提供串行和并行两种模式进行汇聚操作,并发模式能够充分利用多核处理器的优势。串行流就是上面的 stream,而想要并行操作,就需要使用 parallelSteram。下面举一个例子来看看 stream 和 parallelStream 的区别。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
public class ParallelStreamTest {

public static void main(String[] args) {
List<Integer> numList = Arrays.asList(1, 2, 3, 4, 5);

doFor(numList);
doStream(numList);
doParallelStream(numList);
}

private static void doFor(List<Integer> numList) {
long start = System.currentTimeMillis();
for (int num : numList) {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.print(num);
}
System.out.println();
long stop = System.currentTimeMillis();
System.out.println("doFor: " + (stop - start));
}

private static void doStream(List<Integer> numList) {
long start = System.currentTimeMillis();
numList.stream().forEach(num -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.print(num);
});
System.out.println();
long stop = System.currentTimeMillis();
System.out.println("doStream: " + (stop - start));
}

private static void doParallelStream(List<Integer> numList) {
long start = System.currentTimeMillis();
numList.parallelStream().forEach(num -> {
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.print(num);
});
System.out.println();
long stop = System.currentTimeMillis();
System.out.println("doParallelStream: " + (stop - start));
}
}

输出:

1
2
3
4
5
6
12345
doFor: 5003
12345
doStream: 5003
34251
doParallelStream: 1009

代码上 stream 和 parallelStream 语法差异较小,用法基本一样。从执行结果来看,stream 顺序输出,而 parallelStream 无序输出;parallelStream 执行耗时是 stream 的五分之一,stream 和 for 循环用时一样。可以看到在当前测试场景下,parallelStream 获得的相对较好的执行性能,那 parallelStream 背后到底是什么呢?
要深入了解 parallelStream,首先要弄明白 ForkJoin 框架和 ForkJoinPool。ForkJoin 框架是 java 7 中提供的并行执行框架,他的策略是分而治之。说白了,就是把一个大的任务切分成很多小的子任务,子任务执行完毕后,再把结果合并起来。

parallelStream 使用注意点

在开发过程中,经常会遇到遍历一个很大的集合做重复的操作,这时候如果使用串行执行会相当耗时,因此一般会采用多线程来提速。但是 parallelStream 若使用不当,很容易掉进陷阱中。总结以下几点需要注意:

  • parallelStream 对集合操作是无序的,所以若需要顺序操作,请使用 stream 或者使用 parallelStream().forEachOrdered,后者执行时间就和 stream一样了,并不会提高效率。
  • parallelStream 速度并不会总是比 stream 快。将上面的例子修改为 Thread.sleep(1),输出结果为:
1
2
3
4
5
6
12345
doFor: 10
12345
doStream: 12
32154
doParallelStream: 13

可见并不是并行执行就是性能最好的,要根据具体的应用场景测试分析。这个例子中,每个子任务执行时间较短,而线程切换消耗了大量时间。

  • paralleStream 是非线程安全的!非线程安全!非线程安全!重要的事情说三遍。下面看一个例子就可以很明显的看到了。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
public class ThreadSafeTest {

private static List<Integer> list1 = new ArrayList<>();
private static List<Integer> list2 = new ArrayList<>();
private static List<Integer> list3 = new ArrayList<>();
private static Lock lock = new ReentrantLock();

public static void main(String[] args) {
IntStream.range(0, 10000).forEach(list1::add);

IntStream.range(0, 10000).parallel().forEach(list2::add);

IntStream.range(0, 10000).forEach(i -> {
lock.lock();
try {
list3.add(i);
}finally {
lock.unlock();
}
});

System.out.println("串行执行的大小:" + list1.size());
System.out.println("并行执行的大小:" + list2.size());
System.out.println("加锁并行执行的大小:" + list3.size());
}

}

输出:

1
2
3
串行执行的大小:10000
并行执行的大小:9592
加锁并行执行的大小:10000

显而易见,stream.parallel.forEach()中执行的操作并非线程安全。如果需要线程安全,可以把集合转换为同步集合,即:Collections.synchronizedList(new ArrayList<>())。也可以像例子中的那样,对操作进行加锁。

总结

  • Lambda 表达式提供了 Java 的函数化编程能力,取代了匿名内部类。让我们的代码量更少更美观。
  • Lambda表达式在Java中又称为闭包或匿名函数。
  • Stream API 提供了强大的集合操作。让我们在开发过程中更关心逻辑,而不是怎么详细的去实现。
  • stream 是串行的,线程安全的。parallelStream 是并行的,线程不安全的,在使用过程中尤其要注意。

参考资料

Java 8 Lambda 表达式 | 菜鸟教程
Java 8 中的 Streams API 详解 | IBM Developer
Java 8 Lambda 表达式10个示例 | ImportNew
Java 8 parallelStream 浅析 | 知乎
Java 8 parallelStream 并发安全的思考 | puyangsky 博客园
深入浅出 parallelStream | 梦铃之境的专栏


文章标题:Java 8 的 Lambda 表达式和 Stream API
文章作者:cylong
文章链接:http://www.cylong.com/blog/2019/03/18/lambda/
有问题或者建议欢迎在下方评论。欢迎转载、引用,但希望标明出处,感激不尽(●’◡’●)