设计模式(一)—— 单例模式

1 简介

单例模式:保证一个类仅有一个实例,并提供一个能够访问它的全局访问点。这样,外界访问的就都是同一个对象,可以避免对象重复创建,节省资源,同时,也可以加快对象的访问速度。

如果想让一个类只能有一个对象,就不能随便去做 new 操作,所以构造方法要写成私有的,由单例类本身去创建这个唯一的实例。另外,还需要考虑多线程问题,不能因为多个线程,就创建出多个实例。

因此,单例模式不适用于变化的对象,如果同一类型对象在不同的场景发生变化,单例就会引起数据的错误。

单例模式的使用场景:

  • 在一个系统中,要求一个类有且仅有一个对象(整个项目需要一个共享访问点或共享数据)
  • 创建一个对象需要耗费的资源过多,比如访问 I/O 或者数据库等资源
  • 工具类对象

单例模式的通用结构图

Client 为客户端,Singleton 是单例类,通过调用 Singleton.getInstance() 来获取实例对象。

2 单例模式的写法

2.1 懒汉模式(线程不安全)
public class Singleton {

    private static Singleton instance;
    private Singleton() { }

    public static Singleton getInstance() {
        if (instance == null) { // 1
            instance = new Singleton();
        }
        return instance;
    }

}

之所以叫懒汉模式,是因为只有在调用 getInstance() 方法的时候才会创建单例对象,有点“懒惰”。

因为单例类要提供一个全局访问点,所以需要提供一个静态方法将自身的实例返回,即 getInstance 方法,因为在方法中使用了 instance 变量,所以 instance 也必须是静态的。

使用此种模式,全局唯一的实例是在第一次调用 getInstance 方法的时候进行创建,这样做可以避免创建无用的对象,节约了资源,但由于第一次加载时需要实例化,速度会稍慢一些。

在并发环境下,如果线程 1 和线程 2 同时调用 Singleton.getInstance() 方法,此时的 instance == null 都是成立,这样一来,instance 就被创建了多次,出现多个对象,所以是线程不安全的:

public class TestThread extends Thread {
  @Override
  public void run() {
		// 如果是同一个实例,hashCode是相等的
    System.out.println("当前线程名" + Thread.currentThread().getName() + "," + Singleton.getInstance().hashCode());
  }

  public static void main(String[] args) {
    for (int i = 0; i < 10; i++) {
      new TestThread().start();
    }
  }
}
//当前线程名Thread-1,1492031778
//当前线程名Thread-9,1899165466
//当前线程名Thread-0,1492031778
//当前线程名Thread-3,1492031778
//当前线程名Thread-7,1899165466
//当前线程名Thread-4,1492031778
//当前线程名Thread-6,1492031778
//当前线程名Thread-8,1015985986
//当前线程名Thread-2,1492031778
//当前线程名Thread-5,1492031778
2.2 懒汉模式(线程安全)
public class Singleton {

    private static Singleton instance;
    private Singleton() {}

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

}

// 或者
public class Singleton {

    private static Singleton instance;
    private Singleton() { }

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

        return instance;
    }
}

synchronized 是为了保证多线程安全的,这种写法能够在多线程中很好的工作。但是每次调用 getInstance 方法时都需要进行同步,这会造成不必要的同步开销,而且大部分时候用不到同步,所以,不建议使用这种模式。

2.3 双重检查模式(DCL Double Check Lock)
public class Singleton {

    private volatile static Singleton instance;

    private Singleton() {}

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

DCL 的安全性基于 synchronized 以及 volatile 防止指令重排的保证。

首先判断 instance == null 是否成立,再进行加锁操作,可以避免不必要的同步开销。

为了防止 new Singleton() 被多次执行,因此在 new 操作之前加上 synchronized 同步锁,锁住整个类。进入 synchronized 之后,还要再做一次判空,因为当两个线程同时访问的时候,线程 A 构建完对象,线程 B 也同步了最初的判空验证,不做第二次判空的话,线程 B 还是会再次构建 instance 对象。

为什么还要使用 volatile?

这里涉及到 JVM 指令重排, 对于 instance = new Singleton() 可能会被编译器编译成如下 JVM 指令:

memory = allocate(); // 1. 分配对象的内存空间
ctorInstance(memory); // 2. 初始化对象
instance = memory; // 3. 设置instance指向刚分配的内存地址

当这些指令顺序并非一成不变,有可能会经过 JVM 和 CPU 的优化,指令重排成下面的顺序:

memory = allocate(); // 1. 分配对象的内存空间
instance = memory; // 3. 设置instance指向刚分配的内存地址
ctorInstance(memory); // 2. 初始化对象

当线程 A 执行完 1,3 时,instance 对象还没有完成初始化,但已经不再指向 null。此时如果线程 B 抢占 CPU 资源,执行 if(instance == null) 的结果会是 false,从而返回一个没有初始化完成的 instance 对象。

如何避免这一情况呢?需要在 instance 对象前面加上一个修饰符 volatile。volatile 修饰符阻止了变量访问前后的指令重排,保证了指令执行顺序。

2.4 饿汉模式
public class Singleton {

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

}

静态变量 instance 是在类加载时完成的初始化,虽然会导致类加载较慢,但是获取对象的速度快。这种方式基于类加载机制,避免了多线程的同步问题。 在类加载时就完成实例化,没有达到懒加载的效果。如果从始至终未使用过这个实例,则会造成内存的浪费。

饿汉模式中的 getInstance() 方法没有使用 synchronized 关键字,这是因为饿汉模式的线程安全不是依靠 synchronized 来保证的,而是 JVM。 在项目编译阶段,这个实例就已经创建好了,当其他地方再次获取类实例时,直接将类实例返回即可,所以不存在线程安全问题。

饿汉模式与懒汉模式:instance 时 Singleton 类的静态成员,也就是单例对象,它的初始值可以写成 null,也可以写成 new Singleton()。如果单例初始值是 null,还未构建,这个写法属于单例模式当中的懒汉模式。如果单例对象已开始就被 new Singleton() 主动构建,则不在需要判空操作,这种写法属于饿汉模式。

Kotlin 中的单例:

object Singleton {
}

将代码编译成 Java 代码:

public final class Singleton {
   @NotNull
   public static final Singleton INSTANCE;

   private Singleton() {
   }

   static {
      Singleton var0 = new Singleton();
      INSTANCE = var0;
   }
}

可以看到就是 Java 中的饿汉模式。使用:

public class TestThread extends Thread {
  @Override
  public void run() {
    System.out.println("当前线程名:" + Thread.currentThread().getName() + ", " + Singleton.INSTANCE.hashCode());
  }

  public static void main(String[] args) {
    for (int i = 0; i < 10; i++) {
      new TestThread().start();
    }
  }
}
2.5 静态内部类单例模式
public class Singleton {

  private Singleton() {
  }

  public static Singleton getInstance() {
    return SingleHolder.sInstance;
  }

  private static class SingleHolder {
    private static Singleton sInstance = new Singleton();
  }


  public static void main(String[] args) {
    Singleton singleton1 = Singleton.getInstance();
    Singleton singleton2 = Singleton.getInstance();
    Singleton singleton3 = Singleton.getInstance();
    System.out.println(singleton1.toString());
    System.out.println(singleton2.toString());
    System.out.println(singleton3.toString());
  }
}

//com.example.myapplication.Singleton@2503dbd3
//com.example.myapplication.Singleton@2503dbd3
//com.example.myapplication.Singleton@2503dbd3

static 只能修饰内部类,普通类是不能被声明成静态的。静态内部类在调用其方法、静态变量,静态域的时候会被加载。因此在第一次加载 Singleton 类时并不会初始化 sInstance,只有第一次调用 getInstance 方法时,虚拟机才会加载 SingletonHolder 并初始化 sInstance。这样通过 JVM 的加载机制来保证线程安全,也能保证 Singleton 类的唯一性。所以,推荐使用静态内部类单例模式。

静态内部类比双重检查锁定、懒汉模式(线程安全)和饿汉模式都要好,既实现了线程安全又避免了同步带来的性能影响。 从外部无法访问静态内部类 SingletonHolder,只有当调用 getInstance() 的时候,才能得到 sInstance。

静态内部类的实现方式虽然好,但是也存在着单例模式共同的问题:无法防止利用反射来重复构建对象。

通过反射来打破单例模式的约束:

// 获得单例的构造器
Constructor con = Singleton.class.getDeclaredConstructor();
// 将构造器设置为可访问
con.setAccessible(true);
// 构造两个不同的对象
Singleton singleton1 = (Singleton) con.newInstance();
Singleton singleton2 = (Singleton) con.newInstance();
// 验证是否是不同对象
Log.e("CAH", "" + singleton1.equals(singleton2)); // false
2.6 枚举单例
public enum Singleton {
    INSTANCE;
    public void doSomeThing() {
    }
}

有了 enum 语法糖,JVM 会组织反射获取枚举类的私有构造方法。

Constructor con = Singleton.class.getDeclaredConstructor();
con.setAccessible(true); // 抛出异常
Singleton singleton1 = (Singleton) con.newInstance();
Singleton singleton2 = (Singleton) con.newInstance();
Log.e("CAH", "" + singleton1.equals(singleton2));

使用枚举实现的单例模式不仅能够防止反射重复构造对象,而且还可以保证线程安全。不过这种方式也有唯一的缺点,就是它并非使用懒加载,其单例对象是在枚举类被加载的时候进行初始化的。

默认枚举实例的创建时线程安全的,并且在任何情况下都是单例。在上面讲的几种单例模式实现中,有一种情况下会重新创建对象,那就是反序列化;将一个单例实例对象写到磁盘再读回来,从而获得了一个实例。反序列化操作提供了 readResolve 方法,这个方法可以让开发人员控制对象的反序列化。在上面的几个方法中,如果要杜绝单例对象被反序列化时重新生成对象,必须加入以下方法:

private Object readResolve() throw ObjectStreamException {
  return singleton;
}

枚举单例的优点是简单,但是大部分应用开发很少用到枚举,其可读性不高。

对比:
单例模式对比

3 双重检查锁定与延迟初始化

在 Java 多线程程序中,有时候需要采用延迟初始化来降低初始化类和创建对象的开销。双重检查锁定是常见的延迟初始化技术,但它是一个错误的用法。 以下将分析双重检查锁定的错误根源,以及两种线程安全的延迟初始化方案。

3.1 双重检查锁定的由来

在 Java 程序中,有时候需要推迟一些高开销的对象初始化操作,并且只有在使用这些对象时才进行初始化。 但要正确实现线程安全的延迟初始化需要一些技巧,否则很容易出现问题。比如,下面是非线程安全的延迟初始化对象的示例代码:

class UnsafeLazyInitialization {
    private static Instance instance;

    public static Instance getInstance() {
        if (instance == null) { // A、B线程同时执行
            instance = new Instance(); 
        }
        return instance;
    }
}

在 UnsafeLazyInitialization 类中,如果线程 A 和线程 B 同时判断 instance == null,可能会都成立,这样就会创建两个 Instance 对象。因此,可以对 getInstance() 方法做同步处理来实现线程安全的延迟初始化。示例代码如下:

class SafeLazyInitialization {
  private static Instance instance;

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

由于对 getInstance() 方法做了同步处理,synchronized 将导致性能开销。如果 getInstance() 方法被多个线程频繁的调用,将会导致程序执行性能的下降。 反之,如果 getInstance() 方法不会被多个线程频繁的调用,那么这个延迟初始化方案将能提供令人满意的效果。

在早期的 JVM 中,synchronized(甚至是无竞争的 synchronized)存在巨大的性能开销。因此,大家想出了一个“聪明”的技巧:双重检查锁定(Double-Checked Locking),想通过双重检查锁定来降低同步的开销。下面是使用双重检查锁定来实现延迟初始化的示例代码:

class DoubleCheckedLocking {
  private static Instance instance;

  public static Instance getInstance() {
    if (instance == null) { // 1. 第一次检查
      synchronized (DoubleCheckedLocking.class) { // 2. 加锁
        if (instance == null) { // 3. 第二次检查
          instance = new Instance(); // 4. 问题的根源出现在这里 
        }
      }
    }
    return instance;
  }
}

如上面代码所示,在第一次检查 instance 不为 null,那么就不需要执行下面的加锁和初始化操作。因此,可以大幅降低 synchronized 带来的性能开销。 上面代码表面上看起来,似乎两全其美。

  • 多个线程试图在同一时间创建对象时,会通过加锁来保证只有一个线程能创建对象
  • 在对象创建好之后,执行 getInstance() 方法将不需要获取锁,直接返回已创建好的对象

双重检查锁定看起来似乎很完美,但这是一个错误的优化。在线程执行到注释 1 时,代码读取到 instance 不为 null 时,instance 引用的对象有可能还没有完成初始化。

3.2 问题的根源

前面的双重检查锁定示例代码的注释 4:instance = new Singleton(); 创建了一个对象。这一行代码可以分解为如下的 3 行伪代码:

memory = allocate();  // 1:分配对象的内存空间 
ctorInstance(memory); // 2:初始化对象
instance = memory;    // 3:设置instance指向刚分配的内存地址

上面 3 行伪代码中的 2 和 3 之间,可能会被重排序。2 和 3 之间重排序之后的执行时序如下:

memory = allocate();   // 1: 分配对象的内存空间
instance = memory;    // 3: 设置instance指向刚分配的内存地址 —— 注意,此时对象还没有被初始化! 
ctorInstance(memory);  // 2: 初始化对象

根据 Java 语言规范,所有线程在执行 Java 程序时必须要遵守 intra-thread semantics。intra-thread semantics 保证重排序不会改变单线程内的程序执行结果。换句话说,intra-thread semantics 允许那些在单线程内,不会改变单线程程序执行结果的重排序。上面 3 行伪代码的 2 和 3 之间虽然被重排序了,但这个重排序并不会违反 intra-thread semantics。这个重排序在没有改变单线程程序执行结果的前提下,可以提高程序的执行性能。

semantics [sɪˈmæntɪks] [语] 语义学;语义论

为了更好地理解 intra-thread semantics,如下图所示(假设一个线程 A 在构造对象后,立即访问这个对象):

线程执行时序图

如上图所示,只要保证 2 排在 4 的前面,即使 2 和 3 之间重排序了,也不会违反 intra-thread semantics。

下面多线程并发执行的情况:

多线程执行时序图

由于单线程内要遵守 intra-thread semantics,从而能保证 A 线程的执行结果不会被改变。但是,当线程 A 和 B 按上图的时序执行时,B 线程将看到一个还没有被初始化完成的对象。

DoubleCheckedLocking 示例代码的注释 4:instance = new Singleton(); 如果发生重排序,另一个并发执行的线程 B 就有可能在第 1 行判断 instance 不为 null。线程 B 接下来将访问 instance 所引用的对象,但此时这个对象可能还没有被 A 线程初始化。下图是这个场景的具体执行时序:

多线程执行时序表
这里 A2 和 A3 虽然重排序了,但 Java 内存模型的 intra-thread semantics 将确保 A2 一定会排在 A4 前面执行。因此,线程 A 的 intra-thread semantics 没有改变,但 A2 和 A3 的重排序,将导致线程 B 在 B1 处判断出 instance 不为空,线程 B 接下来将访问 instance 引用的对象。此时,线程 B 将会访问到一个还未初始化的对象。

用以下两个办法来实现线程安全的延迟初始化:

  • 不允许 2 和 3 重排序。
  • 允许 2 和 3 重排序,但不允许其他线程“看到”这个重排序。
3.3 基于 volatile 的解决方案

对于前面的基于双重检查锁定来实现延迟初始化的方案(指 DoubleCheckedLocking 示例代码),只需把 instance 声明为 volatile 类型,就可以实现线程安全的延迟初始化:

  class SafeDoubleCheckedLocking {
    private volatile static Instance instance;

    public synchronized static Instance getInstance() {
        if (instance == null) { 
            synchronized (DoubleCheckedLocking.class) { 
                if (instance == null) { 
                    instance = new Instance(); // instance为volatile,现在没问题了
                }s
            }
        }
        return instance;
    }
}

这个解决方案需要 JDK 5 或更高版本(因为从 JDK 5 开始使用新的 JSR-133 内存模型规范,这个规范增强了 volatile 的语义)。

当声明对象的引用为 volatile 后,伪代码中的 2 和 3 之间的重排序,在多线程环境中将会被禁止。上面示例代码将按如下的时序执行,如下图所示:

多线程执行时序图

这个方案本质上是通过禁止上图中的 2 和 3 之间的重排序,来保证线程安全的延迟初始化。

3.4 基于类初始化的解决方案

JVM 会在 Class 被加载后,被线程使用之前,执行类的初始化。而在执行类的初始化期间,JVM 会去获取一个锁,这个锁可以同步多个线程对同一个类的初始化。 基于这个特性,可以实现另一种线程安全的延迟初始化方案(这个方案被称之为 Initialization On Demand Holder idiom)。

class InstanceFactory {

  private InstanceFactory(){ }

  public static Instance getInstance() {
    return InstanceHolder.instance; // 这里将导致InstanceHolder类被初始化
  }

  private static class InstanceHolder {
    public static InstanceFactory instance = new InstanceFactory();
  }
  
}

假设两个线程并发执行 getInstance() 方法,下面是执行的示意图:
两个线程并发执行示意图

这个方案的实质是:允许伪代码中的 2 和 3 重排序,但不允许非构造线程(这里指线程 B)“看到”这个重排序。

初始化一个类,包括执行这个类的静态初始化和初始化在这个类中声明的静态字段。根据 Java 语言规范,在首次发生下列任意一种情况时,一个类或接口类型T将被立即初始化:

  • T 是一个类,而且一个 T 类型的实例被创建
  • T 是一个类,且 T 中声明的一个静态方法被调用
  • T 中声明的一个静态字段被赋值
  • T 中声明的一个静态字段被使用,而且这个字段不是一个常量字段
  • T 是一个顶级类(Top Level Class),而且一个断言语句嵌套在 T 内部被执行

在 InstanceFactory 示例代码中,首次执行 getInstance() 方法的线程将导致 InstanceHolder 类被初始化(符合情况 4)。

由于 Java 语言是多线程的,多个线程可能在同一时间尝试去初始化同一个类或接口(比如这里多个线程可能在同一时刻调用 getInstance() 方法来初始化 InstanceHolder 类)。因此,在 Java 中初始化一个类或者接口时,需要做细致的同步处理。

Java 语言规范规定,对于每一个类或接口 C,都有一个唯一的初始化锁 LC 与之对应。从 C 到 LC 的映射,由 JVM 的具体实现去自由实现。JVM 在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了。

参考

https://zhuanlan.zhihu.com/p/43883488
https://zhuanlan.zhihu.com/p/50632786
https://zhuanlan.zhihu.com/p/23713957
https://zhuanlan.zhihu.com/p/33102022
https://zhuanlan.zhihu.com/p/50632786
https://blog.csdn.net/qq_43279637/article/details/84982874
https://zhuanlan.zhihu.com/p/129040135

  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值