一。什么是内存模型(Java Memory Model)?
内存模型描述了程序中各个变量(实例域、静态域和数组元素)之间的关系,以及在实际计算机系统中将变量存储到内存和从内存中取出变量这样的底层细节。
二。JMM结构:
JMM规定了所有的变量都存储在主内存(Main Memory)中。每个线程还有自己的工作内存(Working Memory),线程的工作内存中保存了该线程使用到的变量的主内存的副本拷贝,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主内存中的变量(volatile变量仍然有工作内存的拷贝,但是由于它特殊的操作顺序性规定,所以看起来如同直接在主内存中读写访问一般)。不同的线程之间也无法直接访问对方工作内存中的变量,线程之间值的传递都需要通过主内存来完成。
内存间的交互动作
动作 | 作用 |
lock (锁定) | 作用于主内存变量,把一个变量标示为一条线程独占的状态 |
unlock (解锁) | 作用于主内存的变量,把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定 |
read (读取) | 作用于主内存的变量,把一个变量的值从主内存传输到线程的工作内存中,以便随后的load动作使用 |
load (载入) | 作用于工作内存的变量,把read操作从主存中得到的变量值放入工作内存的变量副本中 |
use (使用) | 作用于工作内存的变量,把工作内存中一个变量的值传递给执行引擎,每当虚拟机遇到一个需要使用到变量的值的字节码指令时将会执行这个操作 |
assign (赋值) | 作用于工作内存的变量,把一个从执行引擎接收到的值赋给工作内存中的变量,每当虚拟机遇到一个给变量赋值的字节码指令时执行这个操作 |
store (存储) | 作用于工作内存的变量,把工作内存中一个变量的值传送到主内存中,以便随后的write操作使用 |
write (写入) | 作用于主内存的变量,把store操作从工作内存中得到的变量的值放入主内存的变量中 |
三。Java内存模型是围绕着并发编程中原子性、可见性、有序性这三个特征来建立的。
1.原子性
原子性:即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
一个很经典的例子就是银行账户转账问题:
比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。
试想一下,如果这2个操作不具备原子性,会造成什么样的后果。假如从账户A减去1000元之后,操作突然中止。然后又从B取出了500元,取出500元之后,再执行 往账户B加上1000元 的操作。这样就会导致账户A虽然减去了1000元,但是账户B没有收到这个转过来的1000元。
所以这2个操作必须要具备原子性才能保证不出现一些意外的问题。
同样地反映到并发编程中会出现什么结果呢?
举个最简单的例子,大家想一下假如为一个32位的变量赋值过程不具备原子性的话,会发生什么后果?
1 | i = 9; |
假若一个线程执行到这个语句时,我暂且假设为一个32位的变量赋值包括两个过程:为低16位赋值,为高16位赋值。
那么就可能发生一种情况:当将低16位数值写入之后,突然被中断,而此时又有一个线程去读取i的值,那么读取到的就是错误的数据。
2.可见性
可见性是指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。
举个简单的例子,看下面这段代码:
1 2 3 4 5 6 | //线程1执行的代码 int i = 0; i = 10;
//线程2执行的代码 j = i; |
假若执行线程1的是CPU1,执行线程2的是CPU2。由上面的分析可知,当线程1执行 i =10这句时,会先把i的初始值加载到CPU1的高速缓存中,然后赋值为10,那么在CPU1的高速缓存当中i的值变为10了,却没有立即写入到主存当中。
此时线程2执行 j = i,它会先去主存读取i的值并加载到CPU2的缓存当中,注意此时内存当中i的值还是0,那么就会使得j的值为0,而不是10.
这就是可见性问题,线程1对变量i修改了之后,线程2没有立即看到线程1修改的值。
3.有序性
有序性:即程序执行的顺序按照代码的先后顺序执行。举个简单的例子,看下面这段代码:
1 2 3 4 | int i = 0; boolean flag = false; i = 1; //语句1 flag = true; //语句2 |
上面代码定义了一个int型变量,定义了一个boolean类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句1是在语句2前面的,那么JVM在真正执行这段代码的时候会保证语句1一定会在语句2前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。
下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。
比如上面的代码中,语句1和语句2谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句2先执行而语句1后执行。
但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:
1 2 3 4 | int a = 10; //语句1 int r = 2; //语句2 a = a + 3; //语句3 r = a*a; //语句4 |
这段代码有4个语句,那么可能的一个执行顺序是:
那么可不可能是这个执行顺序呢: 语句2 语句1 语句4 语句3
不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令Instruction 2必须用到Instruction 1的结果,那么处理器会保证Instruction 1会在Instruction 2之前执行。
虽然重排序不会影响单个线程内程序执行的结果,但是多线程呢?下面看一个例子:
1 2 3 4 5 6 7 8 9 | //线程1: context = loadContext(); //语句1 inited = true; //语句2
//线程2: while(!inited ){ sleep() } doSomethingwithconfig(context); |
上面代码中,由于语句1和语句2没有数据依赖性,因此可能会被重排序。假如发生了重排序,在线程1执行过程中先执行语句2,而此是线程2会以为初始化工作已经完成,那么就会跳出while循环,去执行doSomethingwithconfig(context)方法,而此时context并没有被初始化,就会导致程序出错。
从上面可以看出,指令重排序不会影响单个线程的执行,但是会影响到线程并发执行的正确性。
也就是说,要想并发程序正确地执行,必须要保证原子性、可见性以及有序性。只要有一个没有被保证,就有可能会导致程序运行不正确。
JMM的三个特性(Java语言 本身对 原子性、可见性以及有序性提供了哪些保证呢):
1.原子性
在Java中,对基本数据类型的变量的读取和赋值操作是原子性操作,即这些操作是不可被中断的,要么执行,要么不执行。
上面一句话虽然看起来简单,但是理解起来并不是那么容易。看下面一个例子i:
请分析以下哪些操作是原子性操作:
1 2 3 4 | x = 10; //语句1 y = x; //语句2 x++; //语句3 x = x + 1; //语句4 |
咋一看,有些朋友可能会说上面的4个语句中的操作都是原子性操作。其实只有语句1是原子性操作,其他三个语句都不是原子性操作。
语句1是直接将数值10赋值给x,也就是说线程执行这个语句的会直接将数值10写入到工作内存中。
语句2实际上包含2个操作,它先要去读取x的值,再将x的值写入工作内存,虽然读取x的值以及 将x的值写入工作内存 这2个操作都是原子性操作,但是合起来就不是原子性操作了。
同样的,x++和 x = x+1包括3个操作:读取x的值,进行加1操作,写入新的值。
所以上面4个语句只有语句1的操作具备原子性。
也就是说,只有简单的读取、赋值(而且必须是将数字赋值给某个变量,变量之间的相互赋值不是原子操作)才是原子操作。
不过这里有一点需要注意:在32位平台下,对64位数据的读取和赋值是需要通过两个操作来完成的,不能保证其原子性。但是好像在最新的JDK中,JVM已经保证对64位数据的读取和赋值也是原子性操作了。
从上面可以看出,Java内存模型只保证了基本读取和赋值是原子性操作,如果要实现更大范围操作的原子性,可以通过synchronized和Lock来实现。由于synchronized和Lock能够保证任一时刻只有一个线程执行该代码块,那么自然就不存在原子性问题了,从而保证了原子性。
2.可见性
对于可见性,Java提供了volatile关键字来保证可见性。
当一个共享变量被volatile修饰时,它会保证修改的值会立即被更新到主存,当有其他线程需要读取时,它会去内存中读取新值。
而普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。
另外,通过synchronized和Lock也能够保证可见性,synchronized和Lock能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。因此可以保证可见性。
3.有序性
在Java内存模型中,允许编译器和处理器对指令进行重排序,但是重排序过程不会影响到单线程程序的执行,却会影响到多线程并发执行的正确性。
在Java里面,可以通过volatile关键字来保证一定的“有序性”,通过synchronized和Lock来保证有序性,很显然,synchronized和Lock保证每个时刻是有一个线程执行同步代码,相当于是让线程顺序执行同步代码,自然就保证了有序性。
另外,Java内存模型具备一些先天的“有序性”,即不需要通过任何手段就能够得到保证的有序性,这个通常也称为 happens-before (先行发生)原则。如果两个操作的执行次序无法从happens-before原则推导出来,那么它们就不能保证它们的有序性,虚拟机可以随意地对它们进行重排序。
happens-before原则:
Java内存模型中定义的两项操作之间的次序关系,如果说操作A先行发生于操作B,操作A产生的影响能被操作B观察到,“影响”包含了修改了内存中共享变量的值、发送了消息、调用了方法等。
下面是Java内存模型下一些”天然的“happens-before关系,这些happens-before关系无须任何同步器协助就已经存在,可以在编码中直接使用。如果两个操作之间的关系不在此列,并且无法从下列规则推导出来的话,它们就没有顺序性保障,虚拟机可以对它们进行随意地重排序。
a.程序次序规则(Pragram Order Rule):在一个线程内,按照程序代码顺序,书写在前面的操作先行发生于书写在后面的操作。准确地说应该是控制流顺序而不是程序代码顺序,因为要考虑分支、循环结构。
b.管程锁定规则(Monitor Lock Rule):一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调的是同一个锁,而”后面“是指时间上的先后顺序。
c.volatile变量规则(Volatile Variable Rule):对一个volatile变量的写操作先行发生于后面对这个变量的读取操作,这里的”后面“同样指时间上的先后顺序。
d.线程启动规则(Thread Start Rule):Thread对象的start()方法先行发生于此线程的每一个动作。
e.线程终于规则(Thread Termination Rule):线程中的所有操作都先行发生于对此线程的终止检测,我们可以通过Thread.join()方法结束,Thread.isAlive()的返回值等作段检测到线程已经终止执行。
f.线程中断规则(Thread Interruption Rule):对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生,可以通过Thread.interrupted()方法检测是否有中断发生。
g.对象终结规则(Finalizer Rule):一个对象初始化完成(构造方法执行完成)先行发生于它的finalize()方法的开始。
g.传递性(Transitivity):如果操作A先行发生于操作B,操作B先行发生于操作C,那就可以得出操作A先行发生于操作C的结论。
一个操作”时间上的先发生“不代表这个操作会是”先行发生“,那如果一个操作”先行发生“是否就能推导出这个操作必定是”时间上的先发生 “呢?也是不成立的,一个典型的例子就是指令重排序。所以时间上的先后顺序与happens-before原则之间基本没有什么关系,所以衡量并发安全问题一切必须以happens-before 原则为准。
四。原始JMM缺陷:
JMM最初设计的时候存在一定的缺陷,这种缺陷虽然现有的JVM平台已经修复。
1)问题1:不可变对象不是不可变的
学过Java的朋友都应该知道Java中的不可变对象,这一点在本文最后讲解String类的时候也会提及,而JMM最初设计的时候,这个问题一直都存在,就是:不可变对象似乎可以改变它们的值(这种对象的不可变指通过使用final关键字来得到保证),(Publis Service Reminder:让一个对象的所有字段都为final并不一定使得这个对象不可变——所有类型还必须是原始类型而不能是对象的引用。而不可变对象被认为不要求同步的。但是,因为在将内存写方面的更改从一个线程传播到另外一个线程的时候存在潜在的延迟,这样就使得有可能存在一种竞态条件,即允许一个线程首先看到不可变对象的一个值,一段时间之后看到的是一个不同的值。这种情况以前怎么发生的呢?在JDK 1.4中的String实现里,这儿基本有三个重要的决定性字段:对字符数组的引用、长度和描述字符串的开始数组的偏移量。String就是以这样的方式在JDK 1.4中实现的,而不是只有字符数组,因此字符数组可以在多个String和StringBuffer对象之间共享,而不需要在每次创建一个String的时候都拷贝到一个新的字符数组里。假设有下边的代码:
String s1 = "/usr/tmp";
String s2 = s1.substring(4); // "/tmp"
这种情况下,字符串s2将具有大小为4的长度和偏移量,但是它将和s1共享“/usr/tmp”里面的同一字符数组,在String构造函数运行之前,Object的构造函数将用它们默认的值初始化所有的字段,包括决定性的长度和偏移字段。当String构造函数运行的时候,字符串长度和偏移量被设置成所需要的值。但是在旧的内存模型中,因为缺乏同步,有可能另一个线程会临时地看到偏移量字段具有初始默认值0,而后又看到正确的值4,结果是s2的值从“/usr”变成了“/tmp”,这并不是我们真正的初衷,这个问题就是原始JMM的第一个缺陷所在,因为在原始JMM模型里面这是合理而且合法的,JDK 1.4以下的版本都允许这样做。
2)问题2:重新排序的易失性和非易失性存储
另一个主要领域是与volatile字段的内存操作重新排序有关,这个领域中现有的JMM引起了一些比较混乱的结果。现有的JMM表明易失性的读和写是直接和主存打交道的,这样避免了把值存储到寄存器或者绕过处理器特定的缓存,这使得多个线程一般能看见一个给定变量最新的值。可是,结果是这种volatile定义并没有最初想象中那样如愿以偿,并且导致了volatile的重大混乱。为了在缺乏同步的情况下提供较好的性能,编译器、运行时和缓存通常是允许进行内存的重新排序操作的,只要当前执行的线程分辨不出它们的区别。(这就是within-thread as-if-serial semantics[线程内似乎是串行]的解释)但是,易失性的读和写是完全跨线程安排的,编译器或缓存不能在彼此之间重新排序易失性的读和写。遗憾的是,通过参考普通变量的读写,JMM允许易失性的读和写被重排序,这样以为着开发人员不能使用易失性标志作为操作已经完成的标志。比如:
Map configOptions;
char[] configText;
volatile boolean initialized = false;
// 线程1
configOptions = new HashMap();
configText = readConfigFile(filename);
processConfigOptions(configText,configOptions);
initialized = true;
// 线程2
while(!initialized)
sleep();
这里的思想是使用易失性变量initialized担任守卫来表明一套别的操作已经完成了,这是一个很好的思想,但是不能在JMM下工作,因为旧的JMM允许非易失性的写(比如写到configOptions字段,以及写到由configOptions引用Map的字段中)与易失性的写一起重新排序,因此另外一个线程可能会看到initialized为true,但是对于configOptions字段或它所引用的对象还没有一个一致的或者说当前的针对内存的视图变量,volatile的旧语义只承诺在读和写的变量的可见性,而不承诺其他变量,虽然这种方法更加有效的实现,但是结果会和我们设计之初大相径庭。