【浅谈设计模式】:(2)单例模式--只有一个实例

849 阅读7分钟

一、初识单例模式(Singleton)

1.1 概述

单例模式,是很多人学的第一个设计模式。引用百度百科的定义如下:

单例模式,属于创建类型的一种常用的软件设计模式。通过单例模式的方法创建的类在当前进程中只有一个实例(根据需要,也有可能一个线程中属于单例,如:仅线程上下文内使用同一个实例)

在我们程序运行的时候,通常会生成很多实例,也就是我们经常说的new一个对象出来,基于一个类可以生成很多个对象,但是,这些对象是同一个吗?显然并不是。

public class SingletonTest {
    public static void main(String[] args) {
        Apple apple = new Apple();
        Apple apple1 = new Apple();
        System.out.println(apple==apple1); //false
    }
}

那么当我们想在程序中表示某个东西只会存在一个时,就会有“只能创建一个实例的需求”,如果想要达到下面两个目的,那就需要引入今天所讲的单例模式:

  • 想要确保任何情况下都绝对只有一个实例。
  • 想要在程序上表现出“只存在一个实例”

1.2 应用场景

上面说了单例模式的使用目的,那么在什么情况下会使用到单例模式呢?毕竟平常些代码中好些也没有用到。下面介绍下几个单例模式的应用场景:

  • 1、Windows的任务管理器就是个典型的单例模式,因为你不可能同时打开两个。

image.png

  • 2、老版本的网站中的计数器,一般也采用单例模式实现,便于同步,但现在好像都放入缓存了。
  • 3、web应用的配置对象的读取,如果这个配置文件是共享的资源,一般也是单例模式。
  • 4、数据库连接池的设计中,我们往往不会直接使用原生的JDBC操作来实现与数据库的连接,因为数据库的连接是一个很宝贵的资源且耗时,我们往往会在内存中引入一个资源池来统一管理数据库的连接,这种模式也被总结未:资源池模式和单例模式。主要是节省打开和关闭数据库连接所引起的效率损耗。

image.png

  • 5、类似的,多线程的线程池的设计一般也是采用单例模式,便于管理线程。
  • 6、操作系统的文件系统,也是大的单例模式实现的例子,一个操作系统只能有一个文件系统。

综上发现,单例模式的应用场景一般发生在以下条件:

(1)资源共享的情况下,避免由于资源操作时导致的性能或损耗等,如应用配置文件。

(2)控制资源的情况下,方便资源之间的互相通信,如线程池等。

二、单例模式的实现方式

单例模式分为两类:

  • 饿汉式:类加载的时候就会创建实例对象
  • 懒汉式:类加载不会创建实例对象,而是首次使用该对象时才会创建。

单例模式思路实现分三步走:

  • 1、将构造函数私有,这样外界就不能直接通过new SingleTon来创建实例了
  • 2、创建一个包含自己的实例,即私有的静态变量成员:instance
  • 3、提供一个公共的静态方法给其他人获取实例

2.1 饿汉式--方式1 (静态变量方式)

饿汉模式通过静态修饰符修饰,一看就很饥饿,随着类的加载而加载,可以得到一个单一实例,线程是安全的。但是可能会浪费空间,加载了很多没有用到的对象,因此想出了懒汉式单例。

public class SingleTon {
    private static SingleTon instance=new SingleTon();
    private SingleTon(){}
    public static SingleTon getInstance(){
        return instance;
    }
}

---- 测试代码-----
public class TestSingleTon {
    public static void main(String[] args) {
        SingleTon instance = SingleTon.getInstance();
        SingleTon instance1 = SingleTon.getInstance();
        System.out.println(instance==instance1); //true
    }
}

2.2 饿汉式--方式2 (静态代码块)

这种方式和上面没有基本没有什么区别,都会浪费内存。

public class SingleTon {
    private static SingleTon instance;
    private SingleTon(){}
    static {
        instance=new SingleTon();
    }
    public static SingleTon getInstance(){
        return instance;
    }
}

2.3 懒汉式--方式1(线程不安全)

该模式存在缺点:多线程不能使用。

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

接下来拿多线程测试:

public class TestSingleTon {
    public static void main(String[] args) {
        for (int i = 0; i < 50; i++) {
            new Thread() {
                @Override
                public void run() {
                    SingleTon instance = SingleTon.getInstance();
                    SingleTon singleTon2=SingleTon.getInstance();
                    System.out.println(instance==singleTon2);
                }
            }.start();
        }
        for (int i = 0; i < 50; i++) {
            new Thread() {
                @Override
                public void run() {
                    SingleTon instance = SingleTon.getInstance();
                    SingleTon singleTon2=SingleTon.getInstance();
                    System.out.println(instance==singleTon2);
                }
            }.start();
        }
    }
}

在打印的结果中,确实证明了该方式线程不安全。

image.png

那么为什么会造成线程不安全呢

这是因为CPU在分配时间片的时候是随机的,也就是两个线程执行时间是随机切换的,同一时刻只有一个线程在执行,当线程A在执行if(instance==null)的时候,假设这个时间片交给线程B,那么线程B执行完new Instance()的时候,又切换回线程A,线程A继续执行下面的new Instance(),造成构造方法被执行了两次,所以,线程不安全!

问题解决,那么解决这个线程不安全的办法就是 : 加锁!

2.4 懒汉式--方式2(线程安全)

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

很显然,加锁之后,达到了我们的目的,既实现了懒加载特效,也解决了线程安全问题。但是加锁之后,会导致该方法的执行效率特别低,其实就是初始化的时候才会出现线程安全,一旦初始化完成就不存在了。因此,需要把锁的粒度降低。不在方法上加锁,在关键问题上上锁。

2.5 懒汉式--方式3(双重检查锁)

public class SingleTon {
  private static SingleTon instance;
  private SingleTon(){
  }
  public static synchronized SingleTon getInstance(){
      //第一次判断,如果instance不为null,不进入抢锁阶段,直接返回实例
      if(instance==null){
          synchronized (SingleTon.class){
              //2、抢到锁后再次判断是否为null
              if(instance==null){
                  instance=new SingleTon();
              }
          }
      }
      return instance;
  }
}

双重检查锁模式是一种非常好的单例模式,解决了单例、性能、线程安全等问题,看起来是完美无缺的,其实是存在问题的,在多线程下,可能会出现空指针问题,出现的原因是JVM在实例化对象的时候会进行优化和指令重排序操作。

关于原子操作和指令重排简述如下:

1、原子操作: 就是不可分割的操作

在计算机中,不会因为线程调度被打断的操作,比如赋值:m=10,就是原子操作,而对于int m=10;这个语句就不是原子操作,拆分为声明变量和赋值。中间有个中间状态。

2、指令重排:计算机为了提高执行效率,会做一些优化,在不影响最终结果的情况下,可能会对一些语句的执行顺序进行调整。例如:

int a=1;
int b=2;
int a=b+1

指令重排可以在不影响结果的前提下打乱这几个执行顺序。那么对应到我们的单例模式:new Singleton(),它在new一个对象的时候也是分为了好几个步骤:

  • 1、看class对象是否加载,如果没有就加载它。
  • 2、分配内存空间,初始化实例。
  • 3、调用构造方法
  • 4、返回地址给引用。

而cpu为了优化程序,可能会进行指令重排,导致实例内存还没分配的时候,就被使用了,这就是前面说的空指针问题。

因此,想要解决空指针问题,只需要使用Volatile关键字,它可以保证可见性和有序性。在多线程下不会有性能问题。

public class SingleTon {
  private static volatile SingleTon instance;
  private SingleTon(){
  }
  public static synchronized SingleTon getInstance(){
      //第一次判断,如果instance不为null,不进入抢锁阶段,直接返回实例
      if(instance==null){
          synchronized (SingleTon.class){
              //2、抢到锁后再次判断是否为null
              if(instance==null){
                  instance=new SingleTon();
              }
          }
      }
      return instance;
  }
}

2.6 懒汉式--方式4(静态内部类)

静态内部类单例模式由内部类创建,由于JVM在加载外部类的时候,是不会加载静态内部类的,只有内部类的属性/方法被调用的时候才会加载,并初始化其静态属性。静态属性由于被static修饰,只能实例化一次,保证了实例化顺序。

public class SingleTon {
  private SingleTon(){
  }
  private static class InnerClass{
      private static final SingleTon instance=new SingleTon();
  }
  //对外提供静态方法
    public static SingleTon getInstance(){
      return InnerClass.instance;
    }
}

静态内部类实现单例是一种优秀的单例模式,在没有加任何锁的情况下,保证了多线程的安全,并且没有任何的性能影响和空间的浪费。

2.7 枚举方式(饿汉式)

枚举类实现单例是极力推荐的单例实现模式,因为枚举类型是线程安全的,并且只会装载一次,设计者充分利用了枚举这个特性来实现单例模式,枚举类型是所有单例中唯一一种不会被破坏的实现模式。

/**
 * 枚举方式
 */
public class Singleton {
    private Singleton(){
    }   
    public static enum SingletonEnum {
        SINGLETON;
        private Singleton instance = null;
        private SingletonEnum(){
            instance = new Singleton();
        }
        public Singleton getInstance(){
            return instance;
        }
    }
}

三、攻击单例模式及化解攻击

3.1 攻击单例模式

由前面的学习,我们知道单例模式的能保证只有一个实例,因此,要攻击单例模式的话,我们就用上面的单例类来创建出多个对象出来,可以采用的方法有两种:

  • 序列化和反序列化
  • 反射
a、序列化和反序列化攻击

Singleton类:

public class Singleton implements Serializable {

    //私有构造方法
    private Singleton() {}

    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }

    //对外提供静态方法获取该对象
    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}

Test类:

public class TestSingleTon {
    public static void main(String[] args) throws Exception {
        //写入文件
//        writeObjectFile();
        SingleTon singleTon = readObjectFile();
        SingleTon singleTon1 = readObjectFile();
        System.out.println(singleTon1==singleTon);  //false
    }

    public static SingleTon readObjectFile() throws Exception{
        ObjectInputStream os = new ObjectInputStream(new FileInputStream("D:\\cs\\a.txt"));
        SingleTon singleTon= (SingleTon) os.readObject();
        return singleTon;
    }

    /**
     * 创建实例写入文件
     * @throws Exception
     */
    public static void writeObjectFile() throws Exception{
        //获取Singleton对象
        SingleTon instance = SingleTon.getInstance();
        //创建对象输出流
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream("D:\\cs\\a.txt"));
        //将这个实例写入文件
        objectOutputStream.writeObject(instance);
    }
}

经过测试:上面运行的结果为false,表明了序列化和饭序列化的操作已经破坏了单例模式。

3.2 反射攻击

Singleton类:

public class Singleton implements Serializable {

    //私有构造方法
    private Singleton() {}

    private static class SingletonHolder {
        private static final Singleton INSTANCE = new Singleton();
    }

    //对外提供静态方法获取该对象
    public static Singleton getInstance() {
        return SingletonHolder.INSTANCE;
    }
}

Test类:

public class SingleTonTest {
    public static void main(String[] args) throws Exception {
        //获取Singleton 类的字节码对象
        Class<SingleTon> singleTonClass = SingleTon.class;
        //获取Singleton 类的私有无参构造
        Constructor<SingleTon> constructor = singleTonClass.getDeclaredConstructor();
        //取消访问检查,就是能访问私有的构造方法
        constructor.setAccessible(true);

        //创建实例1和2
        SingleTon singleTon = constructor.newInstance();
        SingleTon singleTon1 = constructor.newInstance();
        System.out.println(singleTon1==singleTon); //false
    }
}

上面的运行结果是false,表明序列化和反序列化已经破坏了单例设计模式。

3.2 化解攻击

a、化解序列化和反序列化攻击

在Singleton类中添加readResolve()方法,如果定义了这个方法,就返回这个方法的值,如果没有定义,则返回新new出来的对象。

Singleton类

 public class SingleTon implements Serializable {
  private SingleTon(){
  }
  private static class InnerClass{
      private static final SingleTon instance=new SingleTon();
  }
  //对外提供静态方法
    public static SingleTon getInstance(){
      return InnerClass.instance;
    }
  /**
   * 下面是为了解决序列化反序列化破解单例模式
   */
  private Object readResolve() {
    return InnerClass.instance;
  }
}

也就是序列化的时候我们用到了readObject方法,查看它的源码可以发现这个问题。

public final Object readObject() throws IOException, ClassNotFoundException{
    ...
    // if nested read, passHandle contains handle of enclosing object
    int outerHandle = passHandle;
    try {
        Object obj = readObject0(false);//重点查看readObject0方法
    .....
}
    
private Object readObject0(boolean unshared) throws IOException {
    ...
    try {
        switch (tc) {
            ...
            case TC_OBJECT:
                return checkResolve(readOrdinaryObject(unshared));//重点查看readOrdinaryObject方法
            ...
        }
    } finally {
        depth--;
        bin.setBlockDataMode(oldMode);
    }    
}
    
private Object readOrdinaryObject(boolean unshared) throws IOException {
    ...
    //isInstantiable 返回true,执行 desc.newInstance(),通过反射创建新的单例类,
    obj = desc.isInstantiable() ? desc.newInstance() : null; 
    ...
    // 在Singleton类中添加 readResolve 方法后 desc.hasReadResolveMethod() 方法执行结果为true
    if (obj != null && handles.lookupException(passHandle) == null && desc.hasReadResolveMethod()) {
        // 通过反射调用 Singleton 类中的 readResolve 方法,将返回值赋值给rep变量
        // 这样多次调用ObjectInputStream类中的readObject方法,继而就会调用我们定义的readResolve方法,所以返回的是同一个对象。
        Object rep = desc.invokeReadResolve(obj);
        ...
    }
    return obj;
}
b、化解反射攻击
public class SingleTon implements Serializable {
  private SingleTon(){
    if(getInstance()!=null){
      throw new RuntimeException("不能重复构造对象");
    }
  }
  private static class InnerClass{
      private static final SingleTon instance=new SingleTon();
  }
  //对外提供静态方法
    public static SingleTon getInstance(){
      return InnerClass.instance;
    }
}

直接抛出异常来处理反射。第二个对象就无法构造

image.png

顺便一提,这个Runtime类是由单例模式实现的,典型的饿汉模式。


public class Runtime {
    private static Runtime currentRuntime = new Runtime();

    /**
     * Returns the runtime object associated with the current Java application.
     * Most of the methods of class <code>Runtime</code> are instance
     * methods and must be invoked with respect to the current runtime object.
     *
     * @return  the <code>Runtime</code> object associated with the current
     *          Java application.
     */
    public static Runtime getRuntime() {
        return currentRuntime;
    }

    /** Don't let anyone else instantiate this class */
    private Runtime() {}
    ...
}

四、枚举的强大之处

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

这是在底层规定的,因此直接把这种攻击方式排除了。

那么序列化和反序列化可以破坏枚举单例吗?

答案是也不能。

因为枚举式单例是使用了Map<String,T>,Map的key就是我们枚举类中的INSTANCE。由于map中key的唯一性,所以也造就了唯一实例,这也被称为“注册式单例模式”。Spring中的ioc就是这个典型的代表。

五、总结

在本文中,我们学习了第一种设计模式,了解到单例模式的定义和常规实现方法,包括线程安全和不安全写法。单例模式的破坏和反破坏手段,这篇文章也花了我三四个小时的整理。以后在谈到单例模式的时候,这篇文章应该能解决大部分问题了。后续将继续我的浅谈设计模式之旅,如果对你有帮助,请帮忙点个赞,谢谢各位~