面试官,你的单例模式能保证百分之百单例吗?

baojunzh
发布于 2022-11-18 11:06
浏览
0收藏

真快,金三银四面试季就要过去了,你拿到心仪的offer了吗?


因为这次疫情你觉得面试简单了还是更难了?我觉得既简单又难,简单是因为不需要背着包到处跑,不需要打印简历,都是电话面、视频面,非常的便利,难是因为有很多中小公司因此而裁员甚至倒闭。


我的一个小伙伴也趁着这个机会面了几家试了试水,其中有面试官问到了一个问题:使用过单例模式吗?单例模式有哪些实现方式?你用过哪些?你的单例模式能保证百分之百单例吗?


朋友就列举了几种实现方式并且比较了几种方式的优缺点,但对于最后一个问题他当时就想:单例模式不就是单例的吗?事后我告诉他真相,他才恍然大悟,连连感谢


我猜肯定还有不少小伙伴不知道这个,所以今天就科普一下单例模式,如何打破单例模式以及如何保证百分百的单例。其实我很早前就写过一篇类似的文章,谁叫你不看呢



单例模式的基本概念

什么是单例

单例模式是Java设计模式中最简单也是最常用的模式之一。所谓单例就是在系统中只有一个该类的实例,并且提供一个访问该实例的全局访问方法。

单例的实现步骤

单例模式的实现分为三个步骤:

  1. 构造方法私有化。即不能在类外实例化,只能在类内实例化。
  2. 在本类中创建本类的实例。必须自己创建该唯一实例。
  3. 在本类中提供给外部获取实例的方式。提供访问该实例的全局访问方法。

单例模式常见应用场景

Windows任务管理器

数据库连接池

Java中的Runtime

Spring中Bean的默认生命周期  

单例模式的优点

提供了唯一实例的全局访问方法,可以优化共享资源的访问

避免对象的频繁创建和销毁,可以提高性能 

单例的具体实现方式

饿汉式-静态变量

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

public class Singleton{
    //静态变量保存实例变量
    public static Singleton instance = new Singleton();
    //构造器私有化
    private Singleton() {
    }
    //提供访问该实例的全局访问方法
    public static Singleton getInstance(){
        return instance;
    }
}

这里将类的构造器私有化,就不能在外部通过new关键字创建该类的实例,然后定义了一个该类的私有静态变量,接着定义了一个公有getInstance()方法以便外部能够获得该类实例。

优点

getInstance()性能好,线程安全,实现简单。

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

缺点

不能实现懒加载,造成空间浪费。

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


饿汉式-静态代码块

这种方式和上面的静态常量/变量类似,只不过把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 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"));
    }
    //getter and setter...
}

Test

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

输出


面试官,你的单例模式能保证百分之百单例吗?-鸿蒙开发者社区

懒汉式

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

public class Singleton1 {
    //定义静态实例对象,但不初始化
    private static Singleton1 instance = null;
    //构造方法私有化
    private Singleton1() {
    }
    //提供全局访问方法
    public static Singleton1 getInstance() {
        if (instance == null) {
            instance = new Singleton1();
        }
        return instance;
    }
}

同样是构造方法私有化,提供给外部获得实例的方法,getInstance()方法被调用时创建实例。

优点

getInstance()性能好,延迟初始化

缺点

适用于单线程环境,多线程下可能发生线程安全问题,导致创建不同实例的情况发生。

可以看下面的演示。非线程安全演示:

public class LazyUnsafeSingletionTest{
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ExecutorService es = Executors.newFixedThreadPool(2);
        Callable<Singleton1> c1 = new Callable<Singleton1>(){
            @Override
            public Singleton1 call() throws Exception {
                return Singleton1.getInstance();
            }
        };
        Callable<Singleton1> c2 = new Callable<Singleton1>(){
            @Override
            public Singleton1 call() throws Exception {
                return Singleton1.getInstance();
            }
        };
        Future<Singleton1> submit = es.submit(c1);
        Future<Singleton1> submit1 = es.submit(c2);
        Singleton1 lazyUnsafeSingleton = submit.get();
        Singleton1 lazyUnsafeSingleton1 = submit1.get();
        es.shutdown();
        System.out.println(lazyUnsafeSingleton);
        System.out.println(lazyUnsafeSingleton);
        System.out.println(lazyUnsafeSingleton1==lazyUnsafeSingleton);
    }
}

输出 


面试官,你的单例模式能保证百分之百单例吗?-鸿蒙开发者社区

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


懒汉式 + synchronized

通过使用synchronized修饰getInstance()方法保证同步访问该方法,但是访问性能不高。

public class Singleton1 {
    //定义静态实例对象,但不初始化
    private static Singleton1 instance = null;
    //构造方法私有化
    private Singleton1() {
    }
    //提供全局访问方法 synchronized同步访问getInstance
    public static synchronized Singleton1 getInstance() {
        if (instance == null) {
            instance = new Singleton1();
        }
        return instance;
    }
}

优点

线程安全,延迟初始化

缺点

getInstance()性能不好(使用了synchronized修饰访问需要同步,并发访问性能不高)


懒汉式 + Double check

解决懒汉式 + synchronized 访问性能不高的问题

public class Singleton1 {
    //定义静态实例对象,但不初始化
    private static Singleton1 instance = null;
    //构造方法私有化
    private Singleton1() {
    }
    //提供全局访问方法 synchronized同步控制创建实例
    public static Singleton1 getInstance() {
        if (instance == null) {
            synchronized (Singleton1.class) {
                if (instance == null) {
                    instance = new Singleton1();
                }
            }
        }
        return instance;
    }
}

优点

getInstance()访问性能高,延迟初始化

缺点

非线程安全?

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


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

面试官,你的单例模式能保证百分之百单例吗?-鸿蒙开发者社区


可不可以只加内层判空呢?

答案是可以。

那为什么还要加外层判空的呢?

内层判空已经可以满足线程安全了,加外层判空的目的是为了提高效率。

因为可能存在这样的情况:如果不加外层判空,线程1拿到锁后执行同步代码块,在new之后,还没有释放锁的时候,线程2过来了,它在等待锁(此时线程1已经创建了实例,只不过还没释放锁,线程2就来了),然后线程1释放锁后,线程2拿到锁,进入同步代码块中,判空不成立,直接返回实例。

这样提高了效率是不是不用去等待锁了?因为线程1已经创建了实例,只不过还没释放锁。

所以在外层又加了一个判空就是为了防止这种情况,线程2过来后先判空,不为空就不用去等待锁了,这样提高了效率。


懒汉式 + Double check + volatile

双重检查锁模式是一种非常好的单例实现模式,解决了单例、性能问题,上面的双重检测锁模式看上去完美无缺,其实是存在问题,那就是上面缺点中,线程安全后面打问号的原因。

在多线程的情况下,双重检查锁模式可能会出现空指针问题,出现问题的原因是JVM在实例化对象的时候会进行优化和指令重排序操作。什么是指令重排?上面的instance = new Singleton1();这行代码并不是一个原子指令,会被分割成多个指令:

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


经过指令重排后的代码顺序:

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


实例化对象实际上可以分解成以下4个步骤:

  1. 为对象分配内存空间
  2. 初始化默认值(区别于构造器方法的初始化),
  3. 执行构造器方法

将对象指向刚分配的内存空间

编译器或处理器为了性能的原因,可能会将第3步和第4步进行重排序:

  1. 为对象分配内存空间
  2. 初始化默认值
  3. 将对象指向刚分配的内存空间
  4. 执行构造器方法

线程可能获得一个初始化未完成的对象......


若有线程1进行完重排后的第二步,且未执行初始化对象。此时线程2来取instance时,发现instance不为空,于是便返回该值,但由于没有初始化完该对象,此时返回的对象是有问题的。这也就是为什么说看似稳的一逼的代码,实则不堪一击。 上述代码的改进方法:将instance声明为volatile类型即可(volatile有内存屏障的功能)。

private static volatile Singleton1 instance = null;

内部类

该方式天然线程安全,适用于多线程,利用了内部类的特性:加载外部类时不会加载内部类,在内部类被加载和初始化时,才创建实例。静态内部类不会自动随着外部类的加载和初始化而初始化,它是要单独加载和初始化的。因为我们的单例对象是在内部类加载和初始化时才创建的,因此它是线程安全的,且实现了延迟初始化。

public class LazyInnerSingleton{
    private LazyInnerSingleton() {
    }
    private static class Inner{
        private static LazyInnerSingleton instance = new LazyInnerSingleton();
    }
    public static LazyInnerSingleton getInstance(){
        return Inner.instance;
    }
}

优点

getInstance()访问性能高,延迟初始化,线程安全


前面实现方式可能存在的问题:

  • 需要额外的工作来实现序列化,否则每次反序列化一个序列化的对象时都会创建一个新的实例,如果没有自定义序列化方式则单例有被破坏的风险。
  • 可以使用反射强行调用私有构造器,单例有被破坏的风险。

《Effective Java》中推荐使用Enum来创建单例对象

  • 枚举类很好的解决了这两个问题,使用枚举除了线程安全和防止反射调用构造器之外,还提供了自动序列化机制,防止反序列化的时候创建新的对象。

枚举

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

public enum SingletonEnum {
    INSTANCE;
    public void otherMethod(){
        System.out.println("枚举类里的方法");
    }
}


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

public class SingletonEnumTest {
    public static void main(String[] args) {
        SingletonEnum instance = SingletonEnum.INSTANCE;
        System.out.println(instance);
        instance.otherMethod();
    }
}

输出结果

面试官,你的单例模式能保证百分之百单例吗?-鸿蒙开发者社区


优点

getInstance()访问性能高,线程安全

缺点

非延迟初始化


破坏单例模式的方法及预防措施

上面介绍枚举实现单例模式前已经介绍了除枚举外的其他单例模式实现方式存在的两个问题,也正是这两个问题,导致了单例模式若不采取措施,会有被破坏的可能。

1、除枚举方式外,其他方法都会通过反射的方式破坏单例。

反射是通过强行调用私有构造方法生成新的对象,所以如果我们想要阻止单例破坏,可以在构造方法中进行判断,若已有实例,,则阻止生成新的实例,解决办法如下:

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

2、如果单例类实现了序列化接口Serializable, 就可以通过反序列化破坏单例。

所以我们可以不实现序列化接口,如果非得实现序列化接口,可以重写反序列化方法readResolve(),反序列化时直接返回相关单例对象。

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

总结

单例模式,从加载时机方面来说分为饿汉模式和懒汉模式,从程序安全性方面来说分为线程安全和非线程安全的。最后总结一下单例模式各种实现方式的优缺点。

方式

优点

缺点

饿汉式 - 静态变量

线程安全,访问性能高

不能延迟初始化

饿汉式 - 静态代码块

线程安全,访问性能高,支持额外操作

不能延迟初始化

懒汉式

访问性能高,延迟初始化

非线程安全

懒汉式 + synchronized

线程安全,延迟初始化

访性能不高

懒汉式 + Double check

线程安全,延迟初始化

非线程安全

懒汉式 + Double check + volatile

线程安全,延迟初始化,访问性能高

-

内部类

线程安全,延迟初始化,访问性能高

-

枚举

线程安全,访问性能高,安全

不能延迟初始化

后三种用的较多,根据自己的实际场景选择不同的单例模式。


本文转载自公众号BiggerBoy

分类
已于2022-11-18 11:06:59修改
收藏
回复
举报
回复
    相关推荐