设计模式
设计模式
设计基本原则
- 封装变化
- 针对接口编程,而不是针对实现编程
- 多用组合,少用继承
- OP原则:对扩展开放,对修改关闭
OOP
面向对象软件开发的三个阶段
- OOA:面向对象分析
- OOD:面向对象设计
- OOP:面向对象编程。一种编程范式或是编程风格。
什么是OOP?
一句话:以类和对象作为基本单元,将封装、继承、抽象、多态四个特性作为代码设计和实现的基石。
如何判断一个语言是否是面向对象的?
只要看这个语言是否实现了四大特性(不一定全都要实现,放宽要求的话,只要具有类、对象这两个概念,这个语言就可以是OO的了)
四大特性的意义:
- 封装:
- 保证系统运行的安全性,防止意外的更改数据
- 提高易用性(减少不必要的额外操作提高易用性,比方说一个电视机,有很多配置,这需要很大的学习成本,但是如果只有开机、关机、切换频道三个按钮,就很容易上手)
- 抽象:
- 关注大逻辑,忽略小细节
- 继承:
- 代码复用
- 多态:
- 可扩展性
- 复用性
Java如何实现四大特性:
- 封装:通过访问权限控制(
private
、protected
、default
、public
) - 抽象:抽象类与接口类
- 继承:类之间的继承
- 多态:父类可以引用子类对象;重写;重载
Java抽象类
Java 抽象类
- 抽象类不能被实例化(如果被实例化,就会报错,编译无法通过)
- 抽象类中不一定包含抽象方法,但是有抽象方法的类必定是抽象类。
- 抽象类中的抽象方法只是声明,不包含方法体,就是不给出方法的具体实现也就是方法的具体功能。
- 构造方法,类方法(用 static 修饰的方法)不能声明为抽象方法。
- 抽象类的子类必须给出抽象类中的抽象方法的具体实现,除非该子类也是抽象类。
- 抽象类可以有具体的方法
如何决定使用接口还是抽象类?
- 如果我们要表示一种
is-a
的关系,并且是为了解决代码复用的问题,我们就用抽象类 - 如果我们要表示一种
has-a
关系,并且是为了解决抽象而非代码复用的问题,那我们就可以使用接口
创建型
创建型:阐述如何优雅的创建一个对象
单例模式
单例模式的适用场景
单例模式:一个类只创建一个实例
- 防止资源的访问冲突
- 解决资源访问冲突的方法很多:分布式锁、使用并发类、而使用单例模式是最简单的一种解决方法
- 表示全局唯一
- 有时候业务上要表示系统全局唯一的类,比如配置文件
单例模式的实现要点
要实现一个单例模式,有很多种方式:
- 将构造方法设为
private
【必选】 - 是否懒加载
- 保证创建时的线程安全(用
volatile
) - 考虑是否会被反射破坏单例性
实现单例模式的5种方式
饥饿式
特点:private
构造方法、实例是由private static final
修饰
- 线程安全(类成员,而且有
final
) - 初始化时就会被加载
1 | public class Hungary { |
其中初始化时就加载是饥饿式单例一直被诟病的一点
- 缺点:初始化时就加载,延长了启动时间,占用了内存(万一没有使用此单例,相当于白白浪费了内存)
但是这几个缺点真的很致命吗?
- 对于延长了启动时间:如果将启动时间延迟到第一次使用时,那么不就延长了第一次使用时的响应时间吗?
- 对于占用了内存:按照fail-fast(有问题及早暴露)这一观点,提早创建,如果内存不够,可以提早出现OOM,便于去修复
所以饥饿式的两个缺点其实也不是很致命(所以不该对于饥饿式的态度好像有点谈虎色变的感觉)
懒汉式
特点:将创建延迟到第一次使用时
- 线程安全:为保证线程安全,对
static
方法添加了synchronized
关键字,相当于给类对象Lazy.class
加锁 - 第一次使用时才会被加载
1 | public class Lazy { |
懒汉式的缺点很明显,使用synchronized
这种重量级锁,会大大降低并发性(几乎和串行没有区别)
使用要注意:
如果频繁地用到,那频繁加锁、释放锁及并发度低 等问题,会导致性能瓶颈
如果使用并不频繁,懒汉式也是一种比较好的实现方式
DCL
双重检查懒汉式(Double Check Lazy)
特点:解决懒汉式的并发程度太低的问题
- 线程安全:使用两次
if
保证速度,使用volatile
保证创建对象安全
1 | public class DCL { |
为什么要加
volatile
?
因为new
创建一个对象,其实有三个阶段:
- 分配内存
- 初始化
- 指向引用
其中CPU判断2与3并不存在先后执行关系,所以有可能指向引用之后,依然没能初始化,但是对象已经可以拿到了(因为有了引用),所以线程会把它拿回工作内存
因此要加volatile
,避免重排序
为什么要两次判断是否为空?
第二次判断很好理解,为什么要加第一重判断?
如果没有第一重的判断,那么多个线程会为了获取DCL.class
锁而进入等待,而且也没有办法唤醒
如果有第一重判断,那么会让没拿到锁的线程先去做其他事,提高并发度
静态内部类
静态内部类的特点:外部类被加载,内部类不会被加载
- 懒加载:内部类的特点
- 线程安全:
static final
1 | public class StaticInnerClass { |
枚举
枚举可以做到真正的单例(不会被反射创建新的对象)
1 | public enum SingleEnum { |
为什么反射创建不了枚举对象?
JDK源码如下:
1 | if ((clazz.getModifiers() & Modifier.ENUM) != 0) |
如果发现类型是Enum,就会抛出异常
如果是其他的枚举类,可以通过反射获取构造器,设置许可为true的方式创建对象,如下:
1 | DCL instance1 = DCL.getInstance(); |
但如果枚举类这么做就会报错
不同情况下的单例模式
- 进程内唯一的单例模式:在多个线程(一个进程的多个线程)内,保证单例(上述的五种单例模式都是进程唯一的单例模式)
- 集群唯一的单例模式:在多个进程间,保证单例
如何在进群内保证一个对象是单例?
- 需要把共享的对象,序列化到外部一个共享区域;
- 使用时上锁,反序列化后使用;
- 使用完后,并将对象序列化回该区域,释放锁;
多例模式
所谓多例模式:
- 可以理解为创建的对象的个数是有限的
- 也可以理解为同一类的对象为单例,不同类之间创建的对象不同
1、可以创建的对象个数有限
1 | public class MultiCaseMode1 { |
这样每次getInstance
返回的就是三个对象之一
2、同一类创建的对象个数有限
1 | public class MultiCaseMode2 { |
这样,对于同一类(即相同key)的对象,会获得相同的对象。
工厂模式
工厂模式:实现了创建者和调用者的分离
普通的对象,我们可以直接new
来创建,但是当创建的操作比较复杂时,就会考虑使用工厂模式,将创建者和调用者分离,简化调用者的操作。
普通的方式:
1 | // 张三开五菱去上班 |
简单工厂模式(静态工厂模式)
简单工厂就是提供一个静态方法,直接返回对象实例。
使用简单工厂方式是这样的:
1 | zhangSan.drive(CarFactory.getCar("别克")); |
1 | public class CarFactory{ |
工厂模式
原则:当创建逻辑比较复杂的时候使用工厂模式,其余使用简单工厂模式即可
什么算创建复杂的逻辑?
- 涉及到对不同的类型,创建了不同的对象(往往有很多个
if-else
嵌套) - 创建涉及到很多个对象
工厂模式相比于简单工厂,就是定义了详细的接口,更加规范:
- 产品接口
- 产品实现接口
- 工厂接口
- 工厂实现接口
对应的接口都需要有实现类,比如下面的例子:
1 | public static void main(String[] args) { |
产品接口及其产品实现接口:
1 | public interface Car { |
工厂接口及其工厂实现接口:
1 | public interface CarFactory { |
建造者模式
建造者模式解决什么问题?
创建一个对象时,往往使用它的构造方法与set
方法去创建这个对象,但是这样存在几个问题:
如果需要设置的成员很多,那么会导致构造函数的参数很多
- 导致代码可读性、易用性变差
- 调用时可能会搞错参数的顺序
如果参数之间存在依赖关系(比如A参数必须大于B参数等),校验逻辑放在构造函数内也不够优雅
避免对象存在无效状态
1
2
3Rectangle r = new Rectangle();// 创建了长方形对象(无效状态)
r.setWidth(2); // 设置了宽,但是只有宽的长方形也是不能用的(无效状态)
r.setHeight(3);// 宽、高都有(有效状态)建造者模式可以避免处于无效状态的对象被别的地方使用导致出错。
例如这个例子:
1 | public class Rectangle { |
如果我们要创建一个长方形:
1 | public static void main(String[] args) { |
这样,即使是我们给的长给了负数,在最后build()
时,也会抛出异常,这样就避免了无效状态。
建造者模式的缺点
代码重复,发现建造者中有原对象的成员(代码冗余度高)
建造者模式与工厂模式的区别
生活的例子:顾客走进一家餐馆点餐,我们利用工厂模式,根据用户不同的选择,来制作不同的食物,比如披萨、汉堡、沙拉。
对于披萨来说,用户又有各种配料可以定制,比如奶酪、西红柿、起司,我们通过建造者模式根据用户选择的不同配料来制作披萨
- 工厂模式:创建不同但是相关类型的对象
- 建造者模式:创建一种逻辑复杂的对象
Lombok有注解
@Builder
就是建造者模式的使用
(关于此注解可以看此篇博客)
原型模式
原型模式:利用对已有对象(原型)进行复制(或者叫拷贝)的方式,来创建新对象,以达到节省创建时间的目的。
原型模式有两种实现方法:
- 深拷贝:一份完完全全独立的对象
- 浅拷贝:复制对象中基本数据类型数据和引用对象的内存地址,不会递归地复制引用对象,以及引用对象的引用对象
可以通过改写clone
方法来实现原型模式:
- 继承
Cloneable
接口 - 重写
clone
方法
1 |
|
浅拷贝,两个对象地址一样
改写clone
为深拷贝
1 |
|
结构型
结构型:总结了一些类或对象组合在一起的经典结构
代理模式
代理模式在不改变原始类接口的条件下,为原始类定义一个代理类,主要目的是控制访问,而非加强功能,这是它跟装饰器模式最大的不同
代理模式常用在业务系统中开发一些非功能性需求,比如:监控、统计、鉴权、限流、事务、幂等、日志。
分为:
- 静态代理
- 动态代理
静态代理模式
静态代理模式中有 真实对象、代理对象
- 真实对象与代理对象要实现同一个接口
- 代理对象要代理真实的角色
优点:
- 静态代理模式可以帮助我们处理一些其他的事情,真实对象可以专注于做本职任务
- 如果业务发生扩展,方便集中管理
例如:租客与房东之间,租客需要一个中介(代理对象),中介负责去找房东(真实对象)
在Java中,Thread
就是一个静态代理的例子,自定义Thread
类要实现Runnable
接口,而Thread
类也实现了Runnable
接口,此时自定义Thread
类就是真实对象,而Thread
类就是代理对象
除此外,在RPC(远程方法调用)中,客户端与服务端连接,具体的连接过程都由一个代理类来完成,这也是代理模式
动态代理
静态代理模式还存在一些问题,需要给每一个类都创建一个代理类,工作量直接翻倍
对于这种情况就出现了动态代理
其他概念与静态代理相同,只不过代理类是动态生成的,而不是我们直接写好的
基于接口的动态代理:JDK Proxy
基于类的动态代理:CGLIB
字节码实现:Javasist(不是重点,实现在JBoss服务器)
JDK Proxy
此动态代理模式用到两个类:Proxy
与InvocationHandler
Proxy主要了解此方法newProxyInstance
:
1 | public static Object newProxyInstance(ClassLoader loader,// 类加载器, 通常会选择使用动态代理类本身的类加载器 |
InvocationHandler是一个接口,他有一个方法:
1 | Object invoke(Object proxy,// 生成的代理对象 |
在JDK动态代理中,真实对象必须实现接口,代理对象才可以对其进行代理,原理如下面这个demo:
1 | // 接口;在静态代理中,这个就是我们要实现的业务,代理与真实对象都要实现 |
运行结果:
1 | 其他操作... |
这也是Spring AOP的实现原理
CGLib
JDK动态代理中,被代理的对象必须实现接口,而CGlib就不需要
桥接模式
什么是桥接模式?
抽象部分与实现部分解耦,可以理解为接口与实现类都属于桥接模式
- 抽象和实现解耦
- 组合优于继承
装饰者模式
模式概念
装饰者模式解决的问题:
当一个类的子类很多时,如果我们想给父类扩展一个功能,那么所有的子类都会发生变化(类爆炸)
什么是装饰者模式?
动态的将责任附加到对象上,若要扩展功能,装饰者提供了比继承更有弹性的替代方案
具体实现上,装饰者类会继承同一个类(其实就是使用多态的特性实现对功能的增强),然后内部含有一个父类对象的引用(下面的InputStream
就是例子)
装饰者模式与代理模式的区别
- 体现的特性不同:
- 装饰者模式体现多态性
- 代理模式体现封装性
- 要实现的目的不同:
- 装饰者模式是为了增强功能
- 代理模式是为了实现不属于自己的功能
重要的例子
这里以InputStream
类来说明:
InputStream
是一个抽象类,他有几个方法:
1 | public abstract class InputStream implements Closeable { |
他有很多个子类,其中有一个子类FilterInputStream
,该类也有子类BufferedInputStream
、DataInputStream
BufferedInputStream
:为流提供了缓冲区DataInputStream
:为流提供了读取基本类型的方法(例如:readInt
、readLong
不需要思考具体的读取细节)
这个例子中,BufferedInputStream
、DataInputStream
就是装饰类,他们继承了InputStream
,使用的时候可以这么用:
1 | InputStream in = new FileInputStream("/test.txt"); |
为什么此处使用装饰者模式?
如果我们想实现缓冲区、读取单个基本类型的字节等等这样的功能,如果直接在父类InputStream
类实现这个功能,那么会导致其所有子类都会有这个功能,这非我本意
为什么需要有一个中间类
FilterInputStream
,而不是直接使用BufferedInputStream
继承InputStream
?(此处比较难理解)
假设BufferedInputStream
直接继承了InputStream
,那么对于InputStream
的抽象方法(比如说read
方法),我们需要重写:
1 | public class BufferedInputStream extends InputStream { |
同理,对于DataInputStream
,我们也需要这样实现一遍,代码冗余度很高,因此抽出来FilterInputStream
这个方法
因此如果看JDK源码就是这样:
1 | public class FilterInputStream extends InputStream { |
适配器模式
将不兼容的接口转换为可兼容的接口
一种补偿措施,补救设计缺陷:
- 有缺陷的接口:参数过多、命名不规范
- 替换依赖的外部系统
- 兼容老版本接口
- 适配不同格式的数据
门面模式
什么是门面模式
门面模式为子系统提供一组统一的接口,定义一组高层接口让子系统更易用
(我的理解:即封装+抽象,封装难用方法,抽象出一个简洁好用的方法)
门面模式与适配器模式的区别
- 门面模式:解决多接口整合的问题
- 适配器模式:解决接口过时或无法使用的问题
遇到什么问题可以使用门面模式呢?
解决性能问题:
假设客户端与服务器之间通信,服务器暴露了3个接口A、B、C,某业务需要客户端请求A、B、C三个接口。
但是客户端是APP,App 和服务器之间是通过移动网络通信的,网络通信耗时比较多
因此我们可以将A、B、C封装在一个D接口中(门面接口),让客户端直接请求门面接口即可,这样就将网络通信的次数减少到1次
注意:
- 门面接口如果数量少,可以直接和普通的接口放在一起,如果比较多的门面接口的话,还是专门给门面接口放一个包比较好
组合模式
对于具有树结构的数据有奇效,比如:
- 文件与目录
- 员工与部门
一个目录下可以有目录也可以有文件,常有的操作是统计一个目录包含的文件数量,及一个目录的大小
如果不使用设计模式,我们可能会设计成:
1 | public class FileSystemNode { |
而使用组合模式的话:
三个类:文件类与目录类继承文件系统类
1 | public abstract class FileSystemNode { |
文件类:
1 | public class File extends FileSystemNode{ |
目录类:
1 | public class Directory extends FileSystemNode { |
享元模式
所谓享元模式,就是共享元数据。
使用享元模式的目的只有一个,就是为了节省内存
比如有一个在线象棋项目,每一个房间都有一个牌局,有几十万个房间,每个房间都有一副象棋。
一个象棋类,需要记录 颜色、位置、棋的种类
但其实,对于每一个房间来说,只有棋的位置是不同的,其他元素都是相同的,因此对于除位置外的元素我们都可以抽为一个类,使用享元模式进行共享,节约内存
1 | public class ChessPieceUnit { |
Java中的享元模式
比如Integer的整型池、字符串常量池都属于享元模式的实现
享元模式与单例模式、缓存、对象池的区别
- 享元模式与单例模式区别
- 享元模式:一个类可以有多个对象(类似于多例模式,但是目的是为了节省内存,而不是限制对象个数)
- 单例模式:一个类只能创建一个对象
- 享元模式与缓存与对象池的区别
- 享元模式:为了节省内存
- 缓存:为了提高访问效率
- 对象池:为了节省对象的创建时间
行为型
观察者模式
概念介绍
观察者模式(即pub/sub模式)
- pub出版者即主题Subject
- sub订阅者改称为观察者Observe
观察者模式=主题+观察者
观察者模式:
定义了对象之间的一对多依赖(
主题:观察者=1:n
),这样一来,当一个对象改变状态时,它的所有依赖者都会收到通知并自动更新
Java中的观察者模式
Java自带了观察者模式(Observe
接口(sub)与Observable
类(pub)),但是存在一些问题
Observable
是一个类,这意味着在单继承的Java中,使用不是很方便Observable
的API中,setChange()
是protected
修饰的,违反了多用组合、少用继承的原则
因此掌握其设计思想才是重要的!
1 | public interface Observer { |
1 | public class Observable { |
简单的Demo
这里以一个报纸公司与其订阅读报者的例子演示观察者模式:
观察者的接口:只需要做自己需要做的事情即可
1 | public interface MyObserve { |
主题的接口:一般都有三个方法分别为注册、移除、通知
1 | public interface MySubject<E> { |
报纸出版公司:
1 | public class PaperCom implements MySubject<PaperSub>{ |
报纸订阅者:
1 | public class PaperSub implements MyObserve{ |
模板模式
策略模式
定义
策略模式:
定义了算法族,分别封装起来,让它们之间可以互相替换,此模式让算法的变化独立于使用算法的客户端
实际上就是解耦了策略的定义、创建、使用的三个过程
JDK线程池的拒绝策略
JDK线程池有四种拒绝策略,他们都实现了此接口:
1 | public interface RejectedExecutionHandler { |
实现类在ThreadPoolExecutor
内部,作为静态内部类实现,比如
1 | public static class AbortPolicy implements RejectedExecutionHandler { |
策略模式的使用
开发中可能会遇到很多if-else
的逻辑
1 | public class OrderService { |
使用策略模式+工厂模式我们就可以避免if-else
嵌套
- 一个策略接口+不同的策略实现
- 一个工厂类,用key代表类型,value存放不同的策略实现
这样就可以避免重复的判断逻辑
职责链模式
定义:
将请求的发送和接收解耦,让多个接收对象都有机会处理这个请求。
将这些接收对象串成一条链,并沿着这条链传递这个请求,直到链上的某个接收对象能够处理它为止。
翻译一下的话,就是:多个处理器依次处理同一个请求。一个请求先经过 A 处理器处理,然后再把请求传递给B处理器,B 处理器处理完后再 传递给 C 处理器,以此类推,形成一个链条。
链条上的每个处理器各自承担各自的处理职责,所以叫作职责链模式。
- 比如Spring MVC的处理逻辑,就是一个职责链模式
- 对一些UGC应用(用户生成内容),需要过滤敏感词,这也可以用到职责链模式