volatile关键字详解

2 篇文章 0 订阅

1. 并发编程三个重要特性

原子性

一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行。

在 Java 中,可以借助synchronized 、各种 Lock 以及各种原子类实现原子性。

synchronized 和各种 Lock 可以保证任一时刻只有一个线程访问该代码块,因此可以保障原子性。各种原子类是利用 CAS (compare and swap) 操作(可能也会用到 volatile或者final关键字)来保证原子操作。

可见性

当一个线程对共享变量进行了修改,那么另外的线程都是立即可以看到修改后的最新值。

在 Java 中,可以借助synchronizedvolatile 以及各种 Lock 实现可见性。

如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。

有序性

由于指令重排序问题,代码的执行顺序未必就是编写代码时候的顺序。

指令重排序可以保证串行语义一致,但是没有义务保证多线程间的语义也一致 ,所以在多线程下,指令重排序可能会导致一些问题。

在 Java 中,volatile 关键字可以禁止指令进行重排序优化。

2. volatile如何保证变量的可见性?

在 Java 中,volatile 关键字可以保证变量的可见性,如果我们将变量声明为 volatile ,这就指示 JVM,这个变量是共享且不稳定的,每次使用它都到主存中进行读取。
在这里插入图片描述

volatile是通过 MESI缓存一致性协议 来保证可见性的

MESI协议

MESI协议其实是一个变量在内存中的不同状态!MESI 是指4种状态的首字母:

  • M 修改 (Modified): 当一个线程要修改变量
  • E 独享、互斥 (Exclusive): 当一个线程拿到了共享变量,此时为独享状态
  • S 共享 (Shared) : 当多个线程都拿到了共享变量,此时为共享状态
  • I 无效 (Invalid) : 线程丢弃了自己工作内存中的变量,为无效状态

MESI协议如何保证可见性?

  • 首先cpu会根据共享变量是否带有volatile字段,来决定是否使用MESI协议保证缓存一致性。
  • 如果有volatile,汇编层面会对变量加上Lock前缀,当一个线程修改变量的值后,会马上经过store、write等原子操作修改主内存的值(如果不加Lock前缀不会马上同步),为什么监听到修改会马上同步呢?就是为了触发 cpu的嗅探机制, 及时失效其他线程变量副本。

cpu总线嗅探机制

cpu总线嗅探机制监听到这个变量被修改,就会把其他线程的变量副本由共享S置为无效I,当其他线程在使用变量副本时,发现其已经无效,就回去主内存中拿一个最新的值。

写入主内存时在哪里加锁?为什么要加锁?

变量被修改后同步到主内存的过程中会在store之前加锁,写完后解锁,这个锁只有在修改的时候才会加,锁粒度非常小
因为在store时可能已经经过了总线,但此时还没有write进主内存,总线却触发了嗅探机制,其他线程的变量已失效,当其他线程去主内存读最新数据时,新数据还未write进来,产生脏数据!

Lock前缀的作用

lock前缀使得本线程工作内存中的volatile变量值立即写入到主内存中,并且使得其他线程共享的该volatile变量无效化,这样其他线程必须重新从主内存中读取变量值。

  • 使CPU缓存数据立即写回主内存(volatile修饰的变量会带lock前缀)
  • 触发总线嗅探机制缓存一致性协议MESI来失效其他线程的变量
    在这里插入图片描述

3. volatile如何保证有序性?

在 Java 中,volatile 关键字除了可以保证变量的可见性,还有一个重要的作用就是禁止 JVM 的指令重排优化,因而实现了有序性。 如果我们将变量声明为 volatile ,在对这个变量进行读写操作的时候,会通过插入特定的 内存屏障 的方式来禁止指令重排序。

内存屏障

内存屏障(Memory Barrier,或有时叫做内存栅栏,Memory Fence)是一种 CPU 指令,用来禁止处理器指令发生重排序(像屏障一样),从而保障指令执行的有序性。另外,为了达到屏障的效果,它也会使处理器写入、读取值之前,将主内存的值写入高速缓存,清空无效队列,从而保障变量的可见性

即内存屏障是一个这样的CPU指令:

  1. 确保特定操作执行的顺序。
  2. 影响一些数据的可见性,编译器和CPU可以保证输出结果一样的前提下对指令进行重排序,使得性能优化。当插入一个内存屏障,相当于告诉CPU和编译器,先于这个命令的必须先执行,后于这个命令的必须后执行。
  3. 强制更新一次不同的CPU缓存,比如一个写屏障会把这个屏障前写入的数据刷新到缓存,任何试图读取该数据的线程将得到最新的值。

系统提供的内存屏障:

  • LoadLoad屏障
    对于Load1; LoadLoad; Load2 ,操作系统保证在Load2及后续的读操作读取之前,Load1已经读取。
  • StoreStore屏障
    对于Store1; StoreStore; Store2 ,操作系统保证在Store2及后续的写操作写入之前,Store1已经写入。
  • LoadStore屏障
    对于Load1; LoadStore; Store2,操作系统保证在Store2及后续写入操作执行前,Load1已经读取。
  • StoreLoad屏障
    对于Store1; StoreLoad; Load2 ,操作系统保证在Load2及后续读取操作执行前,Store1已经写入,开销较大,但是同时具备其他三种屏障的效果。

volatile修饰的变量,JMM将在写操作后插入一个写屏障指令,在读操作前插入一个读屏障指令,这分别代表着:

  1. 写屏障会确保指令重排序时,不会将写屏障之前的代码排在写屏障之后
 public void actor2(I_Result r) {
  num = 2;
  ready = true; //  ready是被volatile修饰的 , 赋值带写屏障
  // 写屏障
 }
  1. 读屏障会确保指令重排序时,不会将读屏障之后的代码排在读屏障之前
 public void actor1(I_Result r) {
  // 读屏障
  //  ready是被volatile修饰的 ,读取值带读屏障
  if(ready) {
     r.r1 = num;
  } else {
     r.r1 = 1;
  }
 }

在这里插入图片描述

4. volatile为什么不能保证原子性?

原因

写屏障仅仅是保证之后的读能够读到最新的结果,但不能保证其它线程的读跑到它前面去。有序性的保证只是保证了本线程内相关代码不被重排序,无法保证其他线程的读写指令交错,所以volatile无法保证原子性!!!

可以看下面这个例子:

public class Test {
    public static volatile int inc = 0;

    public static void increase(){
        inc++;
    }

    public static void main(String[] args) throws InterruptedException {
        //1、创建10个线程
        Thread[] threads = new Thread[10];

        for (int i = 0; i < 10; i++) {
            //十个线程都调用普通方法
            threads[i] = new Thread(()->{
                for (int j = 0; j < 1000; j++) {
                    //inc ++ 操作执行1000次
                    increase();
                }
            });
            threads[i].start();
        }

        // 等待1.5秒,保证上面程序执行完成
        Thread.sleep(1500);

        System.out.println(inc);
    }
}

运行结果:
在这里插入图片描述
在这里插入图片描述

可能我们想的结果应该是:10000,不过最终运行的结果往往达不到10000,可能我们会有疑问,不对啊,上面是对变量inc进行自增操作,由于volatile保证了可见性,那么在每个线程中对inc自增完之后,在其他线程中都能看到修改后的值啊,所以有10个线程分别进行了1000次操作,那么最终inc的值应该是1000*10=10000

很多人会误认为自增操作 inc++ 是原子性的,实际上,inc++ 其实是一个复合操作,包括三步:

  1. 读取 inc 的值
  2. 对 inc 加 1
  3. 将 inc 的值写回内存

volatile 是无法保证这三个操作是具有原子性的,比如下面这种情况出现:

线程1读取到了inc变量的值,这个时候inc的值为10,还没有进行自增操作时候线程1阻塞了,紧接着线程2对inc变量进行操作,注意这个时候inc的值还是10,线程2对inc进行了自增操作,这个时候inc的值是11,并将这个改变写到主存中,好了,现在线程1恢复了,它并不会去主存中读取inc的值,因为inc已经在它的缓存中了,所以继续进行之前的操作,注意这个时候线程1的缓存中inc的值是10,线程1对inc的值进行加1inc等于11,然后写入主存。

我们发现两个线程都对inc进行了一轮操作,但是inc的值只增加了1

可能我们还是会有疑问,不对啊,前面不是保证一个变量在修改volatile变量时,会让缓存行无效吗?然后其他线程去读就会读到新的值,对,这个没错。这个就是上面的happens-before规则中的volatile变量规则。
但是要注意, 线程1对变量进行读取操作之后,被阻塞了的话,并没有对inc值进行修改。然后虽然volatile能保证线程2对变量inc的值读取是从内存中读取的,但是线程1没有进行修改,所以线程2根本就不会看到修改的值。

解决方案

如果想要保证上面的代码运行正确也非常简单,利用 synchronizedLock或者AtomicInteger都可以。

(1)使用 synchronized 改进:

public synchronized void increase() {
    inc++;
}

(2)使用 AtomicInteger 改进:

public AtomicInteger inc = new AtomicInteger();

public void increase() {
    inc.getAndIncrement();
}

(3)使用 ReentrantLock 改进

Lock lock = new ReentrantLock();
public void increase() {
    lock.lock();
    try {
        inc++;
    } finally {
        lock.unlock();
    }
}

参考文献:
https://blog.csdn.net/Guyui233/article/details/125252111
https://blog.csdn.net/xiewanru/article/details/97644793
https://blog.csdn.net/qq_45783660/article/details/114661595

评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值