并发(一):JMM和Volatile关键字

本文:理论知识偏多, 可自行掌握。
在这里插入图片描述

大纲内容

  • 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,能保证程序"顺序"安全执行完。

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值