单例模式创建方式各自特点

单例模式创建方式各自特点

前言

这是设计模式的第一篇文章,我们从单例模式开始入手,单例模式是 Java 设计模式中最简单的一种,只需要一个类就能实现单例模式,但是,你可不能小看单例模式,虽然从设计上来说它比较简单,但是在实现当中你会遇到非常多的坑,所以,系好安全带,上车。

单例模式定义

单例模式就是在程序运行中只实例化一次,创建一个全局唯一对象,有点像 Java 的静态变量,但是单例模式要优于静态变量,静态变量在程序启动的时候JVM就会进行加载,如果不使用,会造成大量的资源浪费,单例模式能够实现懒加载,在使用实例的时候才去创建实例。开发工具类库中的很多工具类都应用了单例模式,比例线程池、缓存、日志对象等,它们都只需要创建一个对象,如果创建多份实例,可能会带来不可预知的问题,比如资源的浪费、结果处理不一致等问题。

单例的实现思路

  • 静态化实例对象
  • 私有化构造方法,禁止通过构造方法创建实例
  • 提供一个公共的静态方法,用来返回唯一实例

单例的好处

  • 只有一个对象,内存开支少、性能好
  • 避免对资源的多重占用
  • 在系统设置全局访问点,优化和共享资源访问

单例模式的实现方式

单例模式的写法有饿汉模式懒汉模式双重检查锁模式静态内部类单例模式、枚举类实现单例模式五种方式,其中懒汉模式、双重检查锁模式,如果你写法不当,在多线程情况下会存在不是单例或者单例出异常等问题,具体的原因,在后面的对应处会进行说明。我们从最基本的饿汉模式开始我们的单例编写之路。

饿汉式

饿汉模式采用一种简单粗暴的形式,在定义静态属性时,直接实例化了对象。代码如下

public class SingleTon {
    //用静态变量存储唯一实例化对象
    private static SingleTon INSTANCE1 = new SingleTon();

    //私有化构造函数
    private SingleTon() {}
    
    //提供公共的静态方法,用来返回唯一的实例
    public static SingleTon getInstance1() {
        return INSTANCE1;
    }
}

//使用
class Test{
    public static void main(String[] args){
        SingleTon singletion = SingleTon.getInstance1();
    }
}
优点
  • 线程安全,用空间换时间。

    因为饿汉式单例模式,定义的static静态属性直接实例化对象,因此在JVM加载初始化类的时候就初始化了对象,保证了线程安全。

缺点
  • 无法延迟实例化造成空间浪费

    如果一个类比较大,我们在初始化的时候就实例化了这类,但是长时间没有使用到这个类就造成了内存空间浪费。

  • 单例容易被破坏(反射、序列化、反序列化)

懒汉式

懒汉模式是一种偷懒的模式,在程序初始化时不会创建实例,只有在使用实例的时候才会创建实例,所以懒汉模式解决了饿汉模式带来的空间浪费问题,同时也引入了其他的问题,我们先来看看下面这个懒汉模式

public class SingleTon {
	//静态化实例对象
 	private static SingleTon INSTANCE2 = null;

	//私有化构造函数
	private SingleTon() {}
    
    //通过公共的静态方法返回唯一实例
    public static SingleTon getInstance2(){
        // 使用时,先判断实例是否为空,如果实例为空,则实例化对象
        if(INSTANCE2 == null){
            INSTANCE2 = new SingleTon();
        }
        return INSTANCE2;
    }
    
    
//使用
class Test{
    public static void main(String[] args){
        SingleTon singletion = SingleTon.getInstance2();
    }
}

上面是懒汉模式的实现方式,但是上面这段代码在多线程的情况下是不安全的,因为它不能保证是单例模式,有可能会出现多份实例的情况,出现多份实例的情况是在创建实例对象时候造成的。所以我单独把实例化的代码提出,来分析一下为什么会出现多份实例的情况。

1 if(INSTANCE2 == null){
2 	INSTANCE2 = new SingleTon();
  }

假设有两个线程都进入到 1 这个位置,因为没有任何资源保护措施,所以两个线程可以同时判断的 instance都为空,都将去执行 2 的实例化代码,所以就会出现多份实例的情况。

通过上面的分析我们已经知道出现多份实例的原因,如果我们在创建实例的时候进行资源保护,是不是可以解决多份实例的问题?确实如此,我们给 getInstance()方法加上 synchronized关键字,使得 getInstance()方法成为受保护的资源就能够解决多份实例的问题。加上 synchronized关键字之后代码如下:

/**
 * 添加class类锁,影响了性能,加锁之后将代码进行了串行化,
 * 我们的代码块绝大部分是读操作,在读操作的情况下,代码线程是安全的
 */
public synchronized static SingleTon getInstance2(){
        if(INSTANCE2 == null){
            INSTANCE2 = new SingleTon();
        }
        return INSTANCE2;
    }

经过修改后,我们解决了多份实例的问题,但是因为加入了 synchronized关键字,对代码加了锁,就引入了新的问题,加锁之后会使得程序变成串行化,只有抢到锁的线程才能去执行这段代码块,这会使得系统的性能大大下降。

优点
  • 实现了延迟实例化,节省内存空间
缺点
  • 在不加锁情况下,线程不安全,可能存在多份实例。
  • 在加锁情况下,程序串行化,系统存在严重性能问题。
  • 单例容易被破坏(反射、序列化、反序列化)
懒汉式-双重检验锁

再来讨论一下懒汉模式中加锁的问题,对于 getInstance()方法来说,绝大部分的操作都是读操作,读操作是线程安全的,所以我们没必让每个线程必须持有锁才能调用该方法,我们需要调整加锁的问题。由此也产生了一种新的实现模式:双重检查锁模式,下面是双重检查锁模式的单例实现代码块:

 public class SingleTon {
    //静态化实例对象
    public static SingleTon INSTANCE3 = null;

    //私有化构造函数
    private SingleTon() {}
    
    //提供公共的静态方法,返回唯一的实例
    public static SingleTon getInstance3() {
        //第一次判断,如果这里为空,不进入抢锁阶段,直接返回实例
        if (INSTANCE3 == null) {
            synchronized (SingleTon.class) {
                //抢到锁之后再次判断是否为空
                if (INSTANCE3 == null) {
                    INSTANCE3 = new SingleTon();
                }
            }
        }
        return INSTANCE3;
    }
}

双重检查锁模式是一种非常好的单例实现模式,解决了单例、性能、线程安全问题,上面的双重检测锁模式看上去完美无缺,其实是存在问题,在多线程的情况下,可能会出现空指针问题,出现问题的原因是JVM在实例化对象的时候会进行优化和指令重排序操作(乱序执行)。什么是指令重排?,看下面这个例子,简单了解一下指令从排序

private SingletonObject4(){
        1 int x = 10;
        2 int y = 30;
        3 Object o = new Object();
    }

上面的构造函数 SingletonObject4(),我们编写的顺序是1、2、3,JVM 会对它进行指令重排序,所以执行顺序可能是3、1、2,也可能是2、3、1,不管是那种执行顺序,JVM 最后都会保证所有实例都完成实例化。如果构造函数中操作比较多时,为了提升效率,JVM 会在构造函数里面的属性未全部完成实例化时,就返回对象。双重检测锁出现空指针问题的原因就是出现在这里,当某个线程获取锁进行实例化时,其他线程就直接获取实例使用,由于JVM指令重排序的原因,其他线程获取的对象也许不是一个完整的对象,所以在使用实例的时候就会出现空指针异常问题。

回到双重检测锁创建单例代码中:

INSTANCE3 = new SingleTon();

这个步骤,其实在jvm里面执行分为三步

  1. 在堆内存开辟内存空间。
  2. 在堆内存中实例化SingleTon里面的各个参数。
  3. 把对象指向堆内存空间。

由于jvm存在指令重排乱序执行功能,所以可能在2还没执行时就先执行了3,如果此时在被切换到线程B,由于执行了3,INSTANCE3已经非空了,会被直接拿出来用,这样就会抛出异常,这就是著名的DCL失效问题。

要解决双重检查锁模式带来空指针异常的问题,在JDK1.5之后官方发现这个问题后故而具体化了volatile关键字,volatile确保INSTANCE每次均在主内存中读取(而不是使用一个缓存值), volatile关键字严格遵循 happens-before原则,即在读操作前,写操作必须全部完成。添加 volatile关键字之后的单例模式代码:

public class SingleTon {
    //静态化实例对象(使用 volatile关键字修饰)
    public volatile static SingleTon INSTANCE3 = null;

    //私有化构造函数
    private SingleTon() {}
    
    //提供公共的静态方法,返回唯一的实例
    public static SingleTon getInstance3() {
        //第一次判断,如果这里为空,不进入抢锁阶段,直接返回实例
        if (INSTANCE3 == null) {
            synchronized (SingleTon.class) {
                //抢到锁之后再次判断是否为空
                if (INSTANCE3 == null) {
                    INSTANCE3 = new SingleTon();
                }
            }
        }
        return INSTANCE3;
    }
}

//使用
class Test{
    public static void main(String[] args){
        SingleTon singletion = SingleTon.getInstance3();
    }
}

添加 volatile关键字之后的双重检查锁模式是一种比较好的单例实现模式,能够保证在多线程的情况下线程安全也不会有性能问题。

优点
  • 线程安全
  • 延迟实例化,避免内存空间浪费
缺点
  • 容易导致DCL失效(需要使用volatile关键字解决)

  • 单例容易被破坏(反射、序列化、反序列化)

静态内部类方式

静态内部类单例模式也称单例持有者模式,实例由内部类创建,由于 JVM 在加载外部类的过程中, 是不会加载静态内部类的, 只有内部类的属性/方法被调用时才会被加载, 并初始化其静态属性。静态属性由 static修饰,保证只被实例化一次,并且严格保证实例化顺序。静态内部类单例模式代码如下:

public class SingleTon {
    //私有化构造函数
    private SingleTon() {}
    
    //单例持有者(静态内部类创建单例)
    private static class Build {
        private static SingleTon INSTANCE4 = new SingleTon();
    }
    
    //提供公共静态方法,返回唯一的实例对象
    public static SingleTon getInstance4() { 
		//调用内部类属性
        return Build.INSTANCE4;
    }
}

//使用
class Test{
    public static void main(String[] args){
        SingleTon singletion = SingleTon.getInstance4();
    }
}

静态内部类单例模式是一种优秀的单例模式,是开源项目中比较常用的一种单例模式。在没有加任何锁的情况下,保证了多线程下的安全,并且没有任何性能影响和空间的浪费。

优点
  • 线程安全
  • 延迟实例化,避免内存空间浪费
缺点
  • 无法传参数

    由于是静态内部类的形式去创建单例的,故外部无法传递参数进去,例如Context这种参数,所以,我们创建单例时,可以在静态内部类与DCL模式里自己斟酌。

枚举方式

枚举类实现单例模式是 effective java 作者极力推荐的单例实现模式,因为枚举类型是线程安全的,并且只会装载一次,设计者充分的利用了枚举的这个特性来实现单例模式,枚举的写法非常简单,而且枚举类型是所用单例实现中唯一一种不会被破坏的单例实现模式。

public enum SingleTon{
    INSTANCE;
}

//使用
//使用
class Test{
    public static void main(String[] args){
        SingleTon singletion = SingleTon.INSTANCE
    }
}

通过将定义好的枚举反编译,我们就能发现,其实枚举在经过javac的编译之后,会被转换成形如public final class T extends Enum的定义。而且,枚举中的各个枚举项同事通过static来定义的。如:

public enum SingleTon {
    INSTANCE;
}

反编译后代码为:

public final class SingleTon extends Enum
{
    //省略部分内容
    public static final SingleTon INSTANCE;
    private static final SingleTon ENUM$VALUES[];
    static
    {
        INSTANCE = new SingleTon("INSTANCE", 0);
        ENUM$VALUES = (new T[] {INSTANCE});
    }
}

了解JVM的类加载机制的朋友应该对这部分比较清楚。static类型的属性会在类被加载之后被初始化,我们在深度分析Java的ClassLoader机制(源码级别)中介绍过,当一个Java类第一次被真正使用到的时候静态资源被初始化、Java类的加载和初始化过程都是线程安全的(因为虚拟机在加载枚举的类的时候,会使用ClassLoader的loadClass方法,而这个方法使用同步代码块保证了线程安全)。所以,创建一个enum类型是线程安全的。

也就是说,我们定义的一个枚举,在第一次被真正用到的时候,会被虚拟机加载并初始化,而这个初始化过程是线程安全的。而我们知道,解决单例的并发问题,主要解决的就是初始化过程中的线程安全问题。

所以,由于枚举的以上特性,枚举实现的单例是天生线程安全的。

优点
  • 实现简单

  • 线程安全,不会出现DCL失效问题

  • 单例不会被破坏(反射、序列化、反序列化)

缺点
  • 不能延迟实例化

破坏单例模式的方法及解决办法

除枚举方式外, 其他方法都会通过反射或序列化和反序列化的方式破坏单例。

解决反射破坏单例

反射是通过调用构造方法生成新的对象,所以如果我们想要阻止单例破坏,可以在构造方法中进行判断,若已有实例, 则阻止生成新的实例,解决办法如下:

//私有化构造函数
private SingleTon() {
   if(INSTANCE1 != null){
      throw new RuntimeException("此类被设计为单例模式,不允许重复创建对象,"+
       "请使用getInstance()获取单例对象");
    }
}
解决序列化、反序列化破坏单例

如果单例类实现了序列化接口Serializable, 就可以通过反序列化破坏单例,所以我们可以不实现序列化接口,如果非得实现序列化接口,可以重写反序列化方法readResolve(), 反序列化时直接返回相关单例对象。

public Object readResolve () throws ObjectStreamException {
       return instance;  
}

总结

单例创建的方式多种,如果明确要求要懒加载(lazy initialization)会倾向于使用静态内部类,如果涉及到反序列化创建对象时会试着使用枚举方式来实现单例。

参考:

为什么我墙裂建议大家使用枚举来实现单例

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值