「重学多线程」开发N年你不会连Volatile都不知道吧

Volitale

抛砖引玉


别跟爷废话,直接上代码,先看一个demo

public class VolatileTest  {

    public static void main(String[] args) throws InterruptedException {
        Task task = new Task();

        Thread t1 = new Thread(task, "线程t1");
        Thread t2 = new Thread(new Runnable() {
            @Override
            public void run() {
                try {
                    Thread.sleep(1000);
                    System.out.println("开始通知线程停止");
                    task.stop = true; //修改stop变量值。
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }

            }
        }, "线程t2");
        t1.start();  //开启线程t1
        t2.start();  //开启线程t2
        Thread.sleep(1000);
    }
}

class Task implements Runnable {
    boolean stop = false;
    int i = 0;

    @Override
    public void run() {
        long s = System.currentTimeMillis();
        while (!stop) {
            i++;
        }
        System.out.println("线程退出" + (System.currentTimeMillis() - s));
    }
}

运行上面的代码结果是:

可以看到程序阻塞住了,这是为什么呢?按照一贯的思维逻辑,线程2在休眠1秒后会主动修改stop变量的值为true,使得线程1的循环结束,但是为什么程序阻塞住了呢?继续往下看,先有个悬念,我们使用volatile修饰stop变量,程序的运行结果发生了改变:

可以看到程序没有阻塞,并且按照我们的思路完成了工作。下面就来详细的解释下为什么会这样,
在介绍之前先要对几个基本的知识有个了解。

先说Volatile的作用

内存语义:

  • 当写一个 volatile 变量时,JMM(下面会后介绍) 会把该线程对应的本地内存中的共享变量值刷新到主内存。
  • 当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

**
下面一整篇就来说说Volatile是如何做到这些的!!!

计算机内存模型与缓存不一致问题


现代计算机的内存模型大致如下,在多路处理器系统中,每个处理器都有自己的高速缓存,而它们共享同一主内存

读写数据的过程简单来说就是这样几步:

  1. cpu先从主内存取数据拷贝到工作内存
  2. cpu执行操作后更新自己工作内存的数据
  3. 工作内存的数据再同步到主内存

但是这就有个问题了,1个cpu不存在问题,多个cpu同时工作就会存在缓存数据不一致的情况。
(初始时,两个线程分别读取i的值存入各自所在的CPU的高速缓存当中,然后线程1进行加1操作,然后把i的最新值1写入到内存。此时线程2的高速缓存当中i的值还是0,进行加1操作后,i的值为1,然后线程2把i的值写入内存。)
聪明的人想出来两个方案解决这个问题:

  1. 总线加锁,通过对总线加锁,同一时刻只有一个cpu能访问主内存,其他cpu要阻塞,这样是能解决问题,但是带来的性能消耗有点大,不太给力。
  2. MESI协议(缓存一致协议),这个就牛逼了,下面说下这个👇

MESI


它的核心思想:

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

系统是如何做到的呢,答案:多处理器总线嗅探

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

反正就是总结下来一句话:cpu一旦改了共享变量的值,其他cpu通过嗅探,就会将自己当前缓存的缓存行设置为失效,下次要去修改的时候,发现自己的失效了,就又要去主内存获取,这样就保证了一致性。

JMM

继续向下深入,上面介绍了计算机的内存模型,JMM(Java内存模型)就是对于计算机内存模型的进一步的抽象

Java内存模型规定所有的变量都是存在主内存当中(类似于计算机模型中的物理内存),每个线程都有自己的工作内存(类似于计算机模型的高速缓存)。这里的变量包括实例变量和静态变量,但是不包括局部变量,因为局部变量是线程私有的。线程的对于共享变量的操作都是针对于自己工作内存中的变量的操作,无法直接操作主内存。

三个特性


多线程编程要确保并发程序正确地执行,必须要保证原子性、可见性以及有序性,缺一不可,不然就可能导致结果执行不正确。

原子性

即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。
一个很经典的例子就是银行账户转账问题:
比如从账户A向账户B转1000元,那么必然包括2个操作:从账户A减去1000元,往账户B加上1000元。这2个操作必须要具备原子性才能保证不出现一些意外的问题。 (像事务的原子性一样)
我们操作数据也是如此,比如i = i+1;其中就包括,读取i的值,计算i,写入i。这行代码在Java中是不具备原子性的,则多线程运行肯定会出问题,所以也需要我们使用同步和lock这些东西来确保这个特性了。
原子性其实就是保证数据一致、线程安全一部分。

可见性

当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值
若两个线程在不同的cpu,那么线程1改变了i的值还没刷新到主存,线程2又使用了i,那么这个i值肯定还是之前的,线程1对变量的修改线程没看到这就是可见性问题。 使用volatile修饰全局变量达到可见性。

有序性

程序执行的顺序按照代码的先后顺序执行。
一般来说处理器为了提高程序运行效率,可能会对输入代码进行优化**(指令重排序)**,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。

指令重排序


举个现实中的例子:

再举个程序的例子:

指令重排序主要分三种,了解下就好:

当然咯,重排序不是说瞎搞,有个大前提就是as-if-serial

as-if-serial

不管怎么重排序(编译器和处理器为了提供并行度),(单线程)程序的执行结果不能被改变!

为什么要进行指令重排序

减少中断,提高效率!简单地说,每一个指令都会包含多个步骤,每个步骤可能使用不同的硬件。因此,流水线技术产生了,它的原理是指令1还没有执行完,就可以开始执行指令2,而不用等到指令1执行结束之后再执行指令2,这样就大大提高了效率。

happens-before

讲了一堆关于重排序的知识,java程序员可也太苦了,要知道软件也要知道硬件,岂不是太累?JMM考虑到了这一点,所以推出了happens-before规范
**一方面,程序员需要JMM提供一个强的内存模型来编写代码;另一方面,编译器和处理器希望JMM对它们的束缚越少越好,这样它们就可以最可能多的做优化来提高性能,希望的是一个弱的内存模型。**JMM考虑了这两种需求,并且找到了平衡点,对编译器和处理器来说,只要不改变程序的执行结果(单线程程序和正确同步了的多线程程序),编译器和处理器怎么优化都行。而对于程序员,JMM提供了happens-before规则(JSR-133规范),满足了程序员的需求——简单易懂,并且提供了足够强的内存可见性保证。换言之,程序员只要遵循happens-before规则,那他写的程序就能保证在JMM中具有强的内存可见性。
下面这一坨东西看看就好:

总结两句话句话:1、某些情况下是不允许重排序的 2、如果操作A happens-before操作B,那么操作A在内存上所做的操作对操作B都是可见的,不管它们在不在一个线程。

回归正题

Volatile如何保证可见性和禁止指令重排

咣咣咣咣!四个大字!内存屏障
内存屏障做了两件非常重要的事情

内存屏障

硬件层的内存屏障分为两种:Load Barrier 和 Store Barrier即读屏障和写屏障。
很简单就两个作用:

  1. 阻止屏障两侧的指令重排序;
  2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效(这里就是保证可见性的关键所在)
Volatile中的内存屏障(保守策略)
  • 在每个volatile写操作的前面插入一个StoreStore屏障。
  • 在每个volatile写操作的后面插入一个StoreLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadLoad屏障。
  • 在每个volatile读操作的后面插入一个LoadStore屏障。

举个例子解释下,其他的可以类推:
所谓的load就理解为读操作,所谓的store可以理解为写操作
如果有操作如:LoadA; LoadLoad; LoadB,意味:A读取操作,LoadLoad屏障,B读取操作,那么就意味着在B读操作执行时,A读操作必定已经执行!以此限制指令重排

Volatile为什么不能保证原子性

依旧是举例说明,用显而易见的例子说明问题:
如:int i=0;这类的操作是原子性的不可拆分,但是当遇到了 i++这类非原子性操作时,volatile就很头疼
i++操作要被拆解为三个步骤:读-改-存(根据上面的说法,最后还要加上Store屏障,保证可见性)
比如有两个线程都要执行i++,第一个线程执行过程为读-改-存,在进行到存且进行刷新其他cache的过程时,第二个线程刚好执行到了改操作,此时2线程的被1刷新,cache是处于失效状态的,但是2线程的改操作已经执行了,这里的失效态不能影响第二个线程对于主存的数据更改,最后的更改使得i还是等于2,而不是我们希望的3。

运用场景

经典场景DCL

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;
    }
}

这个例子很经典,上了两个锁,1是volatile 2是synchronized,对于它做一个详细的解释为什么要这样?

class Singleton{
    private static Singleton instance = null;
     
    private Integer num;
    
    public void setNum(Integer num) {
    	this.num=num;
    }
    
    public int getNum(Integer num){
    	return num;
    }
    
    private Singleton() {   
    }
     
    public static Singleton getInstance() {
        if(instance == null) {
            synchronized (Singleton.class) {
                if(instance==null)
                    instance = new Singleton();
            }
        }
        return instance;
    }
}

首先我们去掉volatile,思考一下这个过程,多个线程访问getInstance,一个线程拿到锁,进入if判读,创建对象,其他的线程阻塞,这看起来没有问题。
但是仔细思考,却不是这么简单。
对象的创建操作要分为三步:1、在堆内存创建空间 2、数据初始化 3、将对象引用指向内存地址
上面说过,为了提高执行效率会发生指令重排序,上面的3个步骤1操作必定先于2、3执行,但是2、3操作是可以互换的
如果重排序后的执行顺序为 1 、3、2,问题就来了
一个线程获取到了对象锁,开始进行new操作,然后进行到了1、3这个步骤,但是还没执行2的时候
这个时候这个对象已经不是null了,有一个线程这时候来执行getInstance方法,第一个if跳过,直接进行了return
就获取到了一个对象但是成员变量未被初始化,那如果它拿着这个对象调用了比如getNumber方法,就会发生空指针异常!!!
所以这里我们加上了volatile关键字,他的重点就是为了不让他发生指令重排!!!

写在最后

首先,我裂开,多线程好难🤯 作者本人是个比较愚钝的程序员,理解很多知识要反复的看才能明白道理,所以在记录学习Volatile的过程中,搜集了很多很多大佬的文章,其中不少截图都是出自大佬的文章里面的,多多包涵,只是读到的时候觉得写的确实妙哉,然后就引用了一下。QAQ

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值