深入浅出了解happens-before原则

看Java内存模型(JMM, Java Memory Model)时,总有一个困惑。关于线程、主存(main memory)、工作内存(working memory),我都能找到实际映射的硬件:线程可能对应着一个内核线程,主存对应着内存,而工作内存则涵盖了写缓冲区、缓存(cache)、寄存器等一系列为了提高数据存取效率的暂存区域。但是,一提到happens-before原则,就让人有点“丈二和尚摸不着头脑”。这个涵盖了整个JMM中可见性原则的规则,究竟如何理解,把我个人一些理解记录下来。

两个操作间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行。happens-before仅仅要求前一个操作对后一个操作可见。

这个说法我先后在好几本书中都看到过。也就是说,happens-before原则和一般意义上的时间先后是不同的。那究竟是什么呢?一步步来看。

顺序一致性内存模型

我们先来看一个理想化的模型:顺序一致性(Sequentially Consistent)内存模型。在这个模型里,所有操作按程序的顺序来执行,并且每一个操作都是原子的,且立即对所有线程可见。 
 
这个系统中同一时间只有一个线程能读或写内存。也就是说,这个系统里的每两个指令之间,都严格按执行的先后,具有着happens-before关系。所有的线程,都能够看到一致的全局指令执行视图。如果将总线1看做是线程和内存之间的通道,那么顺序一致性模型就相当于在所有读/写内存的操作时,锁住总线。

特别注意一点,顺序一致性模型,不代表多线程没有同步问题,只是每个操作之间不存在同步问题,如果你的操作是多个操作的集合体,照样不能安全工作。图中所示的是常见的自增操作,两个线程都有同样的执行视图:1->2->3->4->5->6。然而,线程A的写结果,依然被线程B所覆盖了。A线程读写固然对B线程立即可见,但是由于5/6的写操作对于内存的影响依赖于1/2的读操作,所以对于多线程仍然存在问题。

显然,顺序一致性模型是一种牺牲并行度、换取多线程对共享内存的可见性的一种理想模型。从JMM实现volatile以及synchronized的内存语义的方式,正是锁住总线或者说锁住线程自身存储(指working memory)。

Java内存模型

关于Java内存模型的书籍文章,汗牛充栋,想必大家也都有自己的理解。那就仅仅由上面的顺序一致性模型来引出JMM,看看具体区别在哪。

可以看出,工作内存是一个明显区别于顺序一致性内存模型的地方。事实上,造成可见性问题的根源之一,就在于这个工作内存(强调一下,包括缓存、写缓冲和寄存器等等)。工作内存使得每个线程都有了自己的私有存储,大部分时间对数据的存取工作都在这个区域完成。但是我们写一个数据,是直到数据写到主存中才算真正完成。实际上每个线程维护了一个副本,所有线程都在自己的工作内存中不断地读/写一个共享内存中的数据的副本。单线程情况下,这个副本不会造成任何问题;但一旦到多线程,有一个线程将变量写到主存,其他线程却不知道,其他线程的副本就都过期。比如,由于工作内存的存在,程序员写的一段代码,写一个普通的共享变量,其可能先被写到缓冲区,那指令完成的时间就被推迟了,实际表现也就是我们常说的“指令重排序”(这实际上是内存模型层面的重排序,重排序还可能是编译器、机器指令层级上的乱序)。

因此,在Java内存模型中,每个线程不再像顺序一致性模型中那样有确定的指令执行视图,一个指令可能被重排了。从一个线程的角度看,其他线程(甚至是这个线程本身)执行的指令顺序有多种可能性,也就是说,一个线程的执行结果对其他线程的可见性无法保证。

总结一下导致可见性问题的原因:

1.数据的写无法及时通知到别的线程,如写缓冲区的引入
2.线程不能及时读到其他线程对共享变量的修改,如缓存的使用
3.各种层级上对指令的重排序,导致指令执行的顺序无法确定

所以要解决可见性问题,本质是要让线程对共享变量的修改,及时同步到其他线程。我们所使用的硬件架构下,不具备顺序一致性内存模型的全局一致的指令执行顺序,讨论指令执行的时间先后并不存在意义或者说根本没办法确定时间上的先后。可以看看下面程序,每个线程中的flag副本会在多久后被更新呢?答案是:无法确定,看线程何时刷新自己的工作内存。

public class testVisibility {
  public static boolean flag = false;

  public static void main(String[] args) {
    List<Thread> thdList = new ArrayList<Thread>();
    for(int i = 0; i < 10; i++) {
      Thread t = new Thread(new Runnable(){
        public void run() {
          while (true) {
            if (flag) {
              // 多运行几次,可能并不会打印出来也可能会打印出来
              // 如果不打印,则表示Thread看到的仍然是工作内存中的flag
              // 可以尝试将flag变成volatile再运行几次看看
                 System.out.println(Thread.currentThread().getId() + " is true now");
            }
          }
        }
      });
      t.start();
      thdList.add(t);
    }

    flag = true;
    System.out.println("set flag true");

    // 等待线程执行完毕
    try {
      for (Thread t : thdList) {
        t.join();
      }
    } catch (Exception e) {

    }
  }
}

那么既然我们无法讨论指令执行的先后,也不需要讨论,我们实际只想知道某线程的操作对另一个线程是否可见,于是就规定了happens-before这个可见性原则,程序员可以基于这个原则进行可见性的判断。

volatile变量

volatile就是一个践行happens-before的关键字。看以下对volatile的描述,就不难知道,happens-before指的是线程接收其他线程修改共享变量的消息与该线程读取共享变量的先后关系。大家可以再细想一下,如果没有happens-before原则,岂不是相当于一个线程读取自己的共享变量副本时,其他线程修改这个变量的消息还没有同步过来?这就是可见性问题。

volatile变量规则:对一个volatile的写,happens-before于任意后续对这个volatile变量的读。
线程A写一个volatile变量,实质上是线程A向接下来要获取这个锁的某个线程发出了(线程A对共享变量修改的)消息。
线程B读一个volatile变量,实质上是线程B接收了之前某个线程发出的(对共享变量所做修改的)消息。
线程A写一个volatile变量,随后线程B读这个变量,这个过程实质上是线程A通过主内存向线程B发送消息。

其实仔细看看volatile的实现方式,实际上就是限制了重排序的范围——加入内存屏障(Memory Barrier or Memory Fence)。也即是说,允许指令执行的时间先后顺序在一定范围内发生变化,而这个范围就是根据happens-before原则来规定。内存屏障概括起来有两个功能:

1.使写缓冲区的内容刷新到内存,保证对其他线程/CPU可见
2.禁止读写操作的越过内存屏障进行重排序

而这上述功能组合起来,就完成上面所说的happens-before所表达的线程通信过程。

每个volatile写操作的前面插入一个StoreStore屏障
每个volatile写操作的后面插入一个StoreLoad屏障
每个volatile读操作的后面插入一个LoadLoad屏障
每个volatile读操作的后面插入一个LoadStore屏障

关于内存屏障的种类,这里不是研究的重点。一直困扰我的是,在多处理器系统下,这个屏障如何能跨越处理器来阻止操作执行的顺序呢?比如下面的读写操作:

public static volatile int race = 0;
// Thread A
public static void save(int src) {
  race = src;
}
// Thread B
public static int load() {
  return race;
}

这就要提到从操作系统到硬件层面的观念转换,可以参看总线事务(Bus transaction)的概念。当CPU要与内存进行数据交换的时候,实际上总线会同步数据交换操作,同一时刻只能有一个CPU进行读/写内存,所以我们所看到的多处理器并行,并行的是CPU的计算资源。在总线看来,对于存储的读写操作就是串行的,是按照一定顺序的。这也就是为什么一个内存屏障能够跨越处理器去限制读写、去完成通信。

以上就是本文的全部内容,希望对大家的学习有所帮助,也希望大家多多支持我们。

(0)

相关推荐

  • Java内存之happens-before和重排序

    happens-before原则规则: 程序次序规则:一个线程内,按照代码顺序,书写在前面的操作先行发生于书写在后面的操作: 锁定规则:一个unLock操作先行发生于后面对同一个锁的lock操作: volatile变量规则:对一个变量的写操作先行发生于后面对这个变量的读操作: 传递规则:如果操作A先行发生于操作B,而操作B又先行发生于操作C,则可以得出操作A先行发生于操作C: 线程启动规则:Thread对象的start()方法先行发生于此线程的每个一个动作: 线程中断规则:对线程interrup

  • 浅谈Java内存模型之happens-before

    happens-before原则非常重要,它是判断数据是否存在竞争.线程是否安全的主要依据,依靠这个原则,我们解决在并发环境下两操作之间是否可能存在冲突的所有问题.下面我们就一个简单的例子稍微了解下happens-before : i = 1;       //线程A执行 j = i ;      //线程B执行 j 是否等于1呢?假定线程A的操作(i = 1)happens-before线程B的操作(j = i),那么可以确定线程B执行后j = 1 一定成立,如果他们不存在happens-be

  • 深入浅出解析正则表达式-替换原则

    下面通过图文并茂的方式给大家介绍正则表达式替换原则,具体内容请看下文. 一.开篇 最近经常用到替换的东西所以就出来整理下,这里要分享的是正则表达式里面的替换原则,首先要声明的是这里提及到的替换原则是.NET里面的正则表达式的替换原则.先看一下替换的定义替换是只能在替换模式中识别的语言元素. 它们使用正则表达式模式定义全部或部分用于替换输入字符串中的匹配文本的文本. 替换模式可以包含一个或多个替换以及本文字符.其实个人总结的替换的大致是这样的,替换的内容永远都是原文本的内容,通过正则表达式匹配出来

  • 深入浅出了解happens-before原则

    看Java内存模型(JMM, Java Memory Model)时,总有一个困惑.关于线程.主存(main memory).工作内存(working memory),我都能找到实际映射的硬件:线程可能对应着一个内核线程,主存对应着内存,而工作内存则涵盖了写缓冲区.缓存(cache).寄存器等一系列为了提高数据存取效率的暂存区域.但是,一提到happens-before原则,就让人有点"丈二和尚摸不着头脑".这个涵盖了整个JMM中可见性原则的规则,究竟如何理解,把我个人一些理解记录下来

  • 10个Java程序员熟悉的面向对象设计原则

    面向对象设计原则是OOPS编程的核心, 但我见过的大多数Java程序员热心于像Singleton (单例) . Decorator(装饰器).Observer(观察者) 等设计模式,而没有把足够多的注意力放在学习面向对象的分析和设计上面.学习面向对象编程像"抽象"."封装"."多态"."继承" 等基础知识是重要的,但同时为了创建简洁.模块化的设计,了解这些设计原则也同等重要.我经常看到不同经验水平的java程序员,他们有的不知

  • 浅析正则表达式-替换原则(.NET) 图文

    一.开篇 最近经常用到替换的东西所以就出来整理下,这里要分享的是正则表达式里面的替换原则,首先要声明的是这里提及到的替换原则是.NET里面的正则表达式的替换原则.先看一下替换的定义替换是只能在替换模式中识别的语言元素. 它们使用正则表达式模式定义全部或部分用于替换输入字符串中的匹配文本的文本. 替换模式可以包含一个或多个替换以及本文字符.其实个人总结的替换的大致是这样的,替换的内容永远都是原文本的内容,通过正则表达式匹配出来文本,来通过组名或者组号来进行对原文本的替换,替换的位置是用正则表达式匹

  • ASP编码必备的8条原则

    ASP是Active Server Page的缩写,意为"动态服务器页面".ASP是微软公司开发的代替CGI脚本程序的一种应用,它可以与数据库和其它程序进行交互,是一种简单.方便的编程工具.在这里仅就代码优化进行一些简单讨论. 1.声明VBScript变量 在ASP中,对vbscript提供了强劲的支持,能够无缝集成vbscript的函数.方法,这样给扩展ASP的现有功能提供了很大便利.由于ASP中已经模糊了变量类型的概念,所以,在进行ASP与vbscript交互的过程中,很多程序员也

  • Mysql中基本语句优化的十个原则小结

    前言 在数据库的应用中,程序员们通过不断的实践总结了很多经验,这些经验是一些普遍的适用规则,每一个程序员都应该了解并记住它们,在构造sql时,养成良好的习惯,下面话不多说,来看看详细的介绍: mysql基本语句优化原则 一.尽量避免在列上运算,这样会导致索引失效 select * from t where YEAR(d) >= 2011; 优化为 select * from t where d >='2011-0101' 二.使用 JOIN 时,应该用小结果集驱动大结果集,同时把复杂的 JOI

  • 从美的原则谈 WWW 网页上的艺术表现

    一:美的原则 什么是美呢? 美的事物应该具备什么条件呢? 我们根据人类美感的共通性可以定出十个美的原则:连续.渐变.对称.对比.比例.平衡.调和.律动.统一.完整. 在讨论美的原则之前,必须先了解「单位形」的意义.单位形是在相同或相似的形象组合中最基本的单位元素.单位形可以单独重复排列,或组成「单位形组合」,再以「单位形组合」为基础,作有规律的反复排列.下列图例说明以一个「单位形」配置构成八种「单位形组合」. 以一个「单位形」配置构成八种「单位形组合」图例 每一个「单位形组合」又可以重复以线状﹝

  • 实例讲解Java设计模式编程中的OCP开闭原则

    定义:一个软件实体如类.模块和函数应该对扩展开放,对修改关闭. 问题由来:在软件的生命周期内,因为变化.升级和维护等原因需要对软件原有代码进行修改时,可能会给旧代码中引入错误,也可能会使我们不得不对整个功能进行重构,并且需要原有代码经过重新测试. 解决方案:当软件需要变化时,尽量通过扩展软件实体的行为来实现变化,而不是通过修改已有的代码来实现变化.          开闭原则是面向对象设计中最基础的设计原则,它指导我们如何建立稳定灵活的系统.开闭原则可能是设计模式六项原则中定义最模糊的一个了,它

  • 举例说明Java设计模式编程中ISP接口隔离原则的使用

    Interface Segregation Principle,ISP接口隔离原则主张使用多个专门的接口比使用单一的总接口要好. 一个类对另外一个类的依赖性应当是建立在最小的接口上的. 一个接口代表一个角色,不应当将不同的角色都交给一个接口.没有关系的接口合并在一起,形成一个臃肿的大接口,这是对角色和接口的污染. "不应该强迫客户依赖于它们不用的方法.接口属于客户,不属于它所在的类层次结构."这个说得很明白了,再通俗点说,不要强迫客户使用它们不用的方法,如果强迫用户使用它们不使用的方法

  • 简单理解遵循接口隔离原则的Java设计模式编程

    定义:客户端不应该依赖它不需要的接口:一个类对另一个类的依赖应该建立在最小的接口上. 问题由来:类A通过接口I依赖类B,类C通过接口I依赖类D,如果接口I对于类A和类B来说不是最小接口,则类B和类D必须去实现他们不需要的方法. 解决方案:将臃肿的接口I拆分为独立的几个接口,类A和类C分别与他们需要的接口建立依赖关系.也就是采用接口隔离原则. 举例来说明接口隔离原则: 这个图的意思是:类A依赖接口I中的方法1.方法2.方法3,类B是对类A依赖的实现.类C依赖接口I中的方法1.方法4.方法5,类D是

随机推荐