概述
学习设计模式的时候,经常会以单例模式作为第一个案例来分析,因为这个模式比较简单,容易理解。但其实一旦牵涉到并发的时候,单例模式也往往很多问题。所以需要系统的梳理一下。
单例模式是一种创建型模式
,在Java应用中,单例对象必须保证在一个Java虚拟机中,该对象有且只能有一个实例。这个模式有一下几种应用场景(或者说有这么些优点):
- 某些类的实例对象创建,开销比较大,消耗很多的系统资源;
- 对于某些频繁使用的对象,减少new操作,避免在Java堆中产生过多实例,降低GC压力;
- 在某些场景中,多个实例对象会导致系统错乱。类似于一个Company有多个CEO,听谁的。
以上这几种情况就非常适合使用单例模式来解决。
单例模式有一下几个特点:
- 必须保持只有一个实例;
- 必须自己创建一个自己的实例(因为构造方式是private的);
- 必须为其他对象提供唯一的实例(需要提供一个getInstance()方法);
单例模式主要有以下5种方式,逐一分析下。
饿汉式(线程安全)
|
|
这种方式非常简单,因为单例的实例被声明为static
和final
,在JVM第一次加载该单例类到内存中的时候就会初始化【1】,所以创建实例本身是线程安全的。
然而这种方式的缺点也是显而易见的:
- 这个方式不是
懒加载模式
(lazy initialization),单例会在第一次加载的时候就被初始化,即使客户端没有调用getInstance()方法。这个时候如果单例类的开销比较大或者时间花费较多,就会降低系统效率。- 在一些场景中是无法使用这种方式的:比如Singleton的创建是依赖参数或配置文件的,在getInstance之前必须调用某个方法设置参数给它,那么这种单例就无法使用了。
懒汉式(线程不安全)
|
|
上面这段代码简单明了,而且使用了懒加载模式,但是却存在着致命伤。当有多个线程同时运行这段代码的时候,就非常可能创建出多个实例,也就是说这个单例类在并发环境中是无法正确运行的。
我们来分析下出现错误的原因:假设有2个线程同时来到了a处,此时的instance为null。假设线程1先进入if语句块中,但是还没执行b,就交出了CPU使用权。接着线程2获取CPU使用权,进入if语句块,执行了b,生成一个对象,这时候交出了CPU使用权。这时候,线程1接着执行b,又生成了一个对象。而在这2个对象的创建期间,其他类可能调用了getInstance()方法,极大可能调用了第1个对象,然后第2个对象创建后,其他类又调用的是第2个对象。这就严重违反了单例模式的核心功能,所以这种写法是完全不正确的。
懒汉式(synchronized)
|
|
为了解决线程安全问题,我们需要使用Java提供的同步机制。为此我们用synchronized
关键字来修饰getInstance()方法。
这种方式虽然做到了线程安全,但是却也并不高效。因为每次只能有一个线程调用getInstance()方法,但是明显同步操作只需要在第一次初始化的时候才被需要。为此我们引入了双重校验锁。
懒汉式(双重校验锁Double Checked Locking)
|
|
其实这种方式本质上就是在同步方式的基础上再加一层校验(也就是if语句判断),只不过把同步的方法换成了同步块。
但是这段代码还是可能出现问题,问题出现在a处instance = new Singleton();
这句代码并非是一个原子操作【2】,JVM大概做了3件事:
- 【分配引用内存】给instance分配内存;
- 【分配实例内存】调用Singleton的构造函数来初始化成员变量;
- 【引用指向实例地址】将instance引用指向分配的实例内存,这时候instance才不为null;
但是JVM可能存在指令重排序
,也就是说上面的2、3顺序是不一定的,最终执行顺序可能是1-2-3,也可能是1-3-2,如果是后者,则在执行完3、2未执行的时候,被线程2夺取了CPU,那么这个时候instance不为null,但是却没有初始化,所以线程2直接就返回了instance,使用的时候就报错了。
为此我们需要把intance声明为volatile
,有部分人认为volatile是保证了可见性,也就是保证线程在本地内存中不会存有instance的副本,每次都是去主内存中获取。但其实是不对的,即使是保证了可见性,也还是会出现上面的问题。使用volatile的主要原因是其另外一个特性:禁止指令重排序
。在volatile变量的赋值操作后,会生成一个内存屏障(在汇编代码上),读操作不会被重排序到内存屏障前。比如上面的例子,取操作必须在执行完1-2-3或1-3-2后,不存在执行到1-3后读取instance的情况。从“先行发生原则”的角度来理解的话,就是对于一个volatile变量的写操作是先行发生与后面对这个变量的读操作(这里的后面指的是时间概念)。
但是特别注意的是,在JDK1.5之前,使用volatile的双重校验锁还是有问题的。因为JDK1.5之前的内存模型是存在缺陷的,即使将变量声明为volatile也不能完全避免重排序,主要是volatile变量前后的代码仍然存在重排序问题。在JDK1.5及之后这个问题得到了修复,可以放心使用。
静态内部类方式
|
|
静态内部类是最被提倡使用的方式,在《effective java》上也是被推荐的。这种写法仍然使用了JVM本身机制保证了线程安全问题。由于SingletonHolder是私有的,除了getInstance()没有其他方法能够访问它,因此它是懒汉式的。同时读取实例的时候不会进行同步,没有性能缺陷;也不依赖 JDK 版本。这种方式其实是懒汉式和饿汉式的结合。
这种方式使用一个私有静态内部类来维护单例的实现,JVM内部机制保证当一个类被加载时,这个类的加载过程是线程互斥的。这样,当我们第一次调用getInstance()方法的时候,JVM能够帮助我们保证INSTANCE只被创建一次,并且会保证把赋值给INSTANCE的内存初始化完毕,这样我们就不用担心上面的问题了。同时该方法也只会在第一次使用的时候使用互斥机制,这样就解决了低性能的问题。
枚举法
在《Effective Java》最后提供了枚举法来实现单例模式,不仅简单,而且保证了线程安全。此外,这个方法还提供了防止序列化和反射创建多个实例的问题,即使面对复杂的序列化和反射攻击(不清楚序列化和反射攻击的,可以看这里 单例模式的破坏)。
单元素的枚举类型已经成为实现Singleton的最佳方法。《Effective Java》
|
|
首先,在枚举类,构造方法自动声明为private,同时每个元素都自动声明为static final,表明元素只能被实例化一次。也就是说,enum里的实例都只会被实例化一次,所以我们INSTANCE也被保证只实例化一次。此外,我们看一下enum的定义:
可以看出,Enum实现了序列化接口,即提供了序列化机制。
枚举是如何防止序列化生成多个实例的
我们知道,反序列化实例化对象是通过ObjectInputStream
的readObject
方法实现的,其中枚举的调用链是:
readObject -> readObject0 -> readEnum -> checkResolve
我们重点来看readEnum方法,部分实现如下:
看第10行代码Enum<?> en = Enum.valueOf((Class)cl, name);
,可以看出是通过Enum.valeOf来获取枚举类对象的,那我们去看一下这个方法。
实际上是通过调用enumType(Class对象的引用)的enumConstantDirectory方法获取到一个Map集合,在该集合内存储了以枚举类name为key,枚举实例变量值为value的键值对数据,因此通过name就可以获取到枚举实例。我们再来看下enumConstantDirectory方法的源码:
getEnumConstantsShared方法最终利用反射调用枚举类的values
方法来获取的枚举类实例数组,然后再以键值对的方式存储到Map集合中去。
到这里,我们的确可以看出来,枚举类反序列化的确不会重新创建实例,JVM保证了每个枚举类实例变量的唯一性。
枚举是如何防止反射生成多个实例的
|
|
我们知道,反射破坏单例主要是通过利用调用私有构造器来实现的,当用反射尝试实例化枚举类的时候,会抛出下面的异常:
显然,异常告诉我们,不能通过反射实例化枚举类,为什么呢?我们来看下newInstance的源码:
看第12行,很显然了。
总结
常见的5种单例模式实现方式:
- 主要:
- 饿汉式:线程安全,调用效率高,不能延迟加载;
- 懒汉式:线程安全,调用效率低,但是可以延迟加载;
- 其他:
- 双重检验锁:JDK1.5之前底层内部模型有问题,不建议使用;
- 静态内部类式:线程安全,调用效率高,可以延迟加载;
- 枚举式:线程安全,调用效率高,不能延迟加载,但是可以天然防止反射和序列化漏洞;
- 主要:
如何选用:
- 单例对象占用资源少,不需要延迟加载:枚举式 > 饿汉式;
- 单例对象占用资源大,需要延迟加载:静态内部式 > 懒汉式
在JDK中的应用
java.lang.Runtime
Runtime
类封装了Java运行时的环境。因为Java程序实际是启动了一个JVM进程,而每个JVM进程都对应一个Runtime实例,此实例是由JVM来实例化的。每个Java程序都有一个Runtime实例,使应用程序能够与其运行的环境交互。
由于Java进程是单进程的,所以在一个JVM中,Runtime实例应该只要一个。所以应该用单例模式来实现。
以上代码为JDK中Runtime代码的部分实现,明显,这是饿汉式的实现方式。在该类被ClassLoader第一次加载的时候被创建。
在Spring中的应用
Spring单例Bean与单例模式的区别在于它们关联的环境不一样,单例模式是指在一个JVM进程中仅有一个实例,而Spring单例是指一个SpringBean容器(ApplicationContext)中仅有一个实例,所以如果一个JVM进程中有多个IoC容器,即使是单例Bean,也会有多个实例,比如:
|
|
疑问
【1】如果是final的话,应该是静态绑定的,在编译期间就可以确定,参考使用父类常量不会触发定义常量的类的初始化。
【2】这与Java 内存模型中提到的synchroized保证了原子性有冲突?
参考文档
- Jark’s Blog: 如何正确地写出单例模式
- CSDN: Java之美[从菜鸟到高手演变]之设计模式
- CSDN: 单例模式有什么好处
- CSDN:深入理解Java枚举类型(enum)
- CSDN:单例模式详解(解决反射反序列化问题)