JMM的“非完整”浅析

时间:2022-03-27 06:59:07

所谓JMM,即Java Memory Model。
所谓“非完整”,即这篇blog只是很浅显的一些笔记,而且所呈现的知识结构体系也是零散和不完整的。原因在于个人的懒散。当我发现要完整的搞懂JMM需要通读大概几十页的英文paper时,我退缩了。这实在是个吃力不讨好的事情,而且我现在也没有这么多时间去完整的了解JMM。所以,以下的内容只是在浏览过wiki和一些书的相关章节后组织起来的。它能够带给你的可能只有一些感性和浅显的认识。
如果想完整系统的了解JMM,这里给出了最权威的文献:
The Java Memory Model, Jeremy Manson, ACM Transactions on Programming Languages and Systems, Vol. TBD, No. TDB, Month Year, Pages 1–64  --  64页的一篇journal paper
The Java Memory Model, Jeremy Manson, POPL 05 – 14页的一篇conference paper

 

从Double-check Locking说起
但凡介绍java concurrency的文章,就没有不提及double-check locking(DCL)的。因为这是一个很好的例子,来展现JMM所带来的对于编程人员的影响。
在谈DCL之前,又得先介绍lazy initialization。
在很多程序中,我们常常需要做这么一件事:某些成员只在它将要被使用的时候才初始化(initialization),如:

上面的代码中,helper一开始是null,没有被初始化。直到程序需要使用helper的时候(即调用getHelper()),我们才调用helper的构造函数。
这样的代码其实在很多singleton pattern(单例模式)中都能找到。

很明显,上述的code在多线程环境下是不安全的(unsafe),原因很简单,就不多说了。那么,怎样才能写一个线程安全的呢:

这是一个在java中很常见的写法。简单的将整个函数设置为synchronized。这意味着任何时刻只有一个线程能够进入到这段代码区。毫无疑问,这么做一定是线程安全。但它有一点点小问题(有时候这个问题可能就不小了),即它的性能不是太好。因为当helper被初始化以后,你可以理解它就是一个只读的变量了,而对于一个只读的变量,没必要做任何的同步。但由于设置了synchronized,所以永远都只能有一个线程来访问这段代码。效率实在是不高。

 

想点办法再改进一下吧:

在这段代码中,我们解决了前面提到的性能不高的问题。首先,我们先检查一下helper是否为null,如果为null,那么就进入synchronized的区域,初始化helper。但如果不为null,那么说明helper已经初始化好了,那么就不需要进入synchronzied区域,直接读取helper。这样,当helper真正创建之后,线程再去访问getHelper()这个函数就不需要再做任何的同步。
而且需要注意的是,当我们进入到synchronzied区域后,我们仍然会再一次的检查helper是否为null。因为检查了两次helper,就称为double-check,这段代码就是典型的DCL。
如果写过C/C++的多线程程序的人应该都写过类似的代码。它在C/C++中没有问题。
但是,这种写法在Java中是错误的!
为什么?

 

JMM的一些概念

在现代的处理器,特别是多核的处理器架构中,采用了非常多的对指令的优化措施。这些优化会使得程序执行的顺序和程序书写的顺序不一致。另外,在现代的内存架构,特别是NUMA,每个processor都有一个local cache, 而这些processor又共享同一个内存。如果有多个processor中的cache都放置了memory中的同一数据时,很难保证这些cache中的数据是同一个版本。因此,如果多个thread共享同一数据时,很多时候它们看到的都是不同的值。
正是因为这些优化带来的复杂性,并且不同的平台,不同的处理器也各不相同,所以JAVA提出了它自己的Memory Model,以达到某种统一。所谓的Java Memory Model,其实就是一些规范,这些规范说明了thread在访问memory时的一些规则以及这些thread怎样通过memory进行交互(wiki的原话是:The Java memory model describes how threads in the Java programming language interact through memory. Together with the description of single-threaded execution of code, the memory model provides the semantics of the Java programming language)。

 

这里,我很难给出这些规范的详细定义,只是提及几个基本的概念:
as-if-serial: 我给出的中文直白的解释就是“看起来像串行执行的”。这是JMM中关于单线程执行的一个规范。意思就是说,无论编译器或者processor怎样优化指令的执行,怎样去打乱指令执行的顺序,但是必须保证,最后的执行结果必须和按照书写顺序的程序执行的结果是一样的。其实,也可以反过来理解,就是只要执行结果和书写的结果是一样的,那么任何的优化和乱序都是可以接受的。
但是,as-if-serial这个语义只规范了单个线程内部的执行,并没有涉及到多个线程之间。它不能阻止多个线程会读到同一个共享数据的不同的值。事实上,JMM也确实没有定义说多个线程读共享数据的值一定是一致的。相对的,JMM给出了一个非常宽泛的规范:
happens-before order: 如果B开始执行的时候A一定已经完成了(无论A,B是在同一线程还是不同线程),那么A和B就满足happens-before的关系。JMM定义了很多这样的关系,比如:
  Monitor lock rule. 如果发生了lock操作,那么它对应的unlock操作一定发生在其它的lock操作之前,如图:

JMM的“非完整”浅析

  Volatile variable rule. 一个对于volatile field的写操作一定在它后面的读操作之前完成;
  Thread start rule. 一个thread.start一定发生在这个线程中的每个action之前;
  当然,happens-before满足传递性。就是说,如果A happens-before B,而B happens-before C,那么A happens-before C。

 

说了半天,再来看个例子:

由于两个thread之间没有任何的明确的同步操作,所以不存在任何happens-before的关系。所以,可能出现多种的执行顺序:

JMM的“非完整”浅析

如果考虑到单线程内的代码有可能乱序的话(这个乱序并没有违背as-if-serial),那么还有一种结果:

JMM的“非完整”浅析

 

回到DCL

回到前面的DCL代码,注意“helper = new Helper();”这条语句。正是由于现代处理器的各种优化手段,导致在执行这条语句的时候,在Helper的构造函数还没有完全结束时就可能对helper赋值,所以,某些情况下,虽然getHelper()这个函数返回了一个对于Helper的引用,但有可能这个引用所指向的那个对象还没有完全构造好,还是一个半成品。举例来说:
Thread A第一个调用了getHelper(),这时候helper肯定是null,所以进入synchronized区域,执行Helper的构造函数;
由于处理器的优化,Helper的构造没有完全结束,就返回了引用给helper;
这时候Thread B调用了getHelper(),由于helper已经不为空了,所以Thread B不会进入synchronized区域,而是直接得到helper引用;
Thread B用getHelper()返回的引用来做其它的事情了,但是这个时候可能Helper的构造函数还没有完成,所以程序出问题了;

 

再多罗嗦两句,Thread B之所以可能得到一个不完整的Helper的引用,是因为它并没有进入到synchronized区域,所以它没有受到我们前面提到的happens-before rule的保护。假设如果Thread B需要进入synchronized区域才能获得helper的引用,那么根据monitor lock rule,当thread B进入到synchronized区域之前,thread A一定在synchronzied区域所执行的代码一定全部都执行完成了,这样thread B就不可能获得不完整的helper。

所以,DCL通过两次检查helper的引用来减少进入synchronzied区域的次数,能够提高性能,但也正是因为如此,它就面临了一个风险,即可能获得一个不完整的helper的引用。

 

最后,再补充一个方法,在java中能够做到threadsafe而又性能很高的lazy initialization:

这个方法起作用的原因是,HelperHolder是一个inner class,那么它只有在被使用的时候才会被加载到JVM中。

 

Reference

[1] Wiki: Double-check Locking http://en.wikipedia.org/wiki/Double-checked_locking

[2] Wiki: JMM http://en.wikipedia.org/wiki/Java_Memory_Model

[3] Java concurrency in Practice

[4] The art of multiprocessor programming

 

补充一个很详细的关于Java Double-check Locking的讲解:

http://www.cs.umd.edu/~pugh/java/memoryModel/DoubleCheckedLocking.html