阅读 446

资深消防猿为你解读Java多线程与并发模型之共享对象

互联网上充斥着对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)。这就是缓存不一致性问题。这会在多线程访问共享变量时出现。

解决缓存不一致问题的方案:

  1. 通过总线锁LOCK#方式。

  2. 通过缓存一致性协议。


图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法则:

  1. 程序次序法则:线程中的每个动作A都Happens-Before于该线程中的每一个动作B,在程序中,所有的动作B都出现在动作A之后。

  2. Lock法则:对于一个Lock的解锁操作总是Happens-Before于每一个后续对该Lock的加锁操作。

  3. volatile变量法则:对于volatile变量的写入操作Happens-Before于后续对同一个变量的读操作。

  4. 线程启动法则:在一个线程里,对Thread.start()函数的调用会Happens-Before于每一个启动线程中的动作。

  5. 线程终结法则:线程中的任何动作都Happens-Before于其他线程检测到这个线程已经终结或者从Thread.join()函数调用中成功返回或者Thread.isAlive()函数返回false。

  6. 中断法则:一个线程调用另一个线程的interrupt总是Happens-Before于被中断的线程发现中断。

  7. 终结法则:一个对象的构造函数的结束总是Happens-Before于这个对象的finalizer(Java没有直接的类似C的析构函数)的开始。

  8. 传递性法则:如果A事件Happens-Before于B事件,并且B事件Happens-Before于C事件,那么A事件Happens-Before于C事件。

当一个变量在多线程竞争中被读取和存储,如果并未按照Happens-Before的法则,那么他就会存在数据竞争关系。

总结

给大家关于Java的共享变量的内容就介绍到这里,现在你已经明白Java的volatile关键字的含义了,了解了为什么volatile不能保障原子性的原因了,了解了Happens-Before规则能让我们的Java程序运行的更加安全。通过这节内容希望可以帮助你更深入的了解Java的并发概念中的内置锁和共享变量。Java的并发内容还有很多,例如在某些场景下比synchronized效率要更高的Lock,阻塞队列,同步器等。



关注下面的标签,发现更多相似文章
评论