第一章:为什么要关心Java8
1.1 Java怎么还在变
编程语言就像生态系统化一样,新的语言会出现,旧语言则被取代,除非他们不断演变,我们都希望出现一种完美的通用语言,可在现实中,某些语言知识更适合某些方面。比如C和C++仍然是构建操作系统和各种嵌入式系统的流行工具,因为他们编出的程序尽管安全性不佳,但是运行时占用资源少,Java和C#等安全型语言在诸多运行资源不紧张的应用中已经取代了C和C++。
- Java在编程语言生态系统中的位置,JVM各种最新的更新旨在帮助这些竞争对手语言在JVM上顺利运行,并与Java交互操作。“一次运行,随处运行”以及早期浏览器安全地执行Java小应用的能力让它占领了大学市场,毕业生随后把它带进了业界
- 流处理
- 用行为参数化把代码传递给方法
- 并行与共享的可变数据,没有共享的可变数据和将方法和函数即代码传递给其他方法的能力是我们平常所说的函数式编程范式的基石
1.2 Java中的函数
编程语言中的函数一词通常是指方法,尤其是静态方法;这是在数学函数,也就是没有副作用的函数之外的新含义。
Lambda方法和方法引用的使用
1.3 流
并行:把每一个任务分配给每一个处理器独立完成。在同一时间点,任务一定是同时运行。多核
并发:把任务在不同的时间点交给处理器进行处理。在同一时间点,任务并不会同时运行。单核
Java中的并行与无共享可变状态,首先,库会负责分块,即把打的流分成几个小的流,以便并行处理,其次,流提供的这个几乎免费的并行,只有在传递给filter之类的库房发的方法不会互动(比方说有可变的共享对象)时才能工作。
1.4 默认方法
在Java 8之前,List并没有stream或parallelStream方法,它实现的Collection接口也没有,因为当初还没有想到这些方法嘛!可没有这些方法,这些代码就不能编译。解决方法:缺失的方法主体随接口提供了(因此就有了默认实现),而不是由实现类提供。
小结:
- 请记住语言生态系统的思想,以及语言面临的“要么改变,要么衰亡”的压力。虽然Java可能现在非常有活力,但你可以回忆一下其他曾经也有活力但未能及时改进的语言的命运,如COBOL。
- Java 8中新增的核心内容提供了令人激动的新概念和功能,方便我们编写既有效又简洁的程序。
- 现有的Java编程实践并不能很好地利用多核处理器。
- 函数是一等值;记得方法如何作为函数式值来传递,还有Lambda是怎样写的。
- Java 8中Streams的概念使得Collections的许多方面得以推广,让代码更为易读,并允许并行处理流元素。
- 你可以在接口中使用默认方法,在实现类没有实现方法时提供方法内容。
- 其他来自函数式编程的有趣思想,包括处理null和使用模式匹配。
第二章:通过行为参数化传递代码
小试牛刀
public interface Predicate {
boolean test(T t);
}
public static List filter(List list, Predicate p) {
List result = new ArrayList<>();
for (T e : list) {
if (p.test(e)) {
result.add(e);
}
}
return result;
}
// 使用
List redApples = filter(inventory, (Apple apple) -> "red".equals(apple.getColor()));
List evenNumbers = filter(numbers, (Integer i) -> i % 2 == 0);
用 Comparator 来排序
inventory.sort( (Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
用Runnable 执行代码块
Thread t = new Thread(() -> System.out.println("Hello world"));
小结
- 行为参数化,就是一个方法接受多个不同的行为作为参数,并在内部使用它们,完成不同行为的能力。
- 行为参数化可让代码更好地适应不断变化的要求,减轻未来的工作量。
- 传递代码,就是将新行为作为参数传递给方法。但在Java 8之前这实现起来很啰嗦。为接口声明许多只用一次的实体类而造成的啰嗦代码,在Java 8之前可以用匿名类来减少。
- Java API包含很多可以用不同行为进行参数化的方法,包括排序、线程和GUI处理。
第三章:Lambda表达式
3.1 Lambda的特点
- 匿名——我们说匿名,是因为它不像普通的方法那样有一个明确的名称:写得少而想得多!
- 函数——我们说它是函数,是因为Lambda函数不像方法那样属于某个特定的类。但和方法一样,Lambda有参数列表、函数主体、返回类型,还可能有可以抛出的异常列表。
- 传递——Lambda表达式可以作为参数传递给方法或存储在变量中。
- 简洁——无需像匿名类那样写很多模板代码
Lambda表达式有三个部分
- 参数列表——这里它采用了Comparator中compare方法的参数,两个Apple。
- 箭头——箭头->把参数列表与Lambda主体分隔开。
- Lambda主体——比较两个Apple的重量。表达式就是Lambda的返回值了
测验3.1:
Lambda语法根据上述语法规则,以下哪个不是有效的Lambda表达式?
(1) () -> {}
(2) () -> "Raoul"
(3) () -> {return "Mario";}
(4) (Integer i) -> return "Alan" + i;
(5) (String s) -> {"IronMan";}
答案:只有4和5是无效的Lambda。
(1) 这个Lambda没有参数,并返回void。它类似于主体为空的方法:public void run() {}。
(2) 这个Lambda没有参数,并返回String作为表达式。
(3) 这个Lambda没有参数,并返回String(利用显式返回语句)。第一个Lambda表达式具有一个String类型的参数并返回一个int。Lambda没有return语句,因为已经隐含了return第二个Lambda表达式有一个Apple 类型的参数并返回一个boolean(苹果的重量是否超过150克)第三个Lambda表达式具有两个int类型的参数而没有返回值(void返回)。注意Lambda表达式可以包含多行语句,这里是两行第四个Lambda表达式没有参数,返回一个int第五个Lambda表达式具有两个Apple类型的参数,返回一个int:比较两个Apple的重量3.2 在哪里以及如何使用 Lambda 37 1 2 3 45 8 10 67 12 11 13 16 14 15
(4) return是一个控制流语句。要使此Lambda有效,需要使花括号,如下所示:(Integer i) -> {return "Alan" + i;}。
(5)“Iron Man”是一个表达式,不是一个语句。要使此Lambda有效,你可以去除花括号和分号,如下所示:(String s) -> "Iron Man"。或者如果你喜欢,可以使用显式返回语句,如下所示:(String s)->{return "IronMan";}
3.2 在哪里使用Lambda
3.2.1 函数式接口
函数式接口就是只定义一个抽象方法的接口。
注意:接口现在还可以拥有默认方法(即在类没有对方法进行实现时,其主体为方法提供默认实现的方法)。哪怕有很多默认方法,只要接口只定义了一个抽象方法,它就仍然是一个函数式接口
测验3.2:函数式接口下面哪些接口是函数式接口?
public interface Adder { int add(int a, int b); } public interface SmartAdder extends Adder { int add(double a, double b); } public interface Nothing { }
答案:只有Adder是函数式接口。SmartAdder不是函数式接口,因为它定义了两个叫作add的抽象方法(其中一个是从Adder那里继承来的)。Nothing也不是函数式接口,因为它没有声明抽象方法。
3.2.2 函数描述符
函数式接口的抽象方法的签名基本上就是Lambda表达式的签名。我们将这种抽象方法叫作函数描述符。例如,Runnable接口可以看作一个什么也不接受什么也不返回(void)的函数的签名,因为它只有一个叫作run的抽象方法,这个方法什么也不接受,什么也不返回(void)。它的函数描述符是() -> void
测验3.3:在哪里可以使用Lambda?
以下哪些是使用Lambda表达式的有效方式?
(1) execute(() -> {}); public void execute(Runnable r){ r.run(); }
(2) public Callable fetch() { return () -> "Tricky example ;-)"; }
(3) Predicate p = (Apple a) -> a.getWeight();
答案:只有1和2是有效的。第一个例子有效,是因为Lambda() -> {}具有签名() -> void,这和Runnable中的抽象方法run的签名相匹配。请注意,此代码运行后什么都不会做,因为Lambda是空的!第二个例子也是有效的。事实上,fetch方法的返回类型是Callable。Callable基本上就定义了一个方法,签名是() -> String,其中T被String代替了。因为Lambda() -> "Trickyexample;-)"的签名是() -> String,所以在这个上下文中可以使用Lambda。第三个例子无效,因为Lambda表达式(Apple a) -> a.getWeight()的签名是(Apple) -> Integer,这和Predicate:(Apple) -> boolean中定义的test方法的签名不同
@FunctionalInterface又是怎么回事?
如果你去看看新的Java API,会发现函数式接口带有@FunctionalInterface的标注(3.4节中会深入研究函数式接口,并会给出一个长长的列表)。这个标注用于表示该接口会设计成一个函数式接口。如果你用@FunctionalInterface定义了一个接口,而它却不是函数式接口的话,编译器将返回一个提示原因的错误。例如,错误消息可能是“Multiple non-overriding abstract methods found in interface Foo”,表明存在多个抽象方法。请注意,@FunctionalInterface不是必需的,但对于为此设计的接口而言,使用它是比较好的做法。它就像是@Override标注表示方法被重写了
3.3 付诸实践
示例:
public static String processFile() throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
return br.readLine();
}
}
3.3.1 第一步:记得行为参数化
String result = processFile((BufferedReader br) -> br.readLine() + br.readLine());
3.3.2 第二步:使用函数式接口传递行为
@FunctionalInterface
public interface BufferedReaderProcessor {
String process(BufferedReader b) throws IOException;
}
//现在你就可以把这个接口作为新的processFile方法的参数了:
public static String processFile(BufferedReaderProcessor p) throws
IOException {
…
}
3.3.3 第三步:执行一个行为
public static String processFile(BufferedReaderProcessor p) throws IOException {
try (BufferedReader br = new BufferedReader(new FileReader("data.txt"))) {
return p.process(br);
}
}
3.3.4 第四步:传递Lambda
// 处理一行:
String oneLine = processFile((BufferedReader br) -> br.readLine());
// 处理两行:
String twoLines = processFile((BufferedReader br) -> br.readLine() + br.readLine());
3.4 使用函数式接口
Predicate Consumer Function 其中T必须是引用类型,例如Integer
注意:拆装箱而引起的隐藏性能损耗
List<Integer> list = new ArrayList<>();
for (int i = 300; i < 400; i++){
list.add(i);
}
// 会自动装箱,编译不会报错,但是性能方面要付出代价。装箱后的值本质上就是把原始类型包裹起来,并保存在堆里。因此,装箱后的值需要更多的内存,并需要额外的内存搜索来获取被包裹的原始值
IntPredicate evenNumbers = (int i) -> i % 2 == 0; evenNumbers.test(1000); // 无装箱,推荐
Predicate oddNumbers = (Integer i) -> i % 2 == 1;oddNumbers.test(1000); // 装箱
常用的函数式接口
测验3.4:函数式接口对于下列函数描述符(即Lambda表达式的签名),你会使用哪些函数式接口?
在表3-2中可以找到大部分答案。作为进一步练习,请构造一个可以利用这些函数式接口的有效Lambda表达式:
(1) T->R
(2) (int, int)->int
(3) T->void
(4) ()->T
(5) (T, U)->R答案如下。
(1) Function不错。它一般用于将类型T的对象转换为类型R的对象(比如Function用来提取苹果的重量)。
(2) IntBinaryOperator具有唯一一个抽象方法,叫作applyAsInt,它代表的函数描述符是(int, int) -> int。
(3) Consumer具有唯一一个抽象方法叫作accept,代表的函数描述符是T -> void。
(4) Supplier具有唯一一个抽象方法叫作get,代表的函数描述符是()-> T。或者,Callable具有唯一一个抽象方法叫作call,代表的函数描述符是() -> T。
(5) BiFunction
具有唯一一个抽象方法叫作apply,代表的函数描述符是(T,U) -> R。
3.5 类型检查、类型推断以及限制
Lambda的类型是从使用Lambda的上下文推断出来的。函数定义->函数接口->函数描述符->判断是否匹配
测验3.5:类型检查——为什么下面的代码不能编译呢?你该如何解决这个问题呢?
Object o = () -> {System.out.println("Tricky example"); };
答案:Lambda表达式的上下文是Object(目标类型)。但Object不是一个函数式接口。为了解决这个问题,你可以把目标类型改成Runnable,它的函数描述符是() -> void:Runnable r = () -> {System.out.println("Tricky example"); };
3.5.4 使用局部变量
对局部变量的限制你可能会问自己,为什么局部变量有这些限制。第一,实例变量和局部变量背后的实现有一个关键不同。实例变量都存储在堆中,而局部变量则保存在栈上。如果Lambda可以直接访问局部变量,而且Lambda是在一个线程中使用的,则使用Lambda的线程,可能会在分配该变量的线程将这个变量收回之后,去访问该变量。因此,Java在访问自由局部变量时,实际上是在访问它的副本,而不是访问原始变量。如果局部变量仅仅赋值一次那就没有什么区别了——因此就有了这个限制。第二,这一限制不鼓励你使用改变外部变量的典型命令式编程模式(我们会在以后的各章中解释,这种模式会阻碍很容易做到的并行处理)。
闭包
你可能已经听说过闭包(closure,不要和Clojure编程语言混淆)这个词,你可能会想Lambda是否满足闭包的定义。用科学的说法来说,闭包就是一个函数的实例,且它可以无限制地访问那个函数的非本地变量。例如,闭包可以作为参数传递给另一个函数。它也可以访问和修改其作用域之外的变量。现在,Java 8的Lambda和匿名类可以做类似于闭包的事情:它们可以作为参数传递给方法,并且可以访问其作用域之外的变量。但有一个限制:它们不能修改定义Lambda的方法的局部变量的内容。这些变量必须是隐式最终的。可以认为Lambda是对值封闭,而不是对变量封闭。如前所述,这种限制存在的原因在于局部变量保存在栈上,并且隐式表示它们仅限于其所在线程。如果允许捕获可改变的局部变量,就会引发造成线程不安全的新的可能性,而这是我们不想看到的(实例变量可以,因为它们保存在堆中,而堆是在线程之间共享的)
3.6 方法引用
它的基本思想是,如果一个Lambda代表的只是“直接调用这个方法”,那最好还是用名称来调用它,而不是去描述如何调用它。事实上,方法引用就是让你根据已有的方法实现来创建Lambda表达式。但是,显式地指明方法的名称,你的代码的可读性会更好。可以把方法引用看作针对仅仅涉及单一方法的Lambda的语法糖
示例:
() -> Thread.currentThread().dumpStack() -> Thread.currentThread()::dumpStack
3.6.1 方法引用特点
如何构建方法引用方法引用主要有三类。
(1) 指向静态方法的方法引用(例如Integer的parseInt方法,写作Integer::parseInt)。
(2) 指向任意类型实例方法 的方法引用(例如 String 的 length 方法,写作String::length)。
(3) 指向现有对象的实例方法的方法引用(假设你有一个局部变量expensiveTransaction用于存放Transaction类型的对象,它支持实例方法getValue,那么你就可以写expensiveTransaction::getValue)。
第二种和第三种方法引用可能乍看起来有点儿晕。类似于String::length的第二种方法引用的思想就是你在引用一个对象的方法,而这个对象本身是Lambda的一个参数。例如,Lambda表达式(String s) -> s.toUppeCase()可以写作String::toUpperCase。但第三种方法引用指的是,你在Lambda中调用一个已经存在的外部对象中的方法。例如,Lambda表达式()->expensiveTransaction.getValue()可以写作expensiveTransaction::getValue
测验3.6:方法引用下列Lambda表达式的等效方法引用是什么?
(1) Function stringToInteger = (String s) -> Integer.parseInt(s);
(2) BiPredicate, String> contains = (list, element) -> list.contains(element);
答案如下。
(1) 这个Lambda表达式将其参数传给了Integer的静态方法parseInt。这种方法接受一个需要解析的String,并返回一个Integer。因此,可以使用图3-5中的办法➊(Lambda表达式调用静态方法)来重写Lambda表达式,如下所示:Function stringToInteger = Integer::parseInt;
(2) 这个Lambda使用其第一个参数,调用其contains方法。由于第一个参数是List类型的,你可以使用图3-5中的办法➋,如下所示:BiPredicate, String> contains = List::contains; 这是因为,目标类型描述的函数描述符是 (List,String) -> boolean,而List::contains可以被解包成这个函数描述符
3.6.2 构造方法引用
测验3.7:构造函数引用你已经看到了如何将有零个、一个、两个参数的构造函数转变为构造函数引用。那要怎么样才能对具有三个参数的构造函数,比如Color(int, int, int),使用构造函数引用呢?
答案:你看,构造函数引用的语法是ClassName::new,那么在这个例子里面就是Color::new。但是你需要与构造函数引用的签名匹配的函数式接口。但是语言本身并没有提供这样的函数式接口,你可以自己创建一个:
public interface TriFunction {
R apply(T t, U u, V v);
}
现在你可以像下面这样使用构造函数引用了:TriFunction colorFactory = Color::new;
3.7 实践
inventory.sort(comparing(Apple::getWeight));
3.8 复合Lambda表达式
3.8.1 比较器复合
inventory.sort(
comparing(Apple::getWeight).
reversed() // 逆序 .thenComparing(Apple::getCountry)); // 两个苹果一样重时进一步按照国家排序
3.8.2 谓词复合
Predicate notRedApple = redApple.negate();
Predicate redAndHeavyApple = redApple.and(a -> a.getWeight() > 150); // 一个苹果既是红色又比较重
Predicate redAndHeavyAppleOrGreen = redApple.and(a -> a.getWeight() > 150).or(a -> "green".equals(a.getColor())); //要么是重(150克以上)的红苹果,要么是绿苹果
3.8.3 函数复合
Function接口为此配了andThen和compose两个默认方法
// andThen相当于g(f(x))
Function f = x -> x + 1;
Function g = x -> x * 2;
Function h = f.andThen(g);
int result = h.apply(1); // 结果是4
// compose相当于f(g(x))
Function f = x -> x + 1;
Function g = x -> x * 2;
Function h = f.compose(g);
int result = h.apply(1); // 结果是3
3.9 数学中类似的思想
f (x) = x+10 上限是3,下限是7
// 函数方法
public double integrate(DoubleFunction f, double a, double b) {
return (f.apply(a) + f.apply(b)) * (b - a) / 2.0;
}
// 使用
integrate((double x) -> x + 10, 3, 7)
小结:
- Lambda表达式可以理解为一种匿名函数:它没有名称,但有参数列表、函数主体、返回类型,可能还有一个可以抛出的异常的列表。
- Lambda表达式让你可以简洁地传递代码。
- 函数式接口就是仅仅声明了一个抽象方法的接口。
- 只有在接受函数式接口的地方才可以使用Lambda表达式。
- Lambda表达式允许你直接内联,为函数式接口的抽象方法提供实现,并且将整个表达式作为函数式接口的一个实例。
- Java 8自带一些常用的函数式接口,放在java.util.function包里,包括Predicate 、Function、Supplier、Consumer和BinaryOperator,如表3-2所述。
- 为了避免装箱操作,对Predicate和Function等通用函数式接口的原始类型特化:IntPredicate、IntToLongFunction等。
- 环绕执行模式(即在方法所必需的代码中间,你需要执行点儿什么操作,比如资源分配和清理)可以配合Lambda提高灵活性和可重用性。
- Lambda表达式所需要代表的类型称为目标类型。
- 方法引用让你重复使用现有的方法实现并直接传递它们。
- Comparator、Predicate和Function等函数式接口都有几个可以用来结合Lambda表达式的默认方法
第四章:引入流
4.1 流是什么
流是Java API的新成员,它允许你以声明性方式处理数据集合(通过查询语句来表达,而不是临时编写一个实现),从支持数据处理操作的源生成的元素序列。流可以透明地并行处理,你无需写任何多线程代码
4.3 流与集合
集合可以遍历多次,流只能遍历一次
集合使用的是外部迭代,流使用的是内部迭代
4.4 流操作
4.4.1 中间操作
诸如filter或sorted等中间操作会返回另一个流。这让多个操作可以连接起来形成一个查询。重要的是,除非流水线上触发一个终端操作,否则中间操作不会执行任何处理——它们很懒。这是因为中间操作一般都可以合并起来,在终端操作时一次性全部处理。
4.4.2 终端操作
终端操作会从流的流水线生成结果
4.4.3 使用流
总而言之,流的使用一般包括三件事:
- 一个数据源(如集合)来执行一个查询;
- 一个中间操作链,形成一条流的流水线;
- 一个终端操作,执行流水线,并能生成结果。
4.5 小结
- 流是“从支持数据处理操作的源生成的一系列元素”。
- 流利用内部迭代:迭代通过filter、map、sorted等操作被抽象掉了。
- 流操作有两类:中间操作和终端操作。
- filter和map等中间操作会返回一个流,并可以链接在一起。可以用它们来设置一条流水线,但并不会生成任何结果。
- forEach和count等终端操作会返回一个非流的值,并处理流水线以返回结果。
- 流中的元素是按需计算的。
第五章: 使用流
5.1 筛选和切片
List dishes = menu.stream()
.filter(d -> d.getCalories() > 300) //筛选
.limit(3) // 截断
.skip(2) // 跳过
.collect(toList());
5.2 映射
5.2.1 map
对流中每一个元素应用函数
List dishNames = menu.stream().map(Dish::getName).collect(toList());
5.2.2 流的扁平化flatMap
flatmap方法让你把一个流中的每个值都换成另一个流,然后把所有的流连接起来成为一个流。
应用场景:对于一张单词表,如何返回一张列表,列出里面 各不相同的字符 呢?例如,给定单词列表["Hello","World"],你想要返回列表["H","e","l","o","W","r","d"]
错误使用:
words.stream().map(word -> word.split("")).distinct().collect(toList()); // 这里返回的是数组流,不符合要求
正确使用:
原理图:
测验5.2 映射
给定两个数字列表,如何返回所有的数对呢?
例如,给定列表[1, 2, 3]和列表[3, 4],应该返回[(1, 3), (1, 4), (2, 3), (2, 4), (3, 3), (3, 4)]。为简单起见,你可以用有两个元素的数组来代表数对。
答案:你可以使用两个map来迭代这两个列表,并生成数对。但这样会返回一个Stream>。你需要让生成的流扁平化,以得到一个Stream。这正是flatMap所做的:
List<Integer> numbers1 = Arrays.asList(1, 2, 3);
List<Integer> numbers2 = Arrays.asList(3, 4);
List<int[]> pairs =
numbers1.stream()
.flatMap(i ->
numbers2.stream()
.filter(j -> (i + j) % 3 == 0) // 只返回总和能被3整除的数对
.map(j -> new int[]{i, j}))
.collect(toList());
5.3 查找和匹配
allMatch、anyMatch、noneMatch、findFirst和findAny都是短路求值,不用处理整个流就能得到结果,limit也是一个短路操作
boolean b = menu.stream().anyMatch(Dish::isVegetarian) // anyMatch 检查谓词是否至少匹配一个元素
boolean b = menu.stream().allMatch(Dish::isVegetarian) // allMatch 检查谓词是否匹配所有元素
boolean b = menu.stream().noneMatch(Dish::isVegetarian) //noneMatch 确保流中没有任何元素与给定的谓词匹配
Optional<Dish> dish = menu.stream().filter(Dish::isVegetarian).findAny() //findAny方法将返回当前流中的任意元素
Optional<Dish> dish = menu.stream().filter(Dish::isVegetarian).findFirst() //findFirst方法将返回当前流中的第一个元素
5.4 归约
reduce将流中的所有元素反复结合起来,折叠操作
5.4.1 元素求和
// 常规
int sum = 0;
for (int x : numbers) {
sum += x;
}
// reduce
int sum = numbers.stream().reduce(0, (a, b) -> a + b);
// 在Java 8中,Integer类现在有了一个静态的sum方法来对两个数求和
int sum = numbers.stream().reduce(0, Integer::sum);
// 解释:首先,0作为Lambda(a)的第一个参数,从流中获得4作为第二个参数(b)。0 + 4得到4,它成了新的累积值作为第一个参数a。
// 然后再用累积值和流中下一个元素5调用Lambda,产生新的累积值9。接下来,再用累积值和下一个元素3调用Lambda,得到12。
// 最后,用12和流中最后一个元素9调用Lambda,得到最终结果21
无初始值,reduce还有一个重载的变体,它不接受初始值,但是会返回一个Optional对象,考虑流中没有任何元素的情况。reduce操作无法返回其和,因为它没有初始值。
Optional sum = numbers.stream().reduce((a, b) -> (a + b));
5.4.2 最大值和最小值
归约的函数描述符(T a,T b) -> T,BinaryOperator,就是无初始值的reduce,返回一个Optional对象
Optional max = numbers.stream().reduce(Integer::max); // 最大值
Optional min = numbers.stream().reduce(Integer::min); // 最小值
归约的优势
相比于外部逐步迭代求和,有个弊病就是有个共享的变量sum,这不是那么容易并行化
流操作:无状态和有状态
诸如map或filter等操作会从输入流中获取每一个元素,并在输出流中得到0或1个结果。这些操作一般都是无状态的:它们没有内部状态(假设用户提供的Lambda或方法引用没有内部可变状态)。
但诸如reduce、sum、max等操作需要内部状态来累积结果,就像外部迭代中的共享变量。在上面的情况下,内部状态很小。在我们的例子里就是一个int或double。不管流中有多少元素要处理,内部状态都是有界的。相反,诸如sort或distinct等操作一开始都和filter和map差不多——都是接受一个流,再生成一个流(中间操作),但有一个关键的区别。从流中排序和删除重复项时都需要知道先前的历史。例如,排序要求所有元素都放入缓冲区后才能给输出流加入一个项目,这一操作的存储要求是无界的。要是流比较大或是无限的,就可能会有问题(把质数流倒序会做什么呢?它应当返回最大的质数,但数学告诉我们它不存在)。我们把这些操作叫作有状态操作。
5.6 数值流
你可以像下面这样计算菜单的热量:
int calories = menu.stream().map(Dish::getCalories).reduce(0, Integer::sum);
这段代码的问题是,它有一个暗含的装箱成本。每个Integer都必须拆箱成一个原始类型,再进行求和。
Java 8引入了三个原始类型特化流接口来解决这个问题:IntStream、DoubleStream和LongStream,分别将流中的元素特化为int、long和double,从而避免了暗含的装箱成本。每个接口都带来了进行常用数值归约的新方法,比如对数值流求和的sum,找到最大元素的max
// 求和
int sum = maxCalories = menu.stream().mapToInt(Dish::getCalories).sum();
// 最大值
OptionalInt maxCalories = menu.stream().mapToInt(Dish::getCalories).max();
// 如果想转回对象流
Stream<Integer> stream = menu.stream().mapToInt(Dish::getCalories).boxed();
// 数值范围
IntStream.rangeClosed(1, 100).filter(n -> n % 2 == 0); // 从1到100有多少个偶数
5.7 构建流
// 由值构建流
Stream<String> stream = Stream.of("Java 8 ", "Lambdas ", "In ", "Action");
// 由数组构建流
int[] numbers = {2, 3, 5, 7, 11, 13};
int sum = Arrays.stream(numbers).sum();
// 由函数构建流
Stream.iterate(0, n -> n + 2).limit(10).forEach(System.out::println);
5.8 小结
- Streams API可以表达复杂的数据处理查询。常用的流操作总结在表5-1中
- 你可以使用filter、distinct、skip和limit对流做筛选和切片。
- 你可以使用map和flatMap提取或转换流中的元素。
- 你可以使用findFirst和 findAny方法查找流中的元素。你可以用allMatch、noneMatch和anyMatch方法让流匹配给定的谓词。
- 这些方法都利用了短路:找到结果就立即停止计算;没有必要处理整个流。
- 你可以利用reduce方法将流中所有的元素迭代合并成一个结果,例如求和或查找最大元素。
- filter和map等操作是无状态的,它们并不存储任何状态。reduce等操作要存储状态才能计算出一个值。sorted和distinct等操作也要存储状态,因为它们需要把流中的所有元素缓存起来才能返回一个新的流。这种操作称为有状态操作。
- 流有三种基本的原始类型特化:IntStream、DoubleStream和LongStream。它们的操作也有相应的特化。
- 流不仅可以从集合创建,也可从值、数组、文件以及iterate与generate等特定方法创建。
- 无限流是没有固定大小的流。
第六章:用流收集数据
6.2 归约和汇总
// 计算数量
long howManyDishes = menu.stream().collect(Collectors.counting());
// 查找最大值和最小值
Comparator<Dish> dishCaloriesComparator = Comparator.comparingInt(Dish::getCalories);
Optional<Dish> mostCalorieDish = menu.stream().collect(maxBy(dishCaloriesComparator));
// 汇总
int totalCalories = menu.stream().collect(summingInt(Dish::getCalories));
// 平均数
double avgCalories = menu.stream().collect(averagingInt(Dish::getCalories));
// 你可以使用summarizingInt工厂方法返回的收集器。例如,通过一次summarizing操作你可以就数出菜单中元素的个数,并得到菜肴热量总和、平均值、最大值和最小值:
IntSummaryStatistics menuStatistics = menu.stream().collect(summarizingInt(Dish::getCalories));
// 结果:IntSummaryStatistics{count=9, sum=4300, min=120, average=477.777778, max=800}
// 连接字符串
String shortMenu = menu.stream().collect(joining(","));
// 广义归约
int totalCalories = menu.stream().collect(reducing( 0, Dish::getCalories, (i, j) -> i + j)); // 有初始值的reduce。要是无初始值的reduce返回时Optional<T>
6.3 分组
// 简单分组
Map<Dish.Type, List<Dish>> dishesByType = menu.stream().collect(groupingBy(Dish::getType));
// 复杂分组
public enum CaloricLevel { DIET, NORMAL, FAT }
Map<CaloricLevel, List<Dish>> dishesByCaloricLevel = menu.stream().collect(
groupingBy(dish -> {
if (dish.getCalories() <= 400) return CaloricLevel.DIET;
else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
else return CaloricLevel.FAT;
} ));
// 多级分组
Map<Dish.Type, Map<CaloricLevel, List<Dish>>> dishesByTypeCaloricLevel =
menu.stream().collect(
groupingBy(Dish::getType,
groupingBy(dish -> {
if (dish.getCalories() <= 400) return CaloricLevel.DIET;
else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
else return CaloricLevel.FAT;
})
)
);
6.3.2 按子组收集数据
// 分组后分别计算数量
Map<Dish.Type, Long> typesCount = menu.stream().collect(groupingBy(Dish::getType, counting())); // 结果:{MEAT=3, FISH=2, OTHER=4}
// 分组后取各组最大值。注意:这个Map中的值是Optional,因为这是maxBy工厂方法生成的收集器的类型,但实际上,如果菜单中没有某一类型的Dish,这个类型就不会对应一个Optional. empty()值,而且根本不会出现在Map的键中。groupingBy收集器只有在应用分组条件后,第一次在流中找到某个键对应的元素时才会把键加入分组Map中。这意味着Optional包装器在这里不是很有用,因为它不会仅仅因为它是归约收集器的返回类型而表达一个最终可能不存在却意外存在的值。
Map<Dish.Type, Optional<Dish>> mostCaloricByType = menu.stream().collect(groupingBy(Dish::getType, maxBy(comparingInt(Dish::getCalories))));
// 把收集器的结果转换为另一种类型,因为分组操作的Map结果中的每个值上包装的Optional没什么用,所以你可能想要把它们去掉
//
Map<Dish.Type, Dish> mostCaloricByType =
menu.stream().collect(groupingBy(Dish::getType,
collectingAndThen(
maxBy(comparingInt(Dish::getCalories)),
Optional::get)));
// 个人总结:groupingBy之后,第二个参数的收集器拿到的是分完组之后的子组流
Map<Dish.Type, Set<CaloricLevel>> caloricLevelsByType = menu.stream().collect(
groupingBy(Dish::getType, mapping(
dish -> { if (dish.getCalories() <= 400) return CaloricLevel.DIET;
else if (dish.getCalories() <= 700) return CaloricLevel.NORMAL;
else return CaloricLevel.FAT; },
toSet() ))); // 结果:{OTHER=[DIET, NORMAL], MEAT=[DIET, NORMAL, FAT], FISH=[DIET, NORMAL]}
6.4 分区
分区是分组的特殊情况:由一个谓词(返回一个布尔值的函数)作为分类函数,它称分区函数。分区函数返回一个布尔值,这意味着得到的分组Map的键类型是Boolean,于是它最多可以分为两组——true是一组,false是一组
Map<Boolean, List<Dish>> partitionedMenu = menu.stream().collect(partitioningBy(Dish::isVegetarian));
Collectors类方法汇总:
6.5 收集器接口
有点难度,后面补充
第七章:并行数据处理与性能
7.1 并行流
可以通过对收集源调用parallelStream方法来把集合转换为并行流。并行流就是一个把内容分成多个数据块,并用不同的线程分别处理每个数据块的流。这样一来,你就可以自动把给定操作的工作负荷分配给多核处理器的所有内核,让它们都忙起来
7.1.1 将顺序流转换为并行流
public static long parallelSum(long n) {
return Stream.iterate(1L, i -> i + 1).limit(n).parallel().reduce(0L, Long::sum); // parallel()将流转换为并行流
}
实际实现效果:
并行流和顺序流切换
stream.parallel().filter(...).sequential().map(...).parallel().reduce();
引出问题:看看流的parallel方法,你可能会想,并行流用的线程是从哪儿来的?有多少个?怎么自定义这个过程呢?
并行流内部使用了默认的ForkJoinPool(7.2节会进一步讲到分支/合并框架),它默认的线程数量就是你的处理器数量,这个值是由Runtime.getRuntime().availableProcessors()得到的。 但是你可以通过系统属性 java.util.concurrent.ForkJoinPool.common.parallelism来改变线程池大小,如下所示: System.setProperty("java.util.concurrent.ForkJoinPool.common.parallelism","12"); 这是一个全局设置,因此它将影响代码中所有的并行流。反过来说,目前还无法专为某个并行流指定这个值。一般而言,让ForkJoinPool的大小等于处理器数量是个不错的默认值,除非你有很好的理由,否则我们强烈建议你不要修改它。
7.1.2 测量流性能
上面的求和方法的并行版本比顺序版本要慢很多。你如何解释这个意外的结果呢?这里实际上有两个问题:
- iterate生成的是装箱的对象,必须拆箱成数字才能求和;
- 我们很难把iterate分成多个独立块来并行执行。
第二个问题更有意思一点,因为你必须意识到某些流操作比其他操作更容易并行化。具体来说,iterate很难分割成能够独立执行的小块,因为每次应用这个函数都要依赖前一次应用的结果。
如何用流来高效地并行求和呢?
使用对应的数值型Stream,例如LongStream.rangeClosed的方法
- LongStream.rangeClosed直接产生原始类型的long数字,没有装箱拆箱的开销。
- LongStream.rangeClosed会生成数字范围,很容易拆分为独立的小块
7.1.3 正确使用并行流
共享可变状态会影响并行流以及并行计算,记住要避免共享可变状态
public static long sideEffectParallelSum(long n) {
Accumulator accumulator = new Accumulator();
LongStream.rangeClosed(1, n).parallel().forEach(accumulator::add); // 最后得到的结果是错误的
return accumulator.total;
}
public class Accumulator {
public long total = 0;
public void add(long value) { total += value; }
}
7.1.4 高效使用并行流
一般而言,想给出任何关于什么时候该用并行流的定量建议都是不可能也毫无意义的,因为任何类似于“仅当至少有一千个(或一百万个或随便什么数字)元素的时候才用并行流)”的建议对于某台特定机器上的某个特定操作可能是对的,但在略有差异的另一种情况下可能就是大错特错。尽管如此,我们至少可以提出一些定性意见,帮你决定某个特定情况下是否有必要使用并行流。
- 如果有疑问,测量。把顺序流转成并行流轻而易举,但却不一定是好事。我们在本节中已经指出,并行流并不总是比顺序流快。此外,并行流有时候会和你的直觉不一致,所以在考虑选择顺序流还是并行流时,第一个也是最重要的建议就是用适当的基准来检查其性能。
- 留意装箱。自动装箱和拆箱操作会大大降低性能。Java 8中有原始类型流(IntStream、LongStream、DoubleStream)来避免这种操作,但凡有可能都应该用这些流。
- 有些操作本身在并行流上的性能就比顺序流差。特别是limit和findFirst等依赖于元素顺序的操作,它们在并行流上执行的代价非常大。例如,findAny会比findFirst性能好,因为它不一定要按顺序来执行。你总是可以调用unordered方法来把有序流变成无序流。那么,如果你需要流中的n个元素而不是专门要前n个的话,对无序并行流调用limit可能会比单个有序流(比如数据源是一个List)更高效。
- 还要考虑流的操作流水线的总计算成本。设N是要处理的元素的总数,Q是一个元素通过流水线的大致处理成本,则N*Q就是这个对成本的一个粗略的定性估计。Q值较高就意味着使用并行流时性能好的可能性比较大。
- 对于较小的数据量,选择并行流几乎从来都不是一个好的决定。并行处理少数几个元素的好处还抵不上并行化造成的额外开销。
- 要考虑流背后的数据结构是否易于分解。例如,ArrayList的拆分效率比LinkedList高得多,因为前者用不着遍历就可以平均拆分,而后者则必须遍历。另外,用range工厂方法创建的原始类型流也可以快速分解。最后,你将在7.3节中学到,你可以自己实现Spliterator来完全掌控分解过程。
- 流自身的特点,以及流水线中的中间操作修改流的方式,都可能会改变分解过程的性能。例如,一个SIZED流可以分成大小相等的两部分,这样每个部分都可以比较高效地并行处理,但筛选操作可能丢弃的元素个数却无法预测,导致流本身的大小未知。
- 还要考虑终端操作中合并步骤的代价是大是小(例如Collector中的combiner方法)。如果这一步代价很大,那么组合每个子流产生的部分结果所付出的代价就可能会超出通过并行流得到的性能提升。表7-1按照可分解性总结了一些流数据源适不适于并行。
7.2 分支/合并框架
分支/合并框架的目的是以递归方式将可以并行的任务拆分成更小的任务,然后将每个子任务的结果合并起来生成整体结果。它是ExecutorService接口的一个实现,它把子任务分配给线程池(称为ForkJoinPool)中的工作线程。首先来看看如何定义任务和子任务。
7.2.1 使用 RecursiveTask
要把任务提交到这个池,必须创建RecursiveTask
的一个子类,其中R是并行化任务(以及所有子任务)产生的结果类型,或者如果任务不返回结果,则是RecursiveAction类型(当然它可能会更新其他非局部机构)。要定义RecursiveTask,只需实现它唯一的抽象方法compute:protected abstract R compute(); 这个方法同时定义了将任务拆分成子任务的逻辑,以及无法再拆分或不方便再拆分时,生成单个子任务结果的逻辑。正由于此,这个方法的实现类似于下面的伪代码
if(任务足够小或不可分) {
顺序计算该任务
} else{
将任务分成两个子任务 递归调用本方法,拆分每个子任务,等待所有子任务完成 合并每个子任务的结果
}
7.2.2 使用分支/合并框架的最佳做法
虽然分支/合并框架还算简单易用,不幸的是它也很容易被误用。以下是几个有效使用它的最佳做法。
- 对一个任务调用join方法会阻塞调用方,直到该任务做出结果。因此,有必要在两个子任务的计算都开始之后再调用它。否则,你得到的版本会比原始的顺序算法更慢更复杂,因为每个子任务都必须等待另一个子任务完成才能启动。
- 不应该在RecursiveTask内部使用ForkJoinPool的invoke方法。相反,你应该始终直接调用compute或fork方法,只有顺序代码才应该用invoke来启动并行计算。
- 对子任务调用fork方法可以把它排进ForkJoinPool。同时对左边和右边的子任务调用它似乎很自然,但这样做的效率要比直接对其中一个调用compute低。这样做你可以为其中一个子任务重用同一线程,从而避免在线程池中多分配一个任务造成的开销。
- 调试使用分支/合并框架的并行计算可能有点棘手。特别是你平常都在你喜欢的IDE里面看栈跟踪(stack trace)来找问题,但放在分支合并计算上就不行了,因为调用compute的线程并不是概念上的调用方,后者是调用fork的那个。
- 和并行流一样,你不应理所当然地认为在多核处理器上使用分支/合并框架就比顺序计算快。我们已经说过,一个任务可以分解成多个独立的子任务,才能让性能在并行化时有所提升。所有这些子任务的运行时间都应该比分出新任务所花的时间长;一个惯用方法是把输入/输出放在一个子任务里,计算放在另一个里,这样计算就可以和输入/输出同时进行。此外,在比较同一算法的顺序和并行版本的性能时还有别的因素要考虑。就像任何其他Java代码一样,分支/合并框架需要“预热”或者说要执行几遍才会被JIT编译器优化。这就是为什么在测量性能之前跑几遍程序很重要,我们的测试框架就是这么做的。同时还要知道,编译器内置的优化可能会为顺序版本带来一些优势(例如执行死码分析——删去从未被使用的计算)
7.2.3 工作窃取(重要)
分支/合并框架工程用一种称为工作窃取(work stealing)的技术。在实际应用中,这意味着这些任务差不多被平均分配到ForkJoinPool中的所有线程上。每个线程都为分配给它的任务保存一个双向链式队列,每完成一个任务,就会从队列头上取出下一个任务开始执行。基于前面所述的原因,某个线程可能早早完成了分配给它的所有任务,也就是它的队列已经空了,而其他的线程还很忙。这时,这个线程并没有闲下来,而是随机选了一个别的线程,从队列的尾巴上“偷走”一个任务。这个过程一直继续下去,直到所有的任务都执行完毕,所有的队列都清空。这就是为什么要划成许多小任务而不是少数几个大任务,这有助于更好地在工作线程之间平衡负载。
7.3 Spliterator(了解)
Spliterator是Java 8中加入的另一个新接口;这个名字代表“可分迭代器”(splitable iterator)。和Iterator一样,Spliterator也用于遍历数据源中的元素,但它是为了并行执行而设计的。Spliterator定义了并行流如何拆分它要遍历的数据。
小结
- 内部迭代让你可以并行处理一个流,而无需在代码中显式使用和协调不同的线程。
- 虽然并行处理一个流很容易,却不能保证程序在所有情况下都运行得更快。并行软件的行为和性能有时是违反直觉的,因此一定要测量,确保你并没有把程序拖得更慢。
- 像并行流那样对一个数据集并行执行操作可以提升性能,特别是要处理的元素数量庞大,或处理单个元素特别耗时的时候。
- 从性能角度来看,使用正确的数据结构,如尽可能利用原始流而不是一般化的流,几乎总是比尝试并行化某些操作更为重要。
- 分支/合并框架让你得以用递归方式将可以并行的任务拆分成更小的任务,在不同的线程上执行,然后将各个子任务的结果合并起来生成整体结果。
- Spliterator定义了并行流如何拆分它要遍历的数据
第八章:重构、测试和调试
8.1 为改善可读性和灵活性重构代码
利用Lambda表达式,你可以写出更简洁、更灵活的代码。用“更简洁”来描述Lambda表达式是因为相较于匿名类,Lambda表达式可以帮助我们用更紧凑的方式描述程序的行为。
8.1.1 改善代码的可读性
Java 8的新特性也可以帮助提升代码的可读性:
- 使用Java 8,你可以减少冗长的代码,让代码更易于理解
- 通过方法引用和Stream API,你的代码会变得更直观
这里我们会介绍三种简单的重构,利用Lambda表达式、方法引用以及Stream改善程序代码的可读性:
- 重构代码,用Lambda表达式取代匿名类
- 用方法引用重构Lambda表达式
- 用Stream API重构命令式的数据处理
8.1.2 从匿名类到 Lambda 表达式的转换
例如:
Runnable r1 = new Runnable(){
public void run(){
System.out.println("Hello");
}
};
Runnable r2 = () -> System.out.println("Hello");
但是某些情况下,将匿名类转换为Lambda表达式可能是一个比较复杂的过程①。首先,匿名类和Lambda表达式中的this和super的含义是不同的。在匿名类中,this代表的是类自身,但是在Lambda中,它代表的是包含类。其次,匿名类可以屏蔽包含类的变量,而Lambda表达式不能(它们会导致编译错误),譬如下面这段代码
int a = 10;
Runnable r1 = () -> {
int a = 2; // 编译错误,变量a已经被定义
System.out.println(a);
};
Runnable r2 = new Runnable() {
public void run() {
int a = 2; //编译通过
System.out.println(a);
}
};
8.1.3 从 Lambda 表达式到方法引用的转换
例如:
inventory.sort(
(Apple a1, Apple a2) -> a1.getWeight().compareTo(a2.getWeight()));
// 转换后
inventory.sort(comparing(Apple::getWeight));
8.1.4 从命令式的数据处理切换到 Stream
例如:
List<String> dishNames = new ArrayList<>();
for(Dish dish: menu){
if(dish.getCalories() > 300){
dishNames.add(dish.getName());
}
}
// 转换后
menu.parallelStream()
.filter(d -> d.getCalories() > 300)
.map(Dish::getName)
.collect(toList());
8.2 使用 Lambda 重构面向对象的设计模式
- 策略模式
- 模板方法
- 观察者模式
- 责任链模式
- 工厂模式