提示:文章写完后,目录可以自动生成,如何生成可参考右边的帮助文档
文章目录
前言
在计算机系统中,线程池、缓存、日志对象、对话框、打印机、显卡的驱动程序对象常被设计成单例。事实上,这些应用都或多或少具有资源管理器的功能。例如,每台计算机可以有若干个打印机,但只能有一个 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 效果时,推荐使用静态内部类。如果涉及到反序列化创建对象时,可以尝试使用枚举方式。如果有其他特殊的需求,可以考虑使用双检检测。
枚举实例的唯一性。