Java 内存模型与线程
概述
在许多场景下,让计算机同时去做几件事情,不仅是因为计算机的运算能力强大了,还有一个很重要的原因是计算机的运算速度与它的存储和通信子系统的速度差距太大,
大量的时间都花费在磁盘I/O、网络通信或者数据库访问上。如果不希望处理器在大部分时间里都处于等待其他资源的空闲状态,就必须使用一些手段去把处理器的运算能力“压榨”出来,
否则就会造成很大的性能浪费,而让计算机同时处理几项任务则是最容易想到,也被证明是非常有效的“压榨”手段。
衡量一个服务性能的高低好坏,每秒事务处理数(Transactions Per Second,TPS)是重要的指标之一,它代表着一秒内服务端平均能响应的请求总数。
对于计算量相同的任务,程序线程并发协调得越有条不紊,效率自然就会越高;反之,线程之间频繁争用数据,互相阻塞甚至死锁,将会大大降低程序的并发能力。
硬件的效率与一致性
物理机遇到的并发问题与虚拟机中的情况有很多相似之处,所以物理机对并发的处理方案对虚拟机的实现也有相当大的参考意义。
“让计算机并发执行若干个运算任务”与“更充分地利用计算机处理器的效能”之间的因果关系,看起来理所当然,实际上它们之间的关系并没有想象中那么简单,
其中一个重要的复杂性的来源是绝大多数的运算任务都不可能只靠处理器“计算”就能完成。处理器至少要与内存交互,如读取运算数据、存储运算结果等,
这个I/O操作就是很难消除的(无法仅靠寄存器来完成所有运算任务)。由于计算机的存储设备与处理器的运算速度有着几个数量级的差距,
所以现代计算机系统都不得不加入一层或多层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之间的缓冲:
将运算需要使用的数据复制到缓存中,让运算能快速进行,当运算结束后再从缓存同步回内存之中,这样处理器就无须等待缓慢的内存读写了。
基于高速缓存的存储交互很好地解决了处理器与内存速度之间的矛盾,但也带来了更高的复杂度,它引入了一个新的问题:缓存一致性(Cache Coherence)。
在多路处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存。当多个处理器的运算任务都涉及同一块主内存区域时,将可能导致各自的缓存数据不一致。
如果真的发生这种情况,那同步回到主内存时该以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,
在读写时要根据协议来进行操作,这类协议有MSI、MESI(Illinois Protocol)、MOSI、Synapse、Firefly及Dragon Protocol等。
图1-1 处理器、高速缓存、主内存间的关系
除了增加高速缓存之外,为了使处理器内部的运算单元能尽量被充分利用,处理器可能会对输入代码进行乱序执行优化,
处理器会在计算之后将乱序执行的结果重组,保证该结果与顺序执行的结果是一致的,但并不保证程序中各个语句计算的先后顺序与输入代码中的顺序一致。
与处理器的乱序执行优化类似,Java虚拟机的即时编译器中也有指令重排序(Instruction Reorder)优化。
Java内存模型
《Java虚拟机规范》中曾试图定义一种“Java内存模型”(Java Memory Model,JMM)来屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果。
在此之前,主流程序语言(如C和C++等)直接使用物理硬件和操作系统的内存模型。因此,由于不同平台上内存模型的差异,有可能导致程序在一套平台上并发完全正常,
而在另外一套平台上并发访问却经常出错,所以在某些场景下必须针对不同的平台来编写程序。
主内存和工作内存
Java内存模型的主要目的是定义程序中各种变量的访问规则,即关注在虚拟机中把变量值存储到内存和从内存中取出变量值这样的底层细节。
此处的变量(Variables)仅包括了实例字段、静态字段和构成数组对象的元素,不包括局部变量与方法参数,因为后者是线程私有的,不会存在竞争问题。
Java内存模型规定了所有的变量都存储在主内存(Main Memory)中。每条线程还有自己的工作内存(Working Memory,可与前面讲的处理器高速缓存类比),
线程的工作内存中保存了被该线程使用的变量的主内存副本(变量引用、访问到的字段)。
线程对变量的所有操作(读取、赋值等)必须在工作内存中进行,当线程赋值给字段时,会将值指定给位于工作内存中的工作拷贝(assign),
指定完成后工作拷贝的内容便会复制到主内存(store->write),由JVM决定何时复制。
Java值传递时,也是复制一份变量引用,所以在方法中对形参进行重新赋值,并不会影响到实参。因为实参、形参已经是两个单独的变量。
不同的线程之间也无法直接访问对方工作内存中的变量,线程间变量值的传递均需要通过主内存来完成,线程、主内存、工作内存三者的交互关系如图1-2。
图1-2 线程、主内存、工作内存交互关系
这里所讲的主内存、工作内存与Java内存区域中的Java堆、栈、方法区等并不是同一个层次的对内存的划分,这两者基本上是没有任何关系的。
如果两者一定要勉强对应起来,那么从变量、主内存、工作内存的定义来看,主内存主要对应于Java堆中的对象实例数据部分,而工作内存则对应于虚拟机栈中的部分区域。
从更基础的层次上说,主内存对应于物理硬件的内存,而为了获取更好的运行速度,虚拟机可能会让工作内存优先存储于寄存器和高速缓存中。
内存间交互操作
关于主内存与工作内存之间具体的交互协议,即一个变量如何从主内存拷贝到工作内存、如何从工作内存同步回主内存这一类的实现细节,Java内存模型中定义了6种操作来完成。
Java虚拟机实现时必须保证下面提及的每一种操作都是原子的、不可再分的(对于double和long类型的变量来说,read和write操作在某些平台上允许有例外)。
- lock(锁定):作用于主内存的变量,它把一个变量标识为一条线程独占的状态。
- unlock(解锁):作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定。
- read(读取):作用于主内存的变量,它把一个变量的值从主内存拷贝到线程的工作内存中。
- use(使用):作用于工作内存的变量,它把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用变量的值的字节码指令时将会执行这个操作。
- assign(赋值):作用于工作内存的变量,它把一个从执行引擎接收的值赋给工作内存的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作。
- write(写入):作用于主内存的变量,它把从工作内存中得到的变量的值放入主内存的变量中。
对于volatile型变量的特殊规则
关键字volatile是Java虚拟机提供的最轻量级的同步机制,但是它并不容易被正确、完整地理解。
当一个变量被定义成volatile之后,它将具备两项特性:
第一项是保证此变量对所有线程的可见性,这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的。
但被volatile修饰并不代表此变量就是线程安全的。如下例程序所示:
public class VolatileTest {
public static volatile int race = 0;
public static void increase() {
race++;
}
private static final int THREADS_COUNT = 20;
public static void main(String[] args) {
Thread[] threads = new Thread[THREADS_COUNT];
for (int i = 0; i < THREADS_COUNT; i++) {
threads[i] = new Thread(new Runnable() {
@Override
public void run() {
for (int i = 0; i < 10000; i++) {
increase();
}
}
});
threads[i].start();
}
// 等待所有累加线程都结束
while (Thread.activeCount() > 1) {
Thread.yield();
}
System.out.println(race);
}
}
这段代码发起了20个线程,每个线程对race变量进行10000次自增操作,如果这段代码能够正确并发的话,最后输出的结果应该是200000。
但运行完这段代码之后,并不会获得期望的结果,而且会发现每次运行程序,输出的结果都不一样,d但都是一个小于200000的数字。
问题就出在自增运算“race++”之中,这个操作并不是一个原子操作。当多个线程同时修改时,会存在前后覆盖的情况。
由于volatile变量只能保证可见性,在不符合以下两条规则的运算场景中,我们仍然要通过加锁(使用synchronized、java.util.concurrent中的锁或原子类)来保证原子性:
- 运算结果并不依赖变量的当前值,或者能够确保只有单一的线程修改变量的值。
- 变量不需要与其他的状态变量共同参与不变约束。
第二项是禁止指令重排序优化,普通的变量仅会保证在该方法的执行过程中所有依赖赋值结果的地方都能获取到正确的结果,而不能保证变量赋值操作的顺序与程序代码中的执行顺序一致。
有volatile修饰的变量,赋值后多执行了一个“lock addl$0x0,(%esp)”操作,这个操作的作用相当于一个内存屏障
(Memory Barrier或Memory Fence,指重排序时不能把后面的指令重排序到内存屏障之前的位置),只有一个处理器访问内存时,并不需要内存屏障;
但如果有两个或更多处理器访问同一块内存,且其中有一个在观测另一个,就需要内存屏障来保证一致性了。
这句指令中的“addl$0x0,(%esp)”(把ESP寄存器的值加0)显然是一个空操作,这里的关键在于lock前缀,查询IA32手册可知,
它的作用是将本处理器的缓存写入了内存,该写入动作也会引起别的处理器或者别的内核无效化(Invalidate)其缓存,这种操作相当于对缓存中的变量做了一次write操作。
所以通过这样一个空操作,可让前面volatile变量的修改对其他处理器立即可见。
针对long和double型变量的特殊规则
Java内存模型要求lock、unlock、read、assign、use、write这6种操作都具有原子性,但是对于64位的数据类型(long和double),
在模型中特别定义了一条宽松的规定:允许虚拟机将没有被volatile修饰的64位数据的读写操作划分为两次32位的操作来进行。
即允许虚拟机实现自行选择是否要保证64位数据类型的read和write这四个操作的原子性,这就是所谓的“long和double的非原子性协定”(Non-Atomic Treatment of double and long Variables)。
如果有多个线程共享一个并未声明为volatile的long或double类型的变量,并且同时对它们进行读取和修改操作,那么某些线程可能会读取到一个既不是原值,也不是其他线程修改值的代表了“半个变量”的数值。
原子性、可见性与有序性
-
原子性:指一个操作是不可中断的,要么全部成功,要么全部失败,在多线程执行时,一个线程原子操作开始,不会被其他线程所干扰。
Java中基本数据类型的访问、读写都是具备原子性的(例外就是long和double的非原子性协定)。
如果应用场景需要一个更大范围的原子性保证,Java内存模型还提供了lock和unlock操作来满足这种需求,尽管虚拟机未把lock和unlock操作直接开放给用户使用,
但是却提供了更高层次的字节码指令monitorenter和monitorexit来隐式地使用这两个操作。
这两个字节码指令反映到Java代码中就是同步块——synchronized关键字,因此在synchronized块之间的操作也具备原子性。 -
可见性:可见性就是指当一个线程修改了共享变量的值时,其他线程能够立即得知这个修改。Java内存模型是通过在变量修改后将新值同步回主内存,
在变量读取前从主内存刷新变量值这种依赖主内存作为传递媒介的方式来实现可见性的,无论是普通变量还是volatile变量都是如此。
普通变量与volatile变量的区别是,volatile的特殊规则保证了新值能立即同步到主内存,以及每次使用前立即从主内存刷新。
除了volatile之外,Java还有两个关键字能实现可见性,它们是synchronized和final。
同步块的可见性是由对一个变量执行unlock操作之前,必须先把此变量同步回主内存中(执行write操作)这条规则获得的。
而final关键字的可见性是指:被final修饰的字段在构造器中一旦被初始化完成,并且构造器没有把“this”的引用传递出去,那么在其他线程中就能看见final字段的值。
如如下代码所示,变量i与j都具备可见性,它们无须同步就能被其他线程正确访问。
public static final int i;
public final int j;
static {
i = 0;
// 省略后续动作
}
{
// 也可以选择在构造函数中初始化
j = 0;
// 省略后续动作
- 有序性:Java内存模型的有序性在前面讲解volatile时也比较详细地讨论过了,Java程序中天然的有序性可以总结为一句话:如果在本线程内观察,所有的操作都是有序的;
如果在一个线程中观察另一个线程,所有的操作都是无序的。前半句是指“线程内似表现为串行的语义”(Within-Thread As-If-SerialSemantics),
后半句是指指令重排序现象和工作内存与主内存同步延迟现象。Java语言提供了volatile和synchronized两个关键字来保证线程之间操作的有序性,volatile关键字本身就包含了禁止指令重排序的语义,
而synchronized则是由一个变量在同一个时刻只允许一条线程对其进行lock操作这条规则获得的,这个规则决定了持有同一个锁的两个同步块只能串行地进入。
Java与线程
线程的实现
线程是比进程更轻量级的调度执行单位,线程的引入,可以把一个进程的资源分配和执行调度分开,各个线程既可以共享进程资源(内存地址、文件I/O等),又可以独立调度。
目前线程是Java里面进行处理器资源调度的最基本单位,如果日后Loom项目能成功为Java引入纤程(Fiber)的话,可能就会改变这一点。
主流的操作系统都提供了线程实现,Java语言则提供了在不同硬件和操作系统平台下对线程操作的统一处理,每个已经调用过start()方法且还未结束的java.lang.Thread类的实例就代表着一个线程。
实现线程主要有三种方式:使用内核线程实现(1:1实现),使用用户线程实现(1:N实现),使用用户线程加轻量级进程混合实现(N:M实现)。
1 内核线程实现
使用内核线程实现的方式也被称为1:1实现。内核线程(Kernel-Level Thread,KLT)就是直接由操作系统内核(Kernel,下称内核)支持的线程,
这种线程由内核来完成线程切换,内核通过操纵调度器(Scheduler)对线程进行调度,并负责将线程的任务映射到各个处理器上。
每个内核线程可以视为内核的一个分身,这样操作系统就有能力同时处理多件事情,支持多线程的内核就称为多线程内核(Multi-Threads Kernel)。
程序一般不会直接使用内核线程,而是使用内核线程的一种高级接口——轻量级进程(LightWeight Process,LWP),轻量级进程就是我们通常意义上所讲的线程,
由于每个轻量级进程都由一个内核线程支持,因此只有先支持内核线程,才能有轻量级进程。这种轻量级进程与内核线程之间1:1的关系称为一对一的线程模型,如图1-3所示:
图1-3 轻量级进程与内核线程之间1:1的关系
由于内核线程的支持,每个轻量级进程都成为一个独立的调度单元,即使其中某一个轻量级进程在系统调用中被阻塞了,也不会影响整个进程继续工作。
轻量级进程也具有它的局限性:
- 由于是基于内核线程实现的,所以各种线程操作,如创建、析构及同步,都需要进行系统调用。而系统调用的代价相对较高,需要在用户态(User Mode)和内核态(Kernel Mode)中来回切换。
- 每个轻量级进程都需要有一个内核线程的支持,因此轻量级进程要消耗一定的内核资源(如内核线程的栈空间),因此一个系统支持轻量级进程的数量是有限的。
2 用户线程实现
使用用户线程实现的方式被称为1:N实现。广义上来讲,一个线程只要不是内核线程,都可以认为是用户线程(User Thread,UT)的一种,因此从这个定义上看,
轻量级进程也属于用户线程,但轻量级进程的实现始终是建立在内核之上的,许多操作都要进行系统调用,因此效率会受到限制,并不具备通常意义上的用户线程的优点。
而狭义上的用户线程指的是完全建立在用户空间的线程库上,系统内核不能感知到用户线程的存在及如何实现的。用户线程的建立、同步、销毁和调度完全在用户态中完成,不需要内核的帮助。
如果程序实现得当,这种线程不需要切换到内核态,因此操作可以是非常快速且低消耗的,也能够支持规模更大的线程数量,部分高性能数据库中的多线程就是由用户线程实现的。
这种进程与用户线程之间1:N的关系称为一对多的线程模型,如图1-4所示:
图1-4 进程与用户线程之间1:N 的关系
用户线程的优势在于不需要系统内核支援,劣势也在于没有系统内核的支援,所有的线程操作都需要由用户程序自己去处理。线程的创建、销毁、切换和调度都是用户必须考虑的问题,
而且由于操作系统只把处理器资源分配到进程,那诸如“阻塞如何处理”“多处理器系统中如何将线程映射到其他处理器上”这类问题解决起来将会异常困难。
Java、Ruby等语言都曾经使用过用户线程,最终又都放弃了使用它。近年来许多新的、以高并发为卖点的编程语言又普遍支持了用户线程,譬如Golang、Erlang等。
3 Java线程的实现:
Java线程在早期的Classic虚拟机上(JDK 1.2以前),是基于一种被称为“绿色线程”(Green Threads)的用户线程实现的,但从JDK 1.3起,
“主流”平台上的“主流”商用Java虚拟机的线程模型普遍都被替换为基于操作系统原生线程模型来实现,即采用1:1的线程模型。
以HotSpot为例,它的每一个Java线程都是直接映射到一个操作系统原生线程来实现的,而且中间没有额外的间接结构,所以HotSpot是不会去干涉线程调度的。
线程调度全由操作系统去处理,包括何时冻结或唤醒线程、该给线程分配多少处理器执行时间、该把线程安排给哪个处理器核心去执行等。
java线程调度
线程调度是指系统为线程分配处理器使用权的过程,调度主要方式有两种,分别是协同式(Cooperative Threads-Scheduling)线程调度和抢占式(Preemptive Threads-Scheduling)线程调度。
-
协同式调度的多线程系统,线程的执行时间由线程本身来控制,线程把自己的工作执行完了之后,要主动通知系统切换到另外一个线程上去。
协同式多线程的最大好处是实现简单,而且由于线程要把自己的事情干完后才会进行线程切换,切换操作对线程自己是可知的,所以一般没有什么线程同步的问题。
它的坏处也很明显:线程执行时间不可控制,甚至如果一个线程的代码编写有问题,一直不告知系统进行线程切换,那么程序就会一直阻塞在那里。
Windows 3.x系统就是使用协同式来实现多进程多任务的,那是相当不稳定的,只要有一个进程坚持不让出处理器执行时间,就可能会导致整个系统崩溃。 -
抢占式调度的多线程系统,那么每个线程将由系统来分配执行时间,线程的切换不由线程本身来决定。譬如在Java中,有Thread::yield()方法可以主动让出执行时间,
但是如果想要主动获取执行时间,线程本身是没有什么办法的。在这种实现线程调度的方式下,线程的执行时间是系统可控的,也不会有一个线程导致整个进程甚至整个系统阻塞的问题。
Java使用的线程调度方式就是抢占式调度。虽然说Java线程调度是系统自动完成的,但我们可以设置线程优先级别来给系统提供调度建议。
Java一共设置了10个级别的线程优先级(Thread.MIN_PRIORITY至Thread.MAX_PRIORITY)。在两个线程同时处于Ready状态时,优先级越高的线程越容易被系统选择执行。
Java与协程
在Java时代的早期,Java语言抽象出来隐藏了各种操作系统线程差异性的统一线程接口,这曾经是它区别于其他编程语言的一大优势。
在此基础上,涌现过无数多线程的应用与框架,譬如在网页访问时,HTTP请求可以直接与Servlet API中的一条处理线程绑定在一起,以“一对一服务”的方式处理由浏览器发来的信息。
时至今日,这种便捷的并发编程方式和同步的机制依然在有效地运作着,但是在某些场景下,却也已经显现出了疲态,如微服务高并发。
内核线程的局限
现代B/S系统中一次对外部业务请求的响应,往往需要分布在不同机器上的大量服务共同协作来实现,这种服务细分的架构在减少单个服务复杂度、增加复用性的同时,
也不可避免地增加了服务的数量,缩短了留给每个服务的响应时间。这要求每一个服务都必须在极短的时间内完成计算,这样组合多个服务的总耗时才不会太长;
也要求每一个服务提供者都要能同时处理数量更庞大的请求,这样才不会出现请求由于某个服务被阻塞而出现等待。
Java目前1:1的内核线程模型,映射到操作系统上的线程天然的缺陷是切换、调度成本高昂,系统能容纳的线程数量也很有限。
在每个请求本身的执行时间变得很短、数量变得很多的前提下,用户线程切换的开销甚至可能会接近用于计算本身的开销,这就会造成严重的浪费。
java解决方案
OpenJDK在2018年创建了Loom项目,这是Java用来应对微服务高并发场景的官方解决方案。Loom项目背后的意图是重新提供对用户线程的支持,
但与过去的绿色线程不同,这些新功能不是为了取代当前基于操作系统的线程实现,而是会有两个并发编程模型在Java虚拟机中并存,可以在程序中同时使用。