天天看点

了解volatile1、认识volatile2、硬件层面的优化及问题3、缓存一致性问题的浮现4、volatile的出现5、volatile的作用6、volatile使用场景7、volatile使用技巧8、小结

目录

1、认识volatile

2、硬件层面的优化及问题

3、缓存一致性问题的浮现

3.1、MESI协议

4、volatile的出现

5、volatile的作用

5.1、volatile的可见性

5.2、volatile的原子性

5.3、volatile的有序性

6、volatile使用场景

7、volatile使用技巧

8、小结

前言     要更好的了解线程安全,就的更多的理解volatile,从计算机硬件的优化开始,到JMM。     volatile核心价值:保证访问写变量的可见性和有序性。

1、认识volatile

从一个简单的问题开始。

public class VolatileDemo {
    public static boolean stop = false;
  // public static volatile boolean stop = false; //加上volatile后就OK了
    public static void main(String[] args) throws InterruptedException {
        Thread thread = new Thread(() -> {
            int i = 0;
            while (!stop) {
                i++;
                System.out.println("i= " + i );
            }
        });
        thread.start();
        System.out.println("begin start thread");
        Thread.sleep(1000);
        stop = true;
    }
}           

如果运行上面的代码,发现这个程序并不会像我们期望的那样停下来,但是只要将stop加上volatile关键字修饰,这个程序就可以达到预期,今天主要探究一下这个问题的原理。

2、硬件层面的优化及问题

一台计算机中最核心的组件就是 CPU/内存/以及io设备。在整个计算机的发展历程中,除了CPU,内存以及i/o设备不断迭代升级来提升计算机处理性能之外,还有一个非常核心的矛盾点,就是这三者的速度效率的差异。 为了最大程度的利用 cpu 提升性能, 平衡三者的速度差异: 从硬件 - 操作系统 - 编译器等方面做了很多优化: CPU: 为了充分利用CPU,使用了多进程/多线程

  • 问题-破坏原子性:由于cpu的采用抢占式调度线程的执行;cpu时间片破坏了线程执行的 原子性;

内存:减少io设备速度差异,增加了cpu高速 缓存 - 程序局部性原理;

  • 问题- 破坏 可见性: 使用缓存是数据存在多个副本,导致了数据的可见性问题;

指令优化:编译器指令优化 - 重排序 - 有序性

  • cpu指令:单线程和单cpu是可以指令重排优化的
  • 编译器指令:原始指令-编译器重排-cpu重排(指令+内存)-最终的指令
  • 缓存指令:因为3级缓存的数据提交到主存的顺序是不确定的,导致了指令的重排序;
  • 重排序条件原则
    • 没有数据依赖
    • 程序结果不变,顺序一致性 as-if-serial
  • 问题- 破坏 有序性: 使多线程指令的执行不再按程序次序;

结论:从上可以看出,每一种优化都会带来相应的问题,这些也是导致线程安全性的根源。  

了解volatile1、认识volatile2、硬件层面的优化及问题3、缓存一致性问题的浮现4、volatile的出现5、volatile的作用6、volatile使用场景7、volatile使用技巧8、小结

3、缓存一致性问题的浮现

       为了充分利用cpu,解决cpu和存储器速度不匹配的问题,通过加入三级高速缓存来解决处理器和内存的速度矛盾,与此同时也为计算机带来了更高的复杂度,因为它引入了一个新的问题: 缓存一致性问题。     有了高速缓存的存在以后,每个CPU的数据处理是先将计算需要用到的数据缓存在CPU高速缓存中(一级-256k,二级缓存-1m属于 cpu 独占,包含指令缓存和数据缓存,三级缓存所有的cpu共享),在CPU进行计算时,直接从高速缓存中读取数据并且在计算完成之后写入到缓存中;在整个运算过程完成后,再把缓存中的数据同步到主内存。     由于在多CPU中,每个线程可能会运行在不同的CPU内, 并且每个线程拥有自己的高速缓存。同一份数据可能会被缓存到多个CPU中,这就可能导致在不同CPU中的线程看到同一变量的值不一样,这 就出现了 缓存不一致的问题,也是可见性问题 。 多线程情况下 可见性的根本原因:

  • 缓存的使用;
  • 指令重排序;

为了解决不一致的问题:cpu提供了两种解决办法:

  • 1、 总线锁: LOCK#,锁住了cpu和其他模块的通信总线,使其他 cpu无法通过总线访问其他内存地址的数据;
  • 缺点:开销很大,使多核cpu无法并行访问数据,性能低, 所以需要控制锁的粒度;
  • 2、缓存锁 :MESI,核心机制是基于缓存一致性协议;

3.1、MESI协议

MESI是硬件层面的实现,无需设置

  • M - modify:表示共享数据只缓存在当前的CPU缓存中,并且是被修改的状态,也就是缓存数据和主内存中的数据不一致;
  • E - exclusive:表示缓存的独占状态,数据只缓存在当前的cpu缓存中,且没有被修改过;
  • S - share:表示数据可能被多个cpu缓存,并且各个缓存中的数据和主内存数据一致;
  • I - invalid:表示缓存已经失效

在MESI协议中,每个缓存的缓存控制器不仅知道自己的读写操作,而且也监听其他的cache的读写操作。 对MESI协议来说:

  • cpu 读请求:缓存处于 MES都可以读,但是 I缓存必须从主存读取;
  • cpu 写请求: ME缓存可以写, S缓存需要将其他的缓存置无效 i才可以写;

使用缓存一致性协议和总线锁以后,缓存与cpu的关系可以抽象如下:

了解volatile1、认识volatile2、硬件层面的优化及问题3、缓存一致性问题的浮现4、volatile的出现5、volatile的作用6、volatile使用场景7、volatile使用技巧8、小结

这里的总线锁有点 分布式锁的意思,多cpu类似多个集群服务器,拿到锁后操作主存的临界数据。 -server -Xcomp -XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly -XX:CompileCommand=compileonly

4、volatile的出现

 总线锁和MESI机制虽然能解决缓存数据一致性问题,但是这里仍然存在一个效率问题,当一个处于Exclusive独占状态的变量被Modeify修改后,需要使其他CPU里的数据无效Invalid,所以在发送使缓存无效通知消息的时候,涉及到两个cpu之间数据的同步通信,这会让cpu产生短暂的阻塞,对于宝贵的CPU资源,这是不可接受的,因为发出去消息后还有一个同步等待ack的问题,为了优化这个阻塞的问题,更进一步完美的利用cpu,cpu引入了 storebuffer缓存,用来暂存修改更新的数据,异步通知其他cpu,否则多核cpu同步等待失效结果的通知,效率低下。引入storebuffer后,读取的时候先从storebuffer中读取,如果没有再从内存中读取,核心为两步:

  • 第一步:更新的数据先写入strorebuffer;
  • 第二步:异步通知其他的cpu失效;

存在缓存,且第二步是异步通知,所以还是存在一定几率的短时间可见性问题,这其实也是一个CAP问题。 显然为了性能的考虑,MESI没有完全在硬件层面解决多线程下的程序逻辑顺序关系导致的可见性问题,为了彻底的解决这个问题,系统提供了 一些cpu指令来让编程开发者自己来解决, 这就有了 volatile指令。通过把这个权利交给了程序员,让程序员去判断,当存在类似可见性问题的时候,就可以使用volatile插入一个内存屏障,实际底层还是基于总线锁来实现,解决变量可见性。

5、volatile的作用

volatile 也可简单称为内存屏障 memory barrier /内存栅栏,是Java虚拟机提供的轻量级的同步机制,底层是一个CPU指令,主要用来解决多线程对共享可变变量的读操作的 可见性和有序性问题, 主要有两个功能:

  • 一、禁止指令重排序: 进行指令优化的时候,禁止将volatile变量前后的语句进行重排序;
  • 二、强制刷缓存:工作内存写完后立即写到主内存,并通知其他线程使其本地该变量缓存无效;

JAVA语言提供了包括读屏障和写屏障,禁止重排序:

  • 写屏障:storestore - 保证在写屏障之前所有的写都已经刷回到主存中,之后的读屏障都是可见的;
  • 读屏障:loadload - 保证在读操作在读屏障之后操作,配合写屏障保证之前所有的修改对其都可见;
  •  全屏障:解决线程之间的可见性;
  • storeLoad 存储store在读load前面执行;
  • loadStore: 读load在存储store前面执行;

5.1、volatile的可见性

先来看下面一个中断程序,例五:

//中断程序
//线程1
boolean volatile stop = false;
while(!stop){
    doSomething();
}
//线程2
stop = true;           

问题分析:在中断程序中,如果stop没有用volatile修饰,一定会发生中断吗?答案是不确定。因为每个线程在运行过程中都有自己的工作内存,那么线程1在运行的时候,会将stop变量的值拷贝一份放在自己的工作内存当中。那么当线程2更改了stop变量的值之后,但是还没来得及写入主存当中,线程2转去做其他事情了,那么线程1由于不知道线程2对stop变量的更改,这样会导致线程一工作内存中的值永远是false,因此还会一直循环下去。

  但是加了volatile后,线程会立即将修改的值刷回主存且使其他cpu缓存数据失效,所以volatile是可以保证可见性的,这里也就回答了开篇的问题了,为什么加了volatile修饰的stop变量可以达到预期的中断效果,因为volatile保证了变量stop怼其他线程的可见性。 导致可见性的原因:

  • 指令重排顺序无法确定;
  • 读写缓冲区;
  • 缓存的使用;

volatile如何保证可见性及禁止指令重排:

  • 使用volatile修饰,对该变量的读取会插入一条内存屏障lock,cpu不会将其后面的指令放在其前面执行,反之亦然;
  • 使用volatile修饰,写操作会强制将修改的值刷新到主存;
  • 使用volatile修饰,其它读,对变量的写操作会导致其他缓存行的值无效,再次读取时会到主存去读取;

5.2、volatile的原子性

我们先看下面一个测试

public class VolatileAtomicTest {
    public volatile int inc = 0;
    public void increase() { //增加1
        inc++;
    }
    public static void main(String[] args) {
         VolatileAtomicTest atomicTest = new VolatileAtomicTest();
        for (int i = 0; i < 3; i++) { //3个线程并发修改inc的值;
            new Thread() {
                public void run() {
                    for (int j = 0; j < 1000; j++)
                        atomicTest.increase();
                }
            }.start();
        }
        try {
            sleep(1000);  //保证所有的线程都执行完毕
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(atomicTest.inc);
    }
}
           

问题分析:这个结果或是多少呢,我们的理想值是3000,但实际上每次都不一样,并且该值是一个小于3000的值。可能这里就会有疑问了,不对啊,上面是对变量inc进行自增操作,由于volatile保证了可见性,那么在每个线程中对inc自增完之后,在其他线程中都能看到修改后的值啊,所以有3个线程分别进行了1000次操作,那么最终inc的值应该是1000*3=3000。

  这里面就有一个误区了,volatile关键字能保证可见性没有错,但是上面的程序错在没能保证原子性。可见性只能保证每次读取的是最新的值,但是volatile没办法保证对变量的操作的原子性。在java内存模型中已经知道,自增操作是不具备原子性的,自增的实现包含三步:

  • 第一步:读取变量的原始值到工作内存;
  • 第二步:对变量进行加1操作;
  • 第三步:写回主内存;

因为自增操作分三步执行,而cpu的时间片机制使这三步变的非原子执行,例如线程1执行到第一步之后,没修改数据,但是时间片到了,放弃了cpu线程2修改了inc的值,等线程1再次获取到cpu执行权限时,执行2-3两步,再写回主存,这在多线程情况下就出现了线程安全问题。   根源就在这里,由于自增操作不是原子性操作,而volatile也无法保证对变量的操作是原子性的。 我们可以采用synchronize/lock/automicInteger中的任何一个都可以达到上面的目标。   

  // 法一:加入synchrozied,保证执行的原子性
    public synchronized void  increase() {
        inc++;
    }

    //方法二:采用lock机制
    Lock lock = new ReentrantLock();
    
    public  void increase() {
        lock.lock();
        try {
            inc++;
        } finally{
            lock.unlock();
        }
    }

    方法三:采用AtomicInteger机制
    public  AtomicInteger inc = new AtomicInteger();
    public  void increase() {
        inc.getAndIncrement();
    }           

Automic的原子性 Automic是利用CAS来实现原子性操作的(Compare And Swap),CAS实际上是利用处理器提供的CMPXCHG指令实现的,而处理器执行CMPXCHG指令是一个原子性操作。

    //AtomicInteger类的getAndIncrement的源代码
    public final int getAndIncrement() {
        for (; ; ) {
            int current = get();                // 取得AtomicInteger里存储的数值
            int next = current + 1;             // 加1
            if (compareAndSet(current, next))   // 调用compareAndSet执行原子更新操作
                return current;
        }
    }           

    CAS利用了基于冲突检测的乐观并发策略 ,CAS自旋volatile变量,可以很高效的解决原子问题。在java 1.5的java.util.concurrent.atomic包下提供了一些原子操作类,即对基本数据类型的 自增(加1操作),自减(减1操作)、以及加法操作(加一个数),减法操作(减一个数)进行了封装,保证这些操作是原子性操作。

5.3、volatile的有序性

由于volatile能禁止修饰变量前后语句指令的重排,所以在一定程度上可以保证有序性。前面也讲过,volatile主要通过内存屏障保证了值在内存中的可见性及有序性。主要有以下两点:     (1)当程序执行到volatile变量的读操作或者写操作时,在其前面的操作的更改肯定全部已经进行,且结果已经对后面的操作可见;在其后面的操作肯定还没有进行;  (2)在进行指令优化时,不能将在对volatile变量访问的语句放在其后面执行,也不能把volatile变量后面的语句放到其前面执行。 案例一:

//线程1:
x = 1;                              //语句1
y = 4;                              //语句2
config = getConfig();               //语句3
inited = false;                     //语句4  volatile

//线程2:
while( inited ){                    //语句5
  x = x + 1;                         
  y = y + 1;                         
}
doSomethingwithconfig(config);      //语句6           

分析:我们知道编译器中由于语句3-4没有依赖性,可能会发生指令重排,可能导致config没有获取到配置信息,当线程2去执行的时候出错。但是当我们加上volatile后volatile inited = false; 就可以避免此类问题的发生。因为volatile保证了在执行语句4的时候,语句1-2-3一定执行完了,1-2-3的执行结果对语句5是可见的,禁止了volatile前后语句的指令重排序,保证来指令执行的有序。但是语句1-2-3和语句5-6的执行顺序是不做保证的。

案例二:另外还有一个经典的double-check单例模式的应用,如下:  

  //TODO 通过volatile设置内存屏障,禁止指令排序,使写先与读执行;
public class DoubleCheckLockSingletonTest {    
    private static volatile DoubleCheckLockSingletonTest singleInstance;
    private DoubleCheckLockSingletonTest() {
    }
    public static DoubleCheckLockSingletonTest getInstance() {
        if (singleInstance == null) {
            synchronized (DoubleCheckLockSingletonTest.class) {
                if (singleInstance == null) {
                    singleInstance = new DoubleCheckLockSingletonTest();
                }
            }
        }
        return singleInstance;
    }           

分析:以上代码中instance为什么需要用volatile来修饰,主要设计到指令的重排和原子操作,保证有序性从而达到可见性。 主要在于singleInstance = new DoubleCheckLockSingletonTest();这句,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事情:

memory = allocate();                   //第一步:给 singleton 分配内存;
DoubleCheckLockSingletonTest(memory);  //第二步:调用 构造函数来初始化成员变量,形成实例;
singleInstance = memory;               //第三步:将singleInstance对象指向分配的内存空间(执行完这步 singleInstance才是非 null 了);
但是由于步骤2-3之间没有依赖性,所以步骤2-3可能会发生指令的重排序。这种重排序在串行的单线程是OK的,但是如果发生在高并发的多线程将产生不可估计的后果。有可能产生如下的执行顺序:
//2-3步发生来指令的重排序
memory = allocate();                   //第一步:给 singleton 分配内存;
singleInstance = memory;               //第二步:将singleInstance对象指向分配的内存空间;
DoubleCheckLockSingletonTest(memory);  //第三步:调用 构造函数来初始化成员变量,形成实例;
           

如果此时线程一正执行到重排后的第三步还未完成,此时线程2请求到达后,判断singleInstance不为空,但是实例化未完成,此时线程2返回的将是一个【线程一初始化未完成的实例这样一个中间状态的值】,所以肯定会出问题。

这里的关键是:线程一没有完成初始化,线程2就读取来其中的内容。所以volatile就派上来用场,保证了初始化的有序,volatile关键字的一个作用是禁止指令重排,把singleInstance声明为volatile之后,对它的写操作就会有一个内存屏障,这样在singleInstance的赋值写操作完成之前,就不用会调用对其读操作,从而保证了有序性。 注意:volatile阻止的不是singleton = new Singleton()这句话内部[1-2-3]步的指令重排,而是保证了在一个写操作([1-2-3])步完成之前,不会调用读操作(if (singleInstance == null))。

6、volatile使用场景

JUC包里到处都是,V olatile和CAS机制撑起了JUC并发编程的半壁江山 。例如:

  1. 状态标志量:读取配置文件 - 中断的场景使用的较多,一般都使用范围来判断而非==值判断;
  2. Double-check,单例的生成;
  3. 一写多读,不支持复合操作的原子性(i++) ;
  4. AQS中的Node-state-双向链表的node-next-pre-waitstatus等变量;
  5. 中断的 isIntrrupted变量;
  6. ConcurrentHashmap中的节点;
  7. 线程池中完成任务数的统计;

7、volatile使用技巧

  1. 性能问题:volatile的使用 导致缓存行失效,不能利用 cpu的缓存加速,频繁读内存开销,影响性能; 
  2. volatile关键字在一写多读的情况下性能要优于synchronized/lock锁机制,但是要注意volatile关键字是无法替代synchronized关键字的,因为volatile关键字在变量存在依赖关系的时候 无法保证操作的原子性。
  3. 不要将volatile用在getAndOperate场合( 这种场合不原子,需要再加锁synchronized或者lock或者使用Atomic*类),仅仅set或者get的场景是适合volatile的。
  4. 对于 volatile数组来说,只能保证对于引用的可见,不能保证数组里数据元素的可见性;

8、小结

    只有保证操作的原子性,才能保证使用volatile关键字的程序在并发时能够正确执行,否则将会发生意想不到的事情。

  • volatile保证访问写变量的可见性和有序性,不保证原子性;
  • volatile主要实现原理:
    • 禁止变量前后指令重排序;
    • 缓存行的数据刷回到主存并且使其他的缓存无效;
  • volatile为实现JMM付出了汗马功劳;

  水滴石穿,积少成多。学习笔记,内容简单,用于复习,梳理巩固。

继续阅读