百度360必应搜狗淘宝本站头条
当前位置:网站首页 > 技术分析 > 正文

以volatile视角看JMM

liebian365 2024-11-27 17:08 2 浏览 0 评论

前言

在写内存模型的文章的时候,恰好赶上小周同学的JVM基础文章,对内存模型和虚拟机栈讨论了一番。

在JVM和JMM中都提到了内存,不管是哪块内存从操作系统层面来说都是物理内存,只是从不同的层面给他了不同的表述,就像勾勾公众号里是小编,在工作中是个搬砖者,描述不一样但是同指一个对象。

JMM层面内存:工作内存、主内存。

JVM:堆,虚拟机栈,方法区,本地方法栈,程序计数器。

工作内存等价于虚拟机栈,虚拟机栈与线程同生共死。

主内存等价于堆区+方法区。

你是否有不同的观点呢?

本篇文章勾勾想从不那么深的角度理解一下volatile,虽然平时开发中不常使用,但是阅读源码比如AQS这个是必会的知识点。

想要深入的理解volatile的底层原理,Java代码层面展示出来的只是结果,它的底层涉及到Jdk源码、汇编、CPU、操作系统等知识,奈何勾勾才疏学浅,只能从表象理解一下volatile关键字。

volatile的语义

volatile可以用来修饰实例变量或者类变量。被volatile修饰的变量具有如下语义:

  • 当一个线程修改volatile修饰的变量时,其他线程能立即看到修改后的最新值,即volatile保证了可见性。
  • volatile关键字禁止JMM和处理器对volatile修饰的变量进行重排序,即volatile保证了有序性。
  • volatile不保证原子性,因此volatile被称为轻量级锁。

volatile与可见性

public class TestVolatile {
    private  static int state ;

    public void setState(int newState) {
        state = newState;
    }

    public static void main(String[] args) {
        new Thread(()->{
            while (true) {
                if (state == 1){
                    System.out.println("判断标志位为1,退出循环");
                    break;
                }

            }
        },"listen").start();

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        new Thread(()->{
            new TestVolatile().setState(1);
            System.out.println("将标志修改为1");
        },"update").start();

    }

}

在上述代码中定义了一个共享的state状态变量,启动listen线程监听state状态的值,当state状态为1时则退出循环,优先启动监听线程。另外启动update线程修改state状态的值为1。

运行结果如下图:

然后我们用volatile修饰state状态变量:

 private static volatile int state ;

线程监听到了修改的值,退出了循环,那么volatile是怎么实现可见性呢?

我们先查看没有volatile修饰和有volatile修饰的字节码信息,勾勾给大家推荐一个可视化查看字节码的插件jclasslib,只需要安装插件即可使用。

没有volatile修饰的字节码内容:

有volatile关键字修饰时的字节码内容:

通过字节码对比可以发现,字节码文件是一样的,那么volatile是如何实现可见性呢?勾勾是一个面向百度开发的程序媛,这个时候必须问下度娘,看了几篇博客结合《深入理解Java虚拟机》这本书,勾勾对可见性有个大致的概念性理解:

读volatile修饰的变量时,会从主内存中取数据,然后在线程的工作内存中创建变量副本。

写volatile修饰的变量时,会对总线lock加锁,此时其他CPU都不能访问到这个变量,当线程将修改后的数据写入主内存并通知其他CPU的数据失效后,对总线解锁。其他线程在后续的过程中因为变量失效不得不从主内存再次获取数据,从而保证了可见性。

所以测试用例的执行过程即是:

listen线程从主内存中获取state的值为0,然后缓存到工作内存中。

update线程修改state的值为1,并且写回到主内存中。

listen线程在工作内存中缓存失效,映射到硬件上就是CPU的L1 Cache或者L2 Cache中的Cache Line失效,因此需要从主内存中再次加载数据,就可以取到最新修改的值了。

volatile与原子性

public class TestVolatile {
    public static volatile int num = 0;

    private static final int threadCount = 20;

    static CountDownLatch countDownLatch = new CountDownLatch(threadCount);

    public static void incre() {
        num ++;
    }

    public static void main(String[] args) {
        for (int i = 0; i < threadCount; i++) {
            new Thread(()->{
                for (int m = 0; m < 1000; m++){
                    incre();
                }
                countDownLatch.countDown();
            }).start();
        }
        try {
            countDownLatch.await();
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        System.out.println("计算结果为:"+num);
    }
}

在上面的代码中,勾勾启动了20个线程,每个线程都对共享变量num执行了1000次的加1操作,我们期望的结果是20000,但是不论执行多少次,得到的结果都比20000小。

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

volatile变量在各个线程的的工作内存中可以存在不一致的情况,但是由于每次使用之前都需先刷新,执行引擎看不到不一致的情况,因此可以认为不存在一致性问题,但是Java里运算并非原子操作,导致volatile变量德运算在并发下也是不安全的。

我们对上面的测试类进行反编译,查看其中的incre方法:

 public static void incre();
    Code:
       0: getstatic     #2                  // Field num:I
       3: iconst_1
       4: iadd
       5: putstatic     #2                  // Field num:I
       8: return

通过字节码文件分析,getstatic指定得到num的值后,volatile保证了num的值此时是最新的,但是在执行iconst_1、iadd这些指令的时候,其他线程可能已经修改了num的值,而在操作栈顶的值就变成了过期的数据,putstatic执行执行后就可能把小的num值更新到了主内存。

JMM要求lock、unlock、read、load、assign、use、store、write这8个原子操作都具有原子性,但多个原子操作合在一期是不一定原子的,且对64位的数据类型(long和double)允许虚拟机将没有volatile修饰的读写操作划分为2次的32位操作,但是现在基本商用虚拟机都选择把64位数据的读写操作作为原子性来对待,所以开发中不必过多忧虑啦。

因为volatile变量不能保证原子性我们需要通过加锁(synchronized或者JUC中的原子类)来保证原子性。

volatile与有序性

public class Singleton {
    private volatile static Singleton instance;
    
    public static Singleton getInstance(){
        if (instance == null){
            synchronized (Singleton.class){
                if (instance == null){
                    instance = new Singleton();
                }
            }
        }
        return instance;
    }

    public static void main(String[] args) {
        Singleton.getInstance();
    }
}

上述测试用例是比较经典的DCL+volatile实现单例模式。

      17: new           #3                  // class com/example/demo/articles/jmm/Singleton
      //1)在堆区分配内存,生成一个空壳,并压入栈顶
      20: dup
      //2)赋值栈顶元素,将复制的数据压入栈顶
      21: invokespecial #4                  // Method "<init>":()V
     //3)执行默认构造函数     
      24: putstatic     #2                  // Field nstance:Lcom/example/demo/articles/jmm/Singleton;
     //4)将完整的对象赋值给共享变量instance

从字节码分析,第三步和第四步之间是没有依赖关系的,有可能发生指令排序而导致有的线程拿到了不完整的对象,这个时候的对象没有执行构造参数。

volatile保证有序性是通过禁止指令重排序来实现的。那么它是怎么做到禁止指令重排序呢?

勾勾先理解一个概念内存屏障,内存屏障分为两类Load和Store。

内存屏障的作用有两个:

  • 阻塞屏障前后的指令重排序;
  • 强制把工作内存中的数据写入主内存,并把其他缓存中的数据失效。

Load屏障的主要作用是在指令前插入Load屏障,可以让缓存的数据失效,强制从主存中获取数据。

Store屏障的主要作用是在指令后插入Store屏障,能让缓存中的最新数据写入主存,并且通知其他缓存中的数据失效。

勾勾对查看了上边测试代码的汇编指令,发现用volatile修饰instance的时候多了lock。

0x000000000297ed75: lock add dword ptr [rsp],0h
  0x000000000297ed7a: cmp     qword ptr [r10+46h],0h
  0x000000000297ed82: jne     297ed9bh
  0x000000000297ed84: mov     rax,0h
  0x000000000297ed8e: lock cmpxchg qword ptr [r10+16h],r15
  0x000000000297ed94: jne     297ed9bh
  0x000000000297ed96: or      eax,1h
  0x000000000297ed99: jmp     297edabh
  0x000000000297ed9b: test    eax,0h
  0x000000000297eda0: jmp     297edabh
  0x000000000297eda2: mov     r10,qword ptr [rax]
  0x000000000297eda5: lock cmpxchg qword ptr [rbp+0h],r10

volatile禁止指令重排序是通过“lock”前缀来实现的,其作用相当于内存屏障。

  • 确保指令重排序时不会将其后面的代码排到内存屏障之前,也不会将前面的代码排到内存屏障之后。
  • 确保在执行到内存屏障修饰的指令时前面的代码全部执行完毕。
  • 强制将线程工作内存中的值刷新到主内存中。
  • 如果是写操作,则会导致其他线程工作内存中的缓存数据失效。

等价于:

在每个volatile修饰的变量的写操作之前插入StoreStore屏障,在写操作之后插入了StoreLoad屏障。

在每个volatile修饰的变量读操作前插入LoadLoad屏障。在读操作后插入LoadStore屏障。

由于内存屏障的作用,避免了volatile变量和其它指令重排序,使得volatile表现出了锁的特性。

happens-before原则

学到这里我们发现JMM中的有序性需要通过volatile和锁来维护,但是平时开发中却不是太需要考虑,主要是因为Java语言中天然的先行发生原则(happens-before),只需要满足这些原则,就无需任何同步器。

程序次序规则:在一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作。

管程锁定规则:一个unlock操作先行发生于后面对同一个锁的lock操作。

volatile变量规则:对一个volatile变量的写操作先行发生于后面对这个变量的读操作。

线程启动原则:Thread对象的start()方法先行发生于此线程的每一个动作 。

线程终止规则:线程中所有的操作都先行发生于线程的终止检测。

线程中断规则:对线程interrupt()方法的调用先行发生于被中断线程的代码检测到中断事件的发生。

对象终结规则:一个对象的初始化完成(构造函数执行结束)先行于它的finalize()方法的开始。

传递规则:如果操作A先行发生于操作B,而操作B先行发生于操作C,则可以得出操作A先行发生于操作C。

volatile与synchronized的区别

  1. 使用:volatile用于修饰变量,不能用于修饰方法,代码块,局部变量和常量。synchronized可以用于修饰方法和代码块,不能修饰变量。
  2. 基础概念的保证:volatile保证了可见性和有序性,无法保证原子性。synchronized保证了原子性,有序性和可见性。虽然都保证了可见性和有序性,但是其实现原理也不相同。
  3. 阻塞:volatile不会使线程进入阻塞。synchronized会使线程进入阻塞。

思考题:

将第一个测试用例中的state变量不用volatile修饰,但是监听线程进行sleep短暂的时间,运行可以看到listen线程很快退出循环。勾勾怀疑sleep进入阻塞状态,休眠到时后进入就绪状态时没有保留工作内存的变量副本。

再次修改代码测试,通过while循环等待一段时间,依然会退出循环。勾勾的另外一个怀疑是变量副本在工作内存中存在失效时间,且其他线程修改后的值什么时候刷新到主存是不确定的,如果我们的程序中没有一直监听共享变量的值,那么可能就会失效导致再次从主内存获取数据。

多并发的程序总是能给人惊喜,不知道勾勾的理解是否正确,你是否有其他的想法呢?

public class TestVolatile {
    public static int state ;

    public void setState(int newState) {
        state = newState;
    }

    public static void main(String[] args) {
        new Thread(()->{
            while (true) {
                //try {
                 //   TimeUnit.MILLISECONDS.sleep(100);
                //} catch (InterruptedException e) {
                  //  e.printStackTrace();
               // }
                Long time = System.currentTimeMillis();
                while ((System.currentTimeMillis()-time) < 1000){

                }
                if (state == 1){
                    System.out.println("判断标志位为1,退出循环");
                    break;
                }

            }
        },"listen").start();

        try {
            TimeUnit.SECONDS.sleep(1);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }

        new Thread(()->{
            new TestVolatile().setState(1);
            System.out.println("将标志修改为1");
        },"update").start();

    }

}

参考文档:

《深入理解Java虚拟机》

《Java高并发变成详解:多线程与架构设计》

相关推荐

快递查询教程,批量查询物流,一键管理快递

作为商家,每天需要查询许许多多的快递单号,面对不同的快递公司,有没有简单一点的物流查询方法呢?小编的回答当然是有的,下面随小编一起来试试这个新技巧。需要哪些工具?安装一个快递批量查询高手快递单号怎么快...

一键自动查询所有快递的物流信息 支持圆通、韵达等多家快递

对于各位商家来说拥有一个好的快递软件,能够有效的提高自己的工作效率,在管理快递单号的时候都需要对单号进行表格整理,那怎么样能够快速的查询所有单号信息,并自动生成表格呢?1、其实方法很简单,我们不需要一...

快递查询单号查询,怎么查物流到哪了

输入单号怎么查快递到哪里去了呢?今天小编给大家分享一个新的技巧,它支持多家快递,一次能查询多个单号物流,还可对查询到的物流进行分析、筛选以及导出,下面一起来试试。需要哪些工具?安装一个快递批量查询高手...

3分钟查询物流,教你一键批量查询全部物流信息

很多朋友在问,如何在短时间内把单号的物流信息查询出来,查询完成后筛选已签收件、筛选未签收件,今天小编就分享一款物流查询神器,感兴趣的朋友接着往下看。第一步,运行【快递批量查询高手】在主界面中点击【添...

快递单号查询,一次性查询全部物流信息

现在各种快递的查询方式,各有各的好,各有各的劣,总的来说,还是有比较方便的。今天小编就给大家分享一个新的技巧,支持多家快递,一次能查询多个单号的物流,还能对查询到的物流进行分析、筛选以及导出,下面一起...

快递查询工具,批量查询多个快递快递单号的物流状态、签收时间

最近有朋友在问,怎么快速查询单号的物流信息呢?除了官网,还有没有更简单的方法呢?小编的回答当然是有的,下面一起来看看。需要哪些工具?安装一个快递批量查询高手多个京东的快递单号怎么快速查询?进入快递批量...

快递查询软件,自动识别查询快递单号查询方法

当你拥有多个快递单号的时候,该如何快速查询物流信息?比如单号没有快递公司时,又该如何自动识别再去查询呢?不知道如何操作的宝贝们,下面随小编一起来试试。需要哪些工具?安装一个快递批量查询高手快递单号若干...

教你怎样查询快递查询单号并保存物流信息

商家发货,快递揽收后,一般会直接手动复制到官网上一个个查询物流,那么久而久之,就会觉得查询变得特别繁琐,今天小编给大家分享一个新的技巧,下面一起来试试。教程之前,我们来预览一下用快递批量查询高手...

简单几步骤查询所有快递物流信息

在高峰期订单量大的时候,可能需要一双手当十双手去查询快递物流,但是由于逐一去查询,效率极低,追踪困难。那么今天小编给大家分享一个新的技巧,一次能查询多个快递单号的物流,下面一起来学习一下,希望能给大家...

物流单号查询,如何查询快递信息,按最后更新时间搜索需要的单号

最近有很多朋友在问,如何通过快递单号查询物流信息,并按最后更新时间搜索出需要的单号呢?下面随小编一起来试试吧。需要哪些工具?安装一个快递批量查询高手快递单号若干怎么快速查询?运行【快递批量查询高手】...

连续保存新单号功能解析,导入单号查询并自动识别批量查快递信息

快递查询已经成为我们日常生活中不可或缺的一部分。然而,面对海量的快递单号,如何高效、准确地查询每一个快递的物流信息,成为了许多人头疼的问题。幸运的是,随着科技的进步,一款名为“快递批量查询高手”的软件...

快递查询教程,快递单号查询,筛选更新量为1的单号

最近有很多朋友在问,怎么快速查询快递单号的物流,并筛选出更新量为1的单号呢?今天小编给大家分享一个新方法,一起来试试吧。需要哪些工具?安装一个快递批量查询高手多个快递单号怎么快速查询?运行【快递批量查...

掌握批量查询快递动态的技巧,一键查找无信息记录的两种方法解析

在快节奏的商业环境中,高效的物流查询是确保业务顺畅运行的关键。作为快递查询达人,我深知时间的宝贵,因此,今天我将向大家介绍一款强大的工具——快递批量查询高手软件。这款软件能够帮助你批量查询快递动态,一...

从复杂到简单的单号查询,一键清除单号中的符号并批量查快递信息

在繁忙的商务与日常生活中,快递查询已成为不可或缺的一环。然而,面对海量的单号,逐一查询不仅耗时费力,还容易出错。现在,有了快递批量查询高手软件,一切变得简单明了。只需一键,即可搞定单号查询,一键处理单...

物流单号查询,在哪里查询快递

如果在快递单号多的情况,你还在一个个复制粘贴到官网上手动查询,是一件非常麻烦的事情。于是乎今天小编给大家分享一个新的技巧,下面一起来试试。需要哪些工具?安装一个快递批量查询高手快递单号怎么快速查询?...

取消回复欢迎 发表评论: