跳至主要內容

JAVA 设计模式

holic-x...大约 15 分钟JAVA基础

JAVA 设计模式

学习核心

  • 设计模式
    • 谈谈所了解的设计模式?
    • 手撕单例模式?(双检查锁单例模式)
    • Spring等框架中使用了什么设计模式?
  • 设计模式扩展
    • 重学设计模式(结合设计模式场景案例学习)

学习资料

​ todo:先了解设计模式核心,后续在项目应用中学习不同的设计模式

设计模式

1.什么是设计模式?

设计模式基础概念

​ 设计模式是人们为软件开发中相同表征的问题,抽象出的可重复利用的解决方案。在某种程度上,设计模式已经代表了一些特定情况的最佳实践,同时也起到了软件工程师之间沟通的“行话”的作用。理解和掌握典型的设计模式,有利于提高沟通、设计的效率和质量

设计模式分类

按照模式的应用目标分类,设计模式可以分为创建型模式、结构型模式和行为型模式。

  • 创建型模式:是对对象创建过程的各种问题和解决方案的总结
    • 工厂模式(Factory、Abstract Factory)、单例模式(Singleton)、构建器模式(Builder)、原型模式(ProtoType)
  • 结构型模式:是针对软件设计结构的总结,关注于类、对象继承、组合方式的实践经验。常见的结构型模式
    • 桥接模式(Bridge)、适配器模式(Adapter)、装饰者模式(Decorator)、代理模式(Proxy)、组合模式(Composite)、外观模式(Facade)、享元模式(Flyweight)
  • 行为型模式:是从类或对象之间交互、职责划分等角度总结的模式
    • 策略模式(Strategy)、解释器模式(Interpreter)、命令模式(Command)、观察者模式(Observer)、迭代器模式(Iterator)、模板方法模式(Template Method)、访问者模式(Visitor)

常见的设计模式(结合案例分析,例如一些框架、常用工具类等,或者结合业务场景中使用到的案例进行分析)

  • 单例模式:饿汉式、饱汉式、双检锁单例模式(重点)、枚举等

  • Spring框架

    • Bean对象创建(工厂模式),参考BeanFactory、ApplicationContext
    • AOP(代理模式)
  • IO机制(装饰器模式)

  • GUI事件监听、各种事件监听器(观察者模式)

  • HTTP请求构建(构建器模式)

  • JDBCTemplate(模板模式)

2.单例模式入门

​ 单例模式核心:保证一个类仅创建一个实例,并提供一个访问它的全局访问点。该模式的核心要素:

  • 这个类只能有一个实例,且该实例必须由这个类自行创建
  • 对外提供访问这个实例的入口

​ 应用场景:在一个系统中,一个类可能会经常被使用在不同的地方,通过单例模式可以避免多次创建多个实例,进而节约系统资源

​ 构建核心:

  • 自行创建对象实例(静态、私有化)
  • 构造函数私有化
  • 对外一个获取该对象实例的公共方法

饿汉式和懒汉式的选择:

  • 如果是程序启动后一定要加载的类,可以使用饿汉式实现单例(但需考虑内存占用)
  • 如果是一些工具类,优先考虑用懒汉式(因为每个项目可能会引用到jar,但未必会使用到这个工具类,因此应该提供一种按需加载的方式,避免类提前被加载到内存中,进而占用系统资源)

饿汉式

饿汉式

​ 饿汉式单例模式的构建核心参考上述思路,其最重要的是对象的创建(饿汉式是在定义对象的时候就直接初始化对象实例)

// 01-饿汉式(在创建实例的时候就初始化对象实例)
class Singleton01 {
    // 1.自行创建静态实例(该实例私有化)
    private static Singleton01 instance = new Singleton01();

    // 2.构造函数私有化
    private Singleton01(){}

    // 3.对外提供访问该实例的入口
    public static Singleton01 getInstance(){
        return instance;
    }
}
// 测试
public class SingletonDemo {
    public static void main(String[] args) {
        // 1.饿汉式
        Singleton01 singleton011 = Singleton01.getInstance();
        Singleton01 singleton012 = Singleton01.getInstance();
        System.out.println(singleton011);
        System.out.println(singleton012);
    }
}

// output
com.noob.design.singleton.Singleton01@2f92e0f4
com.noob.design.singleton.Singleton01@2f92e0f4

如何理解饿汉式在多线程场景下能保证只实例化一次?

​ 因为饿汉式的单例对象实例用static修饰,且在定义的时候就进行初始化,因此变量会在类初始化的过程中被收集进类构造器即 <clinit> 方法中。在多线程场景下,JVM 会保证只有一个线程能执行该类的 <clinit> 方法,其它线程将会被阻塞等待。等到唯一的一次 <clinit> 方法执行完成,其它线程将不会再执行 <clinit> 方法,转而执行自己的代码。

​ 基于这种方式实现的单例模式,在类初始化阶段就已经在堆内存中开辟了一块内存,用于存放实例化对象,因此可以保证在多线程的场景下只实例化一次

饿汉式单例模式的优缺点?

​ 优点:确保多线程场景下实例的唯一性,通过getInstance()返回唯一实例,性能比较高

​ 缺点:如果类成员变量比较多或者变量比较大,这种模式可能在没有使用类对象的情况下会一直占用堆内存。如果一个第三方开源框架中的类都是基于饿汉模式实现的单例,这将会初始化所有的单例类,无疑是灾难性的

饱汉式(懒汉式)

饱汉式(懒汉式)

​ 饱汉式单例模式的构建核心参考上述思路,其最重要的是对象的创建(饱汉式定义对象的时候初始化为null,只有在要用到该类对象的时候才去判断是否要创建对象实例)

// 02-饱汉式(只有在要用到实例的时候才创建)
class Singleton02{
    // 1.自行创建实例(该实例私有化)
    private static Singleton02 instance = null;

    // 2.构造函数私有化
    private Singleton02(){}

    // 3.对外提供访问该实例的入口
    public static Singleton02 getInstance(){
        // 饱汉式是只有在用到实例的时候进行判断,如果实例为null则常见
        if(instance==null){
            instance = new Singleton02();
        }
        return instance;
    }
}
// 测试
public class SingletonDemo {
    public static void main(String[] args) { 
      	// 2.饱汉式
        Singleton02 singleton021 = Singleton02.getInstance();
        Singleton02 singleton022 = Singleton02.getInstance();
        System.out.println(singleton021);
        System.out.println(singleton022);
}

// output
com.noob.design.singleton.Singleton02@5305068a
com.noob.design.singleton.Singleton02@5305068a

​ 懒汉模式的引入则是为了避免在加载类对象时提前创建对象的情况(也就是为了解决饿汉式的缺陷),只有当系统使用到类对象的时候才会将实例加载到堆内存中。但是需要考虑多线程场景下该模式的不足。

​ 懒汉模式在单线程场景下是没有问题的,但是设想在多线程场景下,A、B线程同时访问getInstance()方法,假设A先抢先进入并准备执行对象实例化操作,但是当A还没完成实例化操作时B就进入判断条件,就会认为当前还没有创建instance实例,就会进一步执行new方法也去创建一个实例,基于这种情况就可能会出现多个不同的实例。因此在多线程场景下,饿汉模式的构建是不安全的。

​ 此处可在程序中引入计数器,校验懒汉模式在多线程环境下是否安全

// 饱汉式|懒汉式(只有在要用到实例的时候才创建)
class LazySingleton{
    // 1.自行创建实例(该实例私有化)
    private static LazySingleton instance = null;

    // 定义全局计数器,统计构造函数调用次数
    private static int count ;

    // 2.构造函数私有化
    private LazySingleton(){
        System.out.println("LazySingleton 构造函数被调用第" + (count++) + "次");
    }

    // 3.对外提供访问该实例的入口
    public static LazySingleton getInstance(){
        // 饱汉式是只有在用到实例的时候进行判断,如果实例为null则常见
        if(instance==null){
            instance = new LazySingleton();
        }
        return instance;
    }
}



/**
 * 多线程环境 验证单例模式
 */
public class MultiSingletonDemo {
    public static void main(String[] args) {
        // 验证多线程场景下懒汉式的不安全性
        Runnable task = ()->{
            String threadName = Thread.currentThread().getName();
            LazySingleton instance = LazySingleton.getInstance();
            System.out.println("线程 " + threadName + "\t => " + instance.hashCode());
        };
        // 模拟多线程环境下使用 Singleton 类获得对象
        for(int i=0;i<100;i++){
            new Thread(task,"" + i).start();
        }
    }
}
// output
LazySingleton 构造函数被调用第2次
线程 5	 => 689148940
LazySingleton 构造函数被调用第8次
LazySingleton 构造函数被调用第1次
线程 6	 => 1883380079
线程 8	 => 1383403720
LazySingleton 构造函数被调用第5次
线程 3	 => 575477497
LazySingleton 构造函数被调用第4次
线程 11	 => 575477497
LazySingleton 构造函数被调用第7次
线程 7	 => 2068816179
LazySingleton 构造函数被调用第6次
线程 1	 => 1137996083
LazySingleton 构造函数被调用第0次
线程 14	 => 1137996083
LazySingleton 构造函数被调用第3次
线程 4	 => 1610304993
线程 15	 => 555698773
线程 0	 => 555698773
线程 13	 => 1137996083

​ 基于上述结果分析,可以看到多线程环境下懒汉式无法保证实例的唯一性,前期由于线程并发就会导致实例被创建多次,后期由于实例构建完成则其他线程才会认为实例已经创建而直接引用

懒汉式优化(同步锁synchronized)、double-check

​ 为了解决传统懒汉式的多线程场景问题,此处可以引入同步锁,对创建方法进行加锁,进而确保多线程情况下实例的唯一性(引入synchronized同步锁修饰getInstance方法)

修改方案1:给getInstance方法加上同步锁(基于上面的MultiSingletonDemo案例调整,便于验证多线程环境)

public static synchronized LazySingleton getInstance(){
  .....
}

​ 基于这种修改方案:同步锁会增加锁竞争,带来系统性能开销,从而导致系统性能下降,因此这种方式也会降低单例模式的性能

​ 从代码层面分析,如果将synchronized加在getInstance方法上,除了第一次请求instance会为null(第一次请求判断为null,然后会创建实例),其他的每一次请求都是不为null。但是由于锁加在方法上,就会导致每次请求这个方法是都要加锁,基于这种无脑加锁的方式,就会变相导致程序运行的开销变大(因为加锁可能涉及到用户态->内核态 之间的转换,这样的转换成本很高)

修改方案2:考虑是否可以将锁加在条件上,进而减少同步锁资源竞争,因为在没有加同步锁之前,是因为 if 判断条件为 null 时,才导致创建了多个实例

public static LazySingleton getInstance() {
  synchronized (LazySingleton.class) {
    if (instance == null) {
      instance = new LazySingleton();
    }
  }
  return instance;
}

​ 基于这种修改方案:每次访问还是会进行加锁(会带来额外的性能开销),因此需要在外层加上一个null判断,如果是第一次访问则会进入到方法判断是否创建实例,如果实例创建完成后其他访问都不会再去进入被锁的代码块,而是直接引用对象。这种模式一般被成为Double-Check模式(双检模式),它可以大大提高支持多线程的懒汉模式的运行性能

public static LazySingleton getInstance() {
  // 饱汉式是只有在用到实例的时候进行判断,如果实例为null则常见
  if(instance == null) { // 第一次判断如果为null则进入代码块
    synchronized (LazySingleton.class) { // 加锁
      if (instance == null) { // 第二次判断如果实例为null则创建
        instance = new LazySingleton();
      }
    }
  }
  // 一旦实例被创建,后续所有的访问都会直接返回引用,而不会重复判断
  return instance;
}

​ 但是进一步思考,基于现有版本是否还存在其他问题?此处需要引入Happens-Before 规则和重排序概念

​ 编译器为了尽可能地减少寄存器的读取、存储次数,会充分复用寄存器的存储值,比如以下代码,如果没有进行重排序优化,正常的执行顺序是步骤 1/2/3,而在编译期间进行了重排序优化之后,执行的步骤有可能就变成了步骤 1/3/2,这样就能减少一次寄存器的存取次数。

int a = 1;//步骤1:加载a变量的内存地址到寄存器中,加载1到寄存器中,CPU通过mov指令把1写入到寄存器指定的内存中
int b = 2;//步骤2 加载b变量的内存地址到寄存器中,加载2到寄存器中,CPU通过mov指令把2写入到寄存器指定的内存中
a = a + 1;//步骤3 重新加载a变量的内存地址到寄存器中,加载1到寄存器中,CPU通过mov指令把1写入到寄存器指定的内存中

​ 在 JMM 中,重排序是十分重要的一环,特别是在并发编程中。如果 JVM 可以对它们进行任意排序以提高程序性能,也可能会给并发编程带来一系列的问题。以Double-Check 的单例问题为例,如果类中还有有其它的属性也需要实例化,则除了要实例化单例类本身,还需要对其它属性也进行实例化,则类的实例化过程分析如下:instance = new LazySingleton();

  • 步骤1:给 LazySingleton 分配内存
  • 步骤2:调用 LazySingleton 的构造函数来初始化成员变量
  • 步骤3:将 LazySingleton 对象指向分配的内存空间(执行完这步 instance 就为非 null )

​ 如果虚拟机发生了重排序优化,这个时候 3 可能发生在步骤 2 之前。此时设想一种情况,线程A完成步骤3但是步骤2还没有进行的时候(也就是说这个时候成员变量还没有被初始化),此时另一个线程B进行了第一次判断(因为线程A已经完成步骤3,此时对象判断为非null)的结果是非null(直接返回对象使用),但这个时候属性的构造并没有完成,如果线程B又进一步使用了某个属性就会导致异常。此处Synchronized 只能保证可见性、原子性,无法保证执行的顺序。

​ 基于这种情况,就体现出 Happens-Before 规则的重要性了。通过字面意思,可能会误以为是前一个操作发生在后一个操作之前,但其真正的含义是:前一个操作的结果可以被后续的操作获取。Happens-Before 规则规范了编译器对程序的重排序优化

​ volatile 关键字可以保证线程间变量的可见性,简单地说就是当线程 A 对变量 X 进行修改后,在线程 A 后面执行的其它线程就能看到变量 X 的变动。除此之外,volatile 在 JDK1.5 之后还有一个作用就是阻止局部重排序的发生,也就是说,volatile 变量的操作指令都不会被重排序。

​ 即在原有的版本基础上,将instance用volatile关键字修饰,确保其在JVM加载的时候其操作指令不会被重排序,进而规避了前面“JVM重排序导致的异常问题”

补充说明:只有在很低版本的Java才会出现这个重排序问题。在Java的高版本中,已经不需要增加volatile来禁止类重排序。因为高版本的 Java 已经在 JDK 内部实现中解决了这个问题(解决的方法很简单,只要把对象 new 操作和初始化操作设计为原子操作,就自然能禁止重排序)。

简单总结分析

​ 由于JVM重排序优化的存在,类对象指向分配的内存空间可能会在构造函数初始化之前执行完成(也就是对象不为null但成员属性可能还没初始化完成),这个时候可能会导致多线程场景下一些线程引用到还没有被初始化的成员变量而触发异常。

​ 为了解决上述这个问题,引入Happens-Before 规则规范编译器对程序的重排序优化。而通过引入volatile关键字对对象(instance)进行修饰,可以确保volatile 变量的操作指令都不会被重排序。

修改方案3:引入volatile关键字对对象(instance)进行修饰

private volatile static LazySingleton instance = null;

​ 基于上述优化方案,可以得到最终的【同步锁+双检】的懒汉模式(也就是常说的双检锁模式

// 饱汉式|懒汉式(只有在要用到实例的时候才创建)
class LazySingleton {
    // 1.自行创建实例(该实例私有化)
    private volatile static LazySingleton instance = null;

    // 定义全局计数器,统计构造函数调用次数
    private static int count;

    // 2.构造函数私有化
    private LazySingleton() {
        System.out.println("LazySingleton 构造函数被调用第" + (count++) + "次");
    }

    // 3.对外提供访问该实例的入口
    public static LazySingleton getInstance() {
        // 饱汉式是只有在用到实例的时候进行判断,如果实例为null则常见
        if(instance == null) {
            synchronized (LazySingleton.class) {
                if (instance == null) {
                    instance = new LazySingleton();
                }
            }
        }
        return instance;
    }
}

/**
 * 多线程环境 验证单例模式
 */
public class MultiSingletonDemo {
    public static void main(String[] args) {
        // 验证多线程场景下懒汉式的不安全性
        Runnable task = () -> {
            String threadName = Thread.currentThread().getName();
            LazySingleton instance = LazySingleton.getInstance();
            System.out.println("线程 " + threadName + "\t => " + instance.hashCode());
        };
        // 模拟多线程环境下使用 Singleton 类获得对象
        for (int i = 0; i < 100; i++) {
            new Thread(task, "" + i).start();
        }
    }
}
评论
  • 按正序
  • 按倒序
  • 按热度
Powered by Waline v3.1.3