单例设计模式详解

本文详细探讨了单例模式的多种实现方式,包括饿汉式、懒汉式、双重检查锁、枚举单例法及静态内部类法,分析了每种方法的优缺点,并特别强调了线程安全性和效率问题。

摘要生成于 C知道 ,由 DeepSeek-R1 满血版支持, 前往体验 >

为什么要有单例的类呢? 
有些对象的创建消耗时间和内存是非常大的,恰恰好这些对象在我们的应用中只需要使用 1 个,如果不能得到控制,会造成资源的浪费。 
说明:就向我们的办公室、家里那些很贵的电器,比如电冰箱、空调、打印机、热水器这一类电器,一般情况下,在一个小范围内我们只用使用 1 个。比如我们办公室吧, 1 台打印机就够我们几个人用了,没有必要买 2 台打印机。类似地,Java 中有这样的一些对象,例如线程池、数据库连接池,一个应用程序中,我们只需要有 1 个这样的大对象。

很多朋友们学习设计模式,最先是从单例设计模式开始的,很容易地,我们知道,单例设计模式有两种写法:懒汉式与饿汉式,分别如下。

饿汉式

public class Singleton {
    // 把"唯一的"对象保存在单例类的属性里
    private static Singleton instance = new Singleton();

    // 构造器私有化,不能在类的外部随意创建对象
    private Singleton(){}

    // 提供一个全局的访问点来获得这个"唯一"的对象
    public static Singleton getInstance(){
        return instance;
    }
}

说明:类加载的时候就创建对象。 
优点:简单清楚,代码“看起来”优雅,很安全。因为饿汉式单例设计模式在所有线程启动之前就创建好单例类的对象。 
缺点:没有实现懒加载。 
看了上面的分析,有的朋友要问了,不懒加载的话,又有什么关系呢?就让应用程序启动的时候一起加载就好了呀。“安全”和“懒加载”,两害相较取其轻,我要“安全”,损失一些所谓“性能”,看起来饿汉式的单例模式写法可以说相对“完美”一些。

懒汉式

public class Singleton {

    private static Singleton instance;

    // 构造器私有化,不能在类的外部随意创建对象
    private Singleton(){}

    // 提供一个全局的访问点来获得这个"唯一"的对象
    // 请注意,这样的代码再多线程环境下是有问题的
    // 很可能 instance = new Singleton(); 会被执行多次
    // 我们可以模拟多线程环境来检验我们的猜想
    public static Singleton getInstance(){
        if(instance == null){
            instance = new Singleton();
        }
        return instance;
    }
}

验证在多线程环境下懒汉式单例写法的不“安全”之处

public class Singleton  {

    private static Singleton  instance;

    private static AtomicInteger count=new AtomicInteger(0);

    // 构造器私有化,不能在类的外部随意创建对象
    private Singleton () {

    }

    public static Singleton  getInstance() {
        if(instance == null){
            System.out.println("实例化---->>"+count.incrementAndGet());
            instance = new Singleton ();
        }
        return instance;
    }

}
public class SingletonTest {

    public static void main(String[] args) throws Exception {
        for (int i = 0; i < 1000; i++) {
            new Thread(new Runnable() {
                @Override
                public void run() {
                    Singleton.getInstance();
                }
            }).start();
        }
        Thread.sleep(1000);
    }
}

运行结果如下:
 实例化---->>2
 实例化---->>1

原因分析:

我们假设有多个线程1,线程2都需要使用这个单例对象。而恰巧,线程1在判断完instance==null后突然交换了cpu的使用权,
变为线程2执行,由于instance仍然为null,那么线程2中就会创建这个Singleton的单例对象。之后线程1拿回cpu的使用权,
正好线程1之前暂停的位置就是判断instance是否为null之后,创建对象之前。这样线程1又会创建一个新的Singleton对象

考虑线程安全的写法

这种写法考虑了线程安全,将对singleton的null判断以及new的部分使用synchronized进行加锁。同时,对singleton对象使用volatile关键字进行限制,保证其对所有线程的可见性,并且禁止对其进行指令重排序优化。如此即可从语义上保证这种单例模式写法是线程安全的。注意,这里说的是语义上,实际使用中还是存在小坑的,因为其效率低下,还是无法实际应用。因为每次调用getSingleton()方法,都必须在synchronized这里进行排队,而真正遇到需要new的情况是非常少的。

public class Singleton {
    private static volatile Singleton singleton = null;
 
    private Singleton(){}
 
    public static Singleton getSingleton(){
        synchronized (Singleton.class){
            if(singleton == null){
                singleton = new Singleton();
            }
        }
        return singleton;
    }    
}

兼顾线程安全和效率的写法

虽然上面这种写法是可以正确运行的,但是其效率低下,还是无法实际应用。因为每次调用getSingleton()方法,都必须在synchronized这里进行排队,而真正遇到需要new的情况是非常少的。所以,就诞生了第三种写法--->>双重检查:

public class Singleton {

//使用volatile关键字防止重排序,因为 new Singleton()是一个非原子操作,可能创建一个不完整的实例
    private static volatile Singleton singleton = null;
    
    private Singleton(){}
    
    public static Singleton getSingleton(){
        if(singleton == null){
            synchronized (Singleton.class){
                if(singleton == null){
                    singleton = new Singleton();
                }
            }
        }
        return singleton;
    }    
}

这种写法被称为“双重检查锁”,顾名思义,就是在getSingleton()方法中,进行两次null检查。看似多此一举,但实际上却极大提升了并发度,进而提升了性能。为什么可以提高并发度呢?就像上文说的,在单例中new的情况非常少,绝大多数都是可以并行的读操作。因此在加锁前多进行一次null检查就可以减少绝大多数的加锁操作,执行效率提高的目的也就达到了。


注意: 必须用 volatile修饰singleton  如果上述的实现没有使用 volatile 修饰 singleton ,会导致什么情形发生呢? 为解释该问题,我们分两步来阐述:

(1)、当我们写了 new 操作,JVM 到底会发生什么?

  首先,我们要明白的是: new Singleton () 是一个非原子操作。代码行singleton = new Singleton(); 的执行过程可以形象地用如下3行伪代码来表示:

memory = allocate();        //1:分配对象的内存空间
ctorInstance(memory);       //2:初始化对象 
singleton = memory;        //3:使singleton指向刚分配的内存地址 此时singleton !=null
但实际上,这个过程可能发生无序写入(指令重排序),也就是说上面的3行指令可能会被重排序导致先执行第3行后执行第2行,也就是说其真实执行顺序可能是下面这种:

memory = allocate();        //1:分配对象的内存空间
singleton = memory;        //3:使singleton指向刚分配的内存地址 此时singleton !=null 但是对象还没有完成初始化!!! 加了volatile修饰符将不会指令重排序
ctorInstance(memory);       //2:初始化对象
这段伪代码演示的情况不仅是可能的,而且是一些 JIT 编译器上真实发生的现象。

(2)、重排序情景再现 
    了解 new 操作是非原子的并且可能发生重排序这一事实后,我们回过头看使用 Double-Check idiom 的同步延迟加载的实现:

  我们需要重新考察上述清单中的 //3 行。此行代码创建了一个 Singleton 对象并初始化变量 singleton 来引用此对象。这行代码存在的问题是,在 Singleton 构造函数体执行之前,变量 singleton 可能提前成为非 null 的,即赋值语句在对象实例化之前调用,此时别的线程将得到的是一个不完整(未初始化)的对象,会导致系统崩溃。下面是程序可能的一组执行步骤:

  1、线程 1 进入 getSingleton() 方法; 
  2、由于 singleton 为 null,线程 1 在 //1 处进入 synchronized 块; 
  3、同样由于 singleton 为 null,线程 1 直接前进到 //3 处,但在构造函数执行之前,使实例成为非 null,并且该实例是未初始化的; 
  4、线程 1 被线程 2 预占(交替执行); 
  5、线程 2 检查实例是否为 null。因为实例不为 null,线程 2 得到一个不完整(未初始化)的 Singleton 对象; 
  6、线程 2 被线程 1 预占。 
  7、线程 1 通过运行 Singleton 对象的构造函数来完成对该对象的初始化。

  显然,一旦我们的程序在执行过程中发生了上述情形,就会造成灾难性的后果,而这种安全隐患正是由于指令重排序的问题所导致的。让人兴奋地是,volatile 关键字正好可以完美解决了这个问题。也就是说,我们只需使用volatile关键字修饰单例引用就可以避免上述灾难

简而言之: 在没有加volatile的情况下,由于指令重排,new Singleton()的时候可能会初始化一个还没初始化完全的对象.由于加了synchronized的缘故,这个时候instance对象会被刷入主存,对所有线程可见。这个时候(对象还未初始化完全)线程B进入到if(instance == null )这步,由于这个对象已经初始化(但是还没初始化完全),就直接返回这个对象,导致出错

      更多关于volatile关的介绍, 请移步我的博文《 Java 并发:volatile 关键字解析》

  更多关于类加载及对象初始化顺序的介绍, 请移步我的博文《 Java 继承、多态与类的复用》

那么,这种写法是不是绝对安全呢?前面说了,从语义角度来看,并没有什么问题。但是其实还是有坑。说这个坑之前我们要先来看看volatile这个关键字。其实这个关键字有两层语义。第一层语义相信大家都比较熟悉,就是可见性。可见性指的是在一个线程中对该变量的修改会马上由工作内存(Work Memory)写回主内存(Main Memory),所以会马上反应在其它线程的读取操作中。顺便一提,工作内存和主内存可以近似理解为实际电脑中的高速缓存和主存,工作内存是线程独享的,主存是线程共享的。volatile的第二层语义是禁止指令重排序优化。大家知道我们写的代码(尤其是多线程代码),由于编译器优化,在实际执行的时候可能与我们编写的顺序不同。编译器只保证程序执行结果与源代码相同,却不保证实际指令的顺序与源代码相同。这在单线程看起来没什么问题,然而一旦引入多线程,这种乱序就可能导致严重问题。volatile关键字就可以从语义上解决这个问题。

注意,前面反复提到“从语义上讲是没有问题的”,但是很不幸,禁止指令重排优化这条语义直到jdk1.5以后才能正确工作。此前的JDK中即使将变量声明为volatile也无法完全避免重排序所导致的问题。所以,在jdk1.5版本前,双重检查锁形式的单例模式是无法保证线程安全的

枚举单例法(推荐)

当然,还有一种更加优雅的方法来实现单例模式,那就是枚举写法:

public enum Singleton {
    INSTANCE;
    private String name;
    public String getName(){
        return name;
    }
    public void setName(String name){
        this.name = name;
    }
}
public class SingletonExample7 {

    // 私有构造函数
    private SingletonExample7() {

    }

    public static SingletonExample7 getInstance() {
        return Singleton.INSTANCE.getInstance();
    }

    //创建对象使用
    private enum Singleton {
        INSTANCE;

        private SingletonExample7 singleton;

        // JVM保证这个方法绝对只调用一次
        Singleton() {
            singleton = new SingletonExample7();
        }

        public SingletonExample7 getInstance() {
            return singleton;
        }
    }
}

 

使用枚举除了线程安全和防止反射强行调用构造器之外,还提供了自动序列化机制,防止反序列化的时候创建新的对象。因此,Effective Java推荐尽可能地使用枚举来实现单例。

枚举中的各个枚举项同事通过static来定义的。如:

public enum T {
    SPRING,SUMMER,AUTUMN,WINTER;
}

反编译后代码为:

public final class T extends Enum
{
    //省略部分内容
    public static final T SPRING;
    public static final T SUMMER;
    public static final T AUTUMN;
    public static final T WINTER;
    private static final T ENUM$VALUES[];
    static
    {
        SPRING = new T("SPRING", 0);
        SUMMER = new T("SUMMER", 1);
        AUTUMN = new T("AUTUMN", 2);
        WINTER = new T("WINTER", 3);
        ENUM$VALUES = (new T[] {
            SPRING, SUMMER, AUTUMN, WINTER
        });
    }
}

static类型的属性会在类被加载之后被初始化,我们在深度分析Java的ClassLoader机制(源码级别)中介绍过,当一个Java类第一次被真正使用到的时候静态资源被初始化、Java类的加载和初始化过程都是线程安全的(因为虚拟机在加载枚举的类的时候,会使用ClassLoader的loadClass方法,而这个方法使用同步代码块保证了线程安全)。所以,创建一个enum类型是线程安全的。也就是说,我们定义的一个枚举,在第一次被真正用到的时候,会被虚拟机加载并初始化,而这个初始化过程是线程安全的。而解决单例的并发问题,主要解决的就是初始化过程中的线程安全问题。所以,由于枚举的以上特性,枚举实现的单例是天生线程安全的。

静态内部类法

那么,有没有一种延时加载,并且能保证线程安全的简单写法呢?我们可以把Singleton实例放到一个静态内部类中,这样就避免了静态实例在Singleton类加载的时候就创建对象,并且由于静态内部类只会被加载一次,所以这种写法也是线程安全的:

public class Singleton {
    private static class Holder {
        private static Singleton singleton = new Singleton();
    }
    
    private Singleton(){}
        
    public static Singleton getSingleton(){
        return Holder.singleton;
    }
}

但是,上面提到的所有实现方式都有两个共同的缺点:

  • 都需要额外的工作(Serializable、transient、readResolve())来实现序列化,否则每次反序列化一个序列化的对象实例时都会创建一个新的实例。

  • 可能会有人使用反射强行调用我们的私有构造器(如果要避免这种情况,可以修改构造器,让它在创建第二个实例的时候抛异常)。

总结

不管采取何种方案,请时刻牢记单例的三大要点:

  • 线程安全
  • 延迟加载
  • 序列化与反序列化安全

 

参考: https://blog.youkuaiyun.com/justloveyou_/article/details/64127789

         https://www.cnblogs.com/andy-zhou/p/5363585.html

        https://blog.youkuaiyun.com/moakun/article/details/80688851

 

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值