【20210921】【实践分享】带着目标“重新学习单例模式”

时间:2021年09月21日

作者:小蒋聊技术

大家好,欢迎来到小蒋聊技术。小蒋准备和大家一起聊聊技术的那些事。

小蒋在软件开发这个技术领域也算是摸爬滚打了十多年了。深知技术的学习过程往往很多时候是非常枯燥的。另外还有一个非常严重的问题就是,很多流行的新技术学完了,但是项目中却没有应用的地方,不久就会被忘记。这可如何是好?

带着这样的问题,小蒋不得不开始踏上寻求解决方案之旅。

给小蒋启发最大的是一本书,书名叫《高效能人士的七个习惯》。这本书的作者是大名鼎鼎的史蒂芬•柯维。这本书已经出版了30年,依旧畅销,被奉为永恒的经典,同时入选了亚马逊推荐的人生必读100本书。在美国,此书影响力仅次于《圣经》。

这本书既然被称为是永恒的经典,那肯定有其经典之处。当小蒋认真阅读完此书后,开始意识到技术知识学完就忘这个事,其实是很正常的。小蒋过去的教育体系中,潜意识里一直都接受了一种速成的观念,就比如在上小学的时候,学习就是为了考试能得满分,根本不会在乎是否理解,是否喜欢。

很多时候学习新技术,也是为了应对面试或者工作中的相关的提问。这种一开始目标就错了的学习态度,注定学完的知识也不会存留太久。

那究竟该如何学习新技术呢?作者告诉我们说,“思想决定行动,行动决定习惯,习惯决定品德,品德决定命运”。

小蒋意识到,如果学习的目标是“学完技术后不会忘记”,那其实背后真实的含义,不就是在培养习惯嘛。习惯对我们的生活有极大的影响,因为它是一贯的,在不知不觉中,经年累月的影响着我们,左右着我们的成败。

也正是因为读完这本书的原因,小蒋开始理解亚里士多德的一句话“人的行为总是一再重复。因此卓越不是一时的行为,而是习惯 ”。

既然明白了这个道理,下一个问题就该是,“那该如何培养习惯呢?

作者将习惯定义为“知识”、“技巧”与“意愿”相互交织的结果。

知识,是指“做什么”以及“为何做”;

技巧,是指“如何做”;

意愿,是指“想要做”;

要养成一种习惯,三者缺一不可。

通过在知识、技巧、意愿三方面的努力,如此反复循环,最后才会让我们的技术实力螺旋式向上成长,最后成为一种习惯,这样才是学习技术的正确打开方式。

现在,技术学习的目标清晰了,方法准备好了,剩下来的就是实践了。小蒋准备带着新方法重新再学习一次“单例模式”。

首先,来问自己一个问题:“为什么需要单例模式,单例模式是为了解决什么问题而产生的?

顾名思义,单例模式就是要确保某个类只有一个实例,而且自行实例化并向整个系统提供这个实例。这个类称为单例,它提供全局访问的方法。

单例是为解决哪些问题而产生的呢:

  1. 实现要求。例如唯一ID生成器,本地计数器等。这些功能为了保证ID生成的唯一性,计数的准确性必须只能使用一个实例,多实例会导致系统逻辑乱。
  2. 有的对象创建或销毁的时候非常复杂而且消耗的资源也非常多,这时候为了减少内存的开销,避免频繁的创建和销毁实例引起的不必要浪费并且这些对象完全是可以复用的。例如,就像数据库的连接,它就是一个耗资源的操作,而且这些连接完全是可以复用的。实际上我们就可以把创建数据库连接这个操作设计成单例,只创建一次,以后重复使用就可以了。而不需要每次访问都去创建,如果那样做将会是一件非常恐怖的事情。

接下来再问自己一个问题:“在JAVA中,如何实现单例模式呢?

在JAVA中呢,我们上网查,单例模式实现方法会发现,实现单例模式的实现方法其实有很多种。但是,究竟为什么一个单例模式实现方法会有那么多种写法呢?小蒋个人认为其实单例的实现方法,并的没有想象中的那么简单,因为每种写法都是出于不同的思考方式。而且设计的十分巧妙,其中的思想是值得思考和借鉴的。接下来,小蒋将会一一道来。

首先,从大类上分有两种类型:

饱汉式:饱汉,即与已经吃饱,不着急再吃,饿的时候再吃。形象的比喻该类先不初始化,等第一次使用的时候再初始化,即“懒加载”,也被亲切的称之为“懒汉式”。

饿汉式:与饱汉相对,饿汉非常饿,只想着尽快吃到。形象的比喻该类在最早的时候,即类加载时初始化单例,以后访问时直接返回,直接使用即可。

咱先来写一个“饱汉式”单例对象。

 

这个Singleton类我们将构造器设置为private,这样其他类就没有办法通过new这个关键字来构造这个对象的实例了。其他类如果需要使用Singleton实例只能通过调用getInstance()方法。在getInstance()方法中,首先判断该Singleton实例是否为Null(判空),若已经实例化则直接返回该对象。否则立即执行实例化操作,即懒加载,十分形象。一个简单的“饱汉式”单例就写完了。

饱汉式”的特点就是程序在第一次被调用的时候才真正构建对象实例,而不是程序一启动它就构建好了等着你来调用。非常适合那种使用频次少,但构建消耗资源多的对象,而且懒加载的特性可以有效避免不必要的资源浪费。免得化了大力气构建完了对象,结果根本就没有人来使用,这样岂不是白白消耗资源。

public class Singleton {

  

    private static Singleton singleton;

  

    private Singleton(){}//构造器私有

  

    public static Singleton getInstance() {

        if (singleton == null) {

            singleton = new Singleton();

        }

        return singleton;

    }

  

}

再问自己一个问题:“刚刚写的这个‘饱汉式’单例是否为‘线程安全’?”。

很显然,这个刚写的这个单例肯定不是线程安全的。因为如果A,B两个线程同时判断到singleton为空,那么A,B两个线程它们都会进入if代码块去实例化一个Singleton对象,这就变成多例了。这样运行肯定不是我们所期望的,因为我们想要单例呀,也就是说程序在多线程模式下出现了BUG。

 

刚刚问自己问题,答案已经有了。这个咱刚写的‘饱汉式’单例,不是线程安全的

下一个问题,自然而然就来了:“如何解决上面👆🏻程序中的线程安全问题,以保证程序可以在多线程情况下,依旧可以正常运行?”。

最容易想到的办法就是加锁。可以在getInstance()方法上加synchronized锁,或者是对Singleton类对象加synchronized 锁。程序就会变成下面👇🏻这一个样子:

public static synchronized Singleton getInstance() {

    if (singleton == null) {

        singleton = new Singleton();

    }

    return singleton;

}

或者

public static Singleton getInstance() {

    synchronized(Singleton.class) {

        if (singleton == null) {

            singleton = new Singleton();

        }

    }

    return singleton;

}

这样就解决了线程安全问题,避免了在多线程情况下同时创建Singleton对象的风险。

但是这引入了新的问题,其实我们只是希望对象在构建的时候同步线程,但上面👆🏻这样的实现方式,无疑每次在获取对象时(也就是在调用getInstance()方法时)都要进行同步操作。这对于性能的影响非常大,每次去获对象时先要获取锁,在极端情况下,还有可能出现卡顿。无疑是捡了芝麻丢了西瓜🍉。这种写法,小蒋个人是非常不推荐的

问题继续:那如何能在保证线程安全的情况下,写一个的高效单例模式呢?

通过对代码的分析,我们发现线程的安全问题出现在了构建对象的阶段。那我们是否可以在编译阶段直接构建对象,在运行时直接调用,这样就不用考虑线程安全的问题了。于是,我们尝试一下这样写:

public class Singleton{

  

    private static final Singleton singleton = new Singleton();

  

    private Singleton(){}

  

    public static Singleton getInstance() {

        return singleton;

    }

}

这种方式,被人们亲切的称呼为饿汉式。因为饿汉式类加载时已经创建好了对象。程序在被调用时(也就是在调用getInstance()时)直接返回该单例对象即可,不需要等到第一次被调用时再去创建。

换句话说,虽然饿汉式可以解决线程安全问题,但它并不是懒加载的。这意味着,它的资源利用率存在着问题,小蒋解释一下,这种写法有可能存在,辛辛苦苦构建好了对象根本就没有人过来用的风险,也就是说为了解决线程安全这个问题,可能把一个根本没有人用的大对象,始终驻留在内存中,极其浪费资源。

经过分析,我们得出结论:

饿汉式的确可以满足线程安全的这个条件,但它并不能满足高效这个条件。因为饿汉式存在资源利用率问题

问题升级为:“那如何写一个既是线程安全,还是懒加载(懒加载可以解决资源利用率问题)的,并且高性能的单例模式呢?”。

性能优化,成为主攻方向。在饿汉式不能满足条件的前提下,回到饱汉式。目标是:“如果没有实例化则加锁创建,如果已经实例化了,则不需要加锁,直接获得实例”。在这个目标下,直接在getInstance()方法上加synchronized锁的方式肯定不满足需要,因为这种方式无论如何都要先加锁。

我们再来看看另外一种方式是否可行呢:

public static Singleton getInstance() {           //1
      synchronized(Singleton.class) {           //2
          if (singleton == null) {              //3
              singleton = new Singleton();     //4
          }
    }
    return singleton;
}

  1. 在getInstance()方法时不需要竞争锁,所有线程都可以直接进入。
  2. 按照目标,紧接着马上要进行判断,判断singleton对象是否还没有构建,那么多个线程开始争抢锁。抢到锁的那个线程开始创建实例。按照这个思路,语句2语句3需要调换位置,代码变成如下所示:
        public static Singleton getInstance() {        //1
            if (singleton == null) {                //2
                synchronized(Singleton.class){      //3
                    singleton = new Singleton();    //4
                }
            }
            return singleton;
    //    }
    

  3. 线程在执行到第2步后,如果singleton对象为null,则多个线程开始争抢锁。争抢到的那个线程开始创建实例。以后,其他线程再执行到第2步时,singeton对象已经不在为null,就直接跳过if代码块,返回已经存在的实例对象直接进行使用。再也不用争抢锁,这就实现了懒加载,解决了资源使用效率低问题。
  4. 凡是存在争抢锁的地方,必须考虑线程安全。也就是说多个线程在执行到步骤3时,虽然只有一个线程可以拿到锁,进入步骤4 ,开始构建Singletion对象。但,极有可能已经有多个线程已经进入步骤2的if代码块中,正在排队等待进入步骤3。也就是说,一旦当前线程执行完后,释放锁。后面排队等待的线程立即就会获得锁,再次进入步骤3,开始构建Singleton对象。这样对象又会被构建多次,明明的单例又变成多例了。
  5. 那如何解决呢?马上动手改!!!再加上一个判空的操作,代码变成如下所示:

这样即便有多个线程同时进入到了步骤2的if代码块中,因为步骤4存在一个判空操作,下一个线程再执行的时候,singleton对象已经不为null了,所以不会再次执行步骤5的构建操作。

这样即便有多个线程同时进入到了步骤2的if代码块中,因为步骤4存在一个判空操作,下一个线程再执行的时候,singleton对象已经不为null了,所以不会再次执行步骤5的构建操作。

***以上代码已经达到了我们要求的目标,线程安全懒加载高性能。***

因为上面的代码中需要两次判空,且对类加锁。这种饱汉式写法也被称为:Double Check(双重校验) + Lock(加锁),简称双检锁。双检锁在开发中是一种非常常见的同步线程手段。

完整代码如下所示:

以上代码已经近似完美了,但是还存在一个问题:指令重排

那什么是指令重排?

谈到指令重排,可能就要涉及到Java多线程的happens-before原则了,这个原则主要是谈Java在多线程的时候可见性的问题。

小蒋简单的给大家先说一下,因为Java本身是运行在JVM虚拟机之上的,所以才有了一个非常强悍的特点,一次编译到处运行。

拿我们的代码举例,singleton = new Singleton(),这里在多线程环境下其实没有遵循happens-before原则,因为singleton = new Singleton()在指令层面不是一个原子操作,它实际上分为了三步:

  1. 为singleton分配内存空间。
  2. 初始化singleton对象。
  3. 将singleton指向分配好的内存空间(把步骤1和步骤2链接来)。

指令重排是指:JVM在保证最终结果正确的前提下,可以不按照程序编码的顺序执行语句,尽可能提高程序的性能。

所以,刚刚这三步中,第2步和第3步谁先执行并不一定。可能是1-2-3,也可能是1-3-2。这会导致多个线程获取对象时,有可能线程 A 创建对象的过程中,执行了 1、3 步骤,线程 B 判断 singleton 已经不为空,获取到未初始化的singleton 对象,就会报 NPE 异常。因为语言比较抽象,可以看下面流程图。

解决这个问题需要使用volatile关键字防止指令重排,其背后的原理比较复杂。小蒋并不打算在此篇分享中展开。

朋友们只需要这样理解:使用volatile关键字修饰的变量,可以保证其指令执行的顺序与程序指明的顺序一致,不会发生顺序变化,这样在多线程环境下就不会因为指令重排而发生NPE异常了。

最终,代码如下所示:

另外,volatile 还有第二个作用:使用 volatile 关键字修饰的变量,可以保证其内存可见性,即每一时刻线程读取到该变量的值都是内存中最新的那个值,线程每次操作该变量都需要先读取该变量。

这种双检锁的写法,也是小蒋用的比较多的一种方法。

不断对自己提高要求,提问:“是否可以利用饿汉式中的天然的静态变量的方便线程安全,又希望通过懒加载规避资源浪费呢?”。

成长是需要经历大量的反复练习的,经过努力小蒋后来发现了Holder模式。也就是用到了 Java中的静态内部类。代码如下所示:

Holder模式满足了我们的要求:核心仍然是静态变量,足够方便,同时满足线程安全;通过静态的Holder类持有真正的实例,间接实现了懒加载。也就是说这种方法下,也同样是第一次被调用的时候才会加载。它巧妙的利用了JDK类加载机制的特性,来实现了懒加载。这种单例模式,小蒋也很推荐大家使用。

问题继续升级,提问:“以上的这两种单例的实现方式,是否能被反射或序列化破坏?”。

无论是完美的饱汉式还是Holder式,终究敌不过反射序列化,它们俩都可以把单例对象破坏掉(产生多个对象)。

利用反射破坏单例模式:

代码如下所示:

public static void main(String[] args) { // 获取类的显式构造器 Constructor<Singleton> construct = Singleton.class.getDeclaredConstructor(); // 可访问私有构造器 construct.setAccessible(true); // 利用反射构造新对象 Singleton obj1 = construct.newInstance(); // 通过正常方式获取单例对象 Singleton obj2 = Singleton.getInstance(); System.out.println(obj1 == obj2); // false }

执行结果,如下所示:

上述的代码一针见血:利用反射,强制访问类的私有构造器,去创建另外一个对象。

利用序列化与反序列化破坏单例模式:

代码如下所示:

public static void main(String[] args) { // 创建输出流 ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("Singleton.file")); // 将单例对象写到文件中 oos.writeObject(Singleton.getInstance()); // 从文件中读取单例对象 File file = new File("Singleton.file"); ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file)); Singleton newInstance = (Singleton) ois.readObject(); // 判断是否是同一个对象 System.out.println(newInstance == Singleton.getInstance()); // false }

执行结果,如下所示:

两个对象地址不相等的原因是:readObject() 方法读入对象时它必定会返回一个新的对象实例,必然指向新的内存地址。

反射如此强大,难道真的没有什么办法可以阻挡反射来破坏我们的单例模式吗?”,不知不觉中问题居然又升级了。

实际上,JDK1.5后,Java还真的给我们准备了一手,那就是枚举类型。利用枚举类型实现单例,代码如下所示:

反射为什么厉害,因为反射可以强制访问类的私有构造方法,去创建一个对象。

枚举类型凭什么可以阻挡反射

通过阅读枚举类型的源代码发现,枚举只重写了父类的一个签名是(String,int)的构造方法。根本就没有私有无参的构造方法。

也就是说Enum类型,压根儿在底层就根本没有无参构造方法,反射还怎么访问,怎么破坏。

总结:

  1. 使用枚举实现单例模式,与上面介绍的另外的两种方式比较来说,它更加的简洁
  2. 它不需要做任何额外的操作,就可以保证线程安全对象单一
  3. 而且枚举类型还可以保护单例,避免反射的破坏。

小蒋个人认为,虽然利用枚举方式实现单例有以上这么多优点。但是,并不能得出,使用枚举实现单例就比以上两种方式高级这个结论,它只是利用了Java语法自身的特点。另外,枚举类型单例是否为懒加载呢?

小蒋在Stack Overflow上找到了明确的解释,经过自测枚举的的确确是懒加载,代码如下:

解释:主线程休眠5秒后,保证系统初始化的内容已经全部加载完毕。第一次调用单例INSTANCE实例时,其内部静态代码块才被加载。

写在最后:

小蒋以前学习真的挺努力的,但是知识的存留率却很低。后来,经过现实的不断的碾压与摧残。小蒋开始意识到目标出问题了,终于也开始明白目标的重要性了,如果目标错了,你越是努力,最后反而离目标越远。

后来,小蒋在技术的学习上开始在知识技巧意愿这三方面,持续不断地交替努力,如此反复循环。让技术变得不再只是知识,而是要把技术变成一种自身的习惯

今天分享的内容就到这里。

年龄的增长不可怕,可怕的是从未成长!

感谢大家支持小蒋,小蒋希望和大家共同成长,谢谢。

 喜马拉雅音频地址:20210921[实践分享]带着目标“重新学习单例模式”-喜马拉雅大家好,欢迎来到小蒋聊技术。小蒋准备和大家一起聊聊技术的那些事。文字版材料在CSDN博客,“小蒋的聊技术”的同名文章里。CSDN:https://blog.csdn.net/wei_wei10年龄的增长不可怕,可怕的是从未成长。感谢大家支持小蒋,小蒋想和大家共同成长。https://www.ximalaya.com/keji/51588599/455746651

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

小蒋聊技术

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值