Java面经の深入理解volatile

34 篇文章 2 订阅
6 篇文章 0 订阅
本文详细探讨了Java中的volatile关键字,解释了其确保内存可见性、禁止指令重排的作用,以及为何无法保证原子性。通过实例展示了volatile在多线程环境下的行为,并提出在需要原子性时应使用synchronized或AtomicInteger等工具。此外,还介绍了内存屏障的概念,它是volatile实现其特性的关键。
摘要由CSDN通过智能技术生成

深入理解volatile

下面介绍几个概念:

共享变量:共享变量是指可以同时被多个线程访问的变量,共享变量是被存放在里面,所有的方法内临时变量都不是共享变量。

重排序:重排序是指为了提高指令运行的性能,在编译时或者运行时对指令执行顺序进行调整的机制。重排序分为编译重排序和运行时重排序。编译重排序是指编译器在编译源代码的时候就对代码执行顺序进行分析,在遵循as-if-serial的原则前提下对源码的执行顺序进行调整。as-if-serial原则是指在单线程环境下,无论怎么重排序,代码的执行结果都是确定的。运行时重排序是指为了提高执行的运行速度,系统对机器的执行指令的执行顺序进行调整。

可见性:内存的可见性是指线程之间的可见性,一个线程的修改状态对另外一个线程是可见的,用通俗的话说,就是假如一个线程A修改一个共享变量flag之后,则线程B去读取,一定能读取到最新修改的flag。

说到这里,可能有些同学会觉得,这不是废话吗,线程A修改变量flag后,线程B肯定是可以拿到最新的值的呀。假如你真的这么认为,那么请运行一下以下的代码:

package test;

public class VariableTest {

    public static boolean flag = false;

    public static void main(String[] args) throws InterruptedException {
        ThreadA threadA = new ThreadA();
        ThreadB threadB = new ThreadB();

        new Thread(threadA, "threadA").start();
        Thread.sleep(1000l);//为了保证threadA比threadB先启动,sleep一下
        new Thread(threadB, "threadB").start();


    }

    static class ThreadA extends Thread {
        public void run() {
            while (true) {
                if (flag) {
                 System.out.println(Thread.currentThread().getName() + " : flag is " + flag);
                    break;
                }
            }

        }

    }

    static class ThreadB extends Thread {
        public void run() {
            flag = true;
            System.out.println(Thread.currentThread().getName() + " : flag is " + flag);
        }
    }
}

在这里插入图片描述
以上运行结果证明:线程B修改变量flag之后,线程A读取不到,A线程一直在运行,无法停止。

内存不可见的两个原因:

1、cache机制导致内存不可见

我们都知道,CPU的运行速度是远远高于内存的读写速度的,为了不让cpu为了等待读写内存数据,现代cpu和内存之间都存在一个高速缓存cache(实际上是一个多级寄存器),如下图:

在这里插入图片描述

线程在运行的过程中会把主内存的数据拷贝一份到线程内部cache中,也就是working memory。这个时候多个线程访问同一个变量,其实就是访问自己的内部cache。

在这里插入图片描述

上面例子出现问题的原因在于:线程A把变量flag加载到自己的内部缓存cache中,线程B修改变量flag后,即使重新写入主内存,但是线程A不会重新从主内存加载变量flag,看到的还是自己cache中的变量flag。所以线程A是读取不到线程B更新后的值。

volatile的原理:

volatile修饰的变量不允许线程内部cache缓存和重排序,线程读取数据的时候直接读写内存,同时volatile不会对变量加锁,因此性能会比synchronized好。另外还有一个说法是使用volatile的变量依然会被读到cache中,只不过当B线程修改了flag之后,会将flag写回主内存,同时会通过信号机制通知到A线程去同步内存中flag的值。我更倾向于后者的解释,还望大神指导一下正确的答案。
在这里插入图片描述

但是需要注意的是,volatile不保证操作的原子性,请勿使用volatile来进行原子性操作。

volatile无法保证原子性
来个案例说明一切:

private static volatile int count = 0;
/**
 * count虽然被volatile关键字修饰,但是结果并不是50000,而是小于等于50000
 **/
public static void main(String[] args) throws InterruptedException{
 
    //开启10个线程,分别对count进行自增操作
    for (int i = 0; i < 10; i++) {
        Thread thread = new Thread(() -> {
            for (int j = 0; j < 5000; j++) {
                count++;    //先读,再加,不是一个原子操作
            }
        });
        thread.start();
    }
    Thread.sleep(2000);
    
    System.out.println("count==" + count);
}

count虽然被volatile关键字修饰了,但是输出的结果会小于等于50000
足以说明了volatile无法保证原子性。

原子性:即一个操作或者多个操作,要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。原子性就像数据库里面的事务一样,他们是一个团队,同生共死。

volatile的主要作用是保证可见性以及有序性,但是并不能保证原子性。

所以volatile主要解决的是一个线程修改某个变量之后,其他的线程立马可以读取到这个值的问题。如果多个线程同时去修改某个变量值,还是会存在多线程并发的安全问题,导致数据修改错乱,原子性的问题还是要依靠synchronized,ReentrantLock等加锁机制来解决。

volatile不保证原子性怎么办
什么是原子性(数据的完整性和不可分割性,不可加塞处理),什么情况会失去原子性?
因为在多线程中,ABC 3个线程拿到主内存的数据s后,可能出现,A改了s的值正要刷回主内存的时候线程被挂起,这时候B线程改了s的值,当A线程再次开启时候还没来得及被通知就已经把自己改后的数据注入了,这时候就存在一个数据的丢失问题.

如何在不使用synchroniza的情况下保证int类数据的原子性呢?
java.until.concurrent.atomic.AtomcInteger,它提供了一个保证原子性的int类的数据类AtomicInteger,它可以保证数据的原子性,可以当作int值来使用,自身带有操作数方法
如:AtomicInteger ai=new AtomicInteger(4); ai就是一个值为4的数据,如果括号内不写的话 默认为0

volatile的禁止指令重排
指令重排 : 在计算机执行指令的顺序在经过程序编译器编译之后形成一个指令序列,一般而言,这个指令序列是会输出确定的结果;以确保每一次的执行都有确定的结果。但是,一般情况下,CPU和编译器为了提升程序执行的效率,会按照一定的规则允许进行指令优化,在某些情况下,这种优化会带来一些执行的逻辑问题,主要的原因是代码逻辑之间是存在一定的先后顺序,在并发执行情况下,会发生二义性,即按照不同的执行逻辑,会得到不同的结果信息。

一个非常经典的指令重排序例子:

public class SingletonTest {
    private volatile static SingletonTest instance = null;
    private SingletonTest() { }
    public static SingletonTest getInstance() {
        if(instance == null) {
            synchronized (SingletonTest.class){
                if(instance == null) {
                    instance = new SingletonTest();  //非原子操作
                }
            }
        }
        return instance;
    }
}

这是单例模式中的“双重检查加锁模式”,我们看到instance用了volatile修饰,由于 instance = new SingletonTest();可分解为:
1.memory =allocate(); //分配对象的内存空间
2.ctorInstance(memory); //初始化对象
3.instance =memory; //设置instance指向刚分配的内存地址

操作2依赖1,但是操作3不依赖2,所以有可能出现1,3,2的顺序,当出现这种顺序的时候
虽然instance不为空,但是对象也有可能没有正确初始化,会出错。

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

为什么volatile可以做到保证可见性和禁止指令重排?
先了解一个概念,内存屏障(Memory Barier)又称内存栅栏,是一个CPU指令,内存屏障可以禁止特定类型处理器的重排序,从而让程序按我们预想的流程去执行。内存屏障,又称内存栅栏,是一个CPU指令,基本上它是一条这样的指令:

保证特定操作的执行顺序。
影响某些数据(或则是某条指令的执行结果)的内存可见性。
编译器和CPU能够重排序指令,保证最终相同的结果,尝试优化性能。
而插入一条Memory Barrier会告诉编译器和CPU:
①不管什么指令都不能和这条Memory Barrier指令重排序。
②Memory Barrier所做的另外一件事是强制刷出各种CPU cache,如一个Write-Barrier(写入屏障)将刷出所有在Barrier之前写入 cache 的数据,因此,任何CPU上的线程都能读取到这些数据的最新版本。

volatile就是基于Memory Barier
如果一个变量是volatile修饰的,JMM会在写入这个字段之后插进一个Write-Barrier指令,并在读这个字段之前插入一个Read-Barrier指令。这意味着,如果写入一个volatile变量,内存屏障的插入就可以保证:

一个线程写入变量a后,任何线程访问该变量都会拿到最新值。
在写入变量a之前的写入操作,其更新的数据对于其他线程也是可见的。因为Memory Barrier会刷出cache中的所有先前的写入。

volatile的读写屏障图
在这里插入图片描述

如何使线程的安全性得到保障

工作内存与主内存同步延迟现象导致的可见性问题
可以使用synchronized或volatile关键字解决,它们都可以使一个线程修改后的变量立即对其他线程可见。

对于指令重排导致的可见性问题和有序性问题
可以利用volatile关键字解决,因为volatile的另外一个作用就是禁止重排序优化。
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

阿杰同学

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值