相信大家都听过单例模式,在学习设计模式的时候,第一个接触的设计模式大多也是单例模式。在面试中也频繁的出现——写出两种单例模式,单例模式的各种实现方式等等。单例模式看似简单,但仔细思考,深入研究后会发现有很多值得我们学习和注意的地方。接下来,就深入的研究一下单例模式。
什么是单例模式
单例模式是属于创建模式的一种设计模式,在内存中创建对象只会创建一次对象。在我们开发中频繁的创建同一个对象并且作用不变的时候,为了减少频繁的创建对象使得内存飙升带来的问题,我们可以使用单例模式进行解决。单例模式只会创建一个对象,让所有的调用者都共享这一个对象。
单例模式的实现方式
单例模式有两种基本的实现方式:
饿汉式:在类加载的时候就创建好对象,等待程序需调用。
由于在加载的时候就被创建,如果这个对象不被使用,就会造成内存空间的浪费。
懒汉式:在实际使用的时候才会创建对象。
由于线程安全问题,单例模式还有其他的形式,接下来我们来看看这些具体的实现方式。
饿汉式
饿汉式在类加载时已经创建好对象,在程序调用时直接返回该单例对象即可,即我们在编码时就已经指明了要马上创建这个对象,不需要等到被调用时再去创建。代码实现如下:
public class Singleton{
// 私有成员变量: 在类被加载的时候直接创建好对象,在内存中只会有一个对象,类卸载时,对象也会消亡
private static final Singleton singleton = new Singleton();
// 私有构造方法,防止外部创建对象
private Singleton(){}
// 调用者调用的方法
public static Singleton getInstance() {
return singleton;
}
}
懒汉式
懒汉式是在被程序调用的时候判断对象是否已经被创建(obj == null),如果没有被创建则创建该对象并返回,如果已经创建,则直接返回该对象。实现代码如下:
public class Singleton {
private static Singleton singleton;
private Singleton(){}
public static Singleton getInstance() {
// 判断对象是否存在
if (singleton == null) {
// 不存在,创建对象
singleton = new Singleton();
}
// 返回对象
return singleton;
}
我们看一下下边的图片:
假如现在有两个线程,thread 1刚刚执行完第10行代码,此时thread 2抢占了时间片,正巧也是刚刚执行完第10行。注意此时两个线程都要执行第12行的代码,也就是创建对象,换言之线程1和线程2各自new了一个对象,这两个对象并不是同一个对象。也就是说这种形式的单例模式是有线程安全问题的。
我们可以使用以下代码进行测试:
public class client {
public static void main(String[] args) {
for (int i = 0; i < 10; i++) {
new Thread(() -> {
Singleton instance = Singleton.getInstance();
System.out.println(Thread.currentThread().getName() + ">>>" +instance);
}, "Thread" + i).start();
}
}
}
查看输出结果:
可以看到创建的对象并不是同一个对象。这个代码并不是每次执行都会有效果,需要多执行几次。
加锁的形式
懒汉式单例模式是有线程安全问题的,遇到线程安全问题,最容易的办法就是加锁,那么代码就变成了下边的方式:
public static synchronized Singleton getInstance() {
if (singleton == null) {
singleton = new Singleton();
}
return singleton;
}
// 或者
public static Singleton getInstance() {
synchronized(Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
return singleton;
}
现在锁已经加好了,没有了线程安全的风险。但是也迎来的新的问题——性能问题。在多线程的情况下,每次获取对象都要先获取锁,程序的性能会大大降低。
我们加锁的目标是为了在创建对象的时候保证线程安全,在单例中只会创建一个对象,那么对象已经创建的时候就不需要加锁了,只有对象需要创建的时候才需要加锁,所以在方法上加锁的方式就被废弃了。
双重检查+锁模式
上边的加锁模式已经实现了线程安全,但是还是有一个小问题,多线程环境下,线程之间争抢资源,同步等待,效率低下。那我们可以在进入同步代码块之前就进行检查对象是不是已经创建,如果已经创建就不进入同步代码块。那么代码就如下所示:
public static Singleton getInstance() {
if (singleton == null) {
synchronized(Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
这段代码已经解决了线程安全和性能的问题,我们来分析一下这段代码:
假设有A,B,C,D四个线程。A,B,C三个线程都执行完上图第16行代码,此时C线程获得时间片, 执行完毕,C线程结束,此时A,B线程要开始执行了,A获得执行权进入同步代码块,此时的对象由于C线程执行,已经不是null,所以18行的判断是假,A线程直接返回。此时D线程获取执行权,此时的16行已经不是null,也是直接返回,不需要再去和其他线程一样等待就直接返回,提高了效率。
因为需要两次判空,且对类对象加锁,该懒汉式写法也被称为双重检查+锁模式
双重检查+锁结合volatile模式
计算机在执行程序时,为了提高性能,编译器和处理器尝尝会对指令重排,一般分为一下三种:
什么是指令重排呢:比如下边的代码:
```java
int a = 1; // 1
int b = 2; // 2
a = a + 1; // 3
b = a * a; // 4
``
- 那么程序按照1234的顺序可以正常输出,不影响我们最初的逻辑。
- 按照2134,1324顺序和1234也是没有违背最初的逻辑。
- 但是如果说4123的顺序可不可以呢?肯定是不行的,此时a,b变量都没声明,没有办法进行使用(依赖性)。
JVM在保证最终结果正确的情况下,可以不按照程序编码的顺序执行语句,尽可能提高程序的性能
对JVM有了解的同学应该知道对象在创建的时候分为三步:
(1)分配内存空间,
(2)初始化实例,
(3)返回内存地址给引用。
在使用构造器创建对象时,编译器可能会进行指令重排序。假设线程 A 在执行创建对象时,(2)和(3)进行了重排序,如果线程 B 在线程 A 执行(3)时拿到了引用地址,并在第一个检查中判断 singleton != null 了,但此时线程 B 拿到的不是一个完整的对象,在使用对象进行操作时就会出现问题。
所以,这里使用 volatile 修饰 singleton 变量,就是为了禁止在实例化对象时进行指令重排序。
使用volatile关键字可以防止指令重排序,所以现在修改单例模式代码:
public class Singleton {
private static volatile Singleton singleton;
private Singleton(){}
public static Singleton getInstance() {
if (singleton == null) {
synchronized(Singleton.class) {
if (singleton == null) {
singleton = new Singleton();
}
}
}
return singleton;
}
}
对volatile 想要详细了解的请参考: volatile全面解析
单例的破坏
无论是双重检查锁还是饿汉式,终究敌不过反射和序列化,它们俩都可以把单例对象破坏产生多个对象。
序列化破坏
执行下边的代码,返回的两个对象并不是一个对象,单例模式被破坏。
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 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 enum Singleton {
INSTANCE;
}
(1)Enum 类内部使用Enum 类型判定防止通过反射创建多个对象
(2)Enum 类通过写出(读入)对象类型和枚举名字将对象序列化(反序列化),通过 valueOf() 方法匹配枚举名找到内存中的唯一的对象实例,防止通过反序列化构造多个对象
(3)枚举类不需要关注线程安全、破坏单例和性能问题,其创建对象的时机与饿汉式单例类似。