断断续续看完了《Java实战》(Modern Java in Action, 2nd Edition)这本书,收获还是蛮大的(感慨一下,有了宝宝之后有点自己的时间好南啊~~)。虽然之前看过第一版(书名是《Java 8实战》),但一来看的不是纸质书,二来当时第一部分基础知识都没看完,感觉疗效并不是很明显。在后续的日常开发过程中不少涉及到Java 8的基础语言知识还是要频繁去跪舔Google和StackOverflow,让我意识到了基础知识的重要性和问题的严重性。逛了逛某东,发现出了新版,而且不仅仅局限于Java 8了,大喜,立购之。
这次看得还算蛮仔细的,并且看的过程中在书上做了一些划线和笔记。但是,其实一直有个问题困扰着我,那就是一本书看下去,不管当时感觉收获有多大,过段时间就没啥印象了,也就是“看得快,忘得也快”。所以这次打算做个电子版的笔记,一来再次回顾全书,二来日后可以作为快速参考。
本书结构
本书分为6个部分,如下:
第1部分(1~3章)“基础知识”,旨在帮助读者初步使用Java 8,主要是对Lambda表达式的介绍
第2部分(4~7章)“使用流进行函数式数据处理”,详细讨论新引入的Stream API,帮助读者以声明性方式处理数据(相比过去Collection API的命令式编程)
第3部分(8~10章)“使用流和Lambda进行高效编程”,出发点是介绍高级编程思想,以便编写更高效的Java代码。虽然作者说了本书后续内容不依赖这部分内容,但我还是建议要读一读,毕竟内容也不是很厚
第4部分(11~14章)“无所不在的Java”,介绍Java 8和Java 9中新增的多个特性,主要包括java.util.Optional类、新的日期时间API、接口的默认方法、Java 9引入的模块系统
第5部分(15~17章)“提升Java的并发性”,探讨如何使用Java的高级特性构建并发程序。这里的并发不是前文讨论过的并发流,而是包括异步API思想的介绍、CompletableFuture、Java 9引入的Flow API以及反应式编程等内容
第6部分(18~21章)“函数式编程以及Java未来的演进”,探讨了怎么用Java编写高效的函数式程序,介绍了一些函数式编程术语、高级技术(如高阶函数、柯里化、延迟集合、模式匹配等),并回顾了Java 8以来慢慢走向函数式编程的历程,展望了未来Java中可能出现的增强。
另外,值得一提的是,本书4个附录也值得一看。
附录A总结了正文中未讨论的一些Java 8的小特性。
附录B概述了其他一些Java类库的更新(如集合API新增的一些方法、java.util.concurrent包中一些更新、Number和Math以及Files类新增的一些方法等)。
附录C是第2部分的延续,介绍了流的高级用法。
附录D简单探讨了Java编译器在幕后是如何实现Lambda表达式的(不是匿名内部类的语法糖这么简单)。
第1部分 基础知识
第1章 Java 8、9、10以及11的变化
这一章总结了Java的主要变化,包括Lambda表达式、方法引用、流和默认方法,为学习后面的内容做准备。
引入新特性的原因
- 两大迫切需求:编写更简洁的代码、更便利地利用多核处理器
- Stream API支持多个数据处理的并行操作 其思路和SQL类似——从高层角度描述需求,而由“实现”(这里是Stream库)来选择底层最佳执行策略。这样可以避免编写显式并发代码
- 用
synchronized加锁的方式不仅容易出错,而且在多核CPU上执行所需成本往往也比预期的要高- 同步迫使代码按照顺序执行,而这与并行处理的宗旨相悖
- 多核CPU的每个处理器核都有独立的高速缓存,加锁需要这些高速缓存同步运行,然而这又需要在内核间进行较慢的缓存一致性协议通信
流处理
- 背景:Unix系统中的管道 请注意,在Unix中这些由管道串接起来的命令是同时执行的,就像汽车组装流水线一样,尽管实际上是一个序列,但不同车间的运行一般是并行的
- Stream API的很多方法可以链接起来形成一个复杂的流水线,正如链接起来的Unix命令一样
- 推动这种做法的关键
- 可以在一个更高的抽象层次上写Java程序,代码可读性也更好
- 几乎是免费的并行,因为Java 8可以透明地把输入的不相关部分拿到几个CPU核上去分别执行你给的Stream操作流水线
- 之所以说“几乎”免费,是因为提供给流的行为必须能够同时在不同的输入上安全地执行,这可能需要我们稍微习惯一下编写“纯函数”(或“无副作用函数”、“无状态函数”)
从面向对象编程到函数式编程
- Java 8中的主要变化反映了它开始远离常常侧重于改变现有值的经典面向对象思想,而向函数式编程领域转变
- 函数式编程中,在大体上考虑想做什么被视为头等大事,并且和具体实现方式区分开来
- 极端地说,传统的面向对象编程和函数式编程可能看起来是冲突的。但我们的理念是取两种编程范式中的精华,以便为任务找到理想的工具
- 方法和Lambda作为一等公民
- 编程语言的整个目的就在于操作值,按照传统,这些值被称为一等值(或一等公民)。语言中的其他结构或概念也许有助于表示值的结构,但在程序执行期间不能传递,因而是二等值(e.g. Java中的方法和类等,方法可以定义类,类可以实例化产生值,但二者本身都不是值)
- 在Java 8之前常用将匿名类的实例传给方法的方式来达到传递代码的目的,不仅繁琐而且可读性差,仅仅因为在Java中方法是二等公民;现在只需用方法引用
::语法(即“把这个方法作为值”)即可。 - 方法引用可以被传递,就像使用对象引用传递对象一样,因为现在方法成为一等公民了!重点是,只要方法中由代码,那么用方法引用就可以传递代码
- 除了允许(命名)函数成为一等公民外,Java 8还体现了更广义的将函数作为值的思想,包括Lambda(或匿名函数)
- 引入Lambda后,甚至不需要为只用一次的方法写定义,代码更干净清晰。但如果Lambda的长度多余几行或者行为也不是一目了然的话,那还是用方法引用来指向一个有描述性名称的方法更佳(毕竟方法名本身就很好地描述了方法的作用,并且能够复用)
流
- Java 8给予Stream的并行提倡很少使用
synchronized的函数式编程风格,它关注数据分块而不是协调访问 - Stream API处理数据的方式与Collection API不同 集合是外部迭代(程序员自己管理迭代过程),而流是内部迭代(程序员根本无需操心循环的事情,数据处理完全是在库内部进行的)
- 使用集合的另一个头疼之处在于需要程序员自己处理多线程,而这并非易事
- 需要谨慎地协调共享变量的访问和更新
- 相比一步步执行的顺序模型,这个模型不太好理解
- Stream API解决了两个问题
- 使用集合处理数据时的模板化和晦涩(可读性差?)
- 难以利用多核
- Java 8这样设计Stream API的原因
- 有许多反复出现的数据处理模式(如filter、map、grouping等操作)
- 这类操作常常可以并行
- 重点:Collection主要是为了存储和访问数据,Stream则主要用于描述对数据的计算(应该是处理数据的意思吧?)
默认方法
- Java设计者在引入以上诸多新特性时面临的一个现实问题是现有接口也需要改进,但为接口加入一个新方法对成千上万接口的使用者而言简直是灾难,因为所有使用该接口处都需要去实现新增方法。由此Java 8引入默认方法解决这一问题(支持接口演进)
- 使用
default关键字来表示接口中的默认方法 - 采用了一些限制(从后文知道是给出了3条规则)来避免出现类似于C++中臭名昭著的菱形继承问题
其他
- Java 8提供了
Optional<T>类,帮助避免NullPointerException,第11章会详细讨论这一话题- 这是一个容器对象,它既可以包含值,也可以不包含值
- 它通过类型系统,允许程序员显式地表明一个变量可能缺失值
- Java 9提供了模块系统,允许通过语法定义由一系列包组成的模块,第14章讨论这个话题
- 更好地控制命名空间和包的可见性
- 对简单的类JAR组件进行了增强,使其具备了结构
第1章小结
Java从函数式编程引入的两大核心思想,这两种思想在新的Stream API中都用到了
- 将方法和Lambda作为一等值
- 在没有可变共享状态时,函数或方法可以高效安全地并行执行
第2章 通过行为参数化传递代码
应对不断变化的需求:理想状态下,应该把应对变化的需求所需工作量降到最少,并且类似的新功能实现起来还应该很简单且易于长期维护。一个好的原则是编写类似代码之后,尽量对其进行抽象化
行为参数化
- 行为参数化对应的是值参数化
- 以帮助农民朋友筛选苹果的例子来一步步说明,不断向方法添加参数不是一种好的应对变化之道,最好后退一步来看看更高层次的抽象,从而谈到传递行为而不是简单的参数值,进而引入策略模式,即定义一族算法并封装起来(称为“策略”)
对付啰嗦
- 策略模式在解决筛选苹果的不同需求这个例子中是合适的,但代码过于啰嗦。需要先定义一个接口,再定义多个实现类,进而通过向已有方法传递不同实现类的实例的方式实现行为的参数化
- 继续改进
- 命名类 -> 匿名类,允许同时声明并实例化一个类,换句话说,允许随建随用
- 代码看起来很笨重,明明要的只是方法中的代码,却要作额外包装
- 有时可能令人费解,用一个经典的Java谜题来说明这一点(匿名类中的
this对应的是包含它的类,而不是更外层的类) - 整体来说,啰嗦就不好,好的代码应该是一目了然的
- 匿名类 -> Lambda表达式
- 引入泛型参数T (在通往抽象的路上,还可以更进一步,从而超越眼前要处理的问题)
- 命名类 -> 匿名类,允许同时声明并实例化一个类,换句话说,允许随建随用
更多实例
- Java API中很多方法都可以用不同的行为来参数化,书中展示了4个典型例子
Comparator排序:sort(Comparator compartor)Runnable执行代码块:Thread(Runnable runnable)Callable从任务返回结果:executorService.submit(Callable callable)EventHanlderGUI事件处理:(类似JavaScript中的onClick = () => {})之类
第2章小结
- 行为参数化可让代码更好地适应不断变化的需求,减轻未来工作量
- 传递代码就是将新行为作为参数传递给方法(C++中函数指针?或者C#中的回调函数?),但在Java 8之前实现传递代码很啰嗦,即使用匿名类减少了许多只用一次的实体类的声明,代码可读性依然不佳。现在可以通过Lambda只用一行来实现
第3章 Lambda表达式
Lambda管中窥豹
- 可以把Lambda表达式理解为一种简洁的可传递匿名函数
- 匿名,不像普通方法那样有个明确的名称:写得少而想得多
- 说它是一种函数,因为它不像方法那样属于某个特定的类
- 可传递,它可以作为参数传递给方法或存储在变量中
- 简洁,无须像匿名类那样写很多模板代码
- Lambda表达式主要是提供了简明的传递代码的方式(相比匿名类)。理论上说,Java 8之前做不了的事情,Lambda也做不了
- Lambda的基本语法是
(param) -> expr,称为表达式风格的Lambda。注意,这种风格的Lambda表达式没有return语句,因为已经隐含了return(param) -> { stmts; },称为块风格的Lambda
Lambda表达式的使用
- 何处使用:只有在接受函数式接口的地方才可以使用Lambda表达式
- 可以被赋给一个变量
- 或传递给一个接受函数式接口作为参数的方法
- 函数式接口就是只定义一个抽象方法的接口(e.g.
Comparator、Runnable、Callable等)。注意,哪怕有很多默认方法,只要接口只定义了一个抽象方法,它就仍是一个函数式接口 - Lambda表达式允许你直接以内联的形式为函数式接口的抽象方法提供实现,并把整个表达式作为函数式接口的实例(严格说是函数式接口一个具体实现的实例)
@FunctionalInterface是一个标记注解,用于表示该接口会设计成一个函数式接口,它的作用跟@Override有些类似- 实例:环绕执行模式,如
public void doSomething(Function<T, R> processor) { // 打开资源(模板式代码) // 具体业务代码(变化的部分) => 可作为方法的参数将行为(即代码)传递进来 // 清理工作(模板式代码) }
函数式接口的使用
- 函数式接口的抽象方法的签名称为函数描述符。上文说过,Lambda表达式可以应用在接受函数式接口作为参数的方法上,因此为了应用不同的Lambda表达式,我们需要一套能够描述常见函数描述符的函数式接口。Java 8自带了一些常用的函数式接口,放在
java.util.function包里,如@FunctionalInterface public interface Predicate<T> { // T -> boolean boolean test(T t); } @FunctionalInterface public interface Consumer<T> { //T -> void void accept(T t); } @FunctionalInterface public interface Supplier<T> { // () -> T T get(); } @FunctionalInterface public interface Function<T, R> { // T -> R R apply(T t); } - 基本类型特化
- 泛型只能绑定到引用类型,而非基本类型,这是由Java泛型内部的实现方式造成的(第20章会再次稍微提到泛型的话题)。
- 因此在Java里有装箱(boxing)和拆箱(unboxing)机制,以及自动装箱机制来帮助程序员执行基本类型和对应引用类型间的转换
- 装箱、拆箱在性能方面是要付出代价的。装箱后的值本质上就是把基本类型包裹起来并保存在堆里,因此装箱后的值需要更多内存,并需要额外的内存搜索来获取被包裹的基本值
- Java 8函数式接口带来了一个专门的版本,以便在输入和输出都是基本类型时避免自动装箱操作,比如
DoublePredicate、IntConsumer、LongBinaryOperator、IntFunction、ToIntFunction<T>等 - 如果有需要,还可以设计自己的泛型函数式接口或所需的基本类型特化
类型检查和类型推断
- Lambda的类型是从使用Lambda的上下文推断出来的,通过检查Lambda表达式是否符合函数描述符来作类型检查
- 同一个Lambda表达式可以与不同的函数式接口联系起来,只要它们的抽象方法签名能够兼容
- 类型推断可以帮助程序员省去Lambda语法中参数类型的标注。例如
不过有时显示写出类型更易读,有时去掉它们更易读,没有什么法则说那种更好,完全由程序员自己作决定。// 没有类型推断 Comparator<Apple> c = (Apple a1, Apple a2) -> a1.getWeight().compareTo(a1.getWeight()); // 有类型推断 Comparator<Apple> c = (a1, a2) -> a1.getWeight().compareTo(a1.getWeight()); - 在Lambda表达式中也可以使用局部变量(或自由变量,即外层作用域中定义的变量),称为Lambda捕获了XX变量
- Java编译器对捕获局部变量作出如下限制
- 该局部变量必须是
final或事实上等效于final的(即后续没有修改) - 作出这种限制的原因在于,局部变量保存在栈上,因而隐式地表示它们仅限于其所在线程。如果允许捕获可改变的局部变量,就会引发造成线程不安全的新的可能性(对捕获实例变量没有这种限制,因为它们保存在堆中,而堆是在线程之间共享的)
方法引用
- 方法引用让你重复使用现有的方法并直接传递它们
- 如何构建方法引用
方法引用主要有三类。
- 指向静态方法的方法引用(例如
Integer::parseInt) - 指向实例方法的方法引用(例如
String::length) - 指向现存对象或表达式实例的方法引用(例如
someInstance::doSomething或this::getValue)
- 指向静态方法的方法引用(例如
- 第2种和第3种方法引用初学时容易混淆,实际上不难,我可以作如下推演
// String::length相当于如下Lambda表达式。注意s只是形参而不是实际现存的对象,因此不可能是s::length (String s) -> s.length(); // someInstance::doSomething相当于如下Lambda表达式。注意到someInstance并不是形参,而是实际对象 SomeClass someInstance; Consumer<T> consumer = (args) -> someInstance.doSomething(args);
复合Lambda表达式
可以把多个简单的Lambda复合成复杂的表达式
- 比较器复合,如
// 逆序 inventory.sort(comparing(Apple::getWeight).reversed()); // 比较器链 inventory.sort(comparing(Apple::getWeight).reversed() .thenComparing(Apple::getCountry)); - 谓词复合,如以下代码所示
需要注意,and和or方法是按照在表达式链中的位置从左向右确定优先级的,即// negate Predicate<Apple> notRedApple = redApple.negate(); // and, or Predicate<Apple> redAndHeavyAppleOrGreen = readApple.and(apple -> apple.getWeight() > 150).or(apple -> GREEN.equals(apple.getColor()));a.or(b).and(c)可以看作(a || b) && c - 函数复合。
Function接口所代表的Lambda表达式也可以进行复合,有些类似数学中的复合函数// 类似数学上的h(x) = g(f(x)),即先作f映射,再作g映射 Function<Integer, Integer> h = f.andThen(g); // 类似数学上的h(x) = f(g(x)),即先作g映射,再作f映射 Function<Integer, Integer> h = f.compose(g);- 函数复合在实际中有什么应用呢?可以通过复合各个工具方法构建各种转换流水线,如下所示
java Function<String, String> addHeader = Letter::addHeader; Function<String, String> transformationPipeline = addHeader.andThen(Letter::checkSpelling).andThen(Letter::addFooter);
- 函数复合在实际中有什么应用呢?可以通过复合各个工具方法构建各种转换流水线,如下所示
第2部分 使用流进行函数式数据处理
第4章 引入流
引入流的原因:尽管集合对于几乎任何一个Java应用都是不可或缺的,但集合操作远远算不上完美
- 很多业务逻辑都涉及类似数据库的操作(如查找、分组、筛选等),SQL中只需表达要做什么而不用担心如何显式地实现这些查询语句,怎么到了集合这里就不能这样了呢?
- 处理大量元素时,为了提高性能你需要并行处理,并利用多核架构。但写并行代码比用迭代器还要复杂,而且调试起来也十分没意思
Stream API的优点
- 声明性,代码更简洁易读。这种方法加上行为参数化让你可以轻松应对变化的需求
- 可复合,更灵活。可以把几个基础操作链接起来,表达复杂的数据处理流水线,同时保持代码清晰可读
- 可并行,性能更好。具体操作与线程模型实现了解耦。
filter、sorted、map、collect等操作是与具体线程模型无关的高层次构件,所以它们的内部实现可以是单线程的,也可以透明地充分利用多核架构。在实践中,这意味着你不用为了让某些数据处理任务并行而去操心线程和锁,Stream API都替你做好了!
流简介
- 流到底是什么?答:“流是从支持数据处理的源生成的元素序列”
- 集合讲的是数据,流讲的是计算 因为集合是数据结构,所以它的主要目的是以特定时间/空间复杂度存储/访问元素;但流的目的在于表达计算
- 流的数据处理操作可以顺序执行也可以并行执行;从有序集合生成流时会保留原有的顺序
- 流的两个重要特点
- 流水线 很多流操作本身会返回一个流,这样多个操作就可以链接起来构成更大的流水线
- 内部迭代
流与集合
- 计算时机
- 集合是急切创建/生产驱动。集合是一个内存中的数据结构,它包含数据结构中目前所有的值,每个元素都得先算出来才能成为集合的一部分
- 流是延迟创建/需求驱动。流是在概念上固定的数据结构(不能添加或删除元素),其理念是用户仅仅从流中提取需要的值,而这些值是按需计算或生成的
- 迭代方式
- 集合使用循环外部迭代,显式地取出每个元素进行处理
for-each结构是一个语法糖,已经隐藏了迭代中的一些复杂性,它背后的东西用
Iterator对象表达出来会更丑陋 - 流是内部迭代,因而可以透明地并行处理,或者以更优化的顺序进行处理
- 集合使用循环外部迭代,显式地取出每个元素进行处理
for-each结构是一个语法糖,已经隐藏了迭代中的一些复杂性,它背后的东西用
- 流和迭代器类似,只能遍历一次。遍历完之后,我们就说这个流已经被消费掉了。例如,以下代码会由于试图重复消费流而抛出异常
List<String> title = Arrays.asList("Modern", "Java", "in", "Action"); Stream<String> s = title.stream(); s.forEach(System.out::println); s.forEach(System.out::println);
流操作
- 流的使用一般包括三件事
- 数据源
- 中间操作链
- 终端操作
java.util.stream.Stream接口定义了许多操作,这些操作可以分为两大类:可以链接起来的操作称为中间操作,关闭流的操作称为终端操作- 中间操作很懒,除非流水线上出发一个终端操作,否则中间操作不会执行任何处理 这是因为中间操作一般可以合并起来,在终端操作时一次性全部处理
- 终端操作会从流的流水线生成结果,其结果是“任何非流的值”(e.g.
List、Integer、void等)
- 流的流水线背后的理念类似于构建器模式
xxxBuilder.setX().setY().setZ().build();
第5章 使用流
流的各种操作
-
筛选:
filter和distinctList<Integer> numbers = Arrays.asList(1,2,1,3,3,2,4); numbers.stream() .filter(i -> i % 2 == 0) .distinct() .forEach(System.out::println); -
切片(选择/截短/跳过) 选择:
takeWhile、dropWhile;截短:limit;跳过:skip// 用filter来筛选的缺点是,需要遍历整个流中的数据,对其中每个元素执行predicate操作 // 但filter操作无法利用实际上流中元素是有序的这一已知条件,及时停止 List<Integer> numbers = Arrays.asList(1,2,3,4,5,8,15); List<Integer> smallNumbers1 = numbers.stream() .filter(n -> n <= 5) .collect(toList()); // takeWhile(Java 9引入)在遇到第一个不符合要求的元素时停止处理 // 有些类似于 while (true) { take(); } List<Integer> smallNumbers2 = numbers.stream() .takeWhile(n -> n <= 5) .collect(toList()); // dropWhile也是Java 9引入,与takeWhile类似但意义相反,不再贴代码 // 截短:limit返回另一个不超过给定长度的流 // 跳过:skip与limit互补,返回一个扔掉了前n个元素的流 -
映射:
map和flatMap- 咬文嚼字:映射和转换含义类似,其中的细微差别在于映射是“创建一个新版本”而不是“原地修改”
flatMap方法将各个生成流扁平化为单个流。一言以蔽之,让你把流中的每个值都换成另一个流,然后把所有的流连接起来成为一个流。大白话一点,就是将流的流转化为流,比如用map得到的效果是Stream<Stream<T>>或者Stream<List<Stream<T>>>,改用flatMap能将其扁平化,得到期望的Stream<T>
-
匹配:
allMatch、anyMatch、noneMatch匹配操作接受一个谓词参数,返回一个boolean值,因此是一个终端操作 -
查找:
findAny和findFirstfindAny/findFirst方法返回当前流中的任意/首个元素,因此是终端操作。两个方法都不接受参数(看方法名总是误以为二者接受一个谓词参数)- 为什么同时有
findAny和findFirst呢?答案是并行。找到第一个元素在并行上限制更多,所以如果不关心返回的元素是哪个,使用findAny更好,因为它在使用并行流时限制较少 - 查找操作可能什么元素都没找到,因此返回值类型是
Optional<T>(我猜在流操作上避免null也是引入Optional<T>的一大原因和动力吧)
-
归约
- 归约操作将流中所有元素反复结合起来,得到一个值(将流归约成一个值)。用函数式编程语言的术语来说称为“折叠”,因为你可以将这个操作看成把一张长长的纸(流)反复折叠成一个小方块(折叠操作的结果)
- 元素求和
// for-each循环的方式对数字列表中的元素求和 int sum = 0; for (int x : numbers) { sum += x; } // reduce操作,接受两个参数:初始值,将两个元素结合起来产生一个新值的BinaryOperator<T> int sum = numbers.stream().reduce(0, (a, b) -> a + b); // Java 8中,Integer类现在有一个静态的sum方法来对两数求和,因此代码还可以写得更易读 int sum = numbers.stream().reduce(0, Integer::sum); // reduce还有一个重载版本,不接受初始值,返回一个Optional对象(考虑流中没有任何元素的情况) Optional<T> sum = numbers.stream().reduce((a, b) -> a + b); - 最大/最小值
Optional<Integer> max = numbers.stream().reduce(Integer::max); Optional<Integer> min = numbers.stream().reduce(Integer::min); - 计数
// map-reduce模式 int count = numbers.stream().map(x -> 1).reduce(0, Integer::sum); // 内置count方法就可以计数,返回long值 long count = numbers.stream().count(); - 归约方法的优势
- 迭代式求和的例子中要更新共享变量
sum,不太容易并行化。如果加入了同步,很可能线程竞争抵消了并行本应带来的性能提升。因此这种计算的并行化需要另一种办法:分块求和最后合并。但这样的话代码可读性就很差了。 - 使用
reduce,迭代被内部迭代抽象掉了,使得内部实现得以选择并行执行reduce操作。 - 因此,可变的累加器模式对于并行化来说死路一条,而
reduce提供了一种新的模式
- 迭代式求和的例子中要更新共享变量
数值流
- 原始类型流特化
- Java 8引入了三个原始类型特化流接口来避免某些场合下暗含的装箱成本:
IntStream、DoubleStream和LongStream。每个接口都带来了进行常用数值归约的新方法(e.g.sum、max、average等)以及在必要时转换回对象流的方法。 - 记住,引入特化流的原因并不在于流的复杂性,而是装箱带来的效率差异
- 数值流与对象流的转换
// 映射到数值流,以便进行sum操作,同时避免了用reduce操作求和的装箱开销。 IntStream intStream = menu.stream().mapToInt(Dish::getCalories); int calories = intStream.sum(); // 转换回对象流,以便使用Stream接口中定义的那些更广义的操作 Stream<Integer> stream = intStream.boxed(); Stream<int[]> objStream = intStream.mapToObj(i -> new int[]{i, i}); - 默认值
// 寻找数值流中最大的元素,如果没有最大值,显示给出默认值 // OptionalDouble, OptionalLong类似 OptionalInt maxValue = intStream.max(); int max = maxValue.orElse(0);
- Java 8引入了三个原始类型特化流接口来避免某些场合下暗含的装箱成本:
- 数值范围流
// [1, 100] IntStream numberStream = IntStream.rangeClosed(1, 100); // [1, 100) IntStream numberStream2 = IntStream.range(1, 100);
构建流
- 由值创建流
Stream<String> stream = Stream.of("Modern", "Java", "in", "Action"); Stream<String> emptyStream = Stream.empty(); - 由可空对象创建流
// Java 8 String s = getString(); Stream<String> stringStream8 = s == null ? Stream.empty() : Stream.of(s); // Java 9 Stream<String> stringStream9 = Stream.ofNullable(getString()); - 由数组创建流
int[] numbers = {1, 2, 3, 4, 5}; // 一行代码搞定数组元素求和 int sum = Arrays.stream(numbers).sum(); - 由文件生成流
NIO API已更新,以便利用Stream API,例如
java.nio.file.Files中的很多静态方法都会返回一个流。注意如下代码中的注释说明的小细节long uniqueWords = 0; // Stream接口实现了AutoCLoseable,因此不需要通过finally块显式完成回收工作了 try (Stream<String> lines = Files.lines(Paths.get("data.txt"), Charset.defaultCharset())) { uniqueWorkds = lines.flatMap(line -> Arrays.stream(line.split(" "))) .distinct().count(); } catch (IOException e) { } - 由函数生成流:创建无限流
- Stream API提供了两个静态方法
Stream.iterate和Stream.generate从函数生成流,并且这两个操作可以创建所谓的无限流(产生的流会用给定的函数按需创建值,因此可以无穷无尽地计算下去)。 - 另外,本节分别用两种方法生成了斐波那契数列,实现细节可以参考书P113/P116。
- 一般来说,在需要依次生成一系列值的时候应该使用
iterate - 迭代
iterateiterate方法接受一个初始值,还有一个依次应用在每个产生的新值上的Lambda(UnaryOperator<T>类型)。这种迭代操作基本上是顺序的,因为结果取决于前一次应用。Java 9又对// Stream.iterate(0, n -> n + 2).limit(10).forEach(System.out::println);iterate方法进行了增强,使得迭代可以停止了。这里用IntStream.iterate方法举例如下// 第二个参数是一个谓词,它决定了迭代调用何时终止 IntStream.iterate(0, n -> n < 100, n -> n + 2); - 生成
generategenerate方法不是依次对每个新生成的值应用函数的,它接受一个Supplier<T>类型的Lambda提供新的值。例如Stream.generate(Math::random).limit(5).forEach(System.out::println); - 因为处理的是一个无限流,所以必须使用
limit操作来显式限制它的大小,否则终端操作将永远进行下去!类似的,不能对无限流作排序或归约,因为所有元素都需要处理,而这永远也无法完成!
- Stream API提供了两个静态方法
第5章小结
- 如果明确地知道数据源是有序的,那么用
takeWhile/dropWhile方法通常比filter高效得多 - 查找和匹配的这些操作都利用了短路,一旦找到结果立即停止计算,没有必要处理整个流。上面提到的takeWhile
/dropWhile`也是一种短路操作
第6章 用流收集数据
收集器简介
- 用收集器(
Collector)可以简洁而灵活地定义collect操作用来生成结果集合的标准;正如用比较器(Comparator)定义排序方法的标准 collect操作本质上是一个归约操作Collectors实用类提供了很多静态工厂方法,可供方便地创建常见收集器的实例。它们主要提供了三大功能- 将流元素归约和汇总为一个值
- 元素分组
- 元素分区
归约和汇总
- 计数/最值/汇总/连接字符串
import static java.util.stream.Collectors.*; // 计数 long howManyDishes = menu.stream().collect(counting()); // 最大/最小值 Optional<Dish> mostCalorieDish = menu.stream().collect(maxBy(Comparator.comparingInt(Dish::getCalories))); // 汇总 int totalCalories = menu.stream().collect(summingInt(Dish::getCalories)); double avgCalories = menu.stream().collect(averagingInt(Dish::getCalories)); // 一次操作完成汇总 // IntSummaryStatistics{count=9, sum=4300, min=120, average=477.777778, max=800} IntSummaryStatistics menuStat = menu.stream().collect(summarizingInt(Dish::getCalories)); System.out.print(menuStat); // 连接字符串,把对流中每个对象应用toString方法得到的所有字符串连接起来 // 注意,joining内部使用的是StringBuilder,而不是+=或concat操作 String shortMenu = menu.stream().collect(joining()); - 在需要将流项目重组成集合时一般会实用收集器,如常见的
collect(toList())操作。再宽泛一点说,凡是要把流中所有项目合并成一个结果(可以是任意类型)时就可以用收集器。 - 实际上,
Collectors.reducing工厂方法是上述所有这些特殊情况的一般化。可以说,上述那些案例仅仅是为了方便程序员而已(但是请记住,方便程序员和可读性是头等大事)。reducing有三个参数:初始值、转换函数、累积函数 - 既然
collect方法实际也是归约操作,那么collect和reduce有何不同呢?- 语义上,
reduce方法旨在把两个值结合起来生成一个新值,它是一个不可变的归约。而collect方法的设计就是要改变容器从而累积要输出的结果(例如collect(toList())就是要往集合容器中不断加入流中的元素) - 实际问题,若以错误语义使用
reduce方法会造成归约过程不能并行工作(因为由多个线程并发修改同一个数据结构可能破坏这个数据结构本身;而想要线程安全,就需要每次分配一个新的集合容器,但这又会影响性能)。而collect方法来做适合并行操作,因此特别适合表达可变容器上的归约
- 语义上,
分组
groupingBy有单参数和双参数两个版本。从这个角度来说,普通的单参数groupingBy(f)(f为分类函数)实际上是groupingBy(f, toList())的简单写法;当第二个参数还是groupingBy时就达到了多级分组的效果,这种多级分组操作可以扩展至任意层级。- 理解多级分组,可以把
groupingBy看作“桶”,第一个groupingBy给每个键建立了一个桶,然后再用下游的收集器去收集每个桶中的元素,以此得到n级分组 - 把好几个收集器嵌套起来是很常见的。更进一步,
groupingBy接受的第二个收集器可以是任意类型,而未必一定是另一个groupingBy。例如,要统计菜单中每类菜的数量,可以传递counting收集器作为第二个参数;要查看每类菜中热量最高的菜,可以传递maxBy。这就是按子组收集数据 - 如果说使用Collection API完成单一标准分组时代码还能维持一定可读性,那么扩展到多级分组代码就很难懂了;而Stream API却可以通过高效地组合实现这一点
- 一个小知识点:
collect(toList())对于返回的List类型并没有任何保证,如果想要更多的控制,可以使用如collect(toCollection(ArrayList::new))这样的代码
分区
- 分区(
partioningBy)是分组(groupingBy)的特殊情况:由一个谓词作为分类函数,称为分区函数;因此得到的分组Map的键类型是Boolean,最多可分为true/false两组。例如,如下代码将数字分为质数与非质数public boolean isPrime(int candidate) { int candidateRoot = (int) Math.sqrt((double) candidate); return IntStream.rangeClosed(2, candidateRoot).noneMatch(i -> candidate % i == 0); } public Map<Boolean, List<Integer>> partionPrimes(int n) { return IntStream.rangeClosed(2, n).boxed() .collect(partioningBy(candidate -> isPrime(candidate))); }
收集器接口
- 以上这些预定义收集器都是对
Collector接口的实现,该接口为实现具体的归约操作(即收集器)提供了范本。// T是流中要收集的项目的类型,A是累加器的类型,R是收集操作得到的对象的类型(通常但并不一定是集合) // 前三个方法足以对流进行顺序归约,有了第四个方法(会用到Java 7引入的分支/合并框架)就可以对流进行并行归约 public interface Collector<T, A, R> { // 供应源,建立新的结果容器 Supplier<A> supplier(); // 累加器,将元素添加到结果容器 BiConsumer<A, T> accumulator(); // 对结果容器应用最终转换,如无需转换,可返回恒等函数Function.identity() Function<A, R> finisher(); // 组合器,合并两个结果容器 BinaryOperator<A> combiner(); // 定义了收集器的行为,尤其是关于是否可以并行归约,以及可以使用哪些优化的提示 Set<Characteristics> characteristics(); } - 本节示例代码提到的一个小知识点(p143):需要返回空列表时可以使用单例的
Collections.emptyList()替代常见的new ArrayList<>() - p144-148实现了一个自定义的收集器,以达到将质数和非质数分区的目的;p148-149对该收集器与预定义
partioningBy工厂方法创建的收集器进行了性能对比。这里的对比的方法是:对前100万个自然数进行分区并跑10次,比较所用时间(结果是性能提升了约30%)。这里提到了更为科学的测试方法是用一个诸如JMH的框架。我搜索了一下,JMH是Java Microbenchmark Harness的缩写,译为Java微基准套件(在method层面上进行较为精确的benchmark)。P154用JMH对比了一个方法的两个版本的性能,以便在二者中进行取舍- 这部分代码有个小知识点摘录如下(p146):
public Map<K, V> xxxMethod() { // 这里创建Map的同时对其进行了初始化 // 我认为双花括弧的写法其实就是是正常的单括弧内部有一个非静态代码块 return new HashMap<K, V>() {{ put(XXX, xxx); put(YYY, yyy); }}; // 不要忘了这里的分号 }
- 这部分代码有个小知识点摘录如下(p146):
第7章 并行数据处理与性能
(本章主要讲分支/合并框架和Spliterator,平时几乎用不上,所以只是看了个大概,以后有需要再结合其他资料细看)
概述
- Java 7之前要对集合数据执行并行处理非常麻烦
- 需要明确地把包含数据的数据结构拆分成若干子部分
- 需要给每个子部分分配一个独立的线程
- 需要在恰当的时候对它们进行同步以避免竞争条件,并等待所有线程完成,合并这些部分结果
- Java 7引入名为“分支/合并”的框架,能让这些操作更稳定、更不易出错
并行流
- 并行流就是一个把内容拆分成多个数据块,用不同线程分别处理每个数据块的流。这样就可以自动地把工作分配到多核处理器的所有核,让它们都忙起来
- 对顺序流调用
parallel方法,就可以将流转换成并行流。注意,这一过程并不意味着流本身有任何实际的变化,其实仅仅是在内部设置了一个boolean标志,表示你想让调用parallel之后进行的所有操作都并行执行。类似地,对并行流调用sequential方法就可以将它变成顺序流。 - 最后一次
parallel或sequential调用会影响整个流水线,也就是说流水线是否会并行执行看最后一次调用的是parallel还是sequential - 并行流内部使用了默认的
ForkJoinPool,它默认的线程数量是处理器的数量(由Runtime.getRuntime().availbleProcessors()得到) - 测量流性能
- 优化性能时应始终遵循的黄金法则是:测量,测量,再测量
- 测量流性能的示例中,特别配置了大的堆,并且试图在每次基准测试迭代完成之后强制进行垃圾回收(
System.gc()),希望尽量避免垃圾回收带来的影响。但太多因素都可能影响执行的时间,因此基准测试的结果仍然不可尽信 - 将流标记为并行并不总是能提高性能。必须认识到,某些流操作比其他操作更容易并行化(反例:
iterate在本质上是顺序的,每次应用这个函数都要依赖前一次应用的结果,因此它很难分割成能够独立执行的小块)这意味着,这种情况下,把流标记成并行,其实是给顺序处理增加了开销,它还得把每次求和操作分到一个不同的线程上 - 选择适当的数据结构往往比并行化算法更重要,使用正确的数据结构然后使其并行工作能够保证最佳的性能(书中举例如下)
// 问题:求前N个自然数的和 Stream.iterate(1L, i -> i + 1).limit(N).parallel().reduce(0L, Long::sum); // 性能好很多的原因:1.直接产生原始类型的long数字,避免了拆箱装箱开销;2.产生的流容易拆分 LongStream.rangeClosed(1, N).parallel().reduce(0L, Long::sum); - 并行化并不是没有代价的。并行化过程本身需要对流做递归划分,把每个子流的归约操作分配到不同的线程,然后把这些操作的结果合并成一个值。另外,在多个核之间移动数据的代价也可能比你想的要大
- 错误使用并行流而产生错误的首要原因就是使用的算法改变了某些共享状态(即函数有副作用)
- 高效使用流的建议
- 把顺序流转成并行流轻而易举,却未必能保证正确或高效
- 尽可能用
IntStream等原始类型流避免自动装箱和拆箱操作,它们会大大降低性能 - 有些操作本身在并行流上的性能就比顺序流差(如
limit和findFirst等依赖于元素顺序的操作在并行流上执行的代价非常大) - 对于较小的数据量,选择并行流几乎从来都不是一个好决定
- 要考虑流背后的数据结构是否易于分解(例如
ArrayList拆分效率比LinkedList高得多,前者不用遍历就可以平均拆分;IntStream.range比Stream.iterate可分解性好得多) - 还要考虑终端操作中合并步骤(
Collector::combiner方法)的代价大小 - 并行流背后使用的基础架构是Java 7引入的分支/合并框架。要像正确使用并行流,了解它的内部原理至关重要
分支/合并框架
- 分支/合并框架,目的是以递归方式将可以并行的任务拆分成更小的任务,然后将每个子任务的结果合并起来生成整体结果。它是
ExecutorService接口的一个实现,它把子任务分配给线程池(称为ForkJoinPool)中的线程 - 在实际应用时,使用多个
ForkJoinPool没有太大意义,一般使之成为单例 - 分出大量的小任务而不是少数几个大任务,有助于更好地在工作线程之间平衡负载。分支/合并框架解决实际中每个子任务所花时间可能相差很大这个问题所用的技术称为工作窃取(work dealing):每个线程都为分配给它的任务保存一个双向链表,当完成自己的所有任务时,随机选取一个别的线程并从其队列的尾巴上“偷走”一个任务。
- 并行流背后的流自动拆分机制称为
Spliterator,是Java 8加入的一个新接口。Spliterator名字的含义是“可分迭代器”(splitable iterator),它定义了并行流如何拆分它要遍历的数据。和Iterator一样,Spliterator也用于遍历数据源中的元素,但它是为了并行执行而设计的 - 实践中可能用不着自己开发
Spliterator,但了解它的实现方式会增强对并行流工作原理的理解。书中p166-173对这一内容作了介绍,并以单词计数任务为例演示实现自定义Spliterator的过程
第3部分 使用流和Lambda进行高效编程
第8章 Collection API的增强功能
创建集合
Java 9新增的工厂方法可以简化小规模List、Set或Map的创建
// 这种方法创建的列表是一个不可变集合(只读列表),不能增减元素,也不能更新元素
List<String> wordList = List.of("aaa", "bbb", "ccc");
// java.lang.UnsupportedOperationException
wordList.add("ddd");
wordList.set(0, "ddd");
// 创建不可变Set集合
Set<String> wordSet = Set.of("aaa", "bbb", "ccc");
// 创建不可变Map
// 创建小型Map用of工厂方法比较方便
Map<String, Integer> ageOfFriends = Map.of("Tom", 10, "Jerry", 9, "Jack", 11);
// 稍大型Map可以考虑用Map.ofEntries工厂方法创建
Map<String, Integer> ageOfFriends =
Map.ofEntries(Map.entry("Tom", 10), Map.entry("Jerry", 9), Map.entry("Jack", 11));
处理集合(List和Set)
-
Java 8为
List和Set新引入以下方法:Collection::removeIf、List::replaceAll、List::sort。注意,这些方法都作用于调用对象本身,即它们改变的是集合自身(原地修改)。这一点跟流的操作有很大不同(生成一个新副本) -
为何要引入这些新方法? 因为Java 8之前,集合的修改繁琐且容易出错(典型的例子就是迭代过程中删除列表元素)
// for-each遍历集合元素的同时进行删除操作 for (Integer number : numbers) { if (number % 2 == 0) { // ConcurrentModificationException numbers.remove(number); } } // 错误原因在于,底层实现上for-each循环使用了一个迭代器对象,所以实际代码类似这样 for (Iterator<Integer> iterator = numbers.iterator(); iterator.hasNext(); ) { Integer number = iterator.next(); if (number % 2 == 0) { // 问题在这里,我们使用了两个不同的对象来迭代和修改集合 // 迭代器对象,使用next()和hasNext()方法查询源;集合对象,它调用remove()方法删除集合元素。 // 把迭代器对象和集合对象混在一起使用比较容易出错。这里,迭代器对象的状态没有与集合对象的状态同步 numbers.remove(number); } } // Java 8之前的常见解决方案是不使用for-each,显式地使用Iterator对象 for (Iterator<Integer> iterator = numbers.iterator(); iterator.hasNext(); ) { Integer number = iterator.next(); if (number % 2 == 0) { // 暴露处Iterator对象,进而可以调用remove()方法 iterator.remove(); } }显然,用以上解决方案,这段代码变得繁琐,以下是使用Java 8提供的
removeIf方法的代码numbers.removeIf(number -> number % 2 == 0); -
有时我们想要做的不是删除列表中的元素,而是替换它们。为此,Java 8新增了
replaceAll方法// 可以使用ListIterator对象(该对象提供了set()方法,可用来替换集合中的元素) for (ListIterator<String> iterator = words.listIterator; iterator.hasNext(); ) { String word = iterator.next(); iterator.set(word.toUpperCase()); } // 有了Java 8之后。。。 words.replaceAll(word -> word.toUpperCase()); -
另外,现在
List自身支持sort方法,而不是像以前那样通过Collections.sort的方式对List排序
处理集合(Map)
Java 8在Map接口中新引入了几个默认方法,目的是提供惯用模式(我对这里“模式”的理解:经常遇到的情况或需求),减少重复实现的开销,并帮助我们编写更简洁的代码
- 遍历
- 一直以来,遍历
Map中的键和值都是非常笨拙的操作,需要使用Map.Entry<K, V>迭代器访问Map集合中的每一个元素 forEach方法接受一个BiConsumer,以Map的键值对作为参数persons.forEach((name, age) -> System.out.println(name + ": " + age));
- 一直以来,遍历
- 排序
// 对字典(Map)中的条目按名字排序。另有按值排序的方法Entry.comparingByValue persons.entrySet().stream() .sorted(Entry.comparingByKey()).forEachOrdered(System.out::println); - 键不存在
注意,如果键在
Map中存在,只是碰巧值是null,那么getOrDefault还是会返回null - 计算模式
computeIfAbsent/computeIfPresent/computeList<String> movies = friendsToMovies.get("Tom"); if (movies == null) { movies = new ArrayList<String>(); friendsToMovies.put("Tom", movies); } movies.add("Star Wars"); // In Java 8 friendsToMovies.computeIfAbsent("Tom", name -> new ArrayList<>()).add("Star Wars"); - 删除
remove(key, value)方法删除Map中某个键对应某个特定值的映射对 - 替换
replace/replaceAllpersons.replaceAll((name, age) -> age + 1); - 合并
// merge方法可以用更灵活地处理Map合并时的冲突,而原有的putAll方法仅仅是简单覆盖 // merge方法还可以用来执行初始化检查 // 学习下作者对map对象的命名方法 Map<String, Long> moviesToCount = new HashMap<>(); String movieName = "Matrix"; Long count = moviesToCount.get(movieName); if (count == null) { moviesToCount.put(movieName, 1L); } else { moviesToCount.put(movieName, count + 1); } // Java 8真香 // 如果键当前关联值为空,则将该键关联到1L;否则由BiFunction方法对count进行处理 moviesToCount.merge(movieName, 1L, (key, count) -> count + 1);
ConcurrentHashMap
- 支持三种新的操作:
forEach/reduce/search - 支持从
Map继承的新默认方法,并提供了线程安全的实现(有时间要去看看源码是如何高效地提供线程安全实现的)
第8章小结
- Java 9支持集合工厂,使用
List.of、Set.of、Map.of以及Map.ofEntries可以创建小型不可变的List、Set、Map。这些方法返回的对象都是不可变的,即创建后不能修改它们的状态(相当于通过增强Collection API,另辟蹊径地增加了对集合常量的支持) Map接口为常见模式(需求)提供了几种新的默认方法,并降低了出现缺陷的概率
第9章 重构、测试和调试
改善可读性
- 匿名类迁移到Lambda表达式有时会稍复杂
- 匿名类和Lambda表达式中的
this和super含义是不同的(前者代表类自身,后者中代表的是包含类) - 匿名类可以屏蔽包含类中的变量(即覆盖方法上下文中定义的局部变量),而Lambda表达式不可以(编译错误)
- 涉及重载的上下文中,将匿名类转换为Lambda表达式可能导致代码可读性降低。因为匿名类的类型是初始化时确定的,而Lambda的类型取决于它的上下文
interface Task { public void execute(); } public static void doSomething(Runnable r) { r.run(); } public static void doSomething(Task t) { t.execute(); } // 匿名类,OK doSomething(new Task() { public void execute() { System.out.println("Hello"); } }) // Lambda,模棱两可 doSomething(() -> System.out.println("Lambda")); // 可以尝试使用显式类型转换来消除歧义 doSomething((Task) () -> System.out.println("Lambda"));
- 匿名类和Lambda表达式中的
- 为了改善代码可读性,尽量使用方法引用(相比Lambda),因为方法名往往更能直观表达代码的意图(相比Lambda)
- 从命令式的数据处理切换到Stream
- Stream API能更清晰地表达数据处理管道的意图;并且通过短路、延迟计算、内置并行等技术,我们可以对Stream进行优化
- 将命令式的代码结构转换成Stream API的形式有时是个困难的任务,因为需要考虑控制流语句(e.g.
break/continue/return)并选择恰当的流操作。好消息是,有一些工具可以帮助我们完成这个任务,比如LambdaFicator
增加灵活性
Lambda有利于行为参数化,可以在以下两种通用模式中引入Lambda增加代码的灵活性
- 有条件的延迟执行
我们经常看到这样的代码,控制语句被混杂在业务逻辑代码中。典型的情况包括进行安全性检查以及日志输出
提炼模式如下:// 问题1:logger的状态(日志等级)暴露给了客户端代码 // 问题2:每次输出日志之前都要去显示查询logger对象的状态 if (logger.isLoggable(Log.FINER)) { logger.finer("Problem: " + generateDiagnostic()); } // 将状态检查隐藏到方法实现内部 // 不足在于:即使当前日志不会输出,generateDiagnostic()仍然要计算出结果(也许是一个昂贵操作) logger.log(Level.FINER, "Problem: " + generateDiagnostic()); // 新的API及其内部实现 public void log(Level level, Supplier<String> msgSupplier) { if (logger.isLoggable(level)) { log(level, msgSupplier.get()); } } // 新的客户端代码,仅仅是将原来的立即计算改为了延迟计算 logger.log(Level.FINER, () -> "Problem: " + generateDiagnostic());// 旧的API及客户端代码 public void oldMethod(T value); if (instance.checkState(state)) { instance.oldMethod(calcValue()); } // 新的API及客户端代码 public void newMethod(State state, Supplier<T> supplier) { if (instance.checkState(state)) { oldMethod(supplier.get()) } } instance.newMethod(State.READY, () -> calcValue()); - 环绕执行 重用首、尾模版代码,对中间部分代码行为参数化,以Lambda形式传入
使用Lambda重构设计模式
-
策略模式
- 策略模式代表了解决一类算法的通用方案,可以在运行时选择使用哪种方案。
- 策略模式包含三部分内容
- 一个代表某个算法的接口(Strategy)
- 一个或多个该接口的具体实现,代表了算法的多种实现(AStrategy/BStrategy)
- 一个或多个使用策略对象的客户(依赖于接口以便传递不同的实现)
- 有了Lambda,不再需要声明新的实现类,传递Lambda表达式就能达到同样的目的
-
模板方法模式 创建算法框架,让具体继承类重写或实现某些部分
abstract class ClassX { public void publicMethod() { // common logic abstractMethod(id); // common logic } abstract void abstractMethod(String id); } // 现在可以通过传递Lambda表达式直接插入不同的行为,不再需要创建抽象类ClassX并继承之 class ClassY { public void publicMethod(Consumer<String> abstractFunction) { // common logic abstractFunction.accept(id); // common logic } } -
观察者模式
- 某些事件发生时(例如状态转变),如果一个对象(主题/Subject)需要自动地通知其他多个对象(观察者/Observer),就会采用该方案
- 简单的实例中,例如Observer接口只定义了一个方法时,可以通过Lambda直接对其实例化,省去了声明多个实现类的僵化代码
- 如果Observer的逻辑比较复杂、定义了多个方法或是持有状态,还是应该继续保持原有方式应用该模式
-
责任链模式
- 责任链模式是一种创建处理对象序列的通用方案。通常,通过定义一个代表处理对象的抽象类来实现,在抽象类中定义一个字段来记录后续对象。一旦对象完成它的工作,处理对象就会将它的工作转交给它的后继
// 融合了模板方法模式的责任链模式 public abstract class ProcessingObject<T> { protected ProcssingObject<T> successor; public void setSuccessor(ProcessingObject<T> successor) { this.successor = successor; } public T handle(T input) { T r = handleWork(input); if (successor != null) { return successor.handle(r); } return r; } abstract protected T handleWork(T input); } - 这个模式看起来像是在链接(也就是构造)函数,可以创建多个函数并用andThen方法链接
UnaryOperator<String> headerProcessing = (String text) -> "From Jerry: " + text; UnaryOperator<String> spellCheckerProcessing = (String text) -> text.replaceAll("labda", "lambda"); Function<String, String> pipeline = headerProcessing.andThen(spellCheckerProcessing); String result = pipeline.apply("I have to say labda is really cool!");
- 责任链模式是一种创建处理对象序列的通用方案。通常,通过定义一个代表处理对象的抽象类来实现,在抽象类中定义一个字段来记录后续对象。一旦对象完成它的工作,处理对象就会将它的工作转交给它的后继
测试与调试
- 即使使用了Lambda表达式,也同样可以进行单元测试。但是通常应该关注使用了Lambda表达式的方法的行为(也就是说,把Lambda表达式就当作是包含方法内部的一小段具体代码,因此应该对包含方法进行测试,毕竟单元测试的“单元”本来就是方法)
- 查看栈跟踪调试——涉及Lambda表达式的Stack Trace可能比较难理解(Lambda表达式没有名字,所以编译器只能为它们指定一个名字,e.g.
lambda$main$0),这是Java编译器未来版本可以改进的一个方面 - 使用日志调试——使用
peek查看Stream流水线中数据流的值,peek设计的初衷就是在流的每个元素恢复运行之前插入执行一个动作(不觉得这样调试太麻烦了吗?看来函数式目前写起来爽,调试起来酸爽。。。)// 通过peek操作能清楚地了解流水线操作中每一步的输出结果 List<Integer> result = numbers.stream() .peek(x -> System.out.println("from stream: " + x)) .map(x -> x + 17) .peek(x -> System.out.println("after map: " + x)) .filter(x -> x %2 == 0) .peek(x -> System.out.println("after filter: " + x)) .limit(3) .peek(x -> System.out.println("after limit: " + x)) .collect(toList()); // from stream: 2 // after map: 19 // from stream: 3 // after map: 20 // after filter: 20 // after limit: 20 // from stream: 4 // after map: 21 // from stream: 5 // after map: 22 // after filter: 22 // after limit: 22
第10章 基于Lambda的领域特定语言
(这一章主要讲DSL及API设计等内容,个人觉得是个很有意思的话题。但是,鉴于当前我的目的是多快好省地补习Java各大生态的基础知识,这一章暂时跳过没有看,毕竟还有Spring、MyBatis、MySQL、Redis、ES、并发等等太多专题在等我。。。以后有空一定要回来把这一章补上)
第4部分 无所不在的Java
这部分介绍Java 8和Java 9新增的多个特性,这些特性能帮助我们事半功倍地编写更稳定可靠的代码。主要介绍了java.util.Optional类、新的日期时间API、接口默认方法以及Java模块系统等内容
第11章 用Optional取代null
null带来的种种问题
作者的一个观点是:null检查只是掩耳盗铃。几乎所有Java程序员碰到NullPointerException时的第一冲动就是添加一个if语句作检查,快速搞定问题。但如果你按照这种方式解决问题,丝毫不考虑你的算法或数据结构在这种状况下是否应该返回一个null,那么其实并没有真正解决这个问题,只是暂时地掩盖了它,使得下次该问题的调查和修复更加困难
- 错误之源——
NullPointerException是Java程序中最典型的异常 - 使得代码膨胀——代码充斥着或是深度嵌套的
null检查(代码扩展性、可读性很差),或是过多的退出点(代码维护性差) - 自身没有任何语义
- 破坏了Java的哲学——Java一直试图避免让程序员意识到指针的存在,唯一的例外是
null指针 - 在Java的类型系统上开了个口子——
null并不属于任何类型,但又可以被赋值给任意引用类型的变量
Optional入门
- 变量存在时,
Optional类只是对类简单封装;变量不存在时,缺失的值会被建模成一个“空”的Optional对象,由方法Optional.empty()返回,返回的是Optional类的单例 Optional丰富了模型的语义,通过类型系统让你的域模型中隐藏的知识显式地体现在代码中- 特别强调,引入
Optional类的意图并非要消除每一个null引用。与此相反,它的目标是帮助你更好地设计出普适的API,让程序员看到方法签名就能了解它是否接受一个Optional的值
应用Optional
-
创建Optional对象
Optional<Car> optCar = Optional.empty(); // NullPointerException Optional<Car> optCar = Optional.of(car); // 如果car==null,返回Optional.empty()对象 Optional<Car> optCar = Optional.ofNullable(car); -
从Optional对象中提取和转换值
map实例方法,如果Optional包含值,函数就将该值作为参数传递给map进行转换;如果Optional为空,就什么也不做flatMap实例方法,与Stream中类似,将Optional<Optional<T>>扁平化为Optional<T>
-
在域模型中使用
Optional可能引发问题 由于Optional类设计时就没有特别考虑将其作为类的字段使用,因此它也没有实现Serializable接口。因此,如果应用中使用了某些要求序列化的库或者框架,在域模型中使用Optional有可能引发问题// 使用Optional实现序列化的域模型的替代方案:字段不用Optional类型,但提供返回Optional类型的public方法 public class Person { private Car car; public Optional<Car> getCarAsOptional() { return Optional.ofNullable(car); } } -
Java 9引入了
Optional类的stream()方法,如果有值就返回包含该值的一个Stream,否则返回一个空的Stream -
Optional类提供了多种方法读取Optional实例中的变量值get()是最简单但又最不安全的方法,并且相比嵌套式的null检查也并未体现出多大改进orElse(T other)提供不含值时的默认值orElseGet(Supplier<? extends T> other)是orElse方法的延迟调用版,因为Supplier方法只有在Optional对象不含值时才执行调用。如果创建默认值是个昂贵操作,应该考虑采用这种方式以提升性能orElseThrow和get方法类似,遭遇空的Optional对象时都会抛出一个异常,但是这个方法可以定制希望抛出的异常类型or/ifPresent/ifPresentOrElse等,看Java API自行了解
-
使用filter方法剔除特定值
Optional<Person> optPerson = ...; // 重点:可以将Optional对象看成(类比)最多包含一个元素的Stream对象。那么filter方法的行为就很清晰了 // 若Optional对象为空,不做任何操作;反之则对Optional对象包含的值执行谓词操作。 // 若该操作结果为true,则不做任何改变,直接返回该Optional对象,否则过滤掉,将Optional的值置空 optPerson.filter(person -> person.age >= 35) .ifPresent(p -> System.out.println("35警告"));
使用Optional改造老代码
Optional是个好东西,但出来得太晚。为了保持向后兼容,我们很难对老的Java API进行改动,让它们使用Optional,这一节作者介绍如何修复或绕过这些问题
- 现存Java API几乎都是通过返回一个
null的方式来表示值的缺失或是由于某些原因无法得到该值Optional<Object> value = Optional.ofNullable(map.get("key")); - 除了返回
null,Java API比较常见的替代做法是抛出一个异常,来表示由于某种原因函数无法返回某个值// 将类似这样的方法封装到一个工具类中,例如OptionalUtility,以后就可以直接调用这一封装方法 public static Optional<Integer> optParseInt(String s) { try { return Optional.of(Integer.parseInt(s)); } catch (NumberFormatException e) { return Optional.empty(); } } - 不推荐使用基础类型的Optional
与Stream对象一样,
Optional也提供了类似的基础类型(OptionalInt/OptionalLong/OptionalDouble)。对于Stream而言,出于性能考虑,包含了大量元素时使用基础类型是不错的选择,但Optional对象最多只包含一个值,这个理由不成立。因此不推荐使用,因为基础类型不支持map/flatMap/filter等非常有用的方法
第12章 新的日期和时间API
- 为什么Java 8需要引入新的日期和时间库
Date/Calendar类本身不完善(例如Calendar类月份从0开始计算)- 同时存在
Date/Calendar类增加了程序员对于该用哪个的困惑 - formatter不是线程安全的
Date/Calendar类都是可变的
使用新类
Java 8在java.time包提供了LocalDate、LocalTime、LocalDateTime、Instant、Duration以及Period等类
-
LocalDate/LocalTime/LocalDateTimeLocalDate只提供了简单的日期,不含当天的时间信息,也不附带时区信息(毕竟类名里的Local不是摆设)LocalTime用来表示一天当中的时间LocalDateTime是LocalDate和LocalTime的合体,同时表示了日期和时间,也不含时区信息;LocalDateTime对象可以直接创建,也可以通过合并日期和时间对象创建LocalDate/``LocalTime都可以使用静态方法parse解析代表它们的字符串来创建。parse方法还可以接受一个DateTimeFormatter对象,它是替换老版java.util.DateFormat的推荐替代品
-
Instant- 以上提到的日期时间类是为了便于人类阅读使用,而从计算机的角度来看,建模时间最自然的格式是表示一个持续时间段上某个点的单一大整型数(传统的设定为UTC时区1970年1月1日午夜时分)
Instant类支持纳秒精度,它的设计初衷就是为了便于机器使用。因此不能将二者混用
-
Duration/Period- 以上介绍的类都实现了
Temporal接口,该接口定义了如何读取和操纵为时间建模的对象的值;Duration/Period衡量两个Temporal对象之间的间隔 Duration主要用于以秒或纳秒衡量时间长短,因此不能它的between静态方法不能接受LocalTime/LocalDateTime/Instant对象,但不能接受LocalDatePeriod可用来以年、月、日等方式表示这个时间间隔
- 以上介绍的类都实现了
-
重点是,以上日期/时间类的对象都是不可变对象,这是为了更好地支持函数式编程,确保线程安全
操纵日期
- 以直观的方式操纵
LocalDate的属性get/with方法声明于Temporal接口,所有的日期和时间API类都实现这两个方法。与get/set类似,它们分别用于Temporal对象值的读取/修改。区别在于,with方法不会直接修改现有Temporal对象,而是以该对象为模板对某些状态进行修改创建该对象的副本,称作函数式更新LocalDate date = LocalDate.of(2020, 10, 30); //2020-10-30 LocalDate date1 = date.withYear(2022); // 2022-10-30 LocalDate date2 = date1.withDayOfMonth(25); // 2022-10-25 LocalDate date3 = date2.with(ChronoField.MONTH_OF_YEAR, 2); // 2022-02-25 - 以相对方式修改
LocalDate对象的属性LocalDate date = LocalDate.of(2020, 10, 1); // 2020-10-01 LocalDate date1 = date.plusWeek(1); // 2020-10-08 LocalDate date2 = date1.minusYear(2); // 2018-10-08 LocalDate date3 = date2.plus(3, ChronoUnit.MONTHS); // 2019-01-08 - 使用
TemporalAdjuster调整日期时间TemporalAdjuster让我们能够用更精细、灵活的方式操纵日期,不再局限于一次只能改变它的一个值。更多静态方法自行查阅Java APIimport static java.time.temporal.TemporalAdjuster.*; LocalDate date1 = LocalDate.of(2014, 3, 18); // 2014-03-18 LocalDate date2 = date1.with(nextOrSame(DayOfWeek.SUNDAY)); // 2014-03-23 LocalDate date3 = date2.with(lastDayOfMonth()); // 2014-03-31 // 如果没有找到符合要求的预定义TemporalAdjuster,创建一个该接口的实现即可 //(更妙的是,TemporalAdjuster是一个函数式接口,这使得Lambda有了用武之地)
解析和格式化日期/时间对象
java.time.format包就是特别为处理日期时间对象时作格式化以及解析而设计的,这个包中最重要的类是DateTimeFormatter。// 注意,这里format/parse都是日期时间类自己的方法,而不是formatter的 // 这跟SimpleDateFormat不一样,不要惯性思维 LocalDate date = LocalDate.of(2014, 3, 18); String s1 = date.format(DateTimeFormatter.BASIC_ISO_DATE); // 20140318 String s2 = date.format(DateTimeFormatter.ISO_LOCAL_DATE); // 2014-03-18 LocalDate date3 = LocalDate.parse("20140318", DateTimeFormatter.BASIC_ISO_DATE); LocalDate date4 = LocalDate.parse("2014-03-18", DateTimeFormatter.ISO_LOCAL_DATE); // 按照给定模式创建DateTimeFormatter DateTimeFormatter formatter = DateTimeFormatter.ofPattern("MM/dd/yyyy"); LocalDate date1 = LocalDate.of(2020, 2, 15); String formattedDate = date1.format(formatter); LocalDate date2 = LocalDate.parse(formattedDate, formatter);- 如果还需要更细粒度的控制,
DateTimeFormatterBuilder类提供了更复杂的格式器,如柔性解析(允许解析器使用启发式的机制去解析输入,不精确地匹配指定模式),详情自行查阅Java API - 重点中的重点是,和老的
java.util.DateFormat相比,所有的DateTimeFormatter实例都是线程安全的。所以,我们能够以单例模式创建其实例(如该类定义的那些BASIC_ISO_DATE等常量),并能在多个线程间共享这些实例
处理不同的时区和历法
新版java.time.ZoneId类是老版java.util.TimeZone的替代品,极大简化了时区的处理。跟其他日期时间API一样,ZoneId类也是无法修改的
- 使用时区
- 在
ZoneRules类中包含了40个时区实例。每个特定的ZoneId对象都有一个地区ID标识("区域/城市"格式),如ZoneId romeZone = ZoneId.of("Europe/Rome") - 一旦得到一个
ZoneId对象,你就可以将它与LocalDate、LocalDateTime或Instant对象整合起来,构造为一个ZonedDateTime实例(书中P275图12-1有助于理解LocalDate/LocalTime/LocalDateTime/ZoneId/ZonedDateTime之间的关系)LocalDate date = LocalDate.of(2014, Month.MARCH, 18); ZonedDateTime zdt1 = date.atStartOfDay(romeZone); LocalDateTime dateTime = LocalDateTime.of(2014, Month.MARCH, 18, 13, 45); ZonedDateTime zdt2 = dateTime.atZone(romeZone); Instant instant = Instant.now(); ZonedDateTime zdt3 = instant.atZone(romeZone); ZoneId联系了LocalDateTime和InstantLocalDateTime dateTime = LocalDateTime.of(2014, Month.MARCH, 18, 13, 45); Instant instantFromDateTime = dateTime.toInstant(romeZone); Instant instant = now(); LocalDateTime timeFromInstant = LocalDateTime.ofInstant(instant, romeZone);Instant类中新增toInstant/fromInstant方法联系了新旧API(Date和Instant)
- 在
第13章 默认方法
- Java 8中的接口可以通过静态方法和默认方法两种方式提供方法的代码实现
- 默认方法的开头以关键字
default修饰,方法体与常规的类方法一致// List default void sort(Comparator<? super E> c) { Collections.sort(this, c); } // Collection default Stream<E> stream() { return StreamSupport.stream(spliterator(), false); } - 默认方法的主要目标客户是类库的设计者,它的引入就是为了以兼容的方式解决像Java API这样的类库演进问题的
- 同时定义接口以及工具辅助类(companion class)是Java语言常用的一种模式(如
Collection之于Collections),工具类定义了与接口实例协作的很多静态方法。由于静态方法可以存在于接口内部了,因此你自己代码中的这些辅助类就没有了存在的必要,可以把这些静态方法转移到接口内部。 - 不同类型的兼容性:二进制级兼容、源码级兼容、函数行为兼容
- 函数式接口只包含一个抽象方法,默认方法是非抽象方法
- Java 8中的抽象类和接口之间的区别是什么呢?它们不是都能包含抽象方法和方法体的实现吗?
- 一个类只能继承一个抽象类,但可以实现多个接口
- 一个抽象类可以通过实例变量(即字段)保存状态,而接口不能有实例变量
- 关于继承的一些错误观点(P288)
- 继承不应成为一谈到代码复用就试图倚靠的万精油,有时可能引入不必要的复杂性
- 有些类被刻意声明为
final类型,避免发生这样的反模式,防止核心代码的功能被污染 - 有时声明为
final的类都会有其不同的原因和考虑,例如,String类被声明为final是因为我们不希望有人对这样的核心功能产生干扰
解决冲突
- 问题引入 Java语言中一个类只能继承一个父类但可以实现多个接口。随着默认方法的引入,有可能出现一个类继承了多个使用相同函数签名的方法。这种情况下,类会选择使用哪个方法?
- 解决问题的三条规则(若仍无法解决冲突,则编译器报错拒绝编译)
- 类或父类中显式声明的方法,其优先级高于所有的默认方法
- 若仍无法判断(即类自身和父类都没有提供显式声明),那么选择提供最具体实现(我的理解是:继承层级中的最低层)的默认方法的接口
- 若冲突依然无法解决(多个默认方法都同样具体,如菱形继承问题,
B extends A,C extends A,D implements B,C),只能在类中覆盖该默认方法并显式调用期望的方法// Java 8引入一种新的语法ClassX.super.method(...)作出显式选择 public class D implements B, C { void method() { C.super.method(); } }
第14章 Java模块系统
本章主要介绍Java 9引进的模块系统,而不是简单地将一堆包杂乱无章地堆在一起,从而通过关注点分离和信息隐藏,帮助创建易于理解的软件 因为眼下没有这方面的需求,所以这章简单翻了几页,了解也不是很深,以后有需要再来过一遍这章。大概的印象就是类似于JavaScript中的require和export之类机制(看来Java从函数式编程语言那儿淘了不少东西啊)
第5部分 提升Java的并发性
这部分探讨如何使用Java的高级特性构建并发程序,包括异步编程和反应式编程 这部分很重要,但暂时只是粗略看了几页,等到并发专场的时候再来仔细过一遍
第6部分 函数式编程以及Java未来的演进
这部分主要谈谈怎样用Java编写高效的函数式程序
第18章 函数式的思考
为什么要进行函数式编程
- 声明式编程
- 一般通过编程实现一个系统有两种思考方式:一种专注于如何实现(面向对象编程/命令式编程);一种更关注要做什么(声明式编程)
- 无副作用计算
- 副作用就是函数的效果已经超出了函数自身的范畴;从长远看,减少共享的可变数据结构能帮助你降低维护和调试程序的代价;可以考虑不可变对象
- 如果构成系统的各个组件都能遵守无副作用这一原则,该系统就能在完全无锁的情况下使用多核的并发机制,因为任何一个方法都不会对其他方法造成干扰
- 函数式编程实践了声明式编程和无副作用计算,这两个思想能帮助我们更容易地构建和维护系统
- 正如第1章谈到的,由于硬件(如多核)和程序员期望(如使用类数据库查询式的语言去操纵数据)的变化,促使Java的软件工程风格在某种程度上愈来愈向函数式的方向倾斜
什么是函数式编程
- 什么是函数式编程?最简化的回答:“它是一种使用函数进行编程的方式”。当谈论函数式时,我们其实暗指“像数学函数那样,没有副作用”。
- 引用透明性:没有可感知的副作用(不改变对调用者可见的变量、不进行I/O、不抛出异常)。换句话说,函数无论在何处、何时调用,如果使用同样的输入总能持续地得到相同的结果,那么就具备了函数式的特征。我们准则是,被称为函数式的函数或方法只能修改本地变量,它引用的对象都应该是不可修改的对象
递归和迭代
- 递归(recursion)是函数式编程特别推崇的一种技术,它能培养你思考要“做什么”的编程风格
- 通常而言,执行一次递归方式调用的开销要比迭代执行单一机器级的分支指令大不少。怎样写出产生
StackOverflowError的程序?递归 - 函数式语言提供了一种方法来解决内存消耗较大的问题,即尾调优化(tail-call optimization);坏消息是目前(2018年)Java还不支持这种优化
- 使用Java 8进行编程时,作者的建议是,应该尽量使用
Stream取代迭代操作,从而避免变化带来的影响;如果递归能让你以更精炼且不带任何副作用的方式实现算法,就应该用递归替代迭代 - 使用递归实现往往更易于实现、阅读和理解,大多数时候编程的效率比细微的执行时间差异重要得多
第19章 函数式编程的技巧
函数
- 一等函数是可以作为参数传递,可以作为结果返回,同时还能存储在数据结构中的函数
- 高阶函数是接受一个或多个函数作为输入参数或者返回另一个函数的函数。Java中典型的高阶函数包括
comparing、andThen、compose等 - 柯里化是一种帮助我们模块化函数、提高代码重用性的技术。它表示一种将一个带有n元组参数的函数转换成n个一元函数链的方法
不可变数据结构
(原书标题是“持久化数据结构”,感觉有点晦涩,从对上下文的理解,尤其是P417图19-4下方文字的介绍,改为更好理解的“不可变数据结构”)
- 函数式方法不允许修改任何全局数据结构或作为参数传入的结构,否则两次相同的调用就很可能产生不同的结果——这违背了引用透明性原则,我们也就无法将方法简单地看作由参数到结果的映射
- 既然禁止使用带有副作用的方法,如何更新变量呢?函数式编程的解决方案是:如果需要使用表示结果的数据结构,创建它的一个副本而不是直接修改现存的数据结构
Stream的延迟计算
- 由于各种原因,如实现时的效率考量,Stream的设计有一些局限,如无法声明一个递归的Stream,因为Stream仅能使用一次,一旦对Stream执行一次终端操作调用,它就永久地终止了
- 解决方案是延迟计算,具体细节需要看书中例子来体会
- Stream被刻意设计成具有延迟性的特点:Stream就像是一个黑盒,它接收请求生成结果;向一个Stream发起一系列操作请求时这些请求只是被一一保存起来,只有发起终端操作时才会实际地进行计算。
- 这种设计的显著优点是,对Stream进行多个操作时,Stream只需要遍历一次,而无需为每个操作遍历一次所有的元素(个人感觉其实有些类似餐馆点菜过程,服务员只是一一记下每个请求,只有在执行“下单”指令时才会实际地去执行这些请求)
- 延迟数据结构就是将诸如
Map<String, Object>类型的参数改为Supplier<Map<String, Object>>参数,达到按需创建的目的。不过延迟计算的性能也未必总是更好(因为额外执行函数式接口抽象方法的调用也有开销)。作者的建议是,如果它们能让程序设计更简单就尽量使用它们,如果会带来无法接受的性能损失就使用传统方式
杂项
- 函数每次调用都返回相同的结果,对于引用类型而言这就意味着同一个对象。因此,函数式编程通常不使用
==(引用相等),而是使用equal对数据结构值进行比较,即两个对象逻辑上没有差别,因此仍是函数仍是引用透明的 - 结合器是一种函数式思想,它指的是将两个或多个函数或数据结构进行合并
static <A, B, C> Function<A, C> compose(Function<B, C> g, Function <A, B> f) { // g(f(x)) return x -> g.apply(f.apply(x)); }
第20章 面向对象和函数式编程的混合:Java和Scala的比较
Java和Scala都是整合了面向对象编程和函数式编程特性的编程语言,它们都运行于JVM之上。Scala为函数提供了更佳丰富的特性,这方面比Java做得好。 目前暂时跳过了这章,毕竟要复习的东西太多了。。。 (无意中在网上看到一个说法:“这年头没学个Scala/Clojure都不好意思说自己会Java”,看来对这些JVM语言还是要有一些了解,触类旁通)
第21章 结论以及Java的未来
回顾Java 8的语言特性
- 行为参数化(Lambda以及方法引用)
- 流 集合到底有什么问题,以至于需要另起炉灶替换它们,或者说要通过一个类似却不同的概念Stream来增强它们?数据集越大,减少遍历数据集的次数就越重要。Stream API采用延迟算法将多个操作组成一个流水线,只通过单次遍历就可以一次性完成所有的操作
CompletableFutureJava 5就提供了Future接口,CompletableFuture对于Future的意义就像Stream之于CollectionOptional- 默认方法
Java 9
Java 9并没有增加新的语言特性,它的主要变化是对Java 8发起的工作做进一步的改善,增加了一些新方法。Java 9的重点是引入了新的模块系统
- 模块系统 模块系统从架构的角度改进了我们设计和实现应用的方式,清晰地界定了各个子部分的边界,并定义了它们之间交互的方式。 引入这样的变化,一个重要原因是我们希望能有更好、更严格的跨包的封装性,并且新的模块系统可以帮助我们将Java运行时切分成更细粒度的部分
- Flow API
Java 9对反应式流进行了标准化,基于pull模式的反应式背压协议能避免慢速消费者被一个或多个快速生产者压垮。Flow API包含四个核心接口——
Publisher、Subscriber、Subscription和Processor
Java 10
- 局部变量类型推断
Java的未来
-
Java泛型的局限性
- 局限1:传给泛型的参数只能是对象类型,而不能是基本类型;局限2:拆箱装箱影响性能
- Java 5初次引入泛型时,为了保持兼容性,使用了泛型多态的消除模式(erasure model of generic polymorphism)因此,Java中
ArrayList<String>和ArrayList<Integer>的运行时表示是相同的;而C#语言中,这两种类型的运行时表示本质上就是不同的,这种模型被称作泛型多态的具化模式(reified model of generic polymorphism),或简称具化泛型 - 显然我们期望的是具化泛型,它能更好地融合基本数据类型及其对应的对象类型。Java实现具化泛型的主要难点在于,它需要保持向后兼容性,并且这种兼容需要同时支持JVM,以及使用了反射且希望执行泛型清除的遗留代码
- 小知识点:对象类型
Void实际包含了一个值,它有且仅有一个null值
-
不变性 如果想在Java中实现真正的函数式编程,语言层面的支持必不可少,比如“不可变值”。
- 函数式编程对不修改现有数据结构有非常严格的要求,需确保无论对该字段本身直接的修改,还是对通过该字段能直接或间接访问到的对象的修改都不会发生。但现有关键字
final并未在真正意义上达到这一目标 - 不可变值体现了关于值的一个理念:变量值是不可修改的,只有变量(它们负责存储值)可以被修改,修改之后变量中存储的就变成了别的不可变值
- 函数式编程对不修改现有数据结构有非常严格的要求,需确保无论对该字段本身直接的修改,还是对通过该字段能直接或间接访问到的对象的修改都不会发生。但现有关键字
-
值类型(value type)
- 我们希望能在Java中引入值类型,因为函数式编程处理的不可变对象都不含引用特征。
- 我们希望基本数据类型可以作为值类型的特例,但又不要有Java当前的泛型消除模式,因为这意味着值类型不做装箱就不能使用泛型。
- 当前的问题:由于对象的消除模式,基本类型的对象版本对集合和Java泛型依然非常重要;然而由于它们继承自Object(并因此存在引用特征),这是我们不想要的。解决了这些问题中的任何一个就意味着解决了所有的问题
让Java发展得更快
- Java缓慢的发布速度已经无法使用语言快速发展的需要,这意味着一些小型的改动不得不等待那些大型的变更完成,才能跟随其一起整合到发布语言中,这听起来没什么道理
- Java的开发周期进行了调整,变成了六个月;每隔三年发布一个长期支持版本,对这种版本的支持会持续三年
- 作者预计函数式编程的思想及其影响在不久的将来还会继续引领者Java发展的方向
附录A 其他语言特性的更新
附录A讨论Java 8中尚未谈及的三个新语言特性:重复注解、类型注解和通用目标类型推断
注解
Java中,注解是一种使用附加信息装饰程序元素的机制。换句话说,它就像是一种语法元数据(syntactic metadata)。Java 8对注解机制的改进包括:
- 可以定义重复注解
@Repeatable(Authors.class) @interface Author { String name(); } @interface Authors { Author[] value(); } @Author(name = "Raoul") @Author(name = "Raoul") @Author(name = "Raoul") class Book { } - 可以为任何类型添加注解(Java 8之前,只有声明可以被注解)
@NotNull String name = person.getName(); List<@NotNull Car> cars = new ArrayList<>();
通用目标类型推断
Java 8对泛型参数的推断进行了增强
// 泛型方法签名
static <T> List<T> emptyList();
// Java 7
List<Car> cars = Collections.<Car>emptyList();
// Java 8中目标类型包括向方法传递的参数,因此不再需要提供显式的泛型参数
List<Car> cars = Collections.emptyList();
// 不再需要写类似Collectors.<Car>toList()这样的复杂代码
List<Car> cleanCars = dirtyCars.stream()
.filter(Car::isClean).collect(Collectors.toList());
附录B 其他类库的更新
集合
前文第8章已经提到过很多集合(Collection/Collections/List/Set/Map)中新增的一些方法。这里主要提一下Comparator接口的改变
- 新的实例方法
reversed——对当前的Comparator对象进行逆序排序并返回排序之后新的Comparator对象thenComparing——当两个对象相同时,返回使用另一个Comparator进行比较的Comparator对象thenComparingInt/thenComparingDouble/thenComparingLong
- 新的静态方法
comparing——返回一个Comparator对象,该对象提供了一个可以提取排序关键字的函数comparingInt/comparingDouble/comparingLongnatrualOrder——对Comparator对象进行自然排序,返回一个Comparator对象nullsFirst/nullsLast——对空对象和非空对象进行比较,指定null比non-null小或者大,返回一个Comparator对象reverseOrder——和natrualOrder().reversed()方法类似
并发
- 并行流
CompletableFuturejava.util.concurrent.atomic包中的类新增了更多的方法支持(getAndUpdate/updateAndGet/getAndAccumulate/accumulateAndGet)Adder/Accumulator多线程环境中,如果多个线程需要频繁地进行更新操作,且很少有读取的动作,Java API文档中推荐我们使用新的类(Long/Double)Adder/Accumulator,尽量避免使用它们对应的原子类型。这些新类在设计之初就考虑了动态增长的需求,可有效减少线程间的竞争ConcurrentHashMapConcurrentHashMap类极大提升了HashMap现代化的程度,它允许并发地进行新增和更新操作,因为它仅对内部数据结构的某些部分上锁,因此和同步式的Hashtable比较起来具有更高的读写性能- 为了改善性能,要对内部数据结构进行调整。Java 8中,当桶过于臃肿时会被动态地替换为有序树(sorted tree)
- 支持三种新的操作:
forEach/reduce/search,每种操作都支持4种形式,接受使用key、value、Map.Entry以及key/value对的函数 - 计数:提供了新方法
mappingCount,它返回long而不是像老方法size那样返回int。我们应尽量使用新的方法,因为它提供了更大的计数范围
Arrays
Arrays类现在支持并发操作了
parallelSort方法会以并发的方式对指定的数组进行排序setAll/parallelSetAll以顺序/并发方式对指定数组中所有元素进行设置parallelPrefix
Number和Math
Short、Integer、Long、Float和Double类提供了静态方法sum、min、max等reduce操作- 如果
Math中的方法在操作中出现溢出,Math类提供了新的方法可以抛出算数异常
Files
最重要的改变在于,我们现在可以用文件直接产生流,如lines、list、walk、find等。由于流是延迟消费的,因此数据量较大时这些方法很有用
Reflection
新增变化主要用于支持对于注解机制的几个变化(如重复注解)
String
新增静态方法join
String authors = String.join(", ", "Raoul", "Mario", "Alan");
附录C 如何以并发方式在同一个流上执行多种操作
附录C主要展示了通用API的一些高级用法,以及当语言暂时没有提供想要的特性时,如何有创意地实现曲线救国的
- Java 8中,流的设计有一个非常大(也可能是最大的)局限性:使用时对它操作一次仅能得到一个处理结果。但我们常常希望能同时获得多个结果,换句话说,希望一次性像流中传递多个Lambda表达式,最好还能以并发的方式执行这些操作并得到各自对应的结果
- 目前类似fork这样的复制流的特性并未在Java 8中实现。附录C利用通用API——
Spliterator,结合BlockingQueues和Futures来实现这一特性
附录D Lambda表达式和JVM字节码
附录D通过审视编译生成的.class文件,简要地讨论Java是如何编译Lambda表达式的(个人注:论理解JVM字节码的重要性)
- 匿名类的缺陷
- 编译器会为每个匿名类生成一个新的.class文件
文件名通常为
ClassName$1这种形式,生成大量类文件直接影响应用的启动性能 - 每个新的匿名类都会为类/接口产生一个新的子类型
- 编译器会为每个匿名类生成一个新的.class文件
文件名通常为
- 通过比较字节码文件发现,匿名类和Lambda表达式使用了不同的字节码指令(通过
javap -c -v ClassName查看字节码文件) - 使用匿名类的代码中,创建额外的类由
new指令完成;使用Lambda表达式的代码中,使用了invokedynamic指令 - 字节码指令
invokedynamic最初由JDK7引入,用于支持运行于JVM上的动态类型语言。执行方法调用时,invokedynamic添加了更高层的抽象,使得一部分逻辑可以依据动态语言的特征来决定调用目标 - 在Lambda表达式这个例子中,使用
invokedynamic指令可以将实现Lambda表达式的这部分代码的字节码生成推迟到运行时。效果类似如下伪代码所示public class LambdaDmo { Function<Object, String> f = [dynamic invocation of lambda$1] static String lambda$1(Object obj) { return obj.toString(); } } - 这种设计带来了一系列好处(p485~p486)