初级必备:单例模式的7个问题

开发 前端
实话实说,关于单例模式,网上有N多个版本。你估计也看过很多版本。但看完了又能怎样?我技术群里的一位小伙伴,上周面试,就因为一个单例模式,然后叫他回去等通知了。

[[402350]]

故事

实话实说,关于单例模式,网上有N多个版本。你估计也看过很多版本。但看完了又能怎样?我技术群里的一位小伙伴,上周面试,就因为一个单例模式,然后叫他回去等通知了。

下面是这位同学被问到的问题:

1、说说单例模式的特点?

2、你知道单例模式的具体使用场景吗?

3、单例模式常见写法有几种?

4、怎么样保证线程安全?

5、怎么不会被反射攻击?

6、怎样保证不会被序列化和反序列化的攻击?

7、枚举为什么会不会被序列化?

.....

你也可以尝试行的回答这几个题,看看自己能回答上几个。

定义

单例模式(Singleton Pattern)是 Java 中最简单的设计模式之一。这种类型的设计模式属于创建型模式,它提供了一种创建对象的最佳方式。

这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。

特点:

  • 1、单例类只能有一个实例。
  • 2、单例类必须自己创建自己的唯一实例。
  • 3、单例类必须给所有其他对象提供这一实例
  • 4、隐藏所有的构造方法

**目的:**保证一个类仅有一个实例,并提供一个访问它的全局访问点。

案例:一家企业只能有一个CEO,有多个了其实乱套了。

使用场景

需要确保任何情况下都绝对只有一个实例。

比如:ServletContext、ServletConfig、ApplicationContext、DBTool等,都使用到了单列模式。

单例模式的写法

  • 饿汉式
  • 懒汉式(包含双重检查锁、静态内部类)
  • 注册式(以枚举为例)

饿汉式

从名字上就能看出,饿汉:饿了就得先吃饱,所以,一开始就搞定了。

饿汉式主要是使用了static,饿汉式也有两种写法,但本质可以理解为是一样的。

  1. public class HungrySingleton{ 
  2.  
  3.     private static final HungrySingleton INSTANCE; 
  4.     static { 
  5.         INSTANCE=new HungrySingleton(); 
  6.     } 
  7. //    private static final HungrySingleton INSTANCE=new HungrySingleton(); 
  8.     private HungrySingleton(){ 
  9.  
  10.     } 
  11.  
  12.     public static HungrySingleton getInstance(){ 
  13.         return INSTANCE; 
  14.     } 

饿汉式有个致命的缺点:浪费空间,不需要也实例化。如果是成千上万个,也这么玩,想想有多恐怖。

于是,就会想到,能不能在使用的时候在实例化,从而引出了懒汉式。

懒汉式

顾名思义,就是需要的时候再创建,因为懒,你不调用我方法,我是不会干活的。

下面是懒汉式的Java代码实现:

  1. public class LazySingleton { 
  2.  
  3.     private static LazySingleton lazySingleton = null
  4.  
  5.     private LazySingleton() { 
  6.     } 
  7.  
  8.     public static LazySingleton getInstance() { 
  9.         if (lazySingleton == null) {//01 
  10.             lazySingleton = new LazySingleton();//02 
  11.         } 
  12.         return lazySingleton; 
  13.     }  

进入getInstance方法,先判断lazySingleton是否为空,为空,则创建一个对象,然后返回此对象。

但是,问题来了:

两个线程同时进入getInstance方法,然后都去执行01这行代码,都是true,然后各自进去创建一个对象,然后返回自己创建的对象。

这岂不是不满足只有唯一 一个对象的了吗?所以这类存在线程安全的问题,那怎么解决呢?

第一印象肯定都是想到加锁。于是,就有了下面的线程安全的懒加载版本:

  1. public class LazySingleton { 
  2.  
  3.     private static LazySingleton lazySingleton = null
  4.  
  5.     private LazySingleton() { 
  6.     } 
  7.  
  8.     //简单粗暴的线程安全问题解决方案 
  9.     //依然存在性能问题 
  10.   public synchronized static LazySingleton getInstance() { 
  11.         if (lazySingleton == null) { 
  12.             lazySingleton = new LazySingleton(); 
  13.         } 
  14.         return lazySingleton; 
  15.     } 

给getInstance方法加锁同步锁标志synchronized,但是又涉及到锁的问题了,同步锁是对系统性能优影响的,尽管JDK1.6后,对其做了优化,但它毕竟还是涉及到锁的开销。

每个线程调用getInstance方法时候,都会涉及到锁,所以又对此进行了优化成为了大家耳熟能详的双重检查锁。

双重检查锁

代码实现如下:

  1. public class LazyDoubleCheckSingleton {  
  2.     private static LazyDoubleCheckSingleton lazyDoubleCheckSingleton = null
  3.  
  4.     private LazyDoubleCheckSingleton() { 
  5.     } 
  6.  
  7.     public static LazyDoubleCheckSingleton getInstance() { 
  8.         if (lazyDoubleCheckSingleton == null) {//01 
  9.             synchronized (LazyDoubleCheckSingleton.class) { 
  10.                 if (lazyDoubleCheckSingleton == null) {//02 
  11.                     lazyDoubleCheckSingleton = new LazyDoubleCheckSingleton(); 
  12.                 } 
  13.             } 
  14.         } 
  15.         return lazyDoubleCheckSingleton; 
  16.     } 
  17.  

这段代码中,在01行,如果不为空,就直接返回,这是第一次检查。如果为空,则进入同步代码块,02行又进行一次检查。

双重检查就是现实if判断、获取类对象锁、if判断。

上面这段代码,看似没问题,其实还是有问题的,比如:指令重排序(需要有JVM知识垫底哈)

指令重排是什么意思呢?

比如java中简单的一句

  1. lazyDoubleCheckSingleton = new LazyDoubleCheckSingleton(); 

会被编译器编译成如下JVM指令:

memory =allocate(); //1:分配对象的内存空间

ctorInstance(memory); //2:初始化对象

instance =memory; //3:设置instance指向刚分配的内存地址

但是这些指令顺序并非一成不变,有可能会经过JVM和CPU的优化,指令重排成下面的顺序:

memory =allocate(); //1:分配对象的内存空间

instance =memory; //3:设置instance指向刚分配的内存地址

ctorInstance(memory); //2:初始化对象

为了防止指令重排序,所以,我们可以使用volatile来做文章(注意:volatile能防止指令重排序和线程可见性)。

于是,更好的版本就出来了。

  1. public class LazyDoubleCheckSingleton { 
  2.     //使用volatile修饰 
  3.     private volatile static LazyDoubleCheckSingleton lazyDoubleCheckSingleton = null;  
  4.     private LazyDoubleCheckSingleton() { 
  5.     } 
  6.  
  7.     public static LazyDoubleCheckSingleton getInstance() { 
  8.         if (lazyDoubleCheckSingleton == null) { 
  9.             synchronized (LazyDoubleCheckSingleton.class) { 
  10.                 if (lazyDoubleCheckSingleton == null) { 
  11.                     lazyDoubleCheckSingleton = new LazyDoubleCheckSingleton(); 
  12.                 } 
  13.             } 
  14.         } 
  15.         return lazyDoubleCheckSingleton; 
  16.     } 

尽管相比前面的版本,确实改进了很多,但依然有同步锁,还是会影响性能问题。于是,又进行优化为静态内部类方式:

静态内部类

下面是静态内部类的代码实现:

利用了内部类的特性,在JVM底层,能完美的规避了线程安全的问题,这种方式也是目前很多项目里喜欢使用的方式。

但是,还是会存在潜在的风险,什么风险呢?

可以使用 反射 暴力的串改,同样也会出现创建多个实例:

反射代码实现如下:

  1. import java.lang.reflect.Constructor; 
  2.  
  3. public class LazyStaticSingletonTest { 
  4.     public static void main(String[] args) { 
  5.         try { 
  6.             Class<?> clazz = LazyStaticSingleton.class; 
  7.             Constructor constructor = clazz.getDeclaredConstructor(null); 
  8.             //强行访问 
  9.             constructor.setAccessible(true); 
  10.             Object object = constructor.newInstance(); 
  11.  
  12.             Object object1 = LazyStaticSingleton.getInstance(); 
  13.  
  14.             System.out.println(object == object1); 
  15.         } catch (Exception ex) { 
  16.             ex.printStackTrace(); 
  17.         } 
  18.     } 

这段代码运行结果为false。

所以,上面说的双重检查锁的方式,通过反射,还是会存在潜在的风险。怎么办呢?

在《Effect java 》这本书中,作者推荐使用枚举来实现单例模式,因为枚举不能被反射。

枚举

下面是枚举式的单例模式的代码实现:

  1. public enum EnumSingleton { 
  2.     INSTANCE; 
  3.     private Object data; 
  4.  
  5.     public Object getData() { 
  6.         return data; 
  7.     } 
  8.  
  9.     public static EnumSingleton getInstance(){ 
  10.         return INSTANCE; 
  11.     } 

我们把上面反射的那个代码,来测试这个枚举式单例模式。

  1. public class EnumTest { 
  2.     public static void main(String[] args) { 
  3.         try { 
  4.             Class<?> clazz = EnumSingleton.class; 
  5.             Constructor constructor = clazz.getDeclaredConstructor(null); 
  6.             //强行访问 
  7.             constructor.setAccessible(true); 
  8.             Object object = constructor.newInstance(); 
  9.  
  10.             Object object1 = EnumSingleton.getInstance(); 
  11.  
  12.             System.out.println(object == object1); 
  13.         } catch (Exception ex) { 
  14.             ex.printStackTrace(); 
  15.         } 
  16.     } 

运行这段代码:

  1. java.lang.NoSuchMethodException: com.tian.my_code.test.designpattern.singleton.EnumSingleton.<init>() 
  2.  at java.lang.Class.getConstructor0(Class.java:3082) 
  3.  at java.lang.Class.getDeclaredConstructor(Class.java:2178) 
  4.  at com.tian.my_code.test.designpattern.singleton.EnumTest.main(EnumTest.java:41) 

还真的不能用反射来搞。如果此时面试官,为什么枚举不能被反射呢?

为什么枚举不能被反射呢?

我们在反射的代码中

  1. Constructor constructor = clazz.getDeclaredConstructor(null); 

这行代码是获取他的无参构造方法。并且,从错误日志中,我们也可以看到,错误出现就是在getConstructor0方法中,并且,提示的是没有找到无参构造方法。

很奇怪,枚举也是类,不是说如果我们不给类显示定义构造方法时候,会默认给我们创建一个无参构造方法吗?

于是,我想到了一个办法,我们可以使用jad这个工具去反编译的我们的枚举式单例的.class文件。

找到我们的class文件所在目录,然后我们可以执行下面这个命令:

  1. C:\Users\Administrator>jad D:\workspace\my_code\other-local-demo\target\classes 
  2. com\tian\my_code\test\designpattern\singleton\EnumSingleton.class 
  3. Parsing D:\workspace\my_code\other-local-demo\target\classes\com\tian\my_code\t 
  4. st\designpattern\singleton\EnumSingleton.class... Generating EnumSingleton.jad 

注意:class文件目录以及生成的jad文件所在的目录。

然后打开EnumSingleton.jad 文件:

于是,我就想到了,那我们使用有参构造方法来创建:

  1. public class EnumTest { 
  2.     public static void main(String[] args) { 
  3.         try { 
  4.             Class<?> clazz = EnumSingleton.class;  
  5.             Constructor constructor = clazz.getDeclaredConstructor(String.class,int.class); 
  6.             //强行访问 
  7.             constructor.setAccessible(true); 
  8.             Object object = constructor.newInstance("田维常",996); 
  9.  
  10.             Object object1 = EnumSingleton.getInstance(); 
  11.  
  12.             System.out.println(object == object1); 
  13.         } catch (Exception ex) { 
  14.             ex.printStackTrace(); 
  15.         } 
  16.     } 

再次运行这段代码,结果:

  1. java.lang.IllegalArgumentException: Cannot reflectively create enum objects 
  2.  at java.lang.reflect.Constructor.newInstance(Constructor.java:417) 
  3.  at com.tian.my_code.test.designpattern.singleton.EnumTest.main(EnumTest.java:45) 

提示很明显了,就是不让我们使用反射的方式创建枚举对象。

  1. public T newInstance(Object ... initargs) 
  2.      throws InstantiationException, IllegalAccessException, 
  3.             IllegalArgumentException, InvocationTargetException 
  4.  { 
  5.      if (!override) { 
  6.          if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) { 
  7.              Class<?> caller = Reflection.getCallerClass(); 
  8.              checkAccess(caller, clazz, null, modifiers); 
  9.          } 
  10.      } 
  11.      //Modifier.ENUM就是用来判断是否为枚举的 
  12.      if ((clazz.getModifiers() & Modifier.ENUM) != 0) 
  13.          throw new IllegalArgumentException("Cannot reflectively create enum objects"); 
  14.      ConstructorAccessor ca = constructorAccessor;   // read volatile 
  15.      if (ca == null) { 
  16.          ca = acquireConstructorAccessor(); 
  17.      } 
  18.      @SuppressWarnings("unchecked"
  19.      T inst = (T) ca.newInstance(initargs); 
  20.      return inst; 
  21.  } 

所以,到此,我们才算真正的理清楚了,为什么枚举不让反射的原因。

序列化破坏

我们以非线程安全的饿汉式来演示一下,看看序列化是如何破坏到了模式的。

  1. public class ReflectTest { 
  2.  
  3.     public static void main(String[] args) { 
  4.         // 准备两个对象,singleton1接收从输入流中反序列化的实例 
  5.         HungrySingleton singleton1 = null
  6.         HungrySingleton singleton2 = HungrySingleton.getInstance(); 
  7.         try { 
  8.             // 序列化 
  9.             FileOutputStream fos = new FileOutputStream("HungrySingleton.txt"); 
  10.             ObjectOutputStream oos = new ObjectOutputStream(fos); 
  11.             oos.writeObject(singleton2); 
  12.             oos.flush(); 
  13.             oos.close(); 
  14.  
  15.             // 反序列化 
  16.             FileInputStream fis = new FileInputStream("HungrySingleton.txt"); 
  17.             ObjectInputStream ois = new ObjectInputStream(fis); 
  18.             singleton1 = (HungrySingleton) ois.readObject(); 
  19.             ois.close(); 
  20.  
  21.             System.out.println(singleton1); 
  22.             System.out.println(singleton2); 
  23.              
  24.             System.out.println(singleton1 == singleton2); 
  25.  
  26.         } catch (Exception e) { 
  27.             e.printStackTrace(); 
  28.         } 
  29.     } 

运行结果:

  1. com.tian.my_code.test.designpattern.singleton.HungrySingleton@7e6cbb7a 
  2. com.tian.my_code.test.designpattern.singleton.HungrySingleton@452b3a41 
  3. false 

看到了吗?

使用序列化是可以破坏到了模式的,这种方式,可能很多人不是很清楚。

如何防止呢?

我们对非线程安全的饿汉式代码进行稍微修改:

  1. public class HungrySingleton implements Serializable
  2.  
  3.     private static final HungrySingleton INSTANCE; 
  4.     static { 
  5.         INSTANCE=new HungrySingleton(); 
  6.     }  
  7.     private HungrySingleton(){ 
  8.  
  9.     } 
  10.  
  11.     public static HungrySingleton getInstance(){ 
  12.         return INSTANCE; 
  13.     } 
  14.     //添加了readResolve方法,并返回INSTANCE 
  15.     private Object readResolve方法,并返回(){ 
  16.         return INSTANCE; 
  17.     } 

再次运行上那段序列化测试的代码,其结果如下:

  1. com.tian.my_code.test.designpattern.singleton.HungrySingleton@452b3a41 
  2. com.tian.my_code.test.designpattern.singleton.HungrySingleton@452b3a41 
  3. true 

嘿嘿,这样我们是不是就避免了只创建了一个实例?

答案:否

在类ObjectInputStream的readObject()方法中调用了另外一个方法readObject0(false)方法。在readObject0(false)方法中调用了checkResolve(readOrdinaryObject(unshared))方法。

在readOrdinaryObject方法中有这么一段代码:

  1. Object obj; 
  2. try {  
  3.      //是否有构造方法,有构造放就创建实例 
  4.       obj = desc.isInstantiable() ? desc.newInstance() : null
  5.  } catch (Exception ex) { 
  6.  ...  
  7.  } 
  8. //判断单例类是否有readResolve方法 
  9. if (desc.hasReadResolveMethod()) { 
  10.     Object rep = desc.invokeReadResolve(obj);  
  11.  
  12. //invokeReadResolve方法中 
  13. if (readResolveMethod != null) {  
  14.     //调用了我们单例类中的readResolve,并返回该方法返回的对象 
  15.     //注意:是无参方法 
  16.      return readResolveMethod.invoke(obj, (Object[]) null); 

绕了半天,原来他是这么玩的,上来就先创建一个实例,然后再去检查我们的单例类是否有readResolve无参方法,我们单例类中的readResolve方法

  1. private Object readResolve(){ 
  2.         return INSTANCE; 

结论

我们重写了readResolve()无参方法,表面上看是只创建了一个实例,其实只创建了两个实例。

紧接着,面试官继续问:枚举式单例能不能被序列化破坏呢?

枚举式单例能不能被序列化破坏呢?

答案:不能被破坏,请看我慢慢给你道来。

don't talk ,show me the code。

我们先来验证一下是否真的不能被破坏,请看代码:

  1. public class EnumTest { 
  2.  
  3.     public static void main(String[] args) { 
  4.         // 准备两个对象,singleton1接收从输入流中反序列化的实例 
  5.         EnumSingleton singleton1 = null
  6.         EnumSingleton singleton2 = EnumSingleton.getInstance(); 
  7.         try { 
  8.             // 序列化 
  9.             FileOutputStream fos = new FileOutputStream("EnumSingleton.obj"); 
  10.             ObjectOutputStream oos = new ObjectOutputStream(fos); 
  11.             oos.writeObject(singleton2); 
  12.             oos.flush(); 
  13.             oos.close(); 
  14.  
  15.             // 反序列化 
  16.             FileInputStream fis = new FileInputStream("EnumSingleton.obj"); 
  17.             ObjectInputStream ois = new ObjectInputStream(fis); 
  18.             singleton1 = (EnumSingleton) ois.readObject(); 
  19.             ois.close(); 
  20.  
  21.             System.out.println(singleton1); 
  22.             System.out.println(singleton2); 
  23.  
  24.             System.out.println(singleton1 == singleton2); 
  25.  
  26.         } catch (Exception e) { 
  27.             e.printStackTrace(); 
  28.         } 
  29.     } 

运行结果:

  1. INSTANCE 
  2. INSTANCE 
  3. true 

确实,枚举式单例是不会被序列化所破坏,那为什么呢?总得有个证件理由吧。

在类ObjectInputStream的readObject()方法中调用了另外一个方法readObject0(false)方法。在readObject0(false)方法中调用了checkResolve(readOrdinaryObject(unshared))方法。

  1. case TC_ENUM: 
  2.    return checkResolve(readEnum(unshared)); 

在readEnum方法中

  1. private Enum<?> readEnum(boolean unshared) throws IOException { 
  2.         if (bin.readByte() != TC_ENUM) { 
  3.             throw new InternalError(); 
  4.         } 
  5.         Class<?> cl = desc.forClass(); 
  6.         if (cl != null) { 
  7.             try { 
  8.                 @SuppressWarnings("unchecked"
  9.                 //重点 
  10.                 Enum<?> en = Enum.valueOf((Class)cl, name); 
  11.                 result = en; 
  12.                 //...其他代码省略 
  13.             } 
  14.         } 
  15. public static <T extends Enum<T>> T valueOf(Class<T> enumType, 
  16.                                                 String name) { 
  17.        //enumType.enumConstantDirectory()返回的是一个HashMap 
  18.        //通过HashMap的get方法获取 
  19.         T result = enumType.enumConstantDirectory().get(name); 
  20.         if (result != null
  21.             return result; 
  22.         if (name == null
  23.             throw new NullPointerException("Name is null"); 
  24.         throw new IllegalArgumentException( 
  25.             "No enum constant " + enumType.getCanonicalName() + "." + name); 
  26. //返回一个HashMap 
  27.  Map<String, T> enumConstantDirectory() { 
  28.         if (enumConstantDirectory == null) { 
  29.             T[] universe = getEnumConstantsShared(); 
  30.             if (universe == null
  31.                 throw new IllegalArgumentException( 
  32.                     getName() + " is not an enum type"); 
  33.             //使用的是HashMap 
  34.             Map<String, T> m = new HashMap<>(2 * universe.length); 
  35.             for (T constant : universe) 
  36.                 m.put(((Enum<?>)constant).name(), constant); 
  37.             enumConstantDirectory = m; 
  38.         } 
  39.         return enumConstantDirectory; 

所以,枚举式单例模式是使用了Map

在Spring中也是有大量使用这种注册式单例模式,IOC容器就是典型的代表。

总结

本文讲述了单例模式的定义、单例模式常规写法。单例模式线程安全问题的解决,反射破坏、反序列化破坏等。

注意:不要为了套用设计模式,而使用设计模式。而是要,在业务上遇到问题时,很自然地联想单设计模式作为一种捷径方法。

单例模式的优缺点

优点

在内存中只有一个实例,减少内存开销。可以避免对资源的多重占用。设置全局访问点,严格控制访问。

缺点

没有借口,扩展性很差。如果要扩展单例对象,只有修改代码,没有其他途径。

单例模式是 不符合开闭原则的。

知识点

单例模式的重点知识总结:

  • 私有化构造器
  • 保证线程安全
  • 延迟加载
  • 防止反射攻击
  • 防止序列化和反序列化的破坏

本文转载自微信公众号「Java后端技术全栈」,可以通过以下二维码关注。转载本文请联系Java后端技术全栈公众号。

 

责任编辑:武晓燕 来源: Java后端技术全栈
相关推荐

2021-09-07 10:44:35

异步单例模式

2021-02-01 10:01:58

设计模式 Java单例模式

2018-04-03 15:38:07

Java单例模式模式设计

2021-03-02 08:50:31

设计单例模式

2022-02-06 22:30:36

前端设计模式

2022-09-29 08:39:37

架构

2013-11-26 16:20:26

Android设计模式

2016-03-28 10:23:11

Android设计单例

2021-02-07 23:58:10

单例模式对象

2011-03-16 10:13:31

java单例模式

2022-06-07 08:55:04

Golang单例模式语言

2024-11-06 16:13:00

Python单例模式

2019-06-11 09:50:07

SparkBroadcast代码

2024-02-22 10:02:03

单例模式系统代码

2015-09-06 11:07:52

C++设计模式单例模式

2023-11-21 21:39:38

单例模式音频管理器

2011-06-28 15:18:45

Qt 单例模式

2024-03-06 13:19:19

工厂模式Python函数

2024-02-04 12:04:17

2021-08-11 17:22:11

设计模式单例
点赞
收藏

51CTO技术栈公众号