首页 > 技术文章 > 【JUC】如何理解Java的volatile关键字?

xdcat 2020-05-31 14:13 原文

并发和并行的区别

并行(parallel):指在同一时刻,有多条指令在多个处理器上同时执行。所以无论从微观还是从宏观来看,二者都是一起执行的。

并发(concurrency):指在同一时刻只能有一条指令执行,但多个进程指令被快速的轮换执行,使得在宏观上具有多个进程同时执行的效果,但在微观上并不是同时执行的,只是把时间分成若干段,使多个进程快速交替的执行。

并行在多处理器系统中存在,而并发可以在单处理器和多处理器系统中都存在,并发能够在单处理器系统中存在是因为并发是并行的假象,并行要求程序能够同时执行多个操作,而并发只是要求程序假装同时执行多个操作(每个小时间片执行一个操作,多个操作快速切换执行)。

如何理解volatile?

JUC中需要重点关注的包:java.util.concurrent、java.util.concurrent.atomic (原子)、java.util.concurrent.locks
 
volatile是JVM提供的轻量级的同步机制,它具有以下三个特性:
  1. 保证可见性
  2. 不保证原子性
  3. 禁止指令重排(有序)

Java内存模型(JMM)

抽象概念,是一组规则或规范。定义了各个变量的访问方式。
 
关于同步的规定:线程解锁前,必须把共享变量的值刷回主内存。加锁前,必须把主内存的值加载到工作内存。加锁和解锁是同一把锁。数据的一致性是通过JMM的以下三个特性实现的:
  1. 保证可见性
  2. 保证原子性【volatile不可以保证原子性】
  3. 保证有序性

volatile的可见性保证

JVM运行程序的实体是线程,线程创建时,JVM会为它创建一个工作内存(栈空间),栈空间是线程私有的,JMM规定所有的变量存储在主内存中。主内存是共享区域,所有的线程都可以访问。但是线程对变量的操作必须在工作内存中,所以要将变量从主内存拷贝到工作内存中,操作结束后再写回主内存(并告知其他线程,实现可见性)。线程通信通过主内存来完成。
读取速度:硬盘<内存<缓存<CPU
线程T1修改了i之后 通知T2、T3值有变动,就是JMM规定的保证数据的可见性。
 
如果一个线程A在其工作内存中修改了变量X的值还没有写回主内存,线程B又对主内存中的变量X进行了操作,但此时A线程工作内存中共享变量X对线程B来说是不可见的。可以看出,工作内存和主内存同步存在延迟,所以通过可见性来解决这个问题。

为什么volitale不能保证原子性?

Java中只有对基本类型变量的赋值和读取是原子操作,如i = 1的赋值操作,但是像j = i(先读取i的值,再将i的值赋值给j)或者i++(拿i的值,值加一,把值写回i)这样的操作都不是原子操作。

所以,如果一个变量被volatile修饰了,那么肯定可以保证每次读取这个变量值的时候得到的值是最新的,但是一旦需要对变量进行自增这样的非原子操作,就不会保证这个变量的原子性了。

一个变量i被volatile修饰,两个线程想对这个变量修改,都对其进行自增操作,i++的过程可以分为三步,首先获取i的值,其次对i的值进行加1,最后将得到的新值写会到缓存中。

线程A首先得到了i的初始值100,但是还没来得及修改,就阻塞了,这时线程B开始了,它也得到了i的值,由于i的值未被修改,即使是被volatile修饰,主存的变量还没变化,那么线程B得到的值也是100,之后对其进行加1操作,得到101后,将新值写入到缓存中,再刷入主存中。根据可见性的原则,这个主存的值可以被其他线程可见。

线程A已经读取到了i的值为100,也就是说读取的这个原子操作已经结束了(读取比较的还是B修改之前的值,读取结束后才通知到A主存的值已经改了,但这时候A不和主存比较了),所以这个可见性来的有点晚,线程A阻塞结束后,继续将100这个值加1,得到101,再将值写到缓存,最后刷入主存,所以即便是volatile具有可见性,也不能保证对它修饰的变量具有原子性。

如何解决volitale不保证原子性的问题?

  1. 使用synchronized,因为synchronizated对性能影响较大,没必要杀鸡用牛刀。
  2. 使用java.util.concurrent.atomic (原子):AtomicInteger atomicInteger = new AtomicInteger();atomicInteger.getAndIncrement();【juc包的AtomicInteger的底层原理是CAS】

volitale禁止指令重排

计算机在执行时,为了提高性能,编译器和处理器常常会对指令做重排,一般分以下三种:编译器优化的重排、指令并行的重排、内存系统的重排。
 
单线程环境里面可以确保程序最终执行结果和代码顺序指向的结果一致。处理器在重排时要考虑指令之间的数据依赖性。多线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量不一定可以保证一致性。
 
解决方法:用volitale修饰变量,禁止指令重排,避免多线程里出现乱序执行的现象。volitale禁止重排和可见性是由内存屏障实现的。编译器会告诉CPU禁止对屏障前后的指令进行重排序优化。还会强制刷出CPU的缓存数据,所以CPU上的线程可以读取到这些数据的最新版本。
 

线程安全性如何得到保证?

问题1:主内存和工作内存同步延迟现象导致的可见性问题?
解决:使用synchronizated(加锁 只有一个线程可以操作)或volatile关键字,他们可以使得一个线程修改后的变量对其他线程可见。
 
问题2:指令重排导致的可见性问题和有序性问题?
解决:可以使用volatile关键字解决,因为volatile的另一个作用就是禁止重排序优化。
 

volatile应用场景

多线程中单例模式的实现

 1 package day01Volatile;
 2 
 3 public class SingletonDemo {
 4 
 5     private static volatile SingletonDemo instance = null;
 6 
 7     private SingletonDemo(){
 8         System.out.println(Thread.currentThread().getName()+"构造方法");
 9     }
10 
11     //单线程的单例模式的写法
12     public static SingletonDemo getInstance1(){
13         if(instance == null){
14             instance = new SingletonDemo();
15         }
16         return instance;
17     }
18     //多线程的单例模式的写法
19     public static SingletonDemo getInstance2(){
20         if(instance == null){
21             synchronized (SingletonDemo.class){
22                 if(instance == null){//加锁后判断一次
23                     instance = new SingletonDemo();
24                 }
25             }
26         }
27         return instance;
28     }
29     
30     public static void main(String[] args) {
31         for (int i = 0; i < 10; i++) {
32             new Thread(()->{
33 //                getInstance1();
34                 getInstance2();
35             }).start();
36         }
37     }
38 }
单例模式在多线程会出问题。在方法上加了synchronized可以解决,但是没必要牺牲这么大的性能。
 
DCL双端检锁机制:在加锁前后都进行一次判断【正确是99.99%】
DCL这种解决方式如果指令重排没有控制住,可以会出现问题(线程不一定安全),因为某个线程执行到第一次检测读取到instance不为null时,instance的引用对象可能没有完成初始化。
instance = new SingletonDemo()不是原子操作,可以拆分成3步原子操作:
  1. 分配对象的内存空间 memory=allocate()
  2. 初始化对象 new SingletonDemo()
  3. 设置instance执行刚分配的内存地址,这时instance != null

创建单例对象有三步:分配对象的内存空间、初始化对象、设置对象的引用指向分配的内存地址。第二步和第三步不存在数据依赖关系,可能会出现重排优化。指令重排后,则会出现问题。初始化完成前,就指向内存地址,instance==null。

指令重排只能保证串行语义的执行一致性,不能保证多线程。所以要用volatile禁止指令重排。

推荐阅读