单例模式全解

时间:2024-01-21 09:41:12

目录

设计模式之单例模式

采用单例模式的意义

有些对象我们只需要一个:线程池、缓存等,如果多个实例会有造成冲突、结果的不一致性等问题

单例模式确保一个类最多只有一个实例,并提供一个全局访问点

单例应用的场景

  • 线程池和缓存一般使用单例模式
  • Spring中的Bean默认是单例的
  • 每个servlet也是单例的

常见五种单例模式在多线程下的效率测试

懒汉式>静态内部类式>枚举式>双重检测锁式>饿汉式

代码设计

在单例模式下要用私有构造器

  私有构造器,就是用private关键字声明的构造器。与一般公有构造器最大的区别在于,其访问权限是private,它只能被包含它的类自身所访问,而无法在类的外部调用,故而可以阻止外部实例化对象。

public class Singleton {
    private static Singleton uniqueInstance;
    private Singleton() {
    }
    public static Singleton getUniqueInstance() {
        if (uniqueInstance == null) {
            uniqueInstance = new Singleton();
        }
        return uniqueInstance;
    }
}

这是一个线程不安全的单例,因为如果多个线程能够同时进入 if (uniqueInstance == null) ,并且此时 uniqueInstance 为 null,那么多个线程会执行 uniqueInstance = new Singleton(); 语句,这将导致多次实例化 uniqueInstance。
- - -
可以对getUniqueInstance() 方法加锁:

懒汉式单例

public class Singleton {
    private static Singleton uniqueInstance;
       private Singleton() {
       }
       public static synchronized Singleton getUniqueInstance() {
       if (uniqueInstance == null) {
           uniqueInstance = new Singleton();
       }
       return uniqueInstance;
    }
}

这样有一个问题,就是当一个线程进入该方法之后,其它线程试图进入该方法都必须等待,因此性能上有一定的损耗。

饿汉式单例

public class Singleton {
    private static Singleton uniqueInstance = new Singleton();
       private Singleton() {
       }
       public static synchronized Singleton getUniqueInstance() {
       return uniqueInstance;
    }
}

急切式单例模式不能做到懒加载

双重检测锁(Spring采用模式

采用双重校验锁先判断 uniqueInstance 是否已经被实例化,如果没有被实例化,那么才对实例化语句进行加锁。

public class Singleton {
    private volatile static Singleton uniqueInstance;
    private Singleton() {
    }
    public static Singleton getUniqueInstance() {
        if (uniqueInstance == null) {
            synchronized (Singleton.class) {
                if (uniqueInstance == null) {
                    uniqueInstance = new Singleton();
                }
            }
        }
        return uniqueInstance;
    }
}

如果只有单层判断:

if (uniqueInstance == null) {
    synchronized (Singleton.class) {
        uniqueInstance = new Singleton();
    }
}

在 uniqueInstance == null 的情况下,如果两个线程同时执行if语句,那么两个线程就会同时进入if语句块内。虽然在if语句块内有加锁操作,但是两个线程都会执行uniqueInstance = new Singleton(); 这条语句,只是先后的问题,也就是说会进行两次实例化,从而产生了两个实例。

单例模式中的volatile关键字

  背景:在早期的JVM中,synchronized存在巨大的性能开销。因此,有人想出了一个“聪明”的技巧:双重检查锁定(Double-Checked Locking)。人们想通过双重检查锁定来降低同步的开销。下面是使用双重检查锁定来实现延迟初始化的示例代码。

public class DoubleCheckedLocking { // 1
    private static Instance instance; // 2
    public static Instance getInstance() { // 3
        if (instance == null) { // 4:第一次检查
            synchronized (DoubleCheckedLocking.class) { // 5:加锁
                if (instance == null) // 6:第二次检查
                instance = new Instance(); // 7:问题的根源出在这里
            } // 8
        } // 9
        return instance; // 10
    } // 11
}

上述的Instance类变量是没有用volatile关键字修饰的,会导致这样一个问题:
在线程执行到第4行的时候,代码读取到instance不为null时,instance引用的对象有可能还没有完成初始化。
主要的原因是重排序。重排序是指编译器和处理器为了优化程序性能而对指令序列进行重新排序的一种手段。
第7行的代码创建了一个对象,这一行代码可以分解成3个操作:

memory = allocate();  // 1:分配对象的内存空间
ctorInstance(memory); // 2:初始化对象
instance = memory;  // 3:设置instance指向刚分配的内存地址

根源在于代码中的2和3之间,可能会被重排序。例如:

memory = allocate();  // 1:分配对象的内存空间
instance = memory;  // 3:设置instance指向刚分配的内存地址
// 注意,此时对象还没有被初始化!
ctorInstance(memory); // 2:初始化对象

这在单线程环境下是没有问题的,但在多线程环境下会出现问题:

B线程会看到一个还没有被初始化的对象。

A2和A3的重排序不影响线程A的最终结果,但会导致线程B在B1处判断出instance不为空,线程B接下来将访问instance引用的对象。此时,线程B将会访问到一个还未初始化的对象。
所以只需要做一点小的修改(把instance声明为volatile型),就可以实现线程安全的延迟初始化。因为被volatile关键字修饰的变量是被禁止重排序的。

静态内部类实现

  • 通过反射,是不能从外部类获取内部类的属性的
  • 由于静态内部类的特性,只有在其被第一次引用的时候才会被加载,所以可以保证其线程安全性
public calss Singleton {
    private Singleton(){
    }
    private static class SingletonClassInstance{
        private static final SingletonDemo instance = new SingletonDemo();
    }
    public static SingletonDemo getinstance(){
        return SingletonClassInstance.instance;
    }
}

避免反射和序列化来破坏单例的解决方案

反射:获得类的构造器后用setAccessible(true)绕过权限检查,可以直接调用私有构造器来生成实例。

解决方法:在私有构造器里抛出异常:

private Singleton(){
    if(instance!=null){
        throw new RuntimeException();
    }
}

通过序列化反序列化方式可以破坏单例模式
解决方法:定义Object readResolve()方法

这个方法会紧挨着readObject()之后被调用,该方法的返回值将会代替原来反序列化的对象,而原来readObject()反序列化的对象将会立即丢弃。readObject()方法在序列化单例类,枚举类时尤其有用。

private Object readResolve() throws ObjectStreamException{
    return instance;
}

Effective Java作者Josh Bloch 提倡使用枚举的方式,因为创建一个enum类型是线程安全的。这种方法在功能上与公有域方法相近,但是它更加简洁,无偿提供了序列化机制,绝对防止多次实例化,即使是在面对复杂序列化或者反射攻击的时候。

public enum Singleton {
    uniqueInstance;
}

枚举实现

枚举实现的实例:

public enum Singleton {  
    INSTANCE;  
    public void method() {  
    }  
}  

枚举可解决线程安全问题

上面的双重锁校验的代码之所以很臃肿,是因为大部分代码都是在保证线程安全。为了在保证线程安全和锁粒度之间做权衡,代码难免会写的复杂些。但是,这段代码还是有问题的,因为他无法解决反序列化会破坏单例的问题。

并不是使用枚举就不需要保证线程安全,只不过线程安全的保证不需要我们关心而已。也就是说,其实在“底层”还是做了线程安全方面的保证的。

定义枚举时使用enum和class一样,是Java中的一个关键字。就像class对应用一个Class类一样,enum也对应有一个Enum类。

通过将定义好的枚举反编译,我们就能发现,其实枚举在经过javac的编译之后,会被转换成形如public final class T extends Enum的定义。

例如:

public enum T {
    SPRING,SUMMER,AUTUMN,WINTER;
}

反编译后代码为:

public final class T extends Enum
{
    //省略部分内容
    public static final T SPRING;
    public static final T SUMMER;
    public static final T AUTUMN;
    public static final T WINTER;
    private static final T 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
        });
    }
}

我们知道,static类型的属性会在类被加载之后被初始化,我们在深度分析Java的ClassLoader机制(源码级别)中介绍过,当一个Java类第一次被真正使用到的时候静态资源被初始化、Java类的加载和初始化过程都是线程安全的(因为虚拟机在加载枚举的类的时候,会使用ClassLoader的loadClass方法,而这个方法使用同步代码块保证了线程安全)。所以,创建一个enum类型是线程安全的。

也就是说,我们定义的一个枚举,在第一次被真正用到的时候,会被虚拟机加载并初始化,而这个初始化过程是线程安全的。而我们知道,解决单例的并发问题,主要解决的就是初始化过程中的线程安全问题。所以,由于枚举的以上特性,枚举实现的单例是天生线程安全的。

枚举可避免反序列化破坏单例

在序列化的时候Java仅仅是将枚举对象的name属性输出到结果中,反序列化的时候则是通过java.lang.Enum的valueOf方法来根据名字查找枚举对象。同时,编译器是不允许任何对这种序列化机制的定制的,因此禁用了writeObject、readObject等方法。

普通的Java类的反序列化过程中,会通过反射调用类的默认构造函数来初始化对象。所以,即使单例中构造函数是私有的,也会被反射给破坏掉。由于反序列化后的对象是重新new出来的,所以这就破坏了单例。

但是,枚举的反序列化并不是通过反射实现的。所以,也就不会发生由于反序列化导致的单例破坏问题。