01-Java代码审计-设计模式-单例模式

117 阅读9分钟

Java设计模式-单例模式(Singleton Pattern)

目录

  • 什么是单例模式
  • 单例模式的实现方式及线程安全问题
  • Struts2单例模式的应用

单例模式可以非常有效的节省系统开支

一、什么是单例模式

1.1 什么是单例模式?

单例就是单个实例,在进程所分配的内存中仅能存在唯一一个对象。

单例模式就是通过技术手段,来实现这个对象在内存中是唯一的。

1.2 为什么要使用单例模式?

实例的创建需要系统资源的开支,特别是类功能负责、读取IO、网络、数据库连接等操作更是非常大的开支,那么创建一个实例,所有的功能都使用该实例是不不是就可以节省系统开支。

二、单例模式的实现方式及线程安全问题

第一种:饿汉式
package org.singleton;

/**
 * 单例模式:饿汉式
 */
public class HungryPrinter {
    // 打印机实例
    private static final HungryPrinter instance =  new HungryPrinter();
    // 私有构造函数,禁止使用new创建对象
    private HungryPrinter(){}
    // 获取打印机对象
    public static HungryPrinter getInstance(){
        return instance;
    }
}

饿汉式的有以下几个特点,这三个特点共同完成了单个实例的限制:

  1. 使用private私有化实例;final限制修改对象;static在类加载时初始化Printer对象
  2. 私有化构造函数,禁止主动new对象
  3. 只能通过getInstance获取实例

static关键字决定了,类加载时便会初始化Printer对象,哪怕没有使用这个对象,这是一种资源浪费。

第二种:懒汉式
package org.singleton;

public class LazyPrinter {
    // 打印机实例
    private static LazyPrinter instance;
    // 私有构造函数,禁止使用new创建对象
    private LazyPrinter(){}

    public static LazyPrinter getInstance(){
        // 只有当对象为空时,才会创建对象
        if (instance == null){
            instance = new LazyPrinter();
        }
        return instance;
    }
}

上面的代码是不是看来很完美,解决了饿汉式的资源浪费问题,但引入了新的问题,多线程安全!多么头疼的词语。什么情况下会引起多线程安全?

  1. 存在多线程同时访问一个资源(同时访问instace)
  2. 访问非方法内部的局部变量(instance就是非方法内部的局部变量)

刚写的懒汉式代码完全符合,假设T1与T2两个线程,同时调用了getInstance方法,T1运行到if (instance == null)处后CPU时间片用完了,这时候轮到T2,T2执行的时候T1还没有成功创建,所以成功创建了对象,当T1重新拿回执行权时,恰好也执行了new操作,又重新创建了对象, 这样就破坏了单例模式。

第三种:双锁检测(double check)
package org.singleton;

public class DoubleCheckPrinter {
    // 打印机实例
    private static volatile DoubleCheckPrinter instance;
    // 私有构造函数,禁止使用new创建对象
    private DoubleCheckPrinter(){}

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

volatile的定义如下:Java编程语言允许线程访问共享变量,为了确保共享变量能被准确和一致的更新,线程应该确保通过排它锁单独获得这个变量。Java语言提供了volatile,在某些情况下比锁更加方便。如果一个字段被声明成volatile,Java线程内存模型确保所有线程看到这个变量的值是一致的。

为什么使用volatile?

让我们来看一下这行代码: instance = new DoubleCheckPrinter() ,执行完这一行代码可以分成三个步骤:

  • step1:在内存中分配一块空间。
  • step2:对内存空间进行初始化。
  • step3:把对象在内存中的位置指向 instance

如果按照 CPU 或者 JIT 编译器能够按照片正常的指令执行的话,是不需要 volatile,但是 CPU 和 JIT 即时编译器为了能获得性能上的提升,往往会对字节码指令进行重排序,这就会导致 step2 和 step3 执行的顺序颠倒。执行步骤就变成了:

  • step1:在内存中分配一块空间。
  • step3:把对象在内存中的位置指向 instance
  • step2:对内存空间进行初始化。

现在假设有两个线程T1、T2,T1 线程执行完重排序后的 step3 ,CPU 的执行权被 T2 获得。这个时候,instance 已经不为 null 了,他指向了内存中的一块地址。T2 执行到第一个 if 的时候,发现 instance 不为 null,就直接返回,但是这个 instance 并没有被初始化,这就会导致 T2 在执行的过程中发生不可预知的错误。

现在来看第二个问题,为什么要使用 double check?

现假设有两个 T1 和 T2,T1 执行到注释2处,CPU 的执行权被 T2 抢夺走,T2 执行完成之后创建了一个对象实例,并且释放 Java 的类锁。这个时候 T1 又重新获得了 CPU 的执行权,并且获得了类锁。如果没有第二个 if 的判断,T1 又会重新创建一个 实例对象,这样就破坏了单例。

明白了为什么会有第二个 if ,现在来看为什么会有第一个 if?其实不难看懂,现在假设有一个线程 T3 ,如果没有第一个 if,它就会直接尝试获取锁资源。要知道,锁资源是非常宝贵的,如果每个线程一来就直接申请锁资源,而不是先对 instance 进行判断,这势必会对程序的性能造成影响。

第四种:静态内部类
package org.singleton;

public class InnerClassPrinter {
    // 私有构造函数,禁止使用new创建对象
    private InnerClassPrinter(){}
    
    public static InnerClassPrinter getInstance(){
        return InnerClassHolder.INSTANCE;
    }

    private static final class InnerClassHolder {
        private static final InnerClassPrinter INSTANCE = new InnerClassPrinter();
    }
}

感觉和饿汉模式很类似,就是把实例化的过程放到了内部类,解决了类加载时便会初始化实例的缺陷,同时也解决了懒汉式的线程安全问题。

第五种:枚举单例

黑客的思维总是反人类的,上面写的就真的线程安全吗?实例的产生除了new还有其它方式吗?很显然是有的,反射、反序列化可以实现!我们用Double Check验证下

package org.singleton;

import java.lang.reflect.Constructor;

public class Test {
    public static void main(String[] args) throws Exception {
        DoubleCheckPrinter printer = DoubleCheckPrinter.getInstance();
        DoubleCheckPrinter printer1 = DoubleCheckPrinter.getInstance();

        System.out.println("通过getInstance获取的对象是否为同一个对象:" + (printer == printer1));

        Class<?> cls = DoubleCheckPrinter.getInstance().getClass();
        Constructor constructor = cls.getDeclaredConstructor();
        constructor.setAccessible(true);
        DoubleCheckPrinter printer2 = (DoubleCheckPrinter) constructor.newInstance();
        System.out.println("通过反射获取的对象是否为同一个对象:" + (printer == printer2));
    }
}
// 执行结果如下
通过getInstance获取的对象是否为同一个对象:true
通过反射获取的对象是否为同一个对象:false

可以发现破坏了单例模式,同样的反序列化也能破坏单例,需要先让DoubleCheckPrinter类实现序列化接口

package org.singleton;

public class Test {
    public static void main(String[] args) throws Exception {
        // 将DoubleCheckPrinter对象序列化和到printer.txt
        DoubleCheckPrinter printer = DoubleCheckPrinter.getInstance();
        FileOutputStream fileOutputStream = new FileOutputStream(new File("printer.txt"));
        ObjectOutputStream oos = new ObjectOutputStream(fileOutputStream);
        oos.writeObject(printer);

        // 将printer.txt的对象反序列化到Java中
        FileInputStream inputStream = new FileInputStream("printer.txt");
        ObjectInputStream oosInput = new ObjectInputStream(inputStream);
        System.out.println(printer == oosInput.readObject());

    }
}
// 运行结果
false

那有没有安全的单例呢,有!来看看枚举单例

package org.singleton;

public enum EnumPrinter {

        INSTANCE;

        public static EnumPrinter getInstance() {
            return INSTANCE;
        }

}

package org.singleton;

public class Test {
    public static void main(String[] args) throws Exception {
        // 将EnumPrinter2对象序列化和到printer.txt
        EnumPrinter printer = EnumPrinter.getInstance();
        FileOutputStream fileOutputStream = new FileOutputStream("printer.txt");
        ObjectOutputStream oos = new ObjectOutputStream(fileOutputStream);
        oos.writeObject(printer);

        // 将printer.txt的对象反序列化到Java中
        FileInputStream inputStream = new FileInputStream("printer.txt");
        ObjectInputStream oosInput = new ObjectInputStream(inputStream);
        System.out.println(printer == oosInput.readObject());
    }
}

不要奇怪,代码就是这样写。为什么可以保持单例呢?这要从readObject开始分析,readObject会调用ObjectInputStream.readEnum()方法

image-20230216165627222

通过Enum.valueOf方法获取实例,在enum中有个enumConstantDirectory方法,该方法可以获取存放enum类型的常量Map

image-20230216165744198

如果有则返回,没有便会调用getEnumConstantsShared方法返回名为enumConstants的数组,将数组组装为Map

image-20230216170333960

流程图如下

image-20230216171508162

所以Java在运行时,维护了一个枚举类型的对象的Map,以此来实现单例。

Java Enum 类型的语法结构尽管和 java 类的语法不一样,应该说差别比较大。但是经过编译器编译之后产生的是一个 class 文件。该 class 文件经过反编译可以看到实际上是生成了一个类,该类继承了 java.lang.Enum类,而且INSTANCE是final,类中有values()方法,这就解释了前面反射调用EnumPrinter的values方法

# 在class目录执行javap命令
javap EnumPrinter.class
# 输出如下
Compiled from "EnumPrinter.java"
public final class org.singleton.EnumPrinter extends java.lang.Enum<org.singleton.EnumPrinter> {
  public static final org.singleton.EnumPrinter INSTANCE;
  public static org.singleton.EnumPrinter[] values();
  public static org.singleton.EnumPrinter valueOf(java.lang.String);
  public static org.singleton.EnumPrinter getInstance();
  static {};
}

三、Struts2单例模式的应用

在Struts2中非常典型的关于单例模式的对比就是Action与Servlet的对比,先说结论:

  • Action是多例
  • Servlet是单例
3.1 Action的分析

Action中每次请求产生一个Action的对象。原因是:struts2的Action中包含数据,例如你在页面填写的数据就会包含在Action的成员变量里面。如果Action是单实例的话,这些数据在多线程的环境下就会相互影响。接下来从代码上分析下:

从web.xml中定义的FilterDispatcher类开始分析,代码跳转到Dispatcher.serviceAction方法,通过ActionProxy通过DefaultActionInvocation的createAction来实例化Action类,最终通过ObjectFactory.buildBean方法实例化Action类。

image-20230216225926588

ObjectFactory.buildBean方法实例化Action类,所以Action是多例,而不是单例

image-20230217102552530

通过查看生成的Action对象的ID也可以看出每次请求,创建的Action的ID是不同的,如下2个图

image-20230216222833879

image-20230216222920979

3.2 Servlet的分析

首先,Servlet用的是单例模式,从形式上看属于“饿汉式模式”

image-20230217112319596

Servlet本质是也只是一个普通的Java类,可以创建很多对象,只不过在loadServlet方法中控制只允许创建一个实例,loadServlet方法中调用instanceManager.newInstance实现实例的创建

image-20230217112843264

在Servlet规范中,对于Servlet单例与多例定义如下:

“Deployment Descriptor”, controls how the servlet container provides instances of the servlet.For a servlet not hosted in a distributed environment (the default), the servlet container must use only one instance per servlet declaration. However, for a servlet implementing the SingleThreadModel interface, the servlet container may instantiate multiple instances to handle a heavy request load and serialize requests to a particular instance.

含义是如果一个Servlet没有被部署在分布式的环境中,一般web.xml中声明的一个Servlet只对应一个实例。而如果一个Servlet实现了SingleThreadModel接口,就会被初始化多个实例。但一般不建议实现,因为每个用户的请求都会创建一个Servlet实例,系统开销非常大。我们来看下SingleThreadModel如何实现的多Servlet实例

StandardWrapper.allocate()方法如下:

if (!singleThreadModel) {
    // Load and initialize our instance if necessary
    if (instance == null) {
        // 省略代码
        instance = loadServlet();
        // 省略代码
    }
    // 省略代码
    if (singleThreadModel) {
        if (newInstance) {
            // Have to do this outside of the sync above to prevent a
            // possible deadlock
            synchronized (instancePool) {
                instancePool.push(instance);
                nInstances++;
            }
        }
    }

  synchronized (instancePool) {

      while (countAllocated.get() >= nInstances) {
          // Allocate a new instance if possible, or else wait
          if (nInstances < maxInstances) {
              try {
                  // 可以看到通过instancePool来保存所有的Servlet实例,loadServlet会返回一个实例
                  instancePool.push(loadServlet());
                  nInstances++;
              } catch (ServletException e) {
                  throw e;
              } catch (Throwable e) {
                  ExceptionUtils.handleThrowable(e);
                  throw new ServletException
                      (sm.getString("standardWrapper.allocate"), e);
              }
          } else {
              try {
                  instancePool.wait();
              } catch (InterruptedException e) {
                  // Ignore
              }
          }
      }
      if (log.isTraceEnabled())
          log.trace("  Returning allocated STM instance");
      countAllocated.incrementAndGet();
      return instancePool.pop();
	}
// 省略代码

通过阅读代码,可以发现instancePool中维护了所有的Servlet实例,intancePool是一个Stack,在loadServlet方法中创建

image-20230217115456810

Tomcat创建的Servlet会被Struts2进一步封装为HttpServlet,同样的也是单例模式,下图为简易流程图

image-20230217111629243

以上便是Java中单例模式的使用。