Java内存模型图解
在并发编程中,需要处理两个关键:线程之间如何通信及线程之间如何同步(这里的线程是指并发只晓得活动实体)。通信
是指线程之间以何种机制来交换信息。在命令式编程中,线程之间的通信机制有两种:共享内存和消息传递。
在共享内存的并发模型里,线程之间共享程序的公共状态,通过写-读内存中的公共状态进行隐式通信。在消息传递的并发模
型里,线程之间没有公共状态,线程之间必须通过发送消息来显示进行通信。
同步是指程序中用于控制不同线程间操作发生相对顺序的机制。在共享内存并发模型里,同步是显示进行的,程序员必须显
示指定某个方法或某段代码需要在线程之间互斥执行。在消息传递的并发模型里,由于消息的发送必须在消息的接收之前,
因此同步是隐式进行的。
Java的并发采用的是共享内存模型,java线程之间的通信总是隐式进行,整个通信过程对程序员完全透明。
在Java中,所有实例域、静态域和数组元素都存储在堆内存中,堆内存在线程之间共享。局部变量,方法定义参数和异常处
理器参数不会在线程之间共享,他们不会有内存可见性问题,也不受内存模型的影响。
Java线程之间的通信由Java内存模型控制,JMM决定一个线程对共享变量的写入何时对另一个线程可见。从抽象的角度来看,
JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存中,每个线程都有一个私有本地内存,本地内存
中存储了该线程以读/写共享变量副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存、写缓冲区、寄存器
以及其他的硬件和编译器优化。
从源代码到指令序列的重排序
在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分3中类型
1)编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
2)指令级并行的重排序。现代处理器采用了指令级并行技术来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改
变语句对应机器指令的执行顺序。
3)内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。
由于写缓冲区仅对自己的处理器可见,它会导致处理器执行内存操作的顺序可能会与内存实际的操作执行顺序不一致。由于
现代的处理器都会使用写缓冲区,因此现代的处理器都会允许对写-读操作进行重排序。
happens-before简介
在JMM中,如果一个操作执行的结果需要对另一个操作可见,那么这两个操作之间必须要存在happens-before关系。这里提到
的两个操作既可以是在一个线程之内,也可以是在不同线程之间。
重排序
重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
happens-before
happens-before是JMM最核心的概念。对应java程序员来说,理解happens-before是理解JMM的关键。
在并发编程中,多个线程之间采取是你机制进行通信(信息交换),什么机制进行数据的同步?
在java语言中,采用的是共享内存模型来实现多线程之间的信息交换和数据同步的。
线程之间通过共享程序公共的状态,通过读-写内存中公共状态的方式来进行隐式的通信。同步指的是程序在I控制多个线程
之间执行程序的相对顺序的机制,在共享内存模型中,同步是显示的,程序员必须显示指定某个方法/代码块需要在多线程之
间互斥执行。
Java虚拟机在执行Java程序的过程中,会把它管理的内存划分为几个不同的数据区域,这些区域都有各自的用途,创建时间
、销毁时间。
1.PC寄存器/程序计数器:
严格来说是一个数据结构,用于保存当前正在执行的程序的内存地址,由于Java是支持多线程执行的,所以程序执行的轨迹
不可能一直都是线性执行。当有多线程交叉执行时,被中断的线程的程序当前执行到那条内存地址必然保存下来,以便用于
被中断的线程恢复执行时再按照被中断时的指令地址继续执行下去。为了线程切换后能恢复到正确的执行位置,每个线程都
需要有一个独立的程序计数器,各个线程之间计数器互不影响,独立存储,我们称这类内存区域为“线程私有”的内存,这
在某种程度上有点类似于"ThreadLocal",是线程安全的。
内存结构就是上图中内存空间这些东西,而Java内存模型,完全是另外的一个东西。
在多CPU的系统中,每个CPU都有多级缓存,一般分为L1、L2、L3缓存,因为这些缓存的存在,提供了数据的访问性能,也减
轻了数据总线上数据传输的压力,同时也带来了很多新的挑战。
所以在CPU的层面,内存模型定义了一个充分必要条件,保证其它CPU的写入动作对该CPU是可见的,而且该CPU的写入动作对
其它CPU也是可见的,那这种可见性,应该如何实现呢?
有些处理器提供了强内存模型,所有CPU在任何时候都能看到内存中任意位置相同值,这种完全是硬件提供的支持。
其他处理器,提供了弱内存模型,需要执行一些特殊指令(就是经常看到或者听到的,memory barriers内存屏障),刷新CPU
缓存的数据到内存中,保证这个写操作能够被其它CPU可见,或者将CPU缓存的数据设置为无效状态,保证其它CPU的写操作对
本CPU可见。通常这些内存屏障的行为由底层实现,对于上层语言的程序员说是透明的(不需要太关心具体的内存屏障如何实
现)。
前面说到的内存屏障,除了实现CPU之前的数据可见性之外,还有有一个重要的职责,可以禁止指令的重排序。
这里说的重排序可以发生在好几个地方:编译器、运行时、JIT等,比如编译器会觉得把一个变量的写操作放在最后会更有效
率,编译后,这个指令就在最后了(前提是只要不改变程序的语义,编译器、执行器就可以这样自由的随意优化),一旦编译器
对某个变量的写操作进行优化(放到最后),那么在执行之前,另一个线程将不会看到这个执行结果。
当然了,写入动作可能被移到后面,那么有可能被挪到了前面,这样的“优化”有什么影响呢?这种情况下,其它线程可能
会在程序实现“发生”之前,看到这个写入动作(这里怎么理解,指令已经执行了,但是在代码层还没执行到)。通过内存屏
障的功能,我们可以禁止一些不必要、或者会带来负面影响的重排序优化,在内存模型的范围内,实现更高的性能,同时保
证程序的正确性。
class Reordering{
int x=0;y=0;
public void writer(){
x = 1;
y = 2;
}
public void reader(){
int r1 = y;
int r2 = x;
}
}
假设这段代码有2个线程并发执行,线程A执行writer方法,线程B执行reader方法,线程B看到y的值为2,因为把y设置成2发
生在变量x的写入之后(代码层面),所以能断定线程B这时看到的x就是1吗?
当然不行!因为在writer方法中,可能发生了重排序,y的写入动作可能发在x写入之前,这种情况下,线程B就有可能看到x
的值还是0。
在Java内存模型中,描述了在多线程代码中,哪些行为是正确的、合法的,以及多线程之间如何进行通信,代码中变量的读
写行为如何反应到内存、CPU缓存的底层细节。
在Java中包含了几个关键字:volatile、final和synchronized,帮助程序员把代码中的并发需求描述给编译器。Java内存模
型中定义了它们的行为,确保正确同步的Java代码在所有的处理器架构上都能正确执行。
synchronization 可以实现什么
Synchronization有多种语义,其中最容易理解的是互斥,对于一个monitor对象,只能够被一个线程持有,意味着一旦有线
程进入了同步代码块,那么其它线程就不能进入直到第一个进入的线程退出代码块(这因为都能理解)。
但是更多的时候,使用synchronization并非单单互斥功能,Synchronization保证了线程在同步块之前或者期间写入动作,
对于后续进入该代码块的线程是可见的(又是可见性,不过这里需要注意是对同一个monitor对象而言)。在一个线程退出同
步块时,线程释放monitor对象,它的作用是把CPU缓存数据(本地缓存数据)刷新到主内存中,从而实现该线程的行为可以
被其它线程看到。在其它线程进入到该代码块时,需要获得monitor对象,它在作用是使CPU缓存失效,从而使变量从主内存
中重新加载,然后就可以看到之前线程对该变量的修改。
但从缓存的角度看,似乎这个问题只会影响多处理器的机器,对于单核来说没什么问题,但是别忘了,它还有一个语义是禁
止指令的重排序,对于编译器来说,同步块中的代码不会移动到获取和释放monitor外面。
下面这种代码,千万不要写,会让人笑掉大牙:
这实际上是没有操作的操作,编译器完成可以删除这个同步语义,因为编译知道没有其它线程会在同一个monitor对象上同步
。
所以,请注意:对于两个线程来说,在相同的monitor对象上同步是很重要的,以便正确的设置happens-before关系。
如果一个类包含final字段,且在构造函数中初始化,那么正确的构造一个对象后,final字段被设置后对于其它线程是可见
的。
这里所说的正确构造对象,意思是在对象的构造过程中,不允许对该对象进行引用,不然的话,可能存在其它线程在对象还
没构造完成时就对该对象进行访问,造成不必要的麻烦。
final 可以影响什么
如果一个类包含final字段,且在构造函数中初始化,那么正确的构造一个对象后,final字段被设置后对于其它线程是可见
的。
这里所说的正确构造对象,意思是在对象的构造过程中,不允许对该对象进行引用,不然的话,可能存在其它线程在对象还
没构造完成时就对该对象进行访问,造成不必要的麻烦。
class FinalFieldExample{
final int x;
int y;
static FinalFieldExample(){
x=3;
y=4;
}
static void writer(){
f = new FinalFieldExample();
}
static void reader(){
if(f!=null){
int i = f.x;
int j = f.y;
}
}
}
上面这个例子描述了应该如何使用final字段,一个线程A执行reader方法,如果f已经在线程B初始化好,那么可以确保线程A
看到x值是3,因为它是final修饰的,而不能确保看到y的值是4。
如果构造函数是下面这样的:
public FinalFieldExample(){
x=3;
y=4;
global.obj = this;
}
这样通过global.obj拿到对象后,并不能保证x的值是3.
volatile可以做什么
Volatile字段主要用于线程之间进行通信,volatile字段的每次读行为都能看到其它线程最后一次对该字段的写行为,通过
它就可以避免拿到缓存中陈旧数据。它们必须保证在被写入之后,会被刷新到主内存中,这样就可以立即对其它线程可以见
。类似的,在读取volatile字段之前,缓存必须是无效的,以保证每次拿到的都是主内存的值,都是最新的值。volatile的
内存语义和sychronize获取和释放monitor的实现目的是差不多的。
对于重新排序,volatile也有额外的限制。
class VolatileExample{
int x=0;
volatile boolean v = false;
public void writer(){
x = 42;
v=true;
}
public void reader(){
if(v==true){
}
}
}
同样的,假设一个线程A执行writer,另一个线程B执行reader,writer中对变量v的写入把x的写入也刷新到主内存中。
reader方法中会从主内存重新获取v的值,所以如果线程B看到v的值为true,就能保证拿到的x是42.(因为把x设置成42发生
在把v设置成true之前,volatile禁止这两个写入行为的重排序)。
如果变量v不是volatile,那么以上的描述就不成立了,因为执行顺序可能是v=true, x=42,或者对于线程B来说,根本看不到
v被设置成了true。