本文:理论知识偏多, 可自行掌握。
大纲内容
- Java内存模型
- 什么是JMM
- 重排序
- happens-before
- as-if-serial
- volatile关键字的用法
什么是JMM
JMM是一种抽象思想,是一组规范,定义了程序中每个共享变量的访问方式,JMM是围绕着原子性,有序性,可见性展开的。JMM定义了线程和主内存之间的抽象关系,主内存中主要保存着共享变量,每个线程中都有工作内存,是线程私有的,线程对共享变量的操作必须在工作内存中进行,首先要把变量从主内存中拷贝到自己的工作内存,然后对变量进行操作,操作完后再写回主内存中,不能直接操作主内存中的变量。
线程之间的通信必须依靠主内存来进行读写的。
通俗理解: 当线程A修改了值,线程B想要获取新值,则必须等线程A写回主内存中,线程B再去读主内存的新值,此举就达成"通信"的概念
1:初始值假设x=0,当线程A从主内存中读取x值时,放入到线程A的本地内存中修改,将x=0修改成了x=1。
2:当线程B想要获取到最新值,必须要等线程A把x=1的更新操作写回主内存中,线程B再去主内存中获取x值时,才能拿到最新值。
在案例中,任何线程都不能去主内存中进行更新操作,如果两个线程都同时对一个共享变量进行操作,会引发线程不安全问题,比如线程A想用对x+1操作,线程B想用对x+3操作,当x=0是默认值时,此时x的最终正确值应该是:4,而线程不安全,最终的结果可能是: 1,3,4。
为1时:说明线程A和线程B同时把主内存中的共享变量x=0都读取到自己的工作内存中,线程A中结果为1,线程B结果为3,但线程B先写回主内存中,线程A再写回主内存中,把结果为3的值覆盖成了1。
为3时:结果同上,线程B的结果把线程A的结果给覆盖了。
为4时:说明是串行的,线程A执行完后把结果写回了主内存中,线程B拿到了最新的主内存再进行计算(也有可能是线程B先获取,线程A再计算)。
个人理解:线程不安全指的是多线程下, 无法准确获取到正确值。
这其中有一个有序性原则,而下文的volatile就保证了有序性和可见性,锁可以保证原子性,有序性,可见性。
问题1:线程之间如何保证通信?
1:Java内存模型,通过主内存和共享内存的约束,保证线程之间的通信。
2:阻塞队列/同步队列可以保证线程之间通信。
JMM定义了八种原子操作来解决线程不安全问题
lock:锁定,作用于主内存中,把一个共享变量标记为一条线程的独占状态。
unlock:解锁,作用于主内存中,把一个处于独占状态的共享变量释放出来,释放出来的变量能被其他线程获取。
read:读取主内存中的共享变量。
load:把读取到的共享变量加载进线程的工作内存中。
use: 把工作内存中的变量交给执行引擎进行处理。
assign:接受被执行引擎处理的值,复制给工作内存中的变量。
store:把工作内存的结果值,传送给主内存中。
write:把最终的结果值,更新进主内存的共享变量中。
其中read和load是把主内存中的值读到工作内存中,store和write是把工作内存的最新值写回主内存中。
问题2: JMM和JVM有什么区别?
JMM是虚拟的一种规则,主要是约束了各个线程访问共享变量的一种规范,同时是线程之间通信的一种方式,而JVM是真实的程序,唯一的相似点就是:都存在共享区域的概念,都存在线程私有区域的概念。
重排序
如果重排序后的结果跟顺序执行的结果一致时,程序优先使用重排序,提高执行效率,重排序是只指优化器和处理器为了优化程序代码,对指令序列进行重新排序的一种优化手段,如果代码中存在数据依赖,则该代码块不允许重排序。
在DCL单例模式中,使用了volatile关键字来防止重排序。
happens-before
程序顺序规则
在单线程场景下,按照程序代码的执行顺序,先执行的操作happens-before后续的操作,处理器和编译器可对不存在数据依赖的代码进行指令重排序,目的是为了提高执行效率。
volatile变量规则
对一个volatile变量的写操作是happens-before于后续对该变量的读操作,同时volatile保证了有序性和可见性,同时禁止指令重排。
加锁/解锁规则
一个解锁操作是happens-before下一个加锁操作之前。
线程启动规则
线程的start()是happens-before该线程的所有方法。即如果线程A在执行线程B的start方法之前修改了共享变量的值,那么当线程B执行start()方法时,线程A对共享变量的修改对线程B是可见的。
线程销毁规则
该线程的所有方法都happens-before该线程的销毁方法
线程中断规则
执行该线程的interrupt()方法是happens-before该线程去检验是否发生过中断操作。
对象终结规则
一个对象初始化是happens-before该对象的finalize()方法。finalize()方法就像垃圾回收对象的复活甲一样,如果该对象重写了该方法并引用了引用链上的某个对象,则不会被回收。
as-if-serial
保证单线程内的执行结果不会被改变,如果代码之间不存在数据依赖,处理器和编译器会对代码进行重排序提高并发性,感觉重排序是基于as-if-serial规则的。
volatile关键字
volatile可以保证变量的可见性和有序性。
可见性的意思是当一个线程修改被volatile修饰的变量时,修改完后会立即写回主内存中,其他线程可以立即读到被修改的最新值。
有序性的意思是打破了重排序的指令排序,使用内存屏障禁止指令重排,内存屏障分为四种:读读屏障,读写屏障,写写屏障,写读屏障。
-
读读屏障
保证第一个volatile读优先于第二个volatile读。 -
读写屏障
保证第一个volatile读优先于第二个volatile写。
-
写写屏障
保证第一个volatile写操作优先于第二个volatile写操作写进主内存中。
-
写读屏障
保证第一个volatile写操作优先于第二个volatile读操作,即写操作立即写进主内存中,才开始读的操作。
内存屏障:1:保证指令重排,2:强制刷出各种CPU的缓存数据,因此任何CPU上的线程都能读取到最新的数据。
问题3:为什么要禁止指令重排呢?
1:若不加锁,会造成线程不安全,即两个线程都去new对象。
2:加了两次if判断,个人理解也是大题小作,第一个if判断就是多余的,加了是考虑锁的范围太大,带来性能消耗。
3:如果没有volatile关键字,在new对象时,指令分为三步:给对象分配内存空间,初始化对象,将对象引用指向内存地址,其中第二个操作和第三个操作是可以进行指令重排的,假设先执行第三个操作,再执行第二个操作,当线程A进行指令重排时,并且释放完锁,此时线程B判断不为空,但由于线程A的引用对象其实还没有完成初始化,线程B拿到的instance对象是未初始化的,此时调用对象中的属性就可能会报错。
其实有个疑惑:既然加锁了,只有第一个线程代码块都执行完,释放锁后,第二个线程才会去判断是否为null,那既然第一个线程执行完了代码块后,还存在初始化的流程没有执行完?
public class DclSington{
private volatile static DclSington instance;
private DclSington(){
//禁止外部new该对象
}
public static DclSington getSington(){
if(instance==null){
synchronized(DclSington.class){
if(instance==null){
instance = new DclSington();
return instance;
}
}
}
return instance;
}
}
当写一个volatile变量时,JMM会把该线程对于的本地内存中的变量立即刷新进主内存中。
当读一个volatile变量时,JMM会把该线程对于的本地内存中的变量视为无效,重新去主内存中获取最新值。
volatile只能保证有序性和可见性,但无法保证原子性,下面的代码正确结果是:1000
但结果却不是1000,为什么呢?
因为使用volatile有一个前提:被volatile修饰的变量,不能依赖于上次的原值。
个人理解,在并发情况下,因为无法保证原子性,可能同时会有两个线程的工作内存拿到的都是相同值,同时写回主内存中,造成了线程不安全。
public static volatile Integer key =0;
public static void main(String[] args) {
CountDownLatch countDownLatch = new CountDownLatch(1);
for (int i = 0; i < 1000; i++) {
new Thread(() -> {
try {
countDownLatch.await();
key++;
} catch (InterruptedException e) {
e.printStackTrace();
}
}).start();
}
try {
Thread.sleep(5000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
countDownLatch.countDown();
try {
Thread.sleep(5000L);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(key);
}
问题4:volatile和static有什么区别?
volatile能保证数据的有序性,可见性,如果主内存中的变量被volatile修饰,在工作内存中被修改,会立即刷新到主内存中,让工作内存和主内存的变量保证一致。
static是保证数据的唯一性,不保证数据的有序性和可见性。
原子性: 一个操作是不可中断的,即使在多线程场景下,一个操作一旦开始就不会被其他线程影响。
可见性:当一个线程修改了某个共享变量的值后,其他线程能够立即读取最新值。
有序性:如果不遵循happens-before原则,则不能保证有序性。
单线程内的as-if-serial能保证程序执行的顺序,只要重排序的结果和顺序执行的结果一致时,优先考虑重排序,而多线程场景下,重排序带来的乱序,会导致各个线程间的顺序未必一致。前者是单线程内保证串行语义执行的一致性,后者是指令重排现象导致工作内存和主内存同步延迟的问题。
问题5:如何保证程序的原子性,可见性,有序性?
使用锁,能保证同一时刻,只能由一个线程访问临界资源,比如synchronized和Lock。同时锁还能保证可见性和有序性,在加锁的情况下,一个线程去处理,相当于是单线程,而单线程因为有as-if-serial,能保证程序"顺序"安全执行完。