京东技术

全局视角看技术-Java多线程演进史

JDK 5.0

引入并发包

Doug Lea,中文名为道格·利。是美国的一个大学教师,大神级的人物,J.U.C就是出自他之手。JDK1.5之前,控制程序并发访问同步代码只能使用synchronized,那个时候synchronized的性能还没优化好,控制线程也只能使用Object的wait和notify方法。这个时候Doug Lea给JCP提交了JSR-166的提案,在提交JSR-166之前,Doug Lea已经使用了类似J.U.C包功能的代码三年多,这些代码就是J.U.C的原型。
J.U.C提供了原子化对象、锁、工具套装、线程池及线程安全容器等几大类工具。研发人员可灵活的使用任意能力搭建自己的产品,进可使用ReentrantLock搭建底层框架,退可直接使用现成的工具或容器进行业务代码编写。站在历史的角度去看,J.U.C在2004年毫无争议可以称为“尖端科技产品”。为Java的推广立下了汗马功劳。Java的自动档时代到来了,就好比自动档的汽车降低司机的门槛一样,J.U.C大大降低了程序员使用多线程的门槛,是个开创时代的产品。
当然J.U.C同样存在瑕疵,比如:

CPU开销大:如果自旋CAS长时间地不成功,则会给CPU带来非常大的开销。

解决方案:在JUC中有些地方就限制了CAS自旋的次数,例如BlockingQueue的SynchronousQueue。

ABA问题:如果一个值原来是A,变成了B,然后又变成了A,在CAS检查时会发现没有改变,但实际它已经改变,这就是ABA问题。大部分情况下ABA问题不会影响程序并发的正确性。

解决方案:每个变量都加上一个版本号,每次改变时加1,即A —> B —> A,变成1A —> 2B —> 3A。Java提供了AtomicStampedReference来解决。AtomicStampedReference通过包装[E,Integer]的元组来对对象标记版本戳(stamp),从而避免ABA问题。

只能保证一个共享变量原子操作:CAS机制所保证的只是一个变量的原子性操作,而不能保证整个代码块的原子性。

解决方案:比如需要保证3个变量共同进行原子性的更新,就不得不使用Synchronized了。还可以考虑使用AtomicReference来包装多个变量,通过这种方式来处理多个共享变量的情况。

明确Java内存模型

此版本的JDK重新明确了Java内存模型,在这之前,常见的内存模型包括连续一致性内存模型、先行发生模型。对于连续一致性模型来说,程序执行的顺序和代码上显示的顺序是完全一致的,这对于现代多核且指令执行优化的CPU来说,是很难保证的。而且,顺序一致性的保证将JVM对代码的运行期优化严重限制住了。
但是此版本JSR 133规范指定的先行发生(Happens-before)使得执行指令的顺序变得灵活:
在同一个线程里面,按照代码执行的顺序(即代码语义的顺序),前一个操作先于后面一个操作发生;对一个monitor对象的解锁操作先于后续对同一个monitor对象的锁操作 ;对volatile字段的写操作先于后面的对此字段的读操作;对线程的start操作(调用线程对象的start()方法)先于这个线程的其他任何操作;一个线程中所有的操作先于其他任何线程在此线程上调用 join()方法 如果A操作优先于B,B操作优先于C,那么A操作优先于C。
而在内存分配上,将每个线程各自的工作内存从主存中独立出来,更是给JVM大量的空间来优化线程内指令的执行。主存中的变量可以被拷贝到线程的工作内存中去单独执行,在执行结束后,结果可以在某个时间刷回主存。
但是,怎样来保证各个线程之间数据的一致性?JLS(Java Language Specification)给的办法就是,默认情况下,不能保证任意时刻的数据一致性,但是通过对synchronized、volatile和final这几个语义被增强的关键字的使用,可以做到数据一致性。

JDK 6.0 synchronized优化

作为“共和国长子”synchronized关键字,在5.0版本被ReentrantLock压过了风头。这个版本必须要扳回一局,因此JDK 6.0对锁做了一些优化,比如锁自旋、锁消除、锁合并、轻量级锁、所偏向等,本次优化是对“精细化管理”这个理念的一次诠释。
没优化之前被synchronized加锁的对象只有两个状态:无锁,有锁(重量级锁)。优化后锁一共存在4种状态,级别从低到高依次是:无锁、偏向锁、轻量级锁、重量级锁。这几个状态随着竞争的情况逐渐升级,但是不能降级,目的是为了提高获取锁和释放锁的效率,作者认为其实过于复杂、容易使JVM研发人员望而却步。
这一次优化让synchronized扬眉吐气,自此再也不允许别人说它的性能比ReentrantLock差。但偏向锁在JDK 15被废弃了,作者认为synchronized吃亏在了它只是个关键字,JVM负责它底层的动作,到底应用程序加锁的时候什么样的姿势舒服,得靠JVM“猜”。而ReentrantLock就不同了,它将这件事直接交给程序员去处理,希望公平那就用公平锁、反之就用非公平锁。设计层面算是一种偷懒,但同时也是一种灵活。

JDK 7.0 Fork/Join框架

Fork/Join的诞生也是一个比较先进的产品,它的核心竞争力在于,支持递归式的任务拆解,同时将各任务结果进行合并。但它是一个既熟悉又陌生的技术,熟悉在于它被应用到各种地方,比如接下来JDK8.0要讲的CompletableFuture和Stream;陌生在于实际似乎很少在业务研发过程中使用到它。
甚至有人觉得鸡肋。作者的观点是,对于业务需求相关的研发,基本用不到,因为大批数据量的场景有数仓工具,其它场景可以用线程池代替;如果作为中间件框架编写相关的研发,兴许会用到。

JDK 8.0

此版本的发布对于Java来说是划时代的,以至于现在全世界在运行的Java程序里,此版本占据了一半以上。但多线程相关的更新不如JDK5.0那么具有颠覆性。此版本除了增加了一些原子对象之外 ,最亮眼的便是以下两项更新。

Completable Future

关于CompletableFuture相关介绍很多,大多是讲原理及怎么用。但是作者始终不明白一个问题:为什么在有那么多线程池工具的情况下,还会有CompletableFuture的出现,它解决了什么痛点?它的核心竞争力到底是什么?
作者认为CompletableFuture的核心竞争力是任务编排。CompletableFuture继承Future接口特性,可以进行并发执行任务特性,但这些能力都是有可替代性的。但它的任务编排能力无可替代,它的核心API中包括了构造任务链,合并任务结果等都是为了任务编排而设计的。所以JDK之所以在此版本引入此框架,主要是解决业务开发中越来越痛的任务编排需求。
CompletableFuture底层使用了Fork/Join框架实现。

Stream

《架构整洁之道》里曾提到有三种编程范式,结构化编程(面向过程编程)、面向对象编程、函数式编程。Stream是函数式编程在Java语言中的一种体现,作者认为,初级程序员向中级进阶的必经之路就是攻克Stream,初次接触Stream会特别不适应,但如果熟悉以后将打开一个编程方式的新思路。作为研发人员经常混淆三个概念,函数式编程、Stream、Lambda表达式,作者对于概念的理解如下:
•函数式编程是一种编程思想,各种编程语言中都有该思想的实践;
•Stream是JDK8.0的一个新特性,也可以理解新造了个概念,目的就是迎合函数式编程这种思想,通过Stream的形式可以在集合类上实现函数式编程;
•Lambda 表达式(lambda expression)是一个匿名函数,通过它可以更简洁高效的表达函数式编程。
那么Stream和多线程的关系是什么?Stream中的相关并行方法底层是使用了Fork/Join框架实现的。《Effective Java》中有一条相关建议“谨慎使用Stream并行”,理由就是因为所有的并行都是在一个通用的Fork/Join池中运行的,一个pipeline运行异常,可能损害其他不相关部分性能。

JDK 9.0

改善锁争用机制

锁争用限制了许多Java多线程应用性能,新的锁争用机制改善了Java对象监视器的性能,并得到了多种基准测试的验证(如Volano),这类测试可以估算JVM的极限吞吐量。实际中, 新的锁争用机制在22种不同的基准测试中都得到了出色的成绩。如果新的机制能在Java 9中得到应用的话, 应用程序的性能将会大大提升。
简单的解释就是当多个线程发生锁争用时,优化之前:晚到的线程统一采用相同的标准流程进行锁等待。优化后:JVM识别出一些可优化的场景时直接让晚到的线程进行“VIP通道”式的锁抢占。

响应式流

响应式流(Reactive Streams)是一种以非阻塞背压方式处理异步数据流的标准,提供一组最小化的接口,方法和协议来描述必要的操作和实体。
什么叫非阻塞背压?背压是back pressure的缩写,简单讲,生产者给消费者推送数据,当消费者处理不动了,告知生产者,此时生产者降低生产速率,此机制使用阻塞的方式实现最简单,即推送时直接返回压力数据。非阻塞方式实现增加了设计的复杂度,同时提高了性能。
为了解决消费者承受巨大的资源压力(pressure)而有可能崩溃的问题,数据流的速度需要被控制,即流量控制(flow control),以防止快速的数据流不会压垮目标。因此需要反压即背压(back pressure),生产者和消费者之间需要通过实现一种背压机制来互操作。实现这种背压机制要求是异步非阻塞的,如果是同步阻塞的,消费者在处理数据时生产者必须等待,会产生性能问题。
响应式流(Reactive Streams)通过定义一组实体,接口和互操作方法,给出了实现非阻塞背压的标准。第三方遵循这个标准来实现具体的解决方案,常见的有Reactor、RxJava、Akka Streams、Ratpack等。

JDK 10 线程-局部管控

Safepoint及不足:
Safepoint是Hotspot JVM中一种让所有应用程序停止的一种机制。JVM为了做一些底层的工作,必须要Stop The World,让应用线程都停下来。但不能粗暴的直接停止,而是会给应用线程发送个指令信号告诉它该停下了。此时应用线程执行到一个Safepoint点时就会听从指令并响应。这也是为什么叫Safepoint。之所以加safe,是强调JVM要做一些全局的安全的事情。
全局安全相关的事情包括以下:
1、垃圾清理暂停;2、代码去优化(Code deoptimization);3、flush code cache;4、类文件重新定义时(Class redefinition,比如热更新 or instrumentation);5、偏向锁的取消(Biased lock revocation);6、各种debug操作(比如:死锁检查或者stacktrace dump等)。
然而,让所有线程都到就近的safepoint停下来本身就需要较长的时间。而且让所有线程都停下来显得太过鲁莽和专断。为此Java10就引入了一种可以不用stop all threads的方式,就是线程-局部管控(Thread Local Handshake)。
比如以下是不需要stop所有线程就可以完成的场景:
1、偏向锁撤销。这个事情只需要停止单个线程就可以撤销偏向锁,而不需要停止所有的线程;
2、减少不同类型的可服务性查询的总体VM延迟影响,例如获取具有大量Java线程的VM上的所有线程的stack trace可能是一个缓慢的操作;
3、通过减少对信号(signals)的依赖来执行更安全的stack trace采样;
4、使用非对称Dekker同步技术,通过与Java线程握手来消除一些内存障碍。例如,G1和CMS里使用的“条件卡标记码”(conditional card mark code),将不再需要“内存屏障”。这样的话,G1发送的“写屏障(write barrier)”就可以被优化, 并且那些尝试要规避“内存屏障”的分支也可以被删除。

JDK 15 禁用和废弃偏向锁

为什么要废弃偏向锁?偏向锁在过去带来的的性能提升,在现在看来已经不那么明显了。受益于偏向锁的应用程序,往往是使用了早期 Java 集合 API的程序(JDK 1.1),这些 API(Hashtable 和 Vector) 每次访问时都进行同步。JDK 1.2 引入了针对单线程场景的非同步集合(HashMap 和 ArrayList),JDK 1.5 针对多线程场景推出了性能更高的并发数据结构。这意味着如果代码更新为使用较新的类,由于不必要同步而受益于偏向锁的应用程序,可能会有很大的性能提高。此外,围绕线程池队列和工作线程构建的应用程序,性能通常在禁用偏向锁的情况下变得更好。