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

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

2017-02-11 19:27 1246 查看
面试官问了一道单例模式设计相关的问题。问题大概是这样,先考察一下我对单例模式的了解情况,毕竟这是GoF经典书籍《设计模式》中的一个经典的模式。在确认了我基本清楚这个设计模式后,让我用Java代码写一个单例模式的类,于是我给出了如下代码段。

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

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

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

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

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

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



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

(上图来自于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》一书的最后谈到了这个问题,并指出这种“优化”是丑陋的,不赞成使用。而建议使用如下的代码替代之:

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

可以看到静态内部类ResourceHolder是专门为了处理这个问题而写的,这个类只有首次被用到的时候才会被JVM加载和初始化,做到了延迟初始化
内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
标签: