单例模式的多种创建方式

提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档


前言

在计算机系统中,线程池、缓存、日志对象、对话框、打印机、显卡的驱动程序对象常被设计成单例。事实上,这些应用都或多或少具有资源管理器的功能。例如,每台计算机可以有若干个打印机,但只能有一个 Printer Spooler (单例) ,以避免两个打印作业同时输出到打印机中。再比如,每台计算机可以有若干通信端口,系统应当集中 (单例) 管理这些通信端口,以避免一个通信端口同时被两个请求同时调用。总之,选择单例模式就是为了避免不一致状态,避免政出多头


提示:以下是本篇文章正文内容,下面案例可供参考

一、如何实现一个单例?

单例模式,顾名思义就是只有一个实例,并且她自己负责创建自己的对象,这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。下面我们来看下有哪几种实现方式吧。

1.饿汉式

  • 在类加载的时候,instance 静态实例就已经创建并初始化好了,所以,instance 实例的创建过程是线程安全的。不过,这样的实现方式不支持延迟加载。对 - 内存有一定的浪费。
public class IdGenerator { 
  private static final IdGenerator instance = new IdGenerator();
  private IdGenerator() {}
  public static IdGenerator getInstance() {
    return instance;
  }
  public void getMethod() { 
    System.out.println("我执行了。。。");
  }
}
  • 饿汉式在类创建的同时就已经创建好一个静态的对象供系统使用,以后不再改变,所以天生是线程安全的。

2.懒汉式

  • 懒汉式相对于饿汉式的优势是支持延迟加载。但是,加了synchronized锁,效率很低。
public class IdGenerator { 
  private static IdGenerator instance;
  private IdGenerator() {}
  public static synchronized IdGenerator getInstance() {
    if (instance == null) {
      instance = new IdGenerator();
    }
    return instance;
  }
  public void getMethod() { 
    System.out.println("我执行了。。。");
  }
}
  • 效率低下原因:我们给 getInstance() 这个方法加了一把大锁(synchronzed),导致这个函数的并发度很低。量化一下的话,并发度是 1,也就相当于串行操作了。而这个函数是在单例使用期间,一直会被调用。如果这个单例类偶尔会被用到,那这种实现方式还可以接受。但是,如果频繁地用到,那频繁加锁、释放锁及并发度低等问题,会导致性能瓶颈,这种实现方式就不可取了。

3.双重检测

  • 我们再来看一种既支持延迟加载、又支持高并发的单例实现方式,也就是双重检测实现方式。
public class IdGenerator { 
  private static  IdGenerator instance;
  private IdGenerator() {}
  public static  IdGenerator getInstance() {
    if (instance == null) {
        synchronized(IdGenerator.class){//类级别的锁
            if(instance == null){
                instance = new IdGenerator();
            }
        }
    }
    return instance;
  }
  public void getMethod() { 
    System.out.println("我执行了。。。");
  }
}
  • 此处看似很完美的代码,纯在一个bug。可以使用volatile做解决。解释在目录二中

4.静态内部类

  • 能实现双重检测一样的功效,但实现更简单
public class IdGenerator { 
  private IdGenerator() {}

  private static class SingletonHolder{
    private static final IdGenerator instance = new IdGenerator();
  }
  
  public static IdGenerator getInstance() {
    return SingletonHolder.instance;
  }
 
  public void getMethod() { 
    System.out.println("我执行了。。。");
  }
}

5.枚举

  • 基于枚举类型的单例实现。这种实现方式通过 Java 枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性
public enum IdGenerator {
  INSTANCE;
 
  public void getMethod() { 
    System.out.println("我执行了。。。");
  }
}

二、单例深入解析

1.使用volatile防止指令重排

创建一个对象,在JVM中会经过三步:

(1)为singleton分配内存空间

(2)初始化singleton对象

(3)将singleton指向分配好的内存空间

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

在这里

在这三步中,第2、3步有可能会发生指令重排现象,创建对象的顺序变为1-3-2,会导致多个线程获取对象时,有可能线程A创建对象的过程中,执行了1、3步骤,线程B判断singleton已经不为空,获取到未初始化的singleton对象,就会报NPE异常。文字较为晦涩,可以看流程图

在这里插入图片描述

使用volatile关键字可以**防止指令重排序,**其原理较为复杂,这篇博客不打算展开,可以这样理解:使用volatile关键字修饰的变量,可以保证其指令执行的顺序与程序指明的顺序一致,不会发生顺序变换,这样在多线程环境下就不会发生NPE异常了。

双重检测防止指令重排代码展示:

public class IdGenerator { 
  private static volatile IdGenerator instance;
  private IdGenerator() {}
  public static  IdGenerator getInstance() {
    if (instance == null) {// 线程A和线程B同时看到instance = null,如果不为null,则直接返回instance
        synchronized(IdGenerator.class){//类级别的锁  // 线程A或线程B获得该锁进行初始化
            if(instance == null){// 其中一个线程进入该分支,另外一个线程则不会进入该分支
                instance = new IdGenerator();
            }
        }
    }
    return instance;
  }
  public void getMethod() { 
    System.out.println("我执行了。。。");
  }
}

2.单例并不是无坚不摧的(枚举除外)

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

    • 利用反射,强制访问类的私有构造器,去创建另一个对象
    • 序列化中readObject() 方法读入对象时,它必定会返回一个新的对象实例,必然指向新的内存地址。
  • 反射破坏单例:

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

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

3.无坚不摧的枚举

  • 枚举的优势:

    • 代码简洁,无多余的处理。
    • 使用枚举可以防止调用者使用反射序列化与反序列化机制强制生成多个单例对象,破坏单例模式。
  • 为什么枚举不用考虑反射源码如下:

public T newInstance(Object ... initargs)
    throws InstantiationException, IllegalAccessException,
IllegalArgumentException, InvocationTargetException
{ 
    if (!override) { 
        if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) { 
            Class<?> caller = Reflection.getCallerClass();
            checkAccess(caller, clazz, null, modifiers);
        }
    }
    // 如果是枚举类型,直接抛出异常,不让创建实例对象!
    if ((clazz.getModifiers() & Modifier.ENUM) != 0)
        throw new IllegalArgumentException("Cannot reflectively create enum objects");
    ConstructorAccessor ca = constructorAccessor;   // read volatile
    if (ca == null) { 
        ca = acquireConstructorAccessor();
    }
    @SuppressWarnings("unchecked")
    T inst = (T) ca.newInstance(initargs);
    return inst;
}

如果是enum类型,则直接抛出异常Cannot reflectively create enum objects,无法通过反射创建实例对象!

  • 为什么枚举不用考虑序列化和反序列化
    枚举序列化是由JVM保证的,每一个枚举类型和定义的枚举变量在JVM中都是唯一的,在枚举类型的序列化和反序列化上,Java做了特殊的规定:在序列化时Java仅仅是将枚举对象的name属性输出到结果中,反序列化的时候则是通过java.lang.Enum的valueOf方法来根据名字查找枚举对象。同时,编译器是不允许任何对这种序列化机制的定制的并禁用了writeObject、readObject、readObjectNoData、writeReplace和readResolve等方法,从而保证了枚举实例的唯一性。

总结

实现单例的关键:

  • 构造函数需要是 private 访问权限的,这样才能避免外部通过 new 创建实例;
  • 考虑对象创建时的线程安全问题;
  • 考虑是否支持延迟加载;
  • 考虑 getInstance() 性能是否高(是否加锁)。

一般情况下,使用双汉(饿,懒)方式是不错的选择。在要明确实现 lazy loading 效果时,推荐使用静态内部类。如果涉及到反序列化创建对象时,可以尝试使用枚举方式。如果有其他特殊的需求,可以考虑使用双检检测。

枚举实例的唯一性。

  • 2
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 5
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

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

抵扣说明:

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

余额充值