Java关键字:volatile有序性和可见性的实现原理

并发编程的三大特性:

可见性、有序性、原子性。

volatile 作为Java的一个轻量级关键字,它可以保证可见性和有序性。

那么它底层是怎么一回事呢? 跟着我的思路 几分钟带你搞懂。

我们先看一段代码:

public class test1 {

    private static boolean initFlag = false;

    public static void main(String[] args) throws InterruptedException {
        //创建一个新的线程,让他进入死循环。
        new Thread(() -> {
            System.out.println("thread work...");
            while (!initFlag) {

            }
            System.out.println("=====================end");
        }).start();

        //保证它进入死循环
        Thread.sleep(2000);

        //创建一个新的线程,修改死循环的判断条件 flag
        new Thread(() -> prepareData()).start();
    }

    public static void prepareData() {
        System.out.println("prepare data ...");
        initFlag = true;
        System.out.println("prepare date end ...");
    }
}

创建一个新线程,主线程sleep一会儿,确保线程1进入死循环后,再开启一个线程2去修改 initFlag 

运行一下:

我们可以看到卡在这里了,并不能打出 ====end。这是因为,他们的内存空间,不是同一份。

1、JMM内存模型

每个线程,在工作的时候,会从主内存中,弄一个副本放在自己的工作内存中。

每个线程的工作内存,其它线程无法感知。

也就是说,比如有个变量,initFlag = false; 线程1将它改为了 true,从上面的测试我们也可以发现,别的线程是无法感知的。

那我现在希望一个线程修改完之后,另一个线程感知的到,要怎么做。

我们在flag上加一个关键字 volatile 再运行

private static volatile boolean initFlag = false;

可以看到被感知了。

很多朋友应该都说的出来,是因为这个关键字保证了多线程可见性。

那volatile的可见性,是怎么实现的呢?

我们先来讲讲:

2、JMM数据原子操作

我们结合上面的代码来说。

没有加 volatile 的情况:

线程1,理解成我们代码中第一个 new 的 Thread。 它实际上流程是这样:

它要用到initFlag嘛。所以它先去主内存中获取initFlag变量。这个操作就是JMM的原子操作 read

然后它会把这个变量的副本 放到自己的工作内存中, 这个操作是 load

线程1就开始工作,这个时候是 use 操作

现在到线程2 同样的 它也是 readloaduse

而它不同的是,它修改了一下initFlag的值嘛,所以它有个 assign 操作,等它执行完之后,它又会有 storewrite两个操作。这之后,主内存中的initFlag才改为了true

而线程1的工作内存中的initFlag,是一早就load的副本,所以它一直为false,所以才导致一直在死循环,别的线程修改了也感知不到。

那么 volatile 是怎么实现可见性,也就是一个线程修改,别的线程能感知到呢?

直接跟别的线程说肯定不可能的,因为线程间是不能通讯的。

它其实是跟缓存一致性协议有关:

2.1 缓存一致性协议

对于Intel的CPU 它叫MESI

别的平台不同叫法的。意思都差不多。

前面我们说到了几个原子操作图中我们也可以看出来,cpu和内存之间,它们都是会经过总线的。

硬件级别的机制,有个总线嗅探机制。它就是一个监听机制,类似MQ那种。

当你修改了这个数据并把它写入到主内存的时候,会经过总线,别的线程在监听嘛。就会发现,这个数据本线程有用到,那么它就会把自己的工作内存中的数据失效掉。等线程中下次再用到这个数据怎么办?重新从主内存中加载这个数据。也就是再 read load

3、volatile缓存可见性的实现原理

当有volatile关键字的变量,被修改了。

它在汇编层面,会加上一个lock前缀指令,它会立即锁定这块内存区域的缓存(缓存行锁定),并立即回写到主内存。

汇编代码是基于硬件平台的代码,也就是说 比如Intel有一套 Amd也有一套。

其它线程,通过总线嗅探机制可以感知到变化,而将自己缓存里的数据失效掉。

简单点说,volatile是让变量可以触发CPU的缓存一致性协议,让总线可以触发总线嗅探机制。当某个线程修改了volatile变量时,其它线程会监听并失效自己线程内工作内存的这个变量。等下次用到了该变量,就从主存中取。从而实现了内存可见性。

4、volatile有序性的实现原理

首先讲到有序性,就要从计算机内部的一个机制说起

4.1 指令重排序

它是指计算机在不影响单线程程序执行结果的前提下,计算机为了最大限度的发挥机器性能,它可能会对计算机指令进行重排序。比如代码是: a=100, b=200

cpu觉得 先执行 b=200 再执行 a = 100快。它就会指令重排。

说白话就是:cpu在保证单线程程序执行结果不会有错的情况下,它会为了执行快点,而修改一下执行顺序。

4.2 as-if-serial和happens-before 原则

重排序不是随便重排的,它会遵循 as-if-serialhappens-before 两个原则。

as-if-serial原则:

计算机系统在执行程序时,可以进行各种优化,只要最终的结果与按照程序顺序执行得到的结果一致即可。也就是说,系统可以以任意顺序重新排列指令的执行,只要保证程序的语义不变。这个原则是为了允许编译器、处理器和操作系统对代码进行性能优化,提高程序的执行效率。

happens-before原则:

该原则用于指定在并发环境中,不同的操作之间的执行顺序。如果操作A在操作B之前执行,我们可以说操作A "happens-before" 操作B。这个原则用于建立并发编程模型中的偏序关系,以确保程序在并发情况下的正确执行。happens-before关系是通过同步操作(如锁、原子操作和线程的启动/终止)来建立的,它们提供了一种确定不同操作之间执行顺序的方法,避免了并发访问数据时出现的竞态条件等问题。

这两个原则都是为了确保程序的正确性和可靠性,在并发编程中起着重要的作用。as-if-serial原则允许系统进行优化,提高性能,而happens-before原则则管理并发操作的顺序,保证程序的语义不受到破坏。

那么有没有什么实际工作中的影响呢?

在阿里巴巴规范手册里面就有一个:

4.3 双重检测锁 DCL对象半初始化问题

下面这段代码是单例模式的DCL的实现,很简单:

public class Singleton {
    private static Singleton singleton;
    
    private Singleton (){
        
    }
    
    public static Singleton getSingleton() {
        if(singleton == null) {
            synchronized(Singleton.class) {
                if(singleton == null) {
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }
}

可能从代码层面很难看出来,我们直接看字节码文件。

为了方便,我直接贴到注释中了。

这里又得讲一个知识点:

4.4 一个对象new的全过程

它并不是原子操作的。 它的底层是怎么实现的呢?

它先加载类,它会分配一块内存空间。堆内存嘛,大家都知道。

然后初始化零值。这是什么意思呢。就是它会把成员变量都设置为零值。

这个零值不是指0。 比如:

int类型的零值是0 long类型是0L double是0.0d char:初始化为'\u0000' boolean 是默认 false

String或者其它引用类型,都初始化为null

然后它会设置对象头,对象头可能包含:

  1. Mark Word:标记字,包含对象的锁状态、GC状态、分代年龄等信息。

  2. Class Pointer:指向这个对象所属的类的指针,用于确定这个对象的类型。

  3. Array Length:如果是数组对象,则包含数组长度信息。

最后执行init方法,也就是真正的赋值

回到代码中的例子:

这两行,实际上是满足指令重排的两个原则的。而这两行重排会导致什么后果?

singleton = new Singleton();

重排之后,它还没init。就返回了。简单点说,就是 它还没init完,singleton变量就已经被修改了。它就已经不为null了,因为它被赋予了堆内存地址。但是 它的字段,才刚刚初始化零值,也就是都还是 null 。执行init之后,它才有对应的属性。 这就是 半初始化问题。

那么bug是不是就出来了:

 if(singleton == null) 

这个if就不成立了,然后就return了。那用null去运算,是不是就会有很多问题。

那解决办法就非常简单:

对变量加一个volatile修饰符。禁止指令重排。完事

5、那么为什么volatile可以禁止指令重排呢

前面我们说到了,它在汇编层面,会加上一个lock前缀指令。它提供了内存屏障功能。使lock前后指令不能重排序。

5.1 啥是内存屏障

就是指,两行代码间,如果你不想让计算机去重排序,可以在两行代码间加上内存屏障。这个内存屏障是和计算机约定好的。

Java规范定义的内存屏障有4个:

读读、读写、写写、写读

不同CPU硬件对JVM的内存屏障规范实现指令不一样。

Intel CPU的指令,由 lfence、sfence啥的。

JDK内部,大部分实现屏障,都是通过汇编的lock指令来实现的,它简化了内存屏障。

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

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值