多线程场景下延迟初始化的策略


1.什么是延迟初始化

延迟初始化(lazy initialization,即懒加载)是延迟到需要域的值时才将它初始化的行为。如果永远不需要这个值,这个域就永远不会被初始化。这种方法既静态域,也适用于实例域。

最好建议“除非绝对必要,否则就不要这么做”。

2.延迟初始化线程安全的一个策略:同步

延迟初始化的一个好处,是当域只在类的实例部分被访问,并且初始化这个域的开销很高,那就可能值得进行延迟初始化。

但是在大多数情况下,正常的初始化要优先于延迟初始化。因为在多线程的场景下,采用某种形式的同步是很重要的,否则就容易造成严重的Bug。

如下面的域bar,采取了延迟初始化的方法,那么在获取的时候,必须加上同步。

 1 class Foo {
 2     
 3     private Bar bar;
 4     
 5     synchronized Bar getBar() {
 6         if(null == bar) {
 7             bar = new Bar();
 8         }
 9         return bar;
10     }
11 }

代码段-1

否则的话,将上面的synchronized关键字去掉,考虑这样的场景:

假设线程AB同时访问getBar()方法,下面表示线程执行代码的顺序,数字表示第几行。

A->6, B->6, A->7, A->9, B->7, B->9;

结果A,B两个线程获得的Bar对象,并不是同一个对象,有些情况下是允许的,但有些情况下很可能会引发问题,比如下面会讲到的单例模式。

很明显,同步可以解决这个问题。但无论是synchronized关键字是加在方法上还是块里面,每次访问getBar()方法,都需要加锁,解锁,增加了开销。

上面的案例对于静态域同样适用。

综上所述,同步可以解决在多线程访问域时线程不安全的问题,但会增加额外的开销。所以这不是一个好的方法。

3.静态域延迟初始化的策略:lazy initialization holder class模式

如果出于性能的考虑而需要对静态域使用延迟初始化,就使用lazy initialization holder class模式(也称作initianlize-on-demand holder class idiom)。

这种模式的写法如下:

 1 class Foo {
 2     
 3     //1. 一个私有静态类,将静态域放到静态类里
 4     private static class BarHolder {
 5         static final Bar bar = new Bar();        
 6     }
 7     
 8     //2. 访问域的时候,返回静态类的域
 9     public Bar getBar() {
10         return BarHolder.bar;
11     }
12 }

代码段-2

当getBar()方法被调用时,它读取了BarHolder.bar的值,导致BarHolder类得到初始化。类初始化的时候,会初始化静态域bar. 

对于多个线程来说,无论谁先读取BarHolder.bar的值,BarHolder都将只初始化一次,因此每次访问的结果都是同一个Bar对象。

这种模式的魅力在于,getBar()方法没有被步,并且只执行一个域访问,因此延迟初始化实际上并没有增加任何访问成本。

这种模式最常见的应用,就是在单例模式中。单例模式要求类本身有一个静态域(如instance),指向类的一个实例,该类的getInstance()返回该静态域,并且每次返回的都是同一个对象,这才是一个单例。

假如使用延迟初始化,在getInstance的时候,没有添加同步,如下面所示。

 1 class Foo {
 2     
 3     private static Foo instance ;
 4     
 5     public static Foo getInstance() {
 6         if(null == instance) {
 7             instance = new Foo();
 8         }
 9         return instance;
10     }
11 }

代码段-3

很明显,多次访问可能返回不同的对象,而如果加上synchronized关键字,或者加上读写锁,则开销较大,每次获取一个对象的时候还要竞争锁。

使用了lazy initialization holder class模式,就可以写成下面这样:

 1 class Foo {
 2     
 3     private static class FooHolder {
 4         static final Foo instance = new Foo();
 5     }
 6     
 7     public static Foo getInstance() {
 8         return FooHolder.instance;
 9     }
10 }

代码段-4

当然,最好的实践,还是不要采用延迟初始化。除非是初始化比较耗时而影响了系统的启动,比如我们公司采用OSGi框架,每个bundle启动的时间都要做优化,那么单例的初始化可以等到系统已经启动了,操作时用到再初始化。

4.实例域延迟初始化的策略:双重检查模式(double-check idiom)

这种模式避免了域在被初始化之后访问这个域时的锁定开销(参考代码段-1)。这种模式背后的思想是:两次检查域的值(因此为double check),第一次检查时没有锁定,看看这个域是否被初始化了;第二次检查时有锁定。只有当第二次检查时表明这个域没有被初始化,才会调用初始化方法。因为如果域已经被初始化就不会有锁定(域被声明为volatile很重要)。和代码段-1对比一下,一个每次访问都会锁定,无论域是否已经初始化,一个是存在若干次需要锁定,一旦域已经初始化,将不会再有锁定。可见,开销变小了。

 1 class Foo {
 2     //volatile关键字
 3     private volatile Bar bar;
 4     
 5     public Bar getBar() {
 6         //局部变量只是减少读取次数,提升性能,不是严格需要
 7         Bar result = this.bar;
 8         //第一次检查,不锁定
 9         if(null == result) {
10             //一旦初始化,第一次检查将无法通过,不会有锁定开销
11             synchronized (this) {
12                 result = this.bar;
13                 //第二次检查,锁定
14                 if(null == result) {
15                     this.bar = result = new Bar();
16                 }
17             }
18         }
19         return result;
20     }
21 }

代码段-5

注意:在Java 1.5发行之前,双重检查模式的功能很不稳定,因为volatile修饰符的主义不够强,难以支持它。Java 1.5版本引入的内存模式解决了这个问题(内存模式参考 原子性和可见性)。如今,双重检查模式是延迟初始化一个实例域的方法。

有些时候,你可能需要延迟初始化一个可以接受重复初始化的实例域。如果处于这种情况,就可以使用双重检查模式的一个变形,省去第二次检查。这种叫做单重检查模式(single-check iidiom)。对实例域来说,代码如下:

 1 class Foo {
 2     // volatile关键字
 3     private volatile Bar bar;
 4 
 5     public Bar getBar() {
 6         // 局部变量只是减少读取次数,提升性能,不是严格需要
 7         Bar result = this.bar;
 8         // 只有一次检查
 9         if (null == result) {
10             this.bar = result = new Bar();
11         }
12         return result;
13     }
14 }

代码段-6

对静态域来说,实际上,就是代码段-3。

单重检查模式这种情况是可能发生的。例如最近开发的一个特性,通过工厂模式返回一个HttpClient的包装对象(该类是用来向一个固定的系统发送消息的,因此IP,端口,鉴权信息全都是固定的,全局只有一个对象就够了,客户端调用时也不用填写对端系统信息)。

包装类对象延迟初始化。包装类的作用只是包装了一个第三方HttpClient,发送消息,返回响应消息,中间封装了一些操作,减少客户端的调用代码而已。对于客户端来说,这个对象不存储共享数据,使用后也没有存储起来,因此是允许重复初始化域的。