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

105 阅读10分钟

本文:理论知识偏多, 可自行掌握。 大纲内容

  • 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定义了八种原子操作来解决线程不安全问题

  1. lock:锁定,作用于主内存中,把一个共享变量标记为一条线程的独占状态。
  2. unlock:解锁,作用于主内存中,把一个处于独占状态的共享变量释放出来,释放出来的变量能被其他线程获取。
  3. read:读取主内存中的共享变量。
  4. load:把读取到的共享变量加载进线程的工作内存中。
  5. use: 把工作内存中的变量交给执行引擎进行处理。
  6. assign:接受被执行引擎处理的值,复制给工作内存中的变量。
  7. store:把工作内存的结果值,传送给主内存中。
  8. 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 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,能保证程序 "顺序" 安全执行完。