单例应用
- 单例模式的定义
单例模式确保某个类只有一个实例,而且自行实例化并向整个系统提供这个实例。 - 单例模式的特点
单例类只能有一个实例。
单例类必须自己创建自己的唯一实例。
单例类必须给所有其他对象提供这一实例。 - 单例模式的应用
- 在计算机系统中,线程池、缓存、日志对象、对话框、打印机、显卡的驱动程序对象常被设计成单例。
- 这些应用都或多或少具有资源管理器的功能。每台计算机可以有若干个打印机,但只能有一个Printer Spooler,以避免两个打印作业同时输出到打印机中。每台计算机可以有若干通信端口,系统应当集中管理这些通信端口,以避免一个通信端口同时被两个请求同时调用。总之,选择单例模式就是为了避免不一致状态。
- 单例模式的Java代码
单例模式分为懒汉式(需要才去创建对象)和饿汉式(创建类的实例时就去创建对象)。饿汉模式
我们知道饿汉式的实现是线程安全的,没有延迟加载(Lazy Loading),下面我们深入研究下为什么。
饿汉式的实现如下:1
2
3
4
5
6
7public class Singleton {
private static Singleton singleton = new Singleton();
private Singleton() {}
public static Singleton getSignleton(){
return singleton;
}
}
在介绍饿汉式时我们大多会说这种实现是线程安全的,实例在类加载时实例化,有JVM保证线程安全。虚拟机是怎么保证饿汉式实现的线程安全?
先介绍类生命周期的7个阶段,前面的5个阶段为类加载阶段,每个阶段作用不详细介绍了:
首先,singleton 作为类成员变量的实例化发生在类Singleton 类加载的初始化阶段,初始化阶段是执行类构造器clinit() 方法的过程。
clinit()方法是由编译器自动收集类中的所有类变量(static)的赋值动作和静态语句块(static{})块中的语句合并产生的。因此,private static Singleton singleton = new Singleton();也会被放入到这个方法中。(这是第一步先放到方法中)
虚拟机会保证一个类的clinit()方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的clinit()方法,其他线程都需要阻塞等待,直到活动线程执行clinit()方法完毕。需要注意的是,其他线程虽然会被阻塞,但如果执行clinit()方法的那条线程退出clinit()方法后,其他线程唤醒后不会再次进入clinit()方法。同一个类加载器下,一个类型只会初始化一次。(保证线程安全)
回答了线程安全的问题,我们再看看这个实现为什么不是延迟加载的?(什么时候初始化)
什么情况下需要开始类加载过程的第一个阶段——加载,Java虚拟机规范中没有进行强制的约束,这点可以交给虚拟机的具体实现来自由把握。
但是对于初始化阶段,虚拟机规范则是严格规定了有且只有以下5种情况必须立即对类进行“初始化”(而加载、验证、准备自然需要在此之前完成)。
- 遇到new, getstatic, putstatic, invoke static这4条字节码指令时,如果类没有进行过初始化,由需要先触发其初始化。生成这4条指令的最常见的Java代码场景是:使用new关键字实例化对象,读取或设置一个类的静态字段(被fina修饰的静态字段除外,其已在编译期把值放入了常量池中),以及调用一个类的静态方法。
- 使用java.lang.reflect包的方法 对类进行反射时,如果类没有进行过初始化,由需要先触发其初始化。(反射)
- 初始化一个类的时候,如果发现其父类还没有进行初始化,由先触发其父类的初始化。(父类没有初始化)
- 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟杨会先初始化这个主类。(main方法)
- 当使用JDK 1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getstatic、REF_putStatic、REF_invokeStatic的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。(句柄)
注意到第一条中的new字节码指令会触发初始化,因为private static Singleton singleton = new Singleton();中就有使用new关键字的情况,可知一旦触发初始化clinit() 方法执行,singleton 就会被分配内存完成实例化。单例模式下大部分情况下是调用静态方法getSignleton()被触发初始化,但是也不能100%保证,上述5种情况下,任何一种都会触发初始化,于是就能解释为什么饿汉式不是延迟加载了。
枚举
下面我们用一个枚举实现单个数据源例子来简单验证一下:
声明一个枚举,用于获取数据库连接。1
2
3
4
5
6
7
8
9
10public enum DataSourceEnum {
DATASOURCE;
private DBConnection connection = null;
private DataSourceEnum() {
connection = new DBConnection();
}
public DBConnection getConnection() {
return connection;
}
}
下面深入了解一下为什么枚举会满足线程安全、序列化等标准。
在JDK5 中提供了大量的语法糖,枚举就是其中一种。
所谓 语法糖(Syntactic Sugar),也称糖衣语法,是由英国计算机学家 Peter.J.Landin 发明的一个术语,指在计算机语言中添加的某种语法,这种语法对语言的功能并没有影响,但是但是更方便程序员使用。只是在编译器上做了手脚,却没有提供对应的指令集来处理它。
就拿枚举来说,其实Enum就是一个普通的类,它继承自java.lang.Enum类。1
2
3
4
5
6
7
8
9
10public enum DataSourceEnum {
DATASOURCE;
}
把上面枚举编译后的字节码反编译,得到的代码如下:
public final class DataSourceEnum extends Enum<DataSourceEnum> {
public static final DataSourceEnum DATASOURCE;
public static DataSourceEnum[] values();
public static DataSourceEnum valueOf(String s);
static {};
}
由反编译后的代码可知,DATASOURCE 被声明为 static 的,根据在【单例深思】饿汉式与类加载 中所描述的类加载过程,可以知道虚拟机会保证一个类的clinit() 方法在多线程环境中被正确的加锁、同步。所以,枚举实现是在实例化时是线程安全。
接下来看看序列化问题:
Java规范中规定,每一个枚举类型极其定义的枚举变量在JVM中都是唯一的,因此在枚举类型的序列化和反序列化上,Java做了特殊的规定。
在序列化的时候Java仅仅是将枚举对象的name属性输出到结果中,反序列化的时候则是通过 java.lang.Enum 的 valueOf() 方法来根据名字查找枚举对象。
也就是说,以下面枚举为例,序列化的时候只将 DATASOURCE 这个名称输出,反序列化的时候再通过这个名称,查找对于的枚举类型,因此反序列化后的实例也会和之前被序列化的对象实例相同。1
2
3public enum DataSourceEnum {
DATASOURCE;
}
由此可知,枚举天生保证序列化单例。
懒汉模式
饿汉模式
属性实例化对象1
2
3
4
5
6
7
8
9
10//饿汉模式:线程安全,耗费资源。
public class HugerSingletonTest {
//该对象的引用不可修改
private static final HugerSingletonTest ourInstance = new HugerSingletonTest();
public static HugerSingletonTest getInstance() {
return ourInstance;
}
private HugerSingletonTest() {
}
}
这种写法如果完美的话,就没必要在啰嗦那么多双检锁的问题了。缺点是它不是一种懒加载模式(lazy initialization),单例会在加载类后一开始就被初始化,即使客户端没有调用 getInstance()方法。饿汉式的创建方式在一些场景中将无法使用:譬如 Singleton 实例的创建是依赖参数或者配置文件的,在 getInstance() 之前必须调用某个方法设置参数给它,那样这种单例写法就无法使用了。
在静态代码块实例对象
1 | public class Singleton { |
分析:饿汉式单例模式只要调用了该类,就会实例化一个对象,但有时我们并只需要调用该类中的一个方法,而不需要实例化一个对象,所以饿汉式是比较消耗资源的。
懒汉模式
非线程安全
1 | public class Singleton { |
分析:如果有两个线程同时调用getInstance()方法,则会创建两个实例化对象。所以是非线程安全的。
线程安全:给方法加锁
1 | public class Singleton { |
分析:如果有多个线程调用getInstance()方法,当一个线程获取该方法,而其它线程必须等待,消耗资源。
线程安全:双重检查锁(同步代码块)
1 | public class Singleton { |
分析:为什么需要双重检查锁呢?因为第一次检查是确保之前是一个空对象,而非空对象就不需要同步了,空对象的线程然后进入同步代码块,如果不加第二次空对象检查,两个线程同时获取同步代码块,一个线程进入同步代码块,另一个线程就会等待,而这两个线程就会创建两个实例化对象,所以需要在线程进入同步代码块后再次进行空对象检查,才能确保只创建一个实例化对象。
线程安全:静态内部类
1 | public class Singleton { |
分析:利用静态内部类,都个线程在调用getInstance方法时会创建一个实例化对象。
线程安全:枚举
1 | enum SingletonTest { |
分析:枚举的方式是《Effective Java》书中提倡的方式,它不仅能避免多线程同步问题,而且还能防止反序列化重新创建新的对象,但是在枚举中的其他任何方法的线程安全由程序员自己负责。还有防止上面的通过反射机制调用私用构造器。不过,由于Java1.5中才加入enum特性。
指令重排序
我们再来思考一个问题,就是懒汉式的双重检查版本的单例模式,它一定是线程安全的吗?我会毫不犹豫的告诉你—不一定,因为在JVM的编译过程中会存在指令重排序的问题,(除非你已经加上volatile)。
其实创建一个对象,往往包含三个过程。
对于singleton = new Singleton(),这不是一个原子操作,在 JVM 中包含的三个过程。1
2
31>给 singleton 分配内存
2>调用 Singleton 的构造函数来初始化成员变量,形成实例
3>将singleton对象指向分配的内存空间(执行完这步 singleton才是非 null 了)
但是,由于JVM会进行指令重排序,所以上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是 1-3-2,则在 3 执行完毕、2 未执行之前,被l另一个线程抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以这个线程会直接返回 instance,然后使用,那肯定就会报错了。
针对这种情况,我们有什么解决方法呢?那就是把singleton声明成 volatile,改进后的懒汉式线程安全(双重检查锁)的1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16public class Singleton {
//volatile的作用是:保证可见性、禁止指令重排序,但不能保证原子性
private volatile static Singleton ourInstance;
public synchronized static Singleton getInstance() {
if (null == ourInstance) {
synchronized (Singleton.class) {
if (null == ourInstance) {
ourInstance = new Singleton();
}
}
}
return ourInstance;
}
private Singleton() {
}
}
静态内部类
推荐使用,这种方法也是《Effective Java》上所推荐的。1
2
3
4
5
6
7
8
9public class Singleton {
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
private Singleton (){}
public static final Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
}
这种写法仍然使用JVM本身机制保证了线程安全问题;由于 SingletonHolder 是私有的,除了 getInstance() 之外没有办法访问它,因此它是懒汉式的;同时读取实例的时候不会进行同步,没有性能缺陷;也不依赖 JDK 版本。
一般情况下直接使用饿汉式就好了,如果明确要求要懒加载(lazy initialization)会倾向于使用静态内部类,如果涉及到反序列化创建对象时会试着使用枚举的方式来实现单例。
总结
- 静态内部类能够结构饿汉模式晚加载,再加上静态代码块的方式就可以解决从配置文件加载资源的问题。
- 懒加载可以解决加载资源的问题,但是要注意双重检锁
- 枚举可以反序列化
- 饿汉模式最简单