从JMM层面说说Java并发(一) - 基础概念

为什么要理解JMM(Java Memory Model)模型?在了解JMM之前,我们谈论Java并发的一些问题,如synchronized关键词、volatile关键词等问题时,总是在很高层的抽象解释它们的机制,却无法解释其中的原理,甚至有一些谬误。特别是可见性的部分,如果不了解JMM,是根本无法解释的。JMM是Java语言级别的内存模型,也是JVM的实现标准,在这层面解释并发问题,能保证正确性。

本文不会详细介绍JMM,如果你想好好学习JMM,可以看看这个系列文章。在这里,我只会说一些我的理解。

从硬件和编译器说起

多核CPU的缓存之间只对一致性提供最小保证

换言之,多核CPU的缓存之间是没有强一致性的。

首先,什么是CPU的缓存?简单介绍下。相对于内存,CPU的速度是极高的,如果CPU需要存取数据时都直接与内存打交道,在存取过程中,CPU将一直空闲,这是一种极大的浪费,妈妈说,浪费是不好的,所以,现代的CPU里都有很多寄存器,多级cache,他们比内存的存取速度高多了。在运算时,CPU会从把内存的数值拷贝到缓存,然后直接读写缓存,在必要时,才将缓存中的结果写入内存。在多核CPU中,每一核拥有自己的独立的缓存。它们之间是不会通信的。

想要确保每个处理器都能在任意时刻知道其他处理器正在进行的工作,将需要非常大的开销,而且在大多数时间,这种信息都是不必要的。因此处理器会放宽存储一致性的保证,以换取性能的提升。在不同的处理器架构中,缓存一致性的级别是不同的,其中一部分只提供最小的保证,即:允许不同的CPU在任意时刻从同一存储位置看到不同的值。

重排序

另一点和并发相关的编译器特点是:重排序。顾名思义:计算机在执行代码的时候,有重新排列操作的自由,它不保证执行顺序和代码语句本身的顺序一致。

在执行程序时为了提高性能,编译器和处理器常常会对指令做重排序。重排序分三种类型:

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

这两点特性,使得程序执行时会有可能产生一些非常怪异的行为,比如这一程序:

public class PossibleReordering {
    static int x = 0, y = 0;
    static int a = 0, b = 0;

    public static void main(String[] args) throws InterruptedException {
        Thread one = new Thread(new Runnable() {
            public void run() {
                a = 1;
                x = b;
            }
        });

        Thread other = new Thread(new Runnable() {
            public void run() {
                b = 1;
                y = a;
            }
        });

        one.start(); other.start();
        one.join(); other.join();
        System.out.prinln("(" + x + "," + y + ")");
    }
}

在没有正确同步的情况下,这一段程序会输出什么结果呢?答案是(1,0),(0,1),(1,1),(0,0)都有可能!

前三种结果都容易解释,不过是两个线程执行的先后问题,但为什么(0,0)这个结果竟然也能出现呢?这就涉及前面所说的两点知识。首先,详细说说出现第四个结果时,CPU和内存的执行情况:

Alt

假设线程one和线程other分别在处理器A和处理器B上进行:

  1. 所以操作执行前,此时内存里a = b = x = y = 0
  2. 处理器A将a = 1操作写入自己的 写缓冲区 ,但没有写入 内存
  3. 处理器B同理执行b = 1操作,此时内存里a, b, x, y变量依然是0。
  4. 处理器A执行x = b,将内存中b的值(0)赋给x,并将x的值写入自己的缓冲区。
  5. 处理器B同理。
  6. 此时处理器A,B分别将自己缓冲区中的数值写入内存,也就是四个0。

对于CPU来说,即使它是按顺序执行语句的,但由于将缓存刷新到主内存的时机(第6步)是不确定的,所以对于内存来说,它无法知道这些语句真正的执行顺序。也就是说,从线程B的角度看,线程A中的赋值操作可能以相反的次序执行!

这样的问题可总结为 可见性问题。也就是一个线程修改了某一变量的值,但另一线程却未必能观测到。

在缺乏足够的同步下,两个线程的执行有可能产生非常诡异的结果。内存级别的重排序会使程序的行为变得不可预测。如果程序员总是需要从缓存/内存级别思考并发问题,这将是灾难。

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

Java内存模型提供的抽象

线程和主内存之间的抽象关系

JMM定义了线程和主内存之间的抽象关系:线程之间的共享变量存储在主内存(main memory)中,每个线程都有一个私有的本地内存(local memory),本地内存中存储了该线程以读/写共享变量的副本。本地内存是JMM的一个抽象概念,并不真实存在。它涵盖了缓存,写缓冲区,寄存器以及其他的硬件和编译器优化。Java内存模型的抽象示意图如下:

Alt

如上图所示,本地内存A和B有主内存中共享变量x的副本。假设初始时,这三个内存中的x值都为0。线程A在执行时,把更新后的x值(假设值为1)临时存放在自己的本地内存A中。当线程A和线程B需要通信时,线程A首先会把自己本地内存中修改后的x值刷新到主内存中,此时主内存中的x值变为了1。随后,线程B到主内存中去读取线程A更新后的x值,此时线程B的本地内存的x值也变为了1。

从整体来看,这两个步骤实质上是线程A在向线程B发送消息,而且这个通信过程必须要经过主内存。JMM通过控制主内存与每个线程的本地内存之间的交互,来为java程序员提供内存可见性保证。

happens-before机制

happens-before是JMM提供的内存可见性保证,它使程序员不用学习复杂的重排序规则,而能断定两个操作的之间的可见性。它的定义就是一句简单的话:

在JMM中,如果一个操作执行的结果需要对另一个操作 可见,那么这两个操作之间必须要存在happens-before关系。

注意,这里是用的词是可见,两个操作之间具有happens-before关系,并不意味着前一个操作必须要在后一个操作之前执行!

happens-before规则共8条,下面只列举与程序员密切相关的happens-before规则:

  • 程序顺序规则:一个线程中的每个操作,happens- before 于该线程中的任意后续操作。
  • 监视器锁规则:对一个监视器锁的解锁,happens- before 于随后对这个监视器锁的加锁。
  • 线程结束规则:线程中的任何操作都必须在其他线程检测到该线程已经结束(或者从Thread.join成功返回)之前执行。
  • volatile变量规则:对一个volatile域的写,happens- before 于任意后续对这个volatile域的读。
  • 传递性:如果A happens- before B,且B happens- before C,那么A happens- before C。

也就是说,符合happens-before的两个操作,编译器会做特殊处理,使得第二条操作必然能看到第一条操作的结果。

理解Happen-before机制

初看happens-before规则,确实是一头雾水。但它是我们思考并发问题时最简单而有力的武器。

当习惯用happens-before关系思考并发问题时,会发现我们做的不过是简单逻辑推理和演绎:我们知道某两条操作符合8条hb规则中的的一条,所以它们有hb关系,然后又通过传递性推导某两条操作有hb关系…

JMM提供的happens-before机制,使我们可以跳脱出程序执行时机来思考问题。

我用上面的程序片段来举个例子:为什么说答案是(1,0),(0,1),(1,1),(0,0)都有可能呢?

使用hb机制,不需要思考两个线程间的执行流程,思考的过程应该是这样的:

  • 首先对于变量y,它可能的取值有哪几个?首先,在main方法的线程里,System.out.prinln("(" + x + "," + y + ")");是在join操作之后,根据 线程结束规则 规则,A,B线程里任何操作对该语句都是可见的,而在线程的4个语句中,很明显,仅对y有一次写入:y = a;。即,在System.out.prinln("(" + x + "," + y + ")"); 见到的值,就是y = a;语句对y写入的值。
  • 所以问题等价为y = a;时,可能对y写入什么值?也就是y = a;时,a的取值可能有什么?
  • 而因为对a只有一次写操作,所以问题很容易等价为y = a;a = 1;两个操作有没有hb关系?如果有,则y = a;执行时,a的取值必然为1,如果没有,则y = a;执行时,a的可能的取值为0,1,y的可能取值当然也是0,1。
  • 不难看出,两个操作之间不符合任何一个hb规则。所以它们没有hb关系。

可见,我们得出y的取值可能为0,1,是因为y = a;a = 1;没有happens-before关系。而对于x来说,过程是一样的,所以最终的结果是(1,0),(0,1),(1,1),(0,0)都有可能。

你看,我们由始至终没有分析某两个线程的执行顺序问题。而且,用hb规则思考的过程中,要时刻提醒自己:happens-before的约束是可见性,不是执行顺序。

happens-before是个非常强大的机制。它为并发程序的可见性提供强力有效的保证。因为有时候可能“借助”现有同步机制的可见性属性,然后通过传递性规则和其他规则的结合,对某个未被锁保护的变量的访问操作排序,这里就不展开了。

参考资源

  • 《Java并发编程实战》
  • http://ifeve.com/easy-happens-before/

Updated: