IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> Java知识库 -> 谈谈对volatile关键字理解 -> 正文阅读

[Java知识库]谈谈对volatile关键字理解

谈谈对volatile关键字理解


前言

就volatile关键字而言,无论工作还是面试都是避免老生常谈的问题,但是研究volatile之前,首先需要了解JMM。文章会从JMM和volatile的之间的联系进行阐述。


1:JMM模型

1.1 JMM是什么

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

1.2JMM关于同步的规定

??所有的共享变量都存储于主内存,这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在竞争问题。每一个线程还存在自己的工作内存,线程的工作内存,保留了被线程使用的变量的工作副本。
??由于JVM运行程序的实体是线程,而每个线程创建时JVM都会为其创建一个工作内存(有些地方称为栈空间),工作内存是每个线程的私有数据区域,而Java内存模型中规定所有变量都存储在主内存,主内存是共享内存区域,所有线程都可以访问,但线程对变量的操作(读取赋值等)必须在工作内存中进行,首先要将变量从主内存拷贝到自己的工作内存空间,然后对变量进行操作,操作完成后再将变量写会主内存,不能直接操作主内存中的变量,各个线程中的工作内存中存储着主内存中的变量副本拷贝,因此不同的线程间无法访问对方的工作内存,线程间的通信(传值)必须通过主内存来完成,其简要访问过程如下图:
在这里插入图片描述
上面提到了两个概念:主内存 和 工作内存
?● 主内存:就是计算机的内存,也就是经常提到的8G内存,16G内存
?●工作内存:但我们实例化 person对象,那么 age = 18 也是存储在主内存中
假如主内存中实例化的实例化一个person对象,设置 age 是18,而A线程在自己的工作内存中把age修改为25,其他就是通过JMM来感知到线程A修改了age的值。

1.3 Java内存模型保证缓存一致性

在这里插入图片描述
正上图所示,经过简化CPU与内存操作的简易图,实际上没有这么简单,为了理解方便,我们将三级缓存统一为CPU缓存(有些CPU只有二级缓存,有些CPU有三级缓存)。就目前计算机而言,一般拥有多个CPU并且每个CPU可能存在多个核心,多核是指在一枚处理器(CPU)中集成两个或多个完整的计算引擎(内核),这样就可以支持多任务并行执行,从多线程的调度来说,每个线程都会映射到各个CPU核心中并行运行。在CPU内部有一组CPU寄存器,寄存器是cpu直接访问和处理的数据,是一个临时放数据的空间。一般CPU都会从内存取数据到寄存器,然后进行处理,但由于内存的处理速度远远低于CPU,导致CPU在处理指令时往往花费很多时间在等待内存做准备工作,于是在寄存器和主内存间添加了CPU缓存,CPU缓存比较小,但访问速度比主内存快得多,如果CPU总是操作主内存中的同一址地的数据,很容易影响CPU执行速度,此时CPU缓存就可以把从内存提取的数据暂时保存起来,如果寄存器要取内存中同一位置的数据,直接从缓存中提取,无需直接从主内存取。需要注意的是,寄存器并不每次数据都可以从缓存中取得数据,万一不是同一个内存地址中的数据,那寄存器还必须直接绕过缓存从内存中取数据。所以并不每次都得到缓存中取数据,这种现象有个专业的名称叫做缓存的命中率,从缓存中取就命中,不从缓存中取从内存中取,就没命中,可见缓存命中率的高低也会影响CPU执行性能,这就是CPU、缓存以及主内存间的简要交互过程,总而言之当一个CPU需要访问主存时,会先读取一部分主存数据到CPU缓存(当然如果CPU缓存中存在需要的数据就会直接从缓存获取),进而在读取CPU缓存到寄存器,当CPU需要写数据到主存时,同样会先刷新寄存器中的数据到CPU缓存,然后再把数据刷新到主内存中。

2:Volatile的特性

volatile是Java虚拟机提供的轻量级的同步机制,在日常的单线程环境是应用不到的,经常用于多线程环境,有三大特性

?● 保证可见性
?● 不保证原子性
?● 禁止指令重排

2.1 保证可见性

2.1.1可见性定义(必须是原子类操作)

指当多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值。

2.1.2 代码示例
public class VisibilityDemo {

    public static void main(String args []) {

        // 资源类
        TestData testData = new TestData();
        // AAA线程 实现了Runnable接口的,lambda表达式
        new Thread(() -> {
            System.out.println("线程" + Thread.currentThread().getName() + "\t 执行");
            // 线程睡眠3秒,假设在进行运算
            try {
                TimeUnit.SECONDS.sleep(3);
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
            // 修改number的值
            testData.addNum();
            // 输出修改后的值
            System.out.println("线程:" + Thread.currentThread().getName() + "修改的num的值:" + testData.num);

        }, "AAA").start();

        new Thread(()->{
            while (testData.num == 0){}
            // 按道理这个值是不可能打印出来的,因为线程BBB运行的时候,number的值为0,所以一直在循环
            // 如果能输出这句话,说明AAA线程在睡眠3秒后,更新的number的值,重新写入到主内存,并被BBB线程感知到了
            System.out.println("线程:" + Thread.currentThread().getName() + "执行完毕!");
        },"BBB").start();

    }
}

class TestData{
	//volatile修饰的变量,是具备JVM轻量级同步机制的,能够感知其它线程的修改后的值
    //volatile int num = 0;
    //不用volatile修饰的变量,代码中的线程BBB是无法感知num的值被修改的
    int num = 0;
    public void addNum(){
        this.num = 88;
    }
} 
2.1.2 volatile通过JMM模型保证可见性

在多线程环境下,一个线程对共享变量的操作对其他线程是不可见的。volatile保证不同线程对共享变量操作的可见性,也就是说一个线程修改了volatile修饰的变量,当修改写回主内存时(遵循JMM模型),另外一个线程立即看到最新的值。 当然,synchronize和Lock都可以保证可见性。这里不对synchronize和Lock进行阐述。

2.1.3 volatile要谨慎使用

?●缓存一致性
通过1.3 Java内存模型如何保证内存一致性可知,CPU为了更高效的读取数据,他们一般不会存内存中读取数据,而是先从缓存中读取数据,为什么这里主线程中某个值被更改后,其它线程能马上知晓呢? 其实这里是用到了总线嗅探技术,在说嗅探技术之前,首先谈谈缓存一致性的问题,就是当多个处理器运算任务都涉及到同一块主内存区域的时候,将可能导致各自的缓存数据不一。为了解决缓存一致性的问题,需要各个处理器访问缓存时都遵循一些协议,在读写时要根据协议进行操作,这类协议主要有MSI、MESI等等。
? ● MESI
当CPU写数据时,如果发现操作的变量是共享变量,即在其它CPU中也存在该变量的副本,会发出信号通知其它CPU将该内存变量的缓存行设置为无效,因此当其它CPU读取这个变量的时,发现自己缓存该变量的缓存行是无效的,那么它就会从内存中重新读取。
? ● 总线风暴
总线嗅探技术有哪些缺点?
由于Volatile的MESI缓存一致性协议,需要不断的从主内存嗅探和CAS循环,无效的交互会导致总线带宽达到峰值。因此volatile关键字要谨慎使用

2.2 不保证原子性

2.2.1 原子性定义

即一个操作或者多个操作 要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行。即:在整个操作过程中不会被线程调度器中断的操作,都可认为是原子性。例如 a=1是原子性操作,但是i++ 之类操作就不是原子性操作** (下文中将从JVM字节码的角度解释i++为什么不是原子性)。Java中的原子性操作包括:**
● 除long和double之外的基本类型的赋值操作,且赋值必须是值赋给变量,变量之间的相互赋值不是原子性操作
● 所有引用reference的赋值操作
● java.concurrent.Atomic.* 包中所有类的一切操作

2.2.2 代码示例
public class NotAtomicDemo {
    public static void main(String args []) {

        DataTest dataTest = new DataTest();
        // 创建50个线程,线程里面进行10000次循环
        for (int i = 0; i < 50; i++) {
            new Thread(() -> {
                // 里面
                for (int j = 0; j < 10000; j++) {
                    dataTest.addPlusPlus();
                }
            }, String.valueOf(i)).start();
        }
        // 需要等待上面20个线程都计算完成后,在用main线程取得最终的结果值
        // 这里判断线程数是否大于2,因为默认是有两个线程的,一个main线程,一个gc线程
        while(Thread.activeCount() > 2) {
            // yield表示不执行
            Thread.yield();
        }
        // 假设volatile保证原子性,那么输出的值应该为:  50 * 10000 = 500000
        System.out.println(Thread.currentThread().getName() + "\t 最终执行结果: " + dataTest.number);
    }
}

class DataTest {
    volatile int number = 0;
    //注意,此时number 前面是加了volatile修饰
    public void addPlusPlus() {
        number ++;
    }
}

第一次运行结果:
在这里插入图片描述
第二次运行结果:
在这里插入图片描述

第三次运行结果:
在这里插入图片描述
以上三次结果均出现了丢数据的情况。

2.2.3 JVM字节码层面分析无法保证原子性

将以下代码中的number ++操作,转换为JVM字节码为例:

public class testOne {
    public class T1 {
        volatile int number = 0;
        public void add() {
            number++;
        }
    }
}

转换为JVM字节码后,

  public void add1();
    Code:
       0: aload_0
       1: dup
       2: getfield      #2                  // Field n:I
       5: iconst_1
       6: iadd
       7: putfield      #2                  // Field n:I
      10: return

getfield指令:从主内存中获取值原始n的值,放到自己的工作内存中
iconst_1指令:执行加1操作
putfield指令:把累加后的n写会主内存
假设我们没有加任何的限制,在多线程环境下,代码中50个线程和可能会同时通过getfield指令,拿到主存中的 number的值,各自在自己的工作内存中进行加1操作,但他们并发进行 iadd 命令的时候,因为只能一个进行写,所以其它操作会被挂起,假设1线程,先进行了写操作,在写完后,volatile可以保证可见性,应该需要告诉其它两个线程,主内存的值已经被修改了,但是线程1执行写操作的同时,其它线程也会执行 iadd指令,也进行写入操作,这就造成了其他线程没有接受到主内存number的改变,从而覆盖了原来的值,出现写丢失,这样也就让最终的结果少于500000。

2.2.4 解决

方法一: 在addPlusPlus()方法上加上synchronized ,为什么加上synchronized就可以保证,这里就不做解释。但这种解决方式并不是很好,因为synchronized是重量级同步机制,解决这个问题用synchronized是不优雅的。
代码

class DataTest {
     int number = 0;
    public synchronized void addPlusPlus() {
        number ++;
    }
}

● **方法二 :**还可以使用JUC下面的原子包装类
代码

class DataTest {
     volatile int number = 0;
    //注意,此时number 前面是加了volatile修饰
    public void addPlusPlus() {
        number ++;
    }

    //创建一个原子Integer包装类,默认为0
    AtomicInteger atomicInteger = new AtomicInteger();
    public void addAtomic() {
        // 相当于 atomicInter ++
        atomicInteger.getAndIncrement();
    }
}

运行结果如下图所示:
在这里插入图片描述

2.3 禁止指令重排

2.3.1 指令重排相关说明

指令重排: 简单的说指令重排是指在程序执行过程中, 为了性能考虑, 编译器和CPU可能会对指令重新排序。其目的就是为了提高多核CPU的效率,一般有一下三种:

? ● 编译器优化的重排: 编译器在不改变单线程程序语义的前提下,可以重新排列指令的执行顺序;
? ● 指令并行的重排: 在多核CPU时代,多条指令会同时执行,如果这些指令不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
? ● 内存系统的重排: 由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的。
源代码形成指令的过程:

2.3.2 指令重排举例说明

例一

public void testDataOne() {
    int x = 11; //语句1
    int y = 12; //语句2
    x = x + 5;  //语句3
    y = x * x;  //语句4
}

在单线程情况下,语句执行的顺序是1 2 3 4,
在多线程环境下,会出现指令重排,语句执行的顺序可能是 2 1 3 4,或者是1 3 2 4,可能还是1 2 3 4,但是不会出现 4 3 2 1的情况,因为在没有数据依赖性的情况下才可以进行指令重排(2.3.1中提到),即:语句4依赖于语句1中x的声明和语句2中的y的声明。

例二

public void testDataTwo() {
	int a = 0;
	int b = 0;
	int x = 0;
	int y = 0;
}

按正常没有进行重排的指令执行线程A和线程B,

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

此时xy的结果都是 0;如果进行了一下指令的重排

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

那么这时就会出现x = 2; y = 1的结果,为了避免这种结果,可以将以上的变量加volatile修饰。

2.3.2 volatile可以禁止指令重排的原因

Volatile实现禁止指令重排优化,从而避免了多线程环境下程序出现乱序执行的现象, 首先了解一个概念,内存屏障(Memory Barrier)又称内存栅栏,是一个CPU指令,它的作用有两个:
? ● 保证特定操作的顺序
? ● 保证某些变量的内存可见性(利用该特性实现volatile的内存可见性)
??由于编译器和处理器都能执行指令重排的优化,如果在指令间插入一条Memory Barrier则会告诉编译器和CPU,不管什么指令都不能和这条Memory Barrier指令重排序,也就是说通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化。 内存屏障另外一个作用是刷新出各种CPU的缓存数,因此任何CPU上的线程都能读取到这些数据的最新版本。
volatile写操作: 会在写前面和后面分别插入store屏障指令,具体如图所示:
在这里插入图片描述

**volatile读操作:**会在读操作后面插入Load屏障指令,具体如图所示:
在这里插入图片描述
也就是说:volatile可以禁止指令重排是过内存屏障实现的。

3:Volatile的应用

3.1 单例模式DCL

废话不多说,直接上代码

public class SingletonDemo {

    private static volatile SingletonDemo instance=null;
    private SingletonDemo(){
        System.out.println(Thread.currentThread().getName()+"\t 构造方法");
    }
    /**
     * 双重检测机制
     * @return
     */
    public static SingletonDemo getInstance(){
        if(instance==null){
         // 双重检查加锁多线程情况下会出现某个线程虽然这里已经为空,但是另外一个线程已经执行到d处
            synchronized (SingletonDemo.class){  //b
            //不加volitale关键字的话有可能会出现尚未完全初始化就获取到的情况。原因是内存模型允许无序写入
                if(instance==null){
                // d 此时才开始初始化
                    instance=new SingletonDemo();
                }
            }
        }
        return instance;
    }
    public static void main(String[] args) {
        for (int i = 1; i <=10; i++) {
            new Thread(() ->{
                SingletonDemo.getInstance();
            },String.valueOf(i)).start();
        }
    }
}
3.2 volatile保证单例模式原因分析

volatile可以禁止指令重排,如果SingletonDemo不用volatile修饰,是有指令重排的存在的,DCL(双端检锁) 机制不一定线程安全,原因在于某一个线程在执行到第一次检测,读取到的instance不为null时,instance的引用对象可能没有完成初始化,看一下伪代码:

instance=new SingletonDem(); 可以分为以下步骤(伪代码)
memory=allocate();//1.分配对象内存空间
instance(memory);//2.初始化对象
instance=memory;//3.设置instance的指向刚分配的内存地址,此时instance!=null 

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

memory=allocate();//1.分配对象内存空间
instance=memory;//3.设置instance的指向刚分配的内存地址,此时instance!=null 但对象还没有初始化完。
instance(memory);//2.初始化对象

但是指令重排只会保证串行语义的执行一致性(单线程) 并不会关心多线程间的语义一致性所以当一条线程访问instance不为null时,由于instance实例未必完成初始化,也就造成了线程安全问题。

总结

volatile虽然说的差不多了,有错误的地方欢迎大家批评指正,但是文章中提到了synchronized和Java中的Atomic,关于他们的使用应该注意什么以及他们的实现是如何实现的,我会后续写出来。
你知道的越多,你知道的越多,我是一名小小的程序员,咱们下期见。

  Java知识库 最新文章
计算距离春节还有多长时间
系统开发系列 之WebService(spring框架+ma
springBoot+Cache(自定义有效时间配置)
SpringBoot整合mybatis实现增删改查、分页查
spring教程
SpringBoot+Vue实现美食交流网站的设计与实
虚拟机内存结构以及虚拟机中销毁和新建对象
SpringMVC---原理
小李同学: Java如何按多个字段分组
打印票据--java
上一篇文章           查看所有文章
加:2021-08-07 21:43:52  更:2021-08-07 21:44:20 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年5日历 -2024/5/10 23:17:34-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码