Java线程安全杂谈(中)——Java内存模型、happens-before原则和DCL问题

记得很久前我去参与一次Java面试,面试官问了一道单例模式设计相关的问题。问题大概是这样,先考察一下我对单例模式的了解情况,毕竟这是GoF经典书籍《设计模式》中的一个经典的模式。在确认了我基本清楚这个设计模式后,让我用Java代码写一个单例模式的类,于是我给出了如下代码段。

class SingletonEx
{
	private static SingletonEx instance = null ;
	private SingletonEx(){}
	public static SingletonEx getInstance()
	{
		if( instance == null )
		{
			instance = new SingletonEx();
		}
		return instance;

	}
}

如上,把构造方法和instance对象设置为private则避免外部构造多个实例,并对单例的对象instance做了延迟初始化,第一次使用的时候做初始化。但这是不考虑并发的情况,像上篇文章所说的,instance的为空检测和实例化赋值不能保持原子性,于是又了下面的版本。

    public synchronized static SingletonEx getInstance()
	{
		if( instance == null )
		{
			instance = new SingletonEx();
		}
		return instance;

	}

好了,这个并发运行逻辑上是没什么大问题了,我基本上也就给了这个答案。但这看起来会有效率问题,即使instance已经被创建过了,所有的线程还是要等这个方法的锁,卡在这个方法上。于是我们通常会考虑到缩小锁的范围,让更多的线程顺利通过,但只把synchronized块放在方法里面并没有缩小范围,还要再在外面加一个判断,好让线程走“绿色通道”,于是就有下面版本:

    public static SingletonEx getInstance()
	{
		if( instance == null )
		{
			synchronized(SingletonEx.class)
			{
				if( instance == null )
				{
					instance = new SingletonEx();
				}
			}
		}
		return instance;

	}

嗯,这样虽然看起来比较啰嗦,但至少在功能上貌似是完满了!但是……这实际上会有隐患……

上面我从一个看起来不起眼的单例实现问题开始,讲到了并发的情况,关于最后的问题在哪里?如何解决这个问题,正是下面要说的Java内存模型(JMM)和重排序

相信计算机相关专业的朋友们在学校都学过“计算机组成原理”(也有的叫“计算机组成结构”“计算机体系结构”),我们都知道由于物理实现原因,从处理器到缓存存取、再到主存存取、再到辅存存取(比如硬盘),速度是依次降低的。为了保证处理器计算资源的高效利用,处理器内部设计了寄存器和高速缓存,这样不必在大部分情况下等待存取完成而浪费时间。在多处理器结构中也会有更复杂的考虑。

我们同时也知道在Java体系结构的设计中,JVM在很大程度上屏蔽了底层的实现,使得开发人员不必过分关注底层的细节。Java内存模型也是一个相对于底层设计来讲比较抽象的模型结构。通常来说,为了保证特定线程不被其它其它因素所影响高速执行,每个线程都有对应的“工作内存”,而与此对应的,各线程最终都可以看到的是“主内存区”,这和缓存有点类似。在定义了这样两个内存区基础上,JMM还定义了在这两个内存区的一些基本操作(如在工作内存和主内存间的存取数据转移)和对这些操作的一些要求(存取操作的顺序要求和成对出现等)。

Java内存模型

多处理器情况下内存模型示意图

(上图来自于http://ifeve.com)

根据对JMM的系统描述,通常JMM会被归纳为有三个特性:原子性、可见性、有序性。根据周志明的《深入理解Java虚拟机》,JMM中的操作有如下8种,分别是read、load、use、assign、store、write以及lock和unlock。其中前6种(不考虑long和double)可以被认为是满足原子性的,更大范围的原子性要lock和unlock来保证,对应于锁实现。对于可见性,举个例子就是工作内存里面被线程改了,但未会写到主内存,其它线程就不可见。有序性有这样一句话“本线程内观察,线程内的所有操作有序,一个线程观察另外一个则无序”。说到这三个特性,我们不得不再提一下一个关键字——volatile,它抑制了一些线程运行上的优化,保证了可见性,也一定程度上保证了有序性。复杂的情况则要考虑适当采用synchronized,它在一定意义上保证了这三个特性。

上面这个听起来有点抽象,那么我们可以勉强这样对应一下,主要是有利于我们理解接受,但实际情况可能并不完全有如此清晰的对应关系,好在细节我们不必关注。JVM中每个线程会有自己的栈,而堆是存放各线程所用对象的地方。堆类似于JMM中的主内存,栈中的一部分类似工作内存。

除了有工作内存和主内存的区别外,为了线程的高效并发执行,也是为了配合工作内存或者说缓存的有效利用,实际上会在运行的各个层面上有指令重排序的现象。所以如果在写并发代码的时候,需要把这些考虑进去,保证线程间代码的执行顺序,否则就有可能存在潜在的线程安全问题。在《Java Concurrency in Practice》最后一章中讲到,对JMM的描述还定义了一套偏序规则,通常我们称这个为happens-before规则。这个规则实际上成为了是否符合线程安全要求的最基本依据,也是在重排序中的一个默认保证:

  • Program order rule. 线程内的代码能够保证执行的先后顺序
  • Monitor lock rule. 对于同一个锁,一个解锁操作一定要发生在时间上后发生的另一个锁定操作之前
  • Volatile variable rule. 保证前一个对volatile的写操作在后一个volatile的读操作之前
  • Thread start rule. 一个线程内的任何操作必需在这个线程的start()调用之后
  • Thread termination rule. 一个线程的所有操作都会在线程终止之前
  • Interruption rule. 要保证interrupt()的调用在中断检查之前发生
  • Finalizer rule. 一个对象的终结操作的开始必需在这个对象构造完成之后
  • Transitivity. 可传递性

这个虽然说只有8条规则,但对这些顺序规则的真正理解还要在实践中不断体会才行,这里仅仅是一个整理和介绍。

现在再让我们回头来看本片开头的例子,其中对对象的初始化和赋值是不能保证有序的,所以很有可能赋值发生在对象完整构造好之前,这样instance就不为null,而另一个线程得到了这个不为null而又没有被完全初始化的对象,导致问题的存在。

这是个经典的问题,这类问题被称为双重检查锁定(DCL)问题,在《Java Concurrency in Practice》一书的最后谈到了这个问题,并指出这种“优化”是丑陋的,不赞成使用。而建议使用如下的代码替代之:

public class EagerInitialization {
    private static Resource resource = new Resource();
    public static Resource getResource() { return resource; }
}

如果非要做延迟初始化的话,可以用类似如下的方式:

public class ResourceFactory {
    private static class ResourceHolder {
         public static Resource resource = new Resource();
    }
    public static Resource getResource() {
        return ResourceHolder.resource ;
    }
}

可以看到静态内部类ResourceHolder是专门为了处理这个问题而写的,这个类只有首次被用到的时候才会被JVM加载和初始化,做到了延迟初始化。

还有一种说法是在JDK1.5之后,可以用volatile修饰实例属性,但本人未尝试使用过,不能确定。关于更多DCL问题的讨论,可参见这里:

http://ifeve.com/doublecheckedlocking/

本文到此为止,关于锁和协同的问题,下篇继续讨论。

此条目发表在 Java, Java语言, 并发, 开发, 计算机技术 分类目录,贴了 , , , , , , 标签。将固定链接加入收藏夹。

发表评论

电子邮件地址不会被公开。 必填项已用 * 标注

您可以使用这些 HTML 标签和属性: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>