一文唬住所有面试官:懒汉式单例模式中的线程安全问题
文章目录
问题
懒汉模式相对饿汉模式来说大大减少了内存空间的消耗,但是存在线程安全问题。
代码
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单例模式并解决懒汉式下线程不安全的问题
- JAVA_单例模式懒汉式的线程安全问题
- 懒汉式加载的单例模式怎么个线程不安全?
- 懒汉式单例设计模式线程不安全
- 初学设计模式(3)-----单例模式(在研究单例的线程安全问题时,发现一篇很全面的文章,直接转了)
- 懒汉式单例模式为何线程不安全
- Android开发设计模式之——单例模式关于线程不安全问题处理
- Android开发设计模式之——单例模式关于线程不安全问题处理
- 线程不安全的懒汉式为何不是严格的单例模式
- 单例模式的实现和线程安全问题
- 单例模式引发的血案之深入分析为什么懒汉式是线程不安全和终极解决办法
- 单例模式涉及到的线程安全问题
- 设计模式——单例模式(Java)——考虑多线程环境下的线程安全问题
- 四、关于单例模式下的线程安全问题!必须要会!!!!!
- 单例设计模式中懒汉式并发访问的安全问题
- 多线程安全问题在单例模式中的体现(懒汉式&饿汉式)
- 单例模式与线程安全问题浅析
- 单例模式与线程安全问题浅析
- java之线程安全问题---懒汉式
- Java 单例模式线程安全问题