当前位置: 首页 > 后端技术 > Java

全球视野看技术——Java多线程进化史

时间:2023-04-01 19:47:14 Java

作者:京东科技文涛全文6468字,语言通俗易懂。讲了多线程相关的知识体系,让你知其然,知其所以然。前言2022年9月22日,JDK19发布。这个版本最大的亮点就是对虚拟线程的支持。从此,轻量级线程家族又多了一位大将。虚拟线程让JVM摆脱了通过操作系统调度线程的束缚,JVM自己调度线程。事实上,早期Sun在Solaris操作系统的虚拟机中实现了JVM调度线程。基于其复杂性和可维护性的考虑,最终又回到了操作系统调度线程的模式。锦衣客从长安回来,昨日在城南动工新居。回首这段旅程,多线程的概念让人眼花缭乱,网上相关的解释也太多了,但总觉得缺少了全局的视野。为此,笔者系统梳理了Java关于多线程的演进史,希望能帮助大家掌握多线程知识。本文不讲的内容:1.不讲某些技术点的详细实现原理,不反汇编源码,不画图。如果您从本文中找到您感兴趣的概念和技术,您可以自行搜索。2.不谈支持并发的库和框架,比如Quasar、Akka、Guava等。本文讲的内容1.JDK多线程的演进史2.JDK中一些技术点的作用原理和背景演变过程,以及解决了哪些问题3.作者对一些技术点的看法,欢迎有不同意见的人在评论区讨论里程碑的老规矩,先上传一张统计表。梳理了以往JDK中线程相关的核心概念。这里打个可能不太恰当的比喻,多线程的演进可以映射到汽车上。多线程的演进经历了手动挡时代(JDK1.4及以下)、自动挡时代(JDK5-JDK18)、自动驾驶时代(JDK19及以后)。这个比喻只是告诉读者,JDK5可以用更舒服的姿势来控制多线程。JDK19之后突破了简单的舒适,给IO密集型服务的性能带来了质的飞跃。EraVersionReleaseTimeCoreConceptsManualJDK1.01996-01-23ThreadandRunnableManualJDK1.21998-12-04ThreadLocal,CollectionsAutomaticJDK1.5/5.02004-09-30阐明Java内存模型,引入和发布PackagesAutomaticJDK1.6/6.02006-12-11synchronized优化自动文件JDK1.7/7.02011-07-28Fork/Join框架自动文件JDK1.8/8.02014-03-18CompletableFuture,Stream自动文件JDK1.9/9.02014-09-08改进锁争用JDK102018-03-21线程-部分控制自动变速器JDK152020-09-15禁用和丢弃偏向锁自动驾驶JDK192022-09-22虚拟线程手动变速器时代JDK1.4及以下我称之为多线程“手动”文件”时代,也叫原生多线程时代,线程的运行还是比较原生的,没有可用的线程池,研发人员必须自己写工具,避免频繁创建线程造成的资源浪费,手动锁定共享资源.也正是在这个时代,酝酿出了很多优秀的多线程框架,其中最著名的一个被JDK5.0采用。JDK1.0Thread和Runnable1996年1月的JDK1.0版本从一开始就建立了Java最基本的线程模型,这样的线程模型在后续的修复中没有发生实质性的变化。据说是有继承性的好设计。抢占式和协作式是两种常见的进程/线程调度方法。操作系统非常适合使用抢占式方法来调度其进程。它将时间片分配给不同的进程。对于长期无响应的进程,它有能力抢占它的资源,甚至强制停止它。合作方式要求进程有意识地、主动地释放资源。在这种调度方式下,一个执行时间长的线程可能会导致其他所有需要CPU的线程“饿死”。Java热点虚拟机的调度方式是抢占式调用,所以Java语言一开始就采用抢占式线程调度。JDK1.0中创建线程的方式主要是继承Thread类或者实现Runnable接口。通过对象实例的start方法启动线程。需要并行处理的代码放在run方法中。线程之间的协同通信采用了简单粗暴的stop/resume/Suspend这样的方式。如何解释停止/恢复/暂停的概念?即主线程可以直接调用子线程的termination、pause、continue方法。如果你小时候用过随身听,上面有三个按钮,停止、暂停和继续。想象一下,您正在同时收听3部随身听。三个随身听是三个子线程,你是主线程。您可以随意控制这三个设备的启动和停止。这套机制有个致命的问题,就是容易出现死锁。原因是当线程A锁了一个资源还没有释放时,被主线程挂起(suspend方法不会释放锁)。这时,线程B要占用这个资源,只能等待线程A执行恢复操作(resume)释放资源,否则永远得不到,就会出现死锁。这个版本禁止了JDK1.2粗暴的stop/resume/suspend机制,取而代之的是wait/notify/sleep等多个线程协同动作。值得一提的是,在这个版本中,设计了原子对象AtomicityXXX,主要是为了解决i++非原子性的问题。ThreadLocal和Collections的加入增加了多线程使用的姿态。因为这两项技术,笔者称之为Java的涡轮增压时代。ThreadLocalThreadLocal是一种以无锁的方式实现多线程共享线程不安全对象的方案。不解决“银行账户或存货增减”等问题。它擅长使用具有“工具”属性的类,通过副本的方式安全地执行“工具”方法。典型的如SimpleDateFormat、libraryconnection等。值得一提的是它的设计非常巧妙。想象一下,如果让你去设计,一般简单的思路是:在ThreadLocal维护一个全局线程安全的Map,key是线程,value是共享对象。这种设计的一个缺点就是内存泄漏问题,因为随着越来越多的线程加入,Map会无限膨胀。如果要解决内容泄露,必须在线程结束时清理Map,这就需要加强GC能力。显然投入产出比不合适。所以ThreadLocal的设计就是让Map不被ThreadLocal持有,而是由Thread自己持有。键是ThreadLocal变量,值是值。每个Thread都放了里面用到的ThreadLoacl(当然这个设计还有其他的衍生问题,这里就不一一列举了,有兴趣的同学可以自行搜索)。CollectionsCollections实用类是在这个版本中设计的,它包装了一些线程安全的集合,比如SynchronizedList。在那个只有Hashtable、Vector、Stack等线程安全集合的时代,它的出现也具有划时代的意义。Collections工具的基本思想是,我帮你把线程不安全的集合打包成线程安全的,这样你就不用花很多时间升级改造你原来的代码了。您只需要在创建集合时使用我提供的方法初始化集合即可。与汽车的涡轮增压技术相比,在发动机排量不变的情况下,提高了发动机的功率和扭矩。Java的涡轮增压时代已经到来^_^自动变速器时代JDK5.0引入并封装了DougLea,中文名道格·利。他是美国大学老师,神级人物,J.U.C就是他一手创建的。在JDK1.5之前,我们只能使用synchronized来控制同步代码的并发访问。当时synchronized的性能没有优化,性能不好。控制线程只能使用Object的wait和notify方法。这时,DougLea向JCP提交了JSR-166提案。在提交JSR-166之前,DougLea已经用了三年多类似于J.U.C封装功能的代码。这些代码是J.U.C.的原型。J.U.C提供了原子对象、锁和工具集、线程池、线程安全容器等多种工具。研发人员可以灵活运用任何能力打造自己的产品。他们可以使用ReentrantLock搭建底层框架,可以直接使用现成的工具或者容器进行业务代码的编写。从历史的角度来看,2004年的J.U.C无疑被称为“尖端科技产品”,为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机制只能保证一个变量的原子操作,不能保证整个代码块的原子性。解决方案:比如需要保证三个变量的更新是原子的,就得用Synchronized。也可以考虑使用AtomicReference来包装多个变量,这样可以处理多个共享变量的情况。明确Java内存模型本版本的JDK重新明确了Java内存模型。在此之前,常见的记忆模型包括连续一致性记忆模型和提前发生模型。对于持续一致性模型,程序执行的顺序与代码显示的顺序完全一致。这对于优化指令执行的现代多核CPU来说很难保证。而且,顺序一致性的保证严重限制了JVM对代码的运行时优化。但是,该版本的JSR133规范规定的Happens-before使得执行指令的顺序变得灵活:在同一个线程中,根据代码执行的顺序(即代码语义的顺序),前一个操作先于后者。当对监视对象的解锁操作发生在对同一监视对象的后续锁定操作之前时,就会发生操作。对易失性字段的写入操作先于对该字段的后续读取操作。线程启动操作(调用线程对象的start()方法)在该线程上的任何其他操作之前线程中的所有操作在任何其他线程之前调用该线程上的join()方法如果操作A优先于B并且操作B优先于C,则操作A优先于C在内存分配上,每个线程的工作内存与主存分离,大量的空间给JVM优化指令的执行在线程中。可以将主存中的变量复制到线程的工作内存中单独执行。执行完后,可以在一定时间将结果刷回主存:但是,如何保证线程间数据的一致性呢?JLS(JavaLanguageSpecification)给出的方法是默认情况下无法保证任何时候的数据一致性,但是通过使用synchronized、volatile、final等增强语义的关键字,可以实现数据的一致性。JDK6.0的synchronized优化,作为“共和国长子”synchronized关键字,在5.0版本中被ReentrantLock压倒。这个版本要卷土重来,所以JDK6.0对锁做了一些优化,比如锁自旋、锁消除、锁合并、轻量级锁、偏置。本次优化是对“精细化管理”理念的诠释。优化前synchronized加锁的对象只有两种状态:无锁和有锁(重量级锁)。优化后锁有四种状态,级别从低到高:无锁、偏向锁、轻量级锁、重量级锁。这些状态随着比赛形势逐渐升级,但不能降级。目的是为了提高获取和释放锁的效率(作者认为其实太复杂了,JVM开发者望而却步)。这个优化让synchronized引以为豪,从此再也没有人敢说它的性能比ReentrantLock差。不过好戏还在后头,偏向锁在JDK15(─.─||)中被抛弃了。作者认为synchronized吃亏是因为它只是一个关键字,而JVM负责其底层动作。它依赖于JVM来“猜测”当应用程序被锁定时,什么样的姿势是舒服的。ReentrantLock不同。它把这个事情直接交给了程序员。如果你想要公平,使用公平锁。如果你想让你的不公平,那就使用不公平锁。设计层面是一种懒惰,但也是一种灵活。JDK7.0Fork/Join框架的诞生Fork/Join也是比较高级的产物。其核心竞争力在于支持递归的任务拆解和合并各个任务的结果。但这是一项既熟悉又陌生的技术。熟悉是因为它应用在各个地方,比如JDK8中要讲的CompletableFuture和Stream。甚至有人认为它很鸡肋。笔者的观点是,如果你是做业务需求相关的研发,那是鸡肋,因为基本用不到。数据量大的场景下,数据仓库有一套工具。其他场景可以用线程池代替;如果你是中间件框架写相关的研发,也不是鸡肋,可能会用到。在中国互联网上很少有人质疑这项技术,但在国外已经有人在讨论它了。有兴趣的可以直接跳转到Java中的Fork-Join框架坏了吗?这个版本JDK8.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的新特性,也可以理解为为了迎合新创建的概念tofunctions基于编程思想,可以在集合类上以Stream的形式实现函数式编程?Lambda表达式(lambdaexpression)是一种匿名函数,通过它可以更简洁高效的表达函数式编程这么多了有人说,Stream和多线程有什么关系?Stream中的底层并行方法是使用Fork/Join框架实现的。在《Effective Java》中,有相关建议“谨慎使用Stream并行”。原因是因为所有的并行都运行在一个共同的Fork/Join池中,一个运行异常的pipeline可能会损害其他不相关部分的性能。JDK9.0改进了锁竞争机制。锁争用限制了许多Java多线程应用程序的性能。新的锁争用机制提高了Java对象监视器的性能,并已通过各种基准测试(如Volano)进行了验证。该测试可以估计JVM的最终吞吐量。在实践中,新的锁争用机制在22个不同的基准测试中取得了优异的成绩。如果新机制能够在Java9中得到应用,应用的性能将得到极大的提升。简单的解释就是当多个线程存在争锁时,优化前:迟到的线程统一使用同一个标准进程等待锁。优化后:当JVM识别出一些优化场景后,会直接让迟到的线程进行“VIP通道”锁抢占。详细解释请参考:Contendedlocksexplained–aperformanceapproachReactiveStreams反应流(ReactiveStreams)是以非阻塞反压方式处理异步数据流的标准,提供了一套极简的接口、方法和协议描述必要的操作和实体。什么是非阻塞背压?背压是背压的简称。简单来说就是生产者向消费者推送数据。当消费者无法处理时,通知生产者。这时,生产者降低生产率。这种机制以阻塞方式实现最简单,即推送时直接返回压力数据。以非阻塞方式实现在提高性能的同时增加了设计的复杂性。PS:感觉backpressure这个词翻译不好,不能按字面理解。backpressure是不是更好一些^_^为了解决消费者在巨大的资源压力(pressure)下可能崩溃的问题,需要控制数据流的速度,也就是流量控制(flowcontrol)来防止数据流过快不会压倒目标。所以需要背压,也就是背压,生产者和消费者需要实现背压机制来互操作。这种背压机制的实现需要异步非阻塞。如果是同步阻塞,生产者必须等待消费者处理数据,这会导致性能问题。ReactiveStreams通过定义一组实体、接口和互操作方法,为实现非阻塞背压提供了一个标准。第三方遵循这个标准来实现具体的解决方案,如Reactor、RxJava、AkkaStreams、Ratpack等。JDK10线程本地控制Safepoint及其缺点:Safepoint是HotspotJVM中的一种停止所有应用程序的机制。为了做一些低级的工作,JVM必须停止世界来停止应用程序线程。但是你不能直接停止,而是给应用线程发送一个命令信号,告诉他你应该停止。这时,当应用线程执行到一个Safepoint点时,就会服从指令并做出响应。这就是它被称为安全点的原因。之所以加safe是为了强调JVM要做一些全局安全的事情,所以在这点上加了一个safe。全局安全问题包括:1.垃圾清理挂起2.代码去优化。3.刷新代码缓存。4.当类文件被重新定义时(Classredefinition,如hotupdate或instrumentation)。5.偏向锁撤销。6.各种调试操作(例如:死锁检查或堆栈跟踪转储等)。但是,所有线程都需要很长时间才能停在最近的安全点。停止所有线程不是鲁莽和武断的吗?为此,Java10引入了一种不需要停止所有线程的方法,即ThreadLocalHandshake。例如,可以在不停止所有线程的情况下处理以下场景:1.偏向锁取消。这个东西只需要停止单个线程就可以撤销偏向锁,不需要停止所有线程。2.减少不同类型的可服务性查询对整体VM延迟的影响,例如,在具有大量Java线程的VM上获取所有线程的堆栈跟踪可能是一个缓慢的操作。3.通过减少对信号的依赖来执行更安全的堆栈跟踪采样。4.使用所谓的非对称Dekker同步技术,通过与Java线程握手来消除一些内存障碍。比如在G1和CMS中使用的“条件卡标记代码”(conditionalcardmarkcode)将不再需要“内存屏障”这玩意。这样就可以优化G1发送的“writebarrier”,也可以删除试图绕过“memorybarrier”的分支。JDK15禁用并丢弃偏向锁为什么要丢弃偏向锁?以前偏向锁带来的性能提升,现在已经不是那么明显了。受益于偏向锁定的应用程序往往是使用早期Java集合API(JDK1.1)的程序,这些API(Hashtable和Vector)在每次访问时都是同步的。JDK1.2针对单线程场景引入了异步集合(HashMap和ArrayList),JDK1.5针对多线程场景引入了更高性能的并发数据结构。这意味着如果将代码更新为使用较新的类,则由于不必要的同步而受益于偏向锁定的应用程序可能会获得很大的性能提升。此外,围绕线程池队列和工作线程构建的应用程序通常在禁用偏向锁定的情况下表现更好。使用Hashtable和Vector的API实现如下:ZipOutputStream_usesVector_javax.management.timer.TimerMBean_接口上有Vector_AutopilotEra虚拟线程将Java带入了自动驾驶时代。很多语言都有类似“虚拟线程”的技术,比如Go、C#、Erlang、Lua等,他们称之为“协程”。这次java没有加入任何新的关键字,甚至是新的概念。与goroutine和协程相比,虚拟线程更容易理解。看名字大概就知道它是干什么的了。JDK19虚拟线程传统Java中的线程模型与操作系统1:1对应,创建和切换线程代价高昂,受操作系统限制,只能创建有限的数量。当并发量大的时候,不可能每个请求都创建一个线程。使用线程池可以缓解这个问题。线程池减少了创建线程的消耗,但是不能增加线程数。如果并发为2000,线程池只有1000个线程,那么同时只能处理1000个请求,还有1000个请求无法处理,可以拒绝或者等待一个线程放弃.之前的虚拟线程方案都是采用异步的方式。已经有很多框架实现了异步风格的并发编程(比如Spring5的Reactor),通过线程共享实现更高的可用性。原理是线程共享减少线程切换,减少消耗,同时避免阻塞。线程只在程序执行的时候使用,当程序需要等待的时候线程是不会被占用的。异步风格确实有很多改进,但也有缺点。大多数异步框架使用链式写法将程序分成很多步骤,每个步骤可能在不同的线程中执行。不能再使用熟悉的ThreadLocal等并发编程相关的API,否则可能会出错。编程风格也发生了很大变化。它比传统的编程风格复杂得多,学习成本高。可能需要对项目中已有的很多模块进行改造,以适应异步模式。虚拟线程的实现原理和一些异步框架类似,也是线程共享,当然不需要池化。在使用的时候,你可以认为虚拟线程是无限丰富的,想创建多少就创建多少,不用担心出问题。不仅如此,虚拟线程支持调试并得到Java相关监控工具的支持是非常重要的。虚拟线程会大大降低你程序的内存占用,所有IO密集型应用,比如WebServers,在同等硬件条件下,都可以大大提高IO吞吐量。原来1G内存可以同时承载1000次访问。使用虚拟线程后,按照官方说法,可以轻松应对100万并发。能不能支持具体的业务场景,要看压测,但是我们打个折扣,10万应该很容易实现,不需要付出任何代价,也许你甚至不需要改变代码。因为虚拟线程可以让你保持传统的编程风格,即request-a-thread模式,像线程一样使用虚拟线程,程序只需要改动很少。虚拟线程没有引入新的语法,可以说学习和迁移的成本极低。值得一提的是,虚拟线程底层依然使用了Fork/Join框架。