资深架构师解读Java多线程与并发模型之共享对象

原创
开发 后端
这是一篇总结Java多线程开发的长文。文章是从Java创建之初就存在的synchronized关键字引入,对Java多线程和并发模型进行了探讨。希望通过此篇内容的解读能帮助Java开发者更好的理清Java并发编程的脉络。

【51CTO.com原创稿件】互联网上充斥着对Java多线程编程的介绍,每篇文章都从不同的角度介绍并总结了该领域的内容。但大部分文章都没有说明多线程的实现本质,没能让开发者真正“过瘾”。上篇内容从Java的线程安全鼻祖内置锁介绍开始,让你了解内置锁的实现逻辑和原理以及引发的性能问题,本篇接着说明Java多线程编程中锁的存在是为了保障共享变量的线程安全使用。下面让我们进入正题。

以下内容如无特殊说明均指代Java环境。

第二部分:共享对象 

使用Java编写线程安全的程序关键在于正确的使用共享对象,以及安全的对其进行访问管理。在第一章我们谈到Java的内置锁可以保障线程安全,对于其他的应用来说并发的安全性是在内置锁这个“黑盒子”内保障了线程变量使用的边界。谈到线程的边界问题,随之而来的是Java内存模型另外的一个重要的含义,可见性。Java对可见性提供的原生支持是volatile关键字。

volatile关键字

volatile关键字是Java语言提供的原生实现,可以理解为“易变的”。首先看一个例子:

  1. public class Share { 
  2.     private static boolean ready; 
  3.     private static int number; 
  4.  
  5.     private static class Node extends Thread { 
  6.         public void run() { 
  7.             while (!ready) 
  8.                 Thread.yield(); 
  9.             System.out.println(number); 
  10.         } 
  11.     } 
  12.  
  13.     public static void main(String[] args) { 
  14.         new Node().start(); 
  15.         number = 10; 
  16.         ready = true
  17.     } 
  18.  

代码2.1:变量的可见性问题 

在代码2.1中,可以看到按照正常的逻辑应该打印10之后线程停止,但是实际的情况可能是打印出0或者程序永远不会被终止掉。其原因是没有使用恰当的同步机制以保障线程的写入操作对所有线程都是可见的。

我们一般将volatile理解为synchronized的轻量级实现,在多核处理器中可以保障共享变量的“可见性”,但是不能保障原子性。关于原子性问题在该章节的程序变量规则会加以说明,下面我们先看下Java的内存模型实现以了解JVM和计算机硬件是如何协调共享变量的以及volatile变量的可见性。 

Java内存模型 

我们都知道现代计算机都是冯诺依曼结构的,所有的代码都是顺序执行的。如果计算机需要在CPU中运算某个指令,势必就会涉及对数据的读取和写入操作。由于程序数据的大部分内容都是存储在主内存(RAM)中的,在这当中就存在着一个读取速度的问题,CPU很快而主内存相对来说(相对CPU)就会慢上很多,为了解决这个速度阶梯问题,各个CPU厂商都在CPU里面引入了高速缓存来优化主内存和CPU的数据交互。

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

看这个例子:

  1. int counter = 0; 

  2. 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法则: 

  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,阻塞队列,同步器等。 

参考文献: 

《Java并发编程实战》

更多精彩请访问:

  • XSS常见攻击与防御

https://zhuanlan.zhihu.com/p/30475175

  • 利用500W条微博语料对评论进行情感分析:

https://zhuanlan.zhihu.com/p/30061051

  • 还在手调网络权限?资深IT工程师都这样玩企业组网

https://zhuanlan.zhihu.com/p/29787843

  • 微服务在互联网公司演进过程

https://zhuanlan.zhihu.com/p/29758427
 

作者简介 

魏靓:现就职于五阿哥(www.wuage.com)任职专职架构师工作,负责平台的基础设施搭建工作。 

【51CTO原创稿件,合作站点转载请注明原文作者和出处为51CTO.com】

 

责任编辑:庞桂玉 来源: 51CTO.com
相关推荐

2017-11-17 15:57:09

Java多线程并发模型

2018-07-03 15:46:24

Java架构师源码

2011-06-13 10:41:17

JAVA

2017-09-16 18:29:00

代码数据库线程

2021-07-19 07:55:24

多线程模型Redis

2012-11-01 15:08:10

IBM资深架构师

2010-05-04 08:44:42

Java并发模型

2018-02-05 09:30:23

高性能高并发服务

2018-09-13 15:00:51

JavaHashMap架构师

2022-05-26 08:31:41

线程Java线程与进程

2017-12-18 16:33:55

多线程对象模型

2013-10-17 15:54:46

红帽

2015-04-10 17:35:26

WOT2015谷歌资深架构师李聪

2013-10-17 15:45:24

红帽

2021-06-07 09:35:11

架构运维技术

2019-10-21 09:32:48

缓存架构分层

2020-01-16 15:35:00

高并发架构服务器

2009-03-12 10:52:43

Java线程多线程

2013-11-14 10:06:11

红帽redhat

2009-02-19 16:19:48

SaaS开发SaaS安全SaaS
点赞
收藏

51CTO技术栈公众号