1.前言
单例模式一直有很多种,什么饿汉式懒汉式啥的,还有线程安全线程不安全,本文结合我自己的理解,把我认为的单例写法都说一遍,并解释为什么要这么写。想写好单例,细节点其实很重要。大家先回顾下很重要的基础知识。
1.1 类加载机制
类的加载过程主要分为五步:
加载、连接(验证、准备、解析)、初始化
加载:
- 通过类的全限定名获取定义该类的二进制字节流。
- 将字节流所代表的静态存储结构转换为方法区运行的数据结构,也就是将类信息加载到方法区中。
- 在方法区生成一个Class对象,作为访问这些数据的入口
验证: 主要是进行验证操作,如文件格式验证,字节码验证等等。
准备: 为类变量设置初始值的阶段(是设置初始值,不是设置值,比如static int count = 0)。
解析: 虚拟机将常量池内的符号引用转换为直接引用。
初始化: 其实就是执行static静态块和static变量赋值的过程,谁先定义,就初始化谁。
其实类加载机制的体现就是ClassLoader里面的loadClass,方法里面通过用synchronized关键字去修饰,表明是线程安全的。
1.2 对象的创建
对象的创建过程主要分为以下几步:
- 类加载检查:如果类还没有被加载,那么会先去执行类加载
- 分配内存:为对象分配堆内存
- 指针碰撞:适用于堆内存完整的情况,也就是在堆内存中,已经使用的内存在一边,没有使用的内存在另一边,那么中间的边界线是用一个指针来表示,那么实际上分配内存空间的过程就是指针移动的过程。
- 空闲列表:使用于堆内存不完整的情况,那么JVM会维护一个空闲列表,记录着哪块内存空间空闲,大小是多少,那么分配对象内存的时候只要遍历这张表,找到合适的空闲列表就可以了。
并发情况下,一般是使用TLAB,TLAB的意思是为每一个线程预先在Eden区分配一块内存,那么这个线程为对象分配内存的时候,预先使用这块内存,如果分配失败了再使用CAS+失败重试机制。
- 初始化零值:Java对象在内存中的布局,对实例数据进行初始化,这里的初始化只是初始化零值而已。
- 设置对象头:Java对象头分为Mark World以及类型指针,其实就是设置对象的GC分代年龄,HashCode等等。
- 执行init:执行父类成员变量赋值,再到执行父类构造函数,再执行子类成员变量赋值,执行子类构造函数。
2.饿汉式
public class Singleton {
public static Singleton singleton = new Singleton();
private Singleton(){
}
//
public static int count = 1;
}
这就是典型的单例模式饿汉式,所谓饿汉式就是通过类加载机制加载该类的时候就会生成单例对象。
先简单回顾一下类加载机制,后面会很有用。
回到单例模式饿汉式,这种写法有个问题,也就是我只想用count变量,但是没办法,你类加载过程中的初始化已经把我的单例对象给初始化了,浪费了内存。
3.Double Check
老规矩,先上代码,然后再逐一解释
public class Singleton {
private static volatile Singleton singleton ;
private Singleton(){ }
public static Singleton getInstance(){
if(singleton == null){
synchronized (Singleton.class){
if(singleton == null){
//memory = allocate() 1.分配对象内存空间
//instance(memory) 2.初始化对象
//instance = memory 3.设置Instance指向的内存地址,此时instance!=null
singleton = new Singleton();
}
}
}
return singleton;
}
}
首先为什么要用Synchronized?
当然也可以把Syncrhonized放在外面,当时这样每次执行getInstance方法的线程数只能有一个,效率大打折扣,因为把synchronized放在里面。
特别要注意为什么要使用volatile?
这里使用volatile主要是对象的创建有关,总的来说就是分配对象内存空间、为对象赋值、对象指向分配好的内存空间。 但是这里可能由于编译器为了提高效率,对指令进行了重排序,也就是改成了分配对象内存空间、对象指向分配好的内存空间、为对象赋值,如果执行到第二步的时候,突然有另外一个线程进来,判断了singleTon != null,那么就直接返回了,但是这个线程拿到的是一个不是完全体的对象,里面没有数据。
因此使用了volatile的防止指令重排序的功能。 因此单例对象得要被volatile修饰。
4.静态内部类
上面的饿汉式提前加载浪费内存、Double check看起来很臃肿。因此有了静态内部类的形式。
public class Singleton {
private Singleton() {
}
private static class SingletonHolder {
private static final Singleton singleton = new Singleton();
}
public static Singleton getInstance() {
return SingletonHolder.singleton;
}
}
其中SingletonHolder 只有调用了getInstance方法才会被初始化,因此实现了懒加载的功能。同时也利用了类加载机制实现了现场安全。
5.枚举实现单例
上面的都有一个问题,哪怕都是线程安全的,但是不一定严格意义上的线程安全,因为都可以通过反射来新健一个对象:
public static void main(String[] args) throws NoSuchMethodException, IllegalAccessException, InvocationTargetException, InstantiationException {
System.out.println("传统方法获取:" + Singleton.singleton);
Class<Singleton> singletonClass = Singleton.class;
Constructor<Singleton> declaredConstructor = singletonClass.getDeclaredConstructor();
declaredConstructor.setAccessible(true);
Singleton instance = declaredConstructor.newInstance();
System.out.println("反射方法获取:" + instance);
}
所以严格意义来说都不是安全的。
下面给出枚举类实现单例的形式:
public enum SingletonTwo {
INSTANCE;
public void doSomething(){
System.out.println("枚举实现单例");
}
}
枚举类如何做到线程安全?
枚举类的关键词是enum,就跟class关键字对应的Class类,那么enum也对应一个Enum类。
通过Javap -c -v 反编译大概可以得出以下内容:
public final class Singleton extends Enum
{
//省略部分内容
public static final Singleton SPRING;
public static final Singleton SUMMER;
public static final Singleton AUTUMN;
public static final Singleton WINTER;
private static final Singleton ENUM$VALUES[];
static
{
SPRING = new T("SPRING", 0);
SUMMER = new T("SUMMER", 1);
AUTUMN = new T("AUTUMN", 2);
WINTER = new T("WINTER", 3);
ENUM$VALUES = (new T[] {
SPRING, SUMMER, AUTUMN, WINTER
});
}
}
可以发现我们自定义的枚举继承了Enum,并且我们在枚举里面自定义的变量都用public static final来修饰,并且通过static静态块来进行初始化(因为ClassLoader的loadClass方法是用synchronized修饰的,所以是线程安全的)。
并且在我们原本的无参构造方法多了两个参数:name、order,name表示他们的对象名字,order则表示初始化的顺序,里面的变量初始化完成后,会放在一个valuesOf数组中。
枚举类如何破坏反射机制
哪怕用私有构造方法,一样都可以通过反射机制来新建一个对象。
但是你尝试获取枚举类的构造函数时,会给抛出method not pattern的异常。
因为上面也有说了枚举类的真正实现是会为构造函数加两个参数name、order,那尝试用反射获取到对应的构造函数时,确实能够获取。
但是到新建对象时,就给你抛出异常了。
这就解释清楚了,枚举已经把反射利用构造函数创建对象这条路封死了。
枚举如何破坏序列化和反序列化
从官方的解释中。在序列化的时候Java仅仅是将枚举对象的name属性输出到结果中,反序列化的时候则是通过java.lang.Enum的valueOf方法来根据名字查找枚举对象,也就是那个values数组。
那我可以自己写一个序列化和反序列化。
可惜的是。反序列化也是通过反射机制获取空的构造函数来新建对象,可惜的是枚举已经把构造函数这条路封死了。