volatile 关键字详解

「这是我参与2022首次更文挑战的第21天,活动详情查看:2022首次更文挑战」。

本文主要是讲解 volatile 关键字的使用,首概括它的三大特征,然后引入 JMM 模型,结尾我们解释了单例模式(懒汉模式)中为什么要用 volatile。

volatile 关键字特征

volatile 是 JVM 虚拟机提供的轻量级的同步机制

  • 保证可见性
  • 不保证原子性
  • 禁止指令重排序

JMM 内存模型概述

JMM 的概述如下

线程安全模型

JMM (Java 内存模型 Java Memory Model, 简称 JMM) 本身是一种抽象的概念并不真实存在,它是一组规则或者规范,通过这组规范定义了程序中各个变量(包括实例字段,静态字段和构成数组对象的元素)的访问方式。

JMM 关于同步的规定:

  • 线程解锁前,必须把共享变量的值刷会主内存
  • 线程加锁前,必须读取主内存的新的值到自己的工作内存
  • 加锁解锁是同一把锁

由于 JVM 运行程序的实现是线程,而每个线程创建时 JVM 都会为其创建一个工作内存(有些地方称为栈空间)工作内存是每个线程的私有的数据区域,而 Java 内存模型中规定的所有都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但是线程对变量的操作(读取和赋值)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写回主内存,不能直接操作主内存的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程无法访问对方的工作内存,线程间的通讯(传值)必须通过主内存来完成,其下是简单的访问过程

JMM 模型中变量修改示意图(以多核心 CPU 操作 initFlag 变量为例子)

JMM 的三大特性

1、可见性

通过前面的 JMM 介绍中,我们知道

各个线程中对主内存中共享变量的操作都是各个线程格子拷贝到自己的工作内存区域中进行的操作后,然后回写到主内存中的。

这就是可能存在一个线程 AAA 修改了共享变量的 X 的值但是还未协会主内存时, 另外一个线程 BBB 又对主内存中的同一个变量 X进行操作,但此时 A新城工作内存中共享变量 X 对线程 B 来说并不可见。

这种工作内存与主内存同步延迟现象就造成了可见性问题。

class MyData {
    int number = 0;

    public void addTo60() {
        this.number = 60;
    }
}

public class VolatileDemo {

    public static void main(String[] args) {
        MyData myData = new MyData(); // 资源类

        new Thread(() -> {
            System.out.println(Thread.currentThread().getName() + "\t 进入");
            try { TimeUnit.SECONDS.sleep(3); } catch (InterruptedException e) { e.printStackTrace(); }
            myData.addTo60();
            System.out.println(Thread.currentThread().getName() + "\t 更新");
        }, "AAA");

        // 第二个线程就是我们的 main 线程
        while (myData.number == 0) {
            // main 先蹭你就一直在这里等待循环,一直到 number 值不等于零
        }
        System.out.println(Thread.currentThread().getName() + "\t 修改成 60 成功");

    }
}
复制代码

2、原子性

原子性指的是什么意思?

不可分割的,完整的,也即某个线程则横在做某个具体业务时,中间不可以被加塞或者分割。需要整体成功,或者同时失败。

MyData myData = new MyData();
for (int i = 0; i < 20; i++) {
    new Thread(() -> {
        for (int j = 0; j < 1000; j++) {
            myData.addPlusPlus(); // 方法内执行 this.number++;
        }
    }, "T" + i).start();
}
while (Thread.activeCount() > 2) {
    Thread.yield();
}
System.out.println(myData.number);
复制代码

this.number++;是非线程安全的的操作所以结果不一定是 20000, 如果要保证原子性可以增加 synchronized关键字进行同步操作。或者使用 JUC 提供的 AtomicInteger线程安全类。

为什么数值少于 20000 ,我们通过 javap -c命令来看看

为了方便查看字节码。我修改了一下 ++ 程序

public class OnePlus {
    volatile int number = 0;

    public void addPlusPlus() {
        this.number++;
    }
}
复制代码

字节码指令分析和查看


如何解决原子性问题?不用 sync

public class OnePlus {
    AtomicInteger number = new AtomicInteger(0);

    public void addPlusPlus() {
        number.getAndIncrement();
    }
}
复制代码

3、有序性和指令重排序

在计算机执行程序时,为了提高性能,编译器和处理器的常常会对指令做重排, 一般分为三种情况。

源代码 ==> 编译器优化的重排 ==>指令并行的重排 ==> 内存系统的重排 ==> 最终执行的指令。

但线程的环境里面取保程序最终执行结果和代码顺序执行的结果一致。

处理器在进行重排序时必须要考虑之前的数据依赖性

多线程环境中线程交替执行,由于编译器优化重排的存在,两个线程中使用的变量能否保证一致性是否无法确定的,结果无法预测。

重排序1

public class MySort {

    int x = 11;  //语句 1
    int y = 12;  //语句 2
    x = x + 5;   //语句 3
    y = x * x;   //语句 4

}
复制代码

可能的执行顺序:

1234

2134

1324

问题:请问第4可以重新排序后称为第一条吗?不能因为数据依赖性

重排序2

int a, b, y = 0;

线程1 线程2
x = a y = b
b = 1 a = 2
x =0 y = 0

如果编译器对这段代码执行重排优化后,可能出现一下情况

线程1 线程2
b = 1 a = 2
x = a y = b;
x = 2 y =1

指令重排3

public class MyReSortSeqDemo {

    int a = 0;
    boolean flag = false;

    public void method1() {
        a = 1;          //语句1
        flag = true;    //语句2
    }

    public void method2() {
        if (flag) {
            a = a + 5;   //语句3
            System.out.println("*** retValue " + a);
        }
    }
}
复制代码

禁止指令重排总结

volatile 实现了禁止指令重排优化,从而避免多线程环境下程序出现乱序执行的现象。

先了解一个概念,内存屏障(Memory Barrier) 又称为内存栅栏,是一个CPU 指令,它有两个作用:

1、保证特定操作的执行顺序。

2、保证某些变量的内存可见性(利用该特征实现 volatile 的内存可见性)

由于编译器和处理器都能执行指令重排优化。如果在指令间插入一条 Memmory Barrier 则会告诉编译器和 CPU ,不管什么指令都不能和这条 Memory Barrier 指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排优化。内存屏障另外一个作用是强制刷出各种 CPU 的缓存数据,因此任何 CPU 上的吓成都能读取到这些数据是最新版本

线程安全性获得保障(线程安全访问)

工作内存与主内存同步延迟现象导致的可见性问题

可以使用 synchronized 或者 volatile 关键在解决,他满都可以使一个线程修改后的变量立即对其他的线程可见。

对于指令重排序导致的可见性问题和有序性问题

可以利用 volatile 关键字解决,因为 volatile 的另外一个作用就是禁止重排序优化。

volatile 运用场景

你在那些地方见过 volatile

单例模式 DCL

volatile 关键字的单例 DCL

public class SingletonDemo {

    private static SingletonDemo singletonDemo;


    private SingletonDemo() {
        System.out.println(Thread.currentThread().getName() + "\t  invoke SingletonDemo()");
    }

    public static SingletonDemo getSingleton() {
        if (singletonDemo == null) {
            // 同步代码块加锁
            synchronized (SingletonDemo.class) {
                if (singletonDemo == null) {
                    singletonDemo = new SingletonDemo();
                }
            }
        }
        return singletonDemo;
    }
}
复制代码

单例模式 volatile 分析

volatile 关键字的单例 DCL

public class SingletonDemo {

    private static volatile SingletonDemo singletonDemo;


    private SingletonDemo() {
        System.out.println(Thread.currentThread().getName() + "\t  invoke SingletonDemo()");
    }

    public static SingletonDemo getSingleton() {
        if (singletonDemo == null) {
            // 同步代码块加锁
            synchronized (SingletonDemo.class) {
                if (singletonDemo == null) {
                    singletonDemo = new SingletonDemo();
                }
            }
        }
        return singletonDemo;
    }
}
复制代码

单例模式总结

DCL 双端检锁, 机制下不一定是线程安全的,原因是有指令重排的存在,加入

volatile 可以禁止指令重排

原因在于某一个线程执行到第一次检测,读取到了 instance 不为 null 时,instance 的引用对象 可能没有完全完成初始化。

模拟代码:

instance = new SingletonDemo() 
    
    
memory = allocate() //1. 分配内存空间
instance(memory)    //2. 初始化对象
instance = memory   //3.设置 instance 执行刚才分配的内存地址,此时 instance!= null
    
复制代码

步骤 2 和步骤 3 不存在数据依赖关系,而且无论重排前还是重排后的执行结果在但线程中并没有发生改变,因此这种重排优化是优化是允许的

instance = new SingletonDemo() 
    
    
memory = allocate() //1. 分配内存空间
instance = memory   //3.设置 instance 执行刚才分配的内存地址,此时 instance!= null 但是对象还没有被初始化完成!
instance(memory)    //2. 初始化对象

    
复制代码

但是指令重排只会保证串行语句的执行的一致性(单线程),但是并不会关心多线程间的语义一致性。

所以当一条线程访问 instnce 不为 null 时,由于 instance 实例尾部已经初始化完成,也就造成了线程安全问题。

Guess you like

Origin juejin.im/post/7061638256886022151