您的位置:首页 > 职场人生

一文唬住所有面试官:懒汉式单例模式中的线程安全问题

2020-02-06 10:54 465 查看

文章目录

  • 解决
  • Double Check
  • 内部类(最好的)
  • 反射攻击
  • 序列化攻击
  • 解决方法二(注册式单利,即枚举式单利 《Effective Java》)
  • 总结
  • 问题

    懒汉模式相对饿汉模式来说大大减少了内存空间的消耗,但是存在线程安全问题。

    代码

    public class LazySimpleSingleton {
    private LazySimpleSingleton(){}
    //静态块,公共内存区域
    private static LazySimpleSingleton lazy = null;
    public static LazySimpleSingleton getInstance(){
    if(lazy == null){
    lazy = new LazySimpleSingleton();
    }
    return lazy;
    }
    }
    public class ExectorThread implements Runnable{
    @Override
    public void run() {
    LazySimpleSingleton singleton = LazySimpleSingleton.getInstance();
    //        ThreadLocalSingleton singleton = ThreadLocalSingleton.getInstance();
    System.out.println(Thread.currentThread().getName() + ":" + singleton);
    }
    }
    public class LazySimpleSingletonTest {
    public static void main(String[] args) {
    Thread t1 = new Thread(new ExectorThread());
    Thread t2 = new Thread(new ExectorThread());
    t1.start();
    t2.start();
    System.out.println("End");
    }
    }

    Idea中多线程断点调试

    每个断掉都需要右击断点,并点击Thread


    然后开始调试

    可以看到这里是有多个线程的
    这个时候按F8(调到下一步)
    然后不论是Thread-0还是Thread-1都是运行到了如图这里

    可能一(线程一前一后进入同一段代码)

    然后分别选择Thread-0 Thread-1 分别按照以前以后进入,定位到这一行

    在Thread-0中通过按F8跳转到return lazy,即已经有了lazy,如下图这个lazy是815

    那么之前的Thread-1继续通过F8往下走的时候,也就不会再走

    if(lazy == null)
    里面的内容了,而是直接返回之前创建好的815

    然后分别将两个线程按F8至最后。

    结果:

    如图,两个的结果是一样的

    可能二(两个线程同时进入,同时返回)

    Thread-0和Thread-1都同时进入到如下这行代码,都还没有进行初始化

    Thread-0走完,准备return816

    Thread-1也走完,准备return

    这个时候发现直接是return了817,给覆盖掉了

    这个时候再将所有的线程都走完

    发现还是一样的。

    虽然,最后还是一样的,但是内部其实已经实例化了两次,只不过后面执行比较慢的线程把前面执行快的线程覆盖了

    可能三(两个线程同时进入,一前一后返回)

    Thread-0和Thread-1都同时进入到如下这行代码,都还没有进行初始化

    然后将Thread-0全部执行完

    Thread-1全部执行完

    这个时候就是两个不一样的了。

    总结

    通过上面的三种可能,能够看到如果是同时进入的话,可能最后显示的是两个实例(如上可能三),也可能最后显示的一个实例(如上可能二,淡这只是一个假象),即只要是同时进入的都会创建两个实例。
    之后一前一后进入的时候才会是一个实例。

    解决

    synchronized

    synchronized 关键字

    通过多线程断点的方式再次模拟一次
    Thread-0还是进入到这里

    Thread-1一开始是在这里的

    这个时候Thread-1通过F8尝试进入到同步代码块

    发现报错了,不支持的线程,不允许访问
    仔细看下

    Thread-1也因此变成了Monitor状态,Thread-0是Running状态。
    只有当Thread-0执行完了之后,Thread-1才会变成Running状态。

    那么我们让Thread-0走出同步代码块,发现Thread-1变成Running了

    这个时候通过F8,发现已经有值了,所以跳过了

    lazy = new LazySimpleSingleton();
    ,直接进行return了之前Thread-0,new好的。

    这个时候最后的结果才是没有障眼法的真正的为一个实例。

    synchronized问题

    虽然在JDK1.6之后对synchronized性能优化了许多,但是还是不可避免的存在一定的性能问题。
    因为这个synchronized可能会造成整个类的操作被锁住

    因为它修饰的方法是被static修饰的

    Double Check

    public class LazyDoubleCheckSingleton {
    private volatile static LazyDoubleCheckSingleton lazy = null;
    private LazyDoubleCheckSingleton(){}
    public static LazyDoubleCheckSingleton getInstance(){
    if(lazy == null){
    synchronized (LazyDoubleCheckSingleton.class){
    if(lazy == null){
    lazy = new LazyDoubleCheckSingleton();
    //1.分配内存给这个对象
    //2.初始化对象
    //3.设置lazy指向刚分配的内存地址
    //4.初次访问对象
    }
    }
    }
    return lazy;
    }
    }

    因为synchronized关键字如果修饰静态方法的话,会将整个类锁住,所以将synchronized放在方法里面。
    可是如果这么写的话

    if(lazy == null){
    synchronized (LazyDoubleCheckSingleton.class){
    // if(lazy == null){
    lazy = new LazyDoubleCheckSingleton();
    //1.分配内存给这个对象
    //2.初始化对象
    //3.设置lazy指向刚分配的内存地址
    //4.初次访问对象
    // }
    }
    }
    return lazy;

    即没有里面的双重检查,会导致Thread-0在执行

    lazy = new LazyDoubleCheckSingleton();
    的时候,Thread-1无法执行,但是,当Thread-0执行完这句话之后,Thread-1就能够进来同样执行这句话了,所以实际上还是创建了两次实例。

    所以至此就很明了了,需要进行双重检测,

    if(lazy == null){}

    为什么最外面还要有一个if判断?

    总的来说就是为了减小开销、提升效率。

    最里面的知道是为了保证实例的唯一性,但是最外层的判断是为什么呢?

    那就来假设一下
    代码如下

    public static LazyDoubleCheckSingleton getInstance(){
    
    synchronized (LazyDoubleCheckSingleton.class){
    if(lazy == null){
    lazy = new LazyDoubleCheckSingleton();
    //1.分配内存给这个对象
    //2.初始化对象
    //3.设置lazy指向刚分配的内存地址
    //4.初次访问对象
    }
    }
    
    return lazy;
    }

    这意味着什么?没错里面的判断使得保证了实例的唯一性,但是因为外层没有判断,所以导致里面的synchronized相关代码都是无条件执行的,即每个线程执行到这里都需要获得一个内部锁,锁的获得、释放的开销(包括上下文切换、内存同步等)也就无条件的存在了。相反的加上不为null的判断之后,就能在一定程度上减少所有的线程都经过这里的可能,从而减少开销。

    同时能够提升效率,假象线程一已经实例化了对象,此时线程二持有这把锁,线程三只能等待带线程二执行完,而如果有了外层的判断,线程三就不需要等待了直接返回lazy的值。

    指令重排 – volatile

    private volatile static LazyDoubleCheckSingleton lazy = null;
    lazy = new LazyDoubleCheckSingleton();
    //1.分配内存给这个对象
    //2.初始化对象
    //3.设置lazy指向刚分配的内存地址
    //4.初次访问对象

    上面的这行代码,其实在cpu中是执行了下面的四个操作,这里的2和3其实顺序是可能颠倒的,即指令重排问题。为了解决这个问题,需要在lazy前面加上volatile关键字。

    内部类(最好的)

    package com.gupaoedu.vip.pattern.singleton.lazy;
    //懒汉式单例
    //这种形式兼顾饿汉式的内存浪费,也兼顾synchronized性能问题
    //完美地屏蔽了这两个缺点
    //史上最牛B的单例模式的实现方式
    public class LazyInnerClassSingleton {
    //默认使用LazyInnerClassGeneral的时候,会先初始化内部类
    //如果没使用的话,内部类是不加载的
    private LazyInnerClassSingleton(){
    if(LazyHolder.LAZY != null){
    throw new RuntimeException("不允许创建多个实例");
    }
    }
    
    //每一个关键字都不是多余的
    //static 是为了使单例的空间共享
    //保证这个方法不会被重写,重载
    public static final LazyInnerClassSingleton getInstance(){
    //在返回结果以前,一定会先加载内部类
    return LazyHolder.LAZY;
    }
    
    //默认不加载
    private static class LazyHolder{
    private static final LazyInnerClassSingleton LAZY = new LazyInnerClassSingleton();
    }
    }

    全程没有使用synchronized关键字
    当外面的类LazyInnerClassSingleton 加载的时候,会首先去加载内部类LazyHolder,内部类比外部类要优先加载。

    这里注意内部类LazyHolder中的逻辑,默认是不执行的,猛地一看,这个内部类中是饿汉式的,但是只有当getInstance()去调用这个方法的时候才执行

    private static final LazyInnerClassSingleton LAZY = new LazyInnerClassSingleton();
    ,巧妙的利用了内部类的特性。

    这也是性能最优的一种方式。

    反射攻击

    如果这里的构造方法是如下代码。

    private LazyInnerClassSingleton(){
    
    }


    通过反射的方式,就要调用private的构造方法,也是可以的,这样的到的还是两个实例。

    所以需要将构造方法改成

    private LazyInnerClassSingleton(){
    if(LazyHolder.LAZY != null){
    throw new RuntimeException("不允许创建多个实例");
    }
    }

    如果偷偷的用构造方法实例化的话,会抛出异常,从而防止了反射攻击。

    序列化攻击

    import java.io.Serializable;
    
    //反序列化时导致单例破坏
    public class SeriableSingleton implements Serializable {
    
    //序列化就是说把内存中的状态通过转换成字节码的形式
    //从而转换一个IO流,写入到其他地方(可以是磁盘、网络IO)
    //内存中状态给永久保存下来了
    
    //反序列化
    //讲已经持久化的字节码内容,转换为IO流
    //通过IO流的读取,进而将读取的内容转换为Java对象
    //在转换过程中会重新创建对象new
    
    public  final static SeriableSingleton INSTANCE = new SeriableSingleton();
    private SeriableSingleton(){}
    
    public static SeriableSingleton getInstance(){
    return INSTANCE;
    }
    
    }
    package com.gupaoedu.vip.pattern.singleton.test;
    
    import com.gupaoedu.vip.pattern.singleton.seriable.SeriableSingleton;
    
    import java.io.FileInputStream;
    import java.io.FileOutputStream;
    import java.io.ObjectInputStream;
    import java.io.ObjectOutputStream;
    
    public class SeriableSingletonTest {
    public static void main(String[] args) {
    
    SeriableSingleton s1 = null;
    SeriableSingleton s2 = SeriableSingleton.getInstance();
    
    FileOutputStream fos = null;
    try {
    fos = new FileOutputStream("SeriableSingleton.obj");
    ObjectOutputStream oos = new ObjectOutputStream(fos);
    oos.writeObject(s2);
    oos.flush();
    oos.close();
    
    FileInputStream fis = new FileInputStream("SeriableSingleton.obj");
    ObjectInputStream ois = new ObjectInputStream(fis);
    s1 = (SeriableSingleton)ois.readObject();
    ois.close();
    
    System.out.println(s1);
    System.out.println(s2);
    System.out.println(s1 == s2);
    
    } catch (Exception e) {
    e.printStackTrace();
    }
    }
    }


    上面的s1是先将类写入文件,再从类中读出来。
    s2是公国getInstance()方法实例化。
    最后的结果是不一样的。

    解决方法一(重写ReadResolve方法)

    SeriableSingleton 中重写readResolve方法

    private  Object readResolve(){
    return  INSTANCE;
    }

    即SeriableSingleton 完整代码

    package com.gupaoedu.vip.pattern.singleton.seriable;
    
    import java.io.Serializable;
    
    //反序列化时导致单例破坏
    public class SeriableSingleton implements Serializable {
    
    //序列化就是说把内存中的状态通过转换成字节码的形式
    //从而转换一个IO流,写入到其他地方(可以是磁盘、网络IO)
    //内存中状态给永久保存下来了
    
    //反序列化
    //讲已经持久化的字节码内容,转换为IO流
    //通过IO流的读取,进而将读取的内容转换为Java对象
    //在转换过程中会重新创建对象new
    
    public  final static SeriableSingleton INSTANCE = new SeriableSingleton();
    private SeriableSingleton(){}
    
    public static SeriableSingleton getInstance(){
    return INSTANCE;
    }
    
    private  Object readResolve(){
    return  INSTANCE;
    }
    
    }

    分析

    为什么重写readResolve方法就可以了?

    注意踏实怎么转化成这个类的?
    首先通过readObject方法

    再点到这个方法里面,往下

    读取二进制的对象,点击去,往下

    如果构造方法不为null,就初始化,虽然我们的构造方法是private的,但是只要有构造方法,就会初始化。
    因为返回true,所以重新创建了对象,所以自然s1和s2是不相等的两个对象。

    回到ObjectInputSteam类中的往下(在上面的desc.newInstance下面)

    即虽然上面已经newInstance了,但是这里还是会判断是否有ReadResolve方法,如果有的话,就会执行这个ReadResolve方法。

    至此,虽然已经new instance了,但是因为我们重写了jdk提供给我们的开放借口,所以真正返回的其实是单例类中的单例

    private  Object readResolve(){
    return  INSTANCE;
    }

    而这个方法在哪里?

    通过反射获得名字为ReadResolve的方法。

    总结

    重写readResolve方法,只不过是覆盖了反序列化出来的对象,还是创建了两次,放生在Jvm层面,相对来说比较安全,之前反序列化出来的对象被gc回收了。

    解决方法二(注册式单利,即枚举式单利 《Effective Java》)

    package com.gupaoedu.vip.pattern.singleton.register;
    
    //常量中去使用,常量不就是用来大家都能够共用吗?
    //通常在通用API中使用
    public enum EnumSingleton {
    INSTANCE;
    private Object data;
    public Object getData() {
    return data;
    }
    public void setData(Object data) {
    this.data = data;
    }
    public static EnumSingleton getInstance(){
    return INSTANCE;
    }
    }
    public static void main(String[] args) {
    try {
    EnumSingleton instance1 = null;
    
    EnumSingleton instance2 = EnumSingleton.getInstance();
    instance2.setData(new Object());
    
    FileOutputStream fos = new FileOutputStream("EnumSingleton.obj");
    ObjectOutputStream oos = new ObjectOutputStream(fos);
    oos.writeObject(instance2);
    oos.flush();
    oos.close();
    
    FileInputStream fis = new FileInputStream("EnumSingleton.obj");
    ObjectInputStream ois = new ObjectInputStream(fis);
    instance1 = (EnumSingleton) ois.readObject();
    ois.close();
    
    System.out.println(instance1.getData());
    System.out.println(instance2.getData());
    System.out.println(instance1.getData() == instance2.getData());
    
    }catch (Exception e){
    e.printStackTrace();
    }
    }

    结果

    能够保证相等了。

    抠细节(jad jad)

    jad简单介绍

    介绍:class反编译工具
    下载地址:https://varaneckas.com/jad/
    安装:解压到任意目录,解压后的到两个文件(jad.exe、Readme.txt)
    配置环境变量:在path中添加jad.exe所在的目录(比如当前的jad.exe在F:)那就直接配置F:就好了
    比如我的:

    配置完之后需要重新启动cmd窗口然后在任意路径输入jad


    说明成功了。

    实干家

    因为是maven项目,在target文件下找到需要反编译的class文件,并复制路径

    然后执行jad + filepath(不要有中文最好,我直接吧class文件拖放到了桌面)

    最后生成的jad结尾的文件,这个文件在哪里呢?
    如上入执行jad命令的时候,在哪里执行的就会存放咋哪里,如图我实在桌面执行的,所以就会存放在桌面

    然后用notepad++等工具打开查看。

    可以看到反编译的真实的代码,和我们idea中看到的是不一样的。

    是怎么实现单例的?
    注意静态代码块中的内容

    static
    {
    INSTANCE = new EnumSingleton("INSTANCE", 0);
    $VALUES = (new EnumSingleton[] {
    INSTANCE
    });
    }

    是没有无参的构造方法的,而且是在static静态代码块中进行初始化的,即饿汉式的写法,饿汉式的单例是线程安全的,那么回过头开始如何避免序列化破坏单例的?

    同样回去继续跟源码

    进入readEnum

    通过jdk的valueOf方法加入class名字和枚举中的name确定一个值。

    那么是通过什么来保证不会被反射攻击的呢?

    如上图,通过反射的方法实例化,通过反编译代码,我们知道最终的代码是没有空参的构造方法的,这里模拟一下两个参数,颠倒这个newInstance方法里面

    可以看到这里得到当前clazz的modifires(比如public等),如果得到的是enum,即枚举的话,直接就不实例化,直接就会抛出如上的异常,跟我们console中的到的一样。

    总结

    避免单例模式被反射或者序列化攻击的话,最好通过枚举的方式进行解决,因为在jdk层面已经帮我们做的很好了。
    当然通过重写ReadResolve方法的方式也行,但是最好还是通过枚举的方式。
    《Effective Java》这本书中也是这么说的。

    Java糖果罐
    扫码关注
    • 点赞
    • 收藏
    • 分享
    • 文章举报
    Leesin Dong 博客专家 发布了511 篇原创文章 · 获赞 2225 · 访问量 198万+ 他的留言板 关注
    内容来自用户分享和网络整理,不保证内容的准确性,如有侵权内容,可联系管理员处理 点击这里给我发消息
    标签: