写点什么

快速掌握并发编程 --- 深入了解 volatile

用户头像
田维常
关注
发布于: 2020 年 11 月 02 日

关注Java 后端技术全栈”**


回复“000”获取大量电子书


前面文章我们学习了 synchronized,synchronized 是一个重量级的锁。


快速掌握并发编程---synchronized篇(上)


快速掌握并发编程---synchronized篇(下)


而今天聊得这个 volatile 是一个轻量级的 synchronized,它在多线程开发中保证了共享变量的“可见性”。


可见性的意思是当一个线程修改一个共享变量时,另外一个线程能读到这个修改的值。


如果一个变量使用 volatile ,则它比使用 synchronized 的成本更加低,因为它不会引起线程上下文的切换和调度


Volatile 的官方定义


Java 语言规范第三版中对 volatile 的定义如下:


Java 编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排他锁单独获得这个变量。Java 语言提供了 volatile,在某些情况下比锁更加方便。如果一个字段被声明成 volatile,Java 线程内存模型确保所有线程看到这个变量的值是一致的。


上下文切换


什么是上下文切换


即使是单核 CPU 也支持多线程执行代码,CPU 通过给每个线程分配 CPU 时间片来实现这个机制。时间片是 CPU 分配给各个线程的时间,因为时间片非常短,所以 CPU 通过不停地切换线程执行,让我们感觉多个线程是同时执行的,时间片一般是几十毫秒(ms)。


CPU 通过时间片分配算法来循环执行任务,当前任务执行一个时间片后会切换到下一个任务。但是,在切换前会保存上一个任务的状态,以便下次切换回这个任务时,可以再次加载这个任务的状态,从任务保存到再加载的过程就是一次上下文切换



这就像我们同时读两本书,当我们在读一本英文的技术书籍时,发现某个单词不认识, 于是便打开中英文词典,但是在放下英文书籍之前,大脑必须先记住这本书读到了多少页的第多少行,等查完单词之后,能够继续读这本书。这样的切换是会影响读 书效率的,同样上下文切换也会影响多线程的执行速度。


切出: 一个线程被剥夺处理器的使用权而被暂停运行

切入: 一个线程被系统选中占用处理器开始或继续运行



如何减少上下文切换


既然上下文切换会导致额外的开销,因此减少上下文切换次数便可以提高多线程程序的运行效率。减少上下文切换的方法有无锁并发编程、CAS 算法、使用最少线程和使用协程。


  • 无锁并发编程。多线程竞争时,会引起上下文切换,所以多线程处理数据时,可以用一些办法来避免使用锁,如将数据的 ID 按照 Hash 取模分段,不同的线程处理不同段的数据

  • CAS 算法。Java 的 Atomic 包使用 CAS 算法来更新数据,而不需要加锁

  • 使用最少线程。避免创建不需要的线程,比如任务很少,但是创建了很多线程来处理,这样会造成大量线程都处于等待状态

  • 协程。在单线程里实现多任务的调度,并在单线程里维持多个任务间的切换


Java 内存模型


Java 内存模型(Java Memory Model 简称 JMM)。是的,今天他来了。很多人害怕的家伙来了。JMM 定义了 Java 虚拟机(JVM)在计算机内存(RAM)中的工作方式。JVM 是整个计算机虚拟模型,所以 JMM 是隶属于 JVM 的。如果我们要想深入了解 Java 并发编程,就要先理解好 Java 内存模型。Java 内存模型定义了多线程之间共享变量的可见性以及如何在需要的时候对共享变量进行同步。原始的 Java 内存模型效率并不是很理想,因此 Java1.5 版本对其进行了重构,至今 JDK 十几了仍沿用了 Java1.5 的版本(有待考量)。


一、操作系统语义


计算机在运行程序时,每条指令都是在 CPU 中执行的,在执行过程中势必会涉及到数据的读写。我们知道程序运行的数据是存储在主存中,这时就会有一个问题,读写主存中的数据没有 CPU 中执行指令的速度快,如果任何的交互都需要与主存打交道则会大大影响效率,所以就有了 CPU 高速缓存。CPU 高速缓存为某个 CPU 独有,只与在该 CPU 运行的线程有关。


有了 CPU 高速缓存虽然解决了效率问题,但是它会带来一个新的问题:数据一致性。在程序运行中,会将运行所需要的数据复制一份到 CPU 高速缓存中,在进行运算时 CPU 不再也主存打交道,而是直接从高速缓存中读写数据,只有当运行结束后,才会将数据刷新到主存中。举一个简单的例子:


i = i + 1;
复制代码


当线程运行这段代码时,会执行以下几个步骤:


  1. 从主存中读取 i 的值( 假设此时 i = 1 )

  2. 复制一份到 CPU 高速缓存中

  3. CPU 执行 + 1 的操作(此时 i = 2

  4. 将数据 i = 2 写入到告诉缓存中

  5. 刷新到主存中


其实这样做在单线程中是没有问题的,有问题的是在多线程中。如下:


假如有两个线程 A、B 都执行这个操作( i++ ),按照我们正常的逻辑思维主存中的 i 值应该=3 。但事实是这样么?分析如下:


两个线程从主存中读取 i 的值( 假设此时 i = 1 ),到各自的高速缓存中,然后线程 A 执行 +1 操作并将结果写入高速缓存中,最后写入主存中,此时主存 i = 2 。线程 B 做同样的操作,主存中的 i 仍然 =2 。所以最终结果为 2 并不是 3 。这种现象就是缓存一致性问题


解决缓存一致性方案有两种


  1. 通过在总线加 LOCK 锁的方式

  2. 通过缓存一致性协议


第一种方案, 存在一个问题,它是采用一种独占的方式来实现的,即总线加 LOCK# 锁的话,只能有一个 CPU 能够运行,其他 CPU 都得阻塞,效率较为低下。


第二种方案,缓存一致性协议(MESI 协议),它确保每个缓存中使用的共享变量的副本是一致的。其核心思想如下:当某个 CPU 在写数据时,如果发现操作的变量是共享变量,则会通知其他 CPU 告知该变量的缓存行是无效的,因此其他 CPU 在读取该变量时,发现其无效会重新从主存中加载数据(目前新的 CPU ,增加了缓存锁来保证原子性)。



线程、工作内存、主内存三者的关系如下图所示(其实就是模仿上面这个思路来的):



注意,这里所说的主内存、工作内存跟 Java 虚拟机内存区域划分中的堆、栈是不同层次的内存划分,如果两者一定要勉强对应起来,主内存主要对应于堆中对象的实例部分,而工作内存主要对应与虚拟机栈中的部分区域。


从更低层次来说,主内存主要对应于硬件内存部分,工作内存主要对应于 CPU 的高速缓存和寄存器部分,但也不是绝对的,主内存也可能存在于高速缓存和寄存器中,工作内存也可能存在于硬件内存中。



二、Java 内存模型


上面从操作系统层次阐述了如何保证数据一致性,下面我们来看一下 Java 内存模型,稍微研究一下它为我们提供了哪些保证,以及在 Java 中提供了哪些方法和机制,来让我们在进行多线程编程时能够保证程序执行的正确性。


在并发编程中我们一般都会遇到这三个基本概念:


  • 原子性

  • 可见性

  • 有序性


原子性


原子性是指一段操作一旦开始就会一直运行到底,中间不会被其它线程打断,这段操作可以是一个操作,也可以是多个操作。


由 Java 内存模型来直接保证的原子性操作包括read、load、user、assign、store、write这两个操作,我们可以大致认为基本类型变量的读写是具备原子性的。


如果应用需要一个更大范围的原子性,Java 内存模型还提供了lockunlock这两个操作来满足这种需求,尽管不能直接使用这两个操作,但我们可以使用它们更具体的实现synchronized来实现。因此,synchronized块之间的操作也是原子性的。


在单线程环境下我们可以认为整个步骤都是原子性操作,但是在多线程环境下则不同,Java 只保证了基本数据类型的变量和赋值操作才是原子性的(注:在 32 位的 JDK 环境下,对 64 位数据的读取不是原子性操作,例如:long、double)。要想在多线程环境下保证原子性,则可以通过锁、synchronized 来确保。


下面用一段代码来证明 volatile 是不能保证原子性:


public class VolatileAtomicDemo {    public volatile int inc = 0;    public void increase() {        inc++;    }    public static void main(String[] args) {        final VolatileAtomicDemo volatileAtomicDemo = new VolatileAtomicDemo();        for (int i = 0; i < 10; i++) {            new Thread() {                public void run() {                    for (int j = 0; j < 1000; j++)                        volatileAtomicDemo.increase();                }                ;            }.start();        }        //保证前面的线程都执行完  剩余一个main线程还有一个idea监控线程(我使用的是idea工具,所以判断条件为>2)        while (Thread.activeCount() > 2)  //保证前面的线程都执行完            Thread.yield();        System.out.println(volatileAtomicDemo.inc);    }}
复制代码


输出的结果是小于等于 10000;


什么原因导致的呢?


自增的操作是不具备原子性的,


首先从主存中读取变量的原始值,进行+1 的操作,再将变量写回主存中;


也就是说自增操作可以拆分为 3 个子操作。这样就有可能导致以下的一种情况;


可见性


可见性是指当一个线程修改了共享变量的值,其它线程能立即感知到这种变化。


Java 内存模型是通过在变更修改后同步回主内存,在变量读取前从主内存刷新变量值来实现的,它是依赖主内存的,无论是普通变量还是 volatile 变量都是如此。


普通变量与 volatile 变量的主要区别是是否会在修改之后立即同步回主内存,以及是否在每次读取前立即从主内存刷新。因此我们可以说 volatile 变量保证了多线程环境下变量的可见性,但普通变量不能保证这一点。


当一个变量被 volatile 修饰后,表示着线程本地内存无效。当一个线程修改共享变量后他会立即被更新到主内存中;当其他线程读取共享变量时,它会直接从主内存中读取。


除了 volatile 之外,还有两个关键字也可以保证可见性,它们是 synchronized 和 final。


  • synchronized 的可见性是由“对一个变量执行 unlock 操作之前,必须先把此变量同步回主内存中,即执行 store 和 write 操作”这条规则获取的。

  • final 的可见性是指被 final 修饰的字段在构造器中一旦被初始化完成,那么其它线程中就能看见这个 final 字段了。


很多人都是最怕看文字了,下面写了两段代码:


public class VolatileDemo {    private static  boolean close = false;    public static void main(String[] args) {        new Thread(new Runnable() {            @Override            public void run() {                try {                    //稍微睡会,等主线程先开始执行while                    TimeUnit.MILLISECONDS.sleep(3);                } catch (InterruptedException e) {                    e.printStackTrace();                }                close=true;                System.out.println("子线程运行结束");            }        }).start();        while (!close){        }        System.out.println("主线程运行结束");    }}
复制代码


上面这段代码,如果你对 volatile 关键字不熟悉不理解的话,凭感觉阅读上面这段代码,让你写出答案,你觉得是什么?


把close改成了 true主线程运行结束
复制代码


是这样吗?


答案是这样的



子线程都已经改成 true 了,为什么主线程死循环出不来了呢?主线程怎么还不结束呢?


下面我们给变量 close 加上 volatile 关键字修饰,


public class VolatileDemo {    //加了关键字volatile    private static volatile boolean close = false;    public static void main(String[] args) {        new Thread(new Runnable() {            @Override            public void run() {                try {                    TimeUnit.MILLISECONDS.sleep(3);                } catch (InterruptedException e) {                    e.printStackTrace();                }                close=true;                System.out.println("子线程运行结束");            }        }).start();        while (!close){        }        System.out.println("主线程运行结束");    }}
复制代码


运行结果



子线程运行结束,马上主线程也就跟着运行结束了,不再死循环了。


到这里我们就证明了 volatile 的可见性了。


有序性


Java 程序中天然的有序性可以总结为一句话:如果在本线程中观察,所有的操作都是有序的;如果在另一个线程中观察,所有的操作都是无序的。


前半句是指线程内表现为串行的语义,后半句是指“指令重排序”现象和“工作内存和主内存同步延迟”现象。


Java 中提供了 volatile 和 synchronized 两个关键字来保证有序性。


  • volatile 天然就具有有序性,因为其禁止重排序。

  • synchronized 的有序性是由“一个变量同一时刻只允许一条线程对其进行 lock 操作”这条规则获取的。


int i = 0;              boolean flag = false;i = 1;                //语句1 flag = true;          //语句2
复制代码


上面代码定义了一个 int 型变量,定义了一个 boolean 类型变量,然后分别对两个变量进行赋值操作。从代码顺序上看,语句 1 是在语句 2 前面的,那么 JVM 在真正执行这段代码的时候会保证语句 1 一定会在语句 2 前面执行吗?不一定,为什么呢?这里可能会发生指令重排序(Instruction Reorder)。


下面解释一下什么是指令重排序,一般来说,处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的。


比如上面的代码中,语句 1 和语句 2 谁先执行对最终的程序结果并没有影响,那么就有可能在执行过程中,语句 2 先执行而语句 1 后执行。


但是要注意,虽然处理器会对指令进行重排序,但是它会保证程序最终结果会和代码顺序执行结果相同,那么它靠什么保证的呢?再看下面一个例子:


int a = 10;    //语句1int r = 2;    //语句2a = a + 3;    //语句3r = a*a;     //语句4
复制代码


这段代码有 4 个语句,那么可能的一个执行顺序是:



那么可不可能是这个执行顺序呢:


语句 2 -->语句 1--> 语句 4-->语句 3


不可能,因为处理器在进行重排序时是会考虑指令之间的数据依赖性,如果一个指令 Instruction 2 必须用到 Instruction 1 的结果,那么处理器会保证 Instruction 1 会在 Instruction 2 之前执行。


volatile 原理


JMM 比较庞大,不是上面一点点就能够阐述的。上面简单地介绍都是为了 volatile 做铺垫的。


volatile 可以保证线程可见性且提供了一定的有序性,但是无法保证原子性。在 JVM 底层,volatile 是采用“内存屏障”来实现的。


上面那段话,有两层语义:


  1. 保证可见性、不保证原子性

  2. 禁止指令重排序


第一层语义就不做介绍了,下面重点介绍指令重排序


在执行程序时为了提高性能,编译器和处理器通常会对指令做重排序:


  1. 编译器重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。

  2. 处理器重排序。如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序。


指令重排序对单线程没有什么影响,他不会影响程序的运行结果,但是会影响多线程的正确性。既然指令重排序会影响到多线程执行的正确性,那么我们就需要禁止重排序。那么 JVM 是如何禁止重排序的呢?这个问题稍后回答。


我们先看另一个原则 happens-before


该原则保证了程序的“有序性”,它规定如果两个操作的执行顺序无法从 happens-before 原则中推到出来,那么他们就不能保证有序性,可以随意进行重排序。


下面就来具体介绍下 happens-before 原则(先行发生原则):(摘自《深入理解 Java 虚拟机》)


  • 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作,happens-before 于书写在后面的操作。

  • 锁定规则:一个 unLock 操作,happens-before 于后面对同一个锁的 lock 操作。

  • volatile 变量规则:对一个变量的写操作,happens-before 于后面对这个变量的读操作。

  • 传递规则:如果操作 A happens-before 操作 B,而操作 B happens-before 操作 C,则可以得出,操作 A happens-before 操作 C

  • 线程启动规则:Thread 对象的 start 方法,happens-before 此线程的每个一个动作。

  • 线程中断规则:对线程 interrupt 方法的调用,happens-before 被中断线程的代码检测到中断事件的发生。

  • 线程终结规则:线程中所有的操作,都 happens-before 线程的终止检测,我们可以通过 Thread.join() 方法结束、Thread.isAlive() 的返回值手段,检测到线程已经终止执行。

  • 对象终结规则:一个对象的初始化完成,happens-before 它的 finalize() 方法的开始


总结


这个 volatile 关键字使用的不是很多,但是也是要慎用!!!虽说他是轻量级的 synchronized。但是不是说 volatile 就能替代 synchronized;


使用 volatile 必须满足如下两个条件:


  1. 对变量的写操作,不依赖当前值

  2. 该变量没有包含在具有其他变量的不变式中。


volatile 经常用于两个场景:状态标记变量、Double Check (双重检查锁)。


关键点:避免指令重排、可见性保证、原子性不保证。可见性是采用内存屏障来实现的。


参考:


https://www.cnblogs.com/dolph...


https://www.cnblogs.com/tong-...


扫描关注公众号“Java 后端技术全栈”


解锁程序员的狂野世界



发布于: 2020 年 11 月 02 日阅读数: 42
用户头像

田维常

关注

关注公众号:Java后端技术全栈,领500G资料 2020.10.24 加入

关注公众号:Java后端技术全栈,领500G资料

评论

发布
暂无评论
快速掌握并发编程---深入了解volatile