单例模式(Singleton)

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

1. 饿汉式

变量在申明时即被初始化。

public class Singleton {
    private static Singleton instance = new Singleton();

    public static Singleton getInstance() {
        return instance;
    }

    private Singleton() {}
} 

优点:线程安全;简洁。

有人觉得这种实现方式不好,因为不支持延迟加载,如果实例占用资源多(比如占用内存多)或初始化耗时长(比如需要加载各种配置文件),提前初始化实例是一种浪费资源的行为。最好的方法应该在用到的时候再去初始化。

不过,我个人并不认同这样的观点。如果初始化耗时长,那我们最好不要等到真正要用它的时候,才去执行这个耗时长的初始化过程,这会影响到系统的性能(比如,在响应客户端接口请求的时候,做这个初始化操作,会导致此请求的响应时间变长,甚至超时)。采用饿汉式实现方式,将耗时的初始化操作,提前到程序启动的时候完成,这样就能避免在程序运行的时候,再去初始化导致的性能问题。

如果实例占用资源多,按照 fail-fast 的设计原则(有问题及早暴露),那我们也希望在程序启动时就将这个实例初始化好。如果资源不够,就会在程序启动的时候触发报错(比如 Java 中的 PermGen Space OOM),我们可以立即去修复。这样也能避免在程序运行一段时间后,突然因为初始化这个实例占用资源过多,导致系统崩溃,影响系统的可用性。

JDK 中典型的饿汉式单例的例子,Runtime类。

2. 懒汉式

先声明一个空变量,需要用时才初始化。

public class Singleton {
    private static Singleton instance;

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

    private Singleton() {}
}

这种写法简单,缺点是线程不安全。

可以在方法上加synchronized修饰来保证线程安全,但是这种方式并发度比较低,如果这个类只是偶尔用到还能接受,如果频繁使用,那频繁的加锁、释放锁以及并发度低的问题带来了性能瓶颈,就不可取了。

3. 双检锁

public class Singleton {
    private static volatile Singleton instance;

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

    private Singleton() {}
}

这种写法的优点:既支持延迟加载、又支持高并发。

现在就这种写法问你两个问题:

  1. 为什么双检锁的方式是这种写法,为什么这种写法性能更好?它相比于方式一有何优点?
  2. 为什么要加volatile

针对第一个问题:首先,我们否定了方式一直接在方法上加锁的这种写法,因为它性能低。那么,我们就synchronized 在方法头上拿掉,进入方法体,第一步要做的肯定是判空,那synchronized能不能加在第一个if外面?肯定不能,这样的写法跟加在方法头没区别,所以方法中的第一行代码没问题。其次,我们判空后,又继续判一次空,并且是加了锁的,这么做的目的是什么?试想一下,有两个线程同时执行完了第一个if判断,准备执行加锁语句,这时候线程 1 抢到了这把锁,线程 2 只好等待。线程 1 继续执行判断,然后实例化对象。然后线程 2 再判空,不需要再实例化对象了。当对象实例化完成,后面的所有线程再获取对象时,都不用再走加锁逻辑了,性能更好了。

针对第二个问题:new Singleton()不是一个原子操作,CPU 指令重排序可能导致在Singleton类的对象被关键字 new 创建并赋值给instance之后,还没来得及初始化(比如执行构造函数中的代码逻辑),就被另一个线程使用了。这样,另一个线程就使用了一个没有完整初始化的Singleton类的对象。volatile关键字可以禁止指令重排序。

3.1 双检锁的优化:使用本地变量

public class Singleton {
    private static volatile Singleton instance;

    public static Singleton getInstance() {
        Singleton localRef = instance; // (1)
        if (localRef == null) {
            synchronized (Singleton.class) {
                localRef = instance; // (2)
                if (localRef == null) {
                    localRef = new Singleton();
                }
            }
        }
        return localRef; // (3)
    }

    private Singleton() {}
}

使用本地变量,只用从主内存中访问一次 volatile 变量。否则就要访问两次,一次是判空,一次是方法结束时 return。访问 valatile 变量,意味着每次都取访问主内存,这是有性能消耗的。
以下是引用自一位博主的文章

  • (1) Using localRef, we are reducing the access of volatile variable to just one for positive usecase. If we do not use localRef, then we would have to access volatile variable twice - once for checking null and then at method return time. Accessing volatile memory is quite an expensive affair because it involves reaching out to main memory.
  • (2) Refreshing local reference to latest value after acquiring a lock, since volatile variable may have changed by this time due.
  • (3) volatile variable is accessed at method return time.

4. 静态内部类

public class Singleton {
    public static Singleton getInstance() {
        return SingletonHolder.instance;
    }

    private static class SingletonHolder {
        public static Singleton instance = new Singleton();
    }

    private Singleton() {}
}

这种写法的优点:代码简洁,线程安全。

为什么这种方式能保证线程安全?来分析两个问题:

  • 静态内部类方式是怎么实现懒加载的
  • 静态内部类方式是怎么保证线程安全的

类在初始化的时候,并不会立即加载内部类,内部类会在使用时才加载。所以当此Singleton类加载时,SingletonHolder并不会被立即加载,所以不会像饿汉式那样占用内存。另外,Java 虚拟机规定,当访问一个类的静态字段时,如果该类尚未初始化,则立即初始化此类。当调用SingletongetInstance方法时,由于其使用了SingletonHolder的静态变量instance,所以这时才会去初始化SingletonHolder,在 SingletonHolder中 new 出Singleton对象。这就实现了懒加载。

其次,在初始化类的时候,JVM 会保证在多线程场景下初始化过程会被正确的加锁和同步,从而保证了线程安全。

5. 枚举

public enum  Singleton {

    INSTANCE;
    
    Singleton() {}
}

这种写法的优点:代码简洁,由 JVM 保证线程安全和单一实例。还可以有效防止序列化和反序列化造成多个实例和利用反射创建多个实例的情况。

它的怎么保证的线程安全的呢?枚举类的每个成员变量都是static final的,静态资源随着类的初始化而初始化,而且这个过程是由 JVM 保证线程安全。

6. 总结

饿汉式、静态内部类(懒汉式的一种)、还有枚举方式都是不错的选择。饿汉式的在服务启动时增加的时间损耗可以接受,还能早发现问题;如果实在有启动时的性能要求,可考虑使用代码简洁的静态内部类方式;存在对象序列化问题的可考虑使用枚举类方式。

posted @ 2022-11-05 06:59  xfcoding  阅读(72)  评论(0编辑  收藏  举报