浅谈 volatile

·  阅读 383

文章简介

  1. volatile的内存可见性
  2. 缓存一致性协议
  3. volatile的禁止指令重排序
  4. DCL单例模式的问题分析
  5. volatile不保证原子性
  6. 内存屏障
  7. volatile底层代码分析
  8. as-if-serial语义和happens-before规则
  9. volatile和sychronized的对比

1.简介

volatile 在Java日常开发中,尤其是并发编程中,是一个高频使用的关键字,它提供了一种轻量级的同步机制(常用于和synchronized比较),用来修饰成员变量。volatile 具有如下两大特性:

1. 保证内存可见性
2. 禁止指令重排序

volatile 无法保证原子性。

2.内存可见性

我们先看一段代码:

public class Test {

    public static boolean flag = false;

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

        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                //子线程休眠1s,让主线程启动后再去修改flag
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                //TODO: 修改成员变量
                flag = true;
                System.out.println("子线程修改了flag = " + flag);
            }
        });
        t1.start();

        while (true) {
            if (flag) {
                System.out.println("主线程退出");
                break;
            }
        }
    }
}
复制代码

输出结果: image.png

从运行结果中可以看到,子线程t1中将flag修改为了true,但是主线程并没有读到t1线程修改后的值,导致主线程一直无法退出。所以多线程情况下修改共享变量会出现某一个线程修改后对其他线程不可见

在分析为什么不可见之前,我们先了解下Java内存模型JMM(和并发编程相关的模型)

2.1 JMM

JMM即为JAVA 内存模型(java memory model)。他是虚拟机规范定义的一种内存模型,因为在不同的硬件生产商和不同的操作系统下,内存的访问逻辑有一定的差异,结果就是当你的代码在某个系统环境下运行良好,并且线程安全,但是换了个系统就出现各种问题。Java内存模型是一种标准化,屏蔽系统和硬件的差异,让一套代码在不同平台下能到达相同的访问结果。JMM从java 5开始的JSR-133发布后,已经成熟和完善起来。

参考文档

Java内存模型(java memory model)描述了程序中各种变量(线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量的底层细节。 JMM 有如下规定

  1. 所有的共享变量都位于主内存,这里所说的共享变量指的是实例变量和类变量;不包括局部变量,因为局部变量是线程私有的,不存在竞争问题。
  2. 每一个线程还有自己的工作内存,线程个工作内存保存了被线程使用的变量的工作副本。
  3. 线程对变量的所有操作(read,write)都必须在工作内存中完成,而不能直接读写主内存中的变量。
  4. 不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转来完成。

本地内存和主线程的关系: image.png

JVM在设计时候考虑到,如果JAVA线程每次读取和写入变量都直接操作主内存,对性能影响比较大,所以每条线程拥有各自的工作内存,工作内存中的变量是主内存中的一份拷贝,线程对变量的读取和写入,直接在工作内存中操作,而不能直接去操作主内存中的变量。但是这样就会出现一个问题,当一个线程修改了自己工作内存中变量,对其他线程是不可见的,会导致线程不安全的问题。因为JMM制定了一套标准来保证开发者在编写多线程程序的时候,能够控制什么时候内存会被同步给其他线程

基于以上JMM的知识,我们就可以知道为什么我们的代码会出现不可见的问题.

顺便说一下:工作内存是 JMM 的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。

2.2 不可见问题分析

image.png

  1. 子线程t1从主内存够中将共享变量flag=false读到自己的工作内存中
  2. 主线程也将共享变量flag=false读到自己的工作内存中
  3. 子线程t1在工作内存中将flag修改为true,然后写回到主内存中
  4. 虽然主内存中的flag已经从false变为了true, 但是对于主线程来说,它使用仍然是自己工作内存中的flag, 也就是false

上图中是一个大致的过程,如果在细化一下,这里就需要提一下内存模型中的8大原子操作:参考

  1. read(读取):作用于主内存,它把变量值从主内存传送到线程的工作内存中,以便随后的load动作使用;
  2. load(载入):作用于工作内存,它把read操作的值放入工作内存中的变量副本中;
  3. use(使用):作用于工作内存,它把工作内存中的变量给执行引擎,每当虚拟机遇到一个需要使用这个变量的指令时候,将会执行这个动作;
  4. assign(赋值):作用于工作内存,它把从执行引擎获取的值赋值给工作内存中的变量,每当虚拟机遇到一个给变量赋值的指令时候,执行该操作;
  5. store(存储):作用于工作内存,它把工作内存中的一个变量传送给主内存中,以备随后的write操作使用;
  6. write(写入):作用于主内存,它把store传送值放到主内存中的变量中。
  7. lock(锁定):作用于主内存,它把一个变量标记为一条线程独占状态;
  8. unlock(解锁):作用于主内存,它将一个处于锁定状态的变量释放出来,释放后的变量才能够被其他线程锁定;

那么细化后,我们用下面这张图来展示:

image.png

2.3 不可见问题的原因

所有的共享变量都位于主内存中,每个线程有自己的本地内存(工作内存),而线程的工作内存是私有的,所以线程读写共享数据是必须经过主内存交换才可以,这就是产生不可见的原因。

根据上图可以知道,t1线程写回到主线程后,main线程无法感知,仍然使用自己工作内存中的变量。

2.4 解决变量的不可见问题

在多线程下实现共享变量的可见性,有2种解决方案,一种是加锁(synchronized),另一种就是今天的主角:volatile

2.4.1 使用加锁解决方案

直接修改上面的代码:

public class Test {

    public static boolean flag = false;

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

        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                //子线程休眠1s
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                //TODO: 修改成员变量
                flag = true;
                System.out.println("子线程修改了flag = " + flag);
            }
        });
        t1.start();

        while (true) {
            //TODO:加锁
            synchronized (t1) {
                if (flag) {
                    System.out.println("主线程退出");
                    break;
                }
            }
        }
    }
}
复制代码

当某一个线程进入synchronized代码块前后,它的执行过程简单如下:

  1. 线程获得锁
  2. 清空工作内存
  3. 从主内存拷贝共享变量的最新值到工作内存成为副本
  4. 执行代码
  5. 将修改后的副本值刷回到主内存中
  6. 线程释放锁

所以,当主线程进入while循环后,清空自己的工作内存,然后从主内存中拷贝flag到自己的工作内存中。注意:synchronized要放到while内部,这样它就会不断的从主内存中读取flag, 直到读取到新值为true。

2.4.2 使用volatile解决方案

就是给共享变量flag添加volatile关键字,请看代码:

public class Test {

    //TODO:给共享变量添加 volatile
    public static volatile boolean flag = false;

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

        Thread t1 = new Thread(new Runnable() {
            @Override
            public void run() {
                //子线程休眠1s
                try {
                    TimeUnit.SECONDS.sleep(1);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

                //TODO: 修改成员变量
                flag = true;
                System.out.println("子线程修改了flag = " + flag);
            }
        });
        t1.start();

        while (true) {
            if (flag) {
                System.out.println("主线程退出");
                break;
            }
        }
    }
}
复制代码

image.png

  1. 子线程t1从主内存够中将共享变量flag=false读到自己的工作内存中
  2. 主线程也将共享变量flag=false读到自己的工作内存中
  3. 子线程t1在工作内存中将flag修改为true,然后立即写回到主内存中
  4. 然后底层通过总线机制,让持有该共享变量副本的其他线程(也就是主线程)失效,然后重新从主内存中读取共享变量到工作内存中

总结:volatile保证了多个线程对共享变量操作的可见性,也就是说某一个线程修改了共享变量并写回主存后,其他线程会立即看到修改后的新值

如果还想探究某个线程写回主内存后,其他线程如何让自己的工作内存里的变量失效,以及如何感知主内存中的共享变量被修改了等这些细节内容,这就需要了解缓存一致性协议

3.缓存一致性协议

在学习缓存一致性协议之前,先首先简单看下计算机的大致结构

image.png CPU的运算速度最快,内存的读写速度无法和其速度匹配,如果每次都是CPU直接读取内存进行运算,那么效率将会非常低。为了提升效率,在CPU和内存之间,引入了L1高速缓存、L2高速缓存、L3高速缓存,当CPU需要数据时,就从缓存中获取,从而加快读写速度,提高CPU利用率、提升整体效率。

  • L1高速缓存:也叫一级缓存。一般内置在内核旁边,是与CPU结合最为紧密的CPU缓存。
  • L2高速缓存:也叫二级缓存。空间比L1缓存大,速度比L1缓存略慢。
  • L3高速缓存:也叫三级缓存。部分单CPU多核心的才会有的缓存,介于多核和内存之间。存储空间已达Mb级别。

当CPU要读取一个数据时,首先从L1缓存查找,命中则返回;若未命中,再从L2缓存中查找,如果还没有则从L3缓存查找(如果有L3缓存的话)。如果还是没有,则从内存中查找,并将读取到的数据逐级放入缓存。

3.1 缓存行

如果CPU每次读取数据都是随用随取,那其实效率不是最高的,为此,每次读取时都会将相邻的部分也读取出来。一次获取一整块的内存数据,放入缓存。那么这一块数据,通常称为缓存行(cache line)

缓存行(cache line)是CPU缓存中可分配、操作的最小存储单元。与CPU架构有关,通常有32字节、64字节、128字节不等。目前64位架构下,64字节最为常用。

image.png

3.2 缓存一致性协议

每个处理器都有自己的高速缓存,而又共享同一主内存。当多个处理器都涉及同一块主内存区域(缓存行)的更改时,将导致各自的的缓存数据不一致。那同步到主内存时该以谁的缓存数据为准呢?为了解决一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,来保证处理器间缓存的一致性。这类协议有MSI、MESI、MOSI等。
MESI 协议其实是 4 个状态单词的开头字母缩写,分别是:

  • Modified: 已修改
  • Exclusive: 独占
  • Shared: 共享
  • Invalidated: 已失效

CPU内核中的缓存会监听这些事件来修改自己缓存的缓存行中的Flag标志位。然后通过该标志位来决定CPU如何处理这个缓存数据

在MESI协议中,每个缓存行不仅知道自己的读写操作,而且也监听其它缓存行的读写操作。每个缓存行的状态根据本cpu和其它cpu的读写操作在4个状态间进行迁移

image.png

嗅探机制:

  • 当缓存行处于Modified状态时,会时刻监听其他cpu对该缓存行对应主内存地址的读取操作,一旦监听到,将本cpu的缓存行写回内存,并标记为Shared状态
  • 当缓存行处于Exclusive状态时,会时刻监听其他cpu对该缓存行对应主内存地址的读取操作,一旦监听到,将本cpu的缓存行标记为Shared状态
  • 当缓存行处于Shared状态时,会时刻监听其他cpu对使缓存行失效的指令(即其他cpu的写入操作),一旦监听到,将本cpu的缓存行标记为Invalid状态(其他cpu进入Modified状态)
  • 当缓存行处于Invalid状态时,从内存中读取

总结: 当某个cpu修改缓存行数据时,其他的cpu通过监听机制获悉共享缓存行的数据被修改,会使其共享缓存行失效。本cpu会将修改后的缓存行写回到主内存中。此时其他的cpu如果需要此缓存行共享数据,则从主内存中重新加载,并放入缓存,以此完成了缓存一致性

3.3 总线锁和缓存行锁

总线锁 :顾名思义就是,锁住总线。通过处理器发出lock指令,总线接受到指令后,其他处理器的请求就会被阻塞,直到此处理器执行完成。这样,处理器就可以独占共享内存的使用。但是,总线锁存在较大的缺点,一旦某个处理器获取总线锁,其他处理器都只能阻塞等待,多处理器的优势就无法发挥。

缓存锁:不需锁定总线,只需要“锁定”被缓存的共享对象(实际为:缓存行)即可,接受到lock指令,通过缓存一致性协议,维护本处理器内部缓存和其他处理器缓存的一致性。相比总线锁,会提高cpu利用率。

缓存锁的核心机制是基于缓存一致性协议来实现的,一个处理器的缓存回写到内存会导致其他处理器的缓存无效

到这里,我们就分析了volatile保证可见性的底层机制。

4. 内存屏障

volatile是通过编译器在生成字节码时,在指令序列中添加“内存屏障”来禁止指令重排序的。

volatile写不能往前排,volatile读取不能往后排(读的时候不使用寄存器)

硬件层面的内存屏障

  • sfence:即写屏障(Store Barrier),在写指令之后插入写屏障,能让写入缓存的最新数据写回到主内存,以保证写入的数据立刻对其他线程可见
  • lfence:即读屏障(Load Barrier),在读指令前插入读屏障,可以让高速缓存中的数据失效,重新从主内存加载数据,以保证读取的是最新的数据。
  • mfence:即全能屏障(modify/mix Barrier ),兼具sfence和lfence的功能
  • lock 前缀:lock不是内存屏障,而是一种锁。执行时会锁住总线或者缓存行达到内存屏障的效果。

JMM层面的内存屏障

  • LoadLoad屏障: 对于这样的语句Load1; LoadLoad; Load2,在Load2及后续读取操作要读取的数据被访问前,保证Load1要读取的数据被读取完毕。
  • StoreStore屏障:对于这样的语句Store1; StoreStore; Store2,在Store2及后续写入操作执行前,保证Store1的写入操作对其它处理器可见。
  • LoadStore屏障:对于这样的语句Load1; LoadStore; Store2,在Store2及后续写入操作被刷出前,保证Load1要读取的数据被读取完毕。
  • StoreLoad屏障: 对于这样的语句Store1; StoreLoad; Load2,在Load2及后续所有读取操作执行前,保证Store1的写入对所有处理器可见。

我们可以参考一幅图: image.png

举个例子看下: image.png

5. 禁止指令重排序

5.1 什么是重排序?

为了提高性能,编译器和处理器常常会对既定代码的执行顺序进行乱序执行,这就是指令重排序。重排序分3种类型:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism,ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。

image.png

5.2 重排序的好处

重排序可以提高指令的执行速度。

image.png

他们的执行结果是一样的,但是重排序后处理的指令要少很多,所以速度更快。

5.3 重排序案例演示

重排序固然可以提高程序的执行效率,但是在并发情况下,虚拟机底层并不能保证重排序带来的安全性问题。通过下面这段代码演示一下(代码是别人写的)

public class Reorder {
    private static int x = 0, y = 0;
    private static int a = 0, b =0;

    public static void main(String[] args) throws InterruptedException {
        int i = 0;
        for(;;) {
            i++;
            x = 0; y = 0;
            a = 0; b = 0;
            Thread t1 = new Thread(new Runnable() {
                public void run() {
                    a = 1;
                    x = b;
                }
            });

            Thread t2 = new Thread(new Runnable() {
                public void run() {
                    b = 1;
                    y = a;
                }
            });

            t1.start();
            t2.start();

            t1.join();
            t2.join();

            String result = "第" + i + "次 (" + x + "," + y + ")";

            if(x == 0 && y == 0) {
                System.err.println(result);
                break;
            } else {
                //System.out.println(result);
            }
        }
    }
}
复制代码

我们来分析下这段代码:

image.png 简单分析下它的执行结果:

  1. a = 1, x = b(0), b = 1, y = a(1),   得出 (x,y) -> (0, 1)

t1线程执行完a=1,x=b, 然后切换到线程t2

  1. a = 1, b = 1, x = b(1), y = a(1),     得出 (x,y) -> (1, 1)

t1线程执行完a=1后切换到线程t2执行b=1,然后又切换到线程t1执行x=b,然后再切换到线程t2执行y=a

  1. a = 1, b = 1, y = a(1), x = b(1),     得出  (x,y) -> (1, 1)
  2. b = 1, y = a(0), a = 1, x = b(1),    得出 (x,y) -> (1, 0)
  3. b = 1, a = 1, x = b(1), y = a(1),     得出 (x,y) -> (1, 1)
  4. 。。。。。。。。。。

那么有没有可能出现 (x,y) -> (0,0) 的情况呢?
按照一般的理解是:代码的执行顺序不会变,也就是说t1线程的 a = 1 在 x = b之前执行,t2线程的b = 1 在 y = a 之前执行;所以认为是不会出现 x = 0, y = 0的。

那么真的是这样吗?运行程序看下效果:

image.png

事实上,它出现了(x,y) = (0,0) 的情况,这就说明它确实出现了重排序。

在线程1和线程2内部的两行代码的实际执行顺序和代码在java源文件中的顺序是不一致的,代码指令并不是严格按照代码顺序执行的,他们的顺序可以被改变,这样就发生了重排序

那么如果想避免这种因为重排序导致的安全性问题,我们可以使用volatile关键字,只需要将上面代码中的变量用volatile修饰就可以了。

//TODO: 用volatile修饰
private volatile static int x = 0, y = 0;
private volatile static int a = 0, b =0;
复制代码

经过测试,不会在出现x =0, y = 0 的情况

一句话总结:volatile 可以禁止指令重排序,从而修正因为重排序导致的并发安全性问题。

5.4 经典面试题之:DCL单例模式是否需要使用volatile ?

结论是:必须要加上volatile关键字。
我们先看下DCL单例模式的代码:

public class Singleton {

    //TODO:一定要加上volatile关键字
    private volatile static Singleton INSTANCE;

    //构造器私有
    private Singleton(){

    }

    public static Singleton getInstance() {
        if (INSTANCE == null) {
            synchronized (Singleton.class) {
                if (INSTANCE == null) {
                    //TODO:注意:非原子操作
                    INSTANCE = new Singleton();
                }
            }
        }
        return INSTANCE;
    }
}
复制代码

在分析为什么一定要加volatile关键字之前,我们先看下对象在内存中的分配过程,先简单看下下面的代码然后看下他的汇编码指令:

public class ObjectAllocate {

    public static void main(String[] args) {

       Person p = new Person();
    }
}

class Person {
    int age = 20;
}
复制代码

代码比较简单,就是创建一个Person对象

再看下汇编码:

image.png

简单看下每个指令在做什么?

  1. new 指令:分配内存空间,给属性赋零值
  2. invokespecial 指令: 这个指令就是调用构造器
  3. astore_1 指令:将 person 引用放到局部变量表索引为1的位置.(就是建立堆栈连接)

让栈中的p变量指向堆中的Person对象

image.png

这里看起来好像是没有问题的,但是astore_1指令和invokespecial 指令是可能会发生重排序的!

image.png

那么一旦astore_1指令和invokespecial 指令发生重排,就会变成如下执行顺序:

  1. new 指令:分配内存空间,赋零值
  2. astroe_1指令:将栈中的p变量和堆中的Person对象建立连接(注意:此时的Person是一个半成品)
  3. invokespecial 指令:初始化,此时age的值才是20

2,3 两步骤发生了重排

那么回到我们前面的DCL单例模式的代码中:

image.png 假如线程A先执行了 new Singleton()操作,由于指令重排,INSTANCE 指向的是一个半成品的Singleton对象,但是它不是null, 所以当线程B也过来调用getInstance()方法时,线程B判断INSTANCE == null 的结果就是false, 从而直接返回了一个半成品的INSTANCE 给线程B.

所以说:new Singleton() 是一个非原子操作。

至此可以得出结论:DCL单例模式一定要配合使用volatile关键字

6. volatile 不保证原子性

6.1 经典的i++操作

在上面我们验证了 volatile 的内存可见性禁止指令重排序,在并发编程中,有一个非常重要的概念就是原子性,那么volatile能否保证原子性呢?结论就是:volatile无法保证原子性

所谓的原子性是指不会被线程调度机制打断的操作,这种操作一旦开始,就一直运行到结束,中间不会有任何 context switch (切换到另一个线程) ,在单线程中, 能够在单条指令中完成的操作都可以认为是"原子操作",因为中断只能发生于指令之间 在多线程中,不能被其它进程(线程)打断的操作就叫原子操作

简单点就是:要么这个操作不进行,要么就进行到底,而不用担心线程切换问题

在验证volatile关键字之前,我们先看下经典的i++操作:

/**
 * @author qiuguan
 * @date 2022/07/21 19:31:45  星期四
 */
public class AtomicVolatile {

    public static void main(String[] args) {

        AtomicTask task = new AtomicTask();
        for (int i = 0; i < 100; i++) {
            new Thread(task, "线程 atomic-" + i).start();
        }
    }

    static class AtomicTask implements Runnable {

        private int count= 0;

        @Override
        public void run() {
            for (int i = 0; i < 10000; i++) {
                count++;
                System.out.println(Thread.currentThread().getName() + " 的 count ---------->  " + count);
            }
        }
    }
}
复制代码

期望的代码执行结果应该是100 * 10000 = 1000000 , 那么我们看下运行结果到底是不是呢?

image.png 不难发现,它并没有达到预期的1000000结果,这是为什么呢? 原因就出在了count++身上。
count++ 包含了3个步骤

  1. 从主内存读取count到工作内存
  2. 在工作内存中进行++操作
  3. 将工作内存中的count在写回到主内存

count++ 不是一个原子操作,也就说在某一个时刻对某一个操作的执行,有可能被其他线程打断。

image.png

  1. 线程t1首先从主内存中将count(初始值为0)读取到工作内存,此时由于CPU的切换,切换到t2线程。此时t1线程变为就绪状态,t2线程变为运行状态。
  2. 此时线程t2也将主内存中的count读取到工作内存;由于此时t1线程还没有对count做任何修改,所以t2线程读取到的count也是0
  3. 线程t2在自己的工作内存中执行了++操作,但是还没有刷回到主内存中,此时CPU的执行权又被t1线程获得
  4. t1线程获得CPU的控制权后,也在自己的工作内存中执行了++操作
  5. t1线程将++后的结果count=1 写回到主内存中
  6. t2现成将++后的结果count=1 写回到主内存中

虽然count被两个线程都修改了,但是呈现的效果就是只进行一次++操作。

那如果用volatile修饰count变量能否实现原子性呢?

6.2 volatile 的原子性测试

只需要将count用volatile修饰即可:

private volatile int count= 0;
复制代码

运行结果:

image.png

不难发现,就算给count变量加上了volatile关键字,依然输出预期的1000000,所以说volatile是不能保证原子性的。

前面我们分析内存可见性时知道,一个线程修改了volatile变量后能立即更新到主存,其他线程也会捕捉到被修改后的值,那么为什么不能保证原子性呢?

image.png

其实说白了,无论变量加不加volatile关键字,count++操作这一整个动作都是可以被中断的,也就说会发生线程的切换,既然这样,原子性就无从谈起了。

6.3 原子性解决方案

6.3.1 使用锁机制

只需要对上面代码中加上synchronized关键字,就可以实现count++的原子性效果

public class AtomicVolatile {

    public static void main(String[] args) {

        AtomicTask task = new AtomicTask();
        for (int i = 0; i < 100; i++) {
            new Thread(task, "线程 atomic-" + i).start();
        }
    }

    static class AtomicTask implements Runnable {

        private volatile int count= 0;

        @Override
        public void run() 
            //TODO:使用synchronized关键字
            synchronized (AtomicTask.class) {
                for (int i = 0; i < 10000; i++) {
                    count++;
                    System.out.println(Thread.currentThread().getName() + " 的 count ---------->  " + count);
                }
            }
        }
    }
}
复制代码

其原理就是同一时刻只能有一个线程获得锁,然后执行10000次count++,这期间其他线程只能等待锁的释放

6.3.2 使用JUC的原子类

在jdk1.5的 java.util.concurrent.atomic包下,提供了很多原子类,比如AtomicInteger

public class AtomicVolatile {

    public static void main(String[] args) {

        AtomicTask task = new AtomicTask();
        for (int i = 0; i < 100; i++) {
            new Thread(task, "线程 atomic-" + i).start();
        }
    }

    static class AtomicTask implements Runnable {

       private AtomicInteger atomicInteger = new AtomicInteger(0);

        @Override
        public void run() {
            for (int i = 0; i < 10000; i++) {
                System.out.println(Thread.currentThread().getName() + " 的 count ---------->  " + atomicInteger.incrementAndGet());
            }
        }
    }
}
复制代码

JUC中的原子类底层是基于CAS的乐观锁机制实现的原子性。

7. volatile 的底层实现

我们先看下代码:

public class VolatileDemo {

    public static volatile boolean flag = true;

    public static void main(String[] args) {
        flag = false;
        System.out.println("flag = " + flag);
    }
}
复制代码

然后通过 javap -v VolatileDemo.class 命令反编译查看字节码文件:

image.png

可以看到,修饰flag属性的public、static、volatile关键字,在字节码层面分别是以下访问标志: ACC_PUBLIC, ACC_STATIC, ACC_VOLATILE

volatile 在字节码层面,就是使用访问标志:ACC_VOLATILE 来表示
hotspot虚拟机的底层实现:bytecodeInterpreter.cpp

      CASE(_putfield):  //TODO:给实例属性赋值
      CASE(_putstatic):  //TODO:给静态属性赋值
      
      //TODO:省略很多代码.....
          //
          // Now store the result
          //
          int field_offset = cache->f2_as_index();
          //TODO:判断是否有volatile关键字修饰
          if (cache->is_volatile()) {
            if (tos_type == itos) {
              //TODO:给int属性赋值,其中boolean类型在编译阶段也会转换成int, true变成1,false变成0
              obj->release_int_field_put(field_offset, STACK_INT(-1));
            } else if (tos_type == atos) {
              //TODO:给对象赋值
              VERIFY_OOP(STACK_OBJECT(-1));
              obj->release_obj_field_put(field_offset, STACK_OBJECT(-1));
              OrderAccess::release_store(&BYTE_MAP_BASE[(uintptr_t)obj >> CardTableModRefBS::card_shift], 0);
            } else if (tos_type == btos) {
              //TODO:给byte属性赋值
              obj->release_byte_field_put(field_offset, STACK_INT(-1));
            } else if (tos_type == ltos) {
              //TODO:给long属性赋值
              obj->release_long_field_put(field_offset, STACK_LONG(-1));
            } else if (tos_type == ctos) {
              //TODO:给char属性赋值
              obj->release_char_field_put(field_offset, STACK_INT(-1));
            } else if (tos_type == stos) {
              //TODO:给short属性赋值
              obj->release_short_field_put(field_offset, STACK_INT(-1));
            } else if (tos_type == ftos) {
              //TODO:给float属性赋值
              obj->release_float_field_put(field_offset, STACK_FLOAT(-1));
            } else {
              //TODO:给dobule属性赋值
              obj->release_double_field_put(field_offset, STACK_DOUBLE(-1));
            }
            
            //TODO:增加一个 strole/load 屏障
            OrderAccess::storeload();
          } else {
             //TODO:省略非volatile的属性赋值
          }

复制代码

从C++源码中,可以看到,它主要是做3件事:

  1. 判断是否为volatile属性 (is_volatile())
  2. 给属性赋值
  3. 增加屏障 (OrderAccess::storeload())

那么就分别看下,每个步骤具体都做了什么?

  1. 判断是否为volatile属性,调用的OpenJdk8的accessFlags.hpp文件,用来判断访问标记是否为volatile修饰

image.png

  1. 我们在看下给属性赋值,看下给int变量赋值

属性赋值的操作在OpenJDK8的oop.inline.hpp文件中

//TODO:load操作
inline jint oopDesc::int_field_acquire(int offset) const                    
{ return OrderAccess::load_acquire(int_field_addr(offset));      }

//TODO:store操作
inline void oopDesc::release_int_field_put(int offset, jint contents)       
{ OrderAccess::release_store(int_field_addr(offset), contents);  }
复制代码

其内部调用的是 OrderAccess::release_store方法,那么就继续跟进去

orderAccess.hpp : 这个类中的注释可以重点看下

不同的操作系统和不同的cpu架构有不同的实现。 image.png

其中 orderAccess_linux_x86.inline.hpp 是 linux系统中 x86架构的实现

image.png

不难发现,到C++的实现层面,又使用C++中的volatile关键字,C++的volatile关键字和java的语义是不同的,C++的volatile关键字表示变量每次都从主存里读不从cpu缓存读,且禁止编译器做重排序之类的优化
C++ volatile关键字解释

作为指令关键字,确保本条指令不会因编译器的优化而被省略,即系统每次从变量所在内存读取数据而不是从寄存器读取备份

  1. 增加JMM层级的屏障:OrderAccess::storeload()

我们还是看linux下的x86架构实现

image.png

image.png

不难发现,它和CAS一样,都是内嵌入了一段汇编代码,然后使用的是lock指令,在lock; addl $0,0(%%rsp) 中的addl $0,0(%%rsp) 是把寄存器的值加0,相当于一个空操作(之所以用它,不用空操作专用指令nop,是因为lock前缀不允许配合nop指令使用)

lock前缀,会保证某个处理器对共享内存(一般是缓存行cacheline)的独占使用。它将本处理器缓存写入内存,该写入操作会引起其他处理器或内核对应的缓存失效。通过独占内存、使其他处理器缓存失效,达到了“指令重排序无法越过内存屏障”的作用

前面我们有提到 sfence, lfence, mfence这种可以实现内存屏障的CPU原语,这里为什么不直接使用而是使用lock呢?

主要原因是像 sfence, lfence, mfence 原语不具备可移植性,有的CPU支持,有的不支持,但是lock 指令基本上所有CPU都支持。

8.as-if-serial语义和happens-before规则

as-if-serial语义的意思指:不管怎么重排序(编译器和处理器为了提高并行度),(单线程)程序的执行结果不能被改变。编译器和处理器都必须遵守as-if-serial语义。

为了遵守as-if-serial语义,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。为了具体说明,我们举个例子:

public class Test {

    public static void main(String[] args) {
        int a = 3;
        int b = 6;
        int c = a + b;
        System.out.println("c = " + c);
    }
}
复制代码

变量a和c之间存在数据依赖关系,同时b和c之间也存在数据依赖关系。因此c不能被重排序到a和b之前。但a和b之间没有数据依赖关系,编译器和处理器可以对a和b进行重排。as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器和处理器共同让编写单线程程序的程序员产生了一个幻觉:单线程程序是按程序的顺序来执行的

从JDK5开始,引出了 happens-before的概念,通过这个概念来阐述各操作之间的内存可见性,如果一个操作的执行结果对另一个操作可见,那么这两个操作之间就必须存在 happens-before关系,这里提到的两个操作,既可以是同一个线程内,也可以是在不同线程之间。因此,JMM可以通过happens-before关系向程序员提供跨线程的内存可见性保证(如果A线程的写操作a与B线程的读操作b之间存在happens-before关系,尽管a操作和b操作在不同的线程中执行,但JMM向程序员保证a操作将对b操作可见)

happens-before 共有六项规则

  1. 程序顺序规则(单线程规则)

含义:一个线程中的每个操作,happens-before于该线程中的任意后续操作.
举例:同一个线程中前面的所有写操作都对后面可见

  1. 锁规则(sychronized, Lock等)

含义:对一个锁的解锁,要happens-before于随后对这个锁的加锁.
举例:如果线程A解锁了monitor a, 紧接着线程B锁定了monitor a, 那么线程A的解锁操作对线程B可见(线程A,B可以是一个线程)

  1. volatile 变量规则

含义:对一个volatile域的写,要happens-before于任意后续对这个volatile域的读.
举例:如果线程A写入了volatile变量v,紧着线程B读取了变量v,那么线程A写入v及之前的写操作都对线程B可见(线程A,B可以是一个线程)

  1. 传递性

含义:如果 A happens-before B, B happens-before C, 则 A happens-before C.

  1. start() 规则

含义:如果线程A内部执行线程B的start()操作(启动B线程),那么A线程中的 ThreadB.start()操作要 happens-before线程B的任意操作.
举例:假设线程A在执行过程中,通过ThreadB.start()来启动线程B,那么线程A对共享变量的修改在接下来线程B开始执行之前对线程B可见。注意:在线程B启动之后,线程A在对变量进行修改线程B未必可见。

  1. join() 规则

含义:如果线程A执行操作Thread-B.join()并成功返回,那么线程B中的任意操作要happens-before于线程A从Thread-B.join()操作成功返回。 举例:线程A写入的所有变量,在任意其他线程B调用a.join(),或者a.isAlive()成功返回之后,对线程B可见。

8.1 volatile 写读建立的 happens-before 规则

happens-before 有一个规则:如果线程A对volatile变量是写操作,线程B是对该变量的读操作,那么 A happens-before B.

我们通过一段代码来演示:

public class VolatileHappensBeforeDemo {

    int a = 1;
    int b = 2;

    private void write(){
        a = 3;
        b = a;
    }

    private void read(){
        System.out.println("b = " + b + "; a = " + a);
    }

    public static void main(String[] args) {

        VolatileHappensBeforeDemo vhb = new VolatileHappensBeforeDemo();

        new Thread(() -> vhb.write()).start();

        new Thread(() -> vhb.read()).start();

        /**
         * 分析a,b的值
         *
         * b = 3, a = 3
         * b = 2, a = 1
         * b = 2, a = 3
         *
         * b = 3, a = 1 ?
         */
    }
}
复制代码

我们分析下b,a的值的可能情况:

  1. b = 3 , a = 3
  2. b = 2 , a = 1
  3. b = 2 , a = 3
  4. b = 3 , a = 1

其中第4种情况会以很低的概率出现:如果变量b没有被volatile修饰,那么就有可能出现,因为变量a虽然被修改了,但是其他线程不可见,而b切好被其他线程可见,就会出现 b = 3, a = 1的情况。

如何解决呢?
只需要给变量b添加volatile修饰符就可以了,根据 happens-before规则,b之前的写入,将对读取b之后的代码可见,也就是说即使变量a不加volatile,只要b读取到3,那么b之前的操作(a=3)就一定是可见的,这样也就不会出现读到b是3但a是1的情况了。

public class VolatileHappensBeforeDemo {

    long x = 100L;
    int a = 1;
    //TODO:添加volatile修饰符
    volatile int b = 2;

    private void write(){
        x = 40L;
        a = 3;
        b = a;
    }

    //TODO:只要读到b是3,那么a一定是3,x一定是40L
    private void read(){
        System.out.println("b = " + b + "; a = " + a + ", x = " + x);
    }

    public static void main(String[] args) {

        VolatileHappensBeforeDemo vhb = new VolatileHappensBeforeDemo();

        new Thread(() -> vhb.write()).start();

        new Thread(() -> vhb.read()).start();
    }
}
复制代码

8.2 volatile 重排序规则

image.png

  1. 写volatile变量时,无论前一个操作是什么,都不允许重排序(满足 happens-before规则)
  2. 读volatile变量时,无论后一个操作是什么,都不允许重排序
  3. 先写volatile变量,后读volatile变量时,不能重排序

9. volatile的使用场景

我们知道volatile不能保证原子性,所以不要做i++操作,但是它可以用于做纯赋值操作,一般在实际开发中我们也都是这样使用的。通过代码看下:

public class VolatileScenario {

    volatile boolean flag = false;

    final AtomicInteger atomicInteger = new AtomicInteger();

    /**
     * 纯赋值操作
     */
    public void close() {
        flag = true;
        //TODO:不可以这样使用
        //flag = !flag;
    }

    public static void main(String[] args) throws Exception {
        int num = 10000;
        final CountDownLatch countDownLatch = new CountDownLatch(num);
        VolatileScenario vs = new VolatileScenario();

        for (int i = 0; i < num; i++) {
            new Thread(() -> {
                vs.close();
                vs.atomicInteger.incrementAndGet();
                //放到最后
                countDownLatch.countDown();
            }).start();
        }

        countDownLatch.await();

        /**
         * 预期结果应该是:flag = false ; count = 10000
         */
        System.out.println("flag = " + vs.flag + " ; count = " + vs.atomicInteger.get());

    }
}
复制代码

这样使用是没有问题的,但是如果我修改为:falg = !flag, 能否满足预期呢?

其实是不能的,也很好解释,尽管赋值操作是原子的,但是取反和i++操作本质上差不多,都不是原子的,所以不满足纯赋值操作,从而会出现意外的情况。

10. volatile 和 synchronized 的区别

  1. volatile 可以修饰实例变量和类变量,sychronized 可以修饰方法和代码块。
  2. volatile 保证了有序性和可见性,但是不保证原子性,所以不要对volatile变量进行i++操作,而sychronzied 是一种互斥操作,可以保证有序性,可见性,和原子性。

疑问:既然sychronized 已经保证了有序性,那么DCL单例为什么还要加volatile呢?

  1. synchronized 的有序性是指持有相同锁的线程只能串行化的进入代码块,所以同一时间只有一个线程执行,相当于单线程,而单线程的指令重排是没有问题的.
  2. volatile 的有序性是通过插入内存屏障来保证指令按照顺序执行。不会存在后面的指令跑到前面的指令之前来执行。是保证编译器优化的时候不会让指令乱序.
  1. volatile 可以禁止指令重排序,但是sychronized 无法禁止指令重排。
  2. volatile 可以看做是轻量级的sychronized,虽然volatile无法保证原子性,但是如果对某个共享变量是纯赋值操作和读取操作,而没有其他额外的操作,那么就可以使用volatile``代替sychronized,因为赋值本身是原子的,而volatile又保证了可见性,最终也就保证了线程安全。
  3. volatile 可以在32位的系统中也能保证doublelong变量的赋值是原子性的。(在32位的系统中,64位的longdouble会被拆成2个32进行操作)。
  4. DCL单例模式中,一定要给单例对象属性添加volatile关键字,可以实现可见性和禁止指令重排,这样可以保证其他线程不会拿到一个半初始化对象,避免带来线程安全问题。


好了,关于volatile关键字就写到这里,限于作者水平,文中难免有错误之处,欢迎指正,一起学习,勿喷,感谢感谢!!!


感谢以下文章给我的帮助! 感谢

参考文档1
参考文档2
参考文档3
参考文档4

分类:
后端
收藏成功!
已添加到「」, 点击更改