文章目录
- [第12章 Java内存模型与线程](#第12章 Java内存模型与线程)
-
- 总结
- [12.0 个人感悟](#12.0 个人感悟)
- [12.1 概述](#12.1 概述)
- [12.2 硬件的效率与一致性](#12.2 硬件的效率与一致性)
- [12.3 Java内存模型](#12.3 Java内存模型)
-
- [12.3.1 主内存与工作内存](#12.3.1 主内存与工作内存)
- [12.3.2 内存间交互操作](#12.3.2 内存间交互操作)
- [12.3.3 对于volatile型变量的特殊规则](#12.3.3 对于volatile型变量的特殊规则)
- [12.3.4 针对long和double型变量的特殊规则](#12.3.4 针对long和double型变量的特殊规则)
- [12.3.5 原子性、可见性与有序性](#12.3.5 原子性、可见性与有序性)
- [12.3.6 先行发生原则](#12.3.6 先行发生原则)
- [12.4 Java与线程](#12.4 Java与线程)
-
- [12.4.1 线程的实现](#12.4.1 线程的实现)
- [12.4.2 Java线程调度](#12.4.2 Java线程调度)
- [12.4.3 状态转换](#12.4.3 状态转换)
- [12.5 Java与协程(了解)](#12.5 Java与协程(了解))
-
- [12.5.1 内核线程的局限](#12.5.1 内核线程的局限)
- [12.5.2 协程的复苏](#12.5.2 协程的复苏)
- [12.5.3 Java的解决方案------虚拟线程](#12.5.3 Java的解决方案——虚拟线程)
并发处理的广泛应用是Amdahl定律替代摩尔定律成为计算机性能发展源动力的根本原因,也是人类压榨计算机运算能力的最有力武器。
第12章 Java内存模型与线程
总结
第12章是《深入理解Java虚拟机》"高效并发"部分的开篇章节,从底层硬件讲到上层抽象,再到具体的线程实现,核心要点包括:
- 硬件的效率与一致性:CPU与内存的速度差催生了高速缓存和乱序执行,也带来了缓存一致性和指令重排序问题------这是并发编程复杂性的物理根源。
- Java内存模型:通过主内存与工作内存的抽象屏蔽硬件差异,通过8种原子操作定义内存交互协议。volatile保证了可见性和有序性但不保证原子性,synchronized同时保证了三大特性。
- 先行发生原则:JSR-133提出的Happens-Before规则是理解JMM的核心------它把底层复杂的协议抽象为几条直观的因果关系,程序员只需要关注"谁在谁之前发生"即可。
- Java线程实现:主流采用1:1内核线程模型,优点是实现简单、调度可靠,缺点是创建和切换成本高、数量受限。JDK 21引入的虚拟线程采用M:N模型,将调度权从操作系统收回JVM手中,为高并发应用提供了全新的轻量级解决方案。
12.0 个人感悟
1. 并发编程的复杂性不是故意设计得复杂,根源在于硬件受限。 以前觉得线程同步、volatile、锁这些概念是Java语言故意搞复杂了。读完这一章才明白,并发问题的根源根本不在于语言设计,而在于计算机硬件本身的物理限制------CPU比内存快好几个数量级,高速缓存带来了缓存一致性问题,处理器为了压榨性能又引入了乱序执行。Java内存模型本质上是在软件层面为程序员屏蔽这些硬件差异,让我们能用一套统一的规则写出正确同步的并发代码。理解了"为什么会有这些问题",比死记硬背"怎么解决这些问题"要通透得多。
2. Happens-Before是理解JMM的"第一性原理"。 以前看JMM相关文章,动不动就"主内存""工作内存""8种原子操作",记了一堆术语却依然不会分析并发问题。其实JSR-133最核心的贡献就是提出了Happens-Before规则------它把复杂的底层内存操作抽象成几条简单的因果关系,程序员只需要关注"谁在谁之前发生",就能判断代码是否线程安全。先理解Happens-Before,再反推主内存和工作内存的设计,才是学习JMM的正确打开方式。
3. 又关于技术债。 Java的主流线程实现采用内核线程1:1映射,创建和切换成本高、数量受限,这是Java并发编程长期以来的"阿喀琉斯之踵"。但这个选择在当时是合理的------1:1模型实现简单、调度交给操作系统、与现有系统兼容性好。只不过随着高并发场景的普及,这个历史包袱越来越重。理解这笔技术债的成因,才能真正理解为什么Project Loom和虚拟线程的诞生是Java并发编程领域的一场革命。
12.1 概述
在许多场景下,让计算机同时去做几件事情,不仅是因为计算机的运算能力强大了,还有一个很重要的原因是计算机的运算速度 与它的存储和通信子系统的速度差距太大,大量的时间都花费在磁盘I/O、网络通信或者数据库访问上。如果不希望处理器在大部分时间里都处于等待其他资源的空闲状态,就必须使用一些手段去把处理器的运算能力"压榨"出来,否则就会造成很大的性能浪费,而让计算机同时处理几项任务则是最容易想到、也被证明是非常有效的"压榨"手段。
另外一种更具体的并发应用场景就是服务端同时对多个客户端提供服务。衡量一个服务性能好坏的高低,每秒事务处理数(Transactions Per Second,TPS)是重要的指标之一,它代表着一秒内服务端平均能响应的请求总数,而TPS值与程序的并发能力有非常密切的关系。对于计算量相同的任务,程序线程并发协调得越有条不紊,效率自然就会越高;反之,线程之间频繁争用数据,互相阻塞甚至死锁,将会大大降低程序的并发能力。
Java语言和虚拟机提供了原生的、完善的多线程支持。服务端应用是Java语言擅长的领域之一,不过无论语言、中间件和框架如何先进,开发人员都不应期望它们能独立完成所有并发处理的事情,了解并发的内幕仍然是成为一个高级程序员不可缺少的课程。
12.2 硬件的效率与一致性
在正式介绍Java内存模型之前,有必要先理解物理计算机中的并发问题,因为物理机遇到的并发问题与虚拟机的情况有不少相似之处,物理机对并发的处理方案对虚拟机的实现也有相当大的参考意义。
高速缓存与缓存一致性
由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层或多层读写速度尽可能接近处理器运算速度的高速缓存来作为内存与处理器之间的缓冲:将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存中,这样处理器就无须等待缓慢的内存读写了。

在多路处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存,这种系统称为共享内存多核系统。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。为了解决一致性问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。
乱序执行优化
除了增加高速缓存之外,为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致。因此,如果存在一个计算任务依赖另外一个计算任务的中间结果,那么其顺序性并不能靠代码的先后顺序来保证。
Java内存模型就是在这样的硬件背景下诞生的------它需要在软件层面定义一套规则,让程序员在编写并发代码时不用关心底层硬件具体是怎么处理缓存一致性和指令重排序的。
12.3 Java内存模型
《Java虚拟机规范》中试图定义一种Java内存模型(Java Memory Model,JMM) 来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。在此之前,主流程序语言(如C/C++等)直接使用物理硬件和操作系统的内存模型,因此会由于不同平台上内存模型的差异,导致程序在一套平台上并发完全正常,而在另外一套平台上并发访问却经常出错,因此在某些场景下必须针对不同的平台来编写程序。
12.3.1 主内存与工作内存
Java内存模型规定了所有的变量都存储在主内存中。此处的主内存仅仅是虚拟机内存的一部分,与物理硬件的主内存名字一样,也可以类比,但两者不是同一回事。每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的数据。

不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成。
这里所讲的"主内存、工作内存"与第2章所讲的"Java内存区域中的Java堆、栈、方法区"并不是同一层次的内存划分,这两者基本上是没有关系的。如果两者一定要勉强对应起来,那么从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。从更基础的层次上说,主内存直接对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机(或者是硬件、操作系统本身的优化措施)可能会让工作内存优先存储于寄存器和高速缓存中,因为程序运行时主要访问读写的是工作内存。
12.3.2 内存间交互操作
关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,Java内存模型中定义了以下8种操作来完成。虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的(对于double和long类型的变量,load、store、read和write操作在某些平台上允许有例外)。
- lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
- unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存的变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用。
- load(载入):作用于工作内存的变量,它把read操作从主内存中得到的变量值放入工作内存的变量副本中。
- use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- store(存储):作用于工作内存的变量,它把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用。
- write(写入):作用于主内存的变量,它把store操作从工作内存中得到的变量的值放入主内存的变量中。
Java内存模型还规定了在执行上述8种基本操作时必须满足一系列规则,例如:不允许read和load、store和write操作之一单独出现;不允许一个线程丢弃它最近的assign操作;不允许一个线程无原因地把数据从工作内存同步回主内存中;一个新的变量只能在主内存中"诞生";一个变量在同一个时刻只允许一条线程对其进行lock操作等。
需要说明的是,最新的JSR-133文档中,已经放弃了采用这8种操作去定义Java内存模型的访问协议,缩减为4种(read、write、lock和unlock),但这仅是描述方式的改变,Java内存模型本身并没有改变。
12.3.3 对于volatile型变量的特殊规则
volatile是Java虚拟机提供的最轻量级的同步机制。当一个变量被定义为volatile之后,它将具备两项特性:
第一,保证此变量对所有线程的可见性。 这里的"可见性"是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。普通变量不能做到这一点,普通变量的值在线程间传递时均需要通过主内存来完成。例如,线程A修改了一个普通变量的值,然后向主内存回写,线程B在线程A回写完成之后再从主内存进行读取操作,新变量值才会对线程B可见。
但需要注意的是,Java里面的运算操作符并非原子操作,这导致volatile变量的运算在并发下一样是不安全的 。例如volatile int i = 0后执行i++,这个自增操作实际上是由多条字节码指令构成的复合操作,在多线程环境下仍然可能出错。
第二,禁止指令重排序优化。 普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。而volatile变量通过在赋值后插入内存屏障(Memory Barrier)来禁止指令重排序。
内存屏障的作用相当于一条分界线:重排序时不能把内存屏障后面的指令重排序到内存屏障之前的位置。在硬件层面,volatile变量的赋值后会多执行一个"lock addl $0x0,(%esp)"操作,这个操作相当于一个内存屏障,关键在于lock前缀,它会对处理器的缓存做一次"store和write"操作,让前面volatile变量的修改对其他处理器立即可见。
volatile的使用场景:由于volatile只能保证可见性和有序性,不能保证原子性,因此适合用于"一写多读"的场景,如状态标记、双重检查锁定的单例模式中的instance变量等。需要原子性保障的场景必须使用synchronized或原子类。
关于指令重排,很容易让人联想起单例模式的双锁检查,有兴趣可以看看设计模式学习(4) 23-1 单例模式
12.3.4 针对long和double型变量的特殊规则
Java内存模型要求lock、unlock、read、load、assign、use、store、write这8种操作都具有原子性,但是对于64位的数据类型(long和double),在模型中定义了一条相对宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行,即允许虚拟机实现自行选择是否要保证64位数据类型的load、store、read和write这4个操作的原子性,这就是所谓的long和double的非原子性协定(Non-Atomic Treatment of double and long Variables)。
不过,目前主流商业虚拟机几乎都选择把64位数据的读写操作作为原子操作来对待,因此在日常开发中,除非程序明确部署在32位虚拟机且对long/double的并发访问极其频繁,否则一般不需要刻意用volatile来修饰long和double变量。
12.3.5 原子性、可见性与有序性
Java内存模型是围绕着在并发过程中如何处理原子性、可见性和有序性这三个特征来建立的。
(1)原子性(Atomicity)
由Java内存模型来直接保证的原子性变量操作包括read、load、assign、use、store和write这6个。我们大致可以认为,基本数据类型的访问、读写都是具备原子性的(long和double的非原子性协定例外)。如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作,这两个字节码指令反映到Java代码中就是同步块------synchronized关键字,因此在synchronized块之间的操作也具备原子性。
(2)可见性(Visibility)
可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此。普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。因此可以说volatile保证了多线程操作时变量的可见性,而普通变量则不能保证这一点。
除了volatile之外,Java还有两个关键字能实现可见性,即synchronized和final。同步块的可见性是由"对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行store、write操作)"这条规则获得的。而final关键字的可见性是指:被final修饰的字段在构造器中一旦初始化完成,并且构造器没有把"this"的引用传递出去(this引用逃逸),那么在其他线程中就能看见final字段的值。
(3)有序性(Ordering)
Java内存模型的有序性在前面讲解volatile时也比较详细地讨论过了,Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指"线程内表现为串行的语义"(Within-Thread As-If-Serial Semantics),后半句是指"指令重排序"现象和"工作内存与主内存同步延迟"现象。
Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,而synchronized则是由"一个变量在同一个时刻只允许一条线程对其进行lock操作"这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。
12.3.6 先行发生原则
如果Java内存模型中所有的有序性都仅靠volatile和synchronized来完成,那么有一些操作将会变得很烦琐,但是我们在编写Java并发代码的时候并没有感觉到这一点,这是因为Java语言中有一个先行发生(Happens-Before) 原则。
先行发生是Java内存模型中定义的两项操作之间的偏序关系,如果说操作A先行发生于操作B,其实就是说在发生操作B之前,操作A产生的影响能被操作B观察到,"影响"包括修改了内存中共享变量的值、发送了消息、调用了方法等。
下面是Java内存模型下一些"天然的"先行发生关系,这些先行发生关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来,则它们就没有顺序性保障,虚拟机可以对它们随意地进行重排序:
- 程序次序规则:在一个线程内,按照控制流顺序,书写在前面的操作先行发生于书写在后面的操作。注意,这里说的是控制流顺序而不是程序代码顺序,因为要考虑分支、循环等结构。
- 管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是"同一个锁",而"后面"是指时间上的先后。
- volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作,这里的"后面"同样是指时间上的先后。
- 线程启动规则:Thread对象的start()方法先行发生于此线程的每一个动作。
- 线程终止规则:线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread::join()方法是否结束、Thread::isAlive()的返回值等手段检测线程是否已经终止执行。
- 线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread::interrupted()方法检测到是否有中断发生。
- 对象终结规则:一个对象的初始化完成(构造函数执行结束)先行发生于它的finalize()方法的开始。
- 传递性:如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
特别说明:两个操作之间具有Happens-Before关系,并不意味着前一个操作必须要在后一个操作之前执行!Happens-Before仅仅要求前一个操作(执行的结果)对后一个操作可见,且前一个操作按顺序排在第二个操作之前。
12.4 Java与线程
12.4.1 线程的实现
线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以独立调度。目前线程是Java里面进行处理器资源调度的最基本单位。
实现线程主要有三种方式:使用内核线程实现(1:1实现)、使用用户线程实现(1:N实现)、使用用户线程加轻量级进程混合实现(N:M实现)。
(1)使用内核线程实现
内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核支持的线程,这种线程由内核来完成线程切换,内核通过操纵调度器对线程进行调度,并负责将线程的任务映射到各个处理器上。
程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口------轻量级进程(Light Weight Process,LWP),轻量级进程就是我们通常意义上所讲的线程。由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型。
1:1模型的好处是实现简单,调度完全交给操作系统,线程阻塞不会影响整个进程。但其局限性也很明显:各种线程操作(创建、析构及同步)都需要进行系统调用,代价较高;每个轻量级进程都需要消耗一定的内核资源,因此系统支持轻量级进程的数量是有限的。
(2)使用用户线程实现
狭义上的用户线程(User Thread,UT)指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。
这种进程与用户线程之间1:N的关系称为一对多的线程模型。用户线程的优势在于不需要系统内核支援,操作可以非常快速且低消耗,也能够支持规模更大的线程数量。劣势也在于没有系统内核的支援,所有的线程操作都需要由用户程序自己去处理,诸如"阻塞如何处理""多处理器系统中如何将线程映射到其他处理器上"这类问题解决起来将会异常困难,甚至有些是不可能实现的。
(3)混合实现
将内核线程与用户线程一起使用的实现方式,集两者优点于一身。在这种混合模式下,既存在用户线程,也存在轻量级进程。用户线程还是完全建立在用户空间中,因此用户线程的创建、切换等操作依然廉价,而操作系统提供的轻量级进程则作为用户线程和内核线程之间的桥梁,使线程的调度及处理器映射能够得到内核的支持。
(4)Java线程的实现
Java线程在JDK 1.2之前是基于用户线程实现的,而在JDK 1.2中,线程模型被替换为基于操作系统原生线程模型来实现。目前主流商用虚拟机普遍采用1:1的线程模型,即以操作系统原生线程模型作为Java线程的实现基础。因此,在HotSpot虚拟机中,每个java.lang.Thread实例都直接对应一个操作系统的原生线程。
12.4.2 Java线程调度
线程调度是指系统为线程分配处理器使用权的过程,主要调度方式有两种,分别是协同式线程调度和抢占式线程调度。
协同式线程调度:线程执行的时间由线程本身控制,线程把自己的工作执行完成后,主动通知系统切换到另外一个线程上去。优点是实现简单,切换操作对线程自己是可知的,所以没有同步问题。缺点是如果有一个线程有问题一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。
抢占式线程调度 :每个线程将由系统来分配执行时间,线程的切换不由线程本身决定。在这种实现方式下,线程执行时间是系统可控的,也不会有一个线程导致整个进程阻塞的问题。Java使用的线程调度方式就是抢占式调度。
Java中可以通过设置线程优先级来调节线程的执行时间。不过线程优先级并不是太靠谱,原因是Java的线程是被映射到系统的原生线程上来实现的,交由操作系统来调度,虽然现在很多操作系统都提供线程优先级的概念,但是并不见得能与Java线程的优先级一一对应。
12.4.3 状态转换
Java语言定义了6种线程状态,在任意一个时间点,一个线程只能有且只有其中的一种状态:

| 状态 | 说明 |
|---|---|
| 新建(New) | 创建后尚未启动的线程处于此状态。 |
| 运行(Runnable) | 包括了操作系统线程状态中的Running和Ready,也就是处于此状态的线程有可能正在执行,也有可能正在等待着操作系统为它分配执行时间。 |
| 无限期等待(Waiting) | 处于这种状态的线程不会被分配处理器执行时间,它们要等待被其他线程显式唤醒。以下方法会让线程陷入无限期的等待状态:没有设置Timeout参数的Object::wait()方法、没有设置Timeout参数的Thread::join()方法、LockSupport::park()方法。 |
| 限期等待(Timed Waiting) | 处于这种状态的线程也不会被分配处理器执行时间,不过无须等待被其他线程显式唤醒,在一定时间之后它们会由系统自动唤醒。以下方法会让线程进入限期等待状态:Thread::sleep()方法、设置了Timeout参数的Object::wait()方法、设置了Timeout参数的Thread::join()方法、LockSupport::parkNanos()方法、LockSupport::parkUntil()方法。 |
| 阻塞(Blocked) | 线程被阻塞了,"阻塞状态"与"等待状态"的区别是:"阻塞状态"在等待着获取到一个排它锁,这个事件将在另外一个线程放弃这个锁的时候发生;而"等待状态"则是在等待一段时间,或者唤醒动作的发生。在程序等待进入同步区域的时候,线程将进入这种状态。 |
| 结束(Terminated) | 已终止线程的线程状态,线程已经结束执行。 |
12.5 Java与协程(了解)
12.5.1 内核线程的局限
如今Java Web服务端应用对于用户请求的响应,通常都会要求能够在一秒钟内执行上百万次计算,哪怕每次计算仅消耗1毫秒的处理器时间,而线程的创建、切换、销毁的成本也是相当高的。内核线程的调度成本主要来自于用户态和内核态之间的状态转换,而这两种状态的转换开销主要来自于响应中断、保护和恢复执行现场的成本。
12.5.2 协程的复苏
协程(Coroutine)是一种比线程更加轻量级的存在,它完全由程序自身控制(在用户态执行),不被操作系统内核所管理。协程可以看作是"用户态的线程",它的切换完全在用户空间完成,不需要陷入内核态,因此切换成本极低,并且可以支持比线程更大数量的并发任务。
12.5.3 Java的解决方案------虚拟线程
书中本节内容写于Project Loom尚未正式发布之时,因此使用的是"纤程"这一译名。如今,Project Loom已经正式落地为JDK 21中的虚拟线程(Virtual Threads)。
虚拟线程是JDK 21引入的一种轻量级用户线程,由JVM直接调度,具备以下核心特性:
-
M:N线程模型:大量虚拟线程可以复用在少量操作系统线程(称为平台线程)上执行,类似于Go语言的Goroutine。当虚拟线程遇到阻塞操作(如I/O)时,它会自动让出底层的平台线程,JVM调度器会将平台线程分配给其他虚拟线程。
-
极低的内存开销:传统平台线程的线程栈默认约1MB,而虚拟线程的栈默认仅约64KB,且支持动态扩容,极大降低了内存占用。
-
大规模并发能力:得益于极低的创建和切换成本,应用程序可以轻松创建数十万甚至上百万个虚拟线程来处理并发任务,特别适合I/O密集型的高并发场景。
-
编程模型不变 :虚拟线程仍然是
java.lang.Thread的子类,API与传统的平台线程几乎完全兼容。这意味着开发者无需学习新的并发编程范式,原有的"一个任务一个线程"的编程模型可以无缝迁移到虚拟线程上,代码可读性和可维护性都得到了保留。
使用示例:
java
// 创建虚拟线程的两种方式
Thread vThread = Thread.startVirtualThread(() -> {
// 处理并发任务
});
Thread vThread2 = Thread.ofVirtual()
.name("my-virtual-thread")
.start(() -> {
// 处理并发任务
});
适用场景与注意事项:
虚拟线程的核心优势在于处理大量I/O密集型并发任务(如Web服务器请求处理、数据库访问、远程服务调用等),它能让"一个请求一个线程"的传统编程模式在高并发下依然保持简洁。但对于CPU密集型任务(如大量数学计算),虚拟线程并不能带来性能提升,因为底层仍受限于有限的平台线程数量。
此外,使用虚拟线程时仍需注意线程安全问题------虚拟线程之间仍然存在竞争条件,需要使用锁、原子变量等机制来保证线程安全。
虚拟线程的正式发布,标志着Java并发模型从"以操作系统为中心的1:1模型"迈向了"以JVM为中心的M:N模型",是Java并发编程领域近20年来最重要的一次变革。