关于java:全局视角看技术Java多线程演进史

1次阅读

共计 9715 个字符,预计需要花费 25 分钟才能阅读完成。

作者:京东科技 文涛

全文较长共 6468 字,语言通俗易懂,是一篇具备纲要性质的对于多线程的梳理,作者从历史演进的角度讲了多线程相干常识体系,让你知其然知其所以然。

前言

2022 年 09 月 22 日,JDK19 公布了,此版本最大的亮点就是反对虚构线程,从此轻量级线程家族再添一员大将。虚构线程使 JVM 解脱了通过操作系统调度线程的解放,由 JVM 本身调度线程。其实晚期 sun 在 Solaris 操作系统的虚拟机中实现过 JVM 调度线程,基于其复杂性,和可维护性思考,最终都回归到了由操作系统调度线程的模式。

长安归来锦衣客,昨日城南起新宅。回忆这一路走来,对于多线程的概念令人烟花缭乱,网上相干解说也举不胜举,但总感觉短少一个全局性的视角。为此笔者系统性的梳理了 Java 对于多线程的演进史,心愿对你把握多线程常识有帮忙。

本文不讲什么:

1 不讲某些技术点的具体实现原理,不拆解源码,不画图,如果从本文找到了你感兴趣的概念和技术能够自行搜寻 2 不讲反对并发性的库和框架,如 Quasar、Akka、Guava 等

本文讲什么

1 讲 JDK 多线程的演进历史 2 讲演进中某些技术点的性能原理及背景,以及解决了什么问题 3 讲针对某些技术点笔者的认识,欢送有不同认识的人在评论区探讨

里程碑

老规矩,先上个统计表格。其中梳理了历代 JDK 中对于线程相干的外围概念。在这里,做一个可能不太失当的比喻,能够将多线程的演进映射到汽车上,多线程的演进别离经验了手动档时代(JDK1.4 及以下),自动档时代(JDK5-JDK18),主动驾驶时代(JDK19 及当前)。这个比喻只为了通知读者 JDK5 当前能够有更难受姿态的驾驭多线程,JDK19 当前更是冲破了单纯的难受,它给 IO 密集型服务的性能带来了质的飞跃。

时代 版本 公布工夫 外围概念
手动档 JDK1.0 1996-01-23 Thread 和 Runnable
手动档 JDK1.2 1998-12-04 ThreadLocal、Collections
自动档 JDK1.5/5.0 2004-09-30 明确 Java 内存模型、引入并发包
自动档 JDK1.6/6.0 2006-12-11 synchronized 优化
自动档 JDK1.7/7.0 2011-07-28 Fork/Join 框架
自动档 JDK1.8/8.0 2014-03-18 CompletableFuture、Stream
自动档 JDK1.9/9.0 2014-09-08 改善锁争用机制
自动档 JDK10 2018-03-21 线程 - 部分管控
自动档 JDK15 2020-09-15 禁用和废除偏差锁
主动驾驶 JDK19 2022-09-22 虚构线程

手动档时代

JDK1.4 及以下笔者称之为多线程“手动档”的时代,也叫原生多线程时代。线程的操作还绝对原生,没有线程池可用。研发人员必须手写工具防止频繁创立线程造成资源节约,手动对共享资源加锁。也正是这个时代酝酿了许多优良的多线程框架,最有名的被 JDK5.0 驳回了。

JDK 1.0 Thread 和 Runnable

1996 年 1 月的 JDK1.0 版本,从一开始就确立了 Java 最根底的线程模型,并且,这样的线程模型再后续的修修补补中,并未产生实质性的变更,能够说是一个具备传承性的良好设计。抢占式和合作式是两种常见的过程 / 线程调度形式,操作系统非常适合应用抢占式形式来调度它的过程,它给不同的过程调配工夫片,对于长期无响应的过程,它有能力剥夺它的资源,甚至将其强行进行。采纳合作式的形式,须要过程盲目、被动地开释资源,在这种调度形式下, 可能一个执行工夫很长的线程使得其余所有须要 CPU 的线程”饿死”。Java hotspot 虚拟机的调度形式为抢占式调用,因而 Java 语言一开始就采纳抢占式线程调度的形式。JDK 1.0 中创立线程的形式次要是继承 Thread 类或实现 Runnable 接口,通过对象实例的 start 办法启动线程,须要并行处理的代码放在 run 办法中,线程间的合作通信采纳简略粗犷的 stop/resume/suspend 这样的办法。

如何解释 stop/resume/suspend 的概念呢?就是主线程能够间接调用子线程的终止,暂停,持续办法。如果你小时候用过随身听,下面有三个按键,终止,暂停,持续。设想一下你正在同时听 3 个随身听,三个随身听就是三个子线程,你就是主线程,你能够随便管制这三个设施的启停。

这一套机制有个致命的问题,就是容易产生死锁,起因在于当线程 A 锁定了某个资源,还未开释时,被主线程暂停了(suspend 办法并不会开释锁),此时线程 B 如果想占有这个资源,只能期待线程 A 执行持续操作(resume)后开释资源,否则将永远得不到,产生死锁。

JDK 1.2

粗犷的 stop/resume/suspend 机制在这个版本被禁止应用了,转而采纳 wait/notify/sleep 这样的多条线程配合口头的形式。值得一提的是,在这个版本中,原子对象 AtomicityXXX 曾经设计好了,次要是解决 i ++ 非原子性的问题。ThreadLocal 和 Collections 的退出减少了多线程应用的姿态,因为这两项技术,笔者称它为 Java 的涡轮增压时代。

ThreadLocal

ThreadLocal 是一种采纳无锁的形式实现多线程共享线程不平安对象的计划。它并不能解决“银行账户或库存减少、扣减”这类问题,它善于将具备“工具”属性的类,通过复本的形式平安的执行“工具”办法。典型的如 SimpleDateFormat、库连贯等。值得一提的是它的设计十分奇妙,想像一下如果让你设计,个别的简略思路是:在 ThreadLocal 里保护一个全局线程平安的 Map,key 为线程,value 为共享对象。这样设计有个弊病就是内存泄露问题,因为该 Map 会随着越来越多的线程退出而有限收缩,如果要解决内容泄露,必须在线程完结时清理该 Map,这又得强化 GC 能力了,显然投入产出比不适合。于是,ThreadLocal 就被设计成 Map 不禁 ThreadLocal 持有,而是由 Thread 自身持有。key 为 ThreadLocal 变量,value 为值。每个 Thread 将所用到的 ThreadLoacl 都放于其中(当然此设计还有其它衍生问题在此不表,感兴趣的同学能够自行搜寻)。

Collections

Collections 工具类在这个版本被设计进去了,它包装了一些线程平安汇合如 SynchronizedList。在那个只有 Hashtable、Vector、Stack 等线程平安汇合的年代,它的呈现也是具备时代意义的。Collections 工具的根本思维是我帮你将线程不平安的汇合包装成线程平安的,这样你原有代码降级革新不用花很多工夫,只须要在汇合创立的时候用我提供办法初始化汇合即可。比拟像汽车的涡轮增压技术,在发动机排量不变的状况下,减少发动机的功率和扭矩。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;生疏在于咱们仿佛很少在业务研发过程中应用到它。

甚至有人甚至感觉它鸡肋。笔者的观点是,你如果是业务需要相干的研发,它是鸡肋的,因为根本用不到,少量数据量的场景无数仓那套工具,其它场景能够用线程池代替;如果你是中间件框架编写相干的研发,它不鸡肋,兴许会用到。中文互联网上很少有人质疑这项技术,但国外曾经有人在探讨,感兴趣的能够间接跳转查阅 Is the Fork-Join framework in Java broken?

JDK 8.0

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

CompletableFuture

网上对于 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 通道”式的锁抢占。

具体解释请参考:Contended locks explained – a performance approach

响应式流

响应式流 (Reactive Streams) 是一种以非阻塞背压形式解决异步数据流的规范,提供一组最小化的接口,办法和协定来形容必要的操作和实体。

什么叫非阻塞背压?背压是 back pressure 的缩写,简略讲,生产者给消费者推送数据,当消费者解决不动了,告知生产者,此时生产者降低生产速率,此机制应用阻塞的形式实现最简略,即推送时间接返回压力数据。非阻塞形式实现减少了设计的复杂度,同时进步了性能。PS: 感觉背压这个词翻译的不好,不能顾名思义。反压是不是更好 ^_^

为了解决消费者接受微小的资源压力 (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 要做一些全局的平安的事件了,所以给这个点加了个 safe。

全局的平安的事件包含以下: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 针对多线程场景推出了性能更高的并发数据结构。这意味着如果代码更新为应用较新的类,因为不必要同步而受害于偏差锁的应用程序,可能会看到很大的性能进步。此外,围绕线程池队列和工作线程构建的应用程序,性能通常在禁用偏差锁的状况下变得更好。

以下以应用了 Hashtable 和 Vector 的 API 实现:java.lang.Classloader _uses Vector_ java.util.Properties _extends Hashtable_ java.security.Provider _extends Properties_ java.net.URL _uses Hashtable_ java.net.URConnection _uses Hashtable_ java.util.ZipOutputStream _uses Vector_ javax.management.timer.TimerMBean _has Vector on the interface_

主动驾驶时代

虚构线程使 Java 进入了主动驾驶时代。很多语言都有相似于“虚构线程”的技术,比方 Go、C#、Erlang、Lua 等,他们称之为“协程”。这次 java 没有新增任何关键字,甚至没有新增新的概念,虚构线程比起 goroutine,协程,要好了解得多,看这名字就大略晓得它在做啥了。

JDK 19 虚构线程

传统 Java 中的线程模型与操作系统是 1:1 对应的,创立和切换线程代价很大,受限于操作系统,只能创立无限的数量。当并发量很大时,无奈为每个申请都创立一个线程。应用线程池能够缓解问题,线程池缩小了线程创立的耗费,然而也无奈晋升线程的数量。如果并发量是 2000,线程池只有 1000 个线程,那么同一时刻只能解决 1000 个申请,还有 1000 个申请是无奈解决的,能够回绝掉,也能够使其期待,直到有线程让出。

虚构线程的之前的计划是采纳异步格调。曾经有很多框架实现了异步格调的并发编程(如 Spring5 的 Reactor),通过线程共享来实现更高的可用性。原理是通过线程共享缩小了线程的切换,升高了耗费,同时也防止阻塞,只在程序执行时应用线程,当程序须要期待时则不占用线程。异步格调的确有不少晋升,然而也有毛病。大部分异步框架都应用链式写法,将程序分为很多个步骤,每个步骤可能会在不同的线程中执行。你不能再应用相熟的 ThreadLocal 等并发编程相干的 API,否则可能会有谬误。编程格调上也有很大的变动,比传统模式的编程格调要简单很多,学习老本高,可能还要革新我的项目中的很多已有模块使其适配异步模式。

虚构线程的实现原理和一些异步框架差不多,也是线程共享,当然也就不须要池化。在应用时你能够认为虚构线程是有限富余的,你想创立多少就创立多少,不用放心会有问题。不仅如此,虚构线程反对 debug,并且能被 Java 相干的监控工具所反对,这很重要。虚构线程会使你程序的内存占用大幅升高,所有 IO 密集型利用,比方 Web Servers,都能够在等同硬件条件下,大幅晋升 IO 的吞吐量。原来 1G 内存,同时能够 host 1000 个拜访,应用虚构线程后,依照官网的说法,能轻松解决 100 万的并发,具体到业务场景上是否撑持还要看压力测试,然而咱们打个折扣,10 万应该可能轻松实现,而你不须要为此付出任何的代价,可能连代码都不必改。因为虚构线程能够使得你放弃传统的编程格调,也就是一个申请一个线程的模式,像应用线程一样应用虚构线程,程序只须要做很少的改变。虚构线程也没有引入新的语法,能够说学习和迁徙老本极低。

值得一提的是虚构线程底层仍然应用了 Fork/Join 框架。

正文完
 0