在说java内存模型之前,先澄清下JVM内存结构与Java内存模型
JVM内存结构和Java虚拟机的运行时区域有关
Java内存模型和Java的并发编程有关
JVM内存结构,我们都知道java是要运行在虚拟机上的,而虚拟机在执行Java程序的过程中会把所管理的内存划分为若干不同的数据区域,这些区域都有各自的用途。在《Java虚拟机规范jdk8》中说了JVM运行是内存区域结构可以分为6个区。
堆区
虚拟机栈
方法区
本地方法栈
程序计数器
运行时常量池
以上是java虚拟机规范,不同的虚拟机实现会各有不同,一般会准守规范
这里总结下,JVM内存结构由java虚拟机规范定义的,描述的是java程序执行过程中,由JVM管理的不同数据区域,各个区域有其特定的功能。
以上是JVM内存结构,现在说下Java内存模型上,我们都知道,编写的Java代码,最终还是转化成CPU指令才能执行。我们先回顾下从Java代码到最终执行的CPU指令的大致流程:
最开始,我们编写的Java代码,是*.java文件
在编译(包含词法分析、语义分析等步骤)后,在刚才的.java文件之外,会多出一个新的Java字节码文件,(.class)
JVM会分析刚才生成的字节码文件(*.class),并根据平台等因素,把字节码文件转化为具体平台上的机器指令。
机器指令则可以直接在CPU上运行,也就是最终的程序执行。
那么为啥会有JMM,在早期语言中,其实不存在内存模型的概念。
所以程序最终执行的效果会依赖具体的处理器,而不同的处理器的规则又不一样,不同的处理器之间的差异很大,因此同样一段代码,可能在处理器A上运行正常,而在处理器B上运行的结果却不一致。同理在没有JMM之前,不同的JVM实现,也会带来不同的翻译结果。
所以Java非常需要一个标准,来让开发者、编译器工程师和JVM工程师能够达成一致,达成一致后,我们就可以很清楚的知道什么的代码最终可以达到什么样的效果,让多线程运行结果可以预期,这个标准就是JMM,这也就是JMM出现的原因。
下面我们要研究从Java代码到CPU 指令的这个转化过程要遵循哪些和并发相关的原则和规范,这就是JMM的重点内容。如果不加以规范,那么同样的Java代码,完全可能产生不一样的执行效果,那是不可接受的。
JMM是什么
JMM是一个规范,是和多线程相关的一组规范,是需要各个JVM的实现来遵守的JMM规范,以便开发者可以利用这些规范,更方便的开发多线程程序。这样一来即便同一个程序在不同的虚拟机上运行,得到的程序结果也是一致的。
如果没有JMM内存模型规范,那么很可能在经过不同JVM翻译之后,导致在不同的虚拟机上运行的结果不一样。
因此,JMM与处理器、缓存、并发、编译器有关,它解决了CPU多级缓存、处理器优化、指令重排序等导致的结果不可预期的问题。
JMM工具类和关键字的原理,比如volatile、synchronized、Lock等,其实他们都涉及到JMM,关键字synchronized,JVM就会在JMM规则下,“翻译”出合适的指令,包括限制指令之间的顺序,以便在即使发生重排序的情况下,也能保证必要的“可见性”,这样一来,不同的JVM对于相同的代码的执行结果就变的可预期了,我们只需要用好同步工具和关键字就可以开发出正确的并发程序了。
JMM里最重要的3点,重排序、原子性、内存可见性。
重排序,假设写一个Java程序,包含一系列的语句,我们会默认期望这些语句的实际运行顺序和写的代码顺序一致,但是实际上,编译器、JVM或者CPU都有可能出于优化等目的,对于实际指令执行的顺序进行调整,这就是重排序。
重排序的好处:提高处理速度。
举个例子,重排序之前
部分指令执行情况
a=100; Load a Set to 100 Store a
b=5; Load b Set to 5 Store b
a=a+10; Load a Set to 110 Store a
从这个上面我们可以看出来,会有两次Load a 和两次Store a,存在一定的重排序的优化空间
重排序后,
a=100; Load a Set to 100 Set to 110 Store a
a=a+10 ;
b=5 ; Load b Set to 5 Store b
重排序一般有3中情况
(1)编译器优化
包括JVM JIT编辑器等,重排序并不意味着可以任意排序,它需要保证重排序后,不改变单线程内的语义,否则如果能任意排序的话,程序就乱了
(2)CPU重排序
CPU同样会有优化行为,这里的优化和编译器优化类似,都是通过乱序执行的技术来提高整体的执行效率。所以即使之前编译器不发生重排序,CPU也可能进行重排。
(3)内存的“重排序”
内存系统不存在真正的重排序,但是内存会带来看上去和重排序一样的效果 ,所以重排序打上引号,由于内存有缓存的存在,在JMM里表现为主存和本地内存,而主存和本地内存的内容可能不一致,所以会导致程序表现出乱序的行为。
原子性和原子操作
什么是原子性和原子操作
原子性操作,具有原子性的操作是原子性操作,比如转账操作,转账这边的人要进行扣款,银行会有转账记录,收到转
账的人账号金额要增加,这一系列操作不能中间中断,再举一个不具有原子性操作的例子,i++,初始i的值,然后i+1
,最后在保存这个值,当两个线程,都来操作这个变量,当线程1,操作到i+1时,CPU终止这个线程1,CPU又用线程2,
来操作这个变量i,这个时候,取到的i的值还是1,因为线程1操作i的值还没有保存下来,没有执行完成。线程2这个时
候继续执行i+1,这个时候i变为2。这个时候CPU又把线程2停止了,转到线程1上继续之前的操作,保存i的值,保存成2
。然后CPU暂停线程1,转到线程2,线程2继续执行,保存下来的也是2,而不是3。这个就是典型的线程安全问题。在
java中有8中基本数据类型,其中除了long类型和double类型,其余6中基本数据类型都自带原子性,来保证线程安全性
,但是long和double两个数据类型,没有自带原子性,建议加volatile关键字修饰,但是我们再编码的时候使用long和
double的时候也没有加volatile,是JVM虚拟机强烈建议,把该操作,作为原子操作来实现。
保证内存可见性,volatile只能保证内存可见性,不能保证原子性。synchronized既能保证线程安全,应该说它既保证
内存可见性,又能保证原子性,内存可见性在前一个线程释放锁之后,之前所做的所有修改,都能被获得同一个锁的下
一个线程所看到,也就是能读取到最新的值。原子性,临界区内,最多同时只允许一个线程执行操作。
为什么会发生可见性的问题
CPU是有多级缓存构成,导致读的数据存在过期性。由于CPU处理的速度很快,内存的速度就显得很慢,所以就出现CPU
多级缓存的存在,就是在CPU和内存之间存在 一个缓存层,缓存层有三层,从下往上一次是L1、L2、L3,越往上速度越
快,容量也越小。因为出现多级缓存,肯定会导致数据读取的不一致行。
Java语言,屏蔽的CPU的L1 L2 L3,这些多级缓存的底层作用,我们只需要
主内存和工作内存
Java语言,屏蔽了CPU 的多级缓存L1 L2 L3等这些底层的缓存细节,我们只需要关心JMM抽象出来的主内存和工作内存
,如图,每个线程只能操作各自的工作内存,而不能操作主内存,工作内存里保存的是主内存中的副本,主内存和工作
内存之间通信的是靠JMM控制。
JMM有以下规定:
所有变量都保存在主内存中,同时每个线程拥有自己独立的工作内存,
工作内存的变量是从主内存中拷贝出来的
线程不能直接操作主内存的数据,只能操作自己工作内存的变量,然后再同步
到主内存中,这样其他线程就可以看到本次修改
主内存是由多个线程所共享的,但是线程间不共享各自的工作内存,每
个线程必须操作各自的工作线程,然后同步到主内存,再共享给其他线程
happens-before
happens-before规则是用来描述和可见性相关的问题的:
如果第一个操作happens-before第二个操作
那么第一个操作对于第二个操作一定是可见的
也就是第二个操作在执行时就一定能保证看见第一个操作执行的结果
不具备happens-before规则的例子
happens-before规则有哪些?
如果分别操作x和操作y,用hb(x,y)来表示x happens-beforey
(1)单线程规则:
在一个单独的线程中,按照程序代码的执行流顺序
(时间上的先后顺序,下同)先执行的操作happens-before后执行的操作
也就是,如果操作x和操作y是同一个线程的两个操作
并且在代码执行上x先于y出现,那么有hb(x,y)
Happens-beford规则和重排序冲突,如果happens-before,就不能重排序吗?
答案是否定的,例如,单线程内,语句1在语句2的前面
根据“单线程规则”,语句1happens-before语句2
例如语句1修改的是变量a的值,而语句2的内容和变量a无关
那么语句1和语句2依然有可能被重排序
如果语句1修改的是变量2,而语句2正好是去读取变量a的值
那么语句1就一定会在语句2之前执行。
(2)锁操作的规则(synchronized和Lock接口等) 重点
如果操作A是解锁,而操作B是对同一个锁的加锁,那么hb(A,B),在解锁M之前的所有操作,对于加锁M之后的所有操
作都是可见的。
(3)volatile变量规则, 重点
对一个volatile变量的写操作happens-before后面对该变量的读操作
这就代表了如果变量被volatile修饰,那么每次修改之后其他线程在读取这个变量的时候一定能读取到该变量最新的值
volatile关键字能保证可见性。正式有本条规则所规定的
(4)线程启动规则
Thread对象的start方法happens-before此线程run方法中的每一个操作
线程启动规则
子线程B 在执行run()方法里面的语句的时候,一定会看到,thread.start()之前的所有结果
(5)线程join规则
join可以让线程之间等待,
在join方法返回后,线程B的run方法里面的操作happens-before线程A的join之后的语句,
就是线程A中 它threadB.join()后面的方法,都可以看见线程中的run()里的内容
(6)中断规则
对线程interrupt方法的调用happens-before检测该线程的中断事件
如果一个线程被其他线程interrupt
那么在检测中断时(比如调用Thread.interrupt或者Thread.isInterrupt方法)时,一定能看到此次中断的发生,不会
发生检测结果不准的情况。
(7)工具类的Happens-before规则
线程安全的并发容器(如ConcurrentHashMap)
在get某个值时一定能看到在此之前发生的put等存入操作的结果
线程安全的并发容器的存入操作happens-before读取操作
信号量(Semaphore)会释放许可证,也会获取许可证
这里的释放许可证的操作happens-before获取许可证的操作
如果在获取许可证之前有释放许可证的操作,那么在获取时一定可以看到
Future:Future任务中的所有操作happens-before Future的get操作
线程池:要想利用线程池,就需要往里面提交任务(Runnable或者Callable)
这里面也有一个Happens-Before原则,那就是提交任务的操作happens-before任务的执行
volatile 和synchronized区别
volatile是一种同步机制
当某个变量是共享变量,且这个变量是被volatile修饰的,那么在修饰了这个变量的值之后再读取该变量的值时,可以
保证获取到的值是修改后的最新的值。
相比synchronized或者Lock,volatile是更轻量的
使用volatile不会发生上下文切换等开销很大的情况,不会让线程阻塞,但是它的效果,也是能力,相对也小。
volatile仅在有限的场景中才能发挥作用。
不适合使用volatile场景,不适合需要保证原子性的场景,比如a++,更新的时候需要原来的值,使用volatile是不能
保证线程安全的。
适用场景:1、布尔标记位,如果某个共享变量自始至终只是被各个线程所赋值或读取,而没有其他的操作,那么就可
以使用volatile来代替synchronized或者代替原子类。因为赋值操作自身具有原子性的,volatile同时又保证可见性。
一个典型的场景就是布尔标记位的场景,例如volatile boolean flag,
通常情况下,boolean类型的标记位会被直接赋值
此时不会存在符合操作(如a++),只存在单一操作
flag被volatile修饰之后,就保证了可见性
那么这个flag就可以当作一个标记位
此时它的值一单发生了变化,所有线程都可以立刻看到
2、作为触发器
volatile的作用:
第一层的作用是保证可见性
第二层的作用是禁止指令重排序
volatile和synchronized的关系
相似性
volatile可以看做是一个轻量级的synchronized
比如一个共享变量如果自始至终只被各个线程赋值和读取,而没有其他操作的话
就可以用volatile来代替synchronized或者代替原子变量,足以保证线程安全
对volatile字段的每次读取或者写入都类似于“半同步”
读取volatile与获取synchronized锁有相同的内存语义
写入volatile与释放synchronized锁有相同的内存语义
不可代替
volatile是不能代替synchronized的,volatile并没有提供原子性和互斥性
性能方面
volatile属性的读写操作都是无锁的,是高性能的,比synchronized性能更好。
单例模式,双重校验,并且用volatile修饰变量,作用是禁止指令重排序。