面试中最常被虐的地方一定有并发编程这块知识点,无论你是刚刚入门的大四萌新还是2-3年经验的CRUD怪,也就是说这类问题你最起码会被问3年,何不花时间死磕到底。消除恐惧最好的办法就是面对他,奥利给!(这一系列是本人学习过程中的笔记和总结,并提供调试代码供大家玩耍
上章回顾
1.如何证明volatile不能保证原子性
2.简单描述一下什么是happen-before原则
3.简单描述一下volatile的使用场景
请自行回顾以上问题,如果还有疑问的自行回顾上一章哦~
本章提要
本章主要内容主要围绕单例这一设计模式展开,通过对7种单例设计模式的实现方式的应用,进一步巩固前面几个章节中提到的volatile和synchronized关键字的相关特性。(老规矩,熟悉这块的同学可以选择直接关注点赞👍完成本章学习哦!)
单例模式
单例设计模式是GoF23种设计模式中最常用的设计模式之一。这种类型的设计模式属于创建型模式,它提供一种创建对象的最佳方式。这种模式涉及到一个单一的类,该类负责创建自己的对象,同时确保只有单个对象被创建。这个类提供了一种访问其唯一的对象的方式,可以直接访问,不需要实例化该类的对象。
注意:
-
单例类只能有一个实例。
-
单例类必须自己创建自己的唯一实例。
-
单例类必须给所有其他对象提供这一实例。
根据这3个特性,我们下面从线程安全、高性能和是否支持懒加载三个纬度来看下单例设计模式的7中实现方式及其优缺点。
一、饿汉式
实现方式如下:
public class Singleton {
private byte[] date = new byte[1024];
private static Singleton instance = new Singleton();
private Singleton() {
}
public static Singleton getInstance() {
return instance;
}
}
饿汉式的特点就是能够保证在多线程下提供出来的实例都是唯一的,并且由于在类加载的时候就已经完成了Singleton对象的初始化,所以外部在调用getInstance()的效率相对来说还是挺高的。但是该方法在类类加载的时候就已经完成类实例初始化,所以如果实例占用空间较大的话,结果就不大美丽了。一旦有大量的大实例使用饿汉式单例模式来创建,duang~duang~duang~凉凉。
所以饿汉式适用于一个类中的成员属性少,占用资源小的场景。但是到底多少个成员属性才算少,占用多少资源才算小这都是相对而言的,一旦大量使用结果都是不可控的。所以我们更加推崇支持懒加载的方式来初始化对象。
二、懒加载
实现方式如下:
public class SingletonLazy {
private byte[] date = new byte[1024];
private static SingletonLazy singletonLazy = null;
private SingletonLazy() {
}
public static SingletonLazy getInstance() {
if (null == singletonLazy) {
singletonLazy = new SingletonLazy();
}
return singletonLazy;
}
}
我们可以看到只有在调用getInstance()方法的时候实例对象才会被创建,这就是我们之前所说的懒加载模式,在使用的时候实例对象才会被创建。但是懒加载创建的实例对象在多线程场景下却不是唯一的。
如上图所示,当A、B线程同时调用
SingletonLazy.getInstance()的时候会产生A、B两个实例对象,这时候创建的实例就不是唯一的了。
到这里有好好学习之前synchronized章节的同学就要说了,那我给这个获取实例的方法使用同步策略岂不是就能保证创建的实例都是唯一的了。
三、懒加载+同步策略
改造上一小节部分代码如下:
//增加同步策略
public static synchronized SingletonLazy getInstance() {
if (null == singletonLazy) {
singletonLazy = new SingletonLazy();
}
return singletonLazy;
}
由于synchronized的排他性,确实可以保证在多线程场景下懒加载方式创建的实例都是唯一的,但是也正是因为排他性,每次只有一个线程能获取到这个实例对象的使用权,所以程序的性能会大大降低。
四、Double-Check
实现如下:
public class DoubleCheck {
private byte[] date = new byte[1024];
private static DoubleCheck doubleCheck = null;
private DoubleCheck() {
}
public static DoubleCheck getInstance() {
if (null == doubleCheck) { --(1)
synchronized (DoubleCheck.class) { --(2)
if (null == doubleCheck) { --(3)
doubleCheck = new DoubleCheck(); --(4)
}
}
}
return doubleCheck;
}
}
我们仔细来看getInstance()的实现。首先判断null == doubleCheck,如果是true的才会进入同步模块来创建对应的单例实例对象,相比于懒加载+同步的实现方式来说这一种较为灵活一些,不需要每一个线程都在同步块外面等待,做到了一层简单的过滤作用。
但是这种方式在高并发场景下会产生空指针异常。
在讨论这个问题之前我们先问同学们一个问题,被synchronized修饰的代码块一定是线程安全的吗?
号外号外 ⚠️这是一个“假”synchronized ?
在前面我们说过synchronized关键字锁定了当前对象的监视锁,通过排他性来保证线程安全。事实确实没有错,但是这里我们需要特别注意下这两个概念This Monitor和Class Monitor。
我们在静态方法中使用synchronized方法的时候实际上我们锁定的是DoubleCheck.class Monitor,但是我们在方法中使用new关键字来新建的对象这个过程是对这个对象实例变量的操作也就是This Monitor,所以可以看到我们的新建的这个对象实例其实并没有被synchronized关键字所作用到,这两个不是同一个monitor锁。
我们可以用代码来验证一下this monitor和class mointor之间的差异。
创建ThisOrClsssMonitor.class服务类
public class ThisOrClsssMonitor {
synchronized public static void printA() { //Class锁
try {
System.out.println("线程"+Thread.currentThread().getName()+"进入printA");
Thread.sleep(2000);
System.out.println("线程" +Thread.currentThread().getName()+"离开printA");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
synchronized public static void printB() { //Class锁
try {
System.out.println("线程"+Thread.currentThread().getName()+"进入printB");
Thread.sleep(2000);
System.out.println("线程" +Thread.currentThread().getName()+"离开printB");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
synchronized public void printC() { //对象锁
try {
System.out.println("线程"+Thread.currentThread().getName()+"进入printC");
Thread.sleep(2000);
System.out.println("线程" +Thread.currentThread().getName()+"离开printC");
} catch (InterruptedException e) {
e.printStackTrace();
}
}
}
创建三个线程类来调用这个这个服务类
public class ThreadA extends Thread{
@Override
public void run() {
ThisOrClsssMonitor.printA();
}
}
public class ThreadB extends Thread{
@Override
public void run() {
ThisOrClsssMonitor.printB();
}
}
public class ThreadC extends Thread{
private ThisOrClsssMonitor thisOrClsssMonitor = new ThisOrClsssMonitor();
@Override
public void run() {
thisOrClsssMonitor.printC();
}
}
创建测试类进行测试
public class ThisOrClsssMonitorTest {
public static void main(String[] args) {
ThreadA threadA = new ThreadA();
threadA.setName("A");
threadA.start();
ThreadB threadB = new ThreadB();
threadB.setName("B");
threadB.start();
ThreadC threadC = new ThreadC();
threadC.setName("C");
threadC.start();
}
}
结果输出:
线程A进入printA
线程C进入printC
线程A离开printA
线程C离开printC
线程B进入printB
线程B离开printB
显而易见,我们的线程A和线程C的运行并没有互斥,而是同时进入的逻辑函数,所以证明静态方法的synchronized和非静态方法的synchronized锁定的并不是同一个monitor lock。这一点我们可以多次试验,都能看到只有等A线程执行完成离开printA之后B线程才能获取到class monitor进入自己到执行逻辑。
好的,看完上面那段解析之后相信同学们对synchronized关键字也有了更深的认识,我们再回到double-check这个话题上面来。
之所以产生NPE的原因我们已经清楚了,是因为synchronized锁定的monitor是class monitor,而我们的静态同步快中new的对象实例的监视锁是this monitor导致的。
既然如此那我们需要保证我们new出来的实例对象的线程安全就行了,**在考虑怎么满足需求的前提是要考虑为什么会产生这个需求。**方法论来啦!!!。
我们来看这段代码
if (null == doubleCheck) {
synchronized (DoubleCheck.class) {
if (null == doubleCheck) {
doubleCheck = new DoubleCheck(); --我们看这里 (1)
}
}
}
我们看(1)号位置部分的代码,之所以线程不安全就是因为这个对象创建和赋值的过程不是线程安全的。其实就是对象创建的过程,我们可以把对象创建的过程大致分为以下几步:
1.类是否已被加载、解析和初始化过
2.虚拟机为新生对象分配内存(指针碰撞和空闲列表,有兴趣同学可以了解下,下个系列java虚拟机为也会和大家详细介绍这块内容。)
3.Java虚拟机要对对象进行必要的设置
4.调用构造函数,完成对象的初始化
问题就出现在第四步上面,不知道同学们看出来没有,虚拟机完成对象的必要设置之后,其实java虚拟机内部对象已经生成了,但是我们预定的一些参数初始化还未完成,此时如果一个额外线程来读取这个对象的时候会得到null != doubleCheck也就是说当前对象已经创建完成了,所以如果调用当前对象的一些参数来进行逻辑处理的过程中就会出现NPE的问题。
好至此我们已经把问题的缘由介绍清楚了,我们之前章节有介绍过happen-before原则,其中对volatile关键字我们有过一个约束条件。
volatile原则:对一个volatile变量对写操作happen-before对这个变量对读操作。
我们的问题是在还未对对象完初始化,也就是可能存在部分值还未赋值,仍然是虚拟机给我们设置的NULL值,此时就有线程来读取这个对象的这个NULL值来进行一些特定的业务操作比如最典型的就是null.equals(),就会出现NPE。
五、Double-Check+Volatile
我们修改double-check的部分代如下:
//Volatile 修饰防止重排序
private static volatile DoubleCheck doubleCheck = null;
这样就解决了double-check可能出现的NPE的问题。虽然方案很简单,但是整个探索的过程希望同学们仔细阅读,认真思考哦~
喜欢的同学记得点赞,点关注😄
六、Hold方式
使用内部静态类来实现单例模式
public class SingleHolder {
private byte[] date = new byte[1024];
private SingleHolder() {
}
//内部静态类
private static class Holder {
private static SingleHolder singleHolder = new SingleHolder();
}
public static SingleHolder getInstance() {
return Holder.singleHolder;
}
}
这种实现方式我觉得其实是懒加载的一个变异版本,巧妙的利用的内部静态类只会加载一次的特性,既保证了单例模式这一特性,又能满足懒加载的要求。这是目前使用较为广泛的一种实现方式,我们在平常开发中可以多多使用这种实现方式哦~
七、枚举方式
public enum SingleEnum {
INSTANCE;
private byte[] date = new byte[1024];
SingleEnum(){
System.out.println("INSTANCE will be initialized immediately");
}
public static void method(){
//调用该方法则会主动使用SingleEnum,INSTANCE 会被实力化
}
public static SingleEnum getInstance(){
return INSTANCE;
}
}
这是原始版本的enum版本,原汁原味儿,利用的就是枚举类不可继承,并且只能被加载一次的特性。但是他有一个缺点就是假如我们在这个枚举类中写了一个静态方法method,然后在调用这个静态方法的过程中其实我们的枚举实例就会被初始化完成,这在一定情况下就不符合懒加载的特性。
使用一个test类来复现下这个问题:
public static void main(String[] args) {
SingleEnum.method();
}
}
输出:
INSTANCE will be initialized immediately
可以看到这边已经调用了我们枚举的构造函数,也就是枚举类已经初始化完成了,我们的枚举实例也已经被创建了,但是事实上我们使用的是其中的一个静态方法,枚举的实例其实还没真正应用到我们的业务逻辑里面,所以这是不满足懒加载的。
经典名言
没有什么问题是再往上包一层不能解决的
我们把刚刚的枚举类丢到另外一个类中,成为这个类的私有的内部枚举类,先看代码
public class SingleEnum2 {
private byte[] date = new byte[1024];
private SingleEnum2() {
System.out.println("INSTANCE will be initialized immediately");
}
private enum EnumHolder {
INSTANCE;
private SingleEnum2 singleEnum2;
EnumHolder() {
this.singleEnum2 = new SingleEnum2();
}
private SingleEnum2 getSingleEnum2() {
return singleEnum2;
}
}
public static void method(){
//调用该方法 INSTANCE不 会被实例化
}
public static SingleEnum2 getInstance() {
return EnumHolder.INSTANCE.getSingleEnum2();
}
}
其原理就是把枚举类再丢进一个类中,这样调用这个类的静态方法实例化的是这个类的对象,而并不会实例话我们的内部的私有的枚举类中的实例,所以可以达到懒加载的效果
好啦~本期的巩固学习到这里就结束了,不知道同学们有没有学会这7种单例模式的实现方法呢?学习完成,相信同学们对synchronized和volatile也有了更深入的认识,点赞👍➕关注❤️,持续更新中~~