Java的内存模型
前言
今天周末,闲来无事,干嘛呢?当然看书啊,总结啊!读完书光回想是没用的,必须有个自己的第一遍理解,第二遍理解.....,就比如简简单单的JMM说来轻松,网上博客虽多,图文代码加以解释的甚少,并没有给读者一种层次感。所以我想写这么一篇博客,算是总结自己的第一遍理解,同时尽自己最大的可能让大家理解的时候有一种层次感。
整篇博客都是参考《深入理解Java虚拟机》加上自己读了两遍之后的理解完成的,创作不易,望转载告之,谢谢!
先在记录此篇博客之前,给一个大概的目录结构方便读者留意:
1、Java内存模型介绍
- 什么是内存模型?-------对比Cache存储层次结构
- 工作内存与主内存是什么?-----------结合线程理解
- 工作与主内存之间的交互-----------线程之间的交互(有图)
- 如何保证线程一致性?-------------八种操作的协议规定
2、Volatile关键字规则
- Volatile的两层含义-----------可见性与禁止重排序(代码举例)
- 关于Volatile的误解----------Volatile在高并发条件下不一定是安全的+Volatile并非原子性的(代码举例)
- Volatile与Synchronized简单比较--------------Volatile大部分情况下比Synchronized性能高(2020.11.11修正:其实1.6以后性能上没啥大区别)
3、double与long的非原子性
一、Java的内存模型介绍
第一次见到Java的内存模型,正如《深入理解JVM》中那样提到Cache与主存的关系,我也第一时间想起来了这个,于是便画了如下的存储系统的层次结构图
Cache与主内存之间为了能保持一致性,会不断跟Cache进行交互,也就是地址映像,主要有直接映像、全相联映像、组相联映像,emmmm...打住,这不是正题,只是顺便给自己个机会看《操作系统》就当做复习下,好了接下来是正题,先画出JMM图如下:
从图中可以看出要学好Java线程(高并发)是必须要知道JMM的,同时工作内存就好比Cache,与主内存之间进行交互,需要注意的的是这里的工作内存与主内存并不是我们所知道的内存这个概念,也不只是简单的Java Heap与Java Stack那样简单的概念,为了进一步知道工作内存与主内存是什么,接下来先了解它们,此时你可以先不用看图,了解后再看更佳。
1、工作内存与主内存是什么?它们有什么规定?
(1)工作内存:每条线程都有自己的内存,这就是工作内存,在工作内存中主要是保存使用到的变量在主内存的拷贝(即存放主内存中工作内存用到的变量拷贝);
(2)主内存:VM内存的一部分,是新增变量的地方以及每个线程中所有变量来源之处,是可以被共享的数据元素。
(3)内存模型中的变量:是指实例字段与静态字段构成数组对象的元素,即能被共享的数据元素,而不是被线程私有的局部变量与方法参数等。所有的变量都会存储在主内存(VM内存的一部分)中;
(4)每条线程对变量的操作都必须在工作内存中进行,而不能直接操作主内存;
(5)每条线程之间的工作内存是不能被共享的,不能相互访问各自的变量,线程之间的变量“交流”只能通过主内存来实现;
(6)如果非要将JMM中的主内存与工作内存跟Java Heap、Java Stack、Method Area做比较(实则两者不是一个概念),那么可以认为工作内存就是Java Stack(很好理解,这是因为Java Stack是线程私有的,线程之间不能共享),主内存就是Java Heap中实例数据(很好理解,Java Heap中对象的实例数据是可以共享的)
2、工作内存与主内存之间的交互
这是理解多线程最重要的部分,多线程必然会涉及到内存之间的交互,Java的多线程之间的交互实则就是工作内存与主内存之间的交互,那么它们之间肯定要有相互交互的规定(即协议),主要分为八种:
(1) Lock:作用于主内存的变量,将该变量标识为某一条线程独占的资源,其他线程不能占用。
(2) Unlock:与Lock相反,作用于主内存变量,释放被Lock的变量。
(3)Read:作用于主内存的变量,将该变量从主内从中取出,传到线程的工作内存中。之后Load加载到工作内存的变量副本中。
(4) Load:将Read取到的变量放入工作内存的变量副本中。
(5) Use:将工作内存中变量传递给执行引擎,听从VM指令的安排(被使用到时会有相关指令)
(6)Assign:接受执行引擎返回Use之后执行的结果值,将该值赋值给工作内存中对应的变量。
(7)Store:将功能内存中的值传递到主内存中,之后Write放入主内存的变量中。
(8) Write:将Store的值放入主内存的变量中。
好了,突然一下子要记住八种操作,头也会而且可能还记不住,那么结合图总结下吧:
(1) 要把一个变量从主内存copy到工作内存,只需要Read->Load顺序即可。
(其中Variable Duplicate变量拷贝是属于工作内存Working Memory的,这里主要是为了能更好的展示,所以分离了,希望不要误解!)
(2)如果把工作内存的变量同步回主内存,只需要Store->Write顺序即可。
(其中Variable是属于Main Memory的,这里主要是为了能更好的展示,所以分离了,希望不要误解!)
(3) 如果VM用到这个变量(即有关的操作指令),则执行Use->Assign即可。
这里就不画图了,简单来说就是我们在程序中用到变量,对变量初始化、更新等,也就是只要在VM中有相关操作该变量的指令,就会从工作内存中被Use,之后Assign赋值会写回公共内存,如i++,先拿到i,之后i+1,最后赋值i=i。
注意:这些操作之间并不要求一定要连续,只要保证前后顺序即可,比如Read A, Read B, Load A, Load B即可,而不需要Read A, Load A, Read B, Load B
3、如何保证线程的一致性?
微观上讲我们需要实现线程的一致性这个目标,而宏观上就是如何确保在高并发下是安全的,其实主要是通过八种操作之间的规定,才能保证多线程下一致性:
(1) 不允许read和load,store和write中单一操作出现;
(2)不允许最近的赋值动作即assgin被丢弃(工作内存中变量改变了必须同步回主内存中);
(3) 不允许线程中变量没有改变(即没有assign操作),就把该变量数据同步回主内存(不接受毫无理由的同步回主内存);
(4) 一个变量的产生只能在主内存中,不允许工作内存使用一个未被初始化(即未被assgin赋值或load加载)的变量,即一个新的变量产生必须在主内存中,再read->load到工作内存变量副本中,之后进行中Use->Assign赋值,最后才有可能stroe->write同步回主存,换句话说就是对一个变量进行use/store之前必须先进行assign/load操作。
(5)Lock与Unlock操作是成对出现的,一个变量只能被一个lock操作,一个线程可以多次lock操作。
(6) 一个线程的lock与unlock操作要对应,不允许线程A的unlock线程B的变量。同理,如果没有lock,那么不允许unlock。
(7) 一个变量执行lock操作,会将工作内存中对应的变量清空,在执行引擎获取这个变量之前,必须load/assgin初始化这个变量,这是因为执行引擎要获取的变量必须是最新的值,在lock-unlock过程中该变量可能发生改变,所以必须重新初始化保证获得最新的值。
二、Volatile关键字
实际上,我们在程序中操作的变量是工作内存的变量副本,那么每次变量被改变(Use->Assign)后,都会同步回(Store->Write)主内存中,保持了变量的一致性,但是这只是单线程的情况下,那么在多线程情况下呢?比如线程A已经改变了变量的值,还没来的及同步回主内存,线程B就已经从主内存中将旧的变量值Read->Load到工作内存。这就造成了被线程A修改后的变量值对线程B不可见的问题,导致变量不一致。最轻量的能解决此问题就是利用好Volatile关键字,那么Volatile是如何实现的呢?
简单来说被Volatile关键字的变量一旦被改变后就会立即同步回内存中,保证其他线程能获得最新的当前变量值,而普遍变量不会立即同步回内存(事实上什么时候同步回内存是不确定的),所以导致不可见性。
(1)保证此变量对所有线程的可见性:
① 线程的可见性并不是误认为“Volatile对所有线程的立即可见,也就是对某个变量写操作立马能反映到所有线程中,因此在高并发的情况下是安全的”,“Volatile在高并发下是安全的”这个最后的结论是不成立的。
② Java中相关的操作并不是原子操作,比如i++,其实是分为两步(可以使用Javap反编译查看指令代码)的:先i+1,之后i=i+1。所以Volatile在高并发情况下并不是安全的。
1 /** 2 * 演示使用Volatile在高并发下状态的不安全性: 3 * @author Jian 4 * 5 */ 6 public class VolatileDemo { 7 private static final int THREAD_NUM = 10;//线程数目 8 private static final long AWAIT_TIME = 5*1000;//等待时间 9 private volatile static int counter = 0; 10 11 public static void increase() { counter++; } 12 13 public static void main(String[] args) throws InterruptedException { 14 ExecutorService exe = Executors.newFixedThreadPool(THREAD_NUM); 15 for (int i = 0; i < THREAD_NUM; i++) { 16 exe.execute(new Runnable() { 17 @Override 18 public void run() { 19 for (int j = 0; j < 1000; j++) { 20 increase(); 21 } 22 } 23 }); 24 } 25 //检测ExecutorService线程池任务结束并且是否关闭:一般结合shutdown与awaitTermination共同使用 26 //shutdown停止接收新的任务并且等待已经提交的任务 27 exe.shutdown(); 28 //awaitTermination等待超时设置,监控ExecutorService是否关闭 29 while (!exe.awaitTermination(AWAIT_TIME, TimeUnit.SECONDS)) { 30 System.out.println("线程池没有关闭"); 31 } 32 System.out.println(counter); 33 } 34 }
按道理说最后变量i的结果应该是10*1000=10000,但是运行后你会发现输出结果都是小于10000且各不相同的值,造成这样的结果实则不是Volatile的锅,而是Java的非原子性,只是希望我们在关注并使用Volatile关键字的时候需要知道在高并发下不一定是安全的。
(2)使用Volatile可以禁止指令重排序优化:
也就是一般普通变量(未被Volatile修饰)只能保证最后的变量结果是对的,但是不会保证变量涉及到的程序代码中顺序与底层执行指令顺序是一致。需要注意的是重排序是一种编译过程中的一种优化手段。
下列只能用伪代码的形式举例,因为指令重排序涉及到反编译指令码等(我并不了解,实际上一点也不)
1 public class VolatileDemo2 { 2 //是否已经完成初始化标志 3 private /*volatile*/ static boolean initialized = false; 4 private static int taskA = 0; 5 public static void main(String[] args) throws InterruptedException { 6 ExecutorService exe = Executors.newFixedThreadPool(2); 7 //线程A 8 exe.execute(new Runnable() { 9 @Override 10 public void run() { 11 //A线程的任务是加1,完成初始化 12 taskA++; 13 //initialized初始化完成,赋值为true,必须是先执行+1操作,才能赋值true 14 //但是由于重排序这里可能先于taskA++执行,导致读取到的结果可能为0。 15 initialized = true; 16 } 17 }); 18 exe.execute(new Runnable() { 19 @Override 20 public void run() { 21 //线程B的任务是等待线程A初始化完成后,再读取taskA的值 22 while(!initialized) { 23 try { 24 System.out.println("线程A还未初始化"); 25 Thread.sleep(1000); 26 } catch (InterruptedException e) { 27 e.printStackTrace(); 28 } 29 } 30 System.out.println(taskA); 31 } 32 }); 33 exe.shutdown(); 34 while (!exe.awaitTermination(5*1000, TimeUnit.SECONDS)) { 35 System.out.println("线程池没有关闭"); 36 } 37 } 38 }
需要主要的就是下面的代码,虽然线程A中是保证了有序执行,再标志初始化完成,但是在指令中可能是先赋initialized为true,然后线程B这时候“抢先一步”先读initialized,那么变量taskA的值就可能为0(实际业务中可能会是致命错误!)
taskA++; initialized = true;
如果不使用volatile关键字,那么只有当明确赋值了initialized的方法被调用,接下来的任务才能不会出错(只要结果是true就行,不用管指令顺序):
boolean volatile initialized; public void setInitialized(){ initialized = true; } public otherWorks(){ //初始化完成方法被明确调用,强制initialized结果为true,不用管指令顺序 setInitialized(); while(!initialized){ //other thread's tasks } }
(3)Volatile与Synchronized性能对比:一般情况下Volatile的同步机制要优于Synchronized(但是VM对Synchronized做了很多优化,所以其实也是说不准的),但是Volatile好就好在读取变量跟普通变量的读取几乎没啥差别,但是写操作会慢一点(这是因为会在代码中加入内存屏障,保证指令不会乱序)
4、double与long型变量非原子性
(1)double与long的非原子性:在JMM中规定long与double这样的64位并且没有被volatile修饰数据可以划分为两部分32位来进行操作,即VM允许对64位的数据类型的load、store、read、write不保证其原子性。因为非原子性的存在,按理论上来说某个线程在极小的概率下可能会存在读到“半个变量”的情况。
(2)虽然由于long与double非原子性存在,但是VM对其的操作是具有原子性的,即对操作原子性,对数据非原子性。所以long与double不需要被要求加volatile关键字。