网站建设资讯

NEWS

网站建设资讯

设计模式-单例模式之多线程调试与破坏单例

前言

在之前的 设计模式 - 单例模式(详解)看看和你理解的是否一样? 一文中,我们提到了通过Idea 开发工具进行多线程调试、单例模式的破坏的问题;由于篇幅原因,现在单独开一篇文章进行演示:线程不安全的单例在多线程情况下为何被创建多个、如何破坏单例。

专注于为中小企业提供成都做网站、网站建设服务,电脑端+手机端+微信端的三站合一,更高效的管理,为中小企业金沙免费做网站提供优质的服务。我们立足成都,凝聚了一批互联网行业人才,有力地推动了数千家企业的稳健成长,帮助中小企业通过网站建设实现规模扩充和转变。

如果还不知道如何使用IDEA工具进行线程模式的调试,请先阅读我之前发的一篇文章: 你不知道的 IDEA Debug调试小技巧

一、线程不安全的单例在多线程情况下为何被创建多个

首先回顾简单线程不安全的懒汉式单例的代码以及测试程序代码:

/**
 * @author eamon.zhang
 * @date 2019-09-30 上午10:55
 */public class LazySimpleSingleton {    private LazySimpleSingleton(){}    private static LazySimpleSingleton instance = null;    public static LazySimpleSingleton getInstance(){        if (instance == null) {
            instance = new LazySimpleSingleton();
        }        return instance;
    }
}// 测试程序@Testpublic void test() {    try {
        ConcurrentExecutor.execute(() -> {
            LazySimpleSingleton instance = LazySimpleSingleton.getInstance();
            System.out.println(Thread.currentThread().getName() + " : " + instance);
        }, 2, 2);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

对于这个单例,我们毫无疑问认为它是线程不安全的,至于为什么,接下来使用IDEA工具的线程debug模式来直观的找出答案。

在关键代码上打断点

  1. 单例类LazySimpleSingletonif (instance == null) 处:

设计模式 - 单例模式之多线程调试与破坏单例

  1. 测试类,多线程入口调用getInstance()处:郑州不孕不育医院性价比哪家好:http://yyk.39.net/zz3/zonghe/1d427.html

设计模式 - 单例模式之多线程调试与破坏单例

开始调试

  1. 启动 debug ,我们可以在调试窗口找到我们启动的线程:

设计模式 - 单例模式之多线程调试与破坏单例

  1. 将 pool-1-thread-1 线程单步执行到if (instance == null) 断点处,观察instance值为null

设计模式 - 单例模式之多线程调试与破坏单例

  1. pool-1-thread-1执行到instance = new LazySimpleSingleton();处等待初始化:http://www.chacha8.cn/detail/1132398246.html

设计模式 - 单例模式之多线程调试与破坏单例

  1. 切换线程 pool-1-thread-2 同样单步执行到 if (instance == null) 断点处,此时观察instance值也为null(这就是我们常说的两个线程同时执行到断代码处):

设计模式 - 单例模式之多线程调试与破坏单例

  1. 同样将pool-1-thread-2执行到instance = new LazySimpleSingleton();处等待初始化:

设计模式 - 单例模式之多线程调试与破坏单例

  1. 显然,这两个线程都满足if (instance == null) 的条件,都应该到对应的代码块中执行实例化操作,那么这两个线程就会分别初始化:

线程 pool-1-thread-1 实例化后:

设计模式 - 单例模式之多线程调试与破坏单例

切换线程 pool-1-thread-2 观察 instance 值已经被初始化了,但是,线程pool-1-thread-2 还是会被实例化一遍:

设计模式 - 单例模式之多线程调试与破坏单例

线程pool-1-thread-2实例化后:

设计模式 - 单例模式之多线程调试与破坏单例

大家是否一目了然了呢?

  1. 将两个线程执行完,看控制台:

设计模式 - 单例模式之多线程调试与破坏单例

大家可以看到,虽然输出打印的对象是同一个,但是,确实是创建了两遍,只不过 pool-1-thread-2 实例化后将 pool-1-thread-1实例化的对象值给覆盖了。

当我将线程pool-1-thread-1和线程pool-1-thread-2同时执行到instance = new LazySimpleSingleton();处然后先让pool-1-thread-1执行完打印后,再将pool-1-thread-2执行实例化操作,就会看到打印的对象会是不一样的了:

设计模式 - 单例模式之多线程调试与破坏单例

这就是通过线程调试模式手动控制线程执行顺序来模拟还原多线程环境下,线程不安全的情况。


二、改进线程不安全的单例

我们明白了线程不安全的原因是两个线程同时拿到的instance资源都为null,从而都进行实例化。那么有没有什么方法能解决呢?当然有,给 getInstance()加 上 synchronized 关键字,使这个方法变成线程同步方法:

public class LazySimpleSingleton {    private LazySimpleSingleton(){}    private static LazySimpleSingleton instance = null;    public synchronized static LazySimpleSingleton getInstance(){        if (instance == null) {
            instance = new LazySimpleSingleton();
        }        return instance;
    }
}

当我们将其中一个线程执行并调用 getInstance()方法时,另一个线程在调用 getInstance()方法,线程的状态由 RUNNING 变成了MONITOR,出现阻塞。直到第一个线程执行完,第二个线程才恢复 RUNNING 状态继续调用 getInstance() 方法

设计模式 - 单例模式之多线程调试与破坏单例

这就解决了之前所说的线程安全问题,但是这样子在线程数量比较多情况下,如果 CPU分配压力上升,会导致大批量线程出现阻塞,从而导致程序运行性能大幅下降;为了解决线程安全和程序性能问题,于是乎有了我们的双重检查式的单例。这里就不再多说了。


三、破坏单例

一般情况下,我们创建使用饿汉式单例或双重检查的懒汉式单例是没有问题的,但是在一定情况下,会发生单例被破坏。

反射破坏单例

实际情况下,公司一个程序员写了一个单例,但是另外一个程序员,可能比较牛 X,写代码风格有点不一样,他通过反射来调用别人写的接口,这就会出现此单例并非彼单例的情况。这就破坏了单例。

演示

在我们写单例的时候,大家有没有注意到私有的构造方法前面的修饰符仅为 private,如果我们使用反射来调用其构造方法,然后,再调用 getInstance()方法,应该就会有两个不同的实例。

我们以前面说单例的文章中的 LazyInnerClassSingleton为例,编写反射调用测试代码:

@Testpublic void testReflex() {    try {        // 很无聊的情况下,进行破坏
        Class clazz = LazyInnerClassSingleton.class;        // 通过反射拿到私有的构造方法
        Constructor c = clazz.getDeclaredConstructor(null);        // 设置访问属性,强制访问
        c.setAccessible(true);        // 初始化两次,这就相当于调用了两次构造方法
        LazyInnerClassSingleton o1 = c.newInstance();
        LazyInnerClassSingleton o2 = c.newInstance();        // 只要 o1和o2 地址不相等,就可以说明这是两个不同的对象,也就是违背了单例模式的初衷
        System.out.println(o1 == o2);
    } catch (Exception e) {
        e.printStackTrace();
    }

}

运行结果如下:

设计模式 - 单例模式之多线程调试与破坏单例

显然,是创建了两个不同的实例。现在,我们在其构造方法中做一些限制,一旦出现多次重复创建,则直接抛出异常。来看优化后的代码:

public class LazyInnerClassSingleton {    private LazyInnerClassSingleton() {        if(LazyHolder.INSTANCE != null){            throw new RuntimeException("不允许创建多个实例");
        }
    }    // 注意关键字final,保证方法不被重写和重载
    public static final LazyInnerClassSingleton getInstance() {        return LazyHolder.INSTANCE;
    }    private static class LazyHolder {        // 注意 final 关键字(保证不被修改)
        private static final LazyInnerClassSingleton INSTANCE = new LazyInnerClassSingleton();
    }
}

再次调用:

设计模式 - 单例模式之多线程调试与破坏单例

至此,就避免了单例被反射破坏的问题。

序列化破坏单例

另外一种情况,可能会遇到,我们需要将对象序列化到磁盘,下次使用时再从磁盘反序列化回来,反序列化的对象会被重新分配内存,那如果序列化的对象为单例,则就违背了单例模式的初衷。这也相当于破坏了单例。

演示

我们还是以LazyInnerClassSingleton为例,将LazyInnerClassSingleton 实现 Serializable 接口;

然后编写测试代码:

/**
 * @author eamon.zhang
 * @date 2019-10-08 下午3:06
 */public class SerializableTest {    public static void main(String[] args) {
        LazyInnerClassSingleton s1 = null;
        LazyInnerClassSingleton s2 = LazyInnerClassSingleton.getInstance();

        FileOutputStream fos = null;        try {
            fos = new FileOutputStream("LazyInnerClassSingleton.obj");
            ObjectOutputStream oos = new ObjectOutputStream(fos);
            oos.writeObject(s2);
            oos.flush();
            oos.close();

            FileInputStream fis = new FileInputStream("LazyInnerClassSingleton.obj");
            ObjectInputStream ois = new ObjectInputStream(fis);
            s1 = (LazyInnerClassSingleton)ois.readObject();
            ois.close();

            System.out.println(s1);
            System.out.println(s2);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }
}

执行测试代码:

设计模式 - 单例模式之多线程调试与破坏单例

可以看到,结果为两个不同的对象。这同样违背了单例模式的初衷。那么我们如何保证序列化的情况也能实现单例呢?其实也很简单,使用 readResolve() 方法即可:

public class LazyInnerClassSingleton implements Serializable {    private LazyInnerClassSingleton() {        if (LazyHolder.INSTANCE != null) {            throw new RuntimeException("不允许创建多个实例");
        }
    }    // 注意关键字final,保证方法不被重写和重载
    public static final LazyInnerClassSingleton getInstance() {        return LazyHolder.INSTANCE;
    }    private static class LazyHolder {        // 注意 final 关键字(保证不被修改)
        private static final LazyInnerClassSingleton INSTANCE = new LazyInnerClassSingleton();
    }    // 解决反序列化对象不一致问题
    private Object readResolve() {        return LazyHolder.INSTANCE;
    }
}

大家肯定会问,why?

为了一探究竟,我们来看一下 JDK 源码,我们进入 ObjectInputStream 类的 readObject()方法:

public final Object readObject() throws IOException, ClassNotFoundException {        if (this.enableOverride) {            return this.readObjectOverride();
        } else {            int outerHandle = this.passHandle;

            Object var4;            try {
                Object obj = this.readObject0(false);                this.handles.markDependency(outerHandle, this.passHandle);
                ClassNotFoundException ex = this.handles.lookupException(this.passHandle);                if (ex != null) {                    throw ex;
                }                if (this.depth == 0L) {                    this.vlist.doCallbacks();                    this.freeze();
                }

                var4 = obj;
            } finally {                this.passHandle = outerHandle;                if (this.closed && this.depth == 0L) {                    this.clear();
                }

            }            return var4;
        }
    }

我们发现:readObject 中又调用了我们重写的 readObject0()方法,进入 readObject0()方法:

private Object readObject0(boolean unshared) throws IOException {
        ...        try {            switch(tc) {
            ...            case 115:
                var4 = this.checkResolve(this.readOrdinaryObject(unshared));                return var4;
            ...
        } finally {
            --this.depth;            this.bin.setBlockDataMode(oldMode);
        }        return var4;
    }

我们看到代码中调用了 ObjectInputStream 的 readOrdinaryObject() 方法,我们继续进入看源码:

private Object readOrdinaryObject(boolean unshared) throws IOException {
        ...            if (cl != String.class && cl != Class.class && cl != ObjectStreamClass.class) {
                Object obj;                try {
                    obj = desc.isInstantiable() ? desc.newInstance() : null;
                } catch (Exception var7) {                    throw (IOException)(new InvalidClassException(desc.forClass().getName(), "unable to create instance")).initCause(var7);
                }

        ...

        }
    }

发现调用了 ObjectStreamClass 的 isInstantiable()方法,而 isInstantiable()里面的代码如下:

boolean isInstantiable() {    this.requireInitialized();    return this.cons != null;
}

代码非常简单,就是判断一下构造方法是否为空,构造方法不为空就返回 true,也就是说,只要有无参构造方法就会实例化;这时候,其实还没有找到为什么加上readResolve()方法就避免了单例被破坏的真正原因,我们再次回到ObjectInputStream 的 readOrdinaryObject()方法继续往下看可以找到如下代码:

private Object readOrdinaryObject(boolean unshared) throws IOException {
    ...    if (obj != null && this.handles.lookupException(this.passHandle) == null && desc.hasReadResolveMethod()) {
        Object rep = desc.invokeReadResolve(obj);        if (unshared && rep.getClass().isArray()) {
            rep = cloneArray(rep);
        }        if (rep != obj) {            if (rep != null) {                if (rep.getClass().isArray()) {                    this.filterCheck(rep.getClass(), Array.getLength(rep));
                } else {                    this.filterCheck(rep.getClass(), -1);
                }
            }

            obj = rep;            this.handles.setObject(this.passHandle, rep);
        }
    }
    ...
}

判断无参构造方法是否存在之后,又调用了 hasReadResolveMethod()方法:

boolean hasReadResolveMethod() {    this.requireInitialized();    return this.readResolveMethod != null;
}

逻辑非常简单,就是判断readResolveMethod 是否为空,不为空就返回 true。那么 readResolveMethod是在哪里赋值的呢? 通过全局查找找到了赋值代码在私有方法 ObjectStreamClass()方法中给 readResolveMethod 进行赋值,来看代码:

 ObjectStreamClass.this.readResolveMethod = ObjectStreamClass.getInheritableMethod(cl, "readResolve", (Class[])null, Object.class);

代码的逻辑其实就是通过反射找到一个无参的 readResolve()方法,并且保存下来,现在再回到 ObjectInputStream的 readOrdinaryObject() 方法继续往下看,如果readResolve()存在则调用 invokeReadResolve()方法:

Object invokeReadResolve(Object obj) throws IOException, UnsupportedOperationException {    this.requireInitialized();    if (this.readResolveMethod != null) {        try {            return this.readResolveMethod.invoke(obj, (Object[])null);
        } catch (InvocationTargetException var4) {
            Throwable th = var4.getTargetException();            if (th instanceof ObjectStreamException) {                throw (ObjectStreamException)th;
            } else {
                throwMiscException(th);                throw new InternalError(th);
            }
        } catch (IllegalAccessException var5) {            throw new InternalError(var5);
        }
    } else {        throw new UnsupportedOperationException();
    }
}

我们可以看到在 invokeReadResolve()方法中用反射调用了 readResolveMethod() 方法。 通过JDK源码分析我们可以看出,虽然,增加 readResolve()方法返回实例,解决了单例被破坏的问题。但是,我们通过分析源码以及调试,我们可以看到实际上实例化了两 次,只不过新创建的对象没有被返回而已.

那如果,创建对象的动作发生频率增大,就 意味着内存分配开销也就随之增大;为了解决这个问题,我们推荐使用注册式单例。

为何建议使用注册式(枚举式)单例

我们在前文中说到了,我们极力推荐使用枚举类型的单例;接下来我们分析一下原因:

使用 Java 反编译工具 Jad(自行下载),解压后,使用命令行调用:

./jad ~/IdeaProjects/own/java-advanced/01.DesignPatterns/design-patterns/build/classes/java/main/com/eamon/javadesignpatterns/singleton/enums/EnumSingleton.class

会在当前目录生成一个 EnumSingleton.jad文件,我们使用 vscode 打开这个文件查看:

public final class EnumSingleton extends Enum{    public static EnumSingleton[] values()
    {        return (EnumSingleton[])$VALUES.clone();
    }    public static EnumSingleton valueOf(String name)
    {        return (EnumSingleton)Enum.valueOf(com/eamon/javadesignpatterns/singleton/enums/EnumSingleton, name);
    }    private EnumSingleton(String s, int i)
    {        super(s, i);
        instance = new EnumResource();
    }    public Object getInstance()
    {        return instance;
    }    public static final EnumSingleton INSTANCE;    private Object instance;    private static final EnumSingleton $VALUES[];    static
    {
        INSTANCE = new EnumSingleton("INSTANCE", 0);
        $VALUES = (new EnumSingleton[] {
            INSTANCE
        });
    }
}

请注意这段代码:

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

原来枚举类单例在静态代码块中就给INSTANCE 赋了值,是饿汉式单例的实现方式。那么同样的,我们能否通过反射和序列化方式进行破坏呢?

先分析通过序列化方式:

我们还是回到JDK源码:在 ObjectInputStream 的 readObject0()方法中有如下代码:

 private Object readObject0(boolean unshared) throws IOException {
    ...        case 126:
            var4 = this.checkResolve(this.readEnum(unshared));
    ...    return var4;
}

我们看到 readObject0()中调用了readEnum()方法,跟进该方法:

private Enum readEnum(boolean unshared) throws IOException {    if (this.bin.readByte() != 126) {        throw new InternalError();
    } else {
        ObjectStreamClass desc = this.readClassDesc(false);        if (!desc.isEnum()) {            throw new InvalidClassException("non-enum class: " + desc);
        } else {            int enumHandle = this.handles.assign(unshared ? unsharedMarker : null);
            ClassNotFoundException resolveEx = desc.getResolveException();            if (resolveEx != null) {                this.handles.markException(enumHandle, resolveEx);
            }

            String name = this.readString(false);
            Enum result = null;
            Class cl = desc.forClass();            if (cl != null) {                try {
                    Enum en = Enum.valueOf(cl, name);
                    result = en;
                } catch (IllegalArgumentException var9) {                    throw (IOException)(new InvalidObjectException("enum constant " + name + " does not exist in " + cl)).initCause(var9);
                }                if (!unshared) {                    this.handles.setObject(enumHandle, result);
                }
            }            this.handles.finish(enumHandle);            this.passHandle = enumHandle;            return result;
        }
    }
}

我们发现枚举类型其实通过类名和 Class 对象类找到一个唯一的枚举对象。因此,枚举对象不可能被类加载器加载多次。

那么是否可以通过反射进行破坏呢?我们先来执行以下反射破坏枚举类的测试代码:

@Testpublic void testEnum(){    try {        // 很无聊的情况下,进行破坏
        Class clazz = EnumSingleton.class;        // 通过反射拿到私有的构造方法
        Constructor c = clazz.getDeclaredConstructor(null);        // 设置访问属性,强制访问
        c.setAccessible(true);        // 初始化两次,这就相当于调用了两次构造方法
        EnumSingleton o1 = c.newInstance();
        EnumSingleton o2 = c.newInstance();        // 只要 o1和o2 地址不相等,就可以说明这是两个不同的对象,也就是违背了单例模式的初衷
        System.out.println(o1 == o2);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

执行结果:

设计模式 - 单例模式之多线程调试与破坏单例

报的是 java.lang.NoSuchMethodException 异常,意思是没找到无参的构造方法。

那么我们来看一下 java.lang.Enum 的源码,我们发现它只有一个protected的构造方法:

protected Enum(String name, int ordinal) {    this.name = name;    this.ordinal = ordinal;
}

那我们来做一个这样的测试:

@Testpublic void testEnum1() {    try {
        Class clazz = EnumSingleton.class;
        Constructor c = clazz.getDeclaredConstructor(String.class, int.class);
        c.setAccessible(true);
        EnumSingleton enumSingleton = (EnumSingleton) c.newInstance("Eamon", 666);
    } catch (Exception e) {
        e.printStackTrace();
    }
}

发现控制台输出如下错误:

设计模式 - 单例模式之多线程调试与破坏单例

意思就是不能用反射来创建枚举类型。至于为什么,我们还是来看 JDK 源码,进入ConstructornewInstance()方法中:

    public T newInstance(Object... initargs) throws InstantiationException, IllegalAccessException, IllegalArgumentException, InvocationTargetException {        if (!this.override) {
            Class caller = Reflection.getCallerClass();            this.checkAccess(caller, this.clazz, this.clazz, this.modifiers);
        }        if ((this.clazz.getModifiers() & 16384) != 0) {            throw new IllegalArgumentException("Cannot reflectively create enum objects");
        } else {
            ConstructorAccessor ca = this.constructorAccessor;            if (ca == null) {
                ca = this.acquireConstructorAccessor();
            }

            T inst = ca.newInstance(initargs);            return inst;
        }
    }

原来,在源码中对枚举类型进行了强制性的判断(16384代表枚举类型),如果是枚举类型,直接抛异常。到此为止也就说明了为什么《Effective Java》推荐使用枚举来实现单例的原因: JDK 枚举的语法特殊性,以及反射也为枚举保驾护航,让枚举式单例成为一种比较优雅的实现。


标题名称:设计模式-单例模式之多线程调试与破坏单例
标题链接:http://njwzjz.com/article/poedsd.html