likes
comments
collection
share

细读Java单例模式

作者站长头像
站长
· 阅读数 18

Java中的单例模式看似是一个很简单的设计模式,但事实上,我们可以整出各种各样的“幺蛾子”。单例模式有着不同的实现方式,也很难找到完美的方式。今天我就来分享一下,单例模式的几种常用实现模式以及存在的问题。

之前我写过文章讲解单例模式,不过那个是最简单的方式,还漏掉了许多的情况,这里我们就来详细地学习学习,这里还是以“一个店只能有一个老板”为例,创建老板类单例。

1,常规实现方式

(1) 饿汉式

这个就是上一篇博客讲的方法,也是最简单的实现方法:

package com.example.singleinstance.eager;

import lombok.Getter;
import lombok.Setter;

/**
 * 饿汉式单例模式
 */
@Getter
@Setter
public class Master {

   /**
    * 名字
    */
   private String name;

   /**
    * 唯一单例
    */
   private static Master instance = new Master();

   /**
    * 私有化构造器
    */
   private Master() {
   }

   /**
    * 获取老板唯一单例
    *
    * @return 老板唯一单例
    */
   public static Master getInstance() {
      return instance;
   }

}

可见饿汉式单例模式之所以叫饿汉式,是因为这种单例模式在类加载的时候就初始化了唯一单例了

这种方式的优缺点也很明显:

  • 优点:执行效率高,绝对线程安全
  • 缺点:有可能用不着该单例,但是它无论如何都初始化了,可能会“占着茅坑不拉屎”,浪费内存

那么如果要改善性能,我们需要进行一些修改。

(2) 懒汉式

懒汉式单例模式就是当外部访问该单例的时候,才会初始化:

package com.example.singleinstance.lazy;

import lombok.Getter;
import lombok.Setter;

/**
 * 懒汉式单例模式
 */
@Getter
@Setter
public class Master {

   /**
    * 名字
    */
   private String name;

   /**
    * 唯一单例,先不初始化
    */
   private static Master instance = null;

   /**
    * 私有化构造器
    */
   private Master() {
   }

   /**
    * 获取老板唯一单例
    *
    * @return 老板唯一单例
    */
   public static Master getInstance() {
      // 若没有初始化,则初始化一下
      if (instance == null) {
         instance = new Master();
      }
      return instance;
   }

}

可见我们先不初始化单例,在要调用的时候,判断是否为null,如果是说明是第一次调用,则初始化一下,否则就返回单例。

2,想办法破解单例模式

(1) 多线程破坏单例模式

懒汉式单例模式确实优化了性能,但是并非是线程安全的。假设有n个线程在极短的时间同时访问该单例的getInstance方法,那有可能会有多余一个线程同时判断该单例为null导致最后初始化出多个Master实例。

我们实例化单例的时候就打印输出一下单例的地址,修改getInstance如下:

public static Master getInstance() {
   // 若没有初始化,则初始化一下
   if (instance == null) {
      instance = new Master();
      System.out.println(instance);
   }
   return instance;
}

然后新建两个线程,利用IDEA的线程调试模式,干预线程的执行顺序,来模拟出两个线程同时执行到的情况:

for (int i = 0; i < 2; i++) {
   new Thread(() -> {
      Master master = Master.getInstance();
   }).start();
}

在这里打断点,并右键断点-线程模式:

细读Java单例模式

细读Java单例模式

执行调试,可以在调试控制台这里手动切换线程,控制线程运行:

细读Java单例模式

使用步入按钮(F5),先让0号线程进入if语句,到达实例化这里停下:

细读Java单例模式

切换到1号线程,也让1号线程进入if语句,到达实例化这里停下:

细读Java单例模式

最后让两个线程执行完,可以看见控制台输出了两个不同的地址:

细读Java单例模式

可见,懒汉式也不完全是线程安全的。

这时,我们可以给getInstance方法上锁,实现线程安全:

public synchronized static Master getInstance() {
   // 若没有初始化,则初始化一下
   if (instance == null) {
      instance = new Master();
   }
   return instance;
}

这样,确实是线程安全了,但是总归是上了锁,对程序的性能会有一定的影响,那难道就没有好一点的方法了吗?

我们可以从类的初始化的角度想一下,我们可以借助内部类来解决这些问题。在Java中,内部类是延时加载的,也就是说你用它它就加载,不用就不加载,不受外部类的影响。利用内部类的这个特性,我们是否能够把单例放在内部类里面呢?我们来试一下子:

package com.example.singleinstance.lazy;

import lombok.Getter;
import lombok.Setter;

/**
 * 懒汉式内部类法单例模式
 */
@Getter
@Setter
public class Master {

   /**
    * 名字
    */
   private String name;

   /**
    * 私有化构造器
    */
   private Master() {
   }

   /**
    * 获取老板唯一单例,final使得该方法不允许被重写或者重载
    *
    * @return 老板唯一单例
    */
   public static final Master getInstance() {
      // 返回结果之前,会先加载内部类
      return InnerMaster.INSTANCE;
   }

   /**
    * 老板类的内部类,没有用到它就不会加载
    */
   private static class InnerMaster {
      private static final Master INSTANCE = new Master();
   }

}

这种方式完美地解决了饿汉式单例模式的内存问题,和上锁的性能问题。内部类一定是会在方法调用之前初始化,并且它永远只会初始化一次(一个类无法被加载多次),因此避免了线程安全问题。

(2) 反射破坏单例模式

构造器确实被私有化了,但是利用Java的反射机制,仍然可以访问其构造器:

// 利用反射获取构造方法,并设定可访问
Constructor constructor = Master.class.getDeclaredConstructor();
constructor.setAccessible(true);
Master master1 = (Master) constructor.newInstance();
Master master2 = (Master) constructor.newInstance();
System.out.println(master1 == master2);

细读Java单例模式

可见即使是私有化了构造器,我们仍然还是可以把它new个两下,得到两个实例,违背了单例模式的基本原则。

解决这个问题也不难,我们在构造器里面做点功夫即可:

package com.example.singleinstance.lazy;

import lombok.Getter;
import lombok.Setter;

/**
 * 懒汉式内部类法单例模式
 */
@Getter
@Setter
public class Master {

   /**
    * 名字
    */
   private String name;

   /**
    * 私有化构造器
    */
   private Master() {
      if (InnerMaster.INSTANCE != null) {
         throw new RuntimeException("不允许创建多个实例!");
      }
   }

   /**
    * 获取老板唯一单例,final使得该方法不允许被重写或者重载
    *
    * @return 老板唯一单例
    */
   public static final Master getInstance() {
      // 返回结果之前,会先加载内部类
      return InnerMaster.INSTANCE;
   }

   /**
    * 老板类的内部类,没有用到它就不会加载
    */
   private static class InnerMaster {
      private static final Master INSTANCE = new Master();
   }

}

再次运行上述代码:

细读Java单例模式

好了,到这里我们也更进一步地明白了:一个类被加载时,其内部类不会被加载;而这个类被使用到时,其内部类才会被加载。

这里注意加载使用的区别。应用程序启动时,每个类都会被加载,而你调用这个类用于实例化或者调用其方法的时候,才叫使用这个类。

(3) 序列化破坏单例模式

有时候我们需要把对象序列化并在网络上传输,然后反序列化。大家都知道,反序列化的对象并非是原有的对象,这也破坏了单例模式的原则。

首先让Master类使用Serializable接口,然后作如下测试:

// 序列化
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(Master.getInstance());
// 再反序列化
ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bis);
Master master = (Master) ois.readObject();
System.out.println(master == Master.getInstance());

细读Java单例模式

可见,利用序列化法破坏了单例。

其实,我们只需要在Master类中增加一个readResolve方法即可:

package com.example.singleinstance.lazy;

import lombok.Getter;
import lombok.Setter;

import java.io.Serializable;

/**
 * 懒汉式内部类法单例模式
 */
@Getter
@Setter
public class Master implements Serializable {

   /**
    * 名字
    */
   private String name;

   /**
    * 私有化构造器
    */
   private Master() {
      if (InnerMaster.INSTANCE != null) {
         throw new RuntimeException("不允许创建多个实例!");
      }
   }

   /**
    * 获取老板唯一单例,final使得该方法不允许被重写或者重载
    *
    * @return 老板唯一单例
    */
   public static final Master getInstance() {
      // 返回结果之前,会先加载内部类
      return InnerMaster.INSTANCE;
   }

   private Object readResolve() {
      return InnerMaster.INSTANCE;
   }

   /**
    * 老板类的内部类,没有用到它就不会加载
    */
   private static class InnerMaster {
      private static final Master INSTANCE = new Master();
   }

}

再次运行:

细读Java单例模式

这看起来非常神奇:为什么加这个方法就可以了呢?事实上这和ObjectInputStream类的执行逻辑有关。大家可以去研究一下JDK源码就知道了,再次不再过多赘述。

但事实上,这种方法确实保证只返回了一个单例,但是内存中其实还是有多个单例。

当然,肯定有更好的方法。

3,注册式单例模式

顾名思义,注册式单例模式就是把实例先注册到一个地方,获取的时候根据标识符获取。

通常有下列两种方式实现。

(1) 【推荐】枚举式单例模式

利用枚举实现单例模式,也就是把单例类写成枚举类,我们修改Master类如下:

package com.example.singleinstance.enumerate;

import lombok.Getter;
import lombok.Setter;

@Getter
public enum Master {

   /**
    * 老板类唯一单例
    */
   INSTANCE;

   /**
    * 名字
    */
   @Setter
   private String name;

   /**
    * 获取老板类唯一实例
    *
    * @return 老板类唯一实例
    */
   public static Master getInstance() {
      return INSTANCE;
   }

}

大家都知道:枚举类中的每一个枚举相当于就是这个枚举类的实例,并且枚举类中也可以写成员变量和方法

那枚举类中的枚举是不是单例呢?我们来试一下子。

a. 尝试使用反射破坏

Constructor constructor = Master.class.getDeclaredConstructor();
constructor.setAccessible(true);
Master master = (Master) constructor.newInstance();

结果:

细读Java单例模式

可见反射机制找不到枚举类的构造器,这是因为枚举类的构造方法是protected的:

细读Java单例模式

b. 尝试使用序列化破坏

// 序列化
ByteArrayOutputStream bos = new ByteArrayOutputStream();
ObjectOutputStream oos = new ObjectOutputStream(bos);
oos.writeObject(Master.getInstance());
// 再反序列化
ByteArrayInputStream bis = new ByteArrayInputStream(bos.toByteArray());
ObjectInputStream ois = new ObjectInputStream(bis);
Master master = (Master) ois.readObject();
System.out.println(master == Master.getInstance());

结果:

细读Java单例模式

这也是利用JDK的反序列化机制,也就是说枚举类型其实是通过类名和类对象找到一个唯一的对象,不会被类加载器加载多次

这也可见:枚举值天生就是单例的,非常契合单例模式思想。

(2) 容器式单例

我们还可以使用Map专门做一个单例容器,把实例都放进去:

package com.example.singleinstance;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/**
 * 单例容器
 */
public class SingleContainer {

   private SingleContainer() {
   }

   // 存放所有单例的容器,键为类的全限定名,值为对应单实例
   private static Map<String, Object> container = new ConcurrentHashMap<>();

   /**
    * 获取对应类的单实例,不存在则创建
    *
    * @param className 类的全限定名
    * @return 单实例
    */
   public synchronized static Object getInstance(String className) throws Exception {
      if (!container.containsKey(className)) {
         Object instance = Class.forName(className).getConstructor().newInstance();
         container.put(className, instance);
         return instance;
      }
      return container.get(className);
   }

}

这种方式看起来也很高级,不过也会产生线程问题。

4,总结

可见单例模式看起来简单,事实上要想写一个严谨、滴水不漏的单例模式还是很难的。

日常开发,推荐使用基于内部类的懒汉式单例模式或者是枚举式单例模式。将两者示例代码拎出来如下:

基于内部类的懒汉式单例模式:

package com.example.singleinstance.lazy;

import lombok.Getter;
import lombok.Setter;

/**
 * 懒汉式内部类法单例模式
 */
@Getter
@Setter
public class Master {

   /**
    * 名字
    */
   private String name;

   /**
    * 私有化构造器
    */
   private Master() {
      if (InnerMaster.INSTANCE != null) {
         throw new RuntimeException("不允许创建多个实例!");
      }
   }

   /**
    * 获取老板唯一单例,final使得该方法不允许被重写或者重载
    *
    * @return 老板唯一单例
    */
   public static final Master getInstance() {
      // 返回结果之前,会先加载内部类
      return InnerMaster.INSTANCE;
   }

   private Object readResolve() {
      return InnerMaster.INSTANCE;
   }

   /**
    * 老板类的内部类,没有用到它就不会加载
    */
   private static class InnerMaster {
      private static final Master INSTANCE = new Master();
   }

}

枚举式单例模式:

package com.example.singleinstance.enumerate;

import lombok.Getter;
import lombok.Setter;

@Getter
public enum Master {

   /**
    * 老板类唯一单例
    */
   INSTANCE;

   /**
    * 名字
    */
   @Setter
   private String name;

   /**
    * 获取老板类唯一实例
    *
    * @return 老板类唯一实例
    */
   public static Master getInstance() {
      return INSTANCE;
   }

}

示例仓库地址