1.来谈谈JMM
我们都知道,为了提高CPU的运行效率,我们会在CPU和内存之间加入高速缓存来提高CPU的速度,由于当今CPU都是多核的,这会导致缓存一致性的问题,为了解决一致性的问题,需要处理器在访问缓存时都遵循一些协议,在读写数据时,要根据协议来操作,这类协议有MESI,MOSI,MSI等等…
不了解MESI的可以阅读这篇文章
我们也都知道,不同平台为了达到一致的内存访问效果,会采取不同的协议和手段;
在java之前,比如C和C++,直接使用物理硬件和操作系统的内存模型,由于不同平台内存模型的差异(例如IA-32和Intel64处理器使用的是MESI协议),有可能导致一套程序在一套平台上并发完全正常,而在另一套平台并发访问却经常出错,所以,在某些场景下必须针对不同的平台来编写程序
上面提到了内存模型,内存模型可以理解为: 在特定的操作协议下,对特定内存或高速缓存进行读写访问的过程抽象
而我们又知道java是跨平台的(编译一次,到处运行),要确保java程序在不同平台下都能达到一致的内存效果,我们需要定义一种规范,
这种规范就是所谓的java内存模型(JMM),定义好规范以后,不同平台只需要根据这个规范来实现特定的操作即可;
java内存模型规定所有变量都必须存储在主内存中,每个线程有自己的工作内存,存储的都是主内存的副本,线程间变量值的传递需要通过主内存来完成,java内存模型保证了原子性,有序性,可见性
JMM定义的主内存与工作内存的交互过程如下图所示
JAVA内存模型针对变量如何从主内存拷贝到工作内存,以及如何从工作内存同步会主内存这些实现细节,抽象出了8大原子操作
(后面简化为了read,write,lock,unlock4种操作)
这些操作都是都是原子不可再分
的,具体实现由不同的平台来实现(汇编层面)
2.volatile和synchronized关键字
为了保证上述提到的三大特性,java在语言层面为我们提供了volatile,synchronized等关键字来保证
我们知道volatile可以保证有序性和可见性
synchronized可以保证原子性,有序性,和可见性
2.1volatile是如何保证可见性的??
volatile的可见性主要是通过MESI协议(IA-32和Intel64处理器下)和Lock汇编指令前缀来实现的
拿单例模式DCL来举例
下面是给instance变量赋值的汇编级别的指令
发现instance用volatile和不用volatile的唯一区别就是是否多了一条加上volatile修饰会多一条LOCK指令
这条lock指令是干啥的呢??
所以当我们对volatile修饰的变量进行写操作时,他会把修改的值立即写回主内存,同时由于MESI协议的存在,MESI规定对共享数据的写操作,会导致其他CPU的缓存行失效,重新从主内存加载数据,正是LOCK和MESI配合,才能保证一个核心对数据的修改,立即被其他核心可见
2.2 volatile是如何保证有序性的呢??
LOCK指令还提供了相当于内存屏障的功能
还是拿上面的DCL单例模式举例,上面的DCL,如果instance不加volatile修饰,有可能会指令重排序,也就是invokeSpecial(实际是执行init<>方法)和putStatic这两条字节码指令重排,这样在多线程的情况下,会导致对象的版初始化问题
截取的上面代码字节码指令的一部分
这样会导致t2线程执行到if(instance == null) 时,发现instance已经被赋值,但是此时对象还未初始化完成,即(invokeSpecial未完成),这样就会导致读取到半初始化的对象;
解决办法就是给instance加上volatile修饰,这样就不会导致21行被放到24行指令的后面执行
哪些情况不能重排序??
分析到这里,有伙伴会疑问了,为啥这里21会放到24行代码后面呢???你说重排序就重排序???
其实这里java编译器以及CPU对指令重排序时是有依据的,重排序时会遵循as-if-serial和happens-before规则
同样这两个规则也是java提供的规范,具体实现由不同平台来是实现
as-if-serial语义: 如果我们的程序在单线程内,重排序后不会影响程序的最终结果,也就是遵循as-if-serial语义,那么就可以进行重排序,
比如a = 1,b =2 ,在单线程内这种不论怎么重排序,最终结果都是正确的;
但是像 a = x, b = a这种存在依赖关系,即b需要依赖a的值时,重排序时就会出现问题,这是就不能重排序
happens-before原则:
如果两个操作之间的关系不在下面的原则中,那么虚拟机可以随意对它们进行进行重排序
-
程序次序规则: 在一个单独的线程中,按照程序代码的执行流顺序,(时间上)先执行的操作happen—before(时间上)后执行的操作
(同一个线程中前面的所有写操作对后面的操作可见) -
管理锁定规则:一个unlock操作happen—before后面(时间上的先后顺序)对同一个锁的lock操作。
(如果线程1解锁了monitor a,接着线程2锁定了a,那么,线程1解锁a之前的写操作都对线程2可见(线程1和线程2可以是同一个线程)) -
volatile变量规则:对一个volatile变量的写操作happen—before后面(时间上)对该变量的读操作。
(如果线程1写入了volatile变量v(临界资源),接着线程2读取了v,那么,线程1写入v及之前的写操作都对线程2可见(线程1和线程2可以是同一个线程)) -
线程启动规则:Thread.start()方法happen—before调用用start的线程前的每一个操作。
(假定线程A在执行过程中,通过执行ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行前对线程B可见。注意:线程B启动之后,线程A在对变量修改线程B未必可见。) -
线程终止规则:线程的所有操作都happen—before对此线程的终止检测,可以通过Thread.join()方法结束、Thread.isAlive()的返回值等手段检测到线程已经终止执行。
(线程t1写入的所有变量,在任意其它线程t2调用t1.join(),或者t1.isAlive() 成功返回后,都对t2可见。) -
线程中断规则:对线程interrupt()的调用 happen—before 发生于被中断线程的代码检测到中断时事件的发生。
(线程t1写入的所有变量,调用Thread.interrupt(),被打断的线程t2,可以看到t1的全部操作) -
对象终结规则:一个对象的初始化完成(构造函数执行结束)happen—before它的finalize()方法的开始。
(对象调用finalize()方法时,对象初始化完成的任意操作,同步到全部主存同步到全部cache。) -
传递性:如果操作A happen—before操作B,操作B happen—before操作C,那么可以得出A happen—before操作C。
(A h-b B , B h-b C 那么可以得到 A h-b C)
比如:
又比如:(以下操作在同一个线程内执行)
int j = 1;
int j = 2;
刚才那些规则是给我们程序员来进行分析的,那计算机底层是怎么判断能不能重排序的呢??
其实这就要涉及到编译原理了,我们知道程序在编译的时候,会有语法分析,词法分析,语义分析等等操作
其中的语义分析这一步,就是分析变量之间的依赖关系的,当计算机发现这两个变量之间存在依赖关系时,它就会告诉编译器,cpu不能对这两条指令进行重排序!!
所以,我们再看,刚才的invokespecial和putstatic指令,虽然它满足了happens-before原则的第一条程序次序原则,原则上不能进行重排序,我们在进行语义分析时,发现即使重排序这两个指令,在当前线程内是不影响最终结果的,因为计算机分析不出其他线程会依赖该排序结果,所以CPU为了提高效率,有可能(概率非常低非常低)
对他们进行重排序,这样就会导致其他线程读取到半初始化的对象;
上面说了那么多总结一下:
在遵循as-if-serial语义的情况下,可能会发生重排序
happens-before中的八条规则限定了必须按照顺序执行
但是其中的第一条,程序次序规则,如果是满足as-if-serial语义也是可能重排序的!!
除了上面有关重排序的两个规则外,我们可以显示使用volatile来限制重排序
举例来说,第三行最后一个单元格的意思是:在程序顺序中,当第一个操作为普通变量的读或写时,如果第二个操作为volatile写,则编译器不能重排序这两个操作。
从上表我们可以看出:
当第二个操作是volatile写时,不管第一个操作是什么,都不能重排序。这个规则确保volatile写之前的操作不会被编译器重排序到volatile写之后。
当第一个操作是volatile读时,不管第二个操作是什么,都不能重排序。这个规则确保volatile读之后的操作不会被编译器重排序到volatile读之前。
当第一个操作是volatile写,第二个操作是volatile读时,不能重排序。
其实到这里我们就已经可以解释DCL单例中加上volatile,可以防止重排序的问题了!!
第二个指令putstatic是对volatile的写操作
第一个指令是invokeSpecial在putstatic之前
当第二个操作是volatile写时,不管第一个操作是什么,都不能把volatile写之前的操作放到volatile写之后!!
为了实现volatile的内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来禁止特定类型的处理器重排序。对于编译器来说,发现一个最优布置来最小化插入屏障的总数几乎不可能,为此JMM采取保守策略。下面是基于保守策略的JMM内存屏障插入策略:
- 在每个volatile写操作的前面插入一个StoreStore屏障。
- 在每个volatile写操作的后面插入一个StoreLoad屏障。
- 在每个volatile读操作的后面插入一个LoadLoad屏障。
- 在每个volatile读操作的后面插入一个LoadStore屏障。
上述内存屏障插入策略非常保守,但它可以保证在任意处理器平台,任意的程序中都能得到正确的volatile内存语义。
上述volatile写和volatile读的内存屏障插入策略非常保守。在实际执行时,只要不改变volatile写-读的内存语义,编译器可以根据具体情况省略不必要的屏障。下面我们通过具体的示例代码来说明:
以一段代码为例分析:
public class VolatileBarrierExample {
private int a;
private volatile int v1 = 1;
private volatile int v2 = 2;
void readAndWrite(){
int i = v1; //第一个volatile读
int j = v2; //第二个volatile读
a = i + j; //普通写
v1 = i + 1; //第一个volatile写
v2 = j * 2; //第二个volatile写
}
}
彩色是将屏障指令带入到程序中生成的全部内容,也就是编译器生成的「最稳妥」的方案
右侧虚线框指向的屏障是可以被「优化」删除掉的屏障
灵魂追问
如果 volatile 写之后直接 return,那还会生成 StoreLoad 指令吗?
上面我们可以发现第二个volatile后面的stroeLoad屏障没有省略!!
因为第二个volatile写之后,方法立即return。此时编译器可能无法准确断定后面是否会有volatile读或写,为了安全起见,编译器常常会在这里插入一个StoreLoad屏障。
上面的优化是针对任意处理器平台,由于不同的处理器有不同“松紧度”的处理器内存模型,内存屏障的插入还可以根据具体的处理器内存模型继续优化。以x86处理器为例,上图中除最后的StoreLoad屏障外,其它的屏障都会被省略。
前面保守策略下的volatile读和写,在 x86处理器平台可以优化成:
前文提到过,x86处理器仅会对写-读操作做重排序。X86不会对读-读,读-写和写-写操作做重排序,因此在x86处理器中会省略掉这三种操作类型对应的内存屏障。在x86中,JMM仅需在volatile写后面插入一个StoreLoad屏障(看下文源码追溯)即可正确实现volatile写-读的内存语义。这意味着在x86处理器中,volatile写的开销比volatile读的开销会大很多(因为执行StoreLoad屏障开销会比较大)
内存屏障是JVM定义的规范,它规定了对volatile变量的读写操作会加上读写屏障来防止重排序,说到底内存屏障还是一个规范,一个抽象,那底层到底是怎样实现的呢??
查询jvm源码得知
(使用volatile,在jvm字节码层面是体现不出来的!!!!)
在volatile变量的写操作后面有一个storeLoad
搜寻storeLoad方法发现有很多方法,这里我们点击linux-X86,因为HotSpot虚拟机在不同的操作系统下又不同的实现(跨平台)
C++inline关键字
接着点击fence方法:
我们就会发现底层其实是lock指令为前缀的'一段汇编代码
其中lock指令的第三条提供了类似于内存屏障的作用
想当于volatile写前面是storestore,volatile写后面是storeLoad
至此,有关volatile保证有序性和可见性的分析到此结束!!
补充:
为了看到汇编码,要使用hsdis插件, 在mac系统下需要安装一个hsdis-amd64.dylib的插件。
具体分析可以参考从汇编看volatile
本文参考链接
从汇编看volatile
面试并发volatile关键字时,我们应该具备哪些谈资?
深入理解JVM第三版
深入理解java内存模型
内存模型
既然有MESI,为什么还需要Volatile