JMM及volatile实现原理
目录
一、JMM
1.CPU缓存
CPU缓存是CPU与内存之间的临时储存器,CPU缓存在CPU内部,它的容量比内存小的多但是数据的交换速度比内存快很多。CPU高速缓存的出现主要是为了解决CPU运算速度与内存读写速度不匹配的矛盾。
1.1 CPU读取数据过程
当CPU读取数据并计算时,首先需要从CPU缓存中查到数据,并在最短时间内交付给CPU,如果没有查到所需的数据,CPU就会要求经过缓存从内存中读取数据,再原路返回至CPU进行计算。同时,也会将这个数据存到缓存中,以下是CPU、CPU缓存和内存之间的读取数据过程:
1.2 CPU三级缓存
CPU中一共有三级缓存,CPU一级缓存就是指CPU第一层级的高速缓存,主要担当的工作是缓存指令和缓存数据;CPU二级缓存的容量直接会影响到CPU的性能,故二级缓存越大越好;CPU三级缓存的作用是进一步降低内存的延迟,同时提升海量数据计算时的性能。
以下是CPU三级缓存的图(网上摘抄):
2.Java内存模型
Java线程内存模型和CPU缓存模型是非常类似的,是基于CPU缓存模型来建立的,Java内存模型是标准化的,屏蔽掉了底层不同计算机的区别。
2.1 多线程处理共享数据
当多个CPU进行多个线程对共享变量进行操作时,和CPU缓存一样,Java线程中有一个工作内存(可以认为成CPU缓存),需要先从主内存中读取数据,在线程的工作内存中复制一份,当线程需要修改数据时,是对工作内存中的数据进行修改,然后再将工作内存中的数据写入到主内存中,以下是其工作图:
2.2 多线程修改共享变量可能出现的问题
多线程同时对一个共享数据进行修改,都先是将共享数据复制一份到自己的工作内存中,然后在工作内存中修改数据,修改完毕之后再写入主内存,这样就会发生共享数据的安全问题,那么如何java中是如何解决的呢?
一个bug代码示例
下面先创建一个bug代码,就是来说明以上JMM模型的:
public class test {
private static boolean flag=false;
public static void main(String[] args) throws InterruptedException {
new Thread(new Runnable() {
@Override
public void run() {
while (!flag){
}
System.out.println("======================");
}
},"A").start();
//等待1s,先让上面的线程执行完
Thread.sleep(1000);
new Thread(new Runnable() {
@Override
public void run() {
System.out.println("准备前");
flag=true;
System.out.println("准备完毕");
}
},"B").start();
}
}
运行结果
上面代码的执行结果如下,一直在执行:
现象解释
在线程A和线程B启动中间专门停止了1s,就是为了线程A先在主内存中将共享数据,并且将其存入自己的工作内存中,以后每次访问数据都会在工作内存中进行读取,即使线程B更改了数据,线程A也感知不到。
解决办法:给共享数据加volatile
给共享数据加volatile之后,线程可以读到最新的共享数据值,主要有以下两个原因(volatile保证内存可见性):
- 当有线程修改带有volatile关键字的数据时,修改完之后会立即将修改后的数据从线程的工作内存写入到主内存中
- 当有一个线程修改了共享数据值,那么其他线程就可以感知到,并且其他线程工作内存中的共享数据副本就失效了,只能再次从主内存中获取共享数据
3.JMM数据原子操作
- read(读取):从主内存中读取数据
- load(载入):将主内存读取到的数据写入到工作区
- use(使用):从工作内存中读取数据来计算
- assign(赋值):将计算好的值重新赋值到工作内存中
- store(存储):将工作内存数据写入主内存
- write(写入):将store过去的变量赋值给主内存中的变量
- lock(锁定):将主内存加锁,标识为线程独占状态
- unlock(解锁):将主内存解锁,解锁后其他线程可以锁定该变量
4.JMM缓存不一致问题
由上图可以看到,在多线程环境下,JMM的缓存不一致问题非常明显,对于这个问题是有两种解决方案的:
- 总线加锁(性能太低):cpu从主内存读取数据到工作内存(高速缓存),会在总线加锁,只要一个线程开始从主内存中读取数据,那么就会给主内存中加锁(lock),在加锁期间别的线程是无法在主内存中获取数据的,直到之前那个线程将数据写入到主内存中,才会释放锁(unlock),这种锁的粒度太大,现在几乎不使用。
- MESI缓存一致性协议:多个cpu从主存读取同一个数据到各自的缓存(工作内存),当某个cpu改了缓存中的数据,该数据会马上写入主内存中,其他cpu通过总线嗅探机制可以感知到数据的变化从而使自己的缓存中的数据失效,重新从主内存中获取最新的数据。
二、volatile实现原理
1.可见性实现原理(MESI缓存一致性协议)
在汇编层面,volatile的可见性是通过lock前缀指令实现的,它会锁定这块内存区域的缓存(缓存行锁定)并回写到主内存当中,以下是IA-32架构手册对lock指令的解释:
- 会将当前处理器缓存行的数据立即写回主内存,缓存回写到主内存时(其实是store时)就会将总线锁住(lock),等到写入主内存之后然后解锁(unlock),这也是为了在向主内存中写入共享数据时,一次只能有一个线程写入,所以volatile也加锁了,但是锁的粒度非常小
- 这个写回操作会导致其他缓存了该内存地址的数据无效
2. volatile保证有序性
有序性即代码执行是有序的,但有时cpu在执行代码时,为了提高效率会将代码顺序改变,上面讲到加了volatile关键字的共享数据在执行过程中,汇编指令会带有lock前缀,带有lock前缀的指令,不只是会进行EMSI缓存一致性,也提供了内存屏障,禁止cpu重排序。
2.1 指令重排
指令重排是指编译器和处理器为了优化程序性能而对指令进行重排序的一种手段,重排序分为三种类型:
-
编译器优化的重排序,编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
-
指令级并行的重排序,现代处理器采用了指令级并行技术来将多条指令重叠并行,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
-
内存系统的重排序,由于处理器使用缓存和读/写缓冲区,这使得加载和存储看上去是在乱序执行
上面的1属于编译器重排序,2、3属于处理器重排序,这些重排序可能会导致多线程程序出现内存不可见问题。
对于编译,JMM的编译器重排序规则会禁止特定类型的编译器重排序(即不是所有的编译器重排序都会禁止);对于处理器重排序,JMM重排序规则要求Java编译器在生成指令序列时,通过内存屏障禁止特定类型的处理器重排序。
2.2 happens-before
在JMM中,如果一个操作执行的结果必须让另外一个线程看到,那么这两个操作之间就必须保证happens-before关系,这里提到的两个操作可以是单线程中,也可以是在多线程中。
- 程序顺序规则:一个线程中的每个操作,happens-before于该线程中的任意后续操作
- 监视器锁规则:对一个锁的解锁,happens-before于随后这个锁的加锁
- volatile变量规则:对于一个volatile域的写,happens-before于任意后续对这个volatile域的读
- 传递性:如果A happens-before B,B happens-before C,那么A happens-before C
**【注意】:**这里说的happens-before并不是意味着前一个操作必须在后一个操作之前执行,happens-before仅仅要求前一个操作结果对后一个操作具有可见性。
2.3 as-if-serial语义
as-if-serial语义的意思是,对于存在数据依赖关系的操作,编译器和处理器是不会对其进行重排序的。
2.4 volatile写读内存语义
volatile写内存语义
当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量全部写入到主内存中
volatile读内存语义
当读一个volatile变量时,JMM会把线程对应的本地内存中的共享变量全部置为无效,然后重新从主内存中获取共享变量
2.5 双重检查实现单例模式
了解完指令重排之后,现在看一个实例,单例模式懒汉式实现方法中的双重检查,代码中的sington是必须要加volatile的,就是为了禁止指令重排,是因为一个对象的创建是半初始化的,由以下三步构成的,以下步骤前面写的都是创建对象的汇编指令:
-
new :在堆中开辟一个空间
-
putstatic:在堆中调用构造方法,对对象初始化
-
invokespecial:将线程栈中的引用指向堆中对象
既然知道了创建对象需要三步,那么就有可能会发生指令重排,如果第二步和第三步发生的指令重排,一个对象被创建出来,并且引用指向其对象,但是并没有初始化,另外一个线程就会拿到没有初始化的对象
class Sington{
private static volatile Sington sington;
//构造方法为private,外部不能创建
private Sington(){
}
//获取Sington实例
public Sington getSington(){
if(sington==null){
synchronized (Sington.class){
if(sington==null){
sington=new Sington();
}
}
}
return sington;
}
}
3. volatile不保证原子性
volatile只保证多线程之间的可见性和有序性,不保证原子性,让10个线程分别对共享数据加1000次,以下是代码示例:
public class test {
private static int i=0;
public static void increase(){
i++;
}
public static void main(String[] args) throws InterruptedException {
Thread[] threads=new Thread[10];
for(int i=0;i<10;i++){
threads[i]=new Thread(new Runnable() {
@Override
public void run() {
for (int j=0;j<1000;j++){
increase();
}
}
});
threads[i].start();
}
for (Thread t:threads){
t.join();
}
System.out.println(i);
}
}
10个线程分别对共享数据加1000次,结果应该是10000,但以上代码运行结果总是小于等于10000的,这也就说明了volatile是不保证原子性的,但是为什么不保证原子性呢?
4. volatile不保证原子性的原因
在5.2中的实验已经可以看到,volatile是不保证原子性的,假设以线程A和线程B两个线程为例,来说明不保证原子性的原因,以下是线程对共享数据加1的操作过程:
-
线程A从主内存中将数据read并load到缓存中,此时同时,线程B也是同样的操作
-
线程A从缓存中读取数据,对其加1操作,并写入到缓存中,与此同时,线程B也是同样的操作
-
由于共享数据加了volatile,操作完数据之后,需要立即将数据写入主内存,与此同时,线程B也是同样的操作
-
在写入主内存时(其实是store时)就会将总线锁住,同一时间,只允许一个线程对主内存的数据进行写回,那么线程A和线程B只能有一个成功将数据写回到主内存中,并且另外一个线程的工作内存的数据地址会失效,需要重新从主内存中获取更新之后的数据,假设线程A写回成功,将共享数据更改为1(此时线程B工作内存中的共享数据也是1)
-
线程B重新从主内存中读到共享数据,这个共享数据就是1,但由于线程B的i++操作已经执行过一次了,所以线程B会失去一次i++操作的机会
从上面的步骤就可以清楚的知道,volatile不能保证数据的原子性。