Skip to content

单例模式

单例模式,即保证一个类仅有一个实例,并提供一个访问它的全局访问点。使用场景包括线程池、配置类、工具类等。单例模式在内存中只有一个实例,在频繁调用的场景下,减少了内存的开销,尤其是被实例化的类比较耗资源的时。同时也能避免多线程之间对共享资源占用的交叉影响。

1 实现方式

1.1 饿汉式

实例在类加载时就完成初始化,在整个程序周期内存在,避免了多线程同步的问题。适合单例占用内存小的场景,如果占用内存大,建议使用懒汉式延迟加载的方式实现。

1.1.1 静态常量

java
public class Singleton {

    private static final Singleton INSTANCE = new Singleton();

    private Singleton() {
    }

    public static Singleton getInstance() {
        return INSTANCE;
    }

}

1.1.2 静态代码块

java
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 线程不安全

java
public class Singleton {

    private static Singleton instance;

    private Singleton() {
    }

    public static Singleton getInstance() {
        if (instance == null) {
            instance = new Singleton();
        }
        return instance;
    }

}

该方式未考虑多线程安全的问题,如果两个线程分别走到 if 判断,且创建单例耗时时,就会导致创建两个实例。

Java
// 线程1
if (instance == null) {
    instance = new Singleton();
}

// 线程2
if (instance == null) {
    instance = new Singleton();
}

1.2.2 线程安全,同步方法

为了避免上述线程不安全的情况发生,需要对创建实例的方法进行加锁 synchronized 处理。加锁之后,当每个线程调用 getInstance 方法时会发生锁竞争,性能开销较大。

java
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 线程不安全,同步代码块

为了避免每个线程都去竞争锁,我们可以将锁加载代码块中。这种方式只能缓解,不能避免,开销仍然较大。

java
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 都符合的时候,都会走到锁竞争这一步,都会去创建实例。这是由于 JVMCPU 优化导致的指令重排。具体是因为在执行过程中,以下两个操作可能被重排序:

1、memory = allocate() ; // 分配内存空间

2、instance = memory; // 初始化实例指向刚分配的内存地址

重新排序后,顺序颠倒了,实际发生了先将实例引用指向了内存地址,然后才真正分配内存空间。导致别的线程在 instance 引用指向内存地址后,但实际还未分配到内存时,读取到了非 null 的引用,从而跳过了后面的同步代码块,返回了一个未初始化的示例。为了解决这个问题,就出现了下面的双重校验锁的实现方式。

1.3 双重校验锁

为了解决懒汉式同步代码块的性能开销,大神们又给出了新的思路,使用双重校验锁机制,既保证线程安全,又保证了良好的性能。

java
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 在加载静态内部类时,会先获取其外部类的锁。

  • 由于静态变量使用时要保证线程安全,所以对静态域变量的访问,只能在类初始化时赋值一次。

java
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 只会实例化一次,多线程下保证安全。

Java
public enum Singleton {
    
    INSTANCE;

    public void method() {
        
    }
}

Enum 类是禁止反序列化的,因此也不会被反序列化攻击

Java
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");
}

反射攻击也无效

Java
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 反射攻击

我们以静态内部类实现方式举例。

Java
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
}

为了防止反射攻击,我们可以在构造方法中抛出异常,禁止反射生成实例

Java
private Singleton() {
    throw new RuntimeException("Can't instantiate via reflection");
}

2.2 反序列化攻击

当反射实现了序列化接口时,就会存在反序列化攻击。

Java
public class Singleton implements Serializable
Java
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
}

总结

以上各种实现方式中,无疑枚举是最完美的实现方式,如果不实现序列化接口的前提下,也可以考虑静态内部类实现方式,但需要防止反射攻击。

Released under the MIT License.