浅谈Volatile原理及应用

Volatile关键字

使用锁的方式可以解决共享变量内存可见性问题,但是使用锁太笨重,因为它会带来线程上下文的切换开销。对于解决内存可见性问题,Java还提供了一种弱形式的同步,就是Volatile关键字。该关键字可以确保对一个变量的更新对其他线程马上可见。当一个变量被声明为volatile时,线程在写入变量时不会把值缓存在寄存器或者其他地方,而是会把值刷新回主内存。当其他线程读取该共享变量时,会从主内存重新获取最新值,而不是使用当前线程的工作内存的值。volatile的内存语义和synchronized有相似之处,具体来说就是当线程写入了volatile变量值时就等价于线程退出synchronized同步块(把写入工作内存的变量值同步到主内存),读取volatile变量值时就相当于进入同步块(先清空本地内存变量值,再从主内存获取最新值)。
简单来说,volatile就是java虚拟机提供的轻量级的同步机制

Volatile三大特性

  • 保证可见性
  • 不保证原子性
  • 禁止指令重拍排

保证可见性

确保对一个变量的更新对其他线程马上可见。

public class VolatileTest {
    /**
     * 验证volatile的可见性
     * Value变量没有添加volatile关键字修饰
     */
    private int value;
    public int getValue(){
        return value;
    }
    public void  setValue(int val){
        this.value = val;
    }

    public static void main(String[] args) {
        VolatileTest test = new VolatileTest();
        System.out.println(Thread.currentThread().getName() + "\t 的值为:" + test.value);
        new Thread(()->{
            System.out.println(Thread.currentThread().getName() + "加入进来了");
            try {
                Thread.sleep(3000);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            test.setValue(50);
            System.out.println(Thread.currentThread().getName() + "\t 更新值为:" + test.value);},"changeThread").start();
            while (test.value == 0){
                // main线程一直循环,知道testValue不为0;
            }
        // 按道理这个值是不可能打印出来的,因为主线程运行的时候,value,所以一直在循环

        System.out.println(Thread.currentThread().getName() + "\t 改完值了");
    }
}

上面代码是没有保证可见性的,可见性存在于JMM当中即java内存模型当中的,可见性主要是指当一个线程改变其内部的工作内存当中的变量后,其他线程是否可以观察到,因为不同的线程件无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,因为此处没有添加volatile指令,导致其中changeThread线程对value值变量进行更改时,main线程无法感知到value值发生更改,导致在while处无限循环,读不到新的value值,会发生死循环。

解决以上问题只需要在value前面添加volatile关键字。

public class VolatileTest {
    /**
     * 验证volatile的可见性
     * Value变量没有添加volatile关键字修饰
     */
    private volatile int value;
    public int getValue(){
        return value;
    }
    public void  setValue(int val){
        this.value = val;
    }
}

此时就可以保证内存可见性了。

缓存一致性

缓存一致性就是当多个处理器运算任务都涉及到同一块主内存区域的时候,将可能导致自己的缓存数据不一致,为了解决缓存一致性问题,就需要各个处理器遵循一些协议。
当CPU写数据时,如果发现操作的变量是共享变量,即在其它CPU中也存在该变量的副本,会发出信号通知其它CPU将该内存变量的缓存行设置为无效,因此当其它CPU读取这个变量的时,发现自己缓存该变量的缓存行是无效的,那么它就会从内存中重新读取。
这里主线程的值更改后其他线程可以马上知晓主要是用到了总线嗅探技术,就是每个处理器通过嗅探在总线上传播的数据来检查自己缓存值是否过期了,当处理器发现自己的缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效状态,当处理器对这个数据进行修改操作的时候,会重新从内存中把数据读取到处理器缓存中。

不保证原子性

原子性是指执行一系列操作时,这些操作要么全部执行,要么全部不执行,不存在执行其中一部分的情况。

public class ThreadNotSafeCount {
    private volatile int value;
    public int getValue(){
        return value;
    }
    public void inc(){
        ++value;
    }
}

类的反编译代码为

由此可见,++value是由2、5、6、7四步组成的,其中第2步是获取当前value的值并放入栈顶,第七步则把栈顶的结果赋给value变量。因此,java中简单的++value就不在具有原子性。
可以创建多个线程,分别循环1000次,执行一下value++,查看value结果。

public class ThreadNotSafeCount {
    private volatile int value;
    public int getValue(){
        return value;
    }
    public void inc(){
        ++value;
    }

    public static void main(String[] args) {
        ThreadNotSafeCount test = new ThreadNotSafeCount();
        for (int i = 0; i < 30; i++) {
            new Thread(() -> {
                // 里面
                for (int j = 0; j < 1000; j++) {
                    test.inc();
                }
            }, String.valueOf(i)).start();
        }

        // 这里判断线程数是否大于2,因为默认是有两个线程的,一个main线程,一个gc线程
        while(Thread.activeCount() > 2) {
            // yield线程礼让
            Thread.yield();
        }

        // 查看最终的值
        // 假设volatile保证原子性,那么输出的值应该为:  30 * 1000 = 30000
        System.out.println(Thread.currentThread().getName() + "\t 最终结果: " + test.value);
    }
}


最终结果发现,value输出的值并没有30000,而且是每次运行的结果都不一致的,这说明了volatile修饰的变量不保证原子性。
解决办法:使用synchronized的关键字可以实现线程安全性,即内存可见性和原子性,但是synchronized是独占锁,没有获取内部锁的线程会被阻塞掉,同一时间就只能有一个线程可以调用,这大大降低了并发。除了synchronized关键字外,可以使用JUC下面的原子包装类,即可以用AtomicInteger来代替。

    /**
    * 创建一个原子包装类。
    */
    AtomicInteger atomicInteger = new AtomicInteger();
    public void atomicInc(){
        atomicInteger.getAndIncrement();
    }
        for (int i = 0; i < 30; i++) {
            new Thread(() -> {
                // 里面
                for (int j = 0; j < 1000; j++) {
                    test.atomicInc();
                }
            }, String.valueOf(i)).start();
        }

最后输出结果:

禁止指令重排

计算机在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排。
1、在单线程环境里面确保最终执行结果和代码顺序的结果一致。
2、处理器在进行重排序时,必须考虑指令之间的数据依赖性
3、多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能够保证一致是无法确定的,结果无法预测。
指令重排示例:

线程1 线程2
x=a; y=b;
b=1; a=2;
x=0; y=0;

因为以上代码不存在数据的依赖性,因此编译器可能会对数据进行重排

线程1 线程2
b=1; a=2;
x=a; y=b;
x=2; y=1;

这样造成的结果,和最开始的就不一致了,这就是导致重排后,结果和最开始的不一样,因此为了防止这种结果出现,volatile就规定禁止指令重排,为了保证数据的一致性。

Volatile实现禁止指令重排优化,从而避免了多线程环境下程序出现乱序执行的现象

首先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:

  • 保证特定操作的顺序
  • 保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)

由于编译器和处理器都能执行指令重排的优化,如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说 通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。 内存屏障另外一个作用是刷新出各种CPU的缓存数,因此任何CPU上的线程都能读取到这些数据的最新版本。

posted @ 2022-04-16 20:39  YoungerWb  阅读(65)  评论(0编辑  收藏  举报