设计模式 - 单例模式

329

一、原始创建

需求

客户端需要创建相应的商品,同时这个商品能够重复利用,只需要创建一个就够了

创建方式

抽象商品:

public class SingletonRaw {
    public void work(){
        System.out.println("Unique product is working...");
    }
}

客户端创建:

public class Client {
    public static void main(String[] args) {
        boolean isCreate = false;
        SingletonRaw uniqueObj;
        if (!isCreate) {
            uniqueObj = new SingletonRaw();
        } else {
            throw new RuntimeException("UniqueObj is created");
        }

        // do something to uniqueObj ...
    }
}

问题

显而易见,客户端的简单判断根本无法保证 SingletonRaw 只被创建一次,因为根本没法保证 isCreate 能否被修改

问题抽象

问题的根本是一个类的构造器可以被无限调用,换句话说, 由于构造器是 public 的,我们可以一直实例化该类的对象,无法保证单例

二、懒汉式

创建方式

将商品修改为单例类:

public class Singleton1 {

    private static Singleton1 uniqueObj;

    private Singleton1() {

    }

    public static Singleton1 getInstance() {
        if (uniqueObj == null) {
            uniqueObj = new Singleton1();
        }
        return uniqueObj;
    }

}

在客户端创建单例对象:

public class Singleton1Client {
    public static void main(String[] args) {
        
        Singleton1 uniqueObj = Singleton1.getInstance();

        // do something to uniqueObj ...
    }
}

解决

  1. 单例模式首先是构造器私有化,因为 private 修饰的,保证了外部无法 new 我们类的对象,只能在类内部使用
  2. 提供了一个公有的静态方法,可以返回类的一个对象,利用静态,我们可以不用实例化对像就得到该对象

PS 1: 懒汉式是因为要使用方法时才 new ,而不是类创建的时候立即就 new,“懒” 的意思就是这样

PS 2: 返回的对象为什么要是静态的?这是因为静态方法只能使用静态字段,对象静态是 “被迫” 的

问题

懒汉式的问题体现在多线程的环境下有线程不安全的问题存在,因为多个线程同时访问创建对象的方法,同时发现它不为空,便同时对它创建了,内存里还是会存在多个该对象的实例

问题抽象

懒汉式的单例创建具有线程不安全的问题,说到底是在创建时 unique == null 这一句对多线程来说是没效果的

三、饿汉式

创建方式

将商品修改为单例类:

public class Singleton2 {

    private static Singleton2 uniqueObj = new Singleton2();
    
    private Singleton2(){

    }

    public static Singleton2 getInstance(){
        return uniqueObj;
    }

}

在客户端创建单例对象:

public class Singleton2Client {
    public static void main(String[] args) {
        
        Singleton2 uniqueObj = Singleton2.getInstance();

         // do something to uniqueObj ...
    }
}

解决

解决了懒汉式模式的线程不安全的问题,可以看到一旦类装在进内存的时候,单例对象马上就创建了,创建单例对象的方法只不过返回该类的对象而已

这种方法放弃要使用时才创建的步骤,直接载入内存,没线程调用时就马上创建,保证对象是单例的,所以后面线程再使用时,直接用就好了

问题

类一载入内存,就马上创建了,但也不代表该单例对象到底要不要用,如果不用就会造成资源浪费了,假设有很多个单例类,搞这种方式,那资源消耗就很大了

PS :类在 JVM 内存里加载步骤是 加载 - 验证 - 准备 - 解析 - 初始化 这五个步骤,我们的静态变量,也就是所谓的 static 修饰的变量,在准备这个阶段分配内存,设置初始化的值,使用的方法区的内存

问题抽象

饿汉式的单例模式具有资源浪费的潜在问题,说到底是类加载就实例化该对象造成的,那么又得回到懒汉式找方法了,如何创建了又保证线程安全

四、饿汉式的重量级锁形式

创建方式

将商品修改为单例类:

public class Singleton1 {

    private static Singleton1 uniqueObj;

    private Singleton1() {

    }

    public static synchronized Singleton1 getInstance() {
        if (uniqueObj == null) {
            uniqueObj = new Singleton1();
        }
        return uniqueObj;
    }

}

在客户端创建单例对象:

public class Singleton1Client {
    public static void main(String[] args) {
        
        Singleton1 uniqueObj = Singleton1.getInstance();

        // do something to uniqueObj ...
    }
}

解决

重量级锁 synchronized 保证每次进入创建方法的只有一个线程,完成创建后,那么后面一个进入该方法的线程肯定就知道它不为空了,保证了懒汉式线程安全

问题

重量级锁 synchronized 使得多个线程都在等待占用该创建方法的锁的线程,即使它已经实例化过了,像线程 1 实例化了该对象,线程 2 到 线程 10 常理来看,直接进方法取得单例对象就好了,此时由于重量级锁,它们连取对象都要“排队”等待了

问题抽象

重量级锁版本的懒汉式,具有阻塞线程过长的问题,一旦线程多起来,那总体运行时间太慢了

五、饿汉式的双重校验锁形式

创建方式

将商品修改为单例类:

public class Singleton1 {
    private static volatile Singleton1 uniqueObj;

    private Singleton1(){

    }

    public static Singleton1 getInstance(){
        
        if(uniqueObj == null){
            synchronized(Singleton1.class){
                if(uniqueObj == null){
                    uniqueObj = new Singleton1();
                }
            }
        }

        return uniqueObj;
    }
}

在客户端创建单例对象:

public class Singleton1Client {
    public static void main(String[] args) {
        
        Singleton1 uniqueObj = Singleton1.getInstance();

        // do something to uniqueObj ...
    }
}

解决

只对创建方法的实例化部分的代码加锁,解决了重量级锁多个线程连简单取得单例对象都要 “排队” 导致时间过长的问题,可以发现,一旦单例对象已经实例化过了,就不需要获取锁了

双重校验问题

同时这里可能会想为什么要两个 if ,一个 if 判断 null 不可以么?

可能可以这样想:

    synchronized(Singleton1.class){
        if(uniqueObj == null){
            uniqueObj = new Singleton1();
        }
    }

这个想法是错误的,这样和我们上面 synchronized 加在方法名上有啥区别?每个线程是进来方法了,但不得每个线程加锁阻塞一遍

可能还会这么想:

    if(uniqueObj == null){
        synchronized(Singleton1.class){
                uniqueObj = new Singleton1();
        }
    }

这个想法也是错误的,因为多个线程是可能同时得知单例对象为空,同时被阻塞,等着要创建该单例对象。一旦第一个创建完了,后面一堆线程排队等着的,获得了锁,进入了 synchronized 代码块里,便再次对单例对象多次实例化了!

总结一下,双重校验,是校验单例对象是否为 null

  1. 第一个 if 校验对象是否为 null,是为了检查是否有必要加锁
  2. 第二个 if 校验对象是否为 null,是为了检查是否已经又线程创建过了

volatile 问题

好像双重校验问题看起来已经很完美了,那么为何我们的单例对象还要用 volatile 修饰?

回想一下 volatile 有何作用:

禁止指令重排,保证变量的有序性

利用内存屏障,保证变量的可见性

我们要的正是有序性的保证,uniqueObj = new Singleton1(); 这句代码写的时候的确就一句话,但在 JVM 内存中,它是以指令的形式执行的

我们可以对 Singleton1.java 反编译,如下:

先再回顾一下重量锁有何作用:

保证有序性 - (无法禁止指令重排的”有序“)

保证可见性

保证原子性

其中圈出来的地方就是 synchronized 代码块的区域,区域中可以发现对单例对象的初始化是被分成几条指令的。编译器会进行指令重排,来优化代码执行效率。这在单线程没问题,但多线程如果还重排,就完蛋了。因为指令重排使得 new 实例化操作,可能会返回一个初始化了的对象引用,但它却没被分配内存

那为什么上一个加在方法上的重量锁的懒汉式没用 volatile 修饰单例对象不会有问题?

这和重量锁保证的有序性有关,synchronized 保证的“有序性”就是通过锁的方式,让一个时间段只能有一个线程访问代码,多线程仿佛单线程一般,所以属于天然有序的那种有序,但内部依旧有可能发生指令重排,但如果规定一个时间段内,对创建方法的访问只有一个线程,那么这个方法就仿佛是一个线程执行完的,无论是否发生指令重排,都不影响

所以,方法上的重量锁和方法里的代码块的重量锁区别就是一个线程和多个线程访问进入一个方法操作的区别,当多个线程在方法运行时要禁止指令重排保证有序性

问题

线程安全,资源损耗问题也有解决,但无法防止反射攻击

问题抽象

通过反射的 setAccessible(true) 可以用私有的构造器,这是反射带来的漏洞,上面所有的方式都有这种问题

六、饿汉式的静态内部类形式

创建方式

将商品修改为单例类:

public class Singleton1 {
    
    private Singleton1(){

    }

    private static class getUniqueObj{
        private static final Singleton1 uniqueObj = new Singleton1();
    }

    public static Singleton1 getInstance(){
        return getUniqueObj.uniqueObj;
    }

}

在客户端创建单例对象:

public class Singleton1Client {
    public static void main(String[] args) {
        
        Singleton1 uniqueObj = Singleton1.getInstance();

        // do something to uniqueObj ...
    }
}

解决

静态内部类在类加载的时候不会马上载入内存,而是在调用创建方法的时候才载入,所以它是延迟加载的。同时 JVM 对静态变量 new 天生支持线程安全,所以,它也解决了线程不安全和资源损耗问题

问题

同样的,这种方法也无法防止反射攻击

问题抽象

通过反射的 setAccessible(true) 可以用私有的构造器,这是反射带来的漏洞

七、枚举

创建方式

抽象单例类:

public enum Singleton1 {

    uniqueObj;

    // some properties & method here

}

客户端调用单例对象:

public class Singleton1Client {
    public static void main(String[] args) {
        
        Singleton1 uniqueObj = Singleton1.uniqueObj;

        // do something to uniqueObj ...
    }
}

解决

JVM 层次保证枚举是单例的,而且可以防止反射攻击

八、总结

这一次,参考 CyC 2018 的博客,主要学习了六种单例模式的实现,并从 JMM 和 JVM 的一些特性去进行相关分析,对他的博客进行分析和扩展,其实一般情况下,双重检验锁形式和静态内部类的单例就很够用了

或许对于单例模式,印象就是懒汉式和饿汉式。但从今天起,做一个能写双重校验锁的懒汉式单例的猿吧!

九、参考