1、概念引申
什么是单例模式?
通过该模式的方法,创建的类的实例在该进程中,有且只有一个实例。有时候不一定是进程,也有可能是线程,看需要。
单例模式的特征是什么?
1、这个类只能拥有一个实例
2、这个类只能自己创建实例
3、如果有人需要这个实例,比如由该类来提供。new是new不出来对象的。
单例模式的作用:
单例模式的特征极其明显,它保证了在项目的全局只有一个实例,同时创建该实例也只有一个入口。那么这么做的目的是想解决什么问题呢?在我的理解看来,它想达到的目的无非是控制一个全局类的创建和销毁。
业务场景:比如在班级里面,只有一个语文老师。每次你想用语文老师的时候,你也许会new一下,但是频繁的创建同一个语文老师,会浪费资源。既然语文老师只有一个,那么可以用单例模式来解决。同时如果这个语文老师换人了,那么影响的范围也是全局的,你无需因为实例的改变,重新创建新的实例。
OK,那么接下来我们来说说,单例模式的几种类型
2、实操
2.2、饿汉模式
懒汉模式是单例模式下,最经典的一种方式之一。那么怎么写呢?看下面
该图片素材来自于网上
其实我觉得它不应该叫饿汉模式,应该叫勤汉模式,不管需不需要我都创建好,多勤劳。同时,饿汉模式的线程是安全的,不管你是不是并发的场景,你的实例只有一个。
但是,有没有缺陷呢?有,饿汉模式的机制在于,类加载的时候,就会将实例创建出来,比如这个类有其它静态方法被调用的话,会造成浪费。
为啥呢?首先,如果上面这个类存在一个静态方法,碰巧外部有一个业务使用到了这个静态方法,那么这时候,会对这个类进行装载、连接和初始化(参考资料)。这时候,会在连接阶段的准备阶段中为这个类的静态变量赋内存空间(关键)。因为只初始化,没有进行赋值,所以在内存中只占用一个对象头的大小(在32位机器里面是8字节,64位机器是16字节)
2.2、懒汉模式
懒汉模式是单例模式下,最经典的一种方式之一。那么怎么写呢?看下面
该图片素材来自于网上
看了上面的代码不知道大家能不能明白这个写法懒在哪里呢?其实很简单,懒就懒在,用到这实例的时候我就去创建,不用的时候我都懒得管他是不是创建了。
但是这种方式也有一定的缺陷:线程不安全,在多并非的场景下会出现创建出来的实例,不是同一个的情况。解决方式就是在方法处加上同步锁(synchronized)。
加完锁之后,线程不安全的问题解决了,但是随即而来又有新的问题出现。因为我们加的是同步锁,所以在并发的场景去看,其实线程之间是串行的,在进入getInstance方法的时候,线程间会有阻塞的情况发生。为啥不是等待呢?(参考资料)
2.3、双检锁模式
为了解决线程安全的问题,同时不希望出现这样的阻塞问题,双检锁模式诞生了,让我们看看它对比上面的区别在哪里。
双检锁模式:双检在于,有两层对实例是否创建出来的判断。锁在于,最后创建实例的方法加锁了。
那么它是如何解决上述问题的呢?首先,最后创建实例的方法加锁,保证线程安全的同时,避免了线程间的阻塞。而这个锁加在最内层的意图在于:如果是并非的场景,在第一个线程创建出实例之后,其他线程压根就不会进入到这个方法块里面,压根不会被锁资源所阻塞,从性能的角度去看也满足了多并发的要求。
但是,这样看起来很完美的解决方式,其实也有缺陷的!
首先我们来看 new DoubleCheck()这行代码,这个代码是初始化一个对象。但是这个操作并不是一个原子性的操作(同i++类似)。这里面涉及到
1、分配对象的内存空间 (连接阶段的准备阶段)
2、初始化对象 (初始化阶段)
3、分配的内存地址。(真实内存地址)
那么在这里会出现一个问题,因为上诉的三个操作中,在虚拟机优化资源性能的角度去看,一定概率下(很小)会发生重排序,导致创建对象的步骤变为
1、分配对象的内存空间 (连接阶段的准备阶段)
3、分配的内存地址。(真实内存地址)
2、初始化对象 (初始化阶段)
这样会带来一个小问题:在执行完第三步(分配的内存地址)操作后,实例对象不为null了,那么在并发下,很有可能导致其中某一个线程拿到的实例是还没有初始化的实例,那么很有可能导致你使用这里面某个属性(假设还有一个属性A = 1)的时候,为空!
那么问题来了,为什么会重排序这三步骤呢?重排序概念我就不说了,直接说结论,一般这种情况会发生在JIT编译器中,摘抄至《深入理java虚拟机》
JIT 技术
我们大家都知道,通过 javac 将可以将Java程序源代码编译,转换成 java 字节码,JVM 通过解释字节码将其翻译成对应的机器指令,逐条读入,逐条解释翻译。这就是传统的JVM的解释器(Interpreter)的功能。很显然,Java编译器经过解释执行,其执行速度必然会比直接执行可执行的二进制字节码慢很多。为了解决这种效率问题,引入了 JIT(Just In Time ,即时编译) 技术。
有了JIT技术之后,Java程序还是通过解释器进行解释执行,当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是“热点代码”(Hot Spot Code)。然后JIT会把部分“热点代码”翻译成本地机器相关的机器码,并进行优化,然后再把翻译后的机器码缓存起来,以备下次使用。
\
为了解决上面的问题,
2.4、双检锁模式(volatile版)
\
public class Singleton
{
private static volatile Singleton instance;
private Singleton() { }
public static Singleton GetInstance()
{
//先判断是否存在,不存在再加锁处理
if (instance == null)
{
//在同一个时刻加了锁的那部分程序只有一个线程可以进入
lock (syncRoot)
{
if (instance == null)
{
instance = new Singleton();
}
}
}
return instance;
}
}
为啥加完这个关键字之后,这个重排序就被阻止了呢?
首先volatile关键字的作用在于,在使用多线程的时候,可以保证变量,在不同的线程空间内是可见的。
- 写volatile修饰的变量时,JMM会把本地内存中值刷新到主内存
- 读volatile修饰的变量时,JMM会设置本地内存无效
\
重点:为了实现可见性内存语义,编译器在生成字节码时,会在指令序列中插入内存屏障来防止重排序!
但是哪怕都这样了,还能通过反射破坏单例模式
2.5、手动上锁模式
\
直接上代码
/**
* 反射破坏单例
*/
public class ProxyBreakSingleton {
/**
* 反射获取单例模式对象
*/
private static DoubleIfSynchronizedSingleton getProxySingleton(){
DoubleIfSynchronizedSingleton newSingleton = null;
Constructor<DoubleIfSynchronizedSingleton> constructor = null;
try {
constructor = DoubleIfSynchronizedSingleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
newSingleton = constructor.newInstance();
} catch (NoSuchMethodException | IllegalAccessException | InstantiationException | InvocationTargetException e) {
e.printStackTrace();
}
return newSingleton;
}
}
/**
* 双重检测锁的单例模式
*/
class DoubleIfSynchronizedSingleton{
private volatile static DoubleIfSynchronizedSingleton singleton = null;
private DoubleIfSynchronizedSingleton(){}
public static DoubleIfSynchronizedSingleton getSingleton(){
if (singleton == null){
synchronized (DoubleIfSynchronizedSingleton.class){
if (singleton == null){
singleton = new DoubleIfSynchronizedSingleton();
}
}
}
return singleton;
}
}
getDeclaredConstructors() 获取了类所有的构造方法(私有的构造方法),然后类似重新new了一个对象出来。
那么面对这样的问题能不能破?答案是能,既然他获取了所有构造方法,那我就在唯一的构造方法里面做手脚,破了他丫的。
/**
* 双重检测锁的单例模式
*/
public class DoubleIfSynchronizedSingleton {
private static int count = 0;
private volatile static DoubleIfSynchronizedSingleton singleton = null;
private DoubleIfSynchronizedSingleton() {
synchronized (DoubleIfSynchronizedSingleton.class) {
if (count > 0){
throw new RuntimeException("给我破");
}
count++;
}
}
public static DoubleIfSynchronizedSingleton getSingleton() {
if (singleton == null) {
synchronized (DoubleIfSynchronizedSingleton.class) {
if (singleton == null) {
singleton = new DoubleIfSynchronizedSingleton();
}
}
}
return singleton;
}
}
2.5、破·序列化模式
到这我以为已经到头了,已经没人能破的了我的金身了,谁知道,还有序列化这个玩意。如果这个类需要序列化的话,那么会加上这个关键字,然后反序列化的时候会创建出一个新的实例
为什么呢?很复杂,长话短说:从内存读出数据再组装一个对象,因此破坏了单例的规则。(参考资料)
/**
* 双重检测锁的单例模式
*/
public class Serializable DoubleIfSynchronizedSingleton {
private static int count = 0;
private volatile static DoubleIfSynchronizedSingleton singleton = null;
private DoubleIfSynchronizedSingleton() {
synchronized (DoubleIfSynchronizedSingleton.class) {
if (count > 0){
throw new RuntimeException("给我破");
}
count++;
}
}
public static DoubleIfSynchronizedSingleton getSingleton() {
if (singleton == null) {
synchronized (DoubleIfSynchronizedSingleton.class) {
if (singleton == null) {
singleton = new DoubleIfSynchronizedSingleton();
}
}
}
return singleton;
}
}
怎么破呢?
在代码中添加下面这个方法
public Object readResolve() {
return getInstance();
}
为什么呢?真的很复杂,看这里(参考资料)
总结来说就是:若目标类有readResolve方法,那就通过反射的方式调用要被反序列化的类中的readResolve方法,返回一个对象,然后把这个新的对象复制给之前创建的obj(即最终返回的对象)。那被反序列化的类中的readResolve 方法里是什么?就是直接返回我们的单例对象。
最后到这里,这里的单例模式应该没有办法再破了吧,其实还能破。
2.6、最完美的单例模式:枚举
为什么说枚举是最完美的单例模式
1.这是因为所有Java枚举都隐式继承自Enum抽象类,而Enum抽象类根本没有无参构造方法,只有如下一个构造方法:
protected Enum(String name, int ordinal) {
this.name = name;
this.ordinal = ordinal;
}
哪怕你获得到这个有参的构造方法也没用,因为JDK反射机制内部完全禁止了用反射创建枚举实例的可能性。
为什么禁止呢?不知道了。。。。
同时JDK内部也对序列化也做了保护,在ObjectInputStream类中,对枚举类型有一个专门的readEnum()方法来处理,其作用是绕过反射直接获取单例对象。
究其原因其实可以看一下枚举类型的序列化,和普通类是不一样的(参考资料)。