一、Java内存模型 == JVM内存模型?
很多人都会认为Java内存模型就是JVM内存模型,但实际上是错的,Java内存模型是一个抽象的概念,描述了Java语言的一组规则和规范,JVM实际上也不仅仅支持运行Java代码,还支持很多能在JVM上运行的语言如JRuby、Scale等,这是因为JRuby、Scale也有自己的语言规范,只要编译出来的字节码符合《Java虚拟机规范》,就可以在JVM上运行。
JVM不关心代码是用哪种编程语言写的,只要编译出来的指令码符合JVM规范,那么就可以在JVM上运行,所有语言在JVM上的内存的结构都是一样的,JVM上的内存模型图如下。
在JVM中,所有实例域、静态域和数组元素存储在堆内存中,堆内存在线程之间共享。局部变量、方法定义参数和异常处理器参数不会在线程之间共享,它们不会有内存可见性问题,也不会收内存模型的影响。
《Java语言规范》中对Java内存模型的描述,主要是针对 多线程程序的语义,包括当多个线程修改了共享内存中的值时,应该读取到哪个值的规则,这些语义没有规定如何执行多线程程序,相反它们描述了允许多线程程序的合法行为;所谓的“合法”,其实就是保证多线程对共享数据访问的可见性和修改的安全性。
二、Java内存模型基础
在并发编程中,需要处理的两个关键问题是:线程之前如何通信
和线程之间如何同步
。
1、通信
通信
是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存
和消息传递
。
在共享内存
的并发模型里,线程之间共享程序的公共状态,线程之间通过写-读内存中的公共状态
来隐式
进行通信。
在消息传递
的并发模型里,线程之间没有公共状态,线程之间必须通过明确的发送消息
来显式
进行通信。
2、同步
同步
是指程序用于控制不同线程之间操作发生相对顺序的机制。在共享内存
的并发模型里,同步是显式
进行的,程序员必须显式指定某个方法或某段代码需要在线程之间互斥执行
;在消息传递
的并发模型里,由于消息的发送必须在消息的接收之前,因此同步是隐式
进行的。
Java的并发采用的是共享内存模型,Java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。
3、Java内存模型的抽象
Java线程之间的通信由Java内存模型(JMM)控制。JMM决定了一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,JMM定义了线程与主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每一个线程都有一个自己私有的本地内存,本地内存中存储了该变量以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在,只是为了帮助理解。
从上图来看,如果线程A和线程B通信的话,要如下两个步骤:
(1)线程A需要将本地内存A中的共享变量副本刷新到主内存中
(2)线程B去主内存读取线程A之前已更新过的共享变量
【举个例子】本地内存A和B有主内存共享变量X的副本。假设一开始时,这三个内存中X的值都是0。线程A正执行时,把更新后的X值(假设为1)临时存放在自己的本地内存A中。当线程A和B需要通信时,线程A首先会把自己本地内存A中修改后的X值刷新到主内存去,此时主内存中的X值变为了1。随后,线程B到主内存中读取线程A更新后的共享变量X的值,此时线程B的本地内存的X值也变成了1。
整体看来,这两个步骤是指上是线程A在向线程B发送消息,而这个通信过程必须经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为Java程序员提供内存可见性保证。
4、重排序
在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序,重排序分三类:
(1)编译器优化的重排序
编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
(2)指令级并行的重排序
现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应及其指令的执行顺序。
(3)内存系统的重排序
由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
从Java源代码到最终实际执行的指令序列,会分别经历下面三种重排序:
注意,所有的重排都会遵循
as-if-serial语义
(详见 [Java多线程编程之四] CPU缓存和内存屏障),即重排后指令的对单线程来说跟重排前的指令的执行效果是一样的,但是该语义不能保证程序指令在多线程环境下重排后的指令执行效果跟重排前一致,所以就会导致可见性问题,所谓
可见性
问题,简单地说就是某个线程修改了某个变量的值,但是对另外一个线程来说,它感知不到这种变化,当程序运行用到这个变量时,用的还是旧值,就会导致程序运行结果跟我们预料的大相庭径。
上面的这些重排序都可能导致多线程程序出现内存可见性问题。
对于编译器,JMM的编译器重排序规则会禁止特定类型的编译器重排序(即可能导致程序可见性问题的重排序要禁止,但是不会禁止能优化程序执行效率并不影响程序执行结果正确性的重排序),如下所示:
A和B的初始值都是0,对于线程1和线程2来说,重排序都不会影响单线程的执行效果,但是如果两个线程并发操作A、B的值,则运行结果可能不一致,比如重排序前线程2给r1赋值的时候值为0,但是重排序后,赋值这个操作可能在线程1给B赋值1这步之后执行,此时对线程2来说赋值时B的值就是1了,而这种重排序就是JMM规范里要禁止的。
对于处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障指令,通过内存屏障指令来禁止特定类型的处理器重排序(同样不是所有的处理器重排序都要禁止)。
JMM属于语言级的内存模型,它确保在不同的编译器和不同的处理器平台之上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。
处理器重排序
现代的处理器使用写缓冲区来临时保存向内存写入的数据。写缓冲区可以保证指令流水线持续运行,它可以避免由于处理器停顿下来等待向内存写入数据而产生的延迟。同时,通过以批处理的方式刷新写缓冲区,以及合并写缓冲区中对同一内存地址的多次写,可以减少对内存总线的占用。虽然写缓冲区有这么多好处,但每个处理器上的写缓冲区,仅仅对它所在的处理器可见。这个特定会对内存操作的执行顺序产生重要的影响:处理器对内存的读/写操作的执行顺序,不一定与内存实际发生的读/写操作顺序一致。
示例如下:
假设处理器A和处理器B按程序的顺序并行执行内存访问,最终却可能得到
x = y = 0
。具体的原因如下图所示:
处理器A和B同时把共享变量写入在写缓冲区中(A1、B1),然后再从内存中读取另一个共享变量(A2、B2),最后才把自己写缓冲区中保存的脏数据刷新到内存中(A3、B3)。当以这种时序执行时,程序就可以得到x = y = 0的效果。
从内存操作实际发生的顺序来看,直到处理器A执行A3来刷新自己的写缓存去,写操作A1才算真正执行了。虽然处理器A执行内存操作的顺序为:A1 -> A2,但内存操作实际发生的顺序却是:A2 -> A1。此时,处理器A的内存操作顺序被重排序了。
这里的关键是,由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于现代的处理器都会使用写缓冲区,因此现代处理器都会允许对写-读操作重排序。
内存屏障指令
为了保证内存可见性,Java编译器在生成指令序列的适当位置会插入内存屏障指令来禁止特定类型的处理器重排序。
处理器提供了两个内存屏障指令:
(1)读内存屏障:在指令前插入Load Barrier,可以让高速缓存中的数据失效,强制重新从主内存加载数据,让CPU缓存与主内存保持一致,避免缓存导致的一致性问题。
(2)写内存屏障:在指令后插入Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见;当发生这种强制写入主内存的显式调用,CPU就不会处于性能优化考虑进行指令重排。
程序进行读写时,指令执行顺序可能有:读写,写读,读读,写写,所以JMM把内存屏障指令分为下列四类:
5、多线程编程中常见的问题
对于新手,多线程编程不是个容易上手的技术,对于经验丰富的老手,还时不时马失前蹄,都会遇到很多问题,正因为这些问题,所以《Java语言规范》要提出一些规则范式来解决这些问题,常见的问题主要有:
- 所见非所得
- 无法用肉眼去检测程序的准确性
- 不同的运行平台有不同的表现
- 错误很难重现
下面通过一个实例程序体会下这些问题:
public class Demo1Visibility {
int i = 0;
boolean isRunning = true;
public static void main(String[] args) throws InterruptedException {
Demo1Visibility demo = new Demo1Visibility();
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("hrer i am...");
while (demo.isRunning) {
demo.i++;
}
System.out.println(demo.i);
}
}).start();
Thread.sleep(3000L);
demo.isRunning = false;
System.out.println(demo.i);
System.out.println("shutdown...");
}
}
程序的逻辑很简单,有两个线程:主线程和匿名线程,程序启动时,主线程中会启动匿名线程,接着主线程休眠3秒,由于demo.isRunning
的初始值为true
,因此匿名线程中的while
循环会不断循环,直到主线程休眠结束后,将demo.isRunning
的值更新为false
,此时匿名线程的while
循环会结束,从而打印出demo.i
的值。
但是程序运行后,发现运行结果并不像预料的那样,程序并没有打印出i
的值,并且一直处于运行状态,很明显这是因为匿名线程没有感知到demo.isRunning
的变化,导致一直循环中,如下所示:
如果运行用的是32位的JDK,并且设置程序的启动VM参数
-client
(默认是
-server
),则运行结果如下:
对于上面的程序,经过试验可以知道,JDK、VM参数对程序运行的影响如下:
参数 | 32位JDK | 64位JDK |
---|---|---|
-server |
不打印i的值 | 不打印i的值 |
-client |
打印i的值 | 不打印i的值 |
从上面的示例可以看出,程序运行的结果不一定如我们预料的(所见非所得),在不同版本的JDK下运行有差异(不同的运行平台有不同的表现),使用不同的启动参数-client/-server
有不同的效果,无法肉眼检测程序的准确性,而这些问题就是《Java语言规范》提供的Java内存模型所要解决的问题,但是要注意Java内存模型并不会实际解决这些问题,更多的它是一种规范、规则,而JVM会去真正实现这些规则规范。
对于上面代码,在非32位JDK非-client
下,子线程都不能正常退出while
循环打印出i的值,我们结合上述讲到的Java内存模型的示意图来分析。
首先demo
是一个对象,对象是存储在堆内存中的,从运行时数据区的示意图可知,堆内存是所有线程共享的区域;程序中有主线程和子线程两个线程,每个线程在运行时JVM都会分配一块线程私有的内存块,我们称之为线程工作区
,线程工作区就会存储线程运行中需要的局部变量表、操作数栈、动态链接、返回地址等信息。主线程会修改demo.isRunning
的值为false
,会将修改内容写入到共享堆内存中,而子线程会去读取堆内存中的demo.isRunning
的值,来让程序退出while
循环。
当执行指令时,需要将其加载到CPU中,程序运行中需要存储一些变量,这些变量会保存在RAM内存中,所以线程工作区既分布在CPU也分布在RAM内存中。从[Java多线程编程之四] CPU缓存和内存屏障可知,由于内存读取写入操作的数据远远跟不上CPU运行的速度,所以在CPU和内存中间,有个高速缓存,内存把数据加载到高速缓存中,供CPU读取,当CPU要修改内存时,同样是要先写到高速缓存中,再由高速缓存同步到内存中,高速缓存协议又保证了内存中的数据被修改时同样也会同步到其他线程的高速缓存中,如图所示:
从上一节对重排序的介绍可知,主线程去写
data
时,不会马上写入内存中,而是先写入缓存再写入内存中,同步到内存中后,高速缓存协议会将修改同步到子线程的缓存中,这中间有一定的时延,所以子线程得过一段时间(这个时间其实很短,肉眼感受不到,但确实存在),按理说子线程应该在稍等一段时间后在
while
循环里读取到
data
的最新值,然后退出循环,打印
demo.i
的值,但是实际上却没有,这是怎么回事?
这里又不得不再次提到上面说到的编译优化重排序,在 [Java多线程编程之一] Java代码是怎么运行起来的?看完这篇你就懂了!中提到Java的解释执行和编译执行,解释执行指JVM在读取字节码执行时,是由执行引擎的解释器逐条将字节码翻译成机器可识别的指令,编译执行则是直接将一段字节码翻译成机器可以识别的指令码。
说起Java的编译执行就不得不提到JIT编译器(Just In Time Compiler)
,当Java程序中某个方法不断被调用(比如递归)或者某段代码不断被循环(比如while(true)
)时,调用执行的频率达到一定水平就会升级为热点代码,这时启动JIT编译
,直接将热点代码编译成机器码放到方法区中,当程序再次执行到这段热点代码时,直接从方法区中取机器指令执行,从而提升程序执行的效率,在JIT
编译时,会进行指令重排做性能优化,指令重排不仅仅是对执行指令的重排,程序的逻辑可能也会发生改变,比如上面子线程执行的循环体,可能被优化成下面的形式:
boolean f = demo.isRunning;
if (f) {
while (true) {
i++;
}
}
这相当于demo.isRunning
一开始就被缓存起来了,并且不会再次去读取它的值,这就导致了主线程修改了demo.isRunning
时,子线程感受不到,所以一直在循环执行i++
,上面提到的运行VM参数-client
、-server
属于JIT编译
的参数,影响指令优化重排的行为,所以在32位JDK下,设置不同的参数会有不同的效果。
问题的原因找到了,如何解决这个问题?很简单,定义isRunning
时用volatile
修饰即可,volatile
有禁止指令重排的效果,如下所示:
将使用
javap
编译字节码,可以看到对应的文本描述,从中可以看到对
isRunning
的描述多了一个标志
ACC_VOLATILE
从官方文档可以看出,加了
volatile
描述的字段是不能被缓存的,因此加了
volatile
描述的字段,被多个不同线程访问时,都是直接去内存中查找,而不会加一层缓存,这保证了可见性。
6、Volatile关键字
上面问题分析写了一堆,最终却出人意料地让一个volatile
关键字轻松解决,volatile
有什么魔力?
volatile
可以解决多线程环境中共享数据的可见性问题,即一个线程修改了共享变量时,其他线程马上能够感知到这种变化。
举个例子:
public class VolatileTest {
volatile long a = 1L; // 使用volatile声明的64位long型
public void set(long l) { // 单个volatile变量的写
a = l;
}
public long get() { // 单个volatile变量的读
return a;
}
public void getAndIncreament() {
a++; // 复合多个volatile变量的读/写
}
}
假设有多个线程分别调用上面程序的三个方法,则这个程序在语义上和下面的程序等价:
public class VolatileTest {
long a = 1L; // 64位的long型普通变量
public synchronized void set(long l) {
a = l; // 对单个普通变量的写用同一个锁同步
}
public synchronized long get() {
return a;
}
public void getAndIncreament() {
long temp = get();
temp += 1L;
set(temp);
}
}
如上面示例程序所示,对一个volatile
变量的单个读/写操作,与对一个普通变量的读/写操作使用同一个锁来同步,执行效果是相同的。
锁的语义决定了临界区代码的执行具有原子性,这意味着不管是什么类型的变量,只要它是volatile
变量,对该变量的读写就将具有原子性,但是这种原子性是对单个变量的操作而言的,如果是多个volatile
操作或类似于volatile++
这种复合操作,则其整体上不具有原子性。
再回到5、多线程编程中的程序案例,如果对demo.isRunning
修饰了volatile
,禁止指令重排,JIT
不会优化出下面这种形式的代码,程序读写时虽然也用到高速缓存,但是保证了多个线程对同个共享数据的同步可见。
boolean f = demo.isRunning;
if (f) {
while (true) {
i++;
}
}
总结起来:volatile
变量自身具有下列特性:
- 可见性:对一个
volatile
变量的读,总是能看到(任意线程)对这个volatile
变量最后的写入。 - 原子性:对任意单个
volatile
变量的读/写具有原子性,但类似于volatile++
这种复合操作不具有原子性。
(1)volatile写-读的内存定义
- 当写一个volatile时,JMM会把该线程对应的本地内存中的共享变量刷新到内存。
- 当读一个volatile时,JMM会把该线程对应的本地内存中的共享变量置为无效,线程接下来将从主内存中读取共享变量。
(2)volatile内存语义的实现
下面是JMM针对编译器制定的volatile重排序规则表:
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。
下面是基于保守策略的JMM内存屏障插入策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的前面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
下面是保守策略下,volatile写操作插入内存屏障后生成的指令序列示意图:
下面是保守策略下,volatile读操作插入内存屏障后生成的指令序列示意图:
上述volatile写操作和读操作的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写 - 读的内存语义,编译器可以根据具体情况省略不必要的屏障。
三、Java内存模型中的一些语义和规则
1、as-if-serial语义
不管怎么重排序(编译器和处理器为了提高并行度),单线程程序的执行结果不能被改变,编译器、runtime和处理器都必须遵循as-if-serial语义,也就是说,编译器和处理器不会对存在数据依赖关系的操作做重排序。
2、Shaerd Variables定义
可以在线程之间共享的内存称为共享内存或堆内存。所有实例字段、静态字段和数组元素都存储在堆内存中,这些字段和数组都是共享变量。
多个线程对共享变量的访问操作中,如果至少有一个访问时写操作,那么对同一个变量的两次访问时冲突的,访问顺序的不一样可能导致出现不同的结果。
3、线程间操作的定义
(1)线程间操作指:一个程序执行的操作可被其他线程感知或被其他线程直接影响。
(2)Java内存模型只描述线程间操作,不描述线程内操作,线程内操作按照线程内语义执行。
线程间操作有:
- 普通读
- 普通写
- volatile读
- volatile写
- Lock、Unlock:加锁解锁通常发生在多个线程对共享变量进行操作的同步。
- 线程的第一个和最后一个操作:简单说就是一个线程的启动和终止能被其他线程感知到。
- 外部操作:比如多个线程去访问DB,DB是外部的资源,所以叫外部操作,也是线程间操作。
4、同步规则的定义
(1) 对volatile变量v的写入,与所有其他线程后续对v的读同步
(2) 对于监视器m的解锁与所有后续操作对m的加锁同步
这里有两层语义:一层是加锁解锁操作是不能被重排序的;
另一层含义线程1拿到锁做了一些操作(比如修改了某个变量的值),接着解锁,接下来线程2拿到锁,此时线程2是可以感知到线程1在持有锁期间的操作。
(3)对于每个属性写入默认值(0,false, null)与每个线程对其进行的操作同步
对象创建时,JVM会根据对象属性类型等信息为其分配一块内存,由于内存中可能存在脏数据,所以JVM在创建对象时,会根据其属性类型初始化默认值,比如数字类型的初始化为0,布尔类型初始化为false,对象类型初始化为null,这个初始化的操作在线程访问对象之前完成,确保访问对象的线程不会看到“脏数据”(即没初始化之前的内存乱码)。
(4)启动线程的操作与线程中的第一个操作同步
这里同步的意思同样有两层含义,一层指线程要先被启动才能执行线程里的run方法;一层指启动线程的线程调用start()方法启动了线程,这个启动动作修改了被启动的线程状态,并且被启动线程能感知到这种状态的变化。
(5)线程T2的最后操作与线程T1发现线程T2已经结束同步(isAlive,join可以判断线程是否终结)
跟上面的规则差不多,就是T2线程操作结束时,线程状态会修改成Terminated,这个线程状态对其他线程是可见的。
(6)如果线程T1中断了T2,那么线程T1的中断操作与其他所有线程发现T2被中断了同步,通过抛出InterruptedException异常,或者调用Thread.interrupted或Thread.isInterrupted
线程T1调用t1.interrupted()来中断线程T1,本质是修改T1线程对象的interrupted属性的状态值为true,这个状态值对其他线程可见,其他线程发现线程T2中断肯定在线程T1中断T2之后发生
5、Happens-before先行发生原则
happens-before关系用于描述两个有冲突的动作之间的顺序,如果一个action happends before另一个action,则第一个操作被第二个操作可见,JVM需要实现如下happens-before规则:
(1)某个线程中的每个动作都happens-before该线程中该动作后面的动作
简单地说就是代码指令的顺序执行
(2)某个管程上的unlock动作happens-before同一个管程上后续的lock操作
先加锁后解锁,顺序不可调整
(3)对某个volatile字段的写操作happens-before每个后续对该volatile字段的读操作
(4)在某个线程对象上调用start()方法happens-before该启动线程中的任意动作
(5)如果在线程t1中成功执行了t2.join(),则t2中所有操作对t1可见
(6)如果某个动作a happens-before动作b,且b happens-before动作c,则有a happens-before c
比如线程1创建了下面的6、final在JMM中的处理
Demo2Final
类的对象,线程1对对象属性x、y的修改,只有x可以保证能被线程2读取到正确的版本3,因为x被
final
所修饰;但是y不一定能被读取到正确的构造版本,线程2读取y可能读到的是0。
public class Demo2Final {
final int x;
int y;
static Demo2Final f;
public Demo2Final() {
x = 3;
y = 4;
}
static void writer() { f = new Demo2Final(); }
static void reader() {
if (f != null) {
int i = f.x; // 一定会读到正确的构造版本
int j = f.y; // 可能会读到默认值0
System.out.println("i = " + i + ", j = " + j);
}
}
public static void main(String[] args) throws InterruptedException {
// Thread1 writer
// Thread2 read
}
}
Demo3Final
对象,在构造函数中,被声明为
final
的属性x先初始化,再用x来给y赋值,则y被初始化后的值也可以被线程2看到,而线程2可能依然看不到属性c正确的构造版本。
public class Demo3Final {
final int x;
int y;
int c;
static Demo3Final f;
public Demo3Final() {
x = 3;
// #### 重点语句 ####
y = x; // 因为x被final修饰了,所以可读到y的正确构造版本
c = 4;
}
static void writer() { f = new Demo3Final(); }
static void reader() {
if (f != null) {
int i = f.x;
int j = f.y;
int k = f.c;
System.out.println("i = " + i + ", j = " + j + ", k = " + k);
}
}
public static void main(String[] args) {
// Thread1 write
Demo3Final.writer();
// Thread2 read
}
}
7、Word Tearing字节处理
有些处理器(尤其是早期的Alphas处理器)没有提供写单个字节的功能。在这样的处理器上更新byte数组,若只是简单地读取整个内容,更新对应的字节,然后将整个内容再写回内存,将是不合法的。
这个问题有时候被称为“字分裂(word tearing)”,更新单个字节有难度的处理器,就需要寻求其他方式来解决问题。因此,编程人员需要注意,尽量不要对byte[]中的元素进行重新赋值,更不要在多线程程序中这样做。
如下图所示,展示了字分类的问题:
内存中存在数组[10, 2, 6, 9, 11, 23, 14],现在线程1要修改数组下标为4的元素值为10,线程2要修改数组下标为3的元素值为6。
但是由于处理器无法写单个字节,所以线程t1会拷贝整个数组的内容到线程内存中,对下标为4的元素进行赋值,再重新写回内存中去,线程t2也是类似操作,但是这里存在一个问题,由于对数组进行写操作是整个数组进行的,所以最后数组要么变成t1写入的数组,要么变成t2写入的数据,这两个都会到时另一个线程的修改被抹除掉了,如下所示:
8、double和long的特殊处理
由于《Java语言规范》的原因,对非volatile的double、long的单词写操作是分两次来进行的,每次操作其中32位,这可能导致第一次写入后,读取的值是脏数据,第二次写完成后,才能读到正确值。但是现在的JVM大都针对这点进行了优化,使得对double、long类型数据的操作都能以整体64位进行,保持原子性,防止读取的线程读到脏数据。
读写volatile修饰的long、double是原子性的。
商业JVM不会存在这个问题,虽然规范没要求实现原子性,但是考虑到实际应用,大部分都实现了原子性。
《Java语言规范》中说道:建议程序员将共享的64位值(long、double)用volatile修饰或正确同步其程序以避免可能的复杂的情况。