双重检测锁
使用粒度较小的锁(
缩小锁的范围)
解决了
懒汉式改进版
中存在的性能问题,以提高并发量。
![](https://img-blog.csdn.net/20170412162829104?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvZ2F2aW5fZHlzb24=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/Center)
双重检测锁实现如下:
public class Singleton { // 1
private static Singleton singleton; // 2
private Singleton() {} // 3
public static Singleton getSingleton() { // 4
if (singleton == null) { // 5
synchronized (Singleton.class) { // 6
if (singleton == null) { // 7
singleton = new Singleton(); // 8
} // 9
} // 10
} // 11
return singleton; // 12
} // 13
} // 14
该实现改变了
synchronized
的作用范围,将同步方法改为了同步代码块。并增加了一次
if
(
singleton
==
null
)
判断。
第一次检测的作用是什么?
如果第一次检测
singleton
为
null
,则会进入同步代码块,进行对象实例化操作。这个操作只会进行一次,以后
执行
getSingleton()
第一次检测
singleton
不为
null
,那么就不需要执行下面的加锁和实例化操作,直接返回实例对象,因此可以大幅降低
synchronized
带来的性能开销。因此,第一次检测就是为了对象实例化之后可以直接返回实例对象。
那么为什么还需要第二次检测?
当
singleton
为
null
时,
可能会有多个线程一起进入同步块外的
if
,假设线程A先获得
Singleton.
class
锁,并完成了初始化操作,此时线程B再获得
Singleton.
class
锁,就会再次执行实例化操作,就会生成多个实例了。因此有需要再次判断对象是否为
null
空,以防止出现实例化多个对象的错误。
这个修复了
懒汉式改进版
中存在的性能问题,但真的完美了吗?
天下没有完美的事,这个优化也不例外,这里还有一个致命的错误,会让之前的努力前功尽弃!
错误的根源在于
Java内存模型
中一个很重要的概念 —— 指令重排序。
简单地说,是编译器、处理器以及运行时等可能对操作的执行顺序进行一些意想不到的调整,以提高执行效率。下面是一个概念模型:
上面的代码第8行,
singleton
=
new
Singleton();
创建了一个对象,别看一个简单的
new
,但是幕后要做的工作很多很复杂。当调用静态方法
getSingleton()
时,就会触发类加载,完成类加载后,实例化对象时,简单来说可以分解为以下三个步骤:
- 在堆上给 singleton 分配内存。
- 调用 Singleton() 的构造函数来初始化成员变量,此时类变量已经完成了初始化。
- 将Java 栈本地变量表中的 singleton 引用指向Java堆中为对象分配的内存空间,执行完这步 singleton 就为非 null 了。
上面三个步骤中的 2 和3 之间,可能会被重排序(在一些JIT编译器上,这种重排序是真实发生的)。2 和 3之间重排序之后会使得初始化放到第三步进行,也就是说尽管
singleton
非 null 了,但是这个对象还没有初始化,是一个不可用的对象。
我们怎么知道什么时候会重排序,
怎么解决重排序问题?
重排序问题既然是为了提高执行效率才采取的措施,那么必须先保证执行的结果和未重排序时执行的结果相同,否则就舍本逐末了。
这就引出了Java 内存模型中
另外一个概念
:as-if-serial语义。
as-if-serial语义
的意思指:不管怎么重排序,(单线程)程序的执行结果不能被改变。编译器、运行时和处理器都必须遵守
as-if-serial语义
。
为了遵守
as-if-serial语义
,编译器和处理器不会对存在数据依赖关系的操作做重排序,因为这种重排序会改变执行结果。但是,如果操作之间不存在数据依赖关系,这些操作可能被编译器和处理器重排序。为了具体说明,请看下面计算圆面积的代码示例:
double pi = 3.14; //A
double r = 1.0; //B
double area = pi * r * r; //C
上面三个操作的数据依赖关系如下图所示:
如上图所示,A和C之间存在数据依赖关系,同时B和C之间也存在数据依赖关系。因此在最终执行的指令序列中,C不能被重排序到A和B的前面,C 如果排到A和B的前面,程序的结果将会被改变。
但A和B之间没有数据依赖关系,编译器和处理器可以重排序A和B之间的执行顺序。
下图是该程序的两种执行顺序:
as-if-serial语义把单线程程序保护了起来,遵守as-if-serial语义的编译器、运行时和处理器共同为编写单线程程序的程序员创建了一个幻觉:单线程程序是按程序的顺序来执行的。
as-if-serial语义使单线程程序员无需担心重排序会干扰他们,也无需担心内存可见性问题。也就是说单线程下的
new操作尽管可能进行2和3操作重排序,但是不会影响结果。
重排序对多线程的影响?
这正是我们的双重检测锁实现中遇到的问题,前面说到这种实现可能会让我得到一个
尚未初始化的对象,这就是重排序对多线程的影响之一。
怎么解决多线程中重排序问题?
我们可以使用
volatile 关键字,只需要用这个关键字声明
singleton
变量即可,实现如下:
public
class
Singleton {
private static volatile Singleton singleton;
private Singleton() {}
public static Singleton getSingleton() {
if (singleton == null) {
synchronized (Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
要介绍
volatile
关键字,先要简单介绍
Java内存模型(JMM)
中的相关概念。
内存可见性
:一个线程对
共享变量
值得修改,能够及时地被其他线程看到。
共享变量:如果一个变量在多个线程的工作内存中都存在副本,那么这个变量就是这几个线程的共享变量。
Java 内存模型(Java Memory Model)描述了Java 程序中各种变量(
线程共享变量)的访问规则,以及在JVM中将变量存储到内存和从内存中读取变量这样的底层细节。
主内存:所有的变量都存储在主内存中。
工作内存:没个线程都有自己独立的工作内存,里面保存该线程使用到的变量的副本(主内存中该变量的一份拷贝)。
两条规定:
- 线程对共享变量的所有操作都必须在自己的工作内存中进行,不能直接从主内存中读写。
- 不同线程之间无法直接访问其他线程工作内存中的变量,线程间变量值得传递需要通过主内存来完成。
共享变量可见性实现原理:
线程1对共享变量的修改要想被线程2及时看到,必须经过如下2个步骤:
- 把工作内存1中更新过的共享变量刷新到主内存中
- 把主内存中最新的共享变量的值更新到工作内存2中
要实现共享变量的可见性,必须保证两点:
- 线程修改后的共享变量值能够及时从工作内存刷新到主内存中
- 其他线程能够及时把共享变量的最新值从朱内存更新到自己的工作内存中
Java 语言层面支持的可见性实现方式:
- synchronized
- volatile
volatile 关键字能够保证volatile变量的可见性,不能保证volatile变量复合操作的原子性。
volatile如何实现内存可见性
深入来说:通过加入内存屏障和禁止重排序优化来实现的。
- 对volatile变量执行写操作时,会在写操作后加入一条 store 屏障指令
- 对volatile变量执行读操作时,会在读操作后加入一条 load 屏障指令
通俗来说:
volatile变量在每次被线程访问时,都强迫从主内存中重读该变量的值,而当该变量发生变化时,又会强迫线程将最新的值刷新到主内存。这样任何时刻,不同的线程总会看到该变量的最新值。
所以,
volatile变量
singleton
就能禁止重排序,从而解决了
双重检测锁中的问题了。
不过
volatile
语义在Java 1.5才得到增强,所以在通用性上有些限制。
参考:
http://www.infoq.com/cn/articles/double-checked-locking-with-delay-initialization
http://www.infoq.com/cn/articles/java-memory-model-2