[Java]再次探讨Java单例
0x00
从前在读Effective Java的时候,第一篇的Item 1:使用静态工厂方法而不是构造器
的静态工厂方法,其实就是单例。里面列举了单例的几个优点,比如可以自己取名字,简洁,不用每次创建对象等等。最近看了小灰的公众号又探讨了单例,所以再次学习一下。
0x01 单例的种类
虽然实现起来可以多种多样(比如用HashMap..,其实单例就是一种缓存思想的体现),但是单例一共可以分为两类,也就是网上取的很土的名字,饿汉和懒汉。前者是空间换时间,也就是在类初始化阶段就创建对象;后者是时间换空间,在用的时候检查对象是否为空。具体实现就不贴出来了,脑补一下吧。
0x02 线程安全
饿汉是线程安全的。在类被初始化的时候就创建对象,外界的线程还没有开始竞争getInstance,当然也就不会重复初始化。
既然饿汉是线程安全的,跟懒汉的性能差距到底在哪?我是这样想的:
比如两个线程同时调用一个类的getInstance方法,这个类还没有被加载的话,是要经历类的加载的一系列流程(编译,链接,准备,初始化等)加载到内存中去的,静态的instance会保存在Java虚拟机的方法区(Method Area)作为永久代,永久代是基本不会涉及GC的,持续到进程结束都会有这个静态变量存在。
懒汉式是典型的时间换空间,也就是每次获取实例都会进行判断,看是否需要创建实例,费判断的时间,当然,如果一直没有人使用的话,那就不会创建实例,节约内存空间。
懒汉式不是线程安全的,因为两个线程可能同时走到if(mInstance == null)这个语句,会导致重复创建instance。
0x03 如何保证懒汉式单例的线程安全
- APPROACH 1: 给整个getInstance
方法加上同步锁,synchronized
关键字
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
缺点是,同步是需要开销的,因为多数情况,instance是不为null的,只要不是第一次初始化,都只有最后一行 return instance会被执行,每次都同步太浪费了。
- APPROACH 2: DCL(Double Checking Lock)双重检测加锁
public class Singleton {
private volatile static Singleton instance = null;
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
如果instance还没有初始化,会进入第一重if的body里面,这时候就要同步了,防止重复创建实例。
第二个if的作用是,如果后执行的线程在synchronized语句那里等着了,先执行的线程去创建对象了,这时候对象就不为空了,那后进来的线程就没必要再创建一次对象了。
为什么要使用volatile 修饰instance?
主要在于instance = new Singleton()这句,这并非是一个原子操作,事实上在 JVM 中这句话大概做了下面 3 件事情:
1.给 instance 分配内存
2.调用 Singleton 的构造函数来初始化成员变量
3.将instance对象指向分配的内存空间(执行完这步 instance 就为非 null 了)。
但是在 JVM 的即时编译器中存在指令重排序的优化。也就是说上面的第二步和第三步的顺序是不能保证的,最终的执行顺序可能是 1-2-3 也可能是 1-3-2。如果是后者,则在 3 执行完毕、2 未执行之前,被线程二抢占了,这时 instance 已经是非 null 了(但却没有初始化),所以线程二会直接返回 instance,然后使用,然后顺理成章地报错。
要格外注意的是volatile
关键字。如果不加volatile,编译器在编译阶段会把内层的if语句去掉(这样一来synchronized也不起作用了),因为编译器是不负责线程安全的。但是加了volatile等于告诉编译器,instance
是随时可变的,编译的时候就不会把内层if去掉了。
Volatile的两个语义
volatile
是JVM提供的最轻量级的同步方式。
当一个变量定义成volatile之后,它将具备两种特性:
1. 第一是保证此变量对所有线程的立即可见性。
这里突出一个立即可见,修改对所有线程是立即可见的。
这里的“可见性”是指当一条线程修改了这个变量的值,新值对于其它线程是可以立即得知的!
普通变量做不到这一点。普通变量的变量值在线程间传递,需要通过主内存来完成,如:线程A修改一个普通变量的值,然后向主内存进行回写,另外一条线程B在线程A回写完成了之后再从主内存进行读取操作,新变量的值才会对线程B可见。
但是,使用volatile并不代表线程安全,深入理解Java虚拟机的书上举了例子,起20个线程,对一个static变量int race进行自增,每个线程执行1000次,最后发现每次执行结果都不一样,但都小于预期的20000次。这是因为,race++在字节码会分为4条指令来执行:
race++对应的4条指令
所以当getstatic取race的时候,volatile保证它是正确的,但是执行iconst_1和iadd的时候,其他线程可能已经执行完了putstatic,这时候再putstatic,就会把较小的值同步回主内存去。
由于volatile变量只能保证可见性,在不符合以下条件规则的去处场景中,仍然需要通过加锁来保证原子性。
1.运算结果不依赖变量的当前值,或者能确保只有单一的线程改变变量的值。
2.变量不需要与其它的状态变量共同参与不变约束。
2. 第二个语义是禁止指令重排优化(或说,保证原子性)。
指令重排是指CPU允许将指令不按程序规定的顺序分开发送给相应的电路单元处理。比如A + 10然后再乘以2,B+3,那么B的操作与A是无关的,可以插入到A+10后面处理。
volatile保证了指令的顺序执行。
下面一段是抄的(我发先这段被很多地方抄了,比如程序员小灰..):
指令重排序是为了优化指令,提高程序运行效率。指令重排序包括编译器重排序和运行时重排序。JVM规范规定,指令重排序可以在不影响单线程程序执行结果前提下进行。
下面两段也是抄的,把double/long/reference的原子性和指令重排解释得非常好:
原子性针对一个long或者double或者一个引用类型,对于引用类型,原子性是指在new实例的过程中,不会被其他线程取到,即不会被其他线程取到一个不完整的实例。这种原子性可以理解为new的过程处于一个synchronize段的set方法中,只有set结束才可以被get到,即new的整个过程都是处于set中的。也可以理解为指令重排序,禁止把new过程的指令与把引用赋值给变量的语句重排序,赋值只发生在new结束之后。
long和double型变量,通常需要分两步读写一个double变量,volatile修饰的double可以保证对一个double变量的操作的两部分不会被多线程插入。以及对引用类型赋值的,new一个实例的过程不会被其他线程插入(new在编译指令中是分成几步执行的,防止这几步在执行过程中被其他线程取这个变量值,取到一个不完整的实例)
例如 instance = new Singleton() 可分解为如下伪代码:
memory = allocate(); //1:分配对象的内存空间
ctorInstance(memory); //2:初始化对象
instance = memory; //3:设置instance指向刚分配的内存地址
指令重排:
memory = allocate(); //1:分配对象的内存空间
instance = memory; //3:设置instance指向刚分配的内存地址
//注意,此时对象还没有被初始化!
ctorInstance(memory); //2:初始化对象
将第2步和第3步调换顺序,在单线程情况下不会影响程序执行的结果,但是在多线程情况下就不一样了。线程A执行了instance = memory(这对另一个线程B来说是可见的),此时线程B执行外层 if (instance == null),发现instance不为空,随即返回,但是得到的却是未被完全初始化的实例,在使用的时候必定会有风险,这正是双重检查锁定的问题所在(不使用volatile的情况下)!
0x05 静态内部类实现单例(懒加载)
除了懒汉和饿汉,这是第三种单例,是前面两种的结合。
public class Singleton {
private static class InstanceHolder {
public static final Singleton INSTANCE = new Singleton();
}
private Singleton() {
}
public static Singleton getInstance() {
return InstanceHolder. INSTANCE;
}
}
我这里把instance写成final
的,我也看到有些blog上没把它写成final,查了下,是因为类的加载过程是线程安全的。
加载类的时候,静态内部类不会被加载。静态内部类不持有外部类的引用。这相当于懒汉,不会占用内存。调用getInstance的时候,会加载静态内部类,同时初始化实例。这相当于饿汉,也是线程安全的。相当于用静态内部类替代synchronized。
总结,上面讲了三种线程安全的单例:
- 提前初始化(饿汉)。
- 双重检查锁定 + volatile。
- 延迟初始化占位类模式。
0x06 反射打破单例
单例的前提是构造函数是private的,这样外面才不能new,只能get。但是如果用反射,可以获取构造器。
利用反射打破单例:
//获得构造器
Constructor con = Singleton.class.getDeclaredConstructor();
//设置为可访问
con.setAccessible(true);
//构造两个不同的对象
Singleton singleton1 = (Singleton)con.newInstance();
Singleton singleton2 = (Singleton)con.newInstance();
//验证是否是不同对象
System.out.println(singleton1.equals(singleton2));
最后的equals判断,结果肯定是false了。
解决方法是使用Enum。
public enum SingletonEnum {
INSTANCE;
}
(这部分程序员小灰写的好像不太对,他忘了把私有构造方法写进去了。他想表达的就是,普通class的构造方法,即便写成private,也是可以反射得到的,而Enum的构造器,在反射的时候会报错。)
Enum语法糖,JVM会阻止反射获取枚举类的私有构造方法。
缺点是枚举类不是懒加载的(这一点我不太懂)。
Ref:
http://blog.csdn.net/zhangzeyuaaa/article/details/42673245
http://blog.sina.com.cn/s/blog_6b6468720100kpif.html
https://www.cnblogs.com/guangshan/p/4977542.html(原子性)
http://blog.csdn.net/bjweimengshu/article/details/78706873