前言:在软件开发中,有时我们需要确保一个类在内存中只存在一个实例,这不能只靠程序猿人为保证只创建了一个实例,而需要通过编程技巧约束,让我们只能创建一个实例。这可以通过单例模式(Singleton Pattern)来实现。在本文中,我们将探讨单例模式的两种主要实现方式:饿汉模式和懒汉模式,以及懒汉模式引发的线程安全问题。


目录

一、什么是单例模式?

二、饿汉模式(Eager Initialization)

定义:

 代码实现:

饿汉模式的优点:

饿汉模式的缺点

三、懒汉模式(Lazy Initialization)

定义:

线程不安全版代码实现:

线程并发执行导致的三个问题:

问题1:多线程并发环境下,该类可能会出现多个实例,违背单例原则。

解决方案1:加锁,把这个操作变成原子的。

问题2:加锁后引发效率降低问题

解决方案2:双重检查锁

问题3:指令重排序问题

解决方法3:使用volatile关键字。

四、最终改进版的线程安全的懒汉模式 


一、什么是单例模式?

一种设计模式。用编程技巧确保一个类只有一个实例,提供一个全局访问点。主要有饿汉模式、懒汉模式等。

二、饿汉模式(Eager Initialization)

定义:

饿汉模式在类加载时就创建实例,因此在类加载阶段就完成了单例对象的初始化。这意味着无论是否使用这个实例,都会在类加载时创建。

说人话:

                                                   

饿汉主要强调“迫切”,当一个人很饿的时候,就想立马干饭,刻不容缓,有什么事,等我吃饱再说。 因此,引申到单例模式上,第一件事就是要创建实例,有什么事等我创建好再说。

 代码实现:

class singleton{
    
    //在类加载时初始化实例
    private static singleton instance=new singleton();
    //static修饰类属性,就是在类对象上的
    //每个类的类对象在JVM中只有一个,里面的静态成员只有一份

    //私有构造方法,外部无法实例化
    private singleton(){

    }

    //提供全局访问点
    public static singleton getInstance(){
        return instance;
    }      
}

代码要点: 

1.创建类对象,用static修饰。用private修饰,外部无法直接拿到该对象。 

2.创建私有构造方法,类之外的代码尝试new新对象时,要用构造方法 ,但构造方法是私有的,因此会报错,确保了无法在外部进行初始化。

3.提供一个访问此单例的方法。

编程验证用getInstance()是否取到同一个单例。

    public static void main(String[] args) {
        singleton s1=singleton.getInstance();
        singleton s2=singleton.getInstance();
        System.out.println(s1==s2);
    }

 验证成功。


饿汉模式的优点:

  1. 实现简单:饿汉模式的实现非常简单,不需要使用复杂的同步机制。
  2. 线程安全:由于实例在类加载时就完成初始化,因此天生是线程安全的。

饿汉模式的缺点

资源浪费:即使没有使用该实例,实例也会在类加载时被创建,可能会造成资源浪费,尤其是实例初始化开销较大时。


三、懒汉模式(Lazy Initialization)

定义:

懒汉模式在第一次调用getInstance方法时才创建实例。这种方式实现了延迟加载(Lazy Loading),即在需要时才创建实例。

说人话:

        计算机里的“懒”,是高效率的意思。

       举个例子:老师布置作业,没说要交,我就先不写。说了要交,我才把作业写了。如果不用交,我就不写,要交,才写。思想的精髓就是能省则省。

       因此,引申到单例模式上,你需要这个单例时,我再创建,不用的话我就不创建了。如果实例已经存在,就直接把实例返回。

线程不安全版代码实现:

class singleton_lazy{
    private static singleton_lazy instance=null;//先把实例定为空,要再创建
    private singleton_lazy(){} //私有化构造方法
 
    public static singleton_lazy getInstance(){
          if (instance == null) {
                instance = new singleton_lazy();
          }
           
         return instance;
    } 
}

这样的代码,在单线程环境下不会有问题,但在多线程环境下会引发以下几个安全问题,以下详细解释。

线程并发执行导致的三个问题:

问题1:多线程并发环境下,该类可能会出现多个实例,违背单例原则。

当线程s1、s1同时调用getInstance时,

singleton s1=singleton.getInstance();
singleton s2=singleton.getInstance();

可能会发生如图所示情况,s2已经判断instance为空后,s1才创建实例,之后s2也会创建新实例。

创建了两个实例,这就违背了单例模式的设计原则。 

究其原因,是因为

这个操作不是原子的。意思就是,这两个操作是可拆分的,之间夹杂了很多别的操作。

解决方案1:加锁,把这个操作变成原子的。

加锁后,我们可以保证只有一个线程能够初始化实例。

class singleton_lazy{
    private static singleton_lazy instance=null;//先把实例定为空,要再创建
    private singleton_lazy(){} //私有化构造方法
    

    private static Object locker=new Object();
    public static singleton_lazy getInstance(){
       
       //加锁
       synchronized (locker) {
          if (instance == null) {
                instance = new singleton_lazy();
          }
       }
       return instance;
    } 
}

      改进后,当线程s1拿到锁后,进入if判断语句,这时s2线程无法进入if判断语句,直到锁被s1释放。当锁被释放后,instance也已经被更新了,这样当s2拿到锁时,就会读到instance不为空,就不会创建新实例。


问题2:加锁后引发效率降低问题

       加了上述的锁后,当单例不存在时创建实例确实能保护线程安全。

       但当单例已被创建的前提下,每次调用instance方法时,会引发效率降低问题。

      原因:即使单例已存在,也要进入一遍锁,再返回已创建好的实例,而不是直接返回已创建好的实例。这样的上锁解锁操作,带来了不必要的开销,影响性能。

解决方案2:双重检查锁

在外面加一层判断,确保单例已经被创建时,能立马返回该单例。不必进入锁操作。

class singleton_lazy{
    private static singleton_lazy instance=null;
    private singleton_lazy(){}
    private static Object locker=new Object();
    

    public static singleton_lazy getInstance(){

//在最外面加一层判断,确保单例已经被创建时,能立马返回该单例。不必进入锁操作。
        if (instance==null){
            synchronized (locker) {
                if (instance == null) {
                    instance = new singleton_lazy();
                }
            }
        }
            return instance;
    }

    
}

改进1+改进2的操作也叫做双重检查锁。


问题3:指令重排序问题

在Java中,指令重排序是一种编译器和CPU优化技术,它可以改变指令的执行顺序,以提高性能。这样的机制在单线程模式下是不会有问题的,但当在多线程模式下创建单例时,可能会发生指令重排序,导致其他线程在检查instance时,获取到一个尚未完全初始化的对象。

考虑以下实例化过程:

  1. 分配一块内存。
  2. 调用构造函数,初始化对象。
  3. 将instance指向分配的内存地址。 

传统的执行顺序是123。由于指令重排序,可能会发生执行顺序132。

举个栗子:

       我在街上买了一部苹果手机,我可以先带回家再激活。也可以先激活再带回家。

       在单线程里,效果都是一样的,这部手机都是我的。

      但在多线程里,可不一样哟,如果带回家了之后,我还没来得及激活,被家里的小猫掠夺走激活了,这手机我就激活不了了,已经登陆了小猫的id。

例子: 

依旧是线程s1和s2同时调用instance(),由于多线程并发,可能会发生图中所示情况

线程s2看到instance不为null,获取到对象,但此时对象只是刚分配到内存空间,可能尚未初始化完成。此时对拿到的对象进行操作就是一个危险行为。

解决方法3:使用volatile关键字。

volatile关键字可以确保变量的所有读写操作都是从主内存中进行的,并且禁止指令重排序。 

instance被声明为volatile,以确保指令不会被重排序,保证了线程安全性和实例的正确初始化。

四、最终改进版的线程安全的懒汉模式 

class singleton_lazy{
    private static volatile singleton_lazy instance=null;//加上volatile关键字
    private singleton_lazy(){} //私有化构造方法

    private static Object locker=new Object();
    public static singleton_lazy getInstance(){
        //加锁
        synchronized (locker) {
            if (instance == null) {
                    instance = new singleton_lazy();
            }
        }
        return instance;
    }
}

要点: 

1.volatile关键字

2.双重检查锁

  

Logo

北京人形旗下天工造物具身智能开源社区,聚焦具身天工与慧思开物两大平台

更多推荐