前言

解决并发编程中的可见性和有序性问题最直接的方法就是禁用CPU缓存和编译器的优化。但是,禁用这两者又会影响程序性能。于是我们要做的是按需禁用CPU缓存和编译器的优化

如何按需禁用CPU缓存和编译器的优化就需要提到Java内存模型。Java内存模型是一个复杂的规范。其中最为重要的便是Happens-Before规则。下面我们先介绍如何利用Happens-Before规则解决可见性和有序性问题,然后我们再扩展简单介绍下Java内存模型以及我们前篇文章提到的重排序概念。

volatile

在前一篇文章介绍编译优化带来的有序性问题时,给出的一个解决办法时将共享变量使用volatile关键字修饰。volatile关键字的作用可以简单理解为①禁用重排序,保证程序的有序性;②禁用缓存,保证程序的可见性。

volatile关键字不是Java语言中的特产,C语言中也有,其最原始的意义就是禁用CPU缓存,使得每次访问均需要直接从内存中读写

如果我们声明一个volatile变量,那么也就会让编译器不能从CPU缓存中去读取这个变量,而必须从内存中读取。

class VolatileExample {
  int x = 0;        // 1
  volatile boolean v = false; //2
  public void writer() {    //3
    x = 42;
    v = true;
  }
  public void reader() {    //4
    if (v == true) {
      // 这里 x 会是多少呢?
    }
  }
}
在这段代码中,假设线程A执行了3即writer()方法,设置了x=42和v=true。线程B执行了4即reader()方法,线程B可以看见线程A设置的v为true,那么B读到的x值会是多少呢?(想一想再点击我)

这要分Java版本来说,在1.5之前,会出现x=0的情况。
由于可见性问题,线程A修改的x可能存储在CPU缓存中对线程B是不可见的,于是线程B获取到的x为0。
在Java1.5之后,线程B获取到的x一定就是42。
这是因为Java内存模型对volatile语义进行了增强。增强体现在Java内存模型中的Happens-Before规则上。

Happens-Before规则

Happends-Before规则表达的是:前面一个操作的结果对之后操作是可见的,描述的是两个操作的内存可见性。

Happens-Before约束了编译器的优化行为,虽允许编译器优化,但是要求编译器遵循一定的Happens-Before规则进行优化。

Happens-Before规则包括:

  • 程序顺序规则

    在一个线程中,前面的操作Happens-Before于后续的任意操作。

  • volatile变量规则

    对volatile变量的写操作相对于之后对该volatile变量的读操作是可见的。(这个语义可等价适用于原子变量)

    对volatile变量的写操作 Happens-Before 对该volatile变量的读操作

  • 传递性

    如果操作A Happens-Before 操作B并且操作B Happens-Before 操作C, 那么操作A Happens-Bofore 操作C。

利用程序顺序规则、volatile变量规则、传递性规则说明例子

根据程序顺序规则,在一个线程中,之前的操作是Happens-Before后续的操作,所以x=42; Happens-Before v=true;;根据volatile变量规则,对volatile变量的写操作相对于之后对该volatile变量的读操作是可见的,于是写变量v=ture;Happens-Before读变量v==true;;根据传递性,得出x=42;Happens-Before读变量v==true;于是,最终读出的x值会是42。

  • 管程中的锁规则

    对同一个锁的解锁 Happens-Before 后续对这个锁的加锁。

    (管程:是一种同步原语,在Java中就是指synchronized。)

  • 线程启动规则

    线程的启动操作(即Thread.start()) Happens-Before 该线程的第一个操作。

    主线程A启动子线程B,那么子线程B能够看到线程A在启动B之前的任意操作。

    Thread B = new Thread(()->{
      // 主线程调用 B.start() 之前
      // 所有对共享变量的修改,此处皆可见
      // 此例中,var==77
    });
    // 此处对共享变量 var 修改
    var = 77;
    // 主线程启动子线程
    B.start();
  • 线程结束规则

    线程的最后一个操作 Happens-Before 它的终止事件。

    主线程A等待子线程B完成(A调用B.join())。当B完成之后(主线程A中的join()返回),主线程A可以看见子线程的操作。看到针对的是对共享变量。

    Thread B = new Thread(()->{
      // 此处对共享变量 var 修改
      var = 66;
    });
    // 例如此处对共享变量修改,
    // 则这个修改结果对线程 B 可见
    // 主线程启动子线程
    B.start();
    B.join()
    // 子线程所有对共享变量的修改
    // 在主线程调用 B.join() 之后皆可见
    // 此例中,var==66
  • 中断规则

    线程对其他线程的中断操作 Happens-Before被中断线程所收到中断事件。

    一个线程在另一个线程上调用interrupt,必须在被中断线程检测到interrupt调用之前执行。(被中断线程的InterruptedException异常,或者第三个线程针对被中断线程的Thread.interrupted或者Thread.isInterrupted调用)

  • 析构器规则

    构造器中的最后一个操作 Happens-Before 析构器的第一个操作

    或者说,对象的构造器必须在启动该对象的析构器之前执行完成。

需要注意,A操作 Happens-Before B操作,但并不意味着A操作必须要在B操作之前执行。

Happens-Before表达的是前一个操作执行后的结果是对后续一个操作是可见的,且前一个操作按顺序排在第二个操作之前。

Java内存模型的抽象

共享变量可指代存储与堆内存中的实例域、静态域和数组元素,共享变量是线程间共享的。局部变量、方法定义参数和异常处理器参数不会在线程之间共享,所以,它们不会有内存可见性问题。

Java线程之间的通信由Java内存模型(Java Memory Model, JMM)控制,JMM决定了一个线程对共享变量的写入何时对另一个线程可见。

从抽象角度看,JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存存储了该线程以读/写共享变量的副本。

本地内存是JMM的一个抽象概念,实际并不存在,它主要是指代缓存、写缓冲区、寄存器以及其他的硬件和编译器优化。

Java内存模型的抽象示意图如下:(图来自程晓明的深入理解Java内存模型)

从上图来看,如果线程A和线程B要进行通信,需要进行两步:

  1. 线程A将本地内存A中更新过的共享变量刷新到主内存中
  2. 线程B从主内存中去读取线程A更新到主内存的共享变量

线程A和B的通信过过了主内存,JMM通过控制主内存和每个线程的本地内存之间的交互,来为Java程序员提供内存可见性的保证。

重排序

重排序分类

在执行程序时,为了提高性能,编译器和处理器常常会对指令做重排序处理。加上前面提到的编译器优化,重排序可以分为三种类型:

  1. 编译器优化的重排序。编译器在不改变单线程程序语义的前提下,可以重新安排语句的执行顺序。即在单线程中,重排序指令后执行的结果与未重排序执行的结果一致,那么就可以允许这种优化。
  2. 指令级并行的重排序。现代处理器采用了指令级并行技术(Instruction-Level Parallelism, ILP)来将多条指令重叠执行。如果不存在数据依赖性,处理器便可以改变语句对应机器指令的执行顺序。
  3. 内存系统的重排序。由于处理器使用缓存和读/写缓冲区,这使得加载和存储操作看上去可能是在乱序执行。因为缓存可能会改变将写入变量提交到主内存的次序。

as-if-serial属性:在单线程情况下,虽然有可能不是顺序执行,但是经过重排序的执行结果要和顺序执行的结果一致。 编译器和处理器需要保证程序能够遵守as-if-serial属性。

数据依赖性:如果两个操作访问同一个变量,且这两个操作中有一个为写操作,此时这两个操作之间就存在数据依赖性。编译器和处理器不能对“存在数据依赖关系的两个操作”执行重排序。

从Java源代码到最终执行的指令序列,会经历下面的三种重排序:(图来自程晓明的深入理解Java内存模型)

第一个属于编译器重排序,第二三个属于处理器重排序。这些重排序都可能会导致夺多线程出现内存可见性问题。

针对编译器的重排序,JMM会有编译器重排序规则禁止特定类型的编译器重排序,不会禁止所有类型的编译器重排序。

针对处理器重排序,JMM的处理器重排序规则会要求Java编译器在生成指令序列时,插入特定类型的内存屏障(memory barriers)指令,来禁止特定类型的处理器重排序。

JMM属于语言级的内存模型,它确保在不同的编译器和处理器平台上,通过禁止特定类型的编译器重排序和处理器重排序,为程序员提供一致的内存可见性保证。

内存屏障

处理器架构提供了一些特殊的指令(称为内存屏障)用来在需要共享数据时实现存储协调。JMM使编译器在适当的位置插入内存屏障指令来禁止特定类型的处理器重排序。

内存屏障指令可分为下列四类:

屏障类型 指令示例 说明
LoadLoad Barriers Load1;LoadLoad;Load2 确保Load1数据的装载,之前于Load2及所有后续装载指令的装载。
StoreStore Barriers Store1;StoreStore;Store2 确保Store1数据对其他处理器可见(刷新到内存),之前于Store2及所有后续存储指令的存储。
LoadStore Barriers Load1;LoadStore;Store2 确保Load1数据装载,之前于Store2及所有后续的存储指令刷新到内存。
StoreLoad Barriers Store1;StoreLoad;Load2 确保Store1数据对其他处理器变得可见(刷新到内存),之前于Load2及所有后续装载指令的装载。StoreLoad Barriers会使该屏障之前的所有内存访问指令(存储和装载指令)完成之后,才执行该屏障之后的内存访问指令。

JMM、Happens-Before和重排序规则之间的关系

(图来自程晓明的深入理解Java内存模型)

看图中的概括,一个Happens-Before规则对应于一个或者多个编译器和处理器重排序规则。

对Java程序员来说,只需要熟悉Happens-Before规则,就可以使程序避免遭受内存可见性问题,并且不用为了理解JMM提供的内存可见性保证而学习复杂的重排序规则以及这些规则的具体实现。

再谈volatile

为了不打乱前面的行文思路,于是就在后面补充关于volatile的知识。

volatile变量是Java语言提供的一种较弱的同步机制,用来确保将变量的更新操作都通知到其他线程。将变量声明为volatile类型后,编译器与运行时都会注意到这个变量是共享的,不会将该变量上的操作与其他内存操作一起重排序,即我们前面所说的保证程序有序性。volatile变量不会被缓存在寄存器或者CPU缓存中对其他处理器不可见,读取volatile类型的变量时总会返回最新写入值,即我们前面说的保证程序可见性。然而,频繁地访问 volatile 字段也会因为不断地强制刷新缓存而严重影响程序的性能。

从内存可见性角度来看,写入volatile变量相当于退出同步代码块,而读取volatile变量相当于进入同步代码块。然而,并不建议过度依赖volatile变量提供的可见性。如果在代码中依赖volatile变量来控制状态的可见性,通常比使用锁的代码更脆弱也更加难以理解。(下一篇文章将介绍Java并发中的同步机制)

仅当volatile变量能简化代码的实现以及对同步策略的验证时,才应该使用。

volatile变量的正确使用方式包括:确保自身状态的可见性,确保它们所引用对象的状态的可见性以及标识一些重要的程序生命周期事件的发生(例如,初始化或者关闭)。

下面的例子是volatile变量的一种典型用法:检查某个状态标记以判断是否退出循环。

volatile boolean asleep;
...
while(!asleep)
countSomeSheep();

为了能使这个程序正确执行,alseep必须要为volatile变量。否则,当asleep被另外一个线程修改时,执行判断的线程却发现不了。后面也会讲用锁操作也可以确保asleep更新操作的可见性,但是这将会使代码变得复杂。

需要注意,尽管volatile变量经常用于表示某种状态信息如某个操作完成、发生中断或者标记,但是volatile的语义是不足以确保递增操作(count++)的原子性 ,除非确保只有一个线程对变量执行写操作。后面将要介绍的同步机制中的加锁机制既可以确保可见性又可以确保原子性,而volatile变量只能确保可见性。

小结

行文思路总体看起来有点乱ε(┬┬﹏┬┬)3,不过这也不是有意为之。本打算是重点介绍Happens-Before规则,然后稍微介绍一点Java内存模型。可奈何中途瞥见了一个网友力推程晓明的深入理解Java内存模型,于是就去拜读了一遍。看完发现还是要补充介绍一些东西,于是补着补着就乱了。唉,也怪我这深入浅出介绍知识的能力不够,各位看官择其所需看看就好。

参考:

[1]极客时间专栏王宝令《Java并发编程实战》

[2]Brian Goetz.Tim Peierls. et al.Java并发编程实战[M].北京:机械工业出版社,2016

[3]程晓明.深入理解Java内存模型.https://www.infoq.cn/article/java_memory_model

【Java并发基础】Java内存模型解决有序性和可见性的更多相关文章

  1. Java并发编程、内存模型与Volatile

    http://www.importnew.com/24082.html  volatile关键字 http://www.importnew.com/16142.html  ConcurrentHash ...

  2. 【Java并发基础】加锁机制解决原子性问题

    前言 原子性指一个或多个操作在CPU执行的过程不被中断的特性.前面提到原子性问题产生的源头是线程切换,而线程切换依赖于CPU中断.于是得出,禁用CPU中断就可以禁止线程切换从而解决原子性问题.但是这种 ...

  3. Java并发编程-Java内存模型

    JVM内存结构与Java内存模型经常会混淆在一起,本文将对Java内存模型进行详细说明,并解释Java内存模型在线程通信方面起到的作用. 我们常说的JVM内存模式指的是JVM的内存分区:而Java内存 ...

  4. Java 并发基础

    Java 并发基础 标签 : Java基础 线程简述 线程是进程的执行部分,用来完成一定的任务; 线程拥有自己的堆栈,程序计数器和自己的局部变量,但不拥有系统资源, 他与其他线程共享父进程的共享资源及 ...

  5. java并发基础(一)

    最近在看<java并发编程实战>,希望自己有毅力把它读完. 线程本身有很多优势,比如可以发挥多处理器的强大能力.建模更加简单.简化异步事件的处理.使用户界面的相应更加灵敏,但是更多的需要程 ...

  6. 【Java并发基础】并发编程领域的三个问题:分工、同步和互斥

    前言 可以将Java并发编程抽象为三个核心问题:分工.同步和互斥. 这三个问题的产生源自对性能的需求.最初时,为提高计算机的效率,当IO在等待时不让CPU空闲,于是就出现了分时操作系统也就出现了并发. ...

  7. 面试官:小伙子,你给我讲一下java类加载机制和内存模型吧

    类加载机制 虚拟机把描述类的数据从 Class文件加载到内存,并对数据进行校验.转换解析和初始化,最终形成可以被虚拟机直接使用的java类型,这就是虚拟机的类加载机制. 类的生命周期 加载(Loadi ...

  8. java并发基础(二)

    <java并发编程实战>终于读完4-7章了,感触很深,但是有些东西还没有吃透,先把已经理解的整理一下.java并发基础(一)是对前3章的总结.这里总结一下第4.5章的东西. 一.java监 ...

  9. java中JVM虚拟机内存模型详细说明

    java中JVM虚拟机内存模型详细说明 2012-12-12 18:36:03|  分类: JAVA |  标签:java  jvm  堆内存  虚拟机  |举报|字号 订阅     JVM的内部结构 ...

随机推荐

  1. Python--day37--多进程

    1,创建多进程(父进程和子进程) import os import time #多进程都要导入multiprocessing from multiprocessing import Process d ...

  2. java 合并流(SequenceInputStream)

    需要两个源文件,还有输出的目标文件 SequenceInputStream: 将两个文件的内容合并成一个文件 该类提供的方法: SequenceInputStream(InputStream s1, ...

  3. 2019QLU.ACM集训队暑假训练须知

    1.每场比赛都要认认真真参与并及时记录: 2.每个队员必须做一个单独的博客页面存放自己队伍或者个人的比赛结果和补题计划: 3.比赛记录参考样式:[1]dny[2]ECNU 4.每场比赛结束都会安排一支 ...

  4. 备战省赛组队训练赛第七场(UPC)

    传送门   日文题解:戳这里

  5. 2019-1-29-UWP-IRandomAccessStream-与-Stream-互转

    title author date CreateTime categories UWP IRandomAccessStream 与 Stream 互转 lindexi 2019-01-29 16:33 ...

  6. 读<大道至简>--软件工程实践者的思想有感

    初闻其名,<大道至简>,大多人都会觉得这是一本满腹人生哲理的书籍,作者洋洋洒洒的谈论大道理,其实不然,作者以古典文化为引,以作者的所思所想为线,启蒙了我作为一个软件工程初学者的实践思想. ...

  7. java 嵌入式数据库H2

    H2作为一个嵌入型的数据库,它最大的好处就是可以嵌入到我们的Web应用中,和我们的Web应用绑定在一起,成为我们Web应用的一部分.下面来演示一下如何将H2数据库嵌入到我们的Web应用中. 一.搭建测 ...

  8. 国内免费CMS系统大全

    一.ASP类的CMS程序 1.动易CMS 官方网址:http://www.powereasy.net/(可免费下载) 特点:完全免费,ACCESS数据库,主要功能模块:文章频道.下载频道.图片频道.留 ...

  9. 虚拟机中linux系统无法打开原保存的显示器配置解决方法

    刚刚学习Linux,于是在虚拟机上装了一个redhat,有一次关机的时候,很长一段时间都没有关闭,似乎是死机了,于是我就用任务管理器给强制关闭了.然后再次开启系统就出现了这个问题,如下图所示: 当时我 ...

  10. eclipse中部署web项目时报错java.lang.ClassNotFoundException: org.springframework.web.context.ContextLoaderListener的解决方法

    解决方案: 1.右键点击项目--选择Properties,选择Deployment Assembly,在右边点击Add按钮,在弹出的窗口中选择Java Build Path Entries 2.点击N ...