一文唬住所有面试官:懒汉式单例模式中的线程安全问题
Posted Java糖果罐
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了一文唬住所有面试官:懒汉式单例模式中的线程安全问题相关的知识,希望对你有一定的参考价值。
问题
懒汉模式相对饿汉模式来说大大减少了内存空间的消耗,但是存在线程安全问题。
代码
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简单介绍
配置完之后需要重新启动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》这本书中也是这么说的。
以上是关于一文唬住所有面试官:懒汉式单例模式中的线程安全问题的主要内容,如果未能解决你的问题,请参考以下文章