重温设计模式-单例模式

249 阅读7分钟

引子

最近一段时间在准备面试,为了面试于是准备重新复习一下设计模式,同时我相信这也是一个很好的重新学习的机会。

为什么需要单例模式

单例模式算得上是我第一个接触到的设计模式,那还是在大学期间在学习spring框架的时候老师为了说明spring的优点于是举了一个单例对象和多例对象的例子,但由于那个时候水平很菜(虽然现在也菜)并不能完全理解,于是在课后查询了资料,对单例模式也算有了一个初步认识,貌似扯远了那下面我们直接开始。

我们都知道Java可以通过new进行对象创建,当我们需要使用一个对象的方法时就会进行new,比如 Object obj = new Object();,要知道Java每次进行一次new操作时都会在堆内存创建一个对象,可能对于没接触过JVM的同学来说有点难以理解,可以结合下面这张图:

image.png 这张图画的比较简单主要是便于理解想要详细了解的可以去看看JVM方面的知识,可以看到每个对象都占用了一定的内存空间,但是现实是很多情况下,对于某些对象我们只需要调用它的一些方法,其中并没有状态的变化,每次使用都创建一个新的对象对于内存空间实在有些浪费,要知道内存是很宝贵的资源。为了解决这个问题便诞生了单例模式,我们还是用一张图说明,单例模式下对对象的使用如下:

截屏2023-04-20 11.00.30.png 可以看到我们只创建了一个对象,这个对象被多方调用,很好的节省了内存空间。

单例模式的实现

了解了由来就要开始动手实现了,只有动手写了才能真正理解。

饿汉式

代码实现如下:

public class Singleton {

    private static Singleton instance = new Singleton();

    private Singleton(){

    }

    public static Singleton getInstance(){
        return instance;
    }
}

这段代码的要点如下:

  1. 创建了一个私有的静态对象,在类加载之后就会创建对象
  2. 构造方法私有化,保证不能被外部对象构造
  3. 提供静态方法用于获取对象

这样一个简单的单例模式就实现了,但是这段代码也还有能够优化的点,首先如它的名字饿汉式那样在类加载之后就会初始化对象,这样虽然能够保证它的线程安全,但是如果我们并没有使用这个对象,它却依然在内存中占用了空间,那么有没有办法能够让我们在需要的时候才初始化呢?出于这个需要于是懒汉式就诞生了。

懒汉式

我们还是先看代码:

public class Singleton {

    private static Singleton instance;

    private Singleton() {

    }

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

对比饿汉式,可以看到只有当我们调用静态方法获取对象时才会初始化,这样就解决了饿汉式的内存占用问题,但是这段代码就完美了吗?我们可以套用其它的场景来看这段代码,比如在多线程情况下会不会有线程安全问题呢?

假设有线程A,B,线程A首先调用方法,当走到instance==null后(注意此时因此通过了判断)因为调度策略线程被切换了,此时线程B进入,一路执行下去并且创建了对象,最后结束。调度又重新回到了线程A,A继续往下执行,又创建了一个对象。就出现了线程安全问题。

接触过并发编程的人都知道,如果出现并发问题了我们就加锁(当然这不是说并发问题只能通过加锁解决)。

懒汉式(线程安全)

实现如下:

public class Singleton {

    private static Singleton instance;

    private Singleton() {

    }

    public static synchronized Singleton getInstance() {
        if (instance==null){
            return new Singleton();
        }
        return instance;
    }
}

我们在静态方法上加了一把锁,在调用方法之前需要获得锁保证了只能有一个线程进入初始化,解决了并发问题。

同样的我们还可以问一问自己,这种实现方式会有什么问题呢?

锁在方法上意味着每次调用方法都需要获取锁,即使对象已经实例化依然需要获取锁,要知道对于锁的使用,我们锁住的范围应该尽可能的小,那么对于这种方法我们是不是能够只锁方法内的部分代码呢?

懒汉式双重校验

部分加锁的代码实现如下:

public class Singleton {

    private static Singleton instance;

    private Singleton() {

    }

    public static Singleton getInstance() {
        if (instance==null) {
            synchronized (Singleton.class) {
                if (instance ==null){
                    return new Singleton();
                }
            }
        }
        return instance;
    }
}

可以看到我们首先判断对象是否被实例化,如果没有被实例化才会进入锁的部分,这样在对象实例化之后就不用重复获取锁提高了并发度,那么为什么在内部还要判空呢?

我们可以通过反证法来理解这个双重校验的模式。在没有内部加判断的前提下,假设有两个线程A,B,首先线程A通过外部判断,未获取锁,然后时间片切换,线程B执行,线程B一路执行完成,此时切换回线程A获取了锁,由于没有判空直接创建了对象。由此可见,外部的判断是为了避免对象实例化后重复获取锁的消耗,内部的判断是为了避免对象的重复创建。

看起来好像已经没什么问题了,但是上面的代码真的线程安全吗?要知道Java是有乱序执行机制的(不清楚的可以搜索了解一下),如果在这种机制下上面的代码会没有问题吗?

我们可以模拟一下过程,要知道instance=new Singleton()其实可以拆分为三步:

  1. instance分配空间
  2. 对象初始化
  3. instance指向内存中的对象 正常来说应该是1->2->3的执行顺序,但是由于乱序执行的机制,很可能会变成1->3->2,如果是单线程自然不会有问题,如果是多线程,线程A只执行了1->3,此时线程B调用方法发现实例不为空则直接获取内存中的空间,然而此时对象还没有初始化。

既然问题是指令乱序执行引起的那么禁止就好了,Java中提供了volatile关键字,使用后就能禁止指令的重排序,在添加后我们的最终版本代码如下:

public class Singleton {

    private volatile static Singleton instance;

    private Singleton() {

    }

    public static Singleton getInstance() {
        if (instance==null) {
            synchronized (Singleton.class) {
                if (instance ==null){
                    return new Singleton();
                }
            }
        }
        return instance;
    }
}

乱序执行是为了提高性能才使用的,使用vlotile自然会有性能损耗,加锁也会带来损耗,如果在突然有大量并发的情况下很可能会导致大量线程等待锁,虽然不会有并发问题,但是占用资源,那么有没有办法不使用锁也能保证线程安全呢?

静态内部类实现(线程安全)

老规矩先看代码:

public class Singleton {

    private Singleton() {

    }

    public static class SingletonHandler {
        private static Singleton instance = new Singleton();
    }


    public static Singleton getInstance() {
        return SingletonHandler.instance;
    }
}

通过创建静态内部类的方式,这样就避免了加载时将实例加载到内存中,同时通过JVM保证实例只会被初始化一次,即做到了延迟实例化,又保证了线程安全。

枚举类实现

代码实现如下:

public enum Singleton {
    INSTANCE;

    public void doSomeThing(){
        
    }
}

基于枚举的实现,线程安全,支持单例,防反射与反序列化。

总结与参考

在文章中我分享了对单例模式的理解与一些常用的实现方式,学习设计模式能够帮助我们写出质量更高的代码,虽然比较基础但是学习是个循序渐进的过程,一步一个脚印才能走的更远更稳。

以下是文章参考的内容:

  1. 《大话设计模式》

  2. 代码随想录知识星球精华-大厂面试八股文第二版