单例模式是应用最广的设计模式之一,也是程序员最熟悉的一个设计模式,使用单例模式的类必须保证只能有创建一个对象。
一、为什么要使用单例?
在开发过程中,很多时候一个类我们希望它只创建一个对象,比如:线程池、缓存、网络请求等。当这类对象有多个实例时,程序就可能会出现异常,比如:程序出现异常行为、得到的结果不一致等。
这时候就应该使用单例模式。
单例主要有这两个优点:
1、提供了对唯一实例的受控访问。
2、由于在系统内存中只存在一个对象,因此可以节约系统资源,对于一些需要频繁创建和销毁的对象单例模式无疑可以提高系统的性能。
二、实现单例的 5 种方式
实现单例模式主要有以下几个关键点:
构造函数设置为 private ,这避免外部通过 new 创建实例;
通过一个静态方法或者枚举返回单例类对象;
考虑对象创建时的线程安全问题,确保单例类的对象有且仅有一个,尤其是在多线程环境下;
确保单例类对象在反序列化时不会重新构建对象。
考虑是否支持延迟加载;
下面是常见的集中单例模式的实现方式
1、饿汉式
在类加载的期间,就已经将 instance 静态实例初始化好了,所以,instance 实例的创建是线程安全的。不过,这样的实现方式不支持延迟加载实例。
public class Singleton {
private Singleton(){}
private static final Singleton instance = new Singleton();
public static Singleton getInstance(){
return instance;
}
}
2、懒汉式
懒汉式相对于饿汉式的优势是支持延迟加载。
public class Singleton {
private Singleton(){}
private static Singleton instance;
public static synchronized Singleton getInstance(){
if (instance == null) {
instance = new Singleton();
}
return instance;
}
}
但它的缺点也很明显,getInstance 使用了 synchronize 实现线程同步,导致这个方法的并发很低,每次调用都会频繁的枷锁、释放锁,会导致性能瓶颈。
3、双重检测
饿汉式不能延时加载,懒汉式有性能问题,而双重检测方式既支持延迟加载、又支持高并发的单例实现方式。
当 instance 对象被创建后,再次调用 getInstance 方法不再会进入 synchronize 加锁的代码之中。
它的优点是:资源利用率高,第一次执行 getInstance 时才会被实例化,效率高。缺点是:第一次加载反应稍慢。
public class Singleton {
private Singleton(){}
private static Singleton instance;
public static Singleton getInstance(){
if (instance == null) {
synchronized (Singleton.class) {
if (instance == null) {
instance = new Singleton();
}
}
}
return instance;
}
}
有时候,面试官会问这种实现方式有什么问题。他们指的就是指令重排序。
instance = new Singleton(); 并不是一个原子操作, 这句代码实际执行了三件事。
1、 给 Singleton 的实例分配内存;
2、调用 Singleton 的构造函数,初始化成员变量;
3、将 instance 的对象指向分配的内存空间。
因为 Java 编译器允许处理器乱序执行,2、3的顺序是无法保证的。如果是 1-3-2 执行的顺序,当执行完 3 、2未执行之前,被切换到 B 线程,此时 instance 已经非空,B 会直接取走 instance,在使用时就会出错。
这就是指令重排。
解决办法也很简单:只需要给 instance 成员变量加上 volatile 关键字,就可以禁止指令重排序。
其实这个问题在高版本的 java 中已经被解决了,解决方式也很简单,就是把对象 new 操作和初始化操作设计为原子操作,就自然能禁止重排序。
4、静态内部类
除了以上方法外,使用 Java 的静态内部类也能够实现。
public class Singleton {
private Singleton(){}
private static class Instance {
private static final Singleton instance = new Singleton();
}
public static Singleton getInstance(){
return Instance.instance;
}
}
当第一次加载 Singleton 类时并不会初始化 instance,只有在第一次调用 Singleton 的 getInstance 方法时才会导致 instance 被初始化。
第一次调用 getInstance 方法时会导致虚拟机加载 Instance 类,这种方式不仅能保证线程安全,也能够保证单例对象唯一,同时也延迟了单例的实例化。
5、枚举
枚举是单例最简单的实现方式,这种实现方式通过 Java 枚举类型本身的特性,保证了实例创建的线程安全性和实例的唯一性。
public enum Singleton {
INSTANCE;
}
三、单例存在哪些问题?
1、对 OOP 特性的支持不友好
面向对象的四大特征是:封装、继承、多态。单例对继承、多态特性的支持不友好。
虽然从理论上来讲,单例类也可以被继承、也可以实现多态,但实现起来会非常奇怪。所以,一旦将某个类设计成到单例类,也就意味着放弃了继承和多态这两个面向对象的特性,也就相当于损失了可以应对未来需求变化的扩展性。
2、单例对代码的扩展性不友好
我们知道,单例类只能有一个对象实例。但如果未来改需求了,需要创建两个或多个实例,就需要对代码有比较大的改动。
3、单例不支持有参数的构造函数
单例不支持有参数的构造函数,如果想要传递参数,只能在 getInstance 方法中添加参数,或者定义方法传递参数。
4、针对这些问题,有何替代的解决方案?
为了保证全局唯一,除了使用单例,还可以用静态方法来实现。不过,静态方法这种实现思路,并不能解决之前提到的问题。
实际上,它比单例更加不灵活,比如,它无法支持延迟加载。
目前并没有什么很好的方式来解决。
四、单例对象的作用域的范围
单例模式的类只能创建一个对象,这个对象的作用域是整个 APP 的生命周期,也就是进程中唯一。
当我们打开 APP 后,系统会开启一个进程,并分配给 APP,接着进程会一条一条地执行 APP 文件中包含的代码,比如 当读到 User user = new User(); 这条语句的时候,它就在自己的地址空间中创建一个 user 临时变量和一个 User 对象。
进程之间是不共享地址空间的,如果我们的 APP 开启多个进程,那么每个进程都会分配新的地址空间,单例模式就会失效。。
单例类中对象的唯一性的作用范围是进程内的,在进程间是不唯一的。
五、单例模式是如何保证唯一性的
这里就需要了解JVM 的类加载机制。
虚拟机的类加载是采用双亲委派模型。
它的工作过程是:如果一个类加载器收到了类加载的请求,它首先不会去加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(他的搜索范围中,没有找到这个类),子加载器才会去尝试加载。