volatile 手摸手带你解析

Wesley13
• 阅读 757

volatile 手摸手带你解析

前言

volatile 是 Java 里的一个重要的指令,它是由 Java 虚拟机里提供的一个轻量级的同步机制。一个共享变量声明为 volatile 后,特别是在多线程操作时,正确使用 volatile 变量,就要掌握好其原理。

特性

volatile 具有可见性有序性的特性,同时,对 volatile 修饰的变量进行单个读写操作是具有原子性

这几个特性到底是什么意思呢?

  • 可见性: 当一个线程更新了 volatile 修饰的共享变量,那么任意其他线程都能知道这个变量最后修改的值。简单的说,就是多线程运行时,一个线程修改 volatile 共享变量后,其他线程获取值时,一定都是这个修改后的值。
  • 有序性: 一个线程中的操作,相对于自身,都是有序的,Java 内存模型会限制编译器重排序和处理器重排序。意思就会说 volatile 内存语义单个线程中是串行的语义。
  • 原子性: 多线程操作中,非复合操作单个 volatile 的读写是具有原子性的。

可见性

可见性是在多线程中保证共享变量的数据有效,接下来我们通过有 volatile 修饰的变量和无 volatile 修饰的变量代码的执行结果来做对比分析。

无 volatile 修饰变量

以下是没有 volatile 修饰变量代码,通过创建两个线程,来验证 flag 被其中一个线程修改后的执行情况。

/**
 * Created by YANGTAO on 2020/3/15 0015.
 */
public class ValatileDemo {

    static Boolean flag = true;

    public static void main(String[] args) {

        // A 线程,判断其他线程修改 flag 之后,数据是否对本线程有效
        new Thread(() -> {
            while (flag) {

            }
            System.out.printf("********** %s 线程执行结束! **********", Thread.currentThread().getName());
        }, "A").start();

        
        // B 线程,修改 flag 值
        new Thread(() -> {
            try {
                // 避免 B 线程比 A 线程先运行修改 flag 值  
                TimeUnit.SECONDS.sleep(1);
                flag = false;
                // 如果 flag 值修改后,让 B 线程先打印信息
                TimeUnit.SECONDS.sleep(2);

                System.out.printf("********** %s 线程执行结束! **********", Thread.currentThread().getName());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "B").start();

    }
}

上面代码中,当 flag 初始值 true,被 B 线程修改为 false。如果修改后的值对 A 线程有效,那么正常情况下 A 线程会先于 B 线程结束。执行结果如下:

volatile 手摸手带你解析

执行结果是:当 B 线程执行结束后,flag = false并未对 A 线程生效,A 线程死循环。

volatile 修饰变量

在上述代码中,当我们把 flag 使用 volatile 修饰:

/**
 * Created by YANGTAO on 2020/3/15 0015.
 */
public class ValatileDemo {

    static volatile Boolean flag = true;

    public static void main(String[] args) {

        // A 线程,判断其他线程修改 flag 之后,数据是否对本线程有效
        new Thread(() -> {
            while (flag) {

            }
            System.out.printf("********** %s 线程执行结束! **********", Thread.currentThread().getName());
        }, "A").start();

        
        // B 线程,修改 flag 值
        new Thread(() -> {
            try {
                // 避免 B 线程比 A 线程先运行修改 flag 值  
                TimeUnit.SECONDS.sleep(1);
                flag = false;
                // 如果 flag 值修改后,让 B 线程先打印信息
                TimeUnit.SECONDS.sleep(2);

                System.out.printf("********** %s 线程执行结束! **********", Thread.currentThread().getName());
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }, "B").start();

    }
}

执行结果:

volatile 手摸手带你解析

B 线程修改 flag 值后,对 A 线程数据有效,A 线程跳出循环,执行完成。所以 volatile 修饰的变量,有新值写入后,对其他线程来说,数据是有效的,能被其他线程读到。

主内存和工作内存

上面代码中的变量加了 volatile 修饰,为什么就能被其他线程读取到,这就涉及到 Java 内存模型规定的变量访问规则。

  • **主内存:**主内存是机器硬件的内存,主要对应Java 堆中的对象实例数据部分。
  • **工作内存:**每个线程都有自己的工作内存,对应虚拟机栈中的部分区域,线程对变量的读/写操作都必须在工作内存中进行,不能直接读写主内存的变量。

上面无 volatile 修饰变量部分的代码执行示意图如下:

volatile 手摸手带你解析

当 A 线程读取到 flag 的初始值为true,进行 while 循环操作,B 线程将工作内存 B 里的 flag 更新为false,然后将值发送到主内存进行更新。随后,由于此时的 A 线程不会主动刷新主内存中的值到工作内存 A 中,所以线程 A 所取得 flag 值一直都是true,A 线程也就为死循环不会停止下来。

上面volatile 修饰变量部分的代码执行示意图如下:

volatile 手摸手带你解析

当 B 线程更新 volatile 修饰的变量时,会向 A 线程通过线程之间的通信发送通知(JDK5 或更高版本),并且将工作内存 B 中更新的值同步到主内存中。A 线程接收到通知后,不会再读取工作内存 A 中的值,会将主内存的变量通过主内存和工作内存之间的交互协议,拷贝到工作内存 A 中,这时读取的值就是线程 A 更新后的值flag = false。 整个变量值得传递过程中,线程之间不能直接访问自身以外的工作内存,必须通过主内存作为中转站传递变量值。在这传递过程中是存在拷贝操作的,但是对象的引用,虚拟机不会整个对象进行拷贝,会存在线程访问的字段拷贝。

有序性

volatile 包含禁止指令重排的语义,Java 内存模型会限制编译器重排序和处理器重排序,简而言之就是单个线程内表现为串行语义。 那什么是重排序? 重排序的目的是编译器和处理器为了优化程序性能而对指令序列进行重排序,但在单线程和单处理器中,重排序不会改变有数据依赖关系的两个操作顺序。 比如:

/**
 * Created by YANGTAO on 2020/3/15 0015.
 */
public class ReorderDemo {
    
    static int a = 0;

    static int b = 0;

    public static void main(String[] args) {
        a = 2;
        b = 3;
    }
}

// 重排序后:

public class ReorderDemo {
    
    static int a = 0;

    static int b = 0;

    public static void main(String[] args) {
        b = 3;  // a 和 b 重排序后,调换了位置
        a = 2;
    }
}

但是如果在单核处理器和单线程中数据之间存在依赖关系则不会进行重排序,比如:

/**
 * Created by YANGTAO on 2020/3/15 0015.
 */
public class ReorderDemo {

    static int a = 0;

    static int b = 0;

    public static void main(String[] args) {
        a = 2;
        b = a;
    }
}

// 由于 a 和 b 存在数据依赖关系,则不会进行重排序

volatile 实现特有的内存语义,Java 内存模型定义以下规则(表格中的 No 代表不可以重排序):

volatile 手摸手带你解析

Java 内存模型在指令序列中插入内存屏障来处理 volatile 重排序规则,策略如下:

  • volatile 写操作前插入一个 StoreStore 屏障
  • volatile 写操作后插入一个 StoreLoad 屏障
  • volatile 读操作后插入一个 LoadLoad 屏障
  • volatile 读操作后插入一个 LoadStore 屏障

该四种屏障意义:

  • StoreStore:在该屏障后的写操作执行之前,保证该屏障前的写操作已刷新到主内存。
  • StoreLoad:在该屏障后的读取操作执行之前,保证该屏障前的写操作已刷新到主内存。
  • LoadLoad:在该屏障后的读取操作执行之前,保证该屏障前的读操作已读取完毕。
  • LoadStore:在该屏障后的写操作执行之前,保证该屏障前的读操作已读取完毕。

原子性

前面有提到 volatile 的原子性是相对于单个 volatile 变量的读/写具有,比如下面代码:

/**
 * Created by YANGTAO on 2020/3/15 0015.
 */
public class AtomicDemo {

    static volatile int num = 0;

    public static void main(String[] args) throws InterruptedException {

        final CountDownLatch latch = new CountDownLatch(10);
        for (int i = 0; i < 10; i++) {  // 创建 10 个线程
            new Thread(() -> {
                for (int j = 0; j < 1000; j++) {    // 每个线程累加 1000
                    num ++;
                }
                latch.countDown();
            }, String.valueOf(i+1)).start();
        }

        latch.await();
        
        // 所有线程累加计算的数据
        System.out.printf("num: %d", num);
    }
}

上面代码中,如果 volatile 修饰 num,在 num++ 运算中能持有原子性,那么根据以上数量的累加,最后应该是 num: 10000。 代码执行结果:

volatile 手摸手带你解析

结果与我们预计数据的相差挺多,虽然 volatile 变量在更新值的时候回通知其他线程刷新主内存中最新数据,但这只能保证其基本类型变量读/写的原子操作(如:num = 2)。由于num++是属于一个非原子操作的复合操作,所以不能保证其原子性。

使用场景

  1. volatile 变量最后的运算结果不依赖变量的当前值,也就是前面提到的直接赋值变量的原子操作,比如:保存数据遍历的特定条件的一个值。
  2. 可以进行状态标记,比如:是否初始化,是否停止等等。

总结

volatile 是一个简单又轻量级的同步机制,但在使用过程中,局限性比较大,要想使用好它,必须了解其原理及本质,所以在使用过程中遇到的问题,相比于其他同步机制来说,更容易出现问题。但使用好 volatile,在某些解决问题上能获取更佳的性能。

个人博客: https://ytao.top

关注公众号 【ytao】,更多原创好文

volatile 手摸手带你解析

点赞
收藏
评论区
推荐文章
blmius blmius
3年前
MySQL:[Err] 1292 - Incorrect datetime value: ‘0000-00-00 00:00:00‘ for column ‘CREATE_TIME‘ at row 1
文章目录问题用navicat导入数据时,报错:原因这是因为当前的MySQL不支持datetime为0的情况。解决修改sql\mode:sql\mode:SQLMode定义了MySQL应支持的SQL语法、数据校验等,这样可以更容易地在不同的环境中使用MySQL。全局s
皕杰报表之UUID
​在我们用皕杰报表工具设计填报报表时,如何在新增行里自动增加id呢?能新增整数排序id吗?目前可以在新增行里自动增加id,但只能用uuid函数增加UUID编码,不能新增整数排序id。uuid函数说明:获取一个UUID,可以在填报表中用来创建数据ID语法:uuid()或uuid(sep)参数说明:sep布尔值,生成的uuid中是否包含分隔符'',缺省为
待兔 待兔
5个月前
手写Java HashMap源码
HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程HashMap的使用教程22
Jacquelyn38 Jacquelyn38
3年前
2020年前端实用代码段,为你的工作保驾护航
有空的时候,自己总结了几个代码段,在开发中也经常使用,谢谢。1、使用解构获取json数据let jsonData  id: 1,status: "OK",data: 'a', 'b';let  id, status, data: number   jsonData;console.log(id, status, number )
Stella981 Stella981
3年前
KVM调整cpu和内存
一.修改kvm虚拟机的配置1、virsheditcentos7找到“memory”和“vcpu”标签,将<namecentos7</name<uuid2220a6d1a36a4fbb8523e078b3dfe795</uuid
Easter79 Easter79
3年前
Twitter的分布式自增ID算法snowflake (Java版)
概述分布式系统中,有一些需要使用全局唯一ID的场景,这种时候为了防止ID冲突可以使用36位的UUID,但是UUID有一些缺点,首先他相对比较长,另外UUID一般是无序的。有些时候我们希望能使用一种简单一些的ID,并且希望ID能够按照时间有序生成。而twitter的snowflake解决了这种需求,最初Twitter把存储系统从MySQL迁移
Wesley13 Wesley13
3年前
00:Java简单了解
浅谈Java之概述Java是SUN(StanfordUniversityNetwork),斯坦福大学网络公司)1995年推出的一门高级编程语言。Java是一种面向Internet的编程语言。随着Java技术在web方面的不断成熟,已经成为Web应用程序的首选开发语言。Java是简单易学,完全面向对象,安全可靠,与平台无关的编程语言。
Stella981 Stella981
3年前
Django中Admin中的一些参数配置
设置在列表中显示的字段,id为django模型默认的主键list_display('id','name','sex','profession','email','qq','phone','status','create_time')设置在列表可编辑字段list_editable
Wesley13 Wesley13
3年前
MySQL部分从库上面因为大量的临时表tmp_table造成慢查询
背景描述Time:20190124T00:08:14.70572408:00User@Host:@Id:Schema:sentrymetaLast_errno:0Killed:0Query_time:0.315758Lock_
Python进阶者 Python进阶者
11个月前
Excel中这日期老是出来00:00:00,怎么用Pandas把这个去除
大家好,我是皮皮。一、前言前几天在Python白银交流群【上海新年人】问了一个Pandas数据筛选的问题。问题如下:这日期老是出来00:00:00,怎么把这个去除。二、实现过程后来【论草莓如何成为冻干莓】给了一个思路和代码如下:pd.toexcel之前把这