您的位置:首页 > 移动开发

深入浅出了解happens-before原则

2019-05-24 18:03 1741 查看

看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的计算资源。在总线看来,对于存储的读写操作就是串行的,是按照一定顺序的。这也就是为什么一个内存屏障能够跨越处理器去限制读写、去完成通信。

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

您可能感兴趣的文章:

内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: