单例模式
单例模式
单例对象(Singleton)是一种常见的设计模式。在java应用中,单例对象能保证在一个JVM中,该对象只有一个实例存在。就是在当前进程中,通过单例模式创建的类有且只有一个实例
。
特点:
- 在java应用中单例模式能保证在一个JVM中,该对象只有一个实例存在。
- 构造器必须是私有的,外部类无法通过调用构造器方法创建该实例。
- 没有公开的set方法,外部类无法调用set方法创建该实例。
- 模拟一个公开的get方法获取唯一的这个实例。
单例模式的好处:
- 某些类创建比较繁琐,对于一些大型的对象,这是一笔很大的系统开销。
- 省去了new操作符,降低了系统内存的使用频率,减轻GC压力。
- 系统中某些类,如Spring里的controller,控制着处理流程,如果该类可以创建多个的话,系统就完全乱了。
单例模式的写法如下
懒汉式:延迟加载方式
饿汉式:立即加载
单例模式如果使用不当,就容易引起线程安全问题。
- 饿汉式不存在线程安全问题,但是它一般不被使用,因为它会浪费内存的空间;
- 懒汉式会合理使用空间,只有第一次被加载的时候,才会真正去创建对象,但是这种方式存在线程安全问题。
懒汉式单例模式的实现方式有三种:
双重检查锁方式(DCL)
- 静态内部类方式
- 枚举方式
使用场景:
- 要求生成唯一序列号的环境;
- 在整个项目中需要一个共享访问点或共享数据,例如一个 Web 页面上的计数器,可以不用把每次刷新都记录到数据库中,使用单例模式保持计数器的值,并确保是线程安全的;
- 创建一个对象需要消耗的资源过多,如要访问 IO 和数据库等资源;
- 需要定义大量的静态常量和静态方法(如工具类)的环境,可以采用单例模式(当然,也可以直接声明为 static 的方式)。
饿汉式
/** * 饿汉式 * 在类创建时,直接把该类对象new出来,就是说一旦类创建,类的静态成员就会在类加载过程中被初始 * 化,初始化的时候它就会加锁,所以这个流程是线程安全的。 * 实现方法: * 1.构造私有 * 2.使用私有静态成员变量初始化本身对象 * 3.对外提供静态公共方法获取本身对象 */ public class HungryHanstyle { //成员变量初始化本身对象 private static HungryHanstyle hungryHanstyle = new HungryHanstyle(); //构造方法私有化 private HungryHanstyle(){} //对外提供公共方法获取对象 public static HungryHanstyle getInstance(){ return hungryHanstyle; } }
懒汉式
/** * 懒汉式 * 在类创建时,先不初始化本身对象,只有在调用getInstance()方法的时候才new对象。 * * 实现方法: * 1.构造私有 * 2.定义私有静态成员变量,先不初始化 * 3.定义公开静态方法,获取本身对象 * * 线程安全问题,判断依据: * 1.是否存在多线程 是 * 2.是否有共享数据 是 * 3.是否存在非原子性操作 * */ public class Sluggard { //1.构造私有 private Sluggard(){} //2.定义私有静态成员变量,先不初始化 private static Sluggard sluggard = null; //3.定义公开静态方法,获取本身对象 public static Sluggard getInstance(){ //如果没有对象,则创建 if(sluggard == null){ sluggard = new Sluggard(); } //如果有对象就返回已经有的对象 return sluggard; } }
懒汉式虽然合理的运用了空间,但却是线程不安全的,比如AB两个线程,如果没加锁,AB两线程都会访问到 getInstance() 方法里面, 假如A线程和B线程都访问到“sluggard = new Sluggard();”这行代码的时候,并且它们都创建成功了sluggard对象,也就是说在堆中有A线程创建的sluggard对象,也有B线程创建的sluggard对象。画出图(假设对象的地址码是0x001):
sluggard 变量是没法引用多个对象的,只能引用其中一个,所以另一个就是垃圾对象,需要被回收,sluggard 只能引用最后一个对象。
就是说,如果有多个线程都成功创建了sluggard对象,这些对象中只有一个被引用,而其他的对象就失去了引用,于是就成了垃圾对象,这些垃圾对象就会被GC回收。
这种方式创建对象,造成了线程安全问题,以及浪费了堆内存。
以下有两种方式优化该代码,解决了线程安全问题:
方式一加锁
/** * 懒汉式(方法上加锁) * * synchronized 关键字锁住的是这个对象,这样的用法,在性能上会有所下降 * 因为每次调用 getInstance(),都要对对象上锁 * 事实上,只有在第一次创建对象的时候需要加锁,之后就不需要了 * */ public class Sluggard1 { //1.构造私有 private Sluggard1(){} //2.定义私有静态成员变量,先不初始化 private static Sluggard1 sluggard = null; //3.定义公开静态方法,获取本身对象,给方法加锁(synchronized 关键字) public static synchronized Sluggard1 getInstance(){ //如果没有对象,则创建 if(sluggard == null){ sluggard = new Sluggard1(); } //如果有对象就返回已经有的对象 return sluggard; } }
虽然加锁是线程安全的,但是这个加锁的方式力度大了,因为多线程环境下每个线程都要执行 getInstance(),都要等上一个线程把锁释放,导致阻塞,所以性能下降。
方式二双重检查锁
/** * 懒汉式(通过代码块的方式加锁) * */ public class Sluggard2 { //1.构造私有 private Sluggard2(){} //2.定义私有静态成员变量,先不初始化 private static Sluggard2 sluggard = null; //3.定义公开静态方法,获取本身对象 public static Sluggard2 getInstance(){ //如果没有对象,则创建 if(sluggard == null){ //**位置1** //采用这种方式,对于对象的选择会有问题 //JVM优化机制:先分配内存空间,再初始化 synchronized (Sluggard2.class){ if(sluggard == null){ sluggard = new Sluggard2(); //**位置2** //1、new--->申请内存空间(此时已经由内存空间地址) //2、完成属性的初始化(赋值) //3、将对象内存地址交给变量 sluggard 来保存 //new--->开辟JVM中堆空间--->产生堆内存地址保存到栈内存的 sluggard 引用中--->创建对象 } } } return sluggard; } }
这种方式,在多线程环境下执行 getInstance() 时先判断单例对象是否已经初始化,如果已经初始化,就直接返回单例对象,如果未初始化,就在同步代码块中先进行初始化,然后返回,效率很高。
但是这种方式是一个错误的优化,问题出在位置2中的“sluggard = new Sluggard2();”这行代码。它可以分解为如下3行代码:
memory = Sluggard2(); //1.分配对象的内存空间 ctorInstance(memory); //2.初始化对象 sluggard = memory; //3.设置sInstance指向刚分配的内存地址
上述伪代码中的2和3之间可能会发生重排序,重排序(也就是指令重排序)后的执行顺序如下:
memory = Sluggard2(); //1.分配对象的内存空间 sluggard = memory; //2.设置sInstance指向刚分配的内存地址 ctorInstance(memory); //3.初始化对象
因为这种重排序并不影响Java规范中的规范:intra-thread sematics 允许那些在单线程内不会改变单线程程序执行结果的重排序。其实指令重排序在多CPU情况下,为了提高CPU的利用率。
当发生指令重排后,多线程并发时可能会出现以下情况:
也就是说,如果A线程先执行并且执行到了位置2(这里说的位置2是双重检查锁代码标注的注释位置)的“sluggard = new Sluggard2();”这行代码,new分配了内存空间,并设置 sluggard 指向分配的内存,但这时A线程的CPU时间片段发生停顿了,这时线程B开始执行了,执行到了位置1的代码,于是线程B判断sluggard是否为空,发现不为空,于是就访问 sluggard 引用的对象,可这个对象是没有初始化的,所以B线程访问到的 sluggard 是一个未初始化的对象,程序就报错了。
如果上面图例不理解,可以用JVM内存图来表示,如下:
就是说,如果A、B线程都判断了第一个if,A、B线程跑到了 synchronized 块这里,这时A线程先抢到锁,所以A进去了。A进去判断了if,发现对象为空,于是就执行了“sluggard = new Sluggard2();”这行代码,由于JVM内部的优化机制,JVM会先分配一个空白内存给 Sluggard2 实例,并将这个实例赋值给 sluggard 成员(注意此时JVM还没有开始初始化这个实例),此时,A线程突然有急事,没等实例初始化就跑了,A离开了 synchronized 块,释放了锁。接下来B线程就拿到了锁,进入了 synchronized 块,判断了if,发现 sluggard 不为null(这时对象还没有初始化),所以B也离开了,B打算使用这个 Sluggard2 实例,却发现它没有被初始化,于是就产生了错误。用时间线来表示,如下图:
以下三种是解决双重检查锁问题的优化代码:
使用volatile
/** * 懒汉式(双重检查锁和属性加volatile的方式) * */ public class Sluggard3 { //1.构造私有 private Sluggard3(){} //2.定义私有静态成员变量,先不初始化 //加上volatile(强刷工作内存,保证了有序性,禁止指令重排) private volatile static Sluggard3 sluggard = null; //3.定义公开静态方法,获取本身对象 public static Sluggard3 getInstance(){ //如果没有对象,则创建 if(sluggard == null){ synchronized (Sluggard3.class){ if(sluggard == null){ sluggard = new Sluggard3(); } } } return sluggard; } }
加上volatile后,在多线程环境中禁止指令重排序,并且保证其可见性。
静态内部类
import java.io.Serializable; /** * 懒汉式(静态内部类的方式) * */ public class StaticSingleton implements Serializable { private static final long serialVersionUID = 1L; //构造私有 private StaticSingleton(){} /** * 此处使用一个内部类来维护单例,JVM在类加载的时候,是互斥的,所有可以由此保证线程安全问题 */ private static class SingletonFactory{ private static StaticSingleton singleton = new StaticSingleton(); } /** * 获取实例 * @return */ public static StaticSingleton getInstance(){ return SingletonFactory.singleton; } }
静态内部类不会随着外部类的初始化而初始化,他是要单独去加载和初始化的,当第一次执行 getInstance() 方法时,SingletonFactory 类会被初始化。
静态对象 singleton 的初始化在 SingletonFactory 类初始化阶段进行,类初始化阶段即虚拟机执行类构造器< clinit >()方法的过程。
虚拟机会保证一个类的< clinit >()方法在多线程环境下被正确的加锁和同步,如果多个线程同时初始化一个类,只会有一个线程执行这个类的< clinit >()方法,其它线程都会阻塞等待。
< clinit >() 方法是类构造器方法,对静态变量,静态代码块进行初始化,在类加载过程的初始化阶段虚拟机会执行 < clinit >() 方法。
以下待完善
===========================================
枚举类
/** * 枚举单例 */ public enum EnumSingleton { INSTANCE; public void talk(){ System.out.println("This is an EnumSingleton "+this.hashCode()); } }
Java枚举都隐式继承自Enum抽象类,而Enum抽象类根本没有无参构造方法,只有如下一个构造方法:
protected Enum(String name, int ordinal){
this.name = name;
this.ordinal = ordinal;
}
所以枚举单例对反射防御。
如果是用:
Constructor constructor = EnumSingleton.class.getDeclaredConstructor(String.class, int.class);
其结果还是无法破坏。
来到 Constructor.newInstance() 方法中,有如下语句:
if((clazz.getModifiers() & Modifier.ENUM) != 0){
throw new IllegalArgumentException(“Cannot reflectively create enum objects”);
}
可见,JDK 反射机制内部完全禁止了用反射创建枚举实例的可能性。
枚举对序列化的防御。
如果将 serializationAttack() 方法中的攻击目标换成 EnumSingleton,那么我们会发现s1和s2实际上是同一个实例,最终会打印出true。这是因为 ObjectInputStream 类中,对枚举类型有一个专门的 readEnum() 方法来处理,其简要流程如下:
- 通过类描述获取枚举单例的类型 EnumSingleton;
- 取得枚举单例中的枚举值的名字(这里是 INSTANCE);
- 调用 Enum.valueOf()方法,根据枚举类型和枚举值的名字,获得最终的单例。
这种处理方法与 readResolve() 方法大同小异,都是以绕过反射直接获取单例为目标。不同的是,枚举对序列化的防御仍然是 JDK 内部实现的。
综上所述,枚举单例确实是目前最好的单例实现了,不仅写法非常简单,而且JDK能够保证其安全性,不需要我们做额外的工作。
1和s2实际上是同一个实例,最终会打印出true。这是因为 ObjectInputStream 类中,对枚举类型有一个专门的 readEnum() 方法来处理,其简要流程如下:
- 通过类描述获取枚举单例的类型 EnumSingleton;
- 取得枚举单例中的枚举值的名字(这里是 INSTANCE);
- 调用 Enum.valueOf()方法,根据枚举类型和枚举值的名字,获得最终的单例。
这种处理方法与 readResolve() 方法大同小异,都是以绕过反射直接获取单例为目标。不同的是,枚举对序列化的防御仍然是 JDK 内部实现的。
综上所述,枚举单例确实是目前最好的单例实现了,不仅写法非常简单,而且JDK能够保证其安全性,不需要我们做额外的工作。
============
以上是本人的个人总结,还有瑕疵,待完善。