lazy-init 懒加载的艺术

时间:2025-01-03 10:05:50

  懒加载是一种加载方式,加载单例对象一般有两种方式,一是在启动时就立即创建好,另一种则是在需要用到的时候再去加载即懒加载。懒加载一般会针对单例场景,且一般是针对在加载消耗较大费时,且不一定会用到的场景。

  好了,相信啥意思大家都明白!那么具体如何实现呢?其实挺有意思的!

方案1. 直接用懒加载实例进行判断null,不安全的做法

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

  很明显,这里的懒加载是不是安全的,因为当线程并发访问 getInstance(), 可能同时看到为null,从而同时进行了初始化,这会导致外部获取到的实例是不致的,从而导致不可预估的错误!另一种隐形的加载错误待说!

方案2. 使用锁将懒加载方法锁起来,不省力的做法

  如上,既然既然访问 getInstance() 是非线程安全的,那么,只要加个锁就可以了!

    public static syncronized Instance getInstance(){
if (instance == null){
instance = new Instance();
}
return instance;
}

  很明显,这种做法在高并发的情况下,会有严重的锁竞争,从而导致严重的性能问题!相信不会有同学这么干!

方案3. 双重锁校验的普通方法,一个有隐患的做法

  如上两个问题,既要线程安全,又要性能不影响,其实可以想像到,初始化动作只是一次性的,所以,只要第一次的时候保证线程安全即可,因为后续大家都是获取同一个实例!所以,我们把锁的位置放到第一次加载时!

    public static Instance getInstance(){
if (instance == null){
// 只有在instance为null即未进行过初始化时,才会上锁,从而避免后续性能问题
synchronized(UnsafeLazyInit.class){
// 由于外面的判定是非线程安全的,上锁后,再次进行判定是否已创建
if (instance == null){
// 此处初始化可能出现重排序
instance = new Instance();
}
}
}
return instance;
}

  所以,如上的解决办法,看起来很完美。但其实还是有问题的!问题在于,外部的 instance == null 初始是非线程安全的,任何进入的线程都可以进行断定!
  而 instance = new Instance(); 语句,并不是像代码看起来那样,就一句,可以保证原子性!

  这一条语句在实际执行中,可能会被拆分程三条语句,即分配内存/初始化类变量/赋值给实例变量,大致如下:

    memory = allocate();
ctorInstance(memory);
instance = memory;

  由于jvm的jit编译优化,可能会重排序,在保证结果最终一致的前提下,会将分配内存和赋值实例变量做不确定的重排,而当发生重排后,即先赋值实例变量内存空间,那么由于外部非线程安全的获取实例变量,会立即读取到该变量不为null,从而得到一个未初始化完成的实例进行后续操作!这将带来不可预知的后果!所以,这种双重锁是有问题的!不过看起来问题范围已经很小了!

方案4. 双重锁校验的增强方法,完美

  综上双重锁方法,还存在一个重排序问题,一般针对重排序,我们要条件反射式的想到禁止重排序即可。而jvm禁止重排的方式,有 volatile, final, 等关键词,当然其实现原理都是加入一些内存屏障来保障不重排。不管怎么样,我们只需要使用一些关键词就可以了!

    private volatile static Instance instance;
public static Instance getInstance(){
if (instance == null){
// 只有在instance为null即未进行过初始化时,才会上锁,从而避免后续性能问题
synchronized(UnsafeLazyInit.class){
// 由于外面的判定是非线程安全的,上锁后,再次进行判定是否已创建
if (instance == null){
// 使用volatile后,禁止了jit重排优化
instance = new Instance();
}
}
}
return instance;
}

方案5. 使用类初始化机制创建对象,一种脆弱的加载方式

  这是一种基于类初始化锁的一种懒加载方式!将懒加载放在一个类的静态变量上,依赖于类的安全的类加载来保证期实例化的线程安全性和准确性!如下:

public class InstanceFactory {
private static class InstanceHolder {
// 懒加载实例化放到内部类的静态变量上,需确定两个问题,1. 初始化时机,2. 线程安全性
public static Instance instance = new Instance();
} public static Instance getInstance() {
return InstanceHolder.instance;
}
}

  这看起来虽然有点麻烦,但是理解起来不会有问题!但是有问题我们得考虑下:静态变量不是一开始就会加载出来吗?如果这样的话,就不存在懒加载了啊!

  其实static静态变量是在类初始化的时候才会操作的。

  而类的初始化则有几个时机:

        1. 类首次被创建实例,即 new xxx() 操作时,触发类初始化;
2. 类中的静态方法被首次调用,比如上面的 getInstance() 被首次调用时会触发当前类的初始化;
3. 类的静态字段被赋值,比如 A.instance = abc;
4. 类中的一个非常量字段被使用,常量则不会触发初始化;
5. 类一个*类,而且一个断言语句嵌套在类内部执行;(我也不太明白啥意思)

来看一下实际的例子,说明类的初始化时机:

    @Test
public void testClassInit() {
// new 就不多说了
// 静态方法被使用
A.getInstance();
// 常量使用不会触发类初始化
System.out.println(A.noneInitConst);
// 静态变量被赋值触发类初始化
A.setVarInit = "";
System.out.println("setVarInit=''");
// 非常量静态变量被使用触发类初始化
System.out.println(A.usedVarInit);
}
static class A {
// 类常量被使用,不会触发类初始化
public static final String noneInitConst = "a";
// 静态变量赋值
static String setVarInit;
// 静态变量使用,触发类初始化
static String usedVarInit = "c";
static {
// 类初始化时会执行该静态块
System.out.println("A executed...");
}
// 静态方法触发类初始化
public static String getInstance() {
// 为避免其他规则被触发,直接使用返回字符串
return "A";
}
}

  可以依次注释各规则,查看初始化效果!

  ok,明白了类初始化的时机后,我们知道了,这里的懒加载是有用的!

  但是还有个问题,就是类初始化难道不会并发吗?答案是一定的,既然执行时机一致,那么并发自然存在。

  类初始化时,jvm会去获取一个锁,从而保证同步多个线程对同一个类的初始化!这个从jdk的ClassLoader实现可以看出来!

    protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
// 获取锁
synchronized (getClassLoadingLock(name)) {
// First, check if the class has already been loaded
Class<?> c = findLoadedClass(name);
if (c == null) {
long t0 = System.nanoTime();
try {
if (parent != null) {
c = parent.loadClass(name, false);
} else {
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// ClassNotFoundException thrown if class not found
// from the non-null parent class loader
} if (c == null) {
// If still not found, then invoke findClass in order
// to find the class.
long t1 = System.nanoTime();
c = findClass(name); // this is the defining class loader; record the stats
sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
sun.misc.PerfCounter.getFindClasses().increment();
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}

  所以,类的初始化是线程安全的!从而得出,使用类的 static 变量进行懒加载的正确性!

  当然,这里无故又多了一个内部类,着实让人不爽,而且如果后面往这个类里加入其他变量,则可能一不小心导致了问题!

  不管怎么样,他能解决当下的问题!

方案6. 使用第三方变量来标识懒加载情况
  意思是说,这里的初始化,是一个大对象的初始化,那么我可以换一个 true | false, 的简单变量的判定来处理,变量虽简单,不过也可能遇到的问题其实和上面一样,就不赘述了! 具体做法就是,在完成加载之后,再将该第变量值改变即可!

  但是使用第三方变量还有个好处,就是可以很方便的执行代码块!复杂的加载逻辑,你懂的!

  综上,懒加载这个简单操作,还真是充满了艺术感呢!

参考: 《java并发编程的艺术》