voliate怎么保证可见性

Posted

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了voliate怎么保证可见性相关的知识,希望对你有一定的参考价值。

参考技术A 上次我们学习了volatile是如何解决多线程环境下共享变量的内存可见性问题,并且简单介绍了基于多核CPU并发缓存架构模型的Java内存模型。
详情见文章:

volatile很难?由浅入深怼到CPU汇编,彻底搞清楚它的底层原理

在并发编程中,有三个重要的特性:

内存可见性
原子性
有序性
volatile解决了并发编程中的可见性和有序性,解决不了原子性的问题,原子性的问题需要依赖synchronized关键字来解决。

关于并发编程三大特性的详细介绍,大家可以点击下方卡片搜索查看:

搜更多精彩内容

并发编程三大特性

内存可见性在上一篇文章中已经验证,本文我们继续通过代码学习如下内容:1、volatile为什么解决不了原子性问题?2、缓存行、缓存行填充3、CPU优化导致的乱序执行4、经典面试题:DCL必须要有volatile关键字吗?5、valatile关键字是如何禁止指令重排序的?

以上每一步都会有一段代码来验证,话不多说,开始输出干货!

1、volatile为什么解决不了原子性问题?

如果你对volatile了解的还可以,那么咱们继续往下看,如果不是太熟悉,请先行阅读上一篇文章。

老规矩,先来一段代码:

volatile原子性问题代码验证

这段代码的输出结果是多少?10000?大于10000?小于10000?

程序执行10次输出的结果如下:

join:10000
join:10000
join:10000
join:9819
join:10000
join:10000
join:10000
join:9898
join:10000
join:10000
会有小几率的出现小于10000的情况,因此volatile是无法保证原子性的,那么到底在什么地方出问题了呢?
还是用上篇文章的图来说明一下程序的整体流程:

当线程1从内存读取num的值到工作内存,同时线程2也从内存读取num的值到工作内存了,他俩各自操作自己的num++操作,但是关键点来了:

当线程1、2都执行完num++,线程1执行第五步store操作,通过总线将新的num值写回内存,刷新了内存中的num值,同时触发了总线嗅探机制,告知线程2其工作内存中的num不可用,因此线程2的num++得到的值被抛弃了,但是线程2的num++操作却是执行了。

2、CPU缓存行

写上篇文章的时候,有朋友问到了CPU的三级缓存以及缓存行相关的问题,然后我就找了一些资料学习,形成了下面的一张图:

CPU缓存行

CPU和主内存RAM之间会有三级缓存,因为CPU的速度要比内存的速度要快的多,大概是100:1,也就是CPU的速度比内存要快100倍,因此有了CPU三级缓存,那为什么是三级缓存呢?不是四级、五级呢?四个大字送给你:工业实践!相关概念:

ALU:CPU计算单元,加减乘除都在这里算
PC:寄存器,ALU从寄存器读取一次数据为一个周期,需要时间小于1ns
L1:1级缓存,当ALU从寄存器拿不到数据的时候,会从L1缓存去拿,耗时约1ns
L2:2级缓存,当L1缓存里没有数据的时候,会从L2缓存去拿,耗时约3ns
L3:3级缓存,一颗CPU里的双核共用,L2没有,则去L3去拿,耗时约15ns
RAM内存:当缓存都没有数据的时候,会从内存读取数据
缓存行:CPU从内存读取数据到缓存行的时候,是一行一行的缓存,每行是64字节(现代处理器)
问题来了:

1、缓存行存在的意义?好处是什么?

空间的考虑:一个地址被访问,相连的地址很大可能也被访问;

时间的考虑:最近访问的会被频繁访问好处:比如相连的地址,典型的就是数组,连续内存访问,很快!

2、缓存行会带来什么问题?

缓存行会导致缓存失效的问题,从而导致程序运行效率低下。例如下图:

当x,y两个变量在一个缓存行的时候:

1、线程1执行x++操作,将x和y所在的缓存行缓存到cpu core1里面去,

2、线程2执行y++操作,也将x和y所在的缓存行缓存到cpu core2里面去,

3、线程1执行了x++操作,写入到内存,同时为了保证cpu的缓存一致性协议,需要使其他内核x,y所在的缓存行失效,意味着线程2去执行y++操作的时候,无法从自己的cpu缓存拿到数据,必须从内存获取。

这就是缓存行失效!

一段代码来验证缓存行失效的问题:

缓存行失效例程

耗时:2079ms
这个时候我们做一个程序的改动,在x变量的前面和后面各加上7个long类型变量,如下:

再次运行,看耗时输出:

耗时:671ms
大约三倍的速度差距!

关于缓存行的更多概念,大家也可以点击下方卡片直接搜索更多信息:

搜更多精彩内容

缓存行

3、CPU优化导致的乱序执行

上文在说缓存行的时候,主要是因为CPU的速度大约是内存的速度的100倍,因此CPU在执行指令的时候,为了不等待内存数据的读取,会存在CPU指令优化而导致乱序执行的情况。

看下面这段代码:

cpu乱序执行例程

执行后输出(我执行了900多万次才遇到x=0,y=0的情况,可以试试你的运气哦~):

因为CPU的速度比内存要快100倍,所以当有两行不相关的代码在执行的时候,CPU为了优化执行速度,是会乱序执行的,所以上面的程序会输出:x=0,y=0的情况,也就是两个线程的执行顺序变成了:

x = b;
y = a;
a = 1;
b = 1;
这个时候我们就需要加volatile关键字了,来禁止CPU的指令重排序!

4、DCL单例模式需要加volatile吗?

一道经典的面试题:DCL单例模式需要加volatile字段吗?先来看DCL单例模式的一段代码:

DCL单例模式

DCL全称叫做Double Check Lock,就是双重检查锁来保证一个对象是单例的。

核心的问题就是这个INSTANCE变量是否需要加volatile关键字修饰?答案肯定是需要的。

首先我们来看new一个对象的字节码指令:

查看其字节码指令:

NEW java/lang/Object
DUP
INVOKESPECIAL java/lang/Object.<init> ()V
ASTORE 1
即:

1、创建并默认初始化Object对象;

2、复制操作数栈对该对象的引用;

3、调用Object对象的初始化方法;

4、将变量Object o指向创建的这个对象,此时变量o不再为null;

根据上文描述我们知道因为CPU和内存速度不匹配的问题,CPU在执行命令的时候是乱序执行的,即CPU在执行第3步初始化方法时候如果需要很长的时间,CPU是不会等待第3步执行完了才去执行第4步,所以执行顺序可能是1、2、4、3。

那么继续看DCL单例程序,当线程1执行new DCLStudy()的顺序是先astore再invokespecial,但是invokespecial方法还没有执行的时候,线程2进来了,这个时候线程2拿到的就是一个半初始化的对象。

因此,DCL单例模式需要加volatile关键字,来禁止上述new对象的过程的指令重排序!

valatile关键字是如何禁止指令重排序的

JVM规范中规定:凡是被volatile修饰的变量,在进行其操作时候,需要加内存屏障!

JVM规范中定义的JSR内存屏障定义:

LoadLoad屏障:
对于语句Load1;LoadLoad;Load2;Load1和Load2语句不允许重排序。
StoreStore屏障:
对于语句Store1;StoreStore;Store2;Store1和Store2语句不允许重排序。
LoadStore屏障:
对于语句Load1;StoreStore;Store2;Load1和Store2语句不允许重排序。
StoreLoad屏障:
对于语句Store1;StoreStore;Load2;Store1和Load2语句不允许重排序。
JVM层面volatile的实现要求:

如果对一个volatile修饰的变量进行写操作:

前面加StoreStoreBarrier屏障,保证前面所有的store操作都执行完了才能对当前volatile修饰的变量进行写操作;

后面要加StoreLoadBarrier,保证后面所有的Load操作必须等volatile修饰的变量写操作完成。

如果对一个volatile修饰的变量进行读操作:

后面的读操作LoadLoadBarrier必须等当前volatile修饰变量读操作完成才能读;

后面的写操作LoadStoreBarrier必须等当前的volatile修饰变量读操作完成才能写。

上篇文章我们通过一定的方式看到了程序执行的volatile修饰的变量底层汇编码:

0x000000010d3f3203: lock addl $0x0,(%rsp) ;*putstatic flag
; - com.java.study.VolatileStudy::lambda$main$1@9 (line 31)
也就是到CPU的底层执行的命令其实就是这个lock,这个lock指令既完成了变量的可见性还保证了禁止指令充排序:

LOCK用于在多处理器中执行指令时对共享内存的独占使用。它的作用是能够将当前处理器对应缓存的内容刷新到内存,并使其他处理器对应的缓存失效;另外还提供了有序的指令无法越过这个内存屏障的作用。
end

至此,对volatile的学习就到这里了,通过两篇文章来对volatile这个关键字有了一个系统的学习。

学无止境,对volatile的学习还只是一个基础学习,还有更多的知识等待我们去探索学习,例如:

什么是as-if-serial?什么是happens-before?
Java的哪些指令可以重排序呢?重排序的规则是什么?
我们下期再见!

1372阅读
搜索
java自学一般要学多久
volatile底层原理
汇编111条指令详解
java必背100源代码
volatile架构图
嵌入式volatile详解

Java 并发编程:如何保证共享变量的可见性?

上一篇,我们谈了谈如何通过同步来保证共享变量的原子性(一个操作或者多个操作要么全部执行并且执行的过程不会被任何因素打断,要么就都不执行),本篇我们来谈一谈如何保证共享变量的可见性(多个线程访问同一个变量时,一个线程修改了这个变量的值,其他线程能够立即看得到修改的值)。

我们使用同步的目的不仅是,不希望某个线程在使用对象状态时,另外一个线程在修改状态,这样容易造成混乱;我们还希望某个线程修改了对象状态后,其他线程能够看到修改后的状态——这就涉及到了一个新的名词:内存(可省略)可见性。

要了解可见性,我们得先来了解一下 Java 内存模型。

Java 内存模型(Java Memory Model,简称 JMM)描述了 Java 程序中各种变量(线程之间的共享变量)的访问规则,以及在 JVM 中将变量存储到内存→从内存中读取变量的底层细节。

要知道,所有的变量都是存储在主内存中的,每个线程会有自己独立的工作内存,里面保存了该线程使用到的变量副本(主内存中变量的一个拷贝)。见下图。

技术图片

也就是说,线程 1 对共享变量 chenmo 的修改要想被线程 2 及时看到,必须要经过 2 个步骤:

1、把工作内存 1 中更新过的共享变量刷新到主内存中。
2、将主内存中最新的共享变量的值更新到工作内存 2 中。

那假如共享变量没有及时被其他线程看到的话,会发生什么问题呢?

public class Wanger {
    private static boolean chenmo = false;

    public static void main(String[] args) {
        Thread thread = new Thread(new Runnable() {
            @Override
            public void run() {
                while (!chenmo) {
                }
            }
        });
        thread.start();
        try {
            Thread.sleep(500);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
        chenmo = true;

    }

}

这段代码的本意是:在主线程中创建子线程,然后启动它,当主线程休眠 500 毫秒后,把共享变量 chenmo 的值修改为 true 的时候,子线程中的 while 循环停下来。但运行这段代码后,程序似乎进入了死循环,过了 N 个 500 毫秒,也没有要停下来的意思。

为什么会这样呢?

因为主线程对共享变量 chenmo 的修改没有及时通知到子线程(子线程在运行的时候,会将 chenmo 变量的值拷贝一份放在自己的工作内存当中),当主线程更改了 chenmo 变量的值之后,但是还没来得及写入到主存当中,那么子线程此时就不知道主线程对 chenmo 变量的更改,因此还会一直循环下去。

换句话说,就是:普通的共享变量不能保证可见性,因为普通共享变量被修改之后,什么时候被写入主内存是不确定的,当其他线程去读取时,此时内存中可能还是原来的旧值,因此无法保证可见性。

那怎么解决这个问题呢?

使用 volatile 关键字修饰共享变量 chenmo。

因为 volatile 变量被线程访问时,会强迫线程从主内存中重读变量的值,而当变量被线程修改时,又会强迫线程将最近的值刷新到主内存当中。这样的话,线程在任何时候总能看到变量的最新值。

我们来使用 volatile 修饰一下共享变量 chenmo。

private static volatile boolean chenmo = false;

再次运行代码后,程序在一瞬间就结束了,500 毫秒毕竟很短啊。在主线程(main 方法)将 chenmo 修改为 true 后,chenmo 变量的值立即写入到了主内存当中;同时,导致子线程的工作内存中缓存变量 chenmo 的副本失效了;当子线程读取 chenmo 变量时,发现自己的缓存副本无效了,就会去主内存读取最新的值(由 false 变为 true 了),于是 while 循环也就停止了。

也就是说,在某种场景下,我们可以使用 volatile 关键字来安全地共享变量。这种场景之一就是:状态真正独立于程序内地其他内容,比如一个布尔状态标志(从 false 到 true,也可以再转换到 false),用于指示发生了一个重要的一次性事件

至于 volatile 的原理和实现机制,本篇不再深入展开了(小编自己没搞懂,尴尬而不失礼貌的笑一笑)。

需要再次强调地是:

volatile 变量可以被看作是一种 “程度较轻的 synchronized”;与 synchronized 相比,volatile 变量运行时地开销比较少,但是它所能实现的功能也仅是 synchronized 的一部分(只能确保可见性,不能确保原子性)。

原子性我们上一篇已经讨论过了,增量操作(i++)看上去像一个单独操作,但实际上它是一个由“读取-修改-写入”组成的序列操作,因此 volatile 并不能为其提供必须的原子特性。

除了 volatile 和 synchronized,Lock 也能够保证可见性,它能保证同一时刻只有一个线程获取锁然后执行同步代码,并且在释放锁之前会将对变量的修改刷新到主存当中。关于 Lock 的更多细节,我们后面再进行讨论。

好了,共享变量的可见性就先介绍到这。希望本篇文章能够对大家有所帮助,谢谢大家的阅读。

上一篇:如何保证共享变量的原子性?

下一篇:如何保证对象的线程安全性

微信搜索「*沉默王×××免费视频**」获取 500G 高质量教学视频(已分门别类)。

以上是关于voliate怎么保证可见性的主要内容,如果未能解决你的问题,请参考以下文章

高并发编程-06-可见性-volatile

Juc12_Volatile的可见性不保证可见性有序性使用内存屏障四大指令StoreStoreStoreLoad LoadLoadLoadStore

Java并发 chapter3 共享对象

java并发之CAS详解

volatile为啥不能保证原子性

isVisible() 是不是保证了 JAVA 中 UI 对象的可见性