Java深入学习01:volatile的原理和使用

 

参考博客:https://www.cnblogs.com/200911/p/6010777.html; 描述的非常透彻!

 

 

1.计算机内存模型的相关概念原理 

     计算机在执行程序时,每条指令都是在CPU中执行的,在指令的执行过程中,涉及到数据的读取和写入。由于程序在运行的过程中数据是放在"主存"中的,

由于数据从主存中读取数据和写入数据要比CPU执行指令的速度慢的多,如果任何时候对数据的操作都需要通过和主存进行交互,会大大降低指令的执行速度。

因此在CPU处理器里面有了高速缓存。

      也就是,当程序的运行过程中,会将运算的需要的数据从主存复制一份到CPU的高速缓存中,那么当CPU进行计算时就可以直接从他的高速缓存读取数据

和向高速缓存写入数据,当运算之后将高速缓存中的数据刷新到主存中。

下面是计算机中,数据缓存通过总线、缓存一致性协议在处理器CPU和内存之间的传递过程:

 

 

2.缓存不一致问题解决

举个例子说明:

    例如

           int i= 0;

           i=i+1;

        这段代码在计算机中是如何计算的。

    当线程执行到这个语句的时候,会从主存中读取数据i的值,然后复制一份到高速缓存中,然后CPU指令对i进行+1操作,然后将数据写入到高速缓存中,最后将高速缓存中的i最新的值刷新到主存中。

        这个代码在单线程中运行是没有问题的,但是在多线程中运行就有问题了。在多核的CPU中,每条线程可能运行于不同的CPU中,因此每个线程运行时有自己的高速缓存。

        假如有两个线程A,B;初始的时候分别从主存中读取i的值,然后放在各自所在的CPU高速缓存中,然后线程A进行+1操作,然后把i最新的值写入到主存。此时线程B的高速缓存中i的值还是0,进行+1操作,i的值为1.然后线程B把i的值写入到内存。最终i的值是1,而不是2.

       这就是缓存一致性问题。

也就是说,如果一个变量在多个CPU中都存在缓存(一般在多线程编程时才会出现),那么就可能存在缓存不一致的问题。

  为了解决缓存不一致性问题,通常来说有以下2种解决方法:

  1)通过在总线加LOCK#锁的方式

  2)通过缓存一致性协议

  这2种方式都是硬件层面上提供的方式。

      在早期的CPU当中,是通过在总线上加LOCK#锁的形式来解决缓存不一致的问题。因为CPU和其他部件进行通信都是通过总线来进行的,如果对总线加LOCK#锁的话,也就是说阻塞了其他CPU对其他部件访问(如内存),从而使得只能有一个CPU能使用这个变量的内存。比如上面例子中 如果一个线程在执行 i = i +1,如果在执行这段代码的过程中,在总线上发出了LOCK#锁的信号,那么只有等待这段代码完全执行完毕之后,其他CPU才能从变量i所在的内存读取变量,然后进行相应的操作。这样就解决了缓存不一致的问题。

     但是上面的方式会有一个问题,由于在锁住总线期间,其他CPU无法访问内存,导致效率低下。

     所以就出现了缓存一致性协议。最出名的就是Intel 的MESI协议,MESI协议保证了每个缓存中使用的共享变量的副本是一致的。

     它核心的思想是:当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行置为无效状态,因此当其他CPU需要读取这个变量时,发现自己缓存中缓存该变量的缓存行是无效的,那么它就会从内存重新读取。 

3.深入剖析volatile关键字原理

   一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层含义

   volatile关键字的作用:

   * 可见性:可以保证不同线程对这个变量的可见性,一旦某个线程修改了volatile变量的值,这个值对其他线程是可见的。

   * 原子性:对单个volatile的读和写具有原子性,但是对volatile++这种复合的计数操作不具有原子性。不能用于线程安全计数器。 因为volatile++这种操作,实质上是由一个读取-修改-写入操作序列组成的组合操作。

看下面的代码:

复制代码
package concurrentMy.Volatiles;

public class VolatileFeaturesExample {
    
    int a = 0;
    volatile boolean  flag = true;
    
    public void writer(){
        a = 1; //1
        flag = true; //2
    }
    
    public void reader(){
        if(flag){  //3
            int i= a; //4
            System.out.println(i);
        }
    }
    

}
复制代码

假设线程A执行writer方法后,线程B执行reader方法。

(1)从happens-before原则上来讲,对volatile的写操作一定happen-before对volatile的读。也就是说上述代码2 happens-before与3,根据程序的执行顺序1 happens-before 2,3 happens-before 4。根据happens-before的传递性,1 happens-before 4.也就保证了线程A,写入volatile flag 变量,立即对B线程可见。

(2)从JMM内存语义上来讲,当写一个volatile变量的时候,JMM会把该线程的对应的本地缓存中的共享变量值立即刷新到主内存中。当读一个volatile共享变量时候,JMM会把该线程对应的本地缓存置为无效,也就是上面缓存一致性说的会把该CPU线程对应的缓存行至为无效。直接从主存中读取。

   下图为线程A执行volatile flag写后,共享变量的状态示意图:

   

从写-读的内存语义上来讲,一个线程把共享的volatile写入线程本地内存,然后在刷新到主内存,然后其他线程从主内存中

读取这个共享变量。这样其实就实现了线程之间的通信,通过主内存。

(1)线程A写一个volatile变量,实质上是线程A向将要读这个volatile变量的某个线程发出了消息。

(2)线程A写一个volatile变量,然后线程B读这个volatile变量,这个过程实质上是线程A通过主内存向线程B发送消息。

 

代码论证volatile关键字保证可见性:对于下面这个demo;(1)如果对变量 num 去掉当没有volatile关键字,则主线程会一直在while循环,无法输出"main: num = 2";(2)如果对变量 num 加上volatile关键字,则可以正常 输出"main: num = 2";

public class VolatileVisibleTest {

    public static void main(String[] args) {
        VolatileVisibleDemo demo =  new VolatileVisibleDemo();
        new Thread(()->{
            try {
                TimeUnit.SECONDS.sleep(1);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            demo.setTo2();
        }).start();

        while(demo.num == 0){

        }
        System.out.println(Thread.currentThread().getName() +": num = " +  demo.num);

    }
}
class VolatileVisibleDemo{
    volatile  int num = 0;
    void setTo2(){
        this.num =2;
    }
}

 

代码论证volatile关键字不保证原子性:针对下面示例,如果volatile关键字可以保证原子性,那么最后输出的结果应该是100000;但多次测试,结果少于100000;

  原因: num++不是一个原子操作;在字节码层面它包括读-写-存三步操作,则会出现数据覆盖的情况;

  如何解决:三个方案(1)使用 synchronized 锁;(1)使用 lock 锁;(1)使用 AtomicInteger 代替 int;

public class VolatileAtomicTest {

    public static void main(String[] args) {
        VolatileAtomicDemo demo =  new VolatileAtomicDemo();
        CountDownLatch latch = new CountDownLatch(100);
        for (int i = 0; i < 100; i++) {

            new Thread(()->{
                for(int j = 0; j < 1000; j++){
                    demo.add();
                }
                latch.countDown();
            },"Thread"+i).start();

        }
        try {
            latch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(Thread.currentThread().getName() +": num = " +  demo.num);

    }
}

class VolatileAtomicDemo{
    volatile  int num = 0;
void add(){
        num++;
    }
}

 

 

3.底层实现

 “观察加入volatile关键字和没有加入volatile关键字时所生成的汇编代码发现,加入volatile关键字时,会多出一个lock前缀指令”

  lock前缀指令实际上相当于一个内存屏障(也成内存栅栏),内存屏障会提供3个功能:

  1)它确保指令重排序时不会把其后面的指令排到内存屏障之前的位置,也不会把前面的指令排到内存屏障的后面;即在执行到内存屏障这句指令时,在它前面的操作已经全部完成;

  2)它会强制将对缓存的修改操作立即写入主存;

  3)如果是写操作,它会导致其他CPU中对应的缓存行无效。

  编译器不会对volatile变量的读和读后面的任意内存操作重排序;编译器不会对volatile变量写和写前面任意内存操作做重排序。

 

4.锁和volatile关键字的对比

     功能上锁比volatile更强大,可以保证操作的原子;而可伸缩性和执行的性能上volatile比锁更有优势。

     volatile可以看成一种"程度较轻的synchronized",与synchronized 块相比,volatile变量的使用所需的编码较少,并且运行开销比较小。

     但是不能保证原子性,需要结合一些技术来保证,比如CAS。并发包下面的原子类,可重入锁的实现就是通过volatile结合CAS来实现的。

5.开销较低的读-写锁策略:

 之所以将这种技术称之为 “开销较低的读-写锁” 是因为您使用了不同的同步机制进行读写操作。因为本例中的写操作违反了使用 volatile 的第一个条件,

 因此不能使用 volatile 安全地实现计数器 —— 您必须使用锁。然而,您可以在读操作中使用 volatile 确保当前值的可见性,因此可以使用锁进行所有

 变化的操作,使用 volatile 进行只读操作。其中,锁一次只允许一个线程访问值,volatile 允许多个线程执行读操作,因此当使用 volatile 保证读代

 码路径时,要比使用锁执行全部代码路径获得更高的共享度 —— 就像读-写操作一样。然而,要随时牢记这种模式的弱点:如果超越了该模式的最基本应用,

 结合这两个竞争的同步机制将变得非常困难。

 

6.volatitle的使用场景:

通过关键字sychronize可以防止多个线程进入同一段代码,在某些特定的场景下中,volatitle相当于一个轻量级的sychronize,因为不会引起线程的上下文切换。但是volatitle的使用必须满足两个条件:

1. 对变量的写操作不依赖当前值,如多线程对共享变量执行i++操作,是无法通过volatile保证结果的正确性的;

2.该变量没有包含在具有其他变量的不变式中,通过下面的例子来了解;参考例子(3)

下面看一组例子:多线程对共享变量++操作,单使用volatile变量的话,会出现线程安全的问题,会导致计数不对,下面通过几种方法实现计数功能:

(1)加ReentrantLock互斥锁保证原子性:

复制代码
package concurrentMy.Volatiles;

import java.util.concurrent.locks.Lock;
import java.util.concurrent.locks.ReentrantLock;

/**
 * 
 * (类型功能说明描述)
 *
 * <p>
 * 修改历史:                                            <br>  
 * 修改日期            修改人员       版本             修改内容<br>  
 * -------------------------------------------------<br>  
 * 2016年4月8日 下午6:07:36   user     1.0        初始化创建<br>
 * </p> 
 *
 * @author        Peng.Li 
 * @version        1.0  
 * @since        JDK1.7
 */
public class VolatiteLock implements Runnable{
    // 不能保证原子性,如果不加synchronized的话
    private volatile int inc = 0;
    Lock lock = new ReentrantLock();
    

    /**
     * 
     * 理解:高速缓存 - 主存
     * 通过ReentrantLock保证原子性:读主存,在高速缓存中计算得到+1后的值,写回主存
     * (方法说明描述) 
     *
     */
    public  void increase() {
        lock.lock();
        try {
            inc++;
        } catch (Exception e) {
            e.printStackTrace();
        }finally{
            lock.unlock();
        }

    }
    

    public void run() {
        for (int i = 0; i < 10000; i++) {
            increase();
        }

    }

    public static void main(String[] args) throws InterruptedException {

        VolatiteLock v = new VolatiteLock();
        // 线程1
        Thread t1 = new Thread(v);
        // 线程2
        Thread t2 = new Thread(v);
        t1.start();
        t2.start();

        // for(int i=0;i<100;i++){
        // System.out.println(i);
        // }

        System.out.println(Thread.activeCount() + Thread.currentThread().getId() + Thread.currentThread().getName());

        while (Thread.activeCount() > 1)
            // 保证前面的线程都执行完
            Thread.yield();
        
        //20000
        System.out.println(v.inc);
    }

}
复制代码

 (2)使用原子类,原子自增操作,其底层实现,通过volatile+Cas保证原子性操作,保证读-改-写操作顺序执行,不会发生线程安全的问题。

复制代码
package concurrentMy.Volatiles;

import java.util.concurrent.atomic.AtomicInteger;

/**
 *     
 *     
 *     
 *  
 * <p>
 * 修改历史:                                            <br>  
 * 修改日期            修改人员       版本             修改内容<br>  
 * -------------------------------------------------<br>  
 * 2015年7月14日 下午3:58:30   user     1.0        初始化创建<br>
 * </p> 
 *
 * @author        Peng.Li 
 * @version        1.0  
 * @since        JDK1.7
 */
public class VolatileAtomic implements Runnable {
    private AtomicInteger ai = new AtomicInteger(0);

    /**
     * atomic是利用CAS来实现原子性操作的(Compare And Swap),CAS实际上是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG指令是一个原子性操作。
     * (方法说明描述) 
     *
     */
    public void increaseAtomic() {
        ai.incrementAndGet();
    }

    public void run() {
        for (int i = 0; i < 10000; i++) {
            increaseAtomic();
        }
    }

    public static void main(String[] args) throws InterruptedException {

        VolatileAtomic v = new VolatileAtomic();
        // 线程1
        Thread t1 = new Thread(v);
        // 线程2
        Thread t2 = new Thread(v);
        t1.start();
        t2.start();

        // for(int i=0;i<100;i++){
        // System.out.println(i);
        // }

        System.out.println(Thread.activeCount() + Thread.currentThread().getId() + Thread.currentThread().getName());
        while (Thread.activeCount() > 1)
            // 保证前面的线程都执行完
            Thread.yield();

        System.out.println(v.ai);
    }

}
复制代码

(3):对于“volatitle的使用场景2”的解释如下:

 

复制代码
public class NumberRange {
    private volatile int lower = 0;
     private volatile int upper = 10;

    public int getLower() { return lower; }
    public int getUpper() { return upper; }

    public void setLower(int value) { 
        if (value > upper) 
            throw new IllegalArgumentException(...);
        lower = value;
    }

    public void setUpper(int value) { 
        if (value < lower) 
            throw new IllegalArgumentException(...);
        upper = value;
    }
}
复制代码

上述代码中,上下界初始化分别为0和10,假设线程A和B在某一个时刻同时执行了setLower(8)和setUpper(5),且通过了检查,那么就设置了一个无效的范围(8,5)

所以在这种场景下,需要通过lock保证setLower和setUpper在每一个时刻只有一个线程执行;

下面是项目中经常用到的volatile关键字的两个场景:

1.状态标记量

在高并发的场景中,通过一个boolean类型的变量控制开关按钮,控制代码的逻辑开关(立即生效的开关),比如是否走促销的逻辑,该如何实现?

复制代码
public class SwitchControl {
    private volatile int isOpen;
    public void run() {
        if (isOpen) {
           //促销逻辑
        } else {
          //正常逻辑
        }
    }
    public void setIsopen(boolean isopen) {
        this.isopen = isopen
    }
}
复制代码

这里举个例子说明了volatile的使用方法:用户的请求线程执行到run方法的时候,如果开启促销活动,可以通过mcc配置的开关开启为true,由于isOpen是volatile修饰的,所以一经修改,其他线程都可以拿到isOpen的最新值,在高并发下用户的请求线程可以执行到促销的逻辑了;

 

2.单例的应用中double check检查防止进行重排序

  单例模式很多人忽略写volatile关键字,因为大部分情况下没有这个关键字,程序也很好的的运行,但是代码稳定性不是100%,说不定在某个时刻,隐藏的bug就出来了,可能在高并发的情况下,出现指令重排序,导致线程拿到的单例对象没有初始化;

复制代码
public class Singleton {
   private volatile static Singleton instance;

   private Singleton(){}

   public static Singleton getInstatance(){
       if(instance == null){  // 0 如果不加这个if思考问题?
           synchronized(Singleton.class){
               if(instance = null){  //1 
                  instance = new Singleton(); //2 初始化单例类
              }
           }
        }
       return instance; // 3
   }
}
复制代码

 如果不加0处的if判断,会导致多个线程频繁调用 getInstance方法的时候,将会导致锁竞争,导致性能开销;于是想出了双重检查判定来降低同步的开销;

如果第一次检查instance不为null,那么就不需要进行下面的加锁和初始化操作了,因此可以降低synchronize带来的性能开销;

1.多个线程试图在同一个时间点创建对象,会通过加锁来保证只有一个线程能创建对象。

2.在对象创建好之后,执行getInstance()方法将不需要获取锁,直接返回已经创建好的的对象。

思考:如果再2处的代码不加volatile关键字,这个单例程序会不会有问题?

首先在理解下volatile内存的可见性,volatile的可见性是基于内存屏障实现的,什么是内存屏障?内存屏障,是一个CPU的指令,在程序运行时,为了提高执行的性能,编译器和处理器会对指令进行重排序,JMM为了保证不同不同编译器和CPU上有相同的结果,通过插入特定类型的内存屏障来禁止特定类型的编译器的重排序和处理器的重排序,插入一条内存屏障告诉编译器和CPU;不管什么指令都不能对这条内存屏障进行指令的重排序。如果再2处不加volatile关键字,由于1出的代码内部其实类似这样的实现,对象的初始化过程其实是这样的:

instance = new Singleton(); //2 初始化单例类

分为3个步骤:

*  1. memory = allocate(); 分配对象的内存空间,在堆上
* 2.ctorInstance(memory); 初始化对象
* 3.instance = memory; 设置instance指向刚分配的内存地址

 不加volatile可能导致上面3调语句的执行过程随意进行重排序,即执行的过程可能是123,或者是132;如果是132执行过程,假如A执行完3了,B线程也调用getInstance方法,根据0处的代码if(instance == null)因为A线程给instance分配了内存地址,所以导致B线程认为这个对象不为null,直接返回了这个对象;但是这个对象还没有执行2,所以对象其实还是未初始化。那么程序就出现了问题。

如果加了volatile,会插入内存屏障,会禁止1,2,3步骤的重排序,不允许2,3进行重排序,那么不会发生B访问到的是一个未初始化的对象;

通过观察volatile变量和普通变量的汇编代码可以发现,操作volatile变量多出了一个lock前缀指令:

Java代码:
instance = new Singleton();

汇编代码:
0x01a3de1d: movb $0x0,0x1104800(%esi);
0x01a3de24: **lock** addl $0x0,(%esp);

这个lock前缀指令相当于内存屏障,提供了以下保证:

1、将当前CPU缓存行的数据写会到主内存;

2、这个写回内存的操作导致在其他CPU里面的缓存了该内存地址的数据无效;

CPU为了提高性能,并不直接和内存进行通信,而是将内存的数据读取到内部缓存(L1,L2)再进行操作,但是操作完并不能确定何时写回到内存,如果对volatile变量进行写操作,当CPU执行到Lock前缀指令时候,会将这个变量的缓存行的数据写回到主内存,不过还是存在数据一致性的问题,就算内存是最新的,其他CPU缓存还是旧值,所以为了保证各个CPU缓存的一致性,每个CPU通过嗅探在总线上传播的数据来检查自己缓存数据的有效性,当发现自己的缓存行对应的内存地址的数据被修改,就会将该缓存行设置为无效状态,当CPU读取变量时,发现缓存行被设置为无效,就会重新到主内存读取数据到缓存中。

 

补充第二种线程安全的延迟初始化方案(这个方案被称为 Initialization On Demand Holder idiom IODH):这种方案是通过JVM类的初始化期间获取这个初始化锁,并且每个线程至少获取一次锁开确保这个类已经被初始化过了;这就保证在同一个时刻,A线程在调用getInstance方法初始化下面InstanceHolder类的时候,B线程是需要等待A初始化完后,拿到这个初始化锁才能初始化话这个类,保证了B线程无法看到Instance = new Instance(); 内部的3部初始化重排序过程,也就不会拿到一个未初始化的实例;下面给出第二种单例的写法:

复制代码
public class InstanceFactory{
    private static class InstanceHolder{
        public static Instance instance = new Instance();
   }
      
   public static Instance getInstance(){
       return InstanceHolder.instance;
  }
}
复制代码

两种线程安全延迟初始化方案的对比:

延迟初始化降低了初始化类或者创建实例的开销,如果确实需要对实例字段采用线程安全的延迟初始化,基于volatile的延迟化方案;如果确实需要对静态字段的使用线程安全的延迟初始化,那么建议采用类初始化方案;

 

参考文章:1.http://www.ibm.com/developerworks/cn/java/j-jtp06197.html

              2.http://www.cnblogs.com/dolphin0520/p/3920373.html  

              3.http://docs.oracle.com/javase/7/docs/api/java/util/concurrent/package-frame.html

              4 深入浅出 Java Concurrency (5): 原子操作 part 4:http://www.blogjava.net/xylz/archive/2010/07/04/325206.html 

              5.狼哥:https://www.jianshu.com/p/195ae7c77afe

posted on 2019-11-07 20:22  我不吃番茄  阅读(350)  评论(0编辑  收藏  举报