彻底理解Java并发:volatile关键字

2022-12-01 21:36:15 浏览数 (1)

本篇内容包括:volatile 关键字简介、volatile 保证可见性(包括:关乎不可见性问题描述、JMM内存模型和不可见性的解决方案)以及 volatile 其他特性(包括:volatile 不保证原子性、volatile 原子性的保证操作、volatile 禁止指令重排、内存屏障和 happens-before 规则)

一、volatile 关键字简介

Java 中的 volatile 关键字,用来修饰会被不同线程访问和修改的变量,通常用于并发编程中,是 Java 虚拟机提供的轻量化同步机制。

volatile 关键字可以保证 JMM(Java内存模型)三个特征中的两个,即可见性与有序性。

volatile 关键字的两个作用:

  1. volatile 能够保证共享变量之间的可见性;
  2. volatile 禁止指令重排序。

二、volatile 保证可见性

1、关乎不可见性问题描述

首先要明确一点的是:只要直接采用了多线程的并发模型,并采用共享内存的方式作为数据的通讯方式,就一定有可见性问题。

在多线程并发执行下,多线程修改共享的成员变量,会出现一个线程修改了共享变量的值后,另一个线程不能直接看到该线程修改后的变量的最新值的情况。即多线程下修改共享变量会出现变量修改值后的不可见性。

从硬件层面上来讲,当 CPU 需要从主内存获取数据时,会拷贝一份到高速缓存中,CPU 计算时就可以直接在高速缓存中进行数据的读取和写入,提高吞吐量。当数据运行完成后,再将高速缓存的内容刷新到主内存中,此时其他 CPU 看到的才是执行之后的结果,但在这之间存在着时间差。

2、Java内存模型(JMM)

JMM(Java Memory Model):Java内存模型,是Java虚拟机规范中锁定义的一种内存模型,屏蔽掉了底层不同计算机的区别;

JMM 描述了Java程序中各种变量(线程共享变量)的访问规则,以及JVM中将变量存储到内存和从内存中读取变量这样的底层细节,JMM有以下规定:

  • 所有的共享变量都存储与主内存,这里所说的变量指的是实例变量和类变量,不包含局部变量,因为局部变量是线程私有的,因此不存在线程竞争问题;
  • 每个线程还存在自己的工作内存,线程的工作内存保留了线程使用的变量的工作副本;
  • 线程对变量的所有操作(读,写)都必须在工作内存中完成,不能直接读写主内存中的变量;
  • 不同线程见不饿能直接访问对方的工作内存中变量,线程间变量的值的传递需要通过主内存的中转来完成

本地内存和主内存的关系:

3、不可见性的解决方案

从 JSR-133 开始(即从Jdk5开始),volatile 变量的写-读可以实现线程之间的通信。当写一个 volatile 变量时,JMM 会把该线程对应的本地内存中的共享变量值刷新到主内存。当读一个 volatile 变量时,JMM 会把该线程对应的本地内存置为无效。线程接下来将从主内存中读取共享变量。

即:volatile 会插入内存屏障以阻止重排序。

下面我们分别在 synchronized 和 volatile 的角度,去体验一下他们对于不可见性的解决方案的工作逻辑。

  1. 加锁,synchronized 的加锁逻辑:①、线程获取锁;②、线程清工作内存;③、线程从主内存重新拷贝共享变量最新值到工作内存副本;④、执行代码;⑤、修改后的值返回并刷新主内存;⑥、线程释放锁
  2. 加 volatile,工作逻辑:①、子线程T从主内存读取到数据放入对应的工作内存;②、子线程 T 将 flag 的修改位 true,但此时 flag 的值还没同步回主内存;③、主线程 main 方法读取到了 flag 的值为 flag;④、子线程T将值同步回主内存;⑤、主内存利用总线嗅探机制,失效其他线程对此变量的副本;⑥主线程在对 flag 进行操作的时候会从主线程读取最新的值,放入工作内存中。

三、volatile 其他特性

1、volatile 不保证原子性

原子性是指在一次操作或者多次操作中,要么所有的操作前部都得到了执行并且不会受到人格因素的干扰而中断,要么所有的操作都不执行,volttile 不保证原子性

以 COUNT 问题为例,操作包含三个步骤:

  1. 从主内存读取数据到工作内存
  2. 对工作内存中的数据进行 操作
  3. 将工作内存中的数据同步回主内存

由此可见,count 操作不是一个原子操作,也就是说在某一时刻对某一个值的操作的执行,有可能被其他线程打乱

2、volatile 原子性的保证操作
  • 加锁机制:我们给 count 操作添加锁,那么 count 操作就是临界区的代码,临界区代码同一时刻只能由一个线程去执行,即 count 变成了原子性操作
  • 使用原子类:从 Jdk1.5 开始提供了 java.util.countrrent.atomic 包,这个包中的原子操作类提供了一种用法简单,性能搞笑,线程安全地和更新变量的方式
3、volatile 禁止指令重排

重排序:为了提高性能,编译器和出常常会对既定的代码执行顺序进行指令重排序

原因:一个好的内存模型实际上会放松对处理器和编译器的规则束缚,也就是说软件技术和硬件技术都为一个目标服务:在不改变程序执行结果的前提下,尽可能提高执行效率。JMM对底层尽量减少约束,十七能后发挥自身优势,因此,在执行程序时,为提高性能,编译器和处理器常常会对指令集逆行重排序,一般重排序科一分为如下三种:

  1. 编译器优化的重排序,编译器在不该百年单线程程序语义的前提下,重新安排语句的执行顺序;
  2. 指令级并行的重排序,现代处理器采用额指令级并行技术来讲多条指令重叠执行,如果不存在数据依赖性,处理器可以改变语句对应机器指令的执行顺序;
  3. 内存系统的个重排序,由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行的

重排序的好处:重排序可以提高程序处理速度

重排序的问题:单线程的重排序很简单,因为可以通过语义分析就能知道前后代码的依赖性,但是多线程就不一样了,多线程环境里编译器和CPU指令优化根本无法识别多个线程之间存在的数据依赖性。

volatile 解决不可见问题的方式就是插入内存屏障以阻止重排序:

  • (假设存在前后两个操作)当第二个操作是 volatile 写时,不管第一个操作是什么,都不能重排序。这个规则确保 volatile 写之前的操作不会被编译器重排序到 volatile 写之后
  • (假设存在前后两个操作)当第一个操作是 volatile 读时,不管第二个操作是什么,都不能重排序。这个规则确保 volatile 读之后的操作不会被编译器重排序到 volatile 读之前。
4、内存屏障

内存屏障分为两种:Load Barrier 和 Store Barrier 即读屏障和写屏障。内存屏障有两个作用:

  1. 阻止屏障两侧的指令重排序;
  2. 强制把写缓冲区/高速缓存中的脏数据等写回主内存,让缓存中相应的数据失效

对于 Load Barrier 来说,在指令前插入 Load Barrier,可以让高速缓存中的数据失效,强制从新从主内存加载数据;

对于 Store Barrier 来说,在指令后插入 Store Barrier,能让写入缓存中的最新数据更新写入主内存,让其他线程可见。

由于内存屏障的作用,避免了 volatile 变量和其它指令重排序、线程之间实现了通信,使得 volatile表 现出了锁的特性。

volatile 性能:volatile 的读性能消耗与普通变量几乎相同,但是写操作稍慢,因为它需要在本地代码中插入许多内存屏障指令来保证处理器不发生乱序执行。

5、happens-before 规则

使用 happens-before 概念阐述了两个操作之间的内存可见性

happens-before 关系定义如下:

  1. 如果一个操作 happens-before 另一个操作,那么意味着第一个操作的结果对第二个操作可见,而且第一个操作的执行顺序将排在第二个操作的前面。
  2. 两个操作之间存在 happens-before 关系,并不意味着 Java 平台的具体实现必须按照 happens-before 关系指定的顺序来执行。如果重排序之后的结果,与按照 happens-before 关系来执行的结果一致,那么这种重排序并不非法(也就是说,JMM允许这种重排序)

happens-before 规则如下:

  1. 程序顺序规则:一个线程中的每个操作,happens-before 于该线程中的任意后续操作。
  2. 监视器锁规则:对一个监视器锁的解锁,happens-before 于随后对这个监视器锁的加锁。
  3. volatile 变量规则:对一个 volatile 域的写,happens-before 于任意后续对这个 volatile 域的读。
  4. 传递性:如果 A happens-before B,且 B happens-before C,那么 A happens-before C。
  5. 线程启动(start) 规则:如果 线程A 执行操作 ThreadB.start()(启动线程B),那么 线程A 的 ThreadB.start() 操作 happens-before 于 线程B 中的任意操作
  6. 线程终结(join)规则:如果 线程A 执行操作 ThreadB.join() 并成功返回,那么 线程B 中的任意操作 happens-before 于 线程A 从 ThreadB.join() 操作成功返回。
  7. 线程中断操作:对线程 interrupt() 方法的调用,happens-before 于被中断线程的代码检测到中断事件的发生,可以通过 Thread.interrupted() 方法检测到线程是否有中断发生。
  8. 对象终结规则:一个对象的初始化完成,happens-before 于这个对象的 finalize() 方法的开始。

happens-before 规则非常重要,它是判断数据是否存在竞争、线程是否安全的主要依据,当我们了解并可以合理的运用 happens-before 规则后,就可以更好的写出线程安全的代码啦!

0 人点赞