学习Java并发编程,必须要学习Java内存模型,也是学习和理解后面更深入的课程打下基础,做好准备。今天我们就来学习下Java内存模型。
以下是本文包含的知识点:
1.硬件的效率与一致性
2.Java内存模型
3.主内存和工作内存
4.原子性、可见性与有序性
5.先行发生原则(Happens-before)
一、硬件的效率与一致性
由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器运算速度的高速缓存(Cache)来作为内存与处理器之前的缓冲:将运算需要用到的数据复制到缓存中,让运算能快速运行,当运算结束再从缓存同步回内存之中,这样处理器就无需等待缓慢的内存读写了。
基于高速缓存的存储交互很好的解决了处理器与内存的速度矛盾,但是也引入了一个新的问题:缓存一致性。当多个处理器的运算任务都涉及到同一块主内存区域时,将可能导致各自的缓存数据不一致,那同步回到主内存时以谁的缓存数据为准呢?为了解决一致性的问题,需要各处理器访问缓存时都遵循一些协议,在读写时要根据协议来操作,这里说的协议就是内存模型的抽象。Java虚拟机也有自己的内存模型。
二、Java内存模型
在JDK1.5发布后,Java虚拟机规范中定义的Java内存模型(Java Memory Model JMM)已经成熟和完善。它屏蔽掉各种硬件和操作系统内存的访问差异,以实现让Java程序在各种平台下都能达到一致的访问效果。
三、主内存和工作内存
Java内存模型规定所有变量都存储在主内存(Main Memory)中(可以理解为物理内存,不过是虚拟机内存的一部分),每条线程还有自己的工作内存(Woring Memory,可以理解为前面讲的高速缓存),线程的工作内存中保存了被该线程使用到的变量的主内存副本拷贝,线程对变量的所有操作(读取,赋值)都必须在工作内存中完成。
如多线程执行变量i++操作的流程:
1).先将变量i读取到工作内存中;
2).然后在工作内存中将i+1;
3).最后将变量i同步到主内存中。
四、原子性、可见性与有序性
Java内存模型的三大特征:原子性、可见性与有序性
1. 原子性(Atomicity) :即一个操作要么全部执行并且执行的过程中不被任何因素打断,要不都不执行。
如多线程执行i++操作
public class Test implements Runnable{ int i = 0; public void run(){ i++; } }假如i初始值为0,线程1和线程各自执行一次+1操作,结果是我们想要的2吗?不一定
根据前面讲的内存模型,假如线程1将i=0读取的工作内存中,并对i+1,此时i=1,但只是在线程1的工作内存中,并未同步到主存中。
此时线程2从主存读取i还是=0,并对i+1变为1,此时i=1,但只是在线程1的工作内存中,并未同步到主存中。
然后线程1同步到主存中,最后线程2同步到主存中,程序执行完毕,i的值为1。
这种情况就是原子性操作被打断了。哪如何保证原子性不被打断呢,Java提供了两种方式Lock和synchronized,后续再讲。
2. 可见性(Visibility) 是指当一个线程修改了共享变量的值,其它线程能够立即得知这个修改。
Java内存模型是通过在变量修改后将新值同步回主内存,在变量读取前从主内存刷新变量值来实现可见性,无论是普通变量还是volatile变量都是如此,只不过volatile特殊规则保证了新值能够立即同步到主内存,以及每次使用前都从主内存刷新。因此,可以说volatitle保证了变量的可见性,而普通变量不可以。
除了volatitle之外,java还有两个关键字保证可见性,即synchronized和final。
同步的可见性是由“对一个变量执行unlock操作之前,必须先把此变量同步回主内存中”这条规则获得。
而final关键字的可见性是指,被 final修饰的字段一旦在构造器中初始化完成,并且构造器没有把“this”引用传递出去,那在其它线程中就能看见final字段的值。
public static final int i; public final int j; static{ i = 0; } { //也可以在构造器初始化 j = 0; }上面代码变量i和j都具有可见性,它们无须同步就能被其它线程正确访问到。
3. 有序性(Ordering) 即程序执行代码的先后顺序。
看下面的代码:
int i=0; boolean flag = false; i = 1; //语句1 flag = true; //语句2JVM在真正执行这段代码时会按照先语句1、后语句2的顺序来执行吗,不一定,因为这里可能会发生指令重排序。
指令重排序:一般来说,处理器为了提高运行效率,会对运行的代码优化排序,它不保证各个语句的执行顺序与代码的先后顺序一致,但是它会保证程序的执行结果与顺序执行的结果一致。
那它是怎么来保证执行结果一致的呢,原因是它会考虑数据的依赖性。如果指令2必须要用到指令1的结果,那么处理器会保证指令1比指令2先执行。
指令重排序在单线程中是没有问题的,那在多线程中呢,就不一定了。看下面代码:
Map config; boolean flag = false; //假设线程1执行如下代码 config = initConfig();//初始化config flag = true; //假设线程2执行如下代码 while(!flag){//等待flag为true,代表线程1已经初始化完成 sleep(); } //使用线程1初始化好的配置 doSomethingWithConfig();假如线程1在执行时,发生指令重排序,先执行了flag = true,后执行initConig(),因为两句没有依赖关系,是可以发生的。
然后线程2再执行时,就发生异常了,因为这时config根本没有初始化完成。
所以指令重排序不会影响单个线程的执行,但是会影响多线程并发执行的正确性。
Java语言提供volatitle和synchronized来保证线程之间操作的有序性。
volatitle本身就包含禁止指令重排序的语义,详细的后面再讲。
synchronized则是由“一个变量在同一个时刻只允许一个线程对其进行Lock操作”这条规则获得。
介绍完Java内存模型的三大特征,会不会觉得synchronized是万能的,的确大部分的并发控制操作都能使用synchronized来完成,但就是因为它的万能造就了程序员的滥用,越万能的并发控制,通常会带来越严重的性能影响。后面会讲到虚拟机锁的优化。
五、先行发生原则(Happens-before)
如果Java内存模型的所有有序性都靠volatitle和synchronized来完成,那么有一些操作会变得很烦锁,但是我们在编写java并发代码时并没感觉到这一点,这是因为java语言有一个“先行发生”(happens-before) 原则。这个原则非常重要,这是判断数据是否竞争,线程是否安全的重要依据。
下面的Java内存模型下一些天然的发生关系,这些发生关系无须任何同步器,就已经存在,可以在编码中直接使用。如果有两个操作关系不在此列,并且无法从下列关系推导出来,它们就没有顺序保障,虚拟机可以对它们随意重排序。
1) 程序次序规则 :在一个线程内,按照程序代码顺序或控制流,书写在前在的操作先行发生于写在后面的操作。
2) 管程锁定规则 :一个unlock操作先行发生于后面对同一个锁的lock操作。这里必须强调是同一个对象锁,而后面,指的是时间上的先后顺序。
3) volatitle变量规则 :对一个volatitle变量的写操作先行发生于后面对这个变量的读操作。
4) 线程启动规则 :Thread对象的start()方法先行发生于此线程的每一个动作。
5) 线程终止规则 :线程中的所有操作都先行发生于对此线程的终止检测。我们可以通过join()方法结束、isAlive()返回值检测线程的终止。
6) 线程中断规则 :对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。可以通过interrupted()检测是否有中断发生。
7) 对象终结规则 :一个对象的初始化完成(构造方法执行结束)先行发生于它的finalize()方法的开始。
8) 传递性 :如果操作A先行发生于操作B,操作B先行发生于操作C,那么可以得出操作A先行发生于操作C的结论。
我们来看个例子:
private int value = 0; public void setValue(int value){ this.value = vlaue; } public int getValue(){ return value; }假设存在线程A和线程B,线程A先(时间上的先后)调用setValue(1),然后线程B调用同一个对象的getValue(),会得到什么结果呢?
我们依次分析下先行发生原则里的各项规则,
由于两个方法分别由线程A和线程B调用,不在一个线程中,所以程序次序规则在这里不适用。
由于没有同步块,自然没有lock与unlock,所以管程锁定规则在这里不适用。
由于变量value没有用volatitle修饰,所以volatitle变量规则在这里不适用。
后面的线程启动、终止、中断、对象终结都跟这没关系。
因为没有一个适用的先行发生规则,所以传递性也不适用。
所以我们可以判定尽管线程A在操作时间上先于线程B,但是无法确定线程B的返回结果,即这里操作不是线程安全的。
那么怎么修复这个问题呢,很简单,要么给seter/geter 方法加上synchronized关键字,这样就可以使用管程锁定规则,要么把变量value定义为volatitle类型,由于setter方法对value的修改不依赖value的原值,满足volatitle关键字的使用场景,这样就可以使用volatitle变量规则。
通过上面的例子可以得出一个结论:一个操作“时间上的先发生”不代表这个操作先行发生。
所以我们衡量并发安全问题的时候不要受到时间顺序的干扰,一切必须以先行发生原则为准。
本文永久更新链接地址 : http://www.linuxidc.com/Linux/2016-07/133395.htm