设计模式 - 单例模式之多线程调试与破坏单例
Posted eamonzzz
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了设计模式 - 单例模式之多线程调试与破坏单例相关的知识,希望对你有一定的参考价值。
前言
在之前的 设计模式 - 单例模式(详解)看看和你理解的是否一样? 一文中,我们提到了通过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;
}
}
// 测试程序
@Test
public 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
模式来直观的找出答案。
在关键代码上打断点
- 单例类
LazySimpleSingleton
的if (instance == null)
处:
- 测试类,多线程入口调用
getInstance()
处:
开始调试
- 启动
debug
,我们可以在调试窗口找到我们启动的线程:
- 将
pool-1-thread-1
线程单步执行到if (instance == null)
断点处,观察instance
值为null
;
- 将
pool-1-thread-1
执行到instance = new LazySimpleSingleton();
处等待初始化:
- 切换线程
pool-1-thread-2
同样单步执行到if (instance == null)
断点处,此时观察instance
值也为null
(这就是我们常说的两个线程同时执行到断代码处):
- 同样将
pool-1-thread-2
执行到instance = new LazySimpleSingleton();
处等待初始化:
- 显然,这两个线程都满足
if (instance == null)
的条件,都应该到对应的代码块中执行实例化操作,那么这两个线程就会分别初始化:
线程 pool-1-thread-1
实例化后:
切换线程 pool-1-thread-2
观察 instance
值已经被初始化了,但是,线程pool-1-thread-2
还是会被实例化一遍:
线程pool-1-thread-2
实例化后:
大家是否一目了然了呢?
- 将两个线程执行完,看控制台:
大家可以看到,虽然输出打印的对象是同一个,但是,确实是创建了两遍,只不过 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
为例,编写反射调用测试代码:
@Test
public void testReflex() {
try {
// 很无聊的情况下,进行破坏
Class<LazyInnerClassSingleton> clazz = LazyInnerClassSingleton.class;
// 通过反射拿到私有的构造方法
Constructor<LazyInnerClassSingleton> 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 对象类找到一个唯一的枚举对象。因此,枚举对象不可能被类加载器加载多次。
那么是否可以通过反射进行破坏呢?我们先来执行以下反射破坏枚举类的测试代码:
@Test
public void testEnum(){
try {
// 很无聊的情况下,进行破坏
Class<EnumSingleton> clazz = EnumSingleton.class;
// 通过反射拿到私有的构造方法
Constructor<EnumSingleton> 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;
}
那我们来做一个这样的测试:
@Test
public 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
源码,进入Constructor
的newInstance()
方法中:
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
枚举的语法特殊性,以及反射也为枚举保驾护航,让枚举式单例成为一种比较优雅的实现。
本文中所涉及的源码可在 github 上找到,相关的测试代码在 test 包下:https://github.com/eamonzzz/java-advanced
以上是关于设计模式 - 单例模式之多线程调试与破坏单例的主要内容,如果未能解决你的问题,请参考以下文章