一篇没人看的关于volatile偏底层原理的枯燥文章

深入理解volatile

volatile关键字可以说是Java虚拟机提供的最轻量级的同步机制了,我们应该了解并正确使用volatile关键字以在某些情况下代替重量级的synchronized锁,提高程序效率。

注意,阅读本文需要先阅读《深入理解Java虚拟机》P362-371及《Java并发编程的艺术》P8-10。

volatile关键字的内存语义

这是十分重要的点,也是面试经常考察的,总结起来有以下三点:

  • 线程A修改了volatile关键字修饰的变量,实质上就是线程A向接下来要读取这个volatile变量的线程B发送自己对变量值所做修改的消息
  • 线程B读取了volatile变量的值,实质上就是线程B收到了之前线程A发送的消息
  • 线程A修改volatile变量值,线程B读取volatile变量值,这个过程实质上就是线程A通过主内存向线程B发送消息

看着云里雾里?没关系,且待我慢慢讲解。

主内存和工作内存

首先简单讲解一下主内存和工作内存,注意,主内存和工作内存只是概念上的划分,并不是根据JVM运行时数据区来划分的。

主内存

主内存就是线程共有的内存部分,变量(这里的变量指静态变量、实例变量和构成数组的元素)都在堆中(即堆中对象实例的数据部分),而堆归于主内存的部分,即变量都在主内存中

工作内存

工作内存是线程私有的,为了提高CPU效率,Java内存模型规定线程不能直接读取主内存中的变量,每个线程在都只能处理(读取,修改)自己工作内存中的数据。工作内存一般都是CPU缓存在充当,数据是主内存中变量的副本,线程先从主内存中读取变量值,处理完成之后再将结果写回主内存以更新变量值(注意,无论是普通变量还是volatile变量都是如此)。

volatile的两个特殊规则

先简单描述一下这两个规则:

  • 可见性保证

    线程对volatile变量修改后,会立即写回主内存中,其他线程每次在使用自己工作内存中的volatile变量时都会检查一下有没有过期(即有没有其他线程更新了变量值),如果过期了就重新从主内存中读取

  • 禁止指令重排序优化

    为了提高CPU的使用率,代码经过编译后会进行指令重排序优化,只保证最后结果正确,但是各个指令的执行顺序可能会被改变。例如指令A是将a的值+2,指令B是将a的值*2,指令C是将b的值+1。那么指令A和指令B的顺序不能改变,但是指令C的顺序可以改成在A之前或AB之间,因为最后结果都相同。通过将修改后的volatile变量立即写回主内存中,可以禁止指令重排序优化

主内存和工作内存的规定引发了一个可见性的问题。

很多人在解释普通变量为什么线程间不可见时都只是说:“线程A只读取自己工作内存中的变量值并进行操作,即便线程B修改了变量值也对线程A没有影响。”乍一听挺像那么回事,但是这只是很粗浅的认知,而且还带有一定的错误,下面来更深入理解一下普通变量为什么在线程间不可见。

缓存一致性协议

事实上在多处理器下,为了保证各个处理器的缓存是一致的,就会实现缓存一致性协议,每个处理器通过嗅探在总线上传输的数据来检查自己缓存的值是不是过期了,如果过期了就会设置自己缓存的值无效,下次处理器要对这个值进行操作时就要重新从主内存中读取

这也是为什么我说上面的解释带有一定错误,因为即便线程B修改了变量值,只要它将新的值写回主内存,那么线程A就可以通过缓存一致性原则来设置自己的值是无效的,从而保证缓存的一致性

经过不断的优化,JVM如今已经能在很大程度上保证主内存和工作内存中数据及时同步,即在大部分情况下相当于默认volatile(仅仅只是具有volatile效果,并不代表就能跟volatile相提并论!)。但是也还是有一些情况下无法保证同步,最常见的就是当CPU满负荷运载时!

所以普通变量在线程间究竟可不可见是一个大坑!可不可见最重要的因素应该是线程究竟会不会去嗅探总线(当然还有一个可能的原因就是线程没有及时将新值写回主内存中,这里我们不讨论),假如CPU满负荷运转,它是无法抽空去嗅探总线的!

下面我们举个例子:

public class Test {
    static int c = 0;
    static int x = 0;
    public static void main(String[] args) {
        new Thread(new Runnable() {
            @Override
            public void run() {
                //刚开始c为0,所以while里面的判断不成立,死循环
                while (true) {
                    //1.System.out.println(c);//这个开了,做IO操作,CPU有空闲,有时间去嗅探,会更新c的值,下面的判断就可以输出
                    //2.System.out.println(x++);//同上
                    //3.x++; //CPU依旧需要进行运算,没时间去嗅探,c不更新,依旧为0,死循环
                    if (c == 3) {
                        System.out.println("c=3");
                        break;
                    }
                }
            }
        },"线程A").start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(500);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }//每次循环都睡眠0.5秒,让线程A足够的时间去判断c的值
                for (int i = 0; i < 5; i++) {
                    try {
                        Thread.sleep(500);
                    } catch (InterruptedException e) {
                        e.printStackTrace();
                    }
                    c++;
                }
            }
        },"线程B").start();
    }
}

上面的例子打开1或2都可以输出结果,但是打开3就不行。可见,当CPU满负荷运载时,无法嗅探总线,就失去了volatile的效果,必须手动加上volatile。

事实上,只要在线程A的while循环中任意加入能够让CPU得到空闲的语句(比如new一个对象,sleep()等)都可以实现volatile的效果,这个时候静态变量加不加volatile都一样。

volatile可见性和禁止指令重排序优化的实现

我们上面说过,线程对volatile变量修改后,会立即写回主内存中,其他线程每次在使用自己工作内存中的volatile变量时都会检查一下有没有过期(即有没有其他线程更新了变量值),如果过期了就重新从主内存中读取(缓存一致性协议)。

volatile如何让线程将修改后的值立即写回主内存呢?又是如何保证在它写回主内存的时候的安全呢?

所有修改volatile变量的操作都会多一条具有lock前缀的指令。

这条lock前缀指令是:”lock addl $0x0,(%esp)“,其含义是将ESP寄存器的值+0,很明显这是一个空操作(即不会造成任何影响)。

这个空操作会让CPU将缓存中的数据写回主内存中,同时因为需要把数据写回主内存中,所以这条指令之前的所有操作和这条指令之后的所有操作都不能重排序,即这条指令充当了内存屏障,保证了修改变量的指令在写回主内存之前执行完成。

lock前缀指令导致在执行指令期间,声言处理器的LOCK#信号,在声言该信号期间,处理器可以锁定任何共享内存(现在一般锁住所有处理器的缓存,那么其他线程就无法从主内存读取数据(最主要),当然也无法向主内存写入数据,保证更新的安全性),只有当自己把变量值更新完了,才解除锁定, 这时其他线程才可以从主内存读取数据,这时候线程读取到的数据一定是最新的。

volatile也不安全

虽然volatile保证了变量的值被修改后立即能被其他线程看见,但是也并不是安全的,因为它虽然保证了可见性,但是不具有原子性

很多人对于volatile有极大的误解,认为:“因为对volatile变量进行的修改对所有线程是立即可见的,所以volatile变量在所有线程中的值都是一致的,基于volatile变量的运算在并发下是安全的”。这个理解的因是没问题的,但是果是错的,volatile变量在所有线程中的值不一定一致

假如线程A正在修改变量,但是还没修改完(例如i++这个操作,需要执行4条指令),这时候线程B它也要修改变量,检查了一下发现自己缓存中的数据还没过期,就放心地进行修改,这时候就出现错误了。

所以volatile的使用是有场景的限制的:

  • 运算结果并不依赖变量的当前值,或者能够确保只有一个线程修改变量的值
  • 变量不需要与其他状态的变量共同参与不变约束

我们最上面的那个例子就是确保只有一个线程修改变量值。

最后举个反例

import java.util.concurrent.CountDownLatch;

public class Test {
    volatile static int c = 0;

    public static void main(String[] args) {
        CountDownLatch cd = new CountDownLatch(2);
        new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 100000; i++) {
                    c++;
                }
                cd.countDown();
            }
        }, "线程A").start();

        new Thread(new Runnable() {
            @Override
            public void run() {
                for (int i = 0; i < 100000; i++) {
                    c++;
                }
                cd.countDown();
            }
        }, "线程B").start();

        try {
            cd.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println(c);
    }
}
153394//总数应该是200000,说明有数据被覆盖了
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值