为什么选择JMM和Volatile一起讲解?
这样我觉得你才能充分理解volatile的作用和好处
话不多说我们开始
CPU缓存模型
再讲JMM之前我觉得你得先了解计算机的CPU缓存,什么是CPU缓存?
定义:CPU缓存是为了解决CPU处理速度和内存处理速度不对等的问题
很抽象是吧,先上个图
还是很抽象?
我们先想想内存是有什么用,我们处理数据直接去硬盘读取数据是不是很慢,这时候我们就需要把数据读入内存,然后计算机处理是不是就更快了,所以说内存看作外存的高速缓存,而CPU缓存同理,我们CPU处理内存的数据,也需要读取到CPU缓存中以加快处理速度,CPU缓存可以看作内存的高速缓存,大概有个概念,我们了解以下CPU Cache的类型,大概三种L1,L2,L3 Cache,都是为了加快处理速度。
CPU Cache的工作方式:我们用到内存数据的时候,先从Main Memory复制一份到CPU Cache中,这样CPU就可以直接从中读取,但是在多线程情况下,比如两个线程执行,第一个线程从Main Memory中读取到的数据到CPU Cache中,从CPU CacheL3中读取到的数据到各自的高速缓存中是一样比如i=1,然后经过缓存处理i++,但是并没有写回CPU CacheL3,第二个线程读取缓存的值依然是i=1,然后i++,也等于2,这时候第一个线程将i=2写回了内存,这个就是内存缓存不一致问题,最后我们想要的结果是Main Memory中的i=3,但是这时候有一个线程的数据就会被覆盖,最后的值i=2。
怎么解决?
CPU为了解决内存缓存不一致性问题制定了缓存一致性协议(MESI协议),还有其他手段(比如一个线程加锁,直到运行完毕,释放给另一个线程,效率太低不推荐)下文会提出
JMM是什么?
JVM虚拟机中解释
JMM(Java Memory Model),Java内存模型可以屏蔽各种硬件和操作系统的内存访问差异,以实现让Java程序在各种平台下都能达到一致的内存访问效果,简单的说Java程序都运行在自己的虚拟机里面,只要平台能装Java,都能够正确运行。
你可以把JMM堪称Java并发编程的一种规范,一套操作流程
如图
从上图看:
如果两个线程要通信,就要先从把线程一的共享变量同步到内存中,再从内存中读取到线程二的共享变量副本中
但是这个思想同样跟CPU 缓存会出现的问题一样
具体的操作,Java内存模型定义了八种同步方法
(摘抄JVM圣经内存模型与线程)
-
Lock(锁定)
作用于主内存的变量,把一个变量标识为一条线程独占状态。
-
unlock(解锁)
作用于主内存的变量,它把一个处于锁定状态的变量释放出来,释放后的变量才可以被其他线程锁定
-
read(读取)
作用于主内存变量,它把一个变量的值从主内存传输到线程的工作内存中,以便随后的 load 动作使用
-
load(载入)
作用于工作内存的变量,它把 read 操作从主存中变量放入工作内存中的变量副本中
-
use(使用)
作用于工作内存中的变量,它把工作内存中的变量传输给执行引擎,每当虚拟机遇到一个需要使用到变量的值,就会使用到这个指令
-
assign(赋值)
作用于工作内存中的变量,它把一个从执行引擎中接受到的值放入工作内存的变量副本中
-
store(存储)
作用于工作内存中的变量,它把一个从工作内存中一个变量的值传送到主内存中,以便后续的 write 使用
-
write(写入)
作用于主内存中的变量,它把 store 操作从工作内存中得到的变量的值放入主内存的变量中
了解了JMM规范,那么我们来解决MESI缓存一致性协议问题
MESI 缓存一致性协议是多个 CPU 从主内存读取同一个数据到各自的高速缓存中,当其中的某个 CPU 修改了缓存里的数据,该数据会马上直接同步回主内存,其它 CPU 通过总线嗅探机制可以感知到数据的变化从而将自己缓存里的数据失效。
在并发编程中,如果多个线程对一个共享变量进行操作,我们通常会在这个变量前加volatile,因为它可以保证线程对变量的修改的可见性。volatile的工作原理就是依赖于 MESI 缓存一致性协议实现的。
由此我们引出了volatile
volatile
先上圣经中对volatile特征的解释:
可见性
第一是保证修饰的变量对所有线程是可见的,一条线程修改了这个变量的值,新值对于其他线程来说是可以立即得知的
虽然每次使用变量前都会从主内存中刷新到自己高速缓存中,Java线程的执行引擎看不到不一致的情况,但是因为volatile不能保证原子性,并发情况下的运算也会出现不安全的情况
不能保证原子性
举个例子:
package offer; public class Main { public static void main(String[] args) { Thread[] threads=new Thread[10]; for(int i=0;i<threads.length;i++){ threads[i]=new Thread(()->{ for(int j=0;j<1000;j++){ increase(); } }); threads[i].start(); } for(Thread thread:threads){ try { thread.join(); } catch (InterruptedException e) { e.printStackTrace(); } } System.out.println("Final count: " + count); } public static volatile int count=0; public static void increase() { count++; } }
这个代码开启了10个线程,每个线程执行1000次increase方法,我们通过java的编译和反编译得到字节码指令
可以看到increase()方法虽然只有count++,但是他却有很多步骤,这就是指令,一个count++,第一个线程可能指令读取到一半,另一个线程已经更改了count的值,虽然第一个线程把最新值刷新到了自己的本地变量副本中,但是执行的变量却已经不是本地最新变量,所以说volatile不能保证原子性,并发情况下也会出现值小于理想值
那么你就会有疑问,那么这个加不加volatile,好像都会出现问题那么他的可见性有什么作用呢?
我们再看一个例子:
volatile boolean shutdownRequested; public void shutdown(){ shutdownRequested=true; } public void doWork(){ while(!shutdownRequested){ //代码逻辑 }
这时候可见性就发挥很大作用,即使第一个线程把最后操作完成变量返回到主内存,虽然第二个线程已经更改了主内存值,第一个线程覆盖了第二个线程的变量,但是对程序反而利大于弊,因为volatile是最轻量级同步机制,在这种情况下相比sychronized更加高效
禁止指令重排
什么是指令重排?
JVM会在不改变线程语意得前提下,安排指令执行顺序,来提升执行速度
下面例子帮助理解:
int a=1; int b=2; int c=a+b;
可见第一行代码和第二行代码交换顺序不会影响第三行代码得运行
静止指令重排优化,
package offer; public class Singleton { //正常创建对象Singeleton instance=new Singeleton(); private static volatile Singleton instance; //创建get方法 public static Singleton getInstance(){ if(instance==null){ synchronized (Singleton.class){ if(instance==null){ instance=new Singleton(); } } } return instance; } public static void main(String[] args) { Singleton.getInstance(); } }
创建完一个对像的过程:
1.为instance分配内存空间
2.初始化instance
3.将instance指向分配内存地址
对应得指令类似讲可见性那count++类似
这时候执行得顺序可能不是1,2,3 ,有可能是1,3,2,所以得到的实例可能是个未被初始化的,这时候volatile的作用就体现出来了,它会通过加内存屏障的方式让指令按顺序执行,内存屏障就是一条字节码指令,阻断某几行的指令重排
monitorenter同步块的进入
monitorexit同步块的退出
参考:
嘿,同学,你要的 Java 内存模型 (JMM) 来了:嘿,同学,你要的Java内存模型(JMM)来了_Java_Simon郎_InfoQ写作社区
javaguide:JMM(Java 内存模型)详解 | JavaGuide
深入理解java虚拟机440页