面试官:单例模式了解吗?
我:创建型模式中的一种,指系统中只存在一个实例,所有操作都来使用这一个实例。
面试官:好,写一下?
我:emmmm。。。
别怕,来对地方了。
单例模式有两种类型:懒汉型 和 饿汉型
饿汉型类加载的初始化阶段就会直接让单例对象的实例创建并分配好
public class Singleton { private static final Singleton instance = new Singleton(); private Singleton(){} public static Singleton getInstance() { return instance; } }
这里可能要注意的就是这个单例对象要是static和final的
-
static是因为我只想让它在类加载的初始化的时候创建一次,这也是单例的意义所在
-
final是因为我不想让它被指向别处,当然更重要的是我不想让它在初始化未结束的时候就被别人访问到
然后注意单例和构造方法都要是private的,因为我们并不想让外部自己创建自己的singleton,我们要让别人用我们已经创建好的这个singleton。
饿汉式单例的缺点在于加载类的时候就要进行初始化,如果这个单例很费空间,那类多了内存也就浪费了,因为创建了一堆当前不需要的单例对象。
那我们再来看看懒汉式:
public class Singleton { private static volatile Singleton instance; private Singleton(){} public static Singleton getInstance() { if(instance==null) { synchronized(Singleton.class) { if(instance==null) { instance = new Singleton(); } } } return instance; } }
这个双锁肯定面试官要抓住你狠狠地问,不虚不虚:
-
为什么要加synchronized:因为不能让两个线程同时进行初始化,会很混乱
-
为什么拿到锁之后还要判断:因为在拿到锁之前可能别人先拿到锁进行了初始化,这时instance已经初始化好了,如果不判断就又初始化了一遍,覆盖了上一个instance了
-
那可以把synchronized直接加到方法上吗:不可以,这样每个想要获得这个单例的线程都要排队,性能太差(把第一个判断去掉也是同理)
-
那为什么要加volatile呢:这是为了防止初始化阶段的重排序,java的重排序有编译器的重排序和处理器的重排序,初始化阶段可以分为三个步骤:1.分配空间 2.填入值 3.将这块内存的引用赋给instance变量,重排序可能让23顺序颠倒,也就是可能出现我们访问instance,但得到的里面的变量都是默认值(基本类型是0,对象类型是null)的情况,这就导致一个线程还在synchronized里面慢慢初始化,另一个线程已经在synchronized外面判断instance!=null然后返回这个半生不熟的instance了,导致错误。而volatile可以通过在变量读写前后加读写屏障(read后面加LoadLoad,LoadStore,write前面加StoreStore,后面加StoreLoad)达到防止volatile写前面的写操作重排到volatile写后面去的效果,也就防止了第二步「填入值」的操作(其他写操作)重排到「将内存的引用赋值给变量」(vloatile写)的后面去。
本来以为到此万事大吉了,结果面试官又开口了:那你还知道其他方法吗?
好狠毒的人!
我还知道一种基于类初始化的解决方案:
public class InstanceFactory{ private static class InstanceHolder { public static Instance instance = new Instance(); } public static Instance getInstance() { return InstanceHolder.instance; //这里将导致InstanceHolder类被初始化 } }
能讲讲这个方法起作用的原理吗?
-
这个方案被称为Initialization On Demand Holder idiom
-
JVM在类的“初始化”阶段(即在Class被加载后,且被线程使用之前),会执行类的初始化。在执行类的初始化期间,JVM会去获取一个锁,这个锁可以同步多个线程对同一个类的初始化。
-
若有两个线程同时执行getInstance方法,则会触发内部类InstanceHolder的初始化
-
这里提一嘴,如果你类加载很熟的话,你可以把面试官往这上面引,说一下会触发类初始化的6种情况:
-
遇到new, getstatic, putstatic, invokeestatic这四条字节码指令时
-
new
-
静态字段(被final修饰、已在编译期把结果放入常量池的静态字段除外)
-
静态方法
-
-
反射调用的类
-
正要初始化类的父类
-
main方法所在的类
-
被动态代理的类的静态字段or方法
-
接口中有default且其实现类被初始化
-
-
Java语言规范规定,对于每一个类或接口C,都有一个唯一的初始化锁LC与之对应。从C到LC的映射,由JVM的具体实现去自由实现。JVM在类初始化期间会获取这个初始化锁,并且每个线程至少获取一次锁来确保这个类已经被初始化过了。
-
分步骤来讲,Java初始化一个类或接口的处理过程如下:
-
通过在Class对象上同步(即获取Class对象的初始化锁),来控制类或接口的初始化。这个获取锁的线程会一直等待,直到当前线程能够获取到这个初始化锁
-
抢到初始化锁的线程开始执行类的初始化,同时其他线程在初始化锁对应的condition上等待
-
执行初始化动作的锁完成后设置state = initialized,然后唤醒在condition中等待的所有线程。
-
其他线程检查state为initialized后直接取用
-
-
这就使我们的单例模式中只能有一个线程在进行初始化,别的线程只能等待,这样初始化内部还是会重排序的,只是外部看不到这些重排序了。
当然,还有终极一问:
-
那先前说过的两种初始化方式,能打破吗?
-
还真能,用 反射 或 序列化:
public static void main(String[] args) { //获取类的显式构造器 Constructor<Singleton> construct = Singleton.class.getDeclaredConstructor(); //可访问私有构造器 construct.setAccessible(true); //利用反射构造新对象 Singleton obj1 = construct.newInstance(); //通过正常方式获取单例对象 Singleton obj2 = Singleton.getInstance(); System.out.println(obj1 == obj2); //false }
public static void main(String[] args){ //创建输出流 ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("Singleton.file")); //将单例对象写到文件中 oos.writeObject(Singleton.getInstance()); // 从文件中读取单例对象 File file = new File("Singleton.file"); ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file)); Singleton newInstance = (Singleton) ois.readObject(); // 判断是否是同一个对象 System.out.println(newInstance == Singleton.getInstance()); // false }
-
那有没有可以防住这两招的方法呢?
还真有
-
public enum Singleton { INSTANCE; }
-
没了?
没了,就这么简单。
优势一:简单
优势二:线程安全,枚举类就是类的静态字段,只会在类初始化时初始化一次
优势三:防反射、防序列化
防反射:
防序列化:
在读入Singleton对象时,每个枚举类型和枚举名字都是唯一的,所以在序列化时,仅仅只是对枚举的类型和变量名输出到文件中,在读入文件反序列化成对象时,利用 Enum 类的 valueOf(String name) 方法根据变量的名字查找对应的枚举对象。
所以,在序列化和反序列化的过程中,只是写出和读入了枚举类型和名字,没有任何关于对象的操作。
(1)Enum 类内部使用Enum 类型判定防止通过反射创建多个对象
(2)Enum 类通过写出(读入)对象类型和枚举名字将对象序列化(反序列化),通过 valueOf() 方法匹配枚举名找到内存中的唯一的对象实例,防止通过反序列化构造多个对象
(3)枚举类不需要关注线程安全、破坏单例和性能问题,因为其创建对象的时机与饿汉式单例有异曲同工之妙。