设计模式知识点汇总
文章目录
一、设计模式七大原则
开放-封闭原则
一个软件实体如类、模块和函数应该对扩展开放,对修改关闭。
用抽象构建框架,用实现扩展细节。说白了,就是面向抽象编程而不是面向具体业务。当需要扩展一个功能,不是修改原来的而是增加新的类即可,有利于减少带来新的bug等。
这样做能够提高软件系统的可复用性以及可维护性。
依赖倒转原则
- 高层模块不应该依赖低层模块。两个都应该
依赖抽象
。 抽象不应该依赖细节,细节应该依赖抽象
。
所谓高层模块依赖低层模块是指,举个例子,平时做项目大多数需要访问数据库,于是便把访问数据库的代码写成了函数,每次做新项目的时候就去调用这些函数。这样做是存在问题的,核心便是无法通用,因为高层代码都是和低层的访问数据库代码绑定在一起了,如果希望使用不同的数据库或者存储信息方式,而此时无法复用高层模块,这便是问题。
解决办法是高层模块和低层模块都依赖于抽象类或接口,接口保持稳定,使得高层模块和低层模块都非常容易复用。
这里涉及到里氏替换原则,正是由于子类型的可替换性才使得父亲类型的模块在无法修改的情况下可以扩展。
单一职责原则
不要存在多余一个导致类变更的原因。
接口隔离原则
用多个专门的接口,而不使用单一的总接口,客户端不应该依赖它不需要的接口。
一个类对另一个类的依赖应该建立在最小的接口上,所以在建立接口时,应该建立单一接口,不要建立庞大臃肿的接口,尽量细化接口,接口中的方法尽量少。
也不是极力缩小,适度为好。
里氏替换原则(LSP)
通俗来说就是,一个软件实体如果使用的使用一个父类的话,那么一定适用于其子类。而且它察觉不出父类对象和子类对象的区别。换句话说,在软件编程里,把父类都替换成它的子类程序行为没有发生变化。即子类型必须能够替换掉其父类型。
迪米特法则
迪米特法则(LoD),也叫最少知识原则:如果两个类不必彼此直接通信,那么这两个类就不应该发生直接的相互作用。如果一个类需要调用另一个类中的某一个方法的话,可以通过第三者转发这个调用。
其根本思想,是强调降低类之间的松耦合。
二、创建型
2. 1 简单工厂模式
概念
在创建一个对象时不向客户暴露内部细节,并提供一个创建对象的通用接口。由一个工厂(类)决定创建出哪一种产品的实例。不属于COF23种设计模式。
使用场景
工厂类负责创建的对象比较少、客户端只知道传入工厂类的参数而不关心如何创建对象的逻辑。
优缺点
优点
只需要传入一个正确的参数,就可以获取所需要的对象而无需知道创建细节
缺点
工厂类职责过重,增加新的产品需要修改工厂类的判断逻辑,违背开闭原则。
无法形成基于继承图的等级结构。
其他
对于增加新的需要修改逻辑判断问题,可以使用反射技术来解决。
jdk中的Calendar、JDBC、logback的loggerfactory都使用了简单工厂方法。
2.2 工厂方法模式
概念
定义一个用于创建对象的接口,让子类决定实例化哪个类。工厂方法使一个类的实例化延迟到其子类。
工厂方法克服了简单工厂违背开放-封闭原则的缺点,又保持了封装对象创建过程的优点。
使用场景
- 创建对象需要大量重复的代码
- 客户端不依赖于产品类实例如何被创建、实现等细节
一个类通过其子类来指定创建哪个对象
优缺点
优点
- 用户 只需要关心所需产品对应的工厂,无须关心创建细节
- 加入新产品符合开闭原则,提高可扩展性
缺点
- 类的个数容易过多,增加复杂度
- 增加了系统的抽象性和理解难度
其他
jdk中如iterator、URLStreamHandlerFactory等
logback的loggerfactory都使用了工厂方法
2.3 抽象工厂
概念
抽象工厂模式提供一个创建一系列相关或相互依赖对象的接口,无须指定它们具体的类。
使用场景
- 客户端不依赖于产品类实例如何被创建、实现等细节
- 强调一系列相关的产品对象(属于同一产品族)一起使用创建对象需要大量重复的代码
- 提供一个产品类的库,所有的产品以同样的接口出现,从而使客户端不依赖于具体实现
优缺点
优点
- 具体产品在应用层代码隔离,无须关心创建细节
将一个系列的产品族统一到一起创建
缺点
- 规定了所有可能被创建的产品集合,产品族中扩展新的产品困难,需要修改抽象工厂的接口
- 增加了系统的抽象性和理解难度
其他
抽象工厂关注产品族,工厂模式关注产品等级
2.4 生成器模式
概念
将一个复杂对象的构建与它的表示分离,使得同样的构建过程可以创建不同的表示。换句话说,即封装一个对象的构造过程,并允许按步骤构造。
适用场景
- 一个对象有非常复杂的内部结构(有很多属性)
- 想把复杂对象的创建和使用分离
优缺点
优点
- 封装性好、创建和使用分离
- 扩展新好、建造类之间独立,一定程度上解耦
缺点
- 产生多余的builder对象
- 产品内部发生变化,建造者都要修改,成本较大
其他
指挥者是非常重要的,用来控制构建过程,也用它来隔离用户与建造过程的关联。
生成器模式主要用于创建一些复杂的对象,这些对象内部构建间的建造顺序通常是稳定的,但对象内部的构建通常面临着复杂的变化。
改进版是链式调用,是构造部分。
JDK中的StringBuilder、Guava中的CacheBuilder、Spring中的BeanDefinitionBuilder
2.4 单例模式
概念
保证一个类仅有一个实例,并提供一个全局访问点
适用场景
想确保任何情况下都绝对只有一个实例
优缺点
优点
- 在内存中只有一个实例,减少了内存开销
- 可以避免对资源的多重占用
- 设置全局访问点,严格控制访问
缺点
没有接口,扩展困难
其他重点
-
私有构造器
即构造器必须是私有的,防止外部直接创建对象
-
线程安全
最基本的if判断有线程安全问题,一般的解决方法有三种:
-
synchronized关键字加在方法上,但是效率较低
-
双重校验锁
代码如下:
private volatile static LazySingleton3 uniqueInstance; private LazySingleton3(){} public static LazySingleton3 getUniqueInstance(){ // 这里还是会多个线程同时进入,所以需要双重判断 if (uniqueInstance == null){ synchronized (LazySingleton3.class){ // 保证只实例化一次 if (uniqueInstance == null) { uniqueInstance = new LazySingleton3(); //3 } } } return uniqueInstance; }
这里有几个比较常见的问题:
1) 为什么需要双重校验?
如果不是双重校验,当 instance 为 null 时,两个线程可以并发地进入 if 语句内部。然后,一个线程进入 synchronized 块来初始化 instance,而另一个线程则被阻断。当第一个线程退出 synchronized 块时,等待着的线程进入并创建另一个 Singleton 对象。注意:当第二个线程进入 synchronized 块时,它并没有检查 instance 是否非 null。
第二次检查使得创建两个不同的 Singleton 对象成为不可能。
- 为什么需要volatile关键字?
这里的主要原因是由jvm引起的指令重排序问题。对于语句//3,uniqueInstance = new Singleton(); 这段代码其实是分为三步执行:
* 1. 为 uniqueInstance 分配内存空间
* 2. 初始化 uniqueInstance
* 3. 将 uniqueInstance 指向分配的内存地址
但是由于 JVM 具有指令重排的特性(目的是优化执行速度),执行顺序有可能变成 1 > 3 > 2。
指令重排在单线程环境下不会出现问题,但是在多线程环境下会导致一个线程获得还没有初始化的实例。
例如,线程 T1 执行了 1 和 3,此时 T2 调用 getUniqueInstance() 后发现 uniqueInstance 不为空,
因此返回 uniqueInstance,但此时 uniqueInstance 还未被初始化。
volatile可以禁止指令重排序。
此处的解决方案,本质上是禁止 2 3 重排序,另一种方法是允许重排序,但不允许其他线程看到这个重排序,此时使用
静态内部类
来解决。代码如下所示:private StaticInnerClass(){} private static class SingleHolder{ private static final StaticInnerClass INSTANCE = new StaticInnerClass(); } public static StaticInnerClass getInstance() { return SingleHolder.INSTANCE; }
此时的解决思想就是基于类初始化的延迟加载,如下图所示:
线程0进行Class对象的初始化的时候,右边框里面虽然发生了重排序,但是线程1此时是无法看到的,因为线程1此时再绿色区域等待。jvm保证其只被实例化一次。
-
-
序列化和反序列化安全
单例模式下,通过序列化和反序列化拿到了不同的对象,这是一个需要注意的问题。
以饿汉模式距离,代码如下:
HungrySingleton instance = HungrySingleton.getUniqueInstance(); ObjectOutputStream oos = new ObjectOutputStream(new FileOutputStream("singleton_file")); oos.writeObject(instance); File file = new File("singleton_file"); ObjectInputStream ois = new ObjectInputStream(new FileInputStream(file)); HungrySingleton newInstance = (HungrySingleton) ois.readObject(); System.out.println(instance); System.out.println(newInstance); System.out.println(instance == newInstance);
发现得到的对象发生了变化。
那么如何解决呢?原因又是什么呢?
解决办法:
在HungrySingleton类里加入以下方法:
private Object readResolve() { return hungrySingleton; }
原因探究:
这里需要一步一步调试追踪源码,
HungrySingleton newInstance = (HungrySingleton) ois.readObject();
进入readObject()内部,
进入readObject0方法,实例化的是对象
进入readOrdinaryObject方法,可以发现原因
如果isInstantiable如true,会创建一个新对象,是通过反射实现的,所以不是原来的对象了。再往下看源码,
Desc.hasReadResolveMethod()方法是重点,点进去看源码,看注释可以发现要找的答案
从代码可以看出是通过反射获得方法的,进入invokeReadResolve()方法可以查看
-
反射攻击
构造器是私有的,所以无法直接获取实例,但是可以通过反射,将权限打开
可以发现,可以获得实例,结果为false
对于饿汉式来说,是在类加载的时候生成实例,所以可以在构造器中做判断,用于防御反射攻击
这种方式,适用于类加载时候模式,所以静态内部类实现懒汉单例也可以使用。
而对于其他懒汉式实现,由于不是在类加载时候生成对象,只有调用getInstance方法的时候生成对象,所以跟创建实例的顺序有关。
这种情况下,先调用getInstance(),且此方法是同步方法,调用以后对象会被new出来,后调用的也就走到了异常里面。但是如果先使用反射后调用getInstance,可以发现两个对象都可以创建出来。
多线程情况下,看cpu分配。
懒汉模式
重点:
- 延迟加载
- 私有构造
- if判断时的线程安全性
枚举类型的单例
枚举类型的单例,可能是实现单例模式的最佳实践。
Enum的实现方式不受序列化影响
可以通过查看源码的方式来理解,在ObjectInputStream类里有一个方法readEnum
通过readString方法获得枚举对象的名称,通过类型和name获得枚举常量,为下面的赋值,由于枚举中的name是唯一的,对应一个枚举常量,所以en是唯一的常量对象,所以不受序列化影响的破坏。
此方法依赖于如下:
Enum不受反射攻击影响
Enum只有一个构造器,并且不是无参的,需要传两个参数,直接使用反射会报java.lang.NoSuchMethodException错误。
下面传入两个参数
运行报错如下:
非法的参数异常:不能反射去创建枚举对象!
可以查看Constructor源码发现:
做了校验是不是枚举类型,所以无法进行反射攻击。
使用反编译工具,对SingletonEnum.class进行反编译,可以更加容易理解。
如下图所示
反编译以后,类是final的,私有构造器非常符合单例模式的要求
INSTANCE对象是static final的,创建对象和饿汉模式非常像,通过静态块,在类加载的时候初始化完成,所以也是线程安全的。
2.4 原型模式
概念
用原型实例创建对象的种类,并且通过拷贝这些原型对象创建新的对象。
原型模式其实就是从一个对象再创建另外一个可定制的对象,而且不需要知道任何创建的细节,不调用构造函数。
适用场景
- 类初始化需要消耗较多资源,并且需要创建大量消耗过多资源的对象的时候
- new产生的一个对象需要非常繁琐的过程(数据准备、访问权限等)
- 构造函数比较复杂
- 循环体中生成大量对象
优缺点
优点
一般在初始化的信息不发生变化的情况下,克隆是最好的办法。既因此了对象创建的细节,又大大提高了性能。
不用重新初始化对象,而是动态地获得对象运行时的状态。
简单来说,原型模式性能比直接new一个对象性能高,简化了创建过程
缺点
- 需要注意浅拷贝和深拷贝问题,深拷贝时需要注意拷贝到多少层
- 必须配备克隆方法
- 对克隆复杂对象或对克隆出的对象进行复杂改造时,容易引出风险
深拷贝和浅拷贝问题
在原型模式中,一个对象需要实现Cloneable接口,然后重写clone()方法,此时在克隆对象时是浅拷贝,容易带来bug,建议进行深拷贝,在clone方法里进行成员对象的拷贝即可。举例如下:
protected Object clone() throws CloneNotSupportedException {
Pig pig = (Pig) super.clone();
// 深拷贝
pig.birthday = (Date) pig.birthday.clone();
return pig;
}
克隆破坏单例模式
对于前面的单例模式,让类也同时继承Cloneable接口,此时可以通过克隆破坏原来的单例模式,返回两个不同的对象,解决办法是在clone()方法里返回getInstance()即可。
三、行为型
3.1 模板方法模式
概念
定义一个操作中的算法骨架,而将一些步骤延迟到子类中。模板方法使得子类可以不改变一个算法的结构即可重新定义该算法的某些特定步骤。
适用场景
- 一次性实现一个算法的不变的部分,并将可变的行为留给子类来实现
- 各子类中公共的行为被提取出来并集中到一个公共父类中,从而避免代码重复
优缺点
优点
-
通过把不变行为搬移到超类,去除子类中重复的代码,提供很好的代码复用。
-
提高了扩展性。
-
符合开闭原则。
缺点
- 类数目增加
- 增加了系统实现的复杂度
- 继承关系自身缺点,如果父类添加新的抽象方法,所有子类都要改一遍
模板方法模式与相关设计模式
模板方法模式和工厂方法模式
工厂方法是模板方法的一种特殊实现
模板方法和策略模式
策略模式目的是使不同的算法可以相互替换,并且不影响应用层客户端的使用
而模板方法模式是针对定义一个方法的流程,而将不太一样的实现步骤交给具体子类去实现,模板方法不改变算法流程。
3.2 迭代器模式
概念
提供一种方法,顺序访问一个集合对象中的各个元素,而又不暴露该对象的内部表示。
适用场景
访问一个集合对象的内容而又无需暴露它的内部标识,为遍历不同的集合结构提供一个统一的接口。
优缺点
优点
分离了集合对象的遍历行为
缺点
类的个数成对增加
迭代器相关的设计模式
迭代器模式和访问者模式
都是迭代的访问集合中的各个元素,不同的是,在访问者模式中扩展开放的部分作用于对象的操作上。而在迭代器模式中扩展开放的部分而在于集合的种类上。
四、结构型
4.1 外观模式
概念
提供了一个统一的接口,用来访问子系统中的一群接口,从而让子系统更容易使用。
适用场景
- 子系统变得复杂,增加外观模式提供简单调用接口
- 构建多层系统结构,利用外观对象作为每层的入口,简化层间调用
优缺点
优点
- 简化了调用过程,无须了解深入子系统,防止带来风险
- 减少系统依赖,松散耦合
- 更好的换分访问层次
- 符合迪米特法则,即最少知道原则
缺点
- 增加子系统、扩展子系统行为容易引入风险
- 不符合开闭原则
外观模式和其他模式结合、区别
外观模式和中介者模式
外观模式关注的是外界和子系统之间的交互,中介者模式关注的是子系统内部之间的交互
外观模式和单例模式
可以把外观模式的外观对象做成单例模式结合使用
外观模式和抽象工厂模式
外观类可以通过抽象工厂获取子系统的实例,这样子系统将内部对外观类进行屏蔽
外观模式在框架中的使用
JDBC中的JdbcUtils
Tomcat中有很多Facade结尾的类,如RequestFacade、ResponseFacade等
4.2 装饰模式
概念
动态的给一些对象(不改变原有对象)添加一些额外的职责,就增加功能来说,装饰模式比生成子类更加灵活。
适用场景
- 扩展一个类的功能或给一个类添加附加职责
- 动态的给一个对象添加功能,这些功能可以再动态的撤销
优缺点
优点
- 继承的有力补充,比继承灵活,不改变原有对象的情况下
动态
给一个对象扩展功能 - 通过使用不同装饰类以及这些装饰类的排列组合,可以实现不同效果
- 符合开闭原则
缺点
- 出现更多的代码,更多的类,增加程序复杂性
- 动态装饰时,多层装饰时会更复杂,排错困难
装饰者和其他设计模式
装饰者模式和代理模式
装饰者模式关注在一个对象上动态的添加方法,代理模式关注于控制对对象的访问,代理模式的代理类对它的客户隐藏类的具体信息。
通常在使用代理模式的时候会在代理类中创建一个对象的实例。而在使用装饰者模式的时候,常常将被装饰者当做一个参数传给装饰者的构造器。
装饰者模式和适配器模式
都可以叫做包装模式。装饰者和被装饰者可以实现相同的接口,或者装饰者是被装饰者的子类。在适配器中,适配器和被适配的类具有不同的接口,当然也可能有部分重合。
源码中的使用
主要是Java IO中使用比较多
Spring中的TransactionAwareCacheDecorator类等
总结
装饰功能是为已有功能动态添加更多功能的一种方式。如果不适用装饰模式,当系统需要新功能时,需要向旧的类中添加新的代码,新的代码丰富了原有类的核心职责和主要行为。但是,这样做有很多缺点。新加的变量、方法、逻辑增加了类的复杂度,而这些新加入的东西很多时候仅仅是为了满足某些特定情况下才会执行的需求。
装饰模式把每个要装饰的功能放在单独的类中,并让这个类包装它所需要装饰的对象。客户端在运行时可以有选择、按顺序的包装对象,简化原有的类。
4.3 适配器模式
概念
将一个类的接口转换成客户期望的另一个接口,是原本接口不兼容的类可以一起工作
适用场景
- 已经存在的类,它的方法和需求不匹配时(方法结果相同或相似)
- 不是软件设计阶段考虑的设计模式,是随着软件维护,由于不同产品、不同厂家造成功能类似而接口不同情况下的解决方案
优缺点
优点
- 能提高类的透明性和复用,现有的类复用但不需要改变
- 目标类和适配器类解耦,提高程序扩展性
- 符合开闭原则
缺点
- 适配器编写过程需要全面考虑,可能会增加系统的复杂性增加系统代码可读的难度,比如调用的第1个接口,内部被适配成了第2个接口
适配器模式和其他设计模式
适配器模式和外观模式
都是对现有的类、现存的系统的封装,外观定义了新的接口,而适配器则是复用了原有的接口。
适配器是使两个已有的接口协同工作,外观是在现有的系统中提供一个方便的访问入口。
外观也可以看过适配粒度更粗的,适配的是整个子系统。
其他
适配器模式可以分为类适配器模式
和对象适配器模式
,两者最主要的区别是类适配器通过继承的方式,二对象适配器通过组合的方式。
优先选择组合。
源码中的使用
jdk中的xml解析使用了很多适配器模式
SpringMVC中的Controller的适配 handlerController
4.4 享元模式
概念
提供了减少对象数量从而改善应用所需的对象结构的方式,运用共享技术有效地支持大量细粒度的对象
简单来说就是减少创建对象的数量,从而减少内存的占用,提供系统的性能。
适用场景
- 常用语系统底层的开发,以便解决系统的性能问题,比如java的String类,jdbc的缓存池
- 系统有大量相似的对象、需要缓冲池的场景
优缺点
优点
- 减少对象的创建,降低内存中对象的数量,降低系统的内存占用,提高效率
- 减少内存之外的其他资源占用,比如时间(减少new对象次数)、操作系统中的文件句柄和窗口句柄等
缺点
- 关注内/外部状态,关注线程安全问题
- 使系统、程序的逻辑复杂化
扩展
享元模式有内部状态和外部状态
- 内部状态:在享元对象的内部,并且不会随着环境的改变而改变的共享部分。可以理解为享元对象的属性。
- 外部状态:在享元对象的外部,会改变。
享元模式和其他设计模式
享元模式和代理模式
代理模式需要代理一个类,如果生成需要代理的类需要花费的时间和空间都比较多,那么就可以使用享元模式提高代理类的速度。
享元模式和单例模式
在单例模式中的单例容器中,可以结合享元模式,享元模式就是一种复用对象的思想。
源码中的使用
jdk的Integer类中对-128~127数字的缓存
4.5 组合模式
概念
将对象组合成树形结构以表示“部分-整体”的层次结构,组合模式使客户端对单个对象和组合对象保持一致的处理方式
适用场景
- 希望客户端可以忽略组合对象与单个对象的差异时
- 处理一个树形结构时
优缺点
优点
- 清楚地定义分层次的复杂对象,表示对象的全部或部分层次
- 让客户端忽略了层次的差异,方便对整个层次结构进行控制
- 简化客户端代码
- 符合开闭原则
缺点
- 限制类型时会比较复杂
- 使设计变得更加抽象
4.6 桥接模式
概念
将抽象部分与它的具体实现部分分离,使它们都可以独立的变化。通过组合的方式
建立两个类之间的联系,而不是继承。
适用场景
- 抽象和具体实现之间增加更多的灵活性
- 一个类存在两个或多个独立变化的维度,且这两个或多个维度都需要独立进行扩展
- 不希望使用继承,或因为多层继承导致系统类的个数剧增
优缺点
优点
- 分离抽象部分及其具体实现部分
- 提高了系统的可扩展性
- 符合开闭原则
- 符合合成复用原则(多用组合而不是继承)
缺点
- 增加了系统的理解与设计难度
- 需要正确的识别出系统中两个独立变化的维度
桥接模式和其他设计模式
桥接模式和组合模式
组合模式强调的是整体与部分之间的组合,而桥接模式强调的是平行级别上不同类的组合。
桥接模式和适配器模式
桥接模式和适配器模式都是为了让两个东西配合工作。但两个模式的目的不同,适配器模式是改变已有的接口,让它们之间可以相互配合;桥接模式是分离抽象和具体实现,目的就是分离。适配器模式可以把功能相似但接口不同的类适配起来,而桥接模式是把类的抽象和实现分离开,在此基础上是这些层次结构结合起来。
4.7 代理模式
概念
为其他对象提供一种代理以控制对这个对象的访问。
适用场景
- 保护目标对象
- 增强目标对象
优缺点
优点
- 代理模式能将代理对象与真实被调用的目标对象分离
- 一定程度上降低了系统的耦合度,扩展性好
- 保护目标对象
- 增强目标对象
缺点
- 代理模式造成系统设计中类的数目增加
- 在客户端和目标对象增加一个代理对象,会造成请求处理速度变慢
- 增加系统的复杂度
静态代理、动态代理和CGLib代理
静态代理通过在代码中显式的定义了一个实现类,在代理类中对同名的业务方法进行包装,用户通过代理类中吧哦装过的代理方法来调用目标对象的代理方法,同时对目标对象进行增强
JDK的动态代理是通过接口
中的方法名,在动态生成的代理类中调用业务生成的同名方法。
CGLib代理是通过继承来实现的,生成的动态代理类就是业务的子类,通过重写业务方法进行代理。CGLib低层是通过ASM字节码生成的.CGLib无法代理final
修饰的类和方法。
代理模式和相关设计模式
代理模式和装饰者模式
实现上相似但目的不同。装饰者模式是为对象加上行为,而代理模式是控制访问,代理模式更注重通过代理人的方式来增强目标对象,一般是通过增强目标对象的某些行为。
代理模式和适配器模式
适配器模式主要改变所考虑对象的接口,代理模式是不能改变所代理类的接口的。
Spring代理选择
- 当Bean有实现接口时,Spring就会用JDK的动态代理
- 当Bean没有实现接口时,Spring会使用CGLib
- 可以强制使用CGLib
- 在spring配置中加入<aop:aspectj-autoproxy proxy-target-class=“true”/>
参考资料:https://docs.spring.io/spring/docs/current/spring-framework-reference/core.html
总结
代理的应用场景:
- 远程代理:为一个对象在不同的地址空间提供局部代表,可以隐藏一个对象存在不同地址空间的事实。
- 虚拟代理:根据需要创建开销很大的对象,通过它来存放实力化需要很长时间的真实对象。
- 安全代理:用来控制真实对象访问时的权限。
- 智能指针:当调用真实的对象时,代理处理另外一些事。
参考资料
- 《大话设计模式》
- CS-Notes
- java设计模式精讲 Debug 方式+内存分析