渣说设计模式之单例模式(上)
大家好,我是渣哥,是一个每天都在攒钱植发的程序员。
咱们之前已经聊完了设计模式的七大原则,现在可以开始去了解设计模式了,前面已经说过了,设计模式共有创建型模式、结构型模式、行为型模式三大类,总共27条,那么今天我们就先来了解一下创建型模式的第一条:单例模式。
●了解为什么需要单例模式,单例模式解决了什么问题。
●代码实现各种单例模式
全文共6670字,阅读预计需要15分钟;
在有些系统中,为了节省内存资源、保证数据内容的一致性,对某些类要求只能创建一个实例,这就是所谓的单例模式。
单例模式是最简单的一种设计模式,可以说是最常用的设计模式之一,无论是JDK源码中还是各种常用的框架中,基本都有用到单例模式。
单例模式在现实生活中的应用也非常广泛,例如公司 CEO、部门经理等都属于单例模型。
单例模式有三个特点:
1.单例类只有一个实例对象;
2.该单例对象必须由单例类自行创建;
3.单例类对外提供一个访问该单例的全局访问点。
下面我们就用代码来体验一下单例模式吧!
一般我们提到单例模式,经常听到的就是懒汉式单例与饿汉式单例了,那么这两种到底有什么区别呢?且听渣哥一一道来。
我们先来看一下“饿汉式单例”,“饿汉”是个啥,这种模式是怎么写的呢?又有什么特点呢?咱们一步一步说,首先先了解一下,为啥叫饿汉,打个比方,现在你非常的饿,有很多的食物摆在你面前,你首先肯定是想大吃大喝一顿,先把肚子填饱再说,对吧。至于饿汉式单例呢,它的实现方式跟你想做的差不多,当类加载的时候,它就已经抢先占用内存空间,创建出了自己的实例对象,这样做有啥好处呢?你想呀,一开始就把自己的对象创建出来之后,后面有别的程序需要调用到它的实例对象的时候就不用浪费时间去创建了,这样效率就比较高,随之而来的代价就是在类加载的时候就会额外花费一些时间。那我们从这种方式的特性就可以知道,饿汉式单例就比较适用于那种在系统中应用比较频繁,本身占用空间不会很大的类。并且饿汉式单例由于类加载时其单例就会被创建出来,所以在多线程环境下也不会存在重复创建的问题,所以饿汉式单例天生就是线程安全的。
那么饿汉式单例是怎么实现的呢?我们用一个例子来说明:
我们知道美国总统在全美国甚至全世界只有一个(当然渣哥这里说的是现任的嗷~),那么我们就试用饿汉式单例创建一个总统类,代码如下:
//饿汉式单例模式方式1:静态变量 实现总统类
public class President {
private static President president = new President();
private President() {
System.out.println("选举产生了一位总统!");
}
public static President getInstance() {
return president;
}
public void getName() {
System.out.println("我是拜登登登!");
}
}
//饿汉式单例模式方式2:静态代码块 实现总统类
public class President {
private static President president;
static {
president = new President();
}
private President() {
System.out.println("选举产生了一位总统!");
}
public static President getInstance() {
return president;
}
public void getName() {
System.out.println("我是拜登登登!");
}
}
创建饿汉式单例模式的写法呢有两种,一种是通过静态变量的形式去创建单例对象,一种是通过静态代码块的形式去创建单例对象,两种方式的原理都是在类加载的时候就去执行一次构造函数,使该类在类加载的时候就包含一个构造好的单例对象,然后外界通过一个静态方法getInstance()
来获取这个构造好的单例对象。这样的话无论外界调用多少次的getInstance()
方法获取对象,获取到的对象始终都是同一个,这样就保证了该类的对象在整个系统中只有一个实例对象。
在这里我们需要注意几个细节:1、所有实现单例模式的类,必须要私有其构造方法,不能被外界访问,不然外界就可以直接通过new关键字来直接创建一个对象了,还谈什么单例。
2、私有了构造方法,就意味着一般情况下(当然还有特殊情况, 我们后面再说)无法通过外界去创建该类的对象了,那么这个单例对象要在哪里创建呢?当然要在类内部去创建了,所以单例模式的对象创建过程需要在类内部去完成,不能通过外界去创建。
3、我们把构造方法私有化了,并且在内部创建了单例对象,那么这个类本身就要提供一个静态方法,外界可以通过这个方法来获取到该类的单例对象,所以在实现单例模式的类中,都要有这么一个方法,我们一般把它命名为getInstance()
,通过这个方法给外界返回一个该类的单例。
然后我们再来看看懒汉式单例,使用这种单例模式创建单例的时候,会比较“懒”,你要用它的时候,他才会执行创建单例,你如果不调用他,他就不会创建出来。这样对于系统来说,没用不会占用资源,从使用的角度来看的话,调用的时候才去创建实例,效率就没有那么高了。所以比较适合那些占用空间比较大且使用频率没有很高的类。并且懒汉式单例还有一个问题,我们先看代码再来分析问题在哪里吧。
同样的,我们也用懒汉式单例模式来模拟产生美国当今总统对象。
//懒汉式单例模式实现总统类
public class President {
private static President president;
private President() {
System.out.println("选举产生了一位总统!");
}
public static President getInstance() {
if (president == null) {
president = new President();
} else {
System.out.println("已经有总统了!");
}
return president;
}
public void getName() {
System.out.println("我是拜登登登!");
}
}
通过代码我们了解到,懒汉式单例,是先声明了一个静态变量是该类的单例,这个变量默认为null,什么时候真正的创建对象呢?是在getInstance()
方法被调用的时候,其内部做了一个判断,如果这是这时候单例为空,那么就说明单例还没有被创建出来,那么就创建一次,如果单例不为空,就说明之前创建过了,那么这时候就不用创建,直接返回单例就可以了。
这么一分析,乍一看好像没啥问题,但是仔细想想好像哪里又有点不对。
如果在单线程环境下,这种执行顺序当然没有问题,但是如果是多线程环境,线程1进入到getInstance()
方法,执行判断,发现之前单例为空,之前没有创建过单例,进入下一步,但是这个时候恰好时间片用尽了,换线程2执行了,线程2执行的时候也进入了这个方法,发现单例也是空的,于是后面的情况我们可以想象,最终线程1创建并给外部返回了一个对象,线程2也创建并给外部返回了一个对象,那么外界就有了两个不同的对象,这就违背了单例模式的初衷。这就是懒汉式单例的问题,单线程环境下没啥问题,但是多线程环境下就会存在线程不安全的问题。
那么这种情况该如何避免呢?我们修改一下上面的代码。
//懒汉式单例模式实现总统类(线程安全)
public class President {
private static President president;
private President() {
System.out.println("选举产生了一位总统!");
}
public static synchronized President getInstance() {
if (president == null) {
president = new President();
} else {
System.out.println("已经有总统了!");
}
return president;
}
public void getName() {
System.out.println("我是拜登登登!");
}
}
看下这段代码跟上面的代码有啥区别?好像没啥区别呀,那是你观察的不够仔细,看到没有,在getInstance()
方法声明时,加上了一个synchronized
关键字,加了这个关键字之后呢,这个方法就变成了一个线程安全的方法,所有的线程在执行这个方法的时候,必须要同步执行,也就是说前面的线程执行完了这个方法,后面的才能执行,这样就保证了在同一时间,只有一个线程可以执行这个方法,这样就确保了线程安全,不会存在重复创建单例对象的问题。
但是问题又来了,我们知道,懒汉式的getInstance()
方法内部存在判定,如果单例不存在就创建单例并返回,单例存在就直接返回单例,对于单例模式来说,返回单例的操作(读操作)是远远多于创建单例的操作(写操作)的,因为对于实现了单例模式的类而言创建单例的操作只需要一次,但是返回单例的操作是调用多少次就返回多少次。对于读操作来说,它本身就是线程安全的,实际上并不需要加锁,加锁的行为其实只是为了防止写操作。那么对于我们上面的方式来说,外界每一次调用getInstance()
方法,不管此时是需要读操作还是写操作,都会被强制同步执行未免有点效率过低,那么我们就要想办法改进一下。
对于getInstance()
方法来说,其实内部我们可以这么实现,进入该方法之后,首先判断单例对象是否为空,如果不为空的话,直接返回,这是单纯的读操作;如果为空,那么说明此时需要创建单例对象,在创建单例对象之前,我们先上一个锁,确保此时其他的线程进不来,然后再进行一次判断,单例对象此时是否等于空,如果此时不等于空,就说明已经有线程在此之前创建过单例对象了,那么直接返回单例就可以了,如果等于空,那就说明目前还没有单例对象,那就放心的创建单例对象,然后释放锁。
为什么要在锁的前后要进行两次检查呢?主要是防止多线程的这种情况:
线程1执行到第一次判断,判断当前没有单例对象,正要获取锁的时候时间片用尽了,换线程2执行,线程2执行第一次判断,也判断当前没有单例对象,就直接获取锁,然后去创建对象,创建完之后释放锁,这个时候换线程1执行,获取锁之后,如果没有在锁内进行二次判断的话,线程1就会再次创建一次单例对象,这显然不是我们想要的结果,所以在获取锁之后我们还是需要检查一次现在单例对象有没有,如果没有,我们再去创建,如果有的话,我们直接释放锁就可以了。
这样的话,对于外界调用者而言呢,只要单例对象不为空,直接返回单例对象就可以了,不用在这里堵塞,如果单例对象为空,那就要执行写操作,通过双重检查锁的方式确保了写操作的线程安全。等于说,对于读操作来说是不加锁的,对于写操作来说是加锁的,这样在多线程环境下效率会更高。
原理我们知道了,现在我们来看一下代码如何实现吧。
//懒汉式单例模式实现总统类(线程安全:双重检查锁)
public class President {
private static President president;
private President() {
System.out.println("选举产生了一位总统!");
}
public static President getInstance() {
if (president == null) {
synchronized (President.class) {
if (president == null) {
president = new President();
}
}
}
return president;
}
public void getName() {
System.out.println("我是拜登登登!");
}
}
这样就大功告成了。。。。。吗?
当然不会,在大多数情况下,双重检查锁是可以解决懒汉式单例的线程安全问题,如果JVM运行是按照我们写的代码一行一行运行的话。但是我们的代码写完之后,交由JVM执行的时候,JVM会觉得如果我按照你写的一行一行执行,可能效率不够高,所以JVM它就有它自己的想法,它有可能会把你的代码执行顺序给重新排列了,这是JVM的代码优化和指令重排序功能,他会导致什么结果呢,可能,注意是可能啊,可能会在读操作的时候会出现空指针问题,那么如何预防呢?很简单,加一个volatile
关键字修饰静态单例变量就可以了,至于volatile
关键字是干嘛的,渣哥在这里就不展开了,有兴趣的同学可以自己去搜索一下。
好了,那么今天我们学习了单例模式的两种实现方式,一种是饿汉式单例,这种方式会在类加载的时候就创建好单例对象,天生就是线程安全的。另外一种是懒汉式单例,这种是在调用实例的时候才会去创建实例,存在线程安全问题,我们今天也学习了两种使懒汉式单例模式变得线程安全的方法。
那么有没有更好更简单的方法可以让懒汉式单例线程安全呢?
另外,大家想想,单例模式能否被破坏呢?比如说,你创建了一个单例类,我用反射获取到了你的单例类对象,然后调用构造函数又重新创建出了一个类,不就把你的单例模式破坏了吗?另外我把你的单例对象序列化到文件中,然后再反序列化回来,这在系统中不就存在了两个实例对象了吗?那么针对这两种破坏单例模式的方法,我们该如何应对呢?
我是渣哥,预知后事如何,咱们下回分解!