互联网上充斥着对 Java 多线程编程的介绍,每篇文章都从不同的角度介绍并总结了该领域的内容.但大部分文章都没有说明多线程的实现本质,没能让开发者真正 "过瘾".
以下内容如无特殊说明均指代 Java 环境.
共享对象
使用 Java 编写线程安全的程序关键在于正确的使用共享对象,以及安全的对其进行访问管理.在第一章我们谈到 Java 的内置锁可以保障线程安全,对于其他的应用来说并发的安全性是在内置锁这个 "黑盒子" 内保障了线程变量使用的边界.谈到线程的边界问题,随之而来的是 Java 内存模型另外的一个重要的含义,可见性.Java 对可见性提供的原生支持是 volatile 关键字.
volatile 关键字
volatile 变量具备两种特性,其一是保证该变量对所有线程可见,这里的可见性指的是当一个线程修改了变量的值,那么新的值对于其他线程是可以立即获取的.其二 volatile 禁止了指令重排.
虽然 volatile 变量具有可见性和禁止指令重排序,但是并不能说 volatile 变量能确保并发安全.
public class VolatileTest {public static volatile int a = 0;public static final int THREAD_COUNT = 20;public static void increase() {a++;}public static void main(String[] args) throws InterruptedException {Thread[] threads = new Thread[THREAD_COUNT];for (int i = 0; i < THREAD_COUNT; i++) {threads[i] = new Thread(new Runnable() {public void run() {for (int i = 0; i < 1000; i++) {increase();}}});threads[i].start();}while (Thread.activeCount() > 2) {Thread.yield();}System.out.println(a);}}
按照我们的预期,它应该返回 20000 ,但是很可惜,该程序的返回结果几乎每次都不一样.
问题主要出在 a++ 上,复合操作并不具备原子性, 虽然这里利用 volatile 定义了 a ,但是在做 a++ 时, 先获取到最新的 a 值,比如这时候最新的可能是 50,然后再让 a 增加,但是在增加的过程中,其他线程很可能已经将 a 的值改变了,或许已经成为 52,53 ,但是该线程作自增时,还是使用的旧值,所以会出现结果往往小于预期的 2000.如果要解决这个问题,可以对 increase() 方法加锁.
volatile 适用场景
volatile 适用于程序运算结果不依赖于变量的当前值,也相当于说,上述程序的 a 不要自增,或者说仅仅是赋值运算,例如
boolean flag = true
这样的操作.
volatile boolean shutDown = false;public void shutDown() {shutDown = true;}public void doWork() {while (!shutDown) {System.out.println("Do work " + Thread.currentThread().getId());}}
代码 2.1:变量的可见性问题
在代码 2.1 中,可以看到按照正常的逻辑应该打印 10 之后线程停止,但是实际的情况可能是打印出 0 或者程序永远不会被终止掉.其原因是没有使用恰当的同步机制以保障线程的写入操作对所有线程都是可见的.
我们一般将 volatile 理解为 synchronized 的轻量级实现,在多核处理器中可以保障共享变量的 "可见性",但是不能保障原子性.关于原子性问题在该章节的程序变量规则会加以说明,下面我们先看下 Java 的内存模型实现以了解 JVM 和计算机硬件是如何协调共享变量的以及 volatile 变量的可见性.
Java 内存模型
我们都知道现代计算机都是冯诺依曼结构的,所有的代码都是顺序执行的.如果计算机需要在 CPU 中运算某个指令,势必就会涉及对数据的读取和写入操作.由于程序数据的大部分内容都是存储在主内存(RAM)中的,在这当中就存在着一个读取速度的问题,CPU 很快而主内存相对来说(相对 CPU)就会慢上很多,为了解决这个速度阶梯问题,各个 CPU 厂商都在 CPU 里面引入了高速缓存来优化主内存和 CPU 的数据交互.针对上面的技术我特意整理了一下,有很多技术不是靠几句话能讲清楚,所以干脆找朋友录制了一些视频,很多问题其实答案很简单,但是背后的思考和逻辑不简单,要做到知其然还要知其所以然.如果想学习 Java 工程化,高性能及分布式,深入浅出.微服务,Spring,MyBatis,Netty 源码分析的朋友可以加我的 Java 进阶群:591240817,群里有大牛直播讲解技术,以及 Java 大型互联网技术的视频免费分享
此时当 CPU 需要从主内存获取数据时,会拷贝一份到高速缓存中,CPU 计算时就可以直接在高速缓存中进行数据的读取和写入,提高吞吐量.当数据运行完成后,再将高速缓存的内容刷新到主内存中,此时其他 CPU 看到的才是执行之后的结果,但在这之间存在着时间差.
看这个例子:
int counter = 0; counter = counter + 1;
代码 2.2:自增不一致问题
代码 2.2 在运行时,CPU 会从主内存中读取 counter 的值,复制一份到当前 CPU 核心的高速缓存中,在 CPU 执行完成加 1 的指令之后,将结果 1 写入高速缓存中,最后将高速缓存刷新到主内存中.这个例子代码在单线程的程序中将正确的运行下去.
但我们试想这样一种情况,现在有两个线程共同运行该段代码,初始化时两个线程分别从主内存中读取了 counter 的值 0 到各自的高速缓存中,线程 1 在 CPU1 中运算完成后写入高速缓存 Cache1,线程 2 在 CPU2 中运算完成后写入高速缓存 Cache2,此时 counter 的值在两个 CPU 的高速缓存中的值都是 1.
此时 CPU1 将值刷新到主内存中,counter 的值为 1,之后 CPU2 将 counter 的值也刷新到主内存,counter 的值覆盖为 1,最终的结果计算 counter 为 1(正确的两次计算结果相加应为 2).这就是缓存不一致性问题.这会在多线程访问共享变量时出现.
解决缓存不一致问题的方案:
通过总线锁 LOCK# 方式.
通过缓存一致性协议.
图 2.1 :缓存不一致问题
图 2.1 中提到的两种内存一致性协议都是从计算机硬件层面上提供的保障.CPU 一般是通过在总线上增加 LOCK# 锁的方式,锁住对内存的访问来达到目的,也就是阻塞其他 CPU 对内存的访问,从而使只有一个 CPU 能访问该主内存.因此需要用总线进行内存锁定,可以分析得到此种做法对 CPU 的吞吐率造成的损害很严重,效率低下.
随着技术升级带来了缓存一致性协议,市场占有率较大的 Intel 的 CPU 使用的是 MESI 协议,该协议可以保障各个高速缓存使用的共享变量的副本是一致的.其实现的核心思想是:当在多核心 CPU 中访问的变量是共享变量时,某个线程在 CPU 中修改共享变量数据时,会通知其他也存储了该变量副本的 CPU 将缓存置为无效状态,因此其他 CPU 读取该高速缓存中的变量时,发现该共享变量副本为无效状态,会从主内存中重新加载.但当缓存一致性协议无法发挥作用时,CPU 还是会降级使用总线锁的方式进行锁定处理.
一个小插曲:为什么 volatile 无法保障的原子性
我们看下图 2.2,CPU 在主内存中读取一个变量之后,拷贝副本到高速缓存,CPU 在执行期间虽然识别了变量的 "易变性",但是只能保障最后一步 store 操作的原子性,在 load,use 期间并未实现其原子性操作.
图 2.2:数据加载和内存屏障
JVM 为了使我们的代码得到最优的执行体验,在进行自我优化时,并不保障代码的先后执行顺序(满足 Happen-Before 规则的除外),这就是 "指令重排",而上面提到的 store 操作保障了原子性,JVM 是如何实现的呢?其原因是这里存在一个 "内存屏障" 的指令(以后我们会谈到整个内容),这个是 CPU 支持的一个指令,该指令只能保障 store 时的原子性,但是不能保障整个操作的原子性.
从整个小插曲中,我们看到了 volatile 虽然有可见性的语义,但是并不能真正的保证线程安全.如果要保证并发线程的安全访问,需要符合并发程序变量的访问规则.
并发程序变量的访问规则
1. 原子性
程序的原子性和数据库事务的原子性有着同样的意义,可以保障一次操作要么全部执行成功,要不全部都不执行.
2. 可见性
可见性是微妙的,因为最终的结果总是和我们的直觉大相径庭,当多个线程共同修改一个共享变量的值时,由于存在高速缓存中的变量副本操作,不能及时将数据刷新到主内存,导致当前线程在 CP 中的操作结果对其他 CPU 是不可见状态.
3. 有序性
有序性通俗的理解就是程序在 JVM 中是按照顺序执行的,但是前面已经提到了 JVM 为了优化代码的执行速度,会进行 "指令重排".在单线程中 "指令重排" 并不会带来安全问题,但在并发程序中,由于程序的顺序不能保障,运行过程中可能会出现不安全的线程访问问题.
综上,要想在并发编程环境中安全的运行程序,就必须满足原子性,可见性和有序性.只要以上任何一点没有保障,那程序运行就可能出现不可预知的错误.最后我们介绍一下 Java 并发的 "杀手锏",Happens-Before 法则,符合该法则的情况下可以保障并发环境下变量的访问规则.
happens-before 语义
Java 内存模型使用了各种操作来定义的,包括对变量的读写,监视器的获取释放等,JMM 中使用了
happens-before
语义阐述了操作之间的内存可见性.如果想要保证执行操作 B 的线程看到操作 A 的结构(无论 AB 是否在同一线程),那么 A,B 必须满足
happens-before
关系.如果两个操作之间缺乏
happens-before
Happens-Before 法则:
程序次序法则:线程中的每个动作 A 都 Happens-Before 于该线程中的每一个动作 B,在程序中,所有的动作 B 都出现在动作 A 之后.
Lock 法则:对于一个 Lock 的解锁操作总是 Happens-Before 于每一个后续对该 Lock 的加锁操作.
volatile 变量法则:对于 volatile 变量的写入操作 Happens-Before 于后续对同一个变量的读操作.
线程启动法则:在一个线程里,对 Thread.start() 函数的调用会 Happens-Before 于每一个启动线程中的动作.
线程终结法则:线程中的任何动作都 Happens-Before 于其他线程检测到这个线程已经终结或者从 Thread.join() 函数调用中成功返回或者 Thread.isAlive() 函数返回 false.
中断法则:一个线程调用另一个线程的 interrupt 总是 Happens-Before 于被中断的线程发现中断.
终结法则:一个对象的构造函数的结束总是 Happens-Before 于这个对象的 finalizer(Java 没有直接的类似 C 的析构函数)的开始.
传递性法则:如果 A 事件 Happens-Before 于 B 事件,并且 B 事件 Happens-Before 于 C 事件,那么 A 事件 Happens-Before 于 C 事件.
当一个变量在多线程竞争中被读取和存储,如果并未按照 Happens-Before 的法则,那么他就会存在数据竞争关系.
总结
给大家关于 Java 的共享变量的内容就介绍到这里,现在你已经明白 Java 的 volatile 关键字的含义了,了解了为什么 volatile 不能保障原子性的原因了,了解了 Happens-Before 规则能让我们的 Java 程序运行的更加安全.通过这节内容希望可以帮助你更深入的了解 Java 的并发概念中的内置锁和共享变量.Java 的并发内容还有很多
来源: https://juejin.im/post/5a5876a86fb9a01ca325386f