多线程场景下延迟初始化的策略
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,发送消息,返回响应消息,中间封装了一些操作,减少客户端的调用代码而已。对于客户端来说,这个对象不存储共享数据,使用后也没有存储起来,因此是允许重复初始化域的。