单例模式中的那些坑-阿里云开发者社区

开发者社区> 开发与运维> 正文

单例模式中的那些坑

简介: 本章内容涉及到java多线程,类加载机制,JVM指令重排,final以及volatile的区别,序列化问题,java反编译,反射创建对象机制等相关问题. 需要读者具备一定的Java相关基础.

前言

什么是单例模式

单例模式(Singleton Pattern)是 Java 中最简单的设计模式之一。
这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。

这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。
这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。

注意:

1、单例类只能有一个实例。
2、单例类必须自己创建自己的唯一实例。
3、单例类必须给所有其他对象提供这一实例。
# 摘自菜鸟教程:https://www.runoob.com/design-pattern/singleton-pattern.html

单例模式又分 饿汉模式,懒汉模式
本片文章主要讲解懒汉模式

懒汉模式

  • 首先来看一下它的定义
 懒汉模式:延迟加载,只有在真正使用的时候,才开始实例化.

实现方式

1.双检锁

    private static volatile LazySingleton instance;

    private LazySingleton(){

        if(instance != null){
            throw new RuntimeException("不允许通过反射获取");
        }
    }


    public static  LazySingleton getInstance() {
        //第一次检查
        if (instance == null) {
            //获取锁
            //第一次访问,多个线程同时挤进来,只有一个线程可以获取锁
            synchronized (LazySingleton.class){
                //第一个线程进入 此处为空,进入if并创建对象并返回,之后获得锁的线程此处判断不为空直接返回
                if(instance == null) {
                    //执行构造方法
                    instance = new LazySingleton();
                }
            }
        }
        return instance;
    }

问题1:此处为什么使用volatile.
private static volatile LazySingleton instance;
创建对象是非原子性操作,有三个过程 分配空间,初始化对象赋值,其中2,3步其中可能会发生指令重排现象.

  • 代码示例

    //假设我们有如下语句
    Holder holder = new Holder();
    //则实际执行的操作如下
    tmpRef=allocate(Holder.class)//1.分配空间
    invokeConstructor(tmpRef)//2. 执行构造函数
    holder = tmpRef //3.赋值
  • 只有在初始化对象的那一步才会真正执行构造方法
  • 编译器(JIT),CPU有可能对指令进行重排序,导致使用到尚未初始化的实例,可以通过添加volatile关键字进行修饰,对于volatile修饰的字段,可以防止指令重排.
    问题2:构造方法的反射判断

对于构造方法声明为private,可以防止直接new对象,但是阻止不了反射来获取对象,从而破坏单例.

private LazySingleton(){
        if(instance != null){
            throw new RuntimeException("不允许通过反射获取");
        }
}

以上代码并不能完美的阻止反射,如果从一开始就直接使用反射而不直接去调用提供的创建方法 就会被破解

  • 代码示例
Class<LazySingleton> lazySingletonClass = LazySingleton.class;
        Constructor<LazySingleton> constructor = lazySingletonClass.getDeclaredConstructor();
        // 暴力反射
        constructor.setAccessible(true);
        // 从一开始就不使用给定的方法来创建单例
        //LazySingleton instance = LazySingleton.getInstance();
        LazySingleton lazySingleton = constructor.newInstance();
        LazySingleton lazySingleton1 = constructor.newInstance();
        System.out.println(lazySingleton);
        System.out.println(lazySingleton1);

执行结果

com.leetao.singleton.LazySingleton@511d50c0
com.leetao.singleton.LazySingleton@60e53b93

完全是两个对象...

  • 难道就真的任反射随意宰割了? 别着急,下面会通过静态内部类的方式来介绍如何解决的
  • 在这之前,还是先得来了解一下序列化破坏反射
序列化破坏
  • 代码实例
        //序列化的对象已实现Serializable接口
    
        //内存输出流,此处也可以使用文件输出流(持久化)来代替
        ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
        //内存输入流
    
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(byteArrayOutputStream);
        //获取单例
        LazySingleton instance = LazySingleton.getInstance();
        //存入对象输出中
        objectOutputStream.writeObject(instance);
        objectOutputStream.flush();
        objectOutputStream.close();
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(byteArrayOutputStream.toByteArray());
        //对象输入流
        ObjectInputStream objectInputStream = new ObjectInputStream(byteArrayInputStream);
        LazySingleton lazySingleton = ((LazySingleton) objectInputStream.readObject());
        //方法创建的单例
        System.out.println(instance);
        //序列化之后的单例
        System.out.println(lazySingleton);

运行结果

com.leetao.singleton.LazySingleton@6f94fa3e
com.leetao.singleton.LazySingleton@4e50df2e

也不是同一个...

  • 关于序列化对象之后为什么不是同一个的问题
  • 因为使用了默认的序列化机制,他会直接从字节流中拿数据,并不会去调构造函数来进行初始化
附1:JAVA序列化过程
1.将对象实例相关的类元数据输出。
2.递归地输出类的超类描述直到不再有超类。
3.类元数据完了以后,开始从最顶层的超类开始输出对象实例的实际数据值。
4.从上至下递归输出实例的数据

脚注: 默认的序列化机制会将对象所有实现Serializable接口的-内容-全部序列化,序列化过程会读取内容的字节流数据,会通过此产生新的对象,并不是通过构造函数来制造新的对象(网上很多文章都说序列化通过构造函数来创建对象,其实并不是!!)
原型模式中的深克隆也就是通过此机制来实现的.
脚注所示内容代表的是:对象继承的类以及超类..、成员变量中的引用变量

  • 那么单例中的序列化破坏如何解决
    官方答案

image.png

  • 可以通过写入readResovler() throws ObjectStreamException 方法来实现自定义的序列化机制
    代码
     /**
      * 自定义的序列化机制
      */
    private Object readResolve() throws ObjectStreamException{
        //直接返回单例
        return LazySingleton.getInstance();
    }

再次运行结果

com.leetao.singleton.LazySingleton@6f94fa3e
com.leetao.singleton.LazySingleton@6f94fa3e

序列化破坏的问题完美解决

2. 静态内部类

1.本质上是利用类加载器机制来保证线程安全
2.只有在实际使用的时候,才会触发类的初始化,所以也是懒加载的一种形式
3.借助于jvm类加载机制,保证实例的唯一性.

何为类加载

# 类加载过程
1. 加载: 将字节码数据从不同的数据源读取到 JVM 中,并映射为 JVM 认可的数据结构(Class 对象)
2. 连接: a.校验,b.准备(给类的静态成员变量赋默认值),c.解析
3. 初始化:给类的静态变量赋初值

# 注意:只有在真正使用对应的类是,才会触发初始化 如
    1. 当前类是启动类(main方法所在的类).
    2. 直接进行new操作.
    3. 访问静态字段(final修饰的静态字面量除外).
    4. 访问静态方法.
    5. 用反射访问类.
    6. 初始化此类的子类.
    $. 后续会出关于类加载相关的文章

前面提到反射破坏的问题,在静态内部类中可以这样解决

public class LeeFactory {
    private LeeFactory(){
        //通过静态类加载机制破解反射破坏
       if(LeeFactoryHolder.LEE_FACTORY!=null){
            throw new UnsupportedOperationException("非法反射不予允许");
        }
    }
    /**
     * 静态内部类
     */
    private static class LeeFactoryHolder{
        private static final LeeFactory LEE_FACTORY  = new LeeFactory();
    }

    public static LeeFactory getInstance(){
        return LeeFactoryHolder.LEE_FACTORY;
    }

}

以上代码可以完美解决反射破坏,如果直接通过getInstance()的方式来获取对象的话.第一次调用才会触发类初始化和构造方法.之后的调用直接拿数据

而第一次调用反射会触发两次两次构造方法,
1.构造方法中if中的判断会调用一次(因为访问类字段会涉及到类初始化,类初始化调用了构造方法)
2.反射创建对象本身会调用一次构造方法,此时静态内部类字段因为初始化已经存在值了(判断有值,抛出异常)


还有一个特点
private static final LeeFactory LEE_FACTORY = new LeeFactory();
为什么加final
1.因为一旦被赋值便无法在修改,即使是反射也不能(也是因为这个原因才使用final)
2.被final修饰的字段会在完全初始化后才会对其他线程可见

说到这里不得不说为什么不用volatile

final和volatile 
volatile修饰的字段不但可以防止重排序,还可以直接在主存更新,让其他线程同步更新
但是它阻止不了反射对其重新赋值,如果使用反射对内部类字段赋值为null,会导致其他正常调用的代码出现问题.

而final修饰的类,会在完全初始化后才会对其他线程可见,而且不能被反射破坏,正好符合我们的需求

如果对final感兴趣的同学,可以阅读https://zhuanlan.zhihu.com/p/100536345 了解更多

结尾:浅谈枚举单例

枚举本质上是一个不可变类 ,它的成员全部为类字段.它不可以被反射所破坏,同时还拥有自己的序列化机制.可以说是完美的单例.

参考

Java序列化机制https://www.iteye.com/blog/bingobird-867950
final特征 https://zhuanlan.zhihu.com/p/100536345

文中所述内容,如有错误,欢迎指正.


忌妒别人,不会给自己增加任何的好处,忌妒别人,也不可能减少别人的成就。

菅江晖

版权声明:本文内容由阿里云实名注册用户自发贡献,版权归原作者所有,阿里云开发者社区不拥有其著作权,亦不承担相应法律责任。具体规则请查看《阿里云开发者社区用户服务协议》和《阿里云开发者社区知识产权保护指引》。如果您发现本社区中有涉嫌抄袭的内容,填写侵权投诉表单进行举报,一经查实,本社区将立刻删除涉嫌侵权内容。

分享:
开发与运维
使用钉钉扫一扫加入圈子
+ 订阅

集结各类场景实战经验,助你开发运维畅行无忧

其他文章