单例模式-看这一篇差不多了

266 阅读9分钟

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()方法来处理,其作用是绕过反射直接获取单例对象。

究其原因其实可以看一下枚举类型的序列化,和普通类是不一样的(参考资料)。

写的不好,请大佬斧正