你真的了解单例模式吗?:本文的深度触及JVM底层

单例模式


本次简单的讲一下单例模式.在讲的过程中,笔者会尽量把这个模式讲清楚,讲详细,讲简单.同时也会给出目前使用单例模式的例子,并分析这些案例的实现方式.在这个过程中,你会发现,小小的单例模式里却有着很大的学问在.

单例模式是为了保证在一个jvm环境下,一个类仅有一个对象.一般来说,每讲到单例模式,大家都会想起各种实现方式(比如:懒汉式,饿汉式),这些实现方式,是通过代码的设计来强制保证的单例,可以称为强制性单例模式.当然,通过文档,通过编码约束,也可以认为是实现了一个类仅有一个对象的效果.

通常,项目中的具有连接功能的类(比如:数据库连接,网络连接,打印机的连接),具有配置功能的类,工具类,辅助系统类,会需要使用单例模式.这些类大多是创建和销毁需要消耗大量的系统资源,或者不需要创建多个对象(对象之间无差别).

在Java中,创建单例模式都有两个必不可少的步骤

  1. 私有化类的所有构造函数,以阻止其他代码在该类的外界去创建对象,
  2. 提供获取该对象的静态方法,以返回该类唯一的对象引用.

基于以上两条,可以简单写出三种单例模式:

第一种写法:通过类的静态变量来持有一个该类的对象的引用,同时使用final关键字来阻止其被再次赋值.

public class Singleton1 {
    private static final Singleton1 INSTANCE = new Singleton1();
 private Singleton1(){}
    public static Singleton1 getInstance(){
        return INSTANCE;
  }
}

第二种写法:这种方法和第一种方法大同小异,同样是使用静态变量维护该类的引用,但将对象创建的放在了静态代码块中.

public class Singleton2 {
    private static final Singleton2 INSTANCE ;
    static {
        INSTANCE=new Singleton2();
    }
    private Singleton2(){}
    public static Singleton2 getInstance(){
        return INSTANCE;
    }
}

第三种写法:使用静态变量维持类的对象的引用(这种情况下,由于java语法的限制,将无法使用final关键字),在获取对象的方法里对对象进行判断和创建.

public class Singleton3 {
    private static Singleton3 instance;
 private Singleton3(){}

    public static Singleton3 getInstance() {
        if(null==instance){
            instance=new Singleton3();
  }
        return instance;
  }
}

前两种,将对象的创建时机放在了类的初始化阶段,后面一种,则将对象的创建放在了类的使用阶段.前两种被称为饿汉式,第三种被称为懒汉式.饿汉式的优点是简单易懂,缺点是没有达到懒加载的效果。如果从始至终从未使用过这个实例,就会比较浪费连接资源和内存.

但懒汉式也并不复杂,可以起到懒加载的效果.于是,读者可能更愿意使用懒汉式,或者其变种(比如具有双重检查锁的懒汉式).你的理由是,节省内存,懒加载,而且还很酷.

但事实又是如何呢?为了弄清楚这两种单例方式,需要简单回忆一下类的生命周期.

  1. 类的加载:将类的字节码文件(.class文件)从硬盘载入方法区的过程
  2. 类的连接:该过程由三个部分组成:验证、准备和解析,
  3. 类的初始化:将静态变量赋值,执行的顺序就是:

父类静态变量->静态代码块->子类静态变量->子类静态代码块,饿汉式的对象创建处于这个阶段

  1. 类的使用,如类的实例化,懒汉式的对象创建处于这个阶段,new关键字可以触发该生命周期
  2. 类卸载

那么问题来了,什么时候会对类进行初始化呢?根据类的五个生命周期阶段,我们只需要验证在创建对象之前的那些操作能够触发类的初始化就行.笔者使用jdk1.8,默认配置,进行了简单的实验.首先在构造方法里添加打印语句,打印“init”,然后再添加一个静态方法和一个静态变量.对Singleton1进行检验.

public class Singleton1 {
  private static final Singleton1 INSTANCE = new Singleton1();
  //添加打印语句
  private Singleton1(){
        System.out.println("init");
  }
  public static Singleton1 getInstance(){
        return INSTANCE;
  }
    //静态方法
  public static final void otherMethod(){
    }
    //静态变量
  public static final int staticFiled=0; 
 }

测试1:仅仅进行声明

//测试1: 
public class Test {
    public static void main(String[] args) {
        System.out.println("-------start-------");
        Singleton1 singleton1 = null;
        if(null==singleton1){
            System.out.println("singleton1 is null");
  }
        System.out.println("-------end-------");
  }

    /* out:
     * -------start------- 
     * singleton1 is null 
     * -------end--------- 
     */ 
}

从输出上看,仅仅声明,不会触发类的初始化阶段.
测试2:调用类的静态变量

//测试2: 
public class Test {
    public static void main(String[] args) {
      System.out.println("-------start-------");
      System.out.println(Singleton1.staticFiled);
      System.out.println("-------end---------");
  }

    /* out:
     *-------start-------
     *0
     *-------end--------- 
     */ }

从输出上看,仅仅调用类的静态变量,不会触发类的初始化阶段.
测试3:调用类的静态方法

//测试3 
public class Test {
    public static void main(String[] args) {
        System.out.println("-------start-------");
        Singleton1.otherMethod();
        System.out.println("-------end-------");
  }

    /* out:
     *-------start------- 
     *   init 
     *-------end------- 
     */ 
}

从输出上看,仅仅调用类的静态方法,会触发类的初始化阶段.

通过上面的三个例子,可以看出饿汉式,在某种情况下,也是可以表现出懒加载的效果,并且饿汉式简单,而且不会产生线程安全的问题,在某些情况下是可以代替懒汉式的.并且随着现在硬件的发展,懒汉式的节省内存的优点也可以慢慢的忽略不计了.

在设计上,懒汉式要优于饿汉式,在使用上,能够恰好解决问题的就是好的设计.

在多线程的情况下,懒汉式会有一定修改.当两个线程在if(null==instance)语句阻塞的时候,可能由两个线程进入创建实例,从而返回了两个对象,这是一个概率性的问题,一但出现,排查和定位问题都具有运气性.对此,我们可以加锁,以保证每次仅有一个线程处于getInstance()方法中,从而保证了线程一致性.多线程下的单例模式可以为

public class Singleton4 {
    private static Singleton4 instance;
 private Singleton4(){}

    public static synchronized Singleton4  getInstance() {
        if(null==instance){
            instance=new Singleton4();
  }
        return instance;
  }
}

Singleton4相对于Singleton3,只是在getInstance方法上加了一个锁(静态方法以Singleton4.class对象为锁,非静态方法锁以this对象为锁).从而保证了,每次仅有一个线程进入内部的代码快.试想,一个项目中若有100处获取实例,那么jvm就会有100次进行加锁,放锁的操作,但仅有一次实现了对对象的创建,jvm加锁放锁的操作都需要对对象头进行读写操作,每一次的操作都比较耗费资源.所以该方式实现的单例的模式的效率并不高.instance不为null的概率非常非常高,但又同时要兼容多个线程下的安全性,可以在外面再加一层的判断.可以写成下面的形式

public class Singleton4 {
    private static Singleton4 instance;
    private Singleton4(){}

    private static synchronized void doGetInstance() {
        if(null==instance){
            instance=new Singleton4();
        }
    }
    public static synchronized Singleton4 getInstance(){
        if(null==instance){
            doGetInstance();
        }
        return instance;
  }

简化一下代码,可以写成如下的形式:

public class Singleton5 {

    private static Singleton5 instance;  
    private Singleton5() {
    }

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

上面的这种形式,也就是所谓的双重检查的单例模式写法,如果多个线程同时了通过了第一次检查,并且其中一个线程首先通过了第二次检查并实例化了对象,那么剩余通过了第一次检查的线程就不会再去实例化对象.即提升了效率,又可以显得很牛逼.

但上面的形式还是有些的问题的.还记得上面说的类的生命周期吗?这里再详细展开说明类的连接过程.

类的连接过程又分为 验证阶段,准备阶段解析阶段.验证阶段不用多讲,在这个阶段,jvm对类的合法性进行验证(很多基于jvm的语言都有自己的工具生成java字节码,比如clojure,kotlin等).

准备阶段,则是将类里的静态变量赋予默认值,解析阶段则是将符号引用转换为直接引用.同时如果一个类被直接引用,就会触发类的初始化(处于连接过程的下一个过程).总的来说,一个类在碰到new关键字的时候,一般经历以下三个顺序:

  1. 开辟空间,
  2. 符号引用改空间,并在空间内对类进行初始化操作,
  3. 将符合引用转为直接引用(这个时候if(null==instance)返回false).

可在实际的情况中,为了降低cpu的闲置时间,jvm往往对指令进行重新排序以形成指令流水线.也就是说以三个部署可能是乱序的,可能为

  1. 开辟空间,
  2. 转为直接引用(这个时候if(null=instance)返回false)),
  3. 初始化类.

因此上面的双重检查机制就会出现问题:可能返回一个未被完全初始化的类.

volatile的作用

  1. 可见性:可以将线程和堆内存理解比喻为计算机的cpu的核与主内存.cpu的每一个核心都有自己的缓存,常用数据首先写入自己的缓存,然后再写入主内存.这样会导致最新的数据不能及时的在主内存中存在,但其却能极大的提升效率.同样的jvm中每一个线程也有自己的内存区域.对变量(不是方法中的临时变量,临时变量存在于jvm栈)使用volatile修饰,可以强制将每一次的读写都写入到堆中,实现了各个线程都能共享的最新数据的效果.
  2. 禁止指令重排序优化:由上面的讨论可指,被volatile修饰的变量,在赋值的结尾会插入一个内存屏障(不要被这个名词吓到了),从而防止指令重排序.

volatile增强了数据的一致性,但降低了速率.
由此可知,上面的写法需要进行稍微的修改:

public class Singleton5 {

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

讲到这里,可以再讲一下Singleton1(饿汉式)的写法,上面说过,这种写法的问题是在于没有达到懒加载的效果.但其具有清晰的结构,对线程友好的特点.如果能够在其结构上进行简单的改造,使其具有懒加载的效果,那就完美了!

静态嵌套类的使用用

  • 静态嵌套类 : 是一种在类之外声明的嵌套类,由于是静态的,所以不经过初始化,就可以通过类名直接调用.
  • 内部类 : 即该类作为另一个类的成员,因此只有引用另一个类,才能创建这个类.

通过静态嵌套类,便可以实现对饿汉式进行懒化的效果.

public class Singleton7 {
    private Singleton7(){}
    private static  class SingletonHolder {
        private static  Singleton7 INSTANCE = new Singleton7();
  }
    public static final Singleton7 getInstance() {
        return SingletonHolder.INSTANCE;
  }
}

对比Singleton1,这里做了什么改变?仅仅是唯一的一个类的对象被静态嵌套类包裹了一下.要分析这种方式有没有实现懒加载,就要分析一下语句new Singleton7();是在什么时候被调用了.
当使用javac 进行编译Singleton7时,会生成三个class文件:

  1. Singleton7$1.class
  2. Singleton7$SingletonHolder.class
  3. Singleton7.class

第一个文件可以忽略,是一个空壳文件(读者可以通过反编译插件查看源代码).可以看到静态嵌套类是单独作为一个class存在的,而其中创建对象的逻辑位于嵌套类中,jvm读取嵌套类的字节码以后才能创建对象.从硬盘中读取class文件,再在内存中分配空间,是一件费事费力的工作,所以jvm选择按需加载,没有必要加载的就不加载,没必要分配就不分配.

所以Singleton7从连接和初始化的时候,不会去读取静态嵌套类的class文件,当然也就不能创建Singleton7对象.在调用getInstance时,jvm不得不去加载字节码文件,但不一定需要对类进行初始化.所以结论就是:用静态嵌套类包裹对象的创建过程,可以实现懒加载的同时,又不会让静态嵌套类进行初始化!下面开始实验验证.首先对Singleton7进行修改,加入日志:

public class Singleton7 {
    private Singleton7(){
        System.out.println("Singleton7");
  }
    private static final class SingletonHolder {
        SingletonHolder(){
            System.out.println("SingletonHolder");
  }
        private static final Singleton7 INSTANCE = new Singleton7();
  }
    public static Singleton7 getInstance() {
        return SingletonHolder.INSTANCE;
  }
}

测试类:

public class Test {
    public static void main(String[] args) {
        System.out.println("-------start-------");
        Singleton7.getInstance();
        System.out.println("-------end---------");
  }
    /* out:
     *--------start------     
     *Singleton7 
     *-------end--------- 
     */ 
}

没有输出SingletonHolder!!!,这个说明了什么?
到这里似乎就是要大结局了:我们似乎已经严格且完美实现了一个类在一个jvm环境下仅有一个对象了!!!但事实真是如此吗?

利用JAVA反射破坏单例模式

上面的单例,最主要的一步是将构造方法私有化,从而外界无法new对象.但java的反射可以强制访问private修饰的变量,方法,构造函数!所以:

//测试3 public class Test {
    public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
        Class singleton7Class = Singleton7.class;
  Constructor constructor= singleton7Class.getDeclaredConstructor();
  //这里有个万恶的方法
  constructor.setAccessible(true);
  Singleton7 singleton1=constructor.newInstance();
  Singleton7 singleton2=constructor.newInstance();
  }
    /**out
     * Singleton7 
     * Singleton7 
     */ 
}

看来我们的单例并不是安全的.在java中,有四种创建对象的方式

方式 说明
new 需要调用构造函数
反射 需要调用构造函数,免疫一切访问权限的限制(public,private等)
clone 需要实现Cloneable接口,又分深复制,浅复制
序列化 1.将对象保存在硬盘中 2.通过网络传输对象,需要实现Serializable

而上面介绍的各种单例模式,是不能抵抗反射,clone,序列化的破坏的.

现在考虑如何保护单例模式.对于clone和序列化,可以在设计的过程中不直接或者间接的去实现Cloneable和Serializable接口即可.对于反射,通常来说,使用普通类难以避免(可以通过在调用第二次构造函数的方式进行避免,但这并不是完全之策,详情可以自行搜索相关内容).

枚举类

枚举类是Java 5中新增特性的一部分,它是一种特殊的数据类型,之所以特殊是因为它既是一种类(class)类型却又比类类型多了些特殊的约束.枚举类能够实现接口,但不能继承类,枚举类使用enum定义后在编译时就默认继承了java.lang.Enum类,而不是普通的继承Object类.枚举类会默认实现Serializable和Comparable两个接口,且采用enum声明后,该类会被编译器加上final声明,故该类是无法继承的.枚举类的内部定义的枚举值就是该类的实例.除此之外,枚举类和普通类一致.因此可以利用枚举类来实现一个单例模式

public enum Singleton8
{
    INSTANCE;
  //该方法可有可无
  public static Singleton8 getInstance(){
        return INSTANCE;
  }
    //.....other method 
}

这个就怎么能够防止反射破坏类呢?可以看一下下面的代码片段

public T newInstance(Object ... initargs)
    throws InstantiationException, IllegalAccessException,
  IllegalArgumentException, InvocationTargetException
{
    if (!override) {
        if (!Reflection.quickCheckMemberAccess(clazz, modifiers)) {
            Class caller = Reflection.getCallerClass();
  checkAccess(caller, clazz, null, modifiers);
  }
    }
    //反射在通过newInstance创建对象时,会检查该类是否ENUM修饰,如果是则抛出异常,反射失败。
    if ((clazz.getModifiers() & Modifier.ENUM) != 0)
        throw new IllegalArgumentException("Cannot reflectively create enum objects");
  ConstructorAccessor ca = constructorAccessor; // read volatile
  if (ca == null) {
        ca = acquireConstructorAccessor();
  }
    @SuppressWarnings("unchecked")
    T inst = (T) ca.newInstance(initargs);
 return inst; }

上面截取的是java.lang.reflect.Constructor类的newInstance,可以看出,当当前类是枚举类型时,就会抛出异常,因此枚举类可以抗得住反射攻击!

既然枚举类默认实现了Serializable,那么就能够对枚举类进行序列化操作

public class Test2 {
    public static void main(String[] args) throws Exception{
        File objectFile =new File("Singleton8.javaObject");
        Singleton8 instance1=Singleton8.INSTANCE;
        Singleton8 instance2=null;    
        //序列化到本地
        ObjectOutputStream objectOutputStream = new ObjectOutputStream(new FileOutputStream(objectFile));
        objectOutputStream.writeObject(instance1);
        objectOutputStream.flush();    
        //反序列化到内存
        ObjectInputStream objectInputStream = new ObjectInputStream(new FileInputStream(objectFile));
        instance2= (Singleton8) objectInputStream.readObject();           objectInputStream.close();
        objectOutputStream.close();
        //true,说明两者引用着同一个对象
        System.out.println(Objects.equals(instance1,instance2));
  }
}

在复制操作上.枚举类直接继承java.lang.Enum类,而非Object类,无法实现复制操作.

到这里,单例模式的设计方式就告一段落了,下面再给出两个简单的小案例.

1.实现线程内的单例

对于实现线程内的单例,直观的做法是利用一个map来存储对象.其中key可以为线程的ID,value为每个线程下独有的对象.我们可以做的更好,可以用ThreadLocal来做线程的变量隔离!
线程级单例设计如下

public class Singleton9 {
    private Singleton9(){}
    private static final ThreadLocal threadHolder = new ThreadLocal<>(){
        @Override
  protected Singleton9 initialValue() {
            return new Singleton9();
  }
    };
 public static final Singleton9 getInstance(){
        return threadHolder.get();
  }
}

2.HttpServlet的单例多线程模式

Tomcat的Servlet在需要时被创建加载,以后的请求都将利用同一个Servlet对象.是一个典型的单例多线程模式,Tomcat里的StandardWrapper里的loadServlet可看的出来.对于单例多线程,注意以下问题

  1. 使用栈封闭来管理变量,将变量封闭到线程栈内,防止变量被其他线程污染(上面的说话过于装酷,其实就是避免使用实例变量)
  2. 使用要使用ThreadLocal对实例变量进行变量隔离
  3. 实现 SingleThreadModel 接口:实现SingleThreadModel的Servlet中的service方法将不会有两个线程被同时执行.

程序的设计模式,实用性大于理论性.所以,只要能够恰好的解决问题的设计模式就是好的设计模式.

笔者才疏学浅,上述内容只是个人的整理,请客观的阅读,对于错误的地方,还请读者能够及时给于指出和更正,欢迎一起讨论!

email:[email protected]

你可能感兴趣的:(设计模式,单例模式,java,jvm)