单例设计模式
该设计模式解决的问题是,一个环境中只会存在一个该类的对象
该模式有五种实现方式
- 恶汉模式
- 懒汉模式
- 静态内部类模式
- 枚举模式
该设计模式的核心是构造方法私有化
饿汉模式
恶汉模式:见名知意,就是在类被加载的时候就创建。
案例:Runtime类
缺点:浪费内存空间
我查了资料,查到了这个缺点,但是这个缺点我不时很能理解
1 | public class Hungry { |
懒汉模式
这种实现方式,我们从分几个版本从简单到复杂
Version 01
1 | public class LazyMan { |
缺点:只能在单线程中安全,多线程中是不安全的
现在有线程A和线程B,线程A走到了注解A处,然后线程B抢占了CPU,这时候lAZYMAN还是null,所以线程B创建了一个lAZYMAN对象,然后线程A抢回CPU,它这时候已经在if语句里面了,箭在弦上不得不发,所以线程A也创建了一个lAZYMAN对象,导致单例模式被破坏,所以该实现方式线程不安全
Version 02
1 | public class LazyMan { |
缺点:为方法加锁,确实保证了单例模式,但是我们都知道同步方法,锁的是LazyMan的Class类,所以我们得知这种方式可以保证单例模式的线程安全。但是我们好好想想,当我们创建了第一个单例对象之后,还需要锁来保证同步吗?答案显而易见,肯定是不需要的,所以这种方式效率低下,需要改进,我们来看下一个版本
Version 03
1 | public class LazyMan { |
我们不使用同步方法来保证所有的线程同步,我们只对LAZYMAN对象还为null的线程同步,这时候又出现了问题。我们来分析一下
现在我们有两个线程,线程A和线程B。线程A走到了注释A处,然后线程B抢占了CPU,这时候lAZYMAN还是null,所以线程B创建了一个lAZYMAN对象,然后线程A抢回CPU,它这时候已经在if语句里面了,箭在弦上不得不发,所以线程A也创建了一个lAZYMAN对象,导致单例模式被破坏,所以该实现方式线程不安全,我们发现这和我们没有加锁的时候的情形几乎一模一样。我们来看下一个版本,双重锁机制
Version 04
public class LazyMan {
private static LazyMan LAZYMAN;
private LazyMan(){
}
public static LazyMan getInstance(){
if(LAZYMAN==null){
//A
synchronized (LazyMan.class){
if(LAZYMAN==null){
//B
LAZYMAN = new LazyMan();
}
}
}
return LAZYMAN;
}
}
我个人的理解是第一重锁是为了过滤LAZYMAN为NULL的线程,第二重锁是为了保证只能有一个线程去创建对象,这时候我们似乎找不到什么破绽了,我们来继续往下看
Version 05
1 | public class LazyMan { |
乍一看,好像和上一个版本没什么区别,仔细一看,其实区别是在这个单例变量的修饰符多了一个volatile。
volatile三大作用
- 保证可见性
- 不保证原子性
- 禁止该变量出现的地方发生指令重排
这里的话,我们使用到的特性是第三点,我们来分析一下 LAZYMAN = new LazyMan();
这一句话其实执行了三个步骤
- 开辟一个内存空间
- 初始化对象
- 将该内存空间的地址指向变量
JVM内部会有一种叫指令重排的代码优化机制,这时候,这三者的顺序就有可能会改变。
现在我们有两个线程,线程A和线程B。线程A走到了注释A处,然后线程B抢占了CPU,开始执行LAZYMAN = new LazyMan();
开辟内存空间后,没有先初始化对象,而是先将该内存空间的地址指向变量,这时候线程A抢回了CPU,这时候的LAZYMAN已经不是NULL了,直接将其返回了出去,但是这个对象其实还没有初始化,还是个空架子,这就出现了问题。
当然这种情况十分十分少,但是为了严谨性,最好还是加上volatile关键词禁止指令重排
静态内部类模式
1 | public class StaticWay { |
该实现方式其实就是利用的JVM的一些特性
类的加载分为以下过程
类的加载:将Class文件字节码加载到内存中,并将这些静态数据转换为方法区的运行时数据结构,然后在堆中生成一个代表该类的Class对象,作为方法区类数据的访问入口,可以使用反射获取该类的所有信息。
类的链接:将Java类的二进制代码合并到JVM中
- 验证:确保加载的类符合JVM规范
- 准备:为静态变量分配内存并设置成员变量的默认值
- 解析:JVM常量池内的符号引用(常量名)替换为直接引用(地址)的过程
类的初始化:执行类构造器< clinit >()方法的过程,当初始化一个类的时候,如果其父类为初始化,则先初始化其父类 ,JVM会保证类构造器在多线程环境下被正确加锁
其实最开始的饿汉单例模式也是也是这个原理
颠覆
其实一路到了这里,以上方法都是不安全的。因为java有一个很牛X的包。
java.lang.reflect
我们在最开始说过,单例模式的本质就是构造器私有化,但是java的反射机制,它不讲武德,它可以直接修改权限修饰符。我们来看看代码。
1 | public static void main(String[] args) throws NoSuchMethodException, InvocationTargetException, InstantiationException, IllegalAccessException { |
可不可以对其作出防御呢?
1 | private Hungry(){ |
上述方法对饿汉已经作出了合适的防御,那对于懒汉模式,如果两个对象都是使用反射创建的呢?这时候这个防御就是形同虚设了,因为我们肯定是禁止反射去创建对象,所以这种肯定是不行的
1 | public class LazyMan { |
我们引入一个布尔值来完成这个任务
1 | public class LazyMan { |
这样反射就无法控制我们的单例了!
枚举类
这时候有没有想起来我们在学javaSE的时候就学到了一个自带单例模式的类型,那就是枚举类Enum!
我们来尝试攻击一下枚举类!
1 | public enum SingleEnum { |
这时候发现报错了!!
1 | Exception in thread "main" java.lang.IllegalArgumentException: Cannot reflectively create enum objects |
我们点进newInstance方法看下源码,
1 |
|
我们发现了这句话throw new IllegalArgumentException("Cannot reflectively create enum objects");
这就解释通了,原来是java的反射已经处理好了枚举类,使其真正的实现了安全的单例!