单例模式设计实现与注意事项

2021年09月09日 889次浏览

龙强(青云子)

一 单例介绍

1.1 定义

在我们日常工作中单例模式使用也是最广泛的,它的定义是说保证一个类仅有一个实例,并提供一个全局访问点。

1.2 使用场景

单例有很多使用场景,比如无状态的工具类,日志工具类,全局信息类配置类等,实际应用也能有很多,比如说应用配置,一般用线程池的时候也采用单例设计,还有数据的连接池一般也是单例模式的。

1.3 优缺点

【优点】

在内存里只有一个实例,节省内存和计算,减少了内存开销。特别是一个对象需要频繁的创建销毁时,而且创建和销毁时的性能又无法优化,这个时候单例模式的有时就比较明显了。

可以避免对资源的多重占用。例如我们对一个文件进行写操作,由于只有一个实例存在内存中,可以避免对同一个资源文件同时写操作。

设置全局访问点,严格访问控制,也方便管理。也就说你要使用不要你new出来,你只能通过我这个方法来创建对象,严格的控制访问。

【缺点】

没有接口扩展困难。如果想要扩展的话就得修改代码,基本上没有替他途径可以实现。

1.4 实现的重点

私有构造器private。这个是为了进制从单例内外部调用构造函数来创建这个对象,为了达到这个目的,必须设置构造函的权限为private。

线程安全。对于单例模式来说线程安全在设计的过程中是非常重要的,这一点一定不能忽略。

延迟加载。我们想使用它的时候再创建,那么这个就需要延迟加载。

序列化和反序列化安全。对于单例对象一旦说序列化和反序列化的话,就会对单例进行破坏。

反射。还有一个重点就是反射,单例模式也要防止反射攻击,虽然我们日常写代码中不会特意这么做,但是基于工程师的思想我们也要考虑下这个点。后面会分析源码,包括反射的一些源码以及如何防御反射攻击。

二 单例写法与解析

2.1 简单懒汉式单例

简单懒汉式单例

public class LazySingleton {
    private static LazySingleton lazySingleton = null;
    private LazySingleton() {
    }
    public static LazySingleton getInstance() {
        if (lazySingleton == null) { A
            lazySingleton = new LazySingleton();B
        }
        return lazySingleton;
    }
}

这种写法在单线程中是ok的,但是一旦多线程来使用这个单例的话就会出现线程安全问题。

如果有两个线程,线程1执行到 B 行还没有实例化,线程2执行到A行判断不为空,继续执行,这样就会实例化两次,会返回最后执行完成的实例。

懒汉式单例在多线程的情况下生成了不止一个实例,就会创建很多个对象,如果这单例对象特别消耗资源,那么很有可能造成系统故障,这个是非常危险的。

这个隐患时一定要消除的,怎么消除了?最简单的就是直接在getInstance方法上加一个synchronized关键字,变成一个同步方法。

2.2 同步锁懒汉式单例

public class LazySingleton {
    private static LazySingleton lazySingleton = null;
 
    private LazySingleton() {
    }
 
    public synchronized static LazySingleton getInstance() {
        if (lazySingleton == null) {
            lazySingleton = new LazySingleton();
        }
        return lazySingleton;
    }
}

如果 synchronized 加在 static 方法上,那么相当于锁的是这个类的 class 文件,也就是类锁;如果锁的是普通方法,那么锁的就是在堆内存中生成的对象,也就是我们说的对象锁。

通过这种同步的方式我们解决了懒汉式单列在多线程中可能引起的问题,但是同步锁比较消耗资源,这里有加锁和解锁的开销,而且 synchronized修饰 static 方法的时候锁的使类锁,锁的范围太大,对并发性能有影响。

那还有没有其他的方式在性能和线程安全性方面取得平衡了?答案是肯定有的,那就是双重检查。

2.3 双重检查懒汉式单例

现在我们使用DoubleCheck也就是双重检查的这种方式来写一下懒汉式单例,这种方式兼顾了性能和线程安全,而且是懒加载的。

public class LazyDoubleCheckSingleton {
  private static LazyDoubleCheckSingleton lazyDoubleCheckSingleton = null; // 1
  private LazyDoubleCheckSingleton() {} // 2
  public static LazyDoubleCheckSinleton getInstance() { // 3
    if (lazyDoubleCheckSingleton = null) { // 4
        synchronized(LazyDoubleCheckSingleton.class) { // 6
         if (lazyDoubleCheckSingleton == null) { // 7
            lazyDoubleCheckSingleton = new LazyDoubleCheckSinleton(); // 5
         }
       }
    }
    return lazyDoubleCheckSingleton; // 6
  }
}

DoubleCheck关注的是什么呢?双重检查。在哪里检查呢? 首先这里的getinstance方法就不需要锁了,也就是说代码3这里的方法并不是一调到这里就立刻锁上,而是把锁定放到了方法体中,最上层还是进行一个判断,判断完成之后,这个时候呢我们来锁定这个单例的类。

代码6锁定了这个类,也代表着if是进来的,至少进到6 这里面的线程,它在代码4判断的时候,lazyDoubleCheckSingleton这个对象还是空的。这里想象一下,因为这里的代码4这里边没有锁,所以这个时候另外一个线程进来,它如果判断lazyDoubleCheckSingleton为空,到代码6也会阻塞。如果进到代码6里边的线程已经把这个lazyDoubleCheckSingleton对象生成好了,那刚刚说的新进来的线程在代码7判断的时候,会直接return lazyDoubleCheckSingleton对象。

代码6加锁之后我们肯定还要做一层空的判断,即代码7。那我们看一下现在的这种写法,代码4不加锁,如果不为null就直接返回;如果为null那也只有一个线程进入到代码6里面,这样大幅的降低synchronized加载代码这个 getInstance方法上的时候带来的性能开销。

这样就可以了吗?看上去很完美,但是这里有一个隐患,这个隐患出在代码4 和代码5 之间,为啥?

首先在代码4行的时候,虽然判断了这个lazyDoubleCheckSingleton对象是不是空,这个时候它有可能是不为空的。虽然它不为空,但是很有可能lazyDoubleCheckSingleton对象还没有完成初始化,也就是代码5还没有执行完成。

让我们看下代码5:lazyDoubleCheckSingleton = new LazyDoubleCheckSinleton(); // 5。看上去是一行代码,实际上这里面经历了三个不步骤:

  1. 分配内存给lazyDoubleCheckSingleton对象
  2. 初始化lazyDoubleCheckSingleton对象
  3. 设置lazyDoubleCheckSingleton 指向刚分配的内存地址

在2 和 3 时候可能会被重排序,也就是2 和 3 的顺序有可能会被颠倒,变成这样:

  1. 分配内存给lazyDoubleCheckSingleton对象;
  2. 设置lazyDoubleCheckSingleton 指向刚分配的内存地址;
  3. 初始化lazyDoubleCheckSingleton对象

先分配内存给lazyDoubleCheckSingleton这个对象,然后单例对象来指向刚分配的内存地址,注意这里是已经指向了内存这里,所以这里的代码4的空判断时候并不为空,但是了这个单例对象还有可能没有被初始化完成。

这里面就要说一下Java语言规范里面说,所有的线程在执行Java程序时,必须要遵守intra-thread semantics 这么一个规定来保证重排序,不会改变单线程内的程序执行结果。例如上面的123步骤,对于单线程来说,2 和 3互换位置其实不会改变单线程内的程序执行结果。所以Java语言规范允许那些在单线程内不会改变单线程执行结果的重排序,也就是2 和 3互换是允许的。因为这个重排序可以提高程序的执行性能。

那我们现在知道了问题所在,我们怎么解决了?有两种方法
(1)不允许2和3重排序
(2)允许线程0里面的2和3重排序,但是不能允许线程1看到这个重排序

2.4 双重检查volatile懒汉式单例

我们先来看第一种解决方法:不允许2和3重排序,看改造后的代码。

public class LazyDoubleCheckSingleton {
    private volatile static LazyDoubleCheckSingleton lazyDoubleCheckSingleton = null;
    private LazyDoubleCheckSingleton() {}
   
    public static LazyDoubleCheckSingleton getInstance() {
        if (lazyDoubleCheckSingleton == null) {
            synchronized (LazyDoubleCheckSingleton.class) {
                if (lazyDoubleCheckSingleton == null) {
                    lazyDoubleCheckSingleton = new LazyDoubleCheckSingleton();
                    // 1. 分配内存给这个对象
                    // 2. 初始化对象
                    // 3. 设置lazyDoubleCheckSingleton 指向刚分配的内存地址
                }
            }
        }
        return lazyDoubleCheckSingleton;
    }
}

一说禁止重排序,我们首先想到啥,那就是 volatile 关键字。为啥了?因为我们知道volatile有两个作用:
volatile 的作用
(1)保证被 volatile修饰的共享变量对所有的线程总是可见的
(2)禁止指令重排序优化

那么 volatile如何保证可见性的,原因在于
(1)当写一个volatile变量时,JMM会把该线程对应的工作内存中的共享变量值刷新到主内存
(2)当读一个volatile变量时,JMM会把该线程对应的工作内存置为无效。

另外 volatile是如何禁止指令重排序的,原因在于内存屏障
(1)保证特定的操作的执行顺序
(2)保证某些变量的内存可见性
通过插入内存屏障指令禁止在内存屏障前后的指令执行重排序优化。

我们使用volatile这个关键字来申明这个lazyDoubleCheckSingleton对象,加了这个关键字就可以实现线程安全的延迟初始化,这样重排序就会被禁止。在多线程的时候cpu 也有共享内存,在加了 volatile 关键字之后,所有的线程就都能看到共享内存的执行状态,保证了内存的可见性。用volatile修饰的共享变量,在进行写操作的时候,会多出一些汇编代码,起到两个作用:一是将当前处理器缓存行的数据写回到系统内存,这个写回内存的操作会使在其他cpu里缓存了该内存地址的数据无效。因为其他 cpu 缓存的数据无效了,所以他们又从共享内存同步数据,这样了就保证内存的可见性。这里使用的主要是缓存一致性协议。

通过 volatile 和 double check 这种方式,既兼顾了性能,又兼顾了线程安全。

2.3 静态内部类单例

第二种解决方法:允许线程0里面的2和3重排序,但是不能允许线程1看到这个重排序。基于类初始化的延迟加载解决方案。

public class StaticInnorClassSingleton {
    private static class InnerClass {
        private static StaticInnorClassSingleton staticInnorClassSingleton = new StaticInnorClassSingleton();
    }
 
    public static StaticInnorClassSingleton getInstance() {
        return InnerClass.staticInnorClassSingleton;
    }
 
    private StaticInnorClassSingleton() {
    }
}

静态内部类的单例就完成了,很简单是不是,那它的原理的是啥,我们来分析下。
原理分析:

JVM在类的初始化阶段,也就是class被加载后并且被线程使用之前,都是类的初始化阶段。在这个阶段会执行类的初始化,在执行类的初始化期间,JVM会去获取一个锁,这个锁可以同步多个线程对一个类的初始化,也就是图上绿色的部分。基于这个特性,我们可以实现基于静态内部类的并且是线程安全的延迟初始化单例方案。
那我们看一下这个图,还是线程0和线程1,蓝色和红色。在这种实现模式中,对于右侧的2和3也就是橙色的框,这两个步骤的重排序对于前面我们讲的线程1并不会看到。也就是说非构造线程是不允许看到这个重排序的。
初始化一个类包括执行这个类的静态初始化,还有初始化在这个类中声明的静态变量。根据Java语言规范,主要分为5中情况,首次发生的时候,一个类将被立刻初始化,这里所说的类是泛指,包括接口interface也是一个类。假设这个类是A,现在说一些这几种情况都会导致这个A类被立刻初始化:
首先,第一种情况,有一个A类型的实例被创建;
第二种A类中声明的一个静态方法被调用;
第三种是A类中声明的一个静态成员被赋值;
第四中情况A类中声明的一个静态成员被使用,并且这个成员不是一个常量成员;
前四种用的比较多,第五种用的比较少。如果A类是一个顶级类,并且在这个类中有嵌套的断言语句,这种情况下A类也会被立刻初始化。
前四种是我们经常能碰到的,只要首次发生以上所说的某一个情况,这类都会被初始化。

我们看下这个图,当线程0和线程1试图来获取这个说的时候,也就是获得 class对象的初始化锁,这个时候肯定只有一个线程能获得这个锁

假设线程0获得这个锁了,线程0执行静态内部类的初始化,对于静态内部类即使2,3之间存在重排序,但是线程1是无法看到这个重排序的,因为这里面有一个class 对象的初始化锁。因为这里面有锁,对于线程0而言初始化这个静态内部类的时候,也就是把这个 instance new 出来。2,3怎么排序无所谓,线程1看不到,他还在绿色区域等待。

所以静态内部类是基于类初始化的延迟加载解决方案。

那我们在回到代码里,静态内部类这种方式核心在于InnerClass这个类的 对象的初始化锁,看哪个线程拿到,哪个线程就去初始化它。

2.5 饿汉式单例

搞那么复杂干啥,直接饿汉式不就完了。刚刚说了懒汉式的单例写法,接下来我看看饿汉式的单例写法以及他的优缺点。饿汉式虽然简单,但还是有值得研究的地方。
我们先来写一个饿汉式单例,也就是类加载的时候就初始化了,当然了这个在类加载的时候就初始化我们可以把他做成 final,这样了这个对象就不可改了。因为在加载的时候就给他初始化好了,也不需要更改了。

public class HungrySingleton {
    private final static HungrySingleton hungrySingleton = new HungrySingleton(); //第一种
 
    private HungrySingleton() {
    }
 
    public static HungrySingleton getInstance() {
        return hungrySingleton;
    }
}

优点:写法简单,类加载的时候就完成了初始化,避免了线程同步问题

缺点也是在类加载的时候就完成了初始化,没有延迟加载的效果。

如果这个类从始至终系统都没有用过,还会造成内存的浪费。

我们也可以把这个单例的实例化对象的过程放到静态代码块中:

public class HungrySingleton {
    private final static HungrySingleton hungrySingleton;  //第二种
 
    static {
        hungrySingleton = new HungrySingleton();
    }
 
    private HungrySingleton() {
    }
 
    public static HungrySingleton getInstance() {
        return hungrySingleton;
    }
}

声明为final的变量必须在类加载完成时就已经赋值。

第一种方式就像代码1之前那样直接 new 出来,或者放到静态块里,这个都能完成在类加载完成时就完成赋值。
所以在前面的 lazy 模式中是不能被修饰成 final 的,因为他并不是在类加载的时候就初始化 。

第二种是把创建对象的过程放到了 static 静态代码块里面,那类加载的时候也会执行静态代码块中的代码进行初始化这个类的实例。

看上去饿汉式是最简单的,如果资源浪费少的化,用这种方式也是非常方便的 。

三 单例破坏与防御

经过前面的分析迭代,单例也越来越成熟,那是不是坚不可摧呢?有没有什么方式能破坏他及怎么防止破坏了?接下来我们一起来探讨下,用序列化和反序列化以及反射来破坏单例模式。

3.1 序列化与反序列化破坏单例

使用饿汉式来做测试:

public class HungrySingleton{
    private static HungrySingleton hungrySingleton;
 
    static {
        hungrySingleton = new HungrySingleton();
    }
 
    private HungrySingleton() {
    }
 
    public static HungrySingleton getInstance() {
        return hungrySingleton;
    }
}

想像一下如果我们把这个单例对象序列化到一个文件中,然后再从文件里取出来,那这两个对象还是同 一个对象吗?现在我们就来测试一下。

public class Test {
    public static void main(String[] args) throws Exception {
        //首先声明一个instance
        HungrySingleton hungrySingleton = HungrySingleton.getInstance();
        ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton_file"));
        oos.writeObject(hungrySingleton);
 
        File file = new File("singleton_file");
        ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file));
 
        HungrySingleton newInstance = (HungrySingleton) ois.readObject();
 
        System.out.println(hungrySingleton);
        System.out.println(newInstance);
        System.out.println(hungrySingleton == newInstance);
 
    }
}

运行一下,可以看到如下输出:

HungrySingleton没有实现序列化,ok,让HungrySingleton实现序列化:public class HungrySingleton implements Serializable

再次运行,输出:

com.itermis.pattern.creational.singleton.HungrySingleton@a06812d
com.itermis.pattern.creational.singleton.HungrySingleton@7ceca6e7
false

两个对象不相等,这就违背了单例的初衷,通过序列化和反序列化拿到了不同的对象,而我们了想拿到同一个对象,那这个事情要怎么解了?其实解换个问题也不难,重要的是要理解为什么这么解,理解他的原理是什么。

在HungrySingleton中增加如下方法:

private Object readResolve() {
   return hungrySingleton;
}

再次运行,得出如下结果:

com.itermis.pattern.creational.singleton.HungrySingleton@55d43df5
com.itermis.pattern.creational.singleton.HungrySingleton@55d43df5
true

是不是很神奇。大伙是不是很疑惑,为什么要写这个方法。为什么呢?从 override里面也没有这个方法,他并不是 Object对象的方法,那方法名字为什么又叫readResolve了。叫别的名字不行吗。

让我们回到上面的序列化与反序列化代码中,看下我们使用的类,ObjectOutputStream,ObjectInputStream。我们重点看下ObjectInputStream 这个类的 readObject 方法,我们点进去看看:

重点看下红色框里的这段代码,他又调用了 readObject0这方法,点进去看下,这个方法就是读取对象。

我们的代码会走到这个 object 这里,这里调用了两个方法。所以了这个方法是通过反射出来的,也没什么继承关系,所以只能直接把这个方法名字写到这里。

3.2 反射破坏单例

还是用上面序列化攻击的单例做测试,因为这个单例的构造是是有的,是无法 new 的,但是通过反射确实能获取到这个对象的。

public class Test {
    public static void main(String[] args) throws Exception {
        Class objectClass = HungrySingleton.class;
        Constructor constructors = objectClass.getDeclaredConstructor();
        constructors.setAccessible(true);
        HungrySingleton instance = HungrySingleton.getInstance();
        HungrySingleton newInstance = (HungrySingleton) constructors.newInstance();
        System.out.println(instance);
        System.out.println(newInstance);
        System.out.println(instance == newInstance);
    }
}

运行测试方法,输出:

com.itermis.pattern.creational.singleton.HungrySingleton@1a61c596
com.itermis.pattern.creational.singleton.HungrySingleton@57816fb6
false

对于饿汉式有什么特点,首先是在类加载的时候就生成了实例,因为是在类加载时就生成,所以我们可以在构造器里面进行一个判断。现在了我们就写一些反射防御的代码:

public class HungrySingleton {
    private static HungrySingleton hungrySingleton;

    static {
        hungrySingleton = new HungrySingleton();
    }

    private HungrySingleton() {
        if (hungrySingleton != null) {
            throw new RuntimeException("单例构造器禁止反射调用");
        }
    }

    public static HungrySingleton getInstance() {
        return hungrySingleton;

再次运行Test:

以上这种方式有一个特点,也就是说它对类加载这个时刻就把对象创建好了的这种类是OK的。对于这种单例模式是有效的。静态内部类单例也可以用这种方式进行防御。

对于不是在类加载的时候创建单例对象的这种情况,有如何应对反射攻击呢?我们把throw new RuntimeException("单例构造器禁止发射调用”)这段代码加进懒加载单例中,
这个了就要根据我们创建这个实例的顺序有关了,为什么这么说了。对于懒加载的单例情况,反射是无法避免的。因为一旦多线程就和顺序无关,反射如果先进来那就会拿两个对象。

3.3 其他单例类型

接下来了还有一种单列模式,这种模式即能防御反射,又能保证不被序列化破坏。

枚举单例

public enum EnumInstance {
    INSTANCE {
        @Override
        protected void printTest() {
            System.out.println("itermis print test");
        }
    };

    protected abstract void printTest();

    private Object data;

    public Object getData() {
        return data;
    }

    public void setData(Object data) {
        this.data = data;
    }

    public static EnumInstance getInstance() {
        return INSTANCE;
    }
}

枚举单例反射无法攻击,对于序列化的破坏也是不受影响的。

那序列化和反序列化对枚举是怎么处理的呢?因为枚举中的 name是唯一的,并且对应一个枚举常量,所以拿到的肯定是唯一的常量对象。这样了就没有创建新的对象,维持了这个对象的单例属性。

序列化不受影响,那看看反射呢?

对于枚举类型,是不能通过反射进行对象创建的。

对于容器单例和ThreadLocal线程单例就不展开了,感兴趣的可以去了解下。这里给出他们的写法

容器单例

public class ContainerSingleton {
    private ContainerSingleton() {
    }

    public static Map<String, Object> singletonMap = new HashMap<>(16);

    public static void putInstance(String key, Object instance) {
        if (StringUtils.isNotBlank(key) && instance != null) {
            if (!singletonMap.containsKey(key)) {
                singletonMap.put(key, instance);
            }
        }
    }

    public static Object getInstance(String key) {
        return singletonMap.get(key);
    }

}

ThreadLocal线程单例

public class ThreadLocalSingleton {
    private static final ThreadLocal<ThreadLocalSingleton> threadLocalSingleton = new ThreadLocal<ThreadLocalSingleton>() {
        @Override
        protected ThreadLocalSingleton initialValue() {
            return new ThreadLocalSingleton();
        }
    };

    private ThreadLocalSingleton() {
    }

    public static ThreadLocalSingleton getInstance() {
        return threadLocalSingleton.get();
    }
}

ThreadLocal线程单例不能保证全局唯一,但是可以保证线程唯一。

四 总结
本文给大家介绍了各种单例的写法,以及常见的两种攻击手段,这里做下简单的总结: