单例模式的几种实现方式及对比


所谓单例就是在系统中只有一个该类的实例。单例模式(Singleton),也叫单子模式,是一种常用的软件设计模式。在应用这个模式时,单例对象的类必须保证只有一个实例存在。许多时候整个系统只需要拥有一个全局的对象,这样有利于我们协调系统整体的行为。比如在某个服务器程序中,该服务器的配置信息存放在一个文件中,这些配置数据由一个单例对象统一读取,然后服务进程中的其他对象再通过这个单例对象获取这些配置信息。这种方式简化了在复杂环境下的配置管理。


单例模式实现有以下三个核心步骤:

  1. 构造方法私有化。即不能在该类之外实例化该类(不能再别处new该类),只能在类内实例化。
  2. 在本类中创建本类的实例。
  3. 在本类中提供给外部获取实例的方式。

单例模式的实现方式有两种:饿汉模式和懒汉模式。

1、饿汉模式

不管现在需不需要,先创建实例。关键在于“饿”,饿了就要立即吃。

1.1 静态常量

这里将类的构造器私有化,就不能在外部通过new关键字创建该类的实例,然后定义了一个该类的常量,用static修饰,以便外部能够获得该类实例(通过HungryStaticConstantSingleton.INSTANCE 获得)。也可以不加final关键字,具体看自己的需求。或者将INSTANCE私有化,并提供静态方法getInstance返回INSTANCE实例。

 1 /**
 2  * 恶汉模式-静态常量,简洁直观
 3  */
 4 public class HungryStaticConstantSingleton{
 5     //构造器私有化
 6     private HungryStaticConstantSingleton() {
 7     }
 8     //静态变量保存实例变量 并提供给外部实例
 9     public final static HungryStaticConstantSingleton INSTANCE = new HungryStaticConstantSingleton();
10 }

优点

  • 由于使用了static关键字,保证了在引用这个变量时,关于这个变量的所有写入操作都完成,所以保证了JVM层面的线程安全

缺点

  • 不能实现懒加载,造成空间浪费。如果一个类比较大,我们在初始化的时就加载了这个类,但是我们长时间没有使用这个类,这就导致了内存空间的浪费。

1.2 枚举

这种方式是最简洁的,不需要考虑构造方法私有化。值得注意的是枚举类不允许被继承,因为枚举类编译后默认为final class,可防止被子类修改。常量类可被继承修改、增加字段等,容易导致父类的不兼容。枚举类型是线程安全的,并且只会装载一次,设计者充分的利用了枚举的这个特性来实现单例模式,枚举的写法非常简单,而且枚举类型是所有单例实现中唯一一种不会被破坏的单例实现模式

/**
 * 恶汉-枚举形式,最简洁
 */
public enum HungryEnumSingleton{
    INSTANCE;
    
    public void print(){
        System.out.println("这是通过枚举获得的实例");
        System.out.println("HungryEnumSingleton.pring()");
    }
}

Test,打印实例直接输出了【INSTANCE】,是因为枚举帮我们实现了toString,默认打印名称。

public class EnumSingleton2Test{
    public static void main(String[] args) {
        HungryEnumSingleton singleton2 = HungryEnumSingleton.INSTANCE;
        System.out.println(singleton2);
        singleton2.print();
    }
}

 输出结果

1.3 静态代码块

这种方式和上面的静态常量/变量类似,只不过把new放到了静态代码块里,从简洁程度上比不过第一种。但是把new放在static代码块有别的好处,那就是可以做一些别的操作,如初始化一些变量,从配置文件读一些数据等。

/**
 * 恶汉模式-静态代码块
 */
public class HungryStaticBlockSingleton{

    //构造器私有化
    private HungryStaticBlockSingleton() {
    }

    //静态变量保存实例变量
    public static final HungryStaticBlockSingleton INSTANCE;

    static {
        INSTANCE = new HungryStaticBlockSingleton();
    }
}

如下,在static代码块里读取 info.properties 配置文件动态配置的属性,赋值给 info 字段。

/**
 * 恶汉模式-静态代码块
 * 这种用于可以在静态代码块进行一些初始化
 */
public class HungryStaticBlockSingleton{

    private String info;

    private HungryStaticBlockSingleton(String info) {
        this.info = info;
    }

    //构造器私有化
    private HungryStaticBlockSingleton() {
    }

    //静态变量保存实例变量
    public static final HungryStaticBlockSingleton INSTANCE;

    static {
        Properties properties = new Properties();
        try {
            properties.load(HungryStaticBlockSingleton.class.getClassLoader().getResourceAsStream("info.properties"));
        } catch (IOException e) {
            e.printStackTrace();
        }
        INSTANCE = new HungryStaticBlockSingleton(properties.getProperty("info"));
    }
    public String getInfo() {
        return info;
    }

    public void setInfo(String info) {
        this.info = info;
    }
}

Test,

public class HungrySingletonTest{
    public static void main(String[] args) {
        HungryStaticBlockSingleton hun = HungryStaticBlockSingleton.INSTANCE;
        System.out.println(hun.getInfo());
    }
}

输出

2、懒汉模式

需要时再创建,关键在于“懒”,类似懒加载。

2.1 非线程安全

同样是构造方法私有化,提供给外部获得实例的方法,getInstance()方法被调用时创建实例。该方式适用于单线程,因为在多线程的情况下可能会发生线程安全问题,导致创建不同实例的情况发生。可以看下面的演示。

 1 /**
 2  * 懒汉模式-线程不安全的,适用于单线程
 3  */
 4 public class LazyUnsafeSingleton{
 5     private LazyUnsafeSingleton(){
 6     }
 7     private static LazyUnsafeSingleton instance;
 8     public static LazyUnsafeSingleton getInstance(){
 9         if(instance==null){
10             instance = new LazyUnsafeSingleton();
11         }
12         return instance;
13     }
14 }

非线程安全演示

 1 public class LazyUnsafeSingletionTest{
 2     public static void main(String[] args) throws ExecutionException, InterruptedException {
 3         ExecutorService es = Executors.newFixedThreadPool(2);
 4         Callable c1 = new Callable(){
 5             @Override
 6             public LazyUnsafeSingleton call() throws Exception {
 7                 return LazyUnsafeSingleton.getInstance();
 8             }
 9         };
10         Callable c2 = new Callable(){
11             @Override
12             public LazyUnsafeSingleton call() throws Exception {
13                 return LazyUnsafeSingleton.getInstance();
14             }
15         };
16         Future submit = es.submit(c1);
17         Future submit1 = es.submit(c2);
18         LazyUnsafeSingleton lazyUnsafeSingleton = submit.get();
19         LazyUnsafeSingleton lazyUnsafeSingleton1 = submit1.get();
20         es.shutdown();
21 
22         System.out.println(lazyUnsafeSingleton);
23         System.out.println(lazyUnsafeSingleton);
24         System.out.println(lazyUnsafeSingleton1==lazyUnsafeSingleton);
25     }
26 }

输出 大概运行三次就会出现一次,我们可以在 LazyUnsafeSingleton 中判断 if(instance==null) 之后增加线程休眠以获得更好的效果。

2.2 线程安全的双重检查锁模式

该方式是懒汉模式中线程安全的创建方式。通过同步代码块控制并发创建实例。并且采用双重检验,当两个线程同时执行第一个判空时,都满足的情况下,都会进来,然后去争锁,假设线程1拿到了锁,执行同步代码块的内容,创建了实例并返回,此时线程2又获得锁,执行同步代码块内的代码,因为此时线程1已经创建了,所以线程2虽然拿到锁了,如果内部不加判空的话,线程2会再new一次,导致两个线程获得的不是同一个实例。线程安全的控制其实是内部判空在起作用,至于为什么要加外面的判空下面会说。

/**
 * 懒汉模式-线程安全,适用于多线程
 */
public class LazySafeSingleton{
    private static volatile LazySafeSingleton safeSingleton;//防止指令重排
    private LazySafeSingleton() {
    }
    public static LazySafeSingleton getInstance(){
        if(safeSingleton==null){
            synchronized (LazySafeSingleton.class){
                if(safeSingleton==null){//双重检测
                    safeSingleton = new LazySafeSingleton();
                }
            }

        }
        return safeSingleton;
    }
}

 当不加内层判空时,会出现不是单例的情况,只不过出现的概率更低了点。

可不可以只加内层判空呢?答案是可以。

那为什么还要加外层判空的呢?内层判空已经可以满足线程安全了,加外层判空的目的是为了提高效率。因为可能存在这样的情况:线程1拿到锁后执行同步代码块,在new之后,还没有释放锁的时候,线程2过来了,它在等待锁(此时线程1已经创建了实例,只不过还没释放锁,线程2就来了),然后线程1释放锁后,线程2拿到锁,进入同步代码块汇总,判空,返回。这种情况线程2是不是不用去等待锁了?所以在外层又加了一个判空就是为了防止这种情况,线程2过来后先判空,不为空就不用去等待锁了,这样提高了效率。

双重检查锁模式是一种非常好的单例实现模式,解决了单例、性能、线程安全问题,上面的双重检测锁模式看上去完美无缺,其实是存在问题,在多线程的情况下,可能会出现空指针问题,出现问题的原因是JVM在实例化对象的时候会进行优化和指令重排序操作。什么是指令重排?

上面的safeSingleton = new LazySafeSingleton();操作并不是一个原子指令,会被分割成多个指令:

1 memory = allocate(); //1:分配对象的内存空间
2 ctorInstance(memory); //2:初始化对象
3 instance = memory; //3:设置instance指向刚分配的内存地址

经过指令重排

1 memory = allocate(); //1:分配对象的内存空间
2 instance = memory; //3:设置instance指向刚分配的内存地址,此时对象还没被初始化
3 ctorInstance(memory); //2:初始化对象

若有A线程进行完重排后的第二步,且未执行初始化对象。此时B线程来取singletonTest时,发现singletonTest不为空,于是便返回该值,但由于没有初始化完该对象,此时返回的对象是有问题的。这也就是为什么说看似稳的一逼的代码,实则不堪一击。 

上述代码的改进方法:将safeSingleton声明为volatile类型即可(volatile有内存屏障的功能)。

private static volatile LazySafeSingleton safeSingleton;

2.3 内部类创建外部类实例

该方式天然线程安全,是否final根据自己需要。

 1 /**
 2  * 懒汉模式-线程安全,适用于多线程
 3  * 在内部类被加载和初始化时 才创建实例
 4  * 静态内部类不会自动随着外部类的加载和初始化而初始化,它是要单独加载和初始化的。
 5  * 因为是在内部类加载和初始化时创建的 因此它是线程安全的
 6  */
 7 public class LazyInnerSingleton{
 8     private LazyInnerSingleton() {
 9     }
10     private static class Inner{
11         private static final LazyInnerSingleton INSTANCE = new LazyInnerSingleton();
12     }
13     public static LazyInnerSingleton getInstance(){
14         return Inner.INSTANCE;
15     }
16 }

3、破坏单例模式的方法及解决办法

1、除枚举方式外, 其他方法都会通过反射的方式破坏单例,反射是通过调用构造方法生成新的对象,所以如果我们想要阻止单例破坏,可以在构造方法中进行判断,若已有实例, 则阻止生成新的实例,解决办法如下:

1 private SingletonObject(){
2     if (instance != null){
3         throw new RuntimeException("实例已经存在,请通过 getInstance()方法获取");
4     }
5 }

2、如果单例类实现了序列化接口Serializable, 就可以通过反序列化破坏单例,所以我们可以不实现序列化接口,如果非得实现序列化接口,可以重写反序列化方法readResolve(), 反序列化时直接返回相关单例对象。

1   public Object readResolve() throws ObjectStreamException {
2         return instance;
3    }

4、总结

饿汉模式

  • 静态常量 简洁直观容易理解
  • 枚举 最简洁
  • 静态代码块 可以在静态块里做一些初始化的工作

懒汉模式

  • 单线程形式 该形式下不适用多线程,存在线程安全问题
  • 多线程形式 适用于多线程
  • 内部类形式 最简洁

 
如果你觉得文章不错,欢迎点赞,关注公众号:编程大道