程序员最近都爱上了这个网站  程序员们快来瞅瞅吧!  it98k网:it98k.com

本站消息

站长简介/公众号

  出租广告位,需要合作请联系站长


+关注
已关注

分类  

暂无分类

标签  

暂无标签

日期归档  

Volatile应用及其底层原理解析

发布于2021-05-29 22:42     阅读(680)     评论(0)     点赞(17)     收藏(1)


前言

Volatile在面试的时候算是多线程篇必问的一个问题了,我们先通过几个栗子,看看得到的结果是不是我们想像的一样,然后在引出Volatile,以及其作用。

正文

我们先来看一个栗子:

public class volatileDemo {
    public static void main(String[] args) {
        Jason jason = new Jason();
        jason.start();
        for (;;) {
            if (jason.isFlag()) {
                System.out.println("输出我");
            }
        }
    }
}

class Jason extends Thread {
    private boolean flag = false;
    public boolean isFlag() {
        return flag;
    }

    @Override
    public void run() {
        try {
            Thread.sleep(1000);
        }
        catch (InterruptedException e) {
            e.printStackTrace();
        }
        flag = true;
        System.out.println("flag=" + flag);
    }
}

以上栗子的结果是什么?

是不是有很多小伙伴认为结果应该是:"输出我”?按道理线程改变了flag,主线程也能够访问到啊?

实际并不是这样的,“输出我”这个是永远不会输出的。

为什么会出现这个情况呢?在此之前,我们先了解另外一个东西。

1.现代计算机的内存模型?

其实早期计算的CPU和内存的速度是差不多的,但是在现代计算机中,CPU指令速度远超内存的存取速度,由于计算机的存储设备与处理器的运算速度有几个数量级的差距,所以现代计算机系统都不得不加入一层读写速度尽可能接近处理器的运算速度的高速缓存(Cache),来作为内存与处理器之间的缓存。

将运算需要使用到的数据复制到高速缓存中,让运算能快速进行,当运算结束再从高速缓存同步回内存之中,这样处理器就无需等待缓慢的内存读写了。

基于高速缓存的存储交互很好的解决了处理器和内存速度的矛盾,但是也为计算机系统带来更高的复杂度,因为它引入了一个新的问题:缓存一致性(CacheCoherence)。

在多处理器系统中,每个处理器都有自己的高速缓存,而它们又共享同一主内存(MainMemory)。

在这里插入图片描述

那我们在来看下在Java中内存模型又是如何定义的呢?

2.JMM(JavaMemoryModel)是什么?

JMM:Java内存模型,是Java虚拟机规范中定义的一种内存模型,Java内存模型是标准化的,屏蔽掉了底层不同计算机区别。

描述了Java程序中线程共享变量的访问规则,以及在JVM中将变量,存储到内存和从内存中读取变量这样的底层细节。

:这个和JVM内存模型不是一个东西,千万不要搞错哦。

JMM有以下规定:

所有的共享变量都存储与主内存,这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。

每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。

线程对变量的所欲操作(读、写)都必须在工作内存中完成,而不是直接读写主内存中的变量。

不同线程之间也不能直接访问对方工作内存中的变量,线程间变量的值的传递需要通过主内存中转换来完成。

本地内存和主内存的关系:

在这里插入图片描述

正是因为这样的机制,才导致了可见性问题的存在,那我们下面讨论下可见性问题。

3.可见性解决方案

(1)加锁

public class volatileDemo {
    public static void main(String[] args) {
        Jason jason = new Jason();
        jason.start();
        for (;;) {
            synchronized (jason) {
                if (jason.isFlag()) {
                    System.out.println("输出我");
                }
            }
        }
    }
}

class Jason extends Thread {
    private boolean flag = false;
    public boolean isFlag() {
        return flag;
    }

    @Override
    public void run() {
        try {
            Thread.sleep(1000);
        }
        catch (InterruptedException e) {
            e.printStackTrace();
        }
        flag = true;
        System.out.println("flag=" + flag);
    }
}

这时就可以输出:“输出我” 了。

加锁为什么可以解决可见性问题呢?

因为某一线程进入synchronized代码块前后,线程会获取锁,清空工作内存,从主内存拷贝共享变量最新值到工作内存成为副本,执行代码,将修改后的副本的值刷新回主内存,线程释放锁。

而获取不到锁的线程会阻塞等待,所以变量的值肯定一直都是最新的。

(2)Volatile修饰共享变量

public class volatileDemo {
    public static void main(String[] args) {
        Jason jason = new Jason();
        jason.start();
        for (;;) {
            if (jason.isFlag()) {
                System.out.println("输出我");
            }
        }
    }
}

class Jason extends Thread {
    private volatile boolean flag = false;
    public boolean isFlag() {
        return flag;
    }

    @Override
    public void run() {
        try {
            Thread.sleep(1000);
        }
        catch (InterruptedException e) {
            e.printStackTrace();
        }
        flag = true;
        System.out.println("flag=" + flag);
    }
}

这个时候也是可以输出:“输出我” 的。

Volatile做了什么?

每个线程操作的时候会把数据从主内存读取到自己的工作内存,如果它操作了数据并且写回了,其它已经读取的线程的变量副本就会失效了,需要数据进行操作又要再次去主内存中读取了。

volatile保证了不同线程对共享变量操作的可见性,也就是说一个线程修改了volatile修饰的变量,当修改写回主内存时,另外一个线程立即看到最新的值。

下面在从计算机层面说下缓存一致性问题:

之前我们说过当多个处理器的运算任务都涉及到同一块主内存区域时,将可能导致各自的缓存数据不一致,说明变量在多个CPU之间共享,如果真的发生这种情况,那同步会主内存时以谁的数据为准呢?

为了解决缓存一致性问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议来进行操作,这类协议有MSI、 MESI(IllinoisProtocol)、MOSI、Synapse、Firefly及DragonProtocol等。

这里聊下Intel的MESI把。

4.MESI(缓存一致性协议)

当CPU写数据时,如果发现操作的变量是共享变量,即在其他CPU中也存在该变量的副本,会发出信号通知其他CPU将该变量的缓存行设置为无效,因此当其他CPU需要读取这个变量的时候,发现自己缓存中缓存该变量的缓存行是无效的,那么他就是从主内存中读取。

(1)如何发现数据是否失效的呢?

嗅探

每个处理器通过嗅探在总线上传播的数据来检查自己缓存的值是不是过期了,当处理器发现自己缓存行对应的内存地址被修改,就会将当前处理器的缓存行设置为无效的状态,当处理器对这个数据进行修改操作的时候,会重新从系统内存中把数据读取处理器缓存里。

(2)嗅探也是有缺点的,不知道有没有发现?

总线风暴

由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和CAS不断循环,无效交互会导致总线带宽达到峰值。

所以大家不要大量使用Volatile,至于什么时候使用Volatile是什么使用锁,根据场景区分。

5.Volatile禁止指令重排序

(1)什么是重排序?

为了提高性能,编译器和处理器常常会对既定的代码执行顺序进行指令重排序。

(2)重排序的类型有哪些?源码到最终执行会经过哪些重排序呢?

在这里插入图片描述

一个好的内存模型实际上会放松对处理器和编译器规则的束缚,也就是说软件技术和硬件技术都为同一目标而进行奋斗:在不改变程序执行结果的前提下,尽可能提高执行效率。

JMM对底层尽量减少束缚,使其能够发挥自身优势。

因此,在执行程序时,为了提高性能,编译器和处理器常常会对指令进行重排序。

一般重排序可以分为以下三种:

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

6.as-if-serial含义是什么?

不管怎么重排序,单线程下的执行结果不能被改变。

编译器、runtime和处理器都必须遵守as-if-serial语义。

7.Volatile是怎么保证不会被执行重新排序的呢?

内存屏障

Java编译器会在生成指令系列时在适当的位置会插入内存屏障指令,来禁止特定类型的处理器重排序。

为了实现volatile的内存语义,JMM会限制特定类型的编译器和处理器重排序,JMM会针对编译器制定volatile重排序规则表:

在这里插入图片描述

需要注意的是:volatile写是在前面和后面分别插入内存屏障,而volatile读操作是在后面插入两个内存屏障。

写:

在这里插入图片描述

读:

在这里插入图片描述

上面我提到的重排序原则,为了提高处理速度,JVM会对代码进行编译优化,也就是指令重排序优化,并发编程下指令重排序会带来一些安全隐患,如指令重排序导致的多个线程操作之间的不可见性。

8.happens-before概念?

从JDK5开始,提出了happens-before概念,通过这个概念来阐述操作之间的内存可见性。

如果一个操作的执行结果需要对另一个操作可见,那么这两个操作之间必须存在happens-before关系。

volatile域规则:对于一个volatile域写操作,happens-before于任意线程后续对这个volatile域的读。

9.无法保证原子性

就是一次操作,要么完全成功,要么完全失败。

假设现在有N个线程对同一个变量进行累加也是没有办法保证结果是对的,因为读写这个过成并不是原子性的。

要解决也是简单的,要么用原子类,比如:AtomicInteger,要不加锁(记得关注Atomic底层)。

10.volatile单例模式应用

public class Singleton {
    // 可见性和指令重排序都保证
    private volatile static Singleton instance = null;
    // 私有构造
    private Singleton(){}
    public static Singleton getInstance() {
        // 第一重检查锁定
        if (instance == null) {
            // 同步锁定代码块
            synchronized (Singleton.class) {
                // 第二重检查锁定
                if (instance == null) {
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }
}

单例模式有8种写法,我这里说下避免比较特殊的一种,涉及到Volatile的。

这里先说下为啥要加两重校验:

(1)内层的判断:也就是“第二重检查锁定”是为了防止多次实例化,这里显而易见,要是不加的话,就不是单例模式了。

(2)外层的判断:也即使“第一重检查锁定”,试图想象一种情况,当线程1走完内层判断,对象实例化了,线程3也调用了getInstance函数,如果没有加外层判断,线程3还是要继续等待(synchronized阻塞)线程2的完成,而加上外层判断,就不需要等待了,直接返回了实例化的对象。

在通俗一点讲:外层的判断是为了提高效率,不必synchronized等待。里层的判断就是第一次实例化需要。

(3)synchronized:同步,线程安全。

(4)为什么要加Volatile?

我们先了解下禁止指令重排序的好处。

先说下创建对象要经过的几个步骤:

(1)分配内存空间 比如:memory=allocate()

(2)调用构造器,初始化实例 比如:ctorInstance()

(3)返回地址给引用 比如:singleton=memory

上面我不是说了嘛,是可能发生指令重排序的,(2)和(3)可能发生重排序,可能构造函数在对象初始化完成前就赋值完成,在内存里面开辟了一片存储区域后直接返回内存的引用,这时候还没有真正的初始化完对象。

但是别的线程去判断instance != null,直接拿去用了,其实这个对象是半成品,那就有空指针异常了。

上面的可能说的不够形象,在形象一点说:如果发生了指令重排序,步骤就变成了(1)->(3)->(2),线程A执行到步骤(3)是,线程B调用

getInstance方法,在判断instance == null时不为null,则返回instance。但此时instance并还没初始化完毕,线程B访问的将是个还没有初始化完毕的对象,这样会发生空指针异常错误。当声明对象是Volatile时后,(2)和(3)重排序将在多线程中禁止。

11.Volatile和Synchronieze的区别?

  • volatile只能修饰实例变量和类变量,而synchronized还可以修饰方法,以及代码块。
  • volatile保证了数据的可见性,但是不保证原子性(多线程写操作,不保证线程安全),而synchronized是一种排他(互斥)的机制。
  • volatile用于禁止指令重排序,可以解决单例双重检查对象初始化代码执行乱序问题。
  • volatile可以看作轻量版的synchronized,volatile不保证原子性,但是如果是对一个共享变量进行多线程赋值,而没有其他操作,那么就可以用volatile代替synchronized,因为赋值本身是原子性的,而volatile又保证了可见性,所以就可以保证线程安全了。

总结

  1. volatile修饰符适用以下场景:某个属性被多线程共享,其中一个线程修改了此属性,其他线程可以立即得到修改后的值,比如boolean flag或者作为触发器,实现轻量级同步。
  2. volatile属性的读写操作都是无锁的,它不能代替synchronized,因为它没有提供原子性和互斥性。因为无锁,不需要花时间在获取锁和释放锁上,所以说它是低成本的。
  3. volatile只能作用于属性,我们用volatile修饰属性,这样编译器就不会对这个属性进行指令重排序了。
  4. volatile提供了可见性,任何一个线程对其修改将立即对其它线程可见,volatile不会被线程缓存,始终从主内存中读取。
  5. volatile提供了happens-before保证,对volatile变量v的写入happens-before所有其它线程后续对v的读操作。
  6. volatile可以使的long和double的赋值是原子的。
  7. volatile在单例双重检查中实现了可见性和禁止指令重排序,从而保证安全性。

通过对volatile底层原理的了解,我们对计算机内存层面也有了更深的认识,平常学习中我们不能光只是会调用API,而是应该知道其底层原理,应知其然知其所以然,学习其源码的设计思想,这会潜移默化的改变我们以后设计代码的思路,让我设计出更好更高效的代码,并且在遇到问题的时候快速定位问题所在。不要只做CRUD boy or girl啊,这样又如何提升我们自己呢?

最后引用我很佩服的一个人经常说的话:你知道的越多,你不知道的越多!

文章参考:

https://mp.weixin.qq.com/s/Oa3tcfAFO9IgsbE22C5TEg

https://juejin.cn/post/6844903593930293261

原文链接:https://blog.csdn.net/SeekN/article/details/117306048



所属网站分类: 技术文章 > 博客

作者:我是小豆丁

链接:http://www.javaheidong.com/blog/article/207611/66ceefeb90ebb55f65ea/

来源:java黑洞网

任何形式的转载都请注明出处,如有侵权 一经发现 必将追究其法律责任

17 0
收藏该文
已收藏

评论内容:(最多支持255个字符)