单例模式
单例模式,即保证一个类仅有一个实例,并提供一个访问它的全局访问点。使用场景包括线程池、配置类、工具类等。单例模式在内存中只有一个实例,在频繁调用的场景下,减少了内存的开销,尤其是被实例化的类比较耗资源的时。同时也能避免多线程之间对共享资源占用的交叉影响。
1 实现方式
1.1 饿汉式
实例在类加载时就完成初始化,在整个程序周期内存在,避免了多线程同步的问题。适合单例占用内存小的场景,如果占用内存大,建议使用懒汉式延迟加载的方式实现。
1.1.1 静态常量
public class Singleton {
private static final Singleton INSTANCE = new Singleton();
private Singleton() {
}
public static Singleton getInstance() {
return INSTANCE;
}
}
1.1.2 静态代码块
public class Singleton {
private static final Singleton instance;
static {
instance = new Singleton();
}
private Singleton() {
}
public static Singleton getInstance() {
return instance;
}
}
优点:
- 实现简单,在类初始化时完成实例化
- 避免了多线程的同步问题
缺点:
- 在类加载的时候就完成实例化,无论是否使用都占用资源
- 扩展性较差,如果要更换实现则比较复杂
1.2 懒汉式
懒汉式,是指单例在需要的时候才创建,适合单例使用次数少,且创建单例消耗资源较多的场景。
1.2.1 线程不安全
public class Singleton {
private static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
该方式未考虑多线程安全的问题,如果两个线程分别走到 if 判断,且创建单例耗时时,就会导致创建两个实例。
// 线程1
if (instance == null) {
instance = new Singleton();
}
// 线程2
if (instance == null) {
instance = new Singleton();
}
1.2.2 线程安全,同步方法
为了避免上述线程不安全的情况发生,需要对创建实例的方法进行加锁 synchronized 处理。加锁之后,当每个线程调用 getInstance 方法时会发生锁竞争,性能开销较大。
public class Singleton {
private static Singleton instance;
private Singleton() {
}
public static synchronized Singleton getInstance() {
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
优点:
- 解决了线程安全问题
- 实现了延迟加载
缺点:
- 加锁 synchronized 导致其他线程等待已持有锁的线程执行完毕并释放锁,累计开销较大
1.2.3 线程不安全,同步代码块
为了避免每个线程都去竞争锁,我们可以将锁加载代码块中。这种方式只能缓解,不能避免,开销仍然较大。
public class Singleton {
private static Singleton instance;
private Singleton() {
}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
instance = new Singleton();
}
}
return instance;
}
}
同步代码块的方式存在线程安全风险,当两个线程判断 if 都符合的时候,都会走到锁竞争这一步,都会去创建实例。这是由于 JVM 和 CPU 优化导致的指令重排。具体是因为在执行过程中,以下两个操作可能被重排序:
1、memory = allocate() ; // 分配内存空间
2、instance = memory; // 初始化实例指向刚分配的内存地址
重新排序后,顺序颠倒了,实际发生了先将实例引用指向了内存地址,然后才真正分配内存空间。导致别的线程在 instance 引用指向内存地址后,但实际还未分配到内存时,读取到了非 null 的引用,从而跳过了后面的同步代码块,返回了一个未初始化的示例。为了解决这个问题,就出现了下面的双重校验锁的实现方式。
1.3 双重校验锁
为了解决懒汉式同步代码块的性能开销,大神们又给出了新的思路,使用双重校验锁机制,既保证线程安全,又保证了良好的性能。
public class Singleton {
private volatile static Singleton instance;
private Singleton (){}
public static Singleton getInstance() {
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
可以看到,相比懒汉式的代码,多了 volatile 关键字和双重判空。 volatile 关键字禁止了指令重排,保证每个操作按顺序执行,是实现 DCL 单例的关键。而同步代码块内部再加一层判空,保证了不会重复创建实例。
1.4 静态内部类
除了上述三种方式,我们再来看一下更优雅的实现方式。通过在静态内部类中初始化单例实例,可以保证线程安全和延迟加载。
利用静态内部类的特点实现延迟加载:
静态内部类不会随外部类初始化而初始化,只有首次被访问(加载)时才会加载。
JVM 在加载静态内部类时,会先获取其外部类的锁。
由于静态变量使用时要保证线程安全,所以对静态域变量的访问,只能在类初始化时赋值一次。
public class Singleton {
private Singleton() {
}
public static Singleton getInstance() {
return SingletonHolder.INSTANCE;
}
private static class SingletonHolder {
private static final Singleton INSTANCE = new Singleton();
}
}
1.5 枚举
枚举类会继承 Enum 类,天生具有单例特性,JVM 只会实例化一次,多线程下保证安全。
public enum Singleton {
INSTANCE;
public void method() {
}
}
Enum 类是禁止反序列化的,因此也不会被反序列化攻击
private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException {
throw new InvalidObjectException("can't deserialize enum");
}
private void readObjectNoData() throws ObjectStreamException {
throw new InvalidObjectException("can't deserialize enum");
}
反射攻击也无效
public static void main(String[] args) throws Exception {
Constructor constructor = Singleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
constructor.newInstance();
}
// Exception in thread "main" java.lang.NoSuchMethodException
2 单例攻击
除了枚举外,其他实现方式基本上都可以通过反射的方式生成新的实例。如果单例类实现了序列化接口,也会面临反序列化的方式生成新的实例。
2.1 反射攻击
我们以静态内部类实现方式举例。
public static void main(String[] args) throws Exception {
Constructor constructor = Singleton.class.getDeclaredConstructor();
constructor.setAccessible(true);
Singleton instance1 = (Singleton) constructor.newInstance();
Singleton instance2 = (Singleton) constructor.newInstance();
System.out.println(instance2 == instance1); // false
}
为了防止反射攻击,我们可以在构造方法中抛出异常,禁止反射生成实例
private Singleton() {
throw new RuntimeException("Can't instantiate via reflection");
}
2.2 反序列化攻击
当反射实现了序列化接口时,就会存在反序列化攻击。
public class Singleton implements Serializable
public static void main(String[] args) throws Exception {
// 序列化
Singleton instance = Singleton.getInstance();
ObjectOutput out = new ObjectOutputStream(new FileOutputStream("D:\\a.txt"));
out.writeObject(instance);
out.close();
// 反序列化
ObjectInput in = new ObjectInputStream(new FileInputStream("D:\\a.txt"));
Singleton newInstance = (Singleton) in.readObject();
in.close();
// 实例已被破坏,newInstance != instance
System.out.println(instance == newInstance); // false
}
总结
以上各种实现方式中,无疑枚举是最完美的实现方式,如果不实现序列化接口的前提下,也可以考虑静态内部类实现方式,但需要防止反射攻击。