面试八股文对校招的用处有多大?设计模式篇
前言
1.本系列面试八股文的题目及答案均来自于网络平台的内容整理,对其进行了归类整理,在格式和内容上或许会存在一定错误,大家自行理解。内容涵盖部分若有侵权部分,请后台联系,及时删除。
2.本系列发布内容分为12篇 分别是:
设计模式
操作系统
系统编程
网络原理
网络编程
mysql
redis
服务器
RPG
本文为第四篇,后续会陆续更新。 共计200+道八股文。
3.本系列的200+道为整理的八股文系列的一小部分。完整整理完的八股文面试题共计1000+道,100W字左右,体量太大,故此处放至百度云盘链接: https://pan.baidu.com/s/1IOxQs0ifbSPGgxK7Yz7BtQ?pwd=zl1i
提取码:zl1i 需要的同学自取即可。
4.八股文对于面试的同学来说仅作为参考使用,不能作为面试上岸的唯一准备,还是要结合自身的技术能力和项目,同步发育。
最后祝各位同学都能拿到自己满意的offer,成功上岸!
四、设计模式
01.单例模式实现区别
一、什么是单例模式
需要应用单例模式的对象通常只能有一个实例存在。比如我们每个人只能有一条生命,太阳系只有一个太阳,每个人只有一个身份证号。
单例模式的实现方式是,隐藏构造方法,在内部初始化一次,并提供一个全局的访问点。
通过单例模式,我们可以保证系统中只有一个实例,从而在某些特定的场合下达到节约或者控制系统资源的目的。如下是一些单例使用的场景:
- 需要频繁创建的一些类,使用单例能够降低系统内存使用率,减少GC;
- 某些类在创建实例时会消耗较多资源,或者耗时也比较长,且经常使用,那么使用单例能一直保持这个单例,提升系统资源的使用效率;
- 频繁访问数据库或者文件的IO对象;
- 对于一些硬件级别的实例或者逻辑上需要单例的场景;
单例模式有很多实现方式,我们来逐一看下都有哪些,以及它们之间有什么区别和优缺点。
1.1 饿汉模式的单例
public class Wife1 {
private static final Wife1 wife = new Wife1();
// 将构造函数私有化,避免外部程序调用创建对象
private Wife1(){}
public static Wife1 getWife(){
return wife;
}
}
上面这种模式在一开始类被加载进行初始化的时候就创建了唯一的单例对象,因此这也是饿汉名字的来源。它的好处就是绝对的线程安全,在需要获取单例实例的时候执行效率较高,因为早就准备好了。比较适合在系统中单例个数不多的情况下,否则会造成内存浪费。
1.2 懒汉模式的单例
public class Wife2 {
private static Wife2 wife;
private Wife2(){}
public static Wife2 getWife(){
if(wife == null){
wife = new Wife2();
}
return wife;
}
}
懒汉模式的示例对象只有在真正需要的时候才会被初始化,但由此也产生一个问题,它不是线程安全的,多线程情况下,可能会同时创建出多个实例对象。这是如何发生的呢?其实很简单:
我们假设有两个线程A和B同时调用getWife
方法,A线程在判断完if(wife == null)
之后为true,然后被阻塞了(阻塞方式有很多),此时轮到B线程判断if(wife == null)
,结果也为true,那么它们两个同时认为当前没有人创建过实例,因此都会在接下来创建实例对象。
1.3 同步模式的单例
public class Wife3 {
private static Wife3 wife;
private Wife3(){}
public static synchronized Wife3 getWife(){
if(wife == null){
wife = new Wife3();
}
return wife;
}
}
虽然我们在方法的调用上确保了同步,即同一时间只能有一个线程进入到方法的内部进行执行,但是这却带来了方法执行低效的问题,这是为什么呢?
如果说,实例对象却是从来没有被创建过,那么当前执行该方法的线程理所当然需要走if(wife == null)
的逻辑,但是如果已经有实例对象被创建好了,每个线程每次调用都得走这个判断逻辑,而且还是同步的方式,效率当然低了。这就好比千军万马本来只需要走一个1米的独木桥(即例子中不需要判断if(wife == null)
逻辑的情况)现在却不得不走2米的独木桥。
1.4 双重检查锁模式的单例
public class Wife4 {
private volatile static Wife4 wife;
private Wife4(){}
public static Wife4 getWife(){
if(wife == null){
synchronized(Wife4.class){
if(wife == null){
wife = new Wife4();
}
}
}
return wife;
}
}
这种模式就是上面所说的,走独木桥的方法被优化了,第一个走的人需要走2米,后面其他人都只要走1米了,这边有2个疑问:
-
为什么在
synchronized
代码块中还要加上一个非空判断呢?答案和上面的同步模式的单例情况差不多。同样假设有两个线程A和B同时进入了
wife
为空的判断逻辑,它们只是在synchronized
时进行资源的竞争,然后依然会全部执行完同步代码块中的创建对象的代码,只是谁先谁后执行的问题。所以,必须在创建对象前,再次进行判断,确认还未有人创建才可以继续创建。 -
为什么此时的静态变量
wife
需要声明为volatile
呢?确保wife变量的多线程场景下的可见性。具体可以参考volatile使用详解 - 简书 (jianshu.com)
1.5 静态内部类模式的单例
public class Wife5 {
private Wife5(){}
private static class WifeHolder{
private static final Wife5 wife = new Wife5();
}
public static Wife5 getWife(){
return WifeHolder.wife;
}
}
我们可以看到WifeHolder
类是私有的静态内部类,除了 getWife()
之外没有其它方式可以访问到wife
实例对象,而且只有在调用getWife
方法时,才会真正加载该静态内部类,获取到其中的静态常量,即wife
对象,做到了懒加载。
至于线程安全方面,静态内部类则是利用了 JVM 自身的机制来保证线程安全,因为此处的内部类必须要在外部类的方法调用之前被初始化,所以当调用getWife()
方法的时候,会先初始化内部类中的实例,这一点是线程安全的。
但是这样的写法看似简单、高效、完美,但是在使用反射的场景下就会出现问题:
public static void main(String[] args){
try{
Class<?> clazz = Wife5.class;
Constructor c = clazz.getDeclaredConstructor(null);
// 强制将私有构造方法改为可访问的
c.setAccessibel(ture);
Object wife1 = c.newInstance();
Object wife2 = c.newInstance();
}catch(Exception e) {
e.printStackTrace();
}
}
如此,静态内部类的写法就会被反射所破坏,我们可以对私有构造方法做下改造,来防止这种情况。
private Wife5(){
if(WifeHolder.INSTANCE != null){
throw new RuntimeException("不允许创建多个实例!");
}
}
1.6 枚举模式的单例
public enum EnumWifeSingleton {
INSTANCE;
private String name;
public void setName(String name){
this.name = name;
}
public String getName(){
return this.name;
}
public static EnumWifeSingleton getWife(){
return INSTANCE;
}
}
@Slf4j
public class Main {
public static void main(String[] args) {
EnumWifeSingleton marry = EnumWifeSingleton.getWife();
marry.setName("marry");
log.info("wife's name is :{}", marry.getName());
EnumWifeSingleton lily = EnumWifeSingleton.getWife();
log.info("marry is lily?:{}", marry == lily);
}
}
这种方式区别于以上任何一种,它是利用枚举默认就是线程安全的特性来实现单例(类似与饿汉模式),此外,还可以防止反序列化带来的问题;使用反射也无法操作枚举类型。
但是缺点也是有的,枚举模式的单例虽然简洁优雅,但是和饿汉模式一样,都是提前创建好实例,只是适用单例较少的场景,不适合需要大规模生产单例的场景。
1.7 容器注册式的单例
在实际应用场景中,如果有大规模的单例需要创建,肯定需要有线考虑实现懒加载,那么就推荐使用容器注册式的单例:
public class ContainerRegistrySingleton {
/**
* 私有化构造函数
*/
private ContainerRegistrySingleton(){}
/**
* ioc容器,集中存放管理所有单例
*/
private static Map<String,Object> ioc = new ConcurrentHashMap<String,Object>();
public static Object getBean(String className){
synchronized (ioc) {
if(ioc.containsKey(className)){
return ioc.get(className);
}
Object obj = null;
try {
obj = Class.forName(className).newInstance();
ioc.put(className, obj);
} catch (Exception e) {
e.printStackTrace();
}
return obj;
}
}
}
其中的ContainerRegistrySingleton
就相当于是Bean的单例容器,从它这里获取的各个Bean确保都是单例的,这种写法也正是Spring中的容器单例写法,详情可以参考DefaultSingletonBeanRegistry
这个类的源码。
1.8 ThreadLocal实现线程单例
ThreadLocal的作用就是为每个线程保存一份自己独有的变量副本,详细使用方法可以参考ThreadLocal的使用 - 简书 (jianshu.com)
public class ThreadLocalSingleton {
/**
* 私有化构造函数
*/
private ThreadLocalSingleton() {}
private static ThreadLocal<ThreadLocalSingleton> threadLocalInstance = new ThreadLocal<ThreadLocalSingleton>() {
@Override
protected ThreadLocalSingleton initialValue() {
return new ThreadLocalSingleton();
}
};
public static ThreadLocalSingleton getInstance(){
return threadLocalInstance.get();
}
}
@Slf4j
public class CustomerThread implements Runnable{
@Override
public void run() {
log.info("{}", ThreadLocalSingleton.getInstance());
}
}
@Slf4j
public class Main {
public static void main(String[] args) {
// 主线程就一份该变量,即使调用三次,获取的都是同一个
log.info("{}",ThreadLocalSingleton.getInstance());
log.info("{}",ThreadLocalSingleton.getInstance());
log.info("{}",ThreadLocalSingleton.getInstance());
// 每个线程获取的是单例,线程之间获取的不是相同的对象实例
Thread t1 = new Thread(new CustomerThread());
Thread t2 = new Thread(new CustomerThread());
t1.start();
t2.start();
}
}
[main] INFO com.example.demo.create.singleton.Main - com.example.demo.create.singleton.ThreadLocalSingleton@754ba872
[main] INFO com.example.demo.create.singleton.Main - com.example.demo.create.singleton.ThreadLocalSingleton@754ba872
[main] INFO com.example.demo.create.singleton.Main - com.example.demo.create.singleton.ThreadLocalSingleton@754ba872
[Thread-0] INFO com.example.demo.create.singleton.CustomerThread - com.example.demo.create.singleton.ThreadLocalSingleton@85afa6e
[Thread-1] INFO com.example.demo.create.singleton.CustomerThread - com.example.demo.create.singleton.ThreadLocalSingleton@161afbac
使用ThreadLocal的如上方式,在线程内获取实例getInstance
能确保是单例的,这是由于ThreadLocal内部实现了和如上容器注册方式一样的原理,使用到了map来存储已经创建的实例,可以参考源码ThreadLocal.get()
,但是不同线程各自获取到的实例是不同的,此时就不是单例了。
1.9 总结
饿汉模式 | 否 | 是 | 高 |
懒汉模式 | 是 | 否 | 高 |
同步模式 | 是 | 是 | 低——synchronized |
双重检查锁 | 是 | 是 | 高 |
静态内部类 | 是 | 是 | 高 |
枚举模式 | 否 | 是 | 高 |
容器注册模式 | 是 | 是 | 低——synchronized |
ThreadLocal单线程模式 | 是 | 否 | 高 |
02.策略模式实现
策略模式的优缺点:
优点:
- 避免多重的条件判断
- 易扩展
缺点:
- 代码量增多
- 策略实现需要对外暴露
可以先看看如果没有使用策略模式,那我们在遇到扩展时是怎么处理的。就拿支付来举例吧。伪代码如下:
public String doPay(PayParam payParam) {
// 先验
preCheck(payParam);
String result = null;
if(payParam.getPayChannelEnum() == PayChannelEnum.ALI) {
// 支付宝支付的一些校验和处理
result = doAli(payParam);
} else if (payParam.getPayChannelEnum() == PayChannelEnum.WECHAT) {
// 微信支付的一些校验和处理
result = doWechat(payParam);
} else {
// 其他情况的处理
result = null;
}
// 结果处理
result = resultHandle(result);
re
turn result; } 这个时候如果又加上了银联支付,那么就得在这个if else的分支上进行修改,再加一个分支,如果我们的处理不只是这么简单的话,甚至还涉及到代码结构的调整,先验和结果处理调整,这不符合代码封装的开闭原则。而且也会改得很令人头痛。
1.策略实现
使用策略模式实现也是有好几种方法的,下面我就来讲讲我在真实场景中使用过的策略实现吧。
2.第一种
这种方式非常简单,简直就是上面的if else套了个封装的壳子而已。定义一个接口,然后接口有几个实现,在根据if else判断需要构造哪个实现。然后调用执行方法。
// 策略接口类,用于抽象支付的行为
public interface PayStrategy {
String doPay(PayParam payParam);
}
// 具体支付宝支付的实现
public class AliPayStrategyImpl implements PayStrategy {
@Override
public String doPay(PayParam payParam) {
return "alipay";
}
}
// 具体微信支付的实现
public class WechatPayStrategyImpl implements PayStrategy {
@Override
public String doPay(PayParam payParam) {
return "wechatpay";
}
}
// 调用支付的入口
public class OrderPay {
private void preCheck(PayParam payParam) {
}
private String resultHandle(String result) {
// do something
return "result";
}
public String doPay(PayParam payParam) {
// 先验
preCheck(payParam);
PayStrategy payStrategy = null;
if(payParam.getPayChannelEnum() == PayChannelEnum.ALI) {
payStrategy = new AliPayStrategyImpl();
} else if (payParam.getPayChannelEnum() == PayChannelEnum.WECHAT) {
payStrategy = new WechatPayStrategyImpl();
}
String result = payStrategy == null ? null : payStrategy.doPay(payParam);
// 结果处理
result = resultHandle(result);
return result;
}
}
这种如果需要扩展的话,就是再加一个实现类,然后if else这里构造实现类的地方再增加一个分支,其实也没有完全解决掉对修改关闭的问题。当然如果将bean交给spring管理的话又会方便很多。
3.第二种(推荐)
这种就比较有趣了,是使用枚举类来进行多策略的实现。下面我们就一起来看看吧。
public enum PayChannelEnum {
ALI() {
@Override
public String doPay(PayParam payParam) {
return "alipay";
}
},
WECHAT() {
@Override
public String doPay(PayParam payParam) {
return "wechatpay";
}
},
;
public abstract String doPay(PayParam payParam);
}
这种方式,代码看着很简洁,逻辑也很清晰,使用也很方便;但是有一个缺点,如果你需要用到spring注入的对象的话,这种方式就无法工作了。
4.第三种
接下来这种就需要依赖于Spring的IOC和DI了,这两者是啥我这里就不展开介绍了,如果不了解的小伙伴,可以去搜索一下,大致的就是帮我们管理实现类的,类构造和注入等。
// 策略接口类,用于抽象支付的行为。依然需要一个策略接口用来抽象支付的行为
public interface PayStrategy {
String doPay(PayParam payParam);
}
// 具体支付宝支付的实现
@Component("ALI") // 重点,需要指定实例bean时的名称
public class AliPayStrategyImpl implements PayStrategy {
@Override
public String doPay(PayParam payParam) {
return "alipay";
}
}
// 具体微信支付的实现
@Component("WECHAT") // 重点,需要指定实例bean时的名称
public class WechatPayStrategyImpl implements PayStrategy {
@Override
public String doPay(PayParam payParam) {
return "wechatpay";
}
}
// 调用支付的入口
@Component // 重点
public class OrderPay {
@Autowired // 重点
private Map<String , PayStrategy> payStrategyMap;
private void preCheck(PayParam payParam) {
// do something check
}
private String resultHandle(String result) {
// do something
return "result";
}
public String doPay(PayParam payParam) {
preCheck(payParam);
String result = payStrategyMap.get(payParam.getPayChannelEnum().name()).doPay(payParam);
return resultHandle(result);
}
}
当然这里我们是没有考虑那些异常情况,以及策略未找到之类的兜底或者处理等。实际编程是需要都考虑的。
可以看到在这段实现中我是标了几个重点。
- 首先是各自的实现类需要指定bean名称(策略指定的枚举名称),这是为了在依赖注入的时候方便我们匹配用的
- 其次是在调用的入口OrderPay一定也需要加上Spring的bean声明注解,这是为了将bean统一交给Spring管理,才能将策略实现注入
- payStrategyMap这个域对应的是个map,key为实例名,value为对应的实例。这样在调用doPay时就可以根据传入的策略枚举找到实例然后执行具体方法。
缺点也很明显,需要指定实例名称,且需要是一个静态常量,所以也不能直接使用枚举的名称。这样容易出现两个问题。
- 实例名称如果和其他实例冲突会导致启动服务就报错,那换个实例名称的话,枚举也得跟着改动,容易引起其他依赖的问题。
- 枚举的name和实例名称需要一一对应,如果有哪里大小写或者字符敲错了等问题,不太容易排查。
5.第四种(推荐)
这种目前是我常用的一种策略实现,不过呢,如果策略本身比较少的话,写这个轮子可能会觉得比较浪费,因为本身的架子代码就挺多的了。话不多说,show you the code。
// 很熟悉了吧?一定需要的一个策略接口
public interface PayStrategy {
String doPay(PayParam payParam);
}
// 重要。实现策略的一个抽象类。用于定义所有的策略实现的基类
public abstract class AbstractPayStrategy implements PayStrategy {
// 重要。新定义一个策略实现类需要实现的策略。返回对应的枚举
public abstract PayChannelEnum strategy();
}
// 基础实现,注意,此处是实现顶上的策略接口。在调用入口地方也是注入此实现。
@Component
@Primary // 重要。用于注入时优先选择此实例
public class PayStrategyImpl implements PayStrategy {
private Map<PayChannelEnum, AbstractPayStrategy> payStrategies;
// 构造器注入。然后转化成map存储。
public PayStrategyImpl(List<AbstractPayStrategy> payStrategyList) {
payStrategies = payStrategyList.stream().collect(Collectors.toMap(AbstractPayStrategy::strategy, Function.identity()));
}
// 实际的支付接口都是调用此方法
@Override
public String doPay(PayParam payParam) {
return payStrategies.get(payParam.getPayChannelEnum()).doPay(payParam);
}
}
// 注意这里的改动,此处是继承抽象类,并且实现返回策略枚举的接口
@Component
public class AliPayStrategyImpl extends AbstractPayStrategy {
@Override
public String doPay(PayParam payParam) {
return "alipay";
}
@Override
public PayChannelEnum strategy() {
return PayChannelEnum.ALI;
}
}
@Component
public class WechatPayStrategyImpl extends AbstractPayStrategy {
@Override
public String doPay(PayParam payParam) {
return "wechatpay";
}
@Override
public PayChannelEnum strategy() {
return PayChannelEnum.WECHAT;
}
}
到此整体的架子就已经搭建好了,而具体的调用入口也是很简单的一个注入即可。
@Autowired private OrderPay orderPay; 这种实现代码量很多,但是基本上模糊了选择策略的那部分逻辑,而且有个很重要的优点是只需要维护一个策略枚举,能很方便的将实现与策略枚举映射起来。
其中用到的知识点有:
构造器注入。PayStrategyImpl有定义一个List参数的构造方法,实例化该类时会自动将Spring管理的策略bean注入进去 Spring的Primary注入,当接口有多个实现时,使用Autowired注解的话,Spring并不知道选择哪个bean注入,所以如果加上Primary时就可以给Spring决策,优先注入那个实例。 类图如下:
6.最后
关于策略模式的多种实现,到这里就算结束了。也是有写几个我平常有使用的策略模式实现,当然还有一些其实可以改造一下形成自己代码风格的实现方式,比如第三种,可以再定义一个Map,在bean实例化时将自身put到Map中(可以通过@PostConstruct注解的方式,或者实现InitializingBean的方式等),然后剩下的工作就水到渠成了。
每种实现其实都是有一些优缺点的,也并不一定适用于所有人和所有场景,希望大家能够找到符合自己代码风格的实现思路,也希望大家能够通过这一节对策略模式有一个清晰一点的认识,在实际工作中能够更简洁清晰的写自己的bug咯~。