Java中的volatile关键字

并发编程三要素

  • 原子性: 一个不可再被分割的颗粒。原子性指的是一个或多个操作要么全部执行成功要么全部执行失败。
  • 有序性: 程序执行的顺序按照代码的先后顺序执行
  • 可见性: 一个线程对共享变量的修改,另一个线程能够立刻看到

volatile关键字是什么

volatile是java虚拟机提供的最轻量级的同步机制,主要特点有三个:

  • 保证线程间的可见性
  • 禁止指令重排
  • 不保证原子性

volatile是轻量级的synchronized,不会引起线程上下文切换

volatile的可见性

JMM

Java内存模型的主要目的是定义程序中各种变量的访问规则,JMM规定了所有的变量都存储在主存中,每条线程还有自己的工作内存,线程的工作内存中保存了被该线程使用的变量的主内存副本,线程对变量的所有操作(读取、赋值等)都必须在工作内存中进行,而不能直接读写主存中的数据。不同的线程也无法直接访问对方工作内存中的数据,线程间变量值的传递均需要通过主内存来完成。
在这里插入图片描述

/**
 * VolatileDemo1
 *
 * @author xgSama
 * @date 2021/1/7 21:29
 */
public class VolatileDemo1 {

    static class Child {
        static /*volatile*/ int A = 0;
        public static void setA(int a) {
            A = a;
        }
    }
    public static void main(String[] args) {
        Child.setA(1);
        new Thread(() -> {
            try {
                // 保证先让主线程读到A的值后再写入
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 修改A的值
            Child.setA(3);
            System.out.println("child-thread: read variable A - " + Child.A);
        }).start();
        System.out.println("main-thread: read variable A - " + Child.A);
        // 如果无法感知,主线程一直循环
        while (Child.A == 1) { }
        System.out.println("main-thread: read variable A - " + Child.A);
    }
}

没有volatile的结果
在这里插入图片描述

添加volatile后
![在这里插入图片描述](https://img-blog.csdnimg.cn/20210107213530256.png

禁止指令重排

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序。重排序分为三种:

  • 编译器优化的重排序: 编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序
  • 指令级并行的重排序: 如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序
  • 内存系统的重排序: 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能乱序执行

从Java源代码到最终实际执行的指令序列,会分别经历这三种重排序
在这里插入图片描述
为保证内存可见性,Java编译器在指令序列的适当位置会插入内存屏障(也称内存栅栏,是一组处理器指令)指令来禁止特定类型的处理器重排序。JMM把内存屏障分为4类:

屏障类型指令示例说明
LoadLoad BarriersLoad1 ; LoadLoad ; Load2确保Load1数据的装载先于Load2数据及后续所有Load指令的装载;即 volatile变量的读操作必须先于任何其它volatile变量的读操作
StoreStore BarriersStore1 ; StoreStore ; Store2确保Store1数据对其它线程可见(刷新回主内存)先于Store2及后续所有存储指令的主内存刷新;即 volatile变量的写操作必须先于任何其它volatile变量的写操作
Load Store BarriersLoad1 ; LoadStore ; Store2确保Load1数据的装载先于Store2及后续所有存储指令的回主内存刷新;即 volatile变量的读操作必须先于任何其它变量的写操作
StoreLoad BarriersStore1 ; StoreLoad ; Load2确保Store1数据的存储刷新回主内存先发生于Load2及后续所有装载指令的装载;即 volatile变量的写操作必须先于任何其它volatile变量的读操作

StoreLoad Barriers是一个全能型的屏障,他同时具有其他三个屏障的效果

volatile变量的内存语义及实现

volatile写的内存语义,当写一个volatile变量时,JMM会把该线程对应的本地内存中的共享变量值刷新到主内存
volatile读内存语义,当读一个volatile变量时,JMM会把该线程对应的本地内存置为无效,线程接下来将从主内存中读取共享变量
JVM对volatile内存语义的实现

  • 在每个volatile写操作的前面插入一个StoreStore屏障
  • 在每个volatile写操作的后面插入一个StoreLoad屏障
  • 在每个volatile读操作的后面插入一个LoadLoad屏障
  • 在每个volatile读操作的后面插入一个LoadStore屏障

DCL中关于重排序的问题

以下是一个简单的双检锁单例模式代码:

/**
 1. DCLDemo
 2.  3. @author xgSama
 3. @date 2021/1/7 22:04
 */
public class DCLDemo {

    /**
     * 私有构造方法
     */
    private DCLDemo() { }
    
    private static DCLDemo /*volatile*/ instance = null;
	// step 1
    public static DCLDemo getInstance() {
        // step 2
        if (instance == null) {
        	// step 3
            synchronized (DCLDemo.class) {
            	// step 4
                if (instance == null) {
                	// step 5
                    instance = new DCLDemo();
                }
            }
        }
        return instance;
    }
}

这种方式也可能出现问题,因为底层存在指令重排的原因,检查的顺序可能发生了变化,可能会发生读取到的instance != null,但是instance的引用对象可能没有完成初始化,导致另一个线程读取到了还没有初始化的结果.
出现这种情况的原因在于step 5初始化对象的过程:
第五步初始化过程会分为三步完成:

  1. 分配对象内存空间 memory = allocate()
  2. 初始化对象 instance(memory)
  3. 设置instance执行刚分配的内存地址instance = memory

以上步骤看起来是多么美好,但是在计算机中,软件技术和硬件技术有一个共同的目标:在不改变程序执行结果的前提下,尽可能提高并行度。所以,计算机底层编译器想着让你加速,就可能自作聪明的将2、3步骤调整顺序(重排序),优化成了

  1. 分配对象内存空间 memory = allocate()
  2. 设置instance执行刚分配的内存地址instance = memory
  3. 初始化对象 instance(memory)

这种优化在单线程下问题不大,但是在多线程下就会出现上面提到的问题,如下图:
在这里插入图片描述
为解决上述问题,可以将instance使用volatile关键字修饰

volatile不保证原子性

验证非原子性


/**
 * VolatileAtomic
 *
 * @author xgSama
 * @date 2021/1/7 22:40
 */
public class VolatileAtomic {

    public static void main(String[] args) {
        Resource resource = new Resource();
		// 创建200个线程,每个线程自增1000次
        for (int i = 0; i < 200; i++) {
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {
                    resource.add();
                }
            }, String.valueOf(i)).start();
        }
		// 若上述20线程未计算完,主线程暂时让出执行时间
        while (Thread.activeCount() > 2) {
            Thread.yield();
        }
		// 查看num的值,若可以保证原子性输出值为200000
        System.out.println(resource.num);
    }

}

对num变量进行20万次的自增操作,理论上执行完以后num= 20W,是否真的是这样呢?

多次运行结果如下图所示
在这里插入图片描述
我们可以看到每一次的num都小于200000,即使已经用volatile修饰了,也不能保证线程安全的原子性这一特点。

非原子性的原因

问题出在自增运算 num++ 中
在JMM中定义了8种原子操作保证线程的操作具有内存可见性。

在这里插入图片描述

  1. lock - read - load - use 表示一个线程需要 读取并使用共享变量 的过程
  2. assign - store - write - unlock 表示一个线程需要 写入共享变量 的过程。

这两个读-写操作都是内存可见的,如果破坏了这些顺序,那么就无法保证内存可见性,我们可以这样理解:

  1. 线程读共享变量时必须获取变量的锁,从主内存中获取共享变量的值并加载到工作内存中使用;
  2. 线程写共享变量时必须事先获取共享变量的锁,更新工作内存中的共享变量值后立即写入到主内存,然后释放该变量的锁。

我们不需要记住这8种原子操作的顺序,只要满足happens-before原则,就必定满足上图的情况。 换言之,我们可以通过happens-before原则判断内存访问操作是否是线程间可见。

需要注意的是,类似于synchronized这样的关键字才会具有lock和unlock操作,而volatile是保证在读取和写入共享变量时都要在主内存中读取和写入, 简单来说,volatile并不会锁住一个volatile变量。volatile的读和写可以简化成下图
在这里插入图片描述
num++经历了三步操作:

  1. 获取num的值(从主内存中获取)
  2. num= num+ 1(加法操作)
  3. 写回num的值(同步到主内存中)

所以在第一步就有可能出现错误了,两个线程同时读取r主内存的值num= 2 -> num= 2 + 1 -> 写回主内存num= 3。结果就是少增了一次1,当多个线程一起执行 num++ 时,重叠的次数将会大大增加。

保证volatile线程安全的两个条件

  1. 运算结果不依赖于当前值,或者能够保证只有一个线程更新变量的值

  2. 变量不需要与其它的状态变量共同参与不变约束

/**
 * VolatileDemo2
 *
 * @author xgSama
 * @date 2021/1/7 23:05
 */
public class VolatileDemo2 {


    volatile static int start = 3;
    volatile static int end = 6;

    public static void main(String[] args) {
        new Thread(() -> {
            while (start < end) {
                System.out.println("start < end : " + start + " " + end);
                //do something
            }
        }).start();
        new Thread(() -> {
            while (true) {
                start += 3;
                end += 3;
            }
        }).start();
    }
        
}

有两个volatile变量 start 和 end ,不变性约束是 start < end ,思考一下如果执行代码,会发生什么现象?也许你会觉得一直打印 start < end : xxx xxx 。事实上它会在某个不确定的地方停下来。你可以多运行几遍看看效果。
在这里插入图片描述

那么为什么会出现这种情况呢?
线程B执行了如下操作

while (true) {
    start+=3;
    end+=3;
}

而线程A执行了如下操作

while (start < end){
    System.out.println("start < end : " + start + " " + end);
    //do something
}

原因在于 start < end 这个地方,如果线程B执行了 start += 3 ,还没来得及执行 end += 3 时,线程A判断 start < end 不成立而退出了循环, 所以volatile变量不能与其它状态共同参与不变性约束。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值