一个单例模式,没必要这么卷吧

2024年 2月 22日 51.2k 0

老猫的设计模式专栏已经偷偷发车了。不甘愿做crud boy?看了好几遍的设计模式还记不住?那就不要刻意记了,跟上老猫的步伐,在一个个有趣的职场故事中领悟设计模式的精髓。还等什么?赶紧上车吧

如果把系统软件比喻成江湖的话,那么设计原则绝对是OO程序员的武功心法,而设计模式绝对是招式。光知道心法是没有用的,还是得配合招式。只有心法招式合二为一,遇到强敌(“坑爹系统”)才能见招拆招,百战百胜。

故事

之前让小猫梳理的业务流程以及代码流程基本已经梳理完毕【系统梳理大法&代码梳理大法】。从代码侧而言也搞清楚了系统臃肿的原因【违背设计原则】。小猫逐渐步入正轨,他决定从一些简单的业务场景入手,开始着手优化系统代码。那么什么样的业务代码,动了之后影响最小呢?小猫看了看,打算就从泛滥创建的线程池着手吧,他打算用单例模式做一次重构。

在小猫接手的系统中,线程池的创建基本是想在哪个类用多线程就在那个类中直接创建。所以基本上很多service服务类中都有创建线程池的影子。

写在前面

遇到上述小猫的这种情况,我们的思路是采用单例模式进行提取公共线程池执行器,然后根据不同的业务类型使用工厂模式进行分类管理。

接下来,我们就单例模式开始吧。

概要

单例模式定义

单例模式(Singleton)又叫单态模式,它出现目的是为了保证一个类在系统中只有一个实例,并提供一个访问它的全局访问点。从这点可以看出,单例模式的出现是为了可以保证系统中一个类只有一个实例而且该实例又易于外界访问,从而方便对实例个数的控制并节约系统资源而出现的解决方案。如下图:

单例模式简单示意图

饿汉式单例模式

什么叫做饿汉式单例?为了方便记忆,老猫是这么理解的,饿汉给人的形象就是有食物就迫不及待地去吃的形象。那么饿汉式单例模式的形象也就是当类创建的时候就迫不及待地去创建单例对象,这种单例模式是绝对线程安全的,因为这种模式在尚未产生线程之前就已经创建了单例。

看一下示例,如下:

/**
 * 公众号:程序员老猫
 * 饿汉单例模式 
 */
public class HungrySingleton {

    private static final HungrySingleton HUNGRY_SINGLETON = new HungrySingleton();

    //构造函数私有化,保证不被new方式多次创建新对象
    private HungrySingleton() {
    }

    public static HungrySingleton getInstance(){
        return HUNGRY_SINGLETON;
    }
}

我们看一下上述案例的优缺点:

  • 优点:线程安全,类加载时完成初始化,获取对象的速度较快。
  • 缺点:由于类加载的时候就完成了对象的创建,有的时候我们无需调用的情况下,对象已经存在,这样的话就会造成内存浪费。

当前硬件和服务器的发展,快于软件的发展,另外的,微服务和集群化部署,大大降低了横向扩展的门槛和成本,所以老猫觉得当前的内存其实是不值钱的,所以上述这种单例模式硬说其缺点有多严重其实也不然,个人觉得这种模式用于实际开发过程中其实是没有问题的。

其实在我们日常使用的spring框架中,IOC容器本身就是一个饿汉式单例模式,spring启动的时候就将对象加载到了内存中,这里咱们不做展开,等到后续咱们梳理到spring源代码的时候再展开来说。

懒汉式单例模式

上述饿汉单例模式我们说它的缺点是浪费内存,因为其在类加载的时候就创建了对象,那么针对这种内存浪费的解决方案,我们就有了“懒汉模式”。对于这种类型的单例模式,老猫是这么理解的,懒汉的定义给人的直观感觉是懒惰、拖延。那么对应的模式上来说,这种方案创建对象的方法也是在程序使用对象前,先判断该对象是否已经实例化(判空),若已实例化直接返回该类对象,否则则先执行实例化操作。

看一下示例,如下:

/**
 * 公众号:程序员老猫
 * 懒汉式单例模式
 */
public class LazySingleton {
    private LazySingleton() {
    }

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

上面这种单例模式创建对象,内存问题看起来是已经解决了,但是这种创建方式真的就线程安全了么?咱们接下来写个简单的测试demo:

public class Main {
    public static void main(String[] args) {
        Thread thread1 = new Thread(()->{
            LazySingleton lazySingleton = LazySingleton.getInstance();
            System.out.println(lazySingleton.toString());
        });
        Thread thread2 = new Thread(()->{
            LazySingleton lazySingleton = LazySingleton.getInstance();
            System.out.println(lazySingleton.toString());
        });
        thread1.start();
        thread2.start();
        System.out.println("end");
    }
}

执行输出结果如下:

end
LazySingleton@3fde6a42
LazySingleton@2648fc3a

从上述的输出中我们很容易地发现,两个线程中所获取的对象是不同的,当然这个是有一定概率性质的。所以在这种多线程请求的场景下,就出现了线程安全性问题。

聊到共享变量访问线程安全性的问题,我们往往就想到了锁,所以,咱们在原有的代码块上加上锁对其优化试试,我们首先想到的是给方法代码块加上锁。

加锁后代码如下:

public class LazySingleton {

    private LazySingleton() {
    }

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

经过上述同样的测试类运行之后,我们发现问题似乎解决了,每次运行之后得到的结果,两个线程对象的输出都是一致的。

我们用线程debug的方式看一下具体的运行情况,如下图:

线程输出

我们可以发现,当一个线程进行初始化实例时,另一个线程就会从Running状态自动变成了Monitor状态。试想一下,如果有大量的线程同时访问的时候,在这样一个锁的争夺过程中就会有很多的线程被挂起为Monitor状态。CPU压力随着线程数的增加而持续增加,显然这种实现对性能还是很有影响的。

那还有优化的空间么?当然有,那就是大家经常听到的“DCL”即“Double Check Lock” 实现如下:

/**
 * 公众号:程序员老猫
 * 懒汉式单例模式(DCL)
 * Double Check Lock
 */
public class LazySingleton {

    private LazySingleton() {
    }
    //使用volatile防止指令重排
    private volatile static LazySingleton lazySingleton = null;
    public static LazySingleton getInstance() {
        if (lazySingleton == null) {
            synchronized (LazySingleton.class) {
                if(lazySingleton == null){
                    lazySingleton =  new LazySingleton();
                }
            }
        }
        return lazySingleton;
    }
}

通过DEBUG,我们来看一下下图:

双重校验锁

这里引申一个常见的问题,大家在面试的时候估计也会碰到。问题:为什么要double check?去掉第二次check行不行?

回答:当2个线程同时执行getInstance方法时,都会执行第一个if判断,由于锁机制的存在,会有一个线程先进入同步语句,而另一个线程等待,当第一个线程执行了new Singleton()之后,就会退出synchronized的保护区域,这时如果没有第二重if判断,那么第二个线程也会创建一个实例,这就破坏了单例。

问题:这里为什么要加上volatile修饰关键字?回答:这里加上该关键字主要是为了防止"指令重排"。关于“指令重排”具体产生的原因我们这里不做细究,有兴趣的小伙伴可以自己去研究一下,我们这里只是去分析一下,“指令重排”所带来的影响。

lazySingleton =  new LazySingleton();

这样一个看似简单的动作,其实从JVM层来看并不是一个原子性的行为,这里其实发生了三件事:

  • 给LazySingleton分配内存空间。
  • 调用LazySingleton的构造函数,初始化成员字段。
  • 将LazySingleton指向分配的内存空间(注意此时的LazySingleton就不是null了)

在此期间存在着指令重排序的优化,第2、3步的顺序是不能保证的,最后的执行顺序可能是1-2-3,也可能是1-3-2,假如执行顺序是1-3-2,我们看看会出现什么问题。看一下下图:

指令重排执行

从上图中我们看到虽然LazySingleton不是null,但是指向的空间并没有初始化,最终被业务使用的时候还是会报错,这就是DCL失效的问题,这种问题难以跟踪难以重现可能会隐藏很久。

JDK1.5之前JMM(Java Memory Model,即Java内存模型)中的Cache、寄存器到主存的回写规定,上面第二第三的顺序无法保证。JDK1.5之后,SUN官方调整了JVM,具体化了volatile关键字,private volatile static LazySingleton lazySingleton;只要加上volatile,就可以保证每次从主存中读取(这涉及到CPU缓存一致性问题,感兴趣的小伙伴可以研究研究),也可以防止指令重排序的发生,避免拿到未完成初始化的对象。

上面这种方式可以有效降低锁的竞争,锁不会将整个方法全部锁定,而是锁定了某个代码块。其实完全做完调试之后我们还是会发现锁争夺的问题并没有完全解决,用到了锁肯定会对整个代码的执行效率带来一定的影响。所以是否存在保证线程的安全,并且能够不浪费内存完美的解决方案呢?一起看下下面的解决方案。

内部静态类单例模式

这种方式其实是利用了静态对象创建的特性来解决上述内存浪费以及线程不安全的问题。在这里我们要弄清楚,被static修饰的属性,类加载的时候,基本属性就已经加载完毕,但是静态方法却不会加载的时候自动执行,而是等到被调用之后才会执行。并且被STATIC修饰的变量JVM只为静态分配一次内存。(这里老猫不展开去聊static相关知识点,有兴趣的小伙伴也可以自行去了解一下更多JAVA中static关键字修饰之后的类、属性、方法的加载机制以及存储机制)

所以综合这一特性,我们就有了下面这样的写法:

public class LazyInnerClassSingleton {
    private LazyInnerClassSingleton () {
    }

    public static final LazyInnerClassSingleton getInstance() {
        return LazyHolder.LAZY;
    }

    private static class LazyHolder {
        private static final LazyInnerClassSingleton LAZY = new LazyInnerClassSingleton();
    }
}

上面这种写法,其实也属于“懒汉式单例模式”,并且这种模式相对于“无脑加锁”以及“DCL”以及“饿汉式单例模式”来说无疑是最优的一种实现方式。

但是深度去追究的话,其实这种方式也会有问题,这种写法并不能防止反序列化和反射生成多个实例。我们简单看一下反射的破坏的测试类:

public class DestructionSingletonTest {
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        Class enumSingletonClass = LazyInnerClassSingleton.class;
        //枚举默认有个String 和 int 类型的构造器
        Constructor constructor = enumSingletonClass.getDeclaredConstructor();
        constructor.setAccessible(true);
        //利用反射调用构造方法两次直接创建两个对象,直接破坏单例模式
        LazyInnerClassSingleton singleton1 = (LazyInnerClassSingleton) constructor.newInstance();
        LazyInnerClassSingleton singleton2 = (LazyInnerClassSingleton) constructor.newInstance();
    }
}

这里序列化反序列化单例模式破坏老猫偷个懒,因为下面会有写到,有兴趣的小伙伴继续看下文,老猫觉得这种破坏场景在真实的业务使用场景比较极端,如果不涉及底层框架变动,光从业务角度来看,上面这些单例模式的实现已经管够了。当然如果硬是要防止上面的反射创建单例两次问题也能解决,如下:

public class LazyInnerClassSingleton {
    private LazyInnerClassSingleton () {
        if(LazyHolder.LAZY != null) {
            throw new RuntimeException("不允许创建多个实例");
        }
    }

    public static final LazyInnerClassSingleton getInstance() {
        return LazyHolder.LAZY;
    }

    private static class LazyHolder {
        private static final LazyInnerClassSingleton LAZY = new LazyInnerClassSingleton();
    }
}

写到这里,可能大家都很疑惑了,咋还没提及用单例模式优化线程池创建。下面这不来了么,老猫个人觉得上面的这种方式进行创建单例还是比较好的,所以就用这种方式重构一下线程池的创建,具体代码如下:

public class InnerClassLazyThreadPoolHelper {
    public static void execute(Runnable runnable) {
        ThreadPoolExecutor threadPoolExecutor = ThreadPoolHelperHolder.THREAD_POOL_EXECUTOR;
        threadPoolExecutor.execute(runnable);
    }
    /**
     * 静态内部类创建实例(单例).
     * 优点:被调用时才会创建一次实例
     */
    public static class ThreadPoolHelperHolder {
        private static final int CPU = Runtime.getRuntime().availableProcessors();
        private static final int CORE_POOL_SIZE = CPU + 1;
        private static final int MAXIMUM_POOL_SIZE = CPU * 2 + 1;
        private static final long KEEP_ALIVE_TIME = 1L;
        private static final TimeUnit TIME_UNIT = TimeUnit.SECONDS;
        private static final int MAX_QUEUE_NUM = 1024;

        private ThreadPoolHelperHolder() {
        }

        private static final ThreadPoolExecutor THREAD_POOL_EXECUTOR = new ThreadPoolExecutor(
                CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE_TIME, TIME_UNIT,
                new LinkedBlockingQueue(MAX_QUEUE_NUM),
                new ThreadPoolExecutor.AbortPolicy());
    }
}

到此就结束了吗?当然不是,我们之前说上面这种单例创建模式的弊端是可以被反射或者序列化给攻克,虽然这种还是比较少的,但是技术么,还是稍微钻一下牛角尖。有没有一种单例模式不惧反射以及单例模式呢?显然是有的。我们看下被很多人认为完美单例模式的枚举类的写法。

枚举式单例模式

public enum EnumSingleton {
    INSTANCE;
    private Object object;

    public Object getObject() {
        return object;
    }

    public void setObject(Object object) {
        this.object = object;
    }

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

上面我们写过反射模式破坏“静态内部类单例模式”,那么这里咱们补一下序列化反序列化的例子。具体如下:

public class EnumSingletonTest {
    public static void main(String[] args) {
        try {
            EnumSingleton instance2 = EnumSingleton.getInstance();
            instance2.setObject(new Object());

            FileOutputStream fileOutputStream = new FileOutputStream("EnumSingletonTest");
            ObjectOutputStream oos = new ObjectOutputStream(fileOutputStream);
            oos.writeObject(instance2);
            oos.flush();
            oos.close();

            FileInputStream fileInputStream = new FileInputStream("EnumSingletonTest");
            ObjectInputStream ois = new ObjectInputStream(fileInputStream);
            EnumSingleton instance1  = (EnumSingleton) ois.readObject();
            ois.close();
            System.out.println(instance2.getObject());
            System.out.println(instance1.getObject());
        }catch (Exception e) {
        }
    }
}

最终我们发现其输出的结果是一致的。大家可以参考老猫的代码自己写一下测试,关于反射破坏的方式老猫就不展开了,因为上面已经有写法了,大家可以参考一下,自行做一下测试。

那么既然枚举类的单例模式这么完美,我们就拿它来重构线程池的获取吧。具体代码如下:

public enum EnumThreadPoolHelper {
    INSTANCE;

    private static final ThreadPoolExecutor executor;

    static {
        final int CPU = Runtime.getRuntime().availableProcessors();
        final int CORE_POOL_SIZE = CPU + 1;
        final int MAXIMUM_POOL_SIZE = CPU * 2 + 1;
        final long KEEP_ALIVE_TIME = 1L;
        final TimeUnit TIME_UNIT = TimeUnit.SECONDS;
        final int MAX_QUEUE_NUM = 1024;
        executor = new ThreadPoolExecutor(
                CORE_POOL_SIZE, MAXIMUM_POOL_SIZE, KEEP_ALIVE_TIME, TIME_UNIT,
                new LinkedBlockingQueue(MAX_QUEUE_NUM),
                new ThreadPoolExecutor.AbortPolicy());
    }

    public void execute(Runnable runnable) {
        executor.execute(runnable);
    }

}

当然在上述中,针对赋值的方式老猫用了static代码块自动类加载的时候就创建好了对象,大家也可以做一下其他优化。不过还是得要保证单例模式。判断是否为单例模式,老猫这里有个比较粗糙的办法。我们打印出成员对象变量的值,通过多次调用看看其值是否一样即可。当然如果大家还有其他好办法也欢迎留言。

总结

针对单例模式相信大家对其有了一个不错的认识了。在日常开发的过程中,其实我们都接触过,spring框架中,IOC容器本身就是单例模式的,当然上述老猫也有提及到。框架中的单例模式,咱们等全部梳理完毕设计模式之后再去做深入探讨。

关于单例模式的优点也是显而易见的:

  • 提供了对唯一实例的受控访问。
  • 因为在系统内存中只存在一个对象,所以能够节约系统资源,对于一些需要频繁建立和销毁的对象单例模式无疑能够提升系统的性能。

那么缺点呢?大家有想过么?我们就拿上面的线程池创建这个例子来说事儿。我们整个业务系统其实有很多类别的线程池,如果说我们根据不同的业务类型去做线程池创建的拆分的话,咱们是不是需要写很多个这样的单例模式。那么对于实际的开发过程中肯定是不友好的。所以主要缺点可想而知。

  • 因为单例模式中没有抽象层,所以单例类的扩展有很大的困难。
  • 从开发者角度来说,使用单例对象(尤其在类库中定义的对象)时,开发人员必须记住自己不能使用new关键字实例化对象。

所以具体场景还得具体分析,上面的一些单例模式实现,如果大家还有比较好的方式欢迎大家留言。

上面老猫聊到了不同业务调用创建不同业务线程池的问题,可能需要定义不同的threadFactory名称,那么此时,我们该如何去做?带着疑问,让我们期待接下来的其他模式吧。

相关文章

服务器端口转发,带你了解服务器端口转发
服务器开放端口,服务器开放端口的步骤
产品推荐:7月受欢迎AI容器镜像来了,有Qwen系列大模型镜像
如何使用 WinGet 下载 Microsoft Store 应用
百度搜索:蓝易云 – 熟悉ubuntu apt-get命令详解
百度搜索:蓝易云 – 域名解析成功但ping不通解决方案

发布评论