单例模式(Singleton Pattern)
引言
单例模式顾名思义,只生成单一实例的一种设计模式。这一个过程反应到设计领域就是,要求一个类只能生成一个对象,所有对象对它的依赖都是相同的。
怎么实现呢? 对象产生是通过new关键字完成的(当然也有其他方式,比如对象复制、反射等)
怎么控制每个类只产生一个对象呢? 创建一个对象是通过构造函数,那么我们把构造函数设置为private私有访问权限,就可以达到这一目的(反射仍然还是可以破解)。
一、定义
Ensure a class has only one instance, and provide a global point of accessto it.(确保某一个类只有一个实例,而且自行实例化并向整个系统提供这个实例。)
二、使用场景
● 要求生成唯一序列号的环境;
● 在整个项目中需要一个共享访问点或共享数据,例如一个 Web 页面上的计数器,可以不用把每次刷新都记录到数据库中,使用单例模式保持计数器的值,并确保是线程安全的;
● 创建一个对象需要消耗的资源过多,如要访问 IO 和数据库等资源;
● 需要定义大量的静态常量和静态方法(如工具类)的环境,可以采用单例模式 (当然,也可以直接声明为 static 的方式)。
三、实现
1.饿汉式
该模式的特点是类加载时就生成了单例。代码如下:
public class HungryMan {
//static修饰,加载HungryMan类时,就创建了实例对象
//final修饰,该属性不能在被赋值
public static final HungryMan hungryMan = new HungryMan();
private HungryMan(){}
public static HungryMan getInstance(){
return hungryMan;
}
}
2. 懒汉式
该模式的特点是类加载时没有生成单例,只有当第一次调用 getlnstance 方法时才去创建这个单例。代码如下:
public class LazyMan {
public static LazyMan lazyMan = null;
//构造方法私有
private LazyMan(){}
public static LazyMan getInstance(){
if(lazyMan == null) lazyMan = new LazyMan();
return lazyMan;
}
}
创造两个实例,判断两个实例是否为同一实例
public static void main(String[] args) {
LazyMan lm1 = LazyMan.getInstance();
LazyMan lm2 = LazyMan.getInstance();
System.out.println(lm1==lm2);
}
结果
true
该方式在低并发的情况下尚不会出现问题,若系统压力增大,并发量增加时则可能 在内存中出现多个实例,破坏了最初的预期。
为什么会出现这种情况呢?如一个线程A执行 到singleton = new Singleton(),但还没有获得对象(对象初始化是需要时间的),第二个线程 B也在执行,执行到singleton == null)判断,那么线程B获得判断条件也是为真,于是继续 运行下去,线程A获得了一个对象,线程B也获得了一个对象,在内存中就出现两个对象
代码如下:
public class LazyMan {
public static LazyMan lazyMan = null;
private static int count;
//构造方法私有
private LazyMan(){
System.out.println("Singleton 私有的构造方法被实例化 " + (++count) + " 次。");
}
public static LazyMan getInstance() {
if(lazyMan == null) {
lazyMan = new LazyMan();
}
return lazyMan;
}
}
执行
public static void main(String[] args) {
//Runnable runnable = LazyMan::getInstance;
Runnable runnable = ()->{
System.out.println(Thread.currentThread().getName()+"执行");
LazyMan.getInstance();
};
for (int i = 0; i < 2; i++) {
new Thread(runnable).start();
}
}
结果
Thread-0执行
Thread-1执行
Singleton 私有的构造方法被实例化 2 次。
Singleton 私有的构造方法被实例化 1 次。
分析:
两个线程是并发执行的,很有可能在Thread-0进入 if(lazyMan == null)代码块中还没有实例化或者实例化还没有初始化赋值的时候,lazyMan仍然为空,此时切换到Thread-1执行,完成初始化之后,再切换回Thread-0执行完初始化过程,因此这种方式是线程不安全的。我们可以通过给变量加volatile修饰,使用synchronized加锁。
3. 加锁(synchronized)
第一种:我们可以对方法加锁。
这种方式对静态方法加锁,虽然解决了并发问题,但效率很低。因为,只有当第一次调用 getInstance() 时才需要同步创建对象,创建之后再次调用 getInstance() 时就只是简单的返回成员变量,而这里是无需同步的,所以没必要对整个方法加锁。
public class LazyMan {
public static LazyMan lazyMan = null;
private LazyMan(){}
public static synchronized LazyMan getInstance() {
if(lazyMan == null) {
lazyMan = new LazyMan();
}
return lazyMan;
}
}
第二种:对方法内的部分代码加锁
这种方式同样有问题,因为计算机存在指令重排序。并发情况下,还是有可能创建出多个对象。
public class LazyMan {
public static LazyMan lazyMan = null;
private LazyMan(){}
public static LazyMan getInstance() {
synchronized(LazyMan.class){
if(lazyMan == null) {
lazyMan = new LazyMan();
}
}
return lazyMan;
}
}
计算机在执行程序时候,为了提高代码、指令的执行效率,编译器和处理器会对指令进行重新排序,一般分为编译器对于指令的重新排序、指令并行之间的优化、以及内存指令的优化。
解释:
public static void main(String[] args) {
Object o = new Object();
}
#编译
javac test.java
#反编译,再输出
javap -v test.class > test.txt
查看txt文件,我们可以看到class文件被编译成jvm指令。code中就是创建对象的步骤。不熟悉的同学可以学习一下jvm创建对象的过程:
检查类是否加载—>类加载—>堆中开辟空间,赋默认值(对象默认null)—>初始化(init)—>连接引用
0: new #2 //实例化:堆中开辟空间,赋默认值null
3: dup
4: invokespecial #1 //执行init方法,初始化,此时不为null
7: astore_1 //对象引用 连接 局部变量表中局部变量(变量名‘o’)
8: return
指令未重排序,则不会出现问题。可如果重排序将 4 和 7颠倒,先执行astore_1 后执行invokespecial,就可能创建出两个对象。
7: astore_1
4: invokespecial #1 //执行init方法,初始化,此时不为null
加volatile则可禁止指令重排序,避免这个问题,所以一般来说volatile会和synchronized搭配使用。
public class LazyMan {
public static volatile LazyMan lazyMan = null;
private LazyMan(){}
public static LazyMan getInstance() {
synchronized(LazyMan.class){
if(lazyMan == null) {
lazyMan = new LazyMan();
}
}
return lazyMan;
}
}
这里还可以进行优化。值得优化的地方就在于 synchronized 代码块这里。每个线程进来,不管三七二十一,都要先进入同步代码块再说,如果说现在 INSTANCE 已经不为null了,那么,此时当一个线程进来,先获得锁,然后才会执行 if 判断。我们知道加锁是非常影响效率的,所以,如果 INSTANCE 已经不为null,是不是就可以先判断,再进入 synchronized 代码块。
4. 双重检查加锁
public class LazyMan {
public static volatile LazyMan lazyMan = null;
private LazyMan(){}
public static LazyMan getInstance() {
if(lazyMan == null){
synchronized(LazyMan.class){
if(lazyMan == null) {
lazyMan = new LazyMan();
}
}
}
return lazyMan;
}
}
5. 静态内部类
public class StaticInternalClasses {
public static class InternalClass{
public static StaticInternalClasses singleton = new StaticInternalClasses();
}
private StaticInternalClasses(){}
public static StaticInternalClasses getInstance(){
return InternalClass.singleton;
}
}
6. 枚举
枚举类其实就是一个使用final修饰的类,继承了java.lang.enum枚举的实例都是static修饰的,那么如果枚举只有一个对象,可以作为单例模式的一个实现方式。
public enum EnumSingleton {
instance;
}
四、优点
● 由于单例模式在内存中只有一个实例,减少了内存开支,特别是一个对象需要频繁地创建、销毁时,而且创建或销毁时性能又无法优化,单例模式的优势就非常明显。
● 由于单例模式只生成一个实例,所以减少了系统的性能开销,当一个对象的产生需要 比较多的资源时,如读取配置、产生其他依赖对象时,则可以通过在应用启动时直接产生一个单例对象,然后用永久驻留内存的方式来解决(在Java EE中采用单例模式时需要注意JVM 垃圾回收机制)。
● 单例模式可以避免对资源的多重占用,例如一个写文件动作,由于只有一个实例存在 内存中,避免对同一个资源文件的同时写操作。
● 单例模式可以在系统设置全局的访问点,优化和共享资源访问,例如可以设计一个单 例类,负责所有数据表的映射处理。
五、缺点
● 单例模式一般没有接口,扩展很困难,若要扩展,除了修改代码基本上没有第二种途 径可以实现。单例模式为什么不能增加接口呢?因为接口对单例模式是没有任何意义的,它 要求“自行实例化”,并且提供单一实例、接口或抽象类是不可能被实例化的。当然,在特殊 情况下,单例模式可以实现接口、被继承等,需要在系统开发中根据环境判断。
● 单例模式对测试是不利的。在并行开发环境中,如果单例模式没有完成,是不能进行 测试的,没有接口也不能使用mock的方式虚拟一个对象。
● 单例模式与单一职责原则有冲突。一个类应该只实现一个逻辑,而不关心它是否是单例的,是不是要单例取决于环境,单例模式把“要单例”和业务逻辑融合在一个类中。
六、总结
单例模式是23个模式中比较简单的模式,应用也非常广泛,如在Spring中,每个Bean默 认就是单例的,这样做的优点是Spring容器可以管理这些Bean的生命期,决定什么时候创建 出来,什么时候销毁,销毁的时候要如何处理,等等。