什么是单例?
单例模式是设计模式中最简单的一种,一般通过私有化构造方法的方式避免外部创建类的实例,只提供一个创建好的实例供外部调用,因此成为单例。
单例模式十分常见,例如springmvc中的javabean实例,数据库的连接池;一般需要减少资源消耗,或者需要保证对象内数据的通用性时,会使用单例模式。
如何实现单例?
简单来讲,单例的设计分为三步:
- 私有化构造方法,避免外部直接创建实例
- 类的内部初始化一个静态的实例
- 提供静态方法,供外部调用这个初始化完成的实例
单例的设计种类
面试必考题,单例的设计分为饿汉式和懒汉式,常见的有如下几种具体实现。
1. 饿汉式
最基础的单例模式,适用于90%的系统;在类加载的时候同时初始化实例
优点:代码简单,并且从jvm层级保证线程安全
缺点:不论是否使用,在类加载的时候就会初始化实例,会在项目启动时就消耗资源
public class Singleton01 {
// 创建静态实例
private static final Singleton01 instance = new Singleton01();
// 私有化构造方法,避免外部调用构造方法创建实例
private Singleton01() {
}
// 提供静态方法,供外部调用这个初始化完成的实例
public static Singleton01 getInstance() {
return instance;
}
}
有的地方会将创建实例的代码写到静态代码块中,这本质上和上面的方式是一样的,静态代码块也会在类加载的时候执行;
private static final Singleton01 instance;
// 等同于直接new Singleton()
static {
instance = new Singleton01();
}
这也是为什么能让final修饰过的变量放到静态代码块中初始化的原因,因为静态代码块和静态成员变量的加载是同时期执行的。
2. 懒汉式
由于饿汉式单例会在系统启动时就消耗资源,不能做到“按需加载”,因此在庞大的系统中,为了避免启动时消耗的资源过多,导致启动时间过长,会对非核心的实例进行懒加载(Lazy-init)。
public class Singleton02 {
private static Singleton02 instance;
private Singleton02() {
}
public static Singleton02 getInstance() {
// 获取实例之前先判断实例是否被初始化,如果没有,则进行初始化再返回
if (instance == null) {
instance = new Singleton02();
}
return instance;
}
}
但是这样的代码会带来一个问题,在多线程中,如果第一个线程已经进入了初始化单例的代码行了,但是还没有将实例初始化完成,此时后面的线程判断instance还是为null,也会去初始化一个实例,这就造成了线程不安全的问题。
我们可以在多线程下通过打印hashcode来查看一下是否有这个问题
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
new Thread(()-> System.out.println(Singleton02.getInstance().hashCode())).start();
}
}
由于实例初始化速度过快,可能需要多次运行才会出现这个问题,我们可以在instance = new Singleton02()上方加入Thread.sleep(10)来模拟实例初始化耗时较长的情况。
打印结果如下:
1513195109
494962664
494962664
如何避免线程不安全的问题呢?
最简单的,就是在getInstance方法声明上,添加synchronized修饰符,保证同时只有一个线程去执行获取实例的方法。但是当实例初始化完成后,每次访问方法还需要经过同步锁,会白白消耗资源,于是懒汉式加载有了以下的改进方式。
2.1 懒汉式一:DCL单例模式
DCL:Double check lock双重检查锁。
为了避免不必要的资源浪费,我们需要尽可能将锁的粒度缩小,例如我们可以不把synchronized修饰符放在方法声明中,我们可以放在方法内的代码块上。但这还是不够的,为了解决实例被初始化之后每次获取实例还要通过同步锁,我们可以在同步代码块外层先做一次判断,判断实例是否已经初始化了,如果实例已经初始化,则不需要经过锁,直接返回。
public class Singleton03_DCL {
private volatile static Singleton03_DCL instance;
private Singleton03_DCL() {
}
public static Singleton03_DCL getInstance() {
// 第一次判断,过滤掉已经初始化过实例的情况,否则每次调用实例都需要加锁
if (instance == null) {
synchronized (Singleton03_DCL.class) {
if (instance == null) {
instance = new Singleton03_DCL();
}
}
}
return instance;
}
}
双重检查锁模式为什么要加volatile?
防止CPU的指令重排序引起的对象半初始化问题,具体解析请见《深入解析volatile》一文。(我还没写。。)
2.2 懒汉式二:静态内部类模式
DCL检查代码比较复杂,并且由于volatile的存在,可能在使用中引起多核心CPU频繁的数据交换,造成效率问题;
静态内部类方式懒加载单例,是较完美的懒汉式单例的解决方案之一,可以在代码层不使用锁来实现单例的懒加载;
具体实现流程是,在需要创建单例的类中再创建一个静态内部类,由于JVM加载外部类时不会立刻加载其内部的类,我们可以在内部类初始化时创建外部类的单例,由JVM来保证线程安全。
public class Singleton04_InnerClass {
private Singleton04_InnerClass() {
}
public static Singleton04_InnerClass getInstance() {
return InnerClass.INSTANCE;
}
/**
* 加载外部类时不会加载内部类
*/
private static class InnerClass {
/**
* 如饿汉式单例,线程安全由JVM保证,JVM内部机制保证了每个类只会加载一次
*/
private static final Singleton04_InnerClass INSTANCE = new Singleton04_InnerClass();
}
}
3. 枚举单例
单例破坏
以上的单例模式使用java普通类,便无法避免的可以通过反序列化的方式强制调用构造方法,创建新的实例,这种操作被称为单例破坏。
单例破坏的方法后续补充
枚举类单例有如下特点:
- 由于枚举类本身没有构造方法,可以完美的解决单例破坏的问题
- 代码十分简洁
- 枚举类的特性保证了每个成员对象都是单例的
因此枚举单例模式成为了最近几年较为流行的单例模式。
public enum Singleton05_Enum {
INSTANCE;
}
外部可以通过调用Singleton05_Enum.INSTANCE直接获取实例,并调用实例的方法。
上面这种写法,本质上还是一个饿汉式的单例,枚举对象会随着类的初始化而初始化
而网上常看到的枚举类懒加载单例,是通过内部枚举类来实现的,由于内部枚举类本身就是静态的,因此和静态内部类单例模式类似,只不过换成了枚举类,代码就相对复杂了。
// 无法避免反序列化?待确认
public class Singleton05_Enum {
private Singleton05_Enum() {
}
public static Singleton05_Enum getInstance() {
return Singleton.INSTANCE.getInstance();
}
private enum Singleton {
INSTANCE;
private final Singleton05_Enum instance;
Singleton() {
instance = new Singleton05_Enum();
}
private Singleton05_Enum getInstance() {
return instance;
}
}
}
作者疑问:
我本人在网上搜索枚举单例时,搜到的都是后面这种内部枚举类的方式,但是我对此有些疑问:
1、在此代码中,枚举类的作用看起来只是保证外部类实例初始化时线程安全,所以是否有必要这么复杂?这种方式相对静态内部类单例有什么优势?
2、外部类仍然可以通过反序列化创建实例,无法保证单例不被破坏,那么使用枚举的意义在哪里?
以上,本人暂时没有查找到准确的资料,如果有了解的朋友解答一下,万分感谢!
结语
任何设计模式,都是服务于系统业务的。正所谓大繁化简,思维越深,越了解如何让设计更简洁,因此,我们不可将了解的所有设计模式强加于一个简单的业务,不要盲目套用。
以单例模式来讲,90%的系统使用饿汉式单例足矣,更多的模式,就留给面试官去吧!