一、简单了解一下设计模式
二、了解单例模式
①什么是单例模式
②饿汉式单例模式
(1)什么是饿汉式单例模式?优点?缺点?
(2)饿汉式单例模式,是如何确保类的对象唯一的?
(3)代码实现饿汉式单例模式
(4)饿汉式单例模式,是否线程安全?
③懒汉式单例模式
(1)什么是懒汉式单例模式?
(2)懒汉式是否线程安全?懒汉式单例模式如何确保线程安全?
(3)懒汉式单例模式的优点?缺点?
设计模式,本质上,就是有点类似于一个"棋谱“。回顾一下棋谱,其实就是一些大佬们针对一些常见的对局场景,比如象棋里面的各种"将君"措施,例如铁门栓,闷龚等等。
在计算机的圈子当中,也有一些设计"棋谱"的大佬。他们针对一些特殊的业务场景,也设计了一些对应的解决方案。只要按照这个方案来写代码,就可以达到一定的目的。
单例模式,指的是,在一些特定的场景当中,某一些类只能创建出一个实例(对象)。无论是单线程还是多线程环境下面,都只能生成一个对象,这个时候,就需要使用到单例模式。
通过Java语言特有的语法,达成了某个类只能拥有一个对象。这种确保一个类只能拥有一个对象的设计模式,就是单例模式。
举一个使用单例模式的例子:数据库链接对象Connection。
因为数据库连接对象,含义就是通过这些对象来操作数据库,如果创建出多个,一是会造成资源的浪费,二是这样没有意义
饿汉式单例模式,指的是,在类加载的时候,就吧这个类的唯一实例创建出来了的设计模式。这个对象产生的时间位于类的实例创建之前。
饿汉式单例模式的特点是,类对象的创建特别快速,因为类的加载是一个比较靠前的阶段。
这也是饿汉模式的优点。
饿汉模式的缺点是:如果类的对象被创建之后,长时间没有使用,那么这个对象就有可能被gc回收,从而造成浪费。
①在饿汉式单例模式当中,把类的对象使用static关键字修饰,并且作为类的属性。使用static关键字修饰的成员变量,都属于类,高于对象。同时,使用static关键字修饰的属性,属于类对象(class对象)。通过类名.class获取到的对象。因为类对象也正好是单例的,因此对应的SINGLETON属性也是唯一的。
private static Singleton SINGLETON =new Singleton();
②构造方法被设为private,这样可以确保无法在类的外部创建对象。
class Singleton{
/**先把当前实例创建出来
* 这个和属性无关,而是和类相关
* java代码当中的每个类,都会在编译之后得到.class文件
* jvm加载这个类的时候
* 类加载的时候,就把对象创建出来了,无论是否需要使用到当前对象。
*/
private static Singleton SINGLETON =new Singleton();
/**
* 如果想获取这个实例对象,只能通过这个
* 接口来获取
* 单例模式对象@return
*/
public static Singleton getInstance(){
return SINGLETON;
}
/**
* 无法在类的外部创建实例
*/
private Singleton() {
}
}
饿汉式是线程安全的,因为当多个线程同时调用getInstance()方法的时候,都是读取的操作,并没有在getInstance()方法内部进行一系列的修改操作。之前文章当中,提到过,如果仅仅是读操作,不涉及修改变量的操作,这就是线程安全的.
懒汉模式,指的是在类加载的阶段,并不会立刻创建这个类的唯一对象。当需要使用到这个类的对象时候,才会创建这个对象的设计模式。
其实上面这样的写法,不是线程安全的。原因:当有两个以上线程同时调用getInstznce()方法,并且这个singletonLazy对象还没有被创建出来的时候,有可能两个线程同时都进入了if(singletonLazy==null)这个语句当中,这样,也就创建了两个对象。违背了单例模式的特点。
那如何让这种延迟加载的懒汉式变为线程安全的呢?
那就是加锁,让其中一个线程(假如是thread1)调用getInstance()方法的时候,另外的线程(thread2)需要阻塞等待,直到获取到锁的线程创建完对象之后,解锁了,另外一个线程才可以继续调用getInstance()方法,来获取这个类的实例。
此时,thread2获取到的实例,就是第一个线程已经创建好的了。
这样,才算是线程安全的单例模式。
代码实现:优化1:
这样的写法,虽然线程安全了,但是效率却非常的低下,原因是:当这个对象被创建出来之后,其他线程如果想要获取这个对象,仍然会发生阻塞等待的现象。但是,如果仅仅是读取这个对象,然后返回,由于仅仅涉及"读取”操作,因此没必要针对“读“操作加锁。
前面的文章提到过,如果多个线程仅仅是针对内存当中的某一变量进行“读”操作,是不会存在线程安全问题的。因此,没必要针对“读”的操作频繁加锁,这样会导致锁的粒度过大。
代码实现:优化2:双重if减小锁的粒度
因此,可以考虑:如果对象没有被创建,即:singletonLazy==null的时候,才需要加锁创建对象。如果singletonLazy!=null的时候,直接返回即可。
class SingletonLazy{
/**
* 此处不着急创建属性实例
*/
private static SingletonLazy singletonLazy=null;
public static SingletonLazy getInstance(){
if(singletonLazy==null) {
synchronized (SingletonLazy.class) {
if (singletonLazy == null) {
singletonLazy=new SingletonLazy();
}
}
}
return singletonLazy;
}
private SingletonLazy(){
}
}
图解一下上面的饿汉式单例模式:
需要注意的是,外层的if操作,判断的是是否需要进行加锁操作,内部的if语句,是判断是否需要创建对象。外层的if,为了避免在对象创建对象之后,其他获取此实例的线程都进入阻塞状态
代码优化3:
但是,可以看到一个警告:
在代码优化2当中,看似好像没有任何问题了,但是,其实还是会存在指令重排序,导致的问题;
在上述代码的这一行代码当中:
可以看到,是一个对象初始化的语句,但是,这个语句在编译器底层的实现,其实是分为3个步骤的:
①memory=allocate() //为需要初始化的singletonLazy对象申请一块内存空间;
②ctorInstance(memory) //初始化这个对象
③singletonLazy=memory//设置singletonLazy引用指向刚刚申请的内存空间->memory
但是,此时假如发生了编译器优化,
singletonLazy = new SingletonLazy();
上面的操作就会变成这样的:
①memory=allocate() //为需要初始化的singletonLazy对象申请一块内存空间; ③singletonLazy=memory//设置singletonLazy引用指向刚刚申请的内存空间->memory
②ctorInstance(memory) //初始化这个对象
这样,会发生什么问题呢?我们来图解一下:
时间轴 | 线程A | 线程B |
t1 |
进入到内层if语句,并且执行了①操作:为对象申请内存空间 | |
t2 | 设置引用指向的地址空间(执行③操作) | |
t3 | 刚刚好进入到外层的if语句,进行判断:singletonLazy==null? | |
t4 | 因为线程A已经为对象申请了一块内存空间了,因此判断得到singletonLazy!=null,直接返回singletonLazy引用 | |
t5 | 执行②操作,初始化对象 | |
t6 | 返回instance引用 |
可以看到,在t4时刻,线程B获取到了一个占了内存空间,但是没有被初始化的对象。这一切的原因,就是编译器对①②③指令进行了重排序,变为了①③②。
因此,为了避免指令重排序,需要对singletonLazy属性使用volatile关键字修饰,避免编译器对指令进行重排序。
代码实现:
这样优化过之后的代码,避免了指令重排序,就会变成如下的图解:
时间轴 | 线程A | 线程B |
t1 | 进入外层if语句 | |
t2 | 进入同步代码块(加锁) | |
t3 | 执行①,为对象申请内存空间 | |
t4 | 执行②,初始化对象 | |
t5 | 由于singletonlazy对象还没有被放入被申请的空间,因此singletonLazy==null,进入外层if语句 | |
t6 | 遇到了同步代码块,但是线程A还没有解锁。因此线程B阻塞等待 | |
t7 | 执行③操作:把singletonLazy引用指向对象的内存空间 | |
t8 | 解锁 | 进入同步代码块,但是遇到了内层的if语句,由于此时线程A已经执行完③操作了,因此直接返回线程A创建的对象,确保了单例 |
t9 | 返回 |
优点:真正需要使用某个类的实例的时候,才会创建,这样不会造成资源的浪费。但是,因为使用了synchronized关键字来修饰代码块,有可能造成线程的阻塞,因此,缺点就是效率低下。