锐眼洞察 | Java 8 年度实践总结

作者:TalkingData架构师 赵志刚

本文为TalkingData原创,未经授权禁止转载。申请授权请在评论中留言联系!

 

Java 8 自 2014 年发布至今已有 3 个年头,但直到今年初才真正开始使用某些特性,之前最多是指定其为运行时环境。2017 年关将近,决定写篇文章记录一下近一年的实践历程,主要涵盖一下内容:

  • Lambda 表达式
  • Stream
  • 并发包
  • 集合

在开始引入新特性前,做个规划是少不了的,由于 SDMK 是微服务的架构,且每个服务支持灰度发布,因此影响全局的风险小很多,至于选择引入的服务模块,最终我选择了负责服务配置的 Console 和服务调用接入的 Gateway。

选择 Console 是因为它本身是基于 Java 8 的环境,但是随着演进,单元测试(UT)已经远远滞后,正好可以在 UT 中引入 Lambda 和 Stream,这样既不影响功能又可以踩坑,待熟悉后即可应用到功能开发或重构中。

选择 Gateway 是因为相比其他稳定的服务,Gateway 还有大量的新功能和性能重构需求。

接下来进入正题,以下内容涵盖了这一年的实践过程中用到的一些新特性总结。

1. Lambda 表达式

因为 Java 8 其他的特性几乎都是依赖 Lambda 实现的,因此本节会先介绍一下 Java 8 中的 Lambda 表达式,目的是为了让还不熟悉的同学能够对 Lambda 有个初步了解。

提起 Java 8 的特性,首先肯定是 Lambda。那么什么是 Lambda 表达式,我的理解就是一段程序代码,是引入函数式编程的一种实现,基于这样的特性,Java 实现了对行为的抽象。

让我们来看一个 Lambda 表达式:

  1. s-> System.out.println(s)

-> 右边是函数定义(描述行为的代码),左边是传递给这段代码的参数。接下来再看看具体调用:

  1. List<String> list = new ArrayList<>();
  2. …… //other operation
  3. list.forEach(-> System.out.println(s)); //打印集合中的每个元素

上述代码虽然没有实际作用,但是足以用来说明 Java 提供的函数式编程的特性了:

  • 减少模板代码,提高可读性:可以看到传递给 List 的 forEach 方法的是行为而不再是变量或对象(参数行为化),这样可以使开发人员只关注具体业务,不必再写循环或使用匿名类了。

  • 延迟执行:System.out.println 不是在传递给 forEach 的方法时立即执行,关于这点的优势在下面的并发包会有详解。

那么 Java 是怎么实现 Lambda 的执行呢?先来看一下 forEach 方法声明:

  1. public void forEach(Consumer<? super E> action)

它的参数是一个接口,但是 Oracle 的 JVM 并没有在编译时把 Lambda 表达式简单地变成匿名类,而是在字节码中使用了 InvokeDynamic 指令,Lambda 表达式只有在运行时才会动态被编译成字节码,参数和返回值都会从调用上下文中进行推断。

而且如果 Lambda 表达式是无状态的话,在内存中只会有一个对象对应该表达式。

上面简单介绍了一下 Lambda 表达式,在实际工作中我是如何使用的呢?举一个在 Console 中重构的例子,由于业务的需要,Console 中的代码存在大量校验逻辑,如下图

01.png

02.png

这些方法的逻辑除了生成和返回的业务实体不一样以外,其他完全一样,因此在重构后我提供了一个公共方法:

03.png

而原来的方法都重构为:

04.png

可以看到少了很多模板代码,清爽了许多,可读性也提高了,而且由于抽象到一个公共方法中,非常有利于代码及早进行 JIT 优化。

通过 Jacoco 的统计分析,在重构后,相比应用 Lambda 表达式之前,Console 的代码减少了 20% 左右。

2. Stream

Stream 是 Java 8 提供的另一个重要特性,为集合数据提供了一种基于流式的处理机制,从我实际使用的感受上来看,它屏蔽了数据集的底层处理细节,通过 Lambda 表达式让开发人员更多地关注业务处理而非迭代。下面是在 SDMK 中的几个 Stream 例子:

  1. counters.stream().mapToLong(-> NumberUtils.toLong(s, 0l)).sum();
  2. receipts.stream().filter(receipt ->MAIL_VALIDATOR.matcher(receipt).matches()).collect(Collectors.toList());
  3. assertFalse(records.stream().mapToInt(SimpleServiceEntity::getStatus).anyMatch(-> s != SERVICE_STATUS_ONSELL));

可以看到,没有了以往迭代的模板代码,通过 fluent 式的叠加完成数据的业务操作,使用起来都非常简单和直观。

如果需要对集合进行并行化处理,Java 还提供了创建或者转化为并行流的 API。通过将执行细节进行屏蔽,不仅简化了集合的处理代码,更重要的是,开发人员再也不用编写复杂易错的并行代码了,同时对于执行机制也可进行单独优化。

例如,现在使用 ForkJoinPool 执行并行流,将来可能会使用更高效的处理框架,但是这些对上层业务代码是透明的。

另外,在使用 Stream 时虽然简单,还需要几点注意:

  • 使用 Stream 时请摒弃循环或迭代的思想

  • 所有的操作不是按调用顺序分步执行的,而是延迟到流“启动”后同时执行的。比如上面实例 3,不是先对集合先映射完再查找匹配,可以理解之前都是在攒组合大招的每个招式(中间操作如 map,filter),直到最后一个方法才出招(终止操作如 sum),流一旦执行完毕(终止操作执行完)就不再可用了

  • Stream 的操作不会改变原始集合

  • 并行流的处理简单但不是没有代价,它要求所有操作都是无状态的或者是线程安全的,而且尽量与顺序无关(提高并行效率)

  • 虽然串行流和并行流可以随时互转(sequential () 和 parallel()),但是在流执行时的最终类型是以终止操作前最后一次指定的类型为准

3. 并发包

3.1 ConcurrentHashMap

Java 8 对 ConcurrentHashMap 大量增强,包括 long 型的 mappingCount 代表 map 的大小;使用树形结构取代链表,在 Key 实现了 Comparable 接口时更有效率;支持 Lambda 的原子操作等。

在实际使用时我用的较多的新特性是 computeIfAbsent,在 Java 8 之前如果要创建多个线程使用的缓存需要使用 putIfAbsent,但是比较繁琐,具体如下:

  1. public static AtomicLong createTableAuditInfo(String table) {
  2.    AtomicLong newAudit = new AtomicLong();
  3.    AtomicLong temp;
  4.    if ((temp = audits.putIfAbsent(table, newAudit)) == null) {
  5.      return newAudit;
  6.    } else {
  7.      return temp;
  8.    }
  9.  }

由于 putIfAbsent 操作的 Key 如果在 Map 中不存在时会返回 null,所以这个代码非常变扭,更重要的是每次执行的时候都不可避免地要创建一个新对象!(TDU 注:全文少有的感叹号表达了志刚同学对太多新对象的苦恼)

虽然可以在调用前用 get 判断,但如果 Key 不存在时还是会创建一批不需要的对象!(TDU 注:again,哪位缺对象的同学赶快来帮忙分担一下吧)在 Java 8 中可以使用如下代码:

06.png

一行代码搞定,而且由于创建对象的 Lambda 是延迟执行的,加上操作本身是原子的,所以就保证有且只有一个对象被创建。当然,在高并发下为了避免 computeIfAbsent 每次读时都会进入同步的机制,同样可以在调用前使用 get 进行一次判断。

3.2 ConcurrentHashSet?

这个一直就不存在,但是可以使用 ConcurrentHashMap 的 keySet 方法创建一个等价的 set,只不过在 Java 8 前创建的 set 没有 add 方法。

Java 8 增加了一个 keySet(V mappedValue)方法,这样用该方法创建的 set 就可以添加元素了。

注意的是用 keySet 创建的 set 不能独立存在,所有操作都最终反映到对应的 ConcurrentHashMap 上。

3.3 AtomicLong

主要用的是它提供的支持 Lambda 表达式的原子操作,如 getAndUpdate(LongUnaryOperator),getAndAccumulate 等,这样可以避免自旋使用 CAS 操作的模板代码。

3.4 LongAdder

AtomicLong 和 AtomicInteger 虽然支持原子操作,但是在高并发下更新由于需要不停的自旋重试,极大的降低性能,因此在 gateway 流控计数器中选用了 LongAdder,它的思想就是空间换时间,通过将数值分散在多个内存空间,减少并发修改的几率来提高性能。

当然这样做也有代价,就是 sum 和 sumThenReset 方法不保证是原子的,也就是在调用这些方法时如果存在并发写的话,可能会不符合操作预期。当然由于 Gateway 的流控计数器不需要精确的计数,因此在使用上没有问题。

与 LongAdder 类似的还有 LongAccumulator,它可以执行更通用的累进式运算。

4. 集合

在 Java 8 中,集合类除了支持 Stream 外,最大的增强就是提供了大量的支持 Lambda 表达式的方法了,在我实践的项目中用得最多的就是 forEach(Consumer),主要是用来避免使用迭代的模板代码。

5. 总结

在近一年的实践中,由于工作和时间的关系,我并未能尝试所有的特性,包括 Stream 中的收集方法(用于将流转换为其他类型,如 List)和 Collector,集合中大量支持 lambda 的方法,新的日期时间对象,以及其他(字符串、数字、文件等等)一些增强。

但就已经实践的内容来说,Java 8 提供的这些特性还是非常强悍与实用的,在提高编码效率和代码性能方面会非常有帮助,另外对于固有的设计模式,我想也会有一定的影响,例如策略模式、模板模式等等。

总之,放心拥抱 Java 8 的新特性吧,当然,如果是旧系统迁移,请做好单元测试!

发表评论

电子邮件地址不会被公开。 必填项已用*标注