面试题
一、准备篇
01 企业是如何筛选简历的
首先HR会初步筛选,筛选完以后会交给部门负责人做进一步筛选,如果简历合格会通知HR联系预约面试。
下面是筛选简历的简易流程:

下图是BOSS直聘的后台,HR可以通过城市、岗位关键词、学历、院校、工作年限、年龄、跳槽频率等进行筛选。

HR进行初步筛选后,会由部门负责人进一步筛选,部门负责人主要根据技术条件、业务条件、额外加分项等进行筛选。
比如如下技术条件,面试者指出自己会使用的技术,部门负责人查看这些技术是否符合岗位的要求。

技术符合要求后会查看项目经历,比如该公司要开发一个电商项目,如果你有电商项目经验,则会优先考虑你。

然后看一下额外的加分项,比如管理经验、高并发经验、公有云经验、个人博客、开源项目经验等。
总结:
简历筛选规则:
1、HR如筛选简历:学历、院校、经验、年龄、跳槽频率
2、部门负责人筛选:
- 符合当前项目的技术栈
- 符合业务条件(银行、电商、物流)
- 额外加分项
- 有高可用高并发经验优先
- 熟悉基于公有云的开发经验
- 有团队管理经验
- 有开源项目经验
02 简历注意事项
① 简历整体结构
一份完整的简历应该包含如何几个部分 :

② 个人技能该如何描述
职业技能是最重要的部分,通常放到简历的黄金位置,有如下注意事项
- 放到简历的黄金位置(HR刷选简历的重要参考)
- 基本准则:写在简历上的必须能聊,不然就别写
- 参考公式:职业节能 = 必要技术 + 其他技术
- 针对性的引导面试官(让他问一些你想让他问的)

下面是不同工作经验需要的能力:
1-2年:(springboot + ssm + redis +数据库 ) +(2-3) 技术(微服务、ES、MQ、源码、高并发、jvm、技术选型、设计能力…)
3-5年:(springboot + ssm + redis +数据库) +(3-4) 技术(微服务、ES , MQ、源码、高并发、jvm、技术选型、设计能力…)
5年以上:(springboot + ssm + redis +数据库) +(5+ ) 技术(微服务、ES 、MQ 、源码、高并发、jvm、技术选型、设计能力…)
面试时要针对性的引导面试官,让他问一些你想让他问的,比如如下例子就可以引导面试官问缓存雪崩、缓存穿透等相关问题

下面是MySQL相关的优化:

下面是Java方面的优化技巧:

③ 项目该如何描述
项目经历有如下几个注意事项:
1.项目个数以自己的工作经历为准,时间比较久的可以只写标题或不写(面试官一般也不问)
2.项目要体现业务深度或技术深度
3.有没有主导设计过xx模块开发(0-1或1-2)
4.尽可能展示指标数据(如:达到了多少QPS、达到了多少的数据量)
下面是项目经历的模板:

总结
1.简历的结构
基本信息、教育背景、求职意向、工作经历、职业技能、项目经历、个人优势荣誉
2.职业技能
放到简历的黄金位置(HR刷选简历的重要参考)
职业技能 = 必要技术 + 第三方技术
要有针对性的准备,引导面试官针对性的提问
基本准则:写在简历上的必须能聊,不然就别写
3.项目描述
项目个数以自己的工作经历为准,时间比较久的可以只写标题或不写
项目要体现业务深度或技术深度
有没有主导设计过xx模块开发(0-1或1-2)
尽可能展示指标数据(如:达到了多少QPS、达到了多少的数据量)
03 应届生该如何找到合适的练手项目
① 项目来源
主要有如下三种方式获取到一个项目:
1、通过Gitee或Github开源社区
2、通过B站等视频平台
3、通过博客文章等
下面是通过开源社区获取项目的步骤:
可以通过Gitee或Github搜索想要学习的技术或该技术的具体业务,然后按照star进行排序

搜索比较感兴趣的技术点或者业务点后,首先要本地快速运行起来,debug跟踪代码的逻辑,梳理完业务之后,判断自己能否独立完成。然后找到一、二点深度挖掘,多方位参考,看这些框架是如何做定时任务、分库分表等这些业务的。
② 如何深入学习项目
目标:增加简历的项目模块业务深度、技术含金量、真实度
下面是一些通用的模块:

如何吃透一个模块呢?我们以权限认证为例,我们首先要明白这个功能有哪些实现方案,本项目是如何实现的,然后完成这些功能的过程中有哪些难点,需要考虑哪些问题,最后是如何做到低耦合、高扩展。
1,功能实现
业务功能实现:用户名密码登录、 二维码登录、手机短信登录、用户、角色、权限管理和分配
技术方案支撑:RBAC模型、Spring Security 或 Apache Shiro
2,常见的问题
- token刷新问题、加密、解密、XSS防跨站攻击
3,权限系统设计
- 可扩展性、高可用性、通用性
总结
1.如何找到合适的练手项目
- Gitee或Github搜索开源项目,B站黑马程序员项目课程
2.如何深入学习项目
技术选型:通用模块,可以嵌套到大部分项目中
学习方式:多方位参考深入挖掘业务和技术
学习程度:三个问题(功能实现、常见问题、系统设计)
04 Java程序员的面试过程
① 面试形式

② 面试过程
整体讲解结构:总分结构表述,下面是一般的面试过程,一般是先进行自我介绍,然后是介绍项目,然后面试官询问项目相关的技术。

下面是具体的流程,在简历中写了的一定要提前准备、提前准备、提前准备。比如我们写了主导设计文章发布审核、延时发布等技术方案的选型和实现 那么一定要提前准备一些面试官可能会问的问题。比如:审核什么内容?什么技术实现的审核?延时发布的技术是如何实现的,是Redis,还是RabbitMQ? 为什么要选择Redis而不是RabbitMQ呢?

下面是找工作的一些心态,比如你想找1W的工作,你就要看一看1.5W的招聘要求,这样面试的成功率就会大大提升。

二、Redis篇
下图是Redis常见面试题:

01 哪些场景使用了redis
回答该问题时切记要结合业务去回答,不要随便编一个,问该问题主要是做一个切入点进行深度发问。

02 缓存穿透
有一个GET请求api/news/getById/1作用是根据id查询文章,首选根据id去redis中查,如果redis中有数据则直接返回;如果redis中查不到数据则去数据库中查,如果从数据库中查到了数据则将查询的数据存储到redis后返回查询到的数据。

缓存穿透:查询一个不存在的数据,mysql查询不到数据也不会直接写入缓存,就会导致每次请求都查数据库
解决方案一:缓存不存在的数据
解决方案一:缓存空数据,查询返回的数据为空,仍把这个空结果进行缓存 {key:1,value:null}
**优点:**简单
**缺点:**消耗内存,可能会发生不一致的问题(比如查询时不存在id为2的数据,此时redis中缓存的id为2的数据为空数据。后来添加了id为2的数据后再次请求,但是redis里可以查到id为2的数据为空数据就直接返回了。由于缓存是请求后redis中查不到,通过查询数据库然后才更新的,这样就会导致redis的数据一直没有更新,还是为空数据,因此会发生不一致的问题。如果要解决这个问题可以在添加id为2的数据后判断一下redis中有没有id为2的数据,如果有的话更新redis里id为2的数据)
解决方案二:布隆过滤器
布隆过滤器中查询到不存在则一定不存在,查询到存在则可能存在也可能不存在。常见的布隆过滤器实现方案有Redisson和Guava两种。

**优点:**内存占用较少,没有多余key
**缺点:**实现复杂,存在误判
布隆过滤器介绍:
bitmap(位图):相当于是一个以(bit)位为单位的数组,数组中每个单元只能存储二进制数0或1
**布隆过滤器作用:**布隆过滤器可以用于检索一个元素是否在一个集合中。
初始时所有的bit都为0,然后将id 1 经过多个哈希函数计算后得到的位置为1、3、7,将1、3、7位置的bit改为1

布隆过滤器有可能存在误判,比如id为1的数据经过3次哈希得到的位置是1、3、7。id为2的数据经过3次哈希得到的数据是9、12、14。此时查询一个本应该不存在的id为3的数据,结果经过3次哈希得到的位置是3、9、12都为1,这就属性误判。(布隆过滤器中查询到不存在则一定不存在,查询到存在则可能存在也可能不存在)
误判率:数组越小误判率就越大,数组越大误判率就越小,但是同时带来了更多的内存消耗。

下面是使用redisson测试误判率的代码:
/**
* 测试误判率
* @param bloomFilter
* @param size
* @return
*/
private static int getData(RBloomFilter<String> bloomFilter, int size) {
int count = 0; // 记录误判的数据条数
for (int x = size; x < size * 2; x++) {
if (bloomFilter.contains("add" + x)) {
count++;
}
}
return count;
}
/**
* 初始化数据
* @param bloomFilter
* @param size
*/
private static void initData(RBloomFilter<String> bloomFilter,int size){
// 第一个参数:布隆过滤器存储的元素个数
// 第二个参数:误判率
bloomFilter.tryInit(size, 0.05);
// 在布隆过滤器初始化数据
for (int x = 0; x < size; x++) {
bloomFilter.add("add" + x);
}
System.out.println("初始化完成...");
}
总结
缓存穿透:查询一个不存在的数据,mysql查询不到数据也不会直接写入缓存,就会导致每次请求都查数据库
解决方案一:缓存空数据
解决方案二:布隆过滤器
03 缓存击穿
**缓存击穿:**给某一个key设置了过期时间,当key过期的时候,恰好这时间点对这个key有大量的并发请求过来,这些并发的请求可能会瞬间把DB压垮

常见的解决方案有两种:互斥锁、逻辑过期
**互斥锁:**首先线程1先查询缓存,如果缓存没有命中则获取互斥锁,然后查询数据库重建缓存数据,然后写入缓存并释放锁。线程2在线程1获取到互斥锁期间进来,此时未命中缓存,尝试获取互斥锁失败,然后休眠一会再次查询缓存如果未命中则继续尝试获取互斥锁,失败后再次重试,直到线程1将数据写入缓存后命中了缓存。这样做保证了强一致性,但性能较差,适合对数字比较敏感的数据。
逻辑过期: 存储数据时不设置过期时间,而是手动在value里维护一个过期时间字段。线程1进来先查询缓存,查询到后发现value里的expire字段小于当前时间则证明数据已逻辑过期,此时获取互斥锁,然后新开一个查询数据的线程,并返回已经过期的数据。在新开的线程里查询数据库重建缓存数据并写入缓存,重置逻辑过期时间,然后释放锁。在获取锁期间线程3进来查询缓存,发现已逻辑过期后,尝试获取互斥锁,如果失败则直接返回已经过期的数据。当锁释放后,线程4再次查询,此时命中了缓存并且缓存没有过期,可以查询到新的数据了。这样保证了高可用,性能更好,但是在更新数据的这段时间内返回的都是已经过期的数据,适合大型的互联网项目。

04 缓存雪崩
缓存雪崩 是指在同一时段大量的缓存key同时失效或者Redis服务宕机,导致大量请求到达数据库,带来巨大压力。

解决方案:
给不同的Key的TTL添加随机值
利用Redis集群提高服务的可用性,比如 哨兵模式、集群模式。
给缓存业务添加降级限流策略,比如 ngxin或spring cloud gateway。降级可做为系统的保底策略,适用于穿透、击穿、雪崩
给业务添加多级缓存,比如 Guava或Caffeine。
《缓存三兄弟》
穿透无中生有key,布隆过滤null隔离。
缓存击穿过期key, 锁与非期解难题。
雪崩大量过期key,过期时间要随机。
面试必考三兄弟,可用限流来保底。
05 双写一致
双写一致性:当修改了数据库的数据也要同时更新缓存的数据,缓存和数据库的数据要保持一致
① 延迟双删(有脏数据风险)
下面是常见的做法:我们首先查redis,如果命中了直接返回;如果没有命中则查数据库,查到数据后存储到redis中并将数据返回。

读操作:缓存命中,直接返回;缓存未命中查询数据库,写入缓存,设定超时时间
写操作:延迟双删:先删除缓存,然后修改数据库数据,然后等待大于一次io的时间后再次删除缓存。延迟双删也有脏数据的风险。

一般的业务逻辑只需要删除缓存,然后修改数据库即可,但为什么需要延迟双删呢?
接下来会说明先删除缓存在操作数据库和先操作数据库再删除缓存各会有什么问题。
先删除缓存,再修改数据库
下面是先删除缓存,再修改数据库正常的处理流程:
- 首先数据库和缓存中存放的数据都是10
- 线程1想要将数据修改为10,首先删除了缓存,然后更新了数据的数据为20
- 此时线程2查询缓存数据,发现未命中,然后查询数据库,更新缓存中的数据为20

但是由于线程是抢占运行的,因此有可能会出现如下的情况:
- 首先数据库和缓存中存放的数据都是10
- 线程1先删除缓存
- 此时轮到线程2执行,线程2先查询缓存发现未命中,然后查询数据库将数据库中的10更新到缓存中了
- 此时轮到线程1执行,线程池更新数据库的数据为20
这时数据库的数据为20,缓存中的数据为10,就出现了数据不一致的问题了。

先修改数据库,再删除缓存
下面是 先修改数据库,再删除缓存 正常的处理流程:
- 首先数据库和缓存中存放的数据都是10
- 线程2先更新数据库的数据为20,然后删除缓存
- 此时线程1查询缓存,发现未命中,此时查询数据库并更新缓存数据为20

但是由于线程是抢占运行的,因此有可能会出现如下的情况:
- 首先数据库中存放的数据是10,缓存过期了已经没有数据了
- 线程1先查询缓存,发现缓存中没有,然后查询数据库,查到数据为10
- 线程1还没有写入到缓存,此时轮到线程2执行了,线程2更新数据库的数据为20,然后删除缓存,由于缓存中没数据,也就没有删除
- 此时轮到线程1执行,线程1将老的数据10写入到了缓存
这时数据库的数据为20,缓存中的数据为10,就出现了数据不一致的问题了。

② 分布式锁(强一致)
我们还可以使用分布式锁解决这个问题,在每次操作前都加锁,然后再操作数据。

不过大部分情况都是读多写少,因此我们可以使用读写锁来实现。
**共享锁:**读锁readLock,加锁之后,其他线程可以共享读操作
**排他锁:**独占锁writeLock也叫,加锁之后,阻塞其他线程读写操作
读写锁的流程如下:
- 线程1想读数据,此时线程1会加一把读锁
- 线程2想读数据,此时线程2会再加一把读锁
- 线程3想要写数据,此时线程3想加一把写锁,但由于读写锁互斥,无法加锁,此时可以加一个标志,表明有人想写数据
- 线程4想要读数据,但根据标志判断有人想写数据,此时不允许加读锁
- 线程3等待读锁全部释放完毕后加写锁并写数据,并将标志置空
- 线程4发现没有人想写数据并且没有写锁,此时可以继续加读锁

下面是实现读写锁的完整代码:
@Autowired
RedissonClient redissonClient;
@Autowired
RedisTemplate<String,String> redisTemplate;
@Autowired
ObjectMapper objectMapper;
public Item getById(Integer id) {
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("ITEM_READ_WRITE_LOCK");
// 读之前加读锁,读锁的作用就是等待该lockKey释放写锁以后再读
RLock readLock = readWriteLock.readLock();
try {
// 加锁
readLock.lock();
System.out.println("readLock...");
String itemStr = redisTemplate.opsForValue().get("item:" + id);
if (itemStr != null) {
return objectMapper.readValue(itemStr,Item.class);
}
// 查询业务数据
Item item = new Item(id, "华为手机", "华为手机", 5999.00);
// 写入缓存
redisTemplate.opsForValue().set("item:" + id, objectMapper.writeValueAsString(item));
// 返回数据
return item;
} catch (JsonProcessingException e) {
throw new RuntimeException(e);
} finally {
readLock.unlock();
}
}
public void updateById(Integer id) {
RReadWriteLock readWriteLock = redissonClient.getReadWriteLock("ITEM_READ_WRITE_LOCK");
// 写之前加写锁,写锁加锁成功,读锁只能等待
RLock writeLock = readWriteLock.writeLock();
try {
// 加锁
writeLock.lock();
System.out.println("writeLock...");
// 更新业务数据到数据库
Item item = new Item(id, "华为手机", "华为手机", 5299.00);
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
// 删除缓存
redisTemplate.delete("item:" + id);
} finally {
writeLock.unlock();
}
}
@Data
class Item {
private Integer id;
private String name;
private String desc;
private double price;
public Item(Integer id, String name, String desc, double price) {
this.id = id;
this.name = name;
this.desc = desc;
this.price = price;
}
}
③ 异步通知(最终一致性)
MQ方式
我们写入数据到数据库的同时可以向MQ发布一条消息,然后缓存服务监听MQ的消息,最终更新缓存。

Canal方式
Canal通过主从复制,伪装成一个MySQL从节点,监听主节点的binlog日志,通知缓存服务更新数据。

总结
redis做为缓存,mysql的数据如何与redis进行同步呢?(双写一致性)
介绍自己简历上的业务,我们当时是把文章的热点数据存入到了缓存中,虽然是热点数据,但是实时要求性并没有那么高,所以,我们当时采用的是异步的方案同步的数据
我们当时是把抢券的库存存入到了缓存中,这个需要实时的进行数据同步,为了保证数据的强一致,我们当时采用的是redisson提供的读写锁来保证数据的同步
那你来介绍一下异步的方案(你来介绍一下redisson读写锁的这种方案)
允许延时一致的业务,采用异步通知
① 使用MQ中间中间件,更新数据之后,通知缓存删除
② 利用canal中间件,不需要修改业务代码,伪装为mysql的一个从节点,canal通过读取binlog数据更新缓存
强一致性的,采用Redisson提供的读写锁
①共享锁:读锁readLock,加锁之后,其他线程可以共享读操作
②排他锁:独占锁writeLock也叫,加锁之后,阻塞其他线程读写操作
06 持久化
Redis持久化有两种方式,分别是RDB和AOF。
① RDB
RDB全称Redis Database Backup file(Redis数据备份文件),也被叫做Redis数据快照。简单来说就是把内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据
我们可以主动备份,也可以按照一定规则自动备份。
主动备份可以使用save命令或bgsave命令,save命令由Redis主进程来执行RDB,会阻塞所有命令。bgsave命令会开启子进程执行RDB,避免主进程受到影响,因此建议使用bgsave命令。

Redis内部有触发RDB的机制(自动执行bgsave命令),可以在redis.conf文件中找到,格式如下:

当执行bgsave时会开一个子进程来执行RDB文件的操作,它是一个异步的,对主进程几乎是没有阻塞的,在开启新进程时会产生阻塞,这个阻塞是纳秒级的可以忽略不计。首先有一个物理内存(计算机的内存条),然后是Redis主进程。Redis要实现对数据的读写操作肯定要在内存中去操作。在Linux操作系统中所有的进程都无法直接操作物理内存。操作系统会给每个进程分配一个虚拟内存,主进程只能操作虚拟内存,操作系统会维护一个虚拟内存和物理内存的映射关系表,这个表被称为页表,功能是记录虚拟地址与物理地址的映射关系。 执行bgsave会fork一个子进程,父进程会把页表拷贝给子进程,这样子进程就有了和主进程相同的映射关系,子进程在操作自己的页表时,由于映射关系是一样的,最终一定能映射到相同的物理内存中去,这样就实现了主进程与子进程内存空间的共享。
子进程在写RDB文件时,主进程可以接收请求来修改内存中的数据,这样就有可能出现脏数据。
因此fork会采用copy-on-write技术:
当主进程执行读操作时,访问共享内存。
当主进程执行写操作时,则会拷贝一份数据,执行写操作。
写操作后,再次读该数据会读取拷贝后的数据。

② AOF
AOF全称为Append Only File(追加文件)。Redis处理的每一个写命令都会记录在AOF文件,可以看做是命令日志文件。

AOF默认是关闭的,需要修改redis.conf配置文件来开启AOF:
## 是否开启AOF功能,默认是no
appendonly yes
## AOF文件的名称
appendfilename "appendonly.aof"

AOF的命令记录的频率也可以通过redis.conf文件来配:
## 表示每执行一次写命令,立即记录到AOF文件
appendfsync always
## 写命令执行完先放入AOF缓冲区,然后表示每隔1秒将缓冲区数据写到AOF文件,是默认方案
appendfsync everysec
## 写命令执行完先放入AOF缓冲区,由操作系统决定何时将缓冲区内容写回磁盘
appendfsync no

| 配置项 | 刷盘时机 | 优点 | 缺点 |
|---|---|---|---|
| Always | 同步刷盘 | 可靠性高,几乎不丢数据 | 性能影响大 |
| everysec | 每秒刷盘 | 性能适中 | 最多丢失1秒数据 |
| no | 操作系统控制 | 性能最好 | 可靠性较差,可能丢失大量数据 |
因为是记录命令,AOF文件会比RDB文件大的多。而且AOF会记录对同一个key的多次写操作,但只有最后一次写操作才有意义。通过执行bgrewriteaof命令,可以让AOF文件执行重写功能,用最少的命令达到相同效果。

Redis也会在触发阈值时自动去重写AOF文件。阈值也可以在redis.conf中配置:
## AOF文件比上次文件 增长超过多少百分比则触发重写(比如设置100,则之前是1M,后来是2M增长了100%,此时会触发)
auto-aof-rewrite-percentage 100
## AOF文件体积最小多大以上才触发重写
auto-aof-rewrite-min-size 64mb

总结
RDB和AOF各有自己的优缺点,如果对数据安全性要求较高,在实际开发中往往会结合两者来使用。
| RDB | AOF | |
|---|---|---|
| 持久化方式 | 定时对整个内存做快照 | 记录每一次执行的命令 |
| 数据完整性 | 不完整,两次备份之间会丢失 | 相对完整,取决于刷盘策略 |
| 文件大小 | 会有压缩,文件体积小 | 记录命令,文件体积很大 |
| 宕机恢复速度 | 很快 | 慢 |
| 数据恢复优先级 | 低,因为数据完整性不如AOF | 高,因为数据完整性更高 |
| 系统资源占用 | 高,大量CPU和内存消耗 | 低,主要是磁盘IO资源 但AOF重写时会占用大量CPU和内存资源 |
| 使用场景 | 可以容忍数分钟的数据丢失,追求更快的启动速度 | 对数据安全性要求较高常见 |
07 数据过期策略
假如redis的key过期之后,会立即删除吗?
Redis对数据设置数据的有效时间,数据过期以后,就需要将数据从内存中删除掉。可以按照不同的规则进行删除,这种删除规则就被称之为数据的删除策略(数据过期策略)。
① 惰性删除
惰性删除:设置该key过期时间后,我们不去管它,当需要该key时,我们在检查其是否过期,如果过期,我们就删掉它,反之返回该key

优点 :对CPU友好,只会在使用该key时才会进行过期检查,对于很多用不到的key不用浪费时间进行过期检查
缺点 :对内存不友好,如果一个key已经过期,但是一直没有使用,那么该key就会一直存在内存中,内存永远不会释放
② 定期删除
定期删除:每隔一段时间,我们就对一些key进行检查,删除里面过期的key(从数据库中取出一定数量的随机key进行检查,并删除其中的过期key)。
定期清理有两种模式:
SLOW模式是定时任务,执行频率默认为10hz(每秒执行10次,每个执行周期是100ms),每次耗时不超过25ms,以通过修改配置文件redis.conf 的hz选项来调整这个次数
FAST模式执行频率不固定,但两次间隔不低于2ms,每次耗时不超过1ms
优点:可以通过限制删除操作执行的时长和频率来减少删除操作对 CPU 的影响。另外定期删除,也能有效释放过期键占用的内存。
缺点:难以确定删除操作执行的时长和频率。
Redis的过期删除策略:惰性删除 + 定期删除两种策略进行配合使用
总结
惰性删除:访问key的时候判断是否过期,如果过期,则删除
定期删除:定期检查一定量的key是否过期( SLOW模式+ FAST模式)
Redis的过期删除策略:惰性删除 + 定期删除两种策略进行配合使用
08 数据淘汰策略
假如缓存过多,内存是有限的,内存被占满了怎么办?其实就是想问redis的数据淘汰策略是什么?
数据的淘汰策略:当Redis中的内存不够用时,此时在向Redis中添加新的key,那么Redis就会按照某一种规则将内存中的数据删除掉,这种数据的删除规则被称之为内存的淘汰策略。
LRU(Least Recently Used)最近最少使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。(key1是在3s之前访问的, key2是在9s之前访问的,删除的就是key2)
LFU(Least Frequently Used)最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高。(key1最近5s访问了4次, key2最近5s访问了9次, 删除的就是key1)
Redis支持8种不同策略来选择要删除的key:
noeviction: 不淘汰任何key,但是内存满时不允许写入新数据,默认就是这种策略。volatile-ttl: 对设置了TTL的key,比较key的剩余TTL值,TTL越小越先被淘汰allkeys-random:对全体key ,随机进行淘汰。volatile-random:对设置了TTL的key ,随机进行淘汰。allkeys-lru: 对全体key,基于LRU算法进行淘汰volatile-lru: 对设置了TTL的key,基于LRU算法进行淘汰allkeys-lfu: 对全体key,基于LFU算法进行淘汰volatile-lfu: 对设置了TTL的key,基于LFU算法进行淘汰
数据淘汰策略-使用建议
1.优先使用 allkeys-lru 策略。充分利用 LRU 算法的优势,把最近最常访问的数据留在缓存中。如果业务有明显的冷热数据区分,建议使用。
2.如果业务中数据访问频率差别不大,没有明显冷热数据区分,建议使用 allkeys-random,随机选择淘汰。
3.如果业务中有置顶的需求,可以使用 volatile-lru 策略,同时置顶数据不设置过期时间,这些数据就一直不被删除,会淘汰其他设置过期时间的数据。
4.如果业务中有短时高频访问的数据,可以使用 allkeys-lfu 或 volatile-lfu 策略。
关于数据淘汰策略其他的面试问题
1.数据库有1000万数据 ,Redis只能缓存20w数据, 如何保证Redis中的数据都是热点数据 ?
使用allkeys-lru(挑选最近最少使用的数据淘汰)淘汰策略,留下来的都是经常访问的热点数据
2.Redis的内存用完了会发生什么?
主要看数据淘汰策略是什么?如果是默认的配置( noeviction ),会直接报错
总结
1.Redis提供了8种不同的数据淘汰策略,默认是noeviction不删除任何数据,内存不足直接报错
2.LRU:最少最近使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。
3.LFU:最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高
平时开发过程中用的比较多的就是allkeys-lru(结合自己的业务场景)
09 分布式锁
如下是抢优惠券的代码:
@Autowired
private RedisTemplate<String,String> redisTemplate;
/**
* 抢优惠券
* @throws InterruptedException
*/
@Test
public void rushToPurchase() {
// 获取优惠券数量
String numStr = redisTemplate.opsForValue().get("num");
// 判断是否抢完
if (null == numStr || Integer.parseInt(numStr) <= 0) {
throw new RuntimeException("优惠券已抢完");
}
// 优惠券数量减一,说明抢到了优惠券
Integer num = Integer.parseInt(numStr);
num = num -1;
// 重新设置优惠券的数量
redisTemplate.opsForValue().set("num", String.valueOf(num));
}
下面是该代码的流程图:

但是在多线程下就会出现库存小于0的情况,下面是具体的步骤
- 初始时库存为1
- 此时线程1查询优惠券数量为1
- 切换到线程2执行,查询优惠券数量也为1
- 切换到线程1执行,此时判断优惠券大于1,则证明库存充足扣减库存,此时库存为0
- 切换到线程2执行,由于之前查到优惠券数量为1大于0,则说明库存充足,此时再次扣减库存,此时的库存就变为-1了

如果我们的项目是单体项目,并且只启动了一台服务可以使用Java自带的同步互斥锁来实现。
@Autowired
private RedisTemplate<String,String> redisTemplate;
/**
* 抢优惠券
* @throws InterruptedException
*/
@Test
public void rushToPurchase() {
synchronized (this) {
// 获取优惠券数量
String numStr = redisTemplate.opsForValue().get("num");
// 判断是否抢完
if (null == numStr || Integer.parseInt(numStr) <= 0) {
throw new RuntimeException("优惠券已抢完");
}
// 优惠券数量减一,说明抢到了优惠券
Integer num = Integer.parseInt(numStr);
num = num -1;
// 重新设置优惠券的数量
redisTemplate.opsForValue().set("num", String.valueOf(num));
}
}

但是互联网项目通常会将一份代码部署在多个tomcat中,然后通过nginx反向代理,负载均衡到其中一个服务。

此时我们写的代码就有问题了,虽然都加了锁,但是这把锁是本地锁只属于当前JVM,它只能解决同一个JVM的线程安全,解决不了多个JVM的线程安全。每一个tomcat服务有不同的JVM,它们之间不共享锁,也就是拿到的不是同一把锁,因此我们不能使用本地的锁需要使用外部的锁,也就是分布式锁来解决。

我们使用分布式锁,使这些服务都使用的是相同的锁,这样就解决了线程安全问题。

Redis分布式锁
Redis实现分布式锁主要利用Redis的setnx命令。setnx是SET if not exists(如果不存在,则 SET)的简写。
获取锁:
注意不要通过setnx设置值然后再设置过期时间,因为它们是两条命令,不能保证原子性。
如果不设置过期时间,则有可能出现死锁的问题。
## 添加锁,NX是互斥、EX是设置超时时间
SET lock value NX EX 10
释放锁:
## 释放锁,删除即可
DEL key
下面是使用Redis锁的基本流程:


redisson实现的分布式锁
线程1进来后,会加锁并新开一个看门狗线程,这个线程每隔 (releaseTime / 3) 的时间做一次续期,默认的releaseTime(过期时间)是30s,也就是看门狗每个10s,做一次续期(续期的时间为releaseTime,默认是30s)当处理完毕后手动通过代码释放锁,释放锁时会通知看门狗停止监听。
线程2进来时,如果获取锁失败会自旋(休息一会然后循环尝试获取锁),一定时间内获取到锁了则可以继续执行接下来的代码。

下面是获取锁的代码:
@Autowired
RedissonClient redissonClient;
public void redisLock() throws InterruptedException {
// 获取锁(可重入锁),执行锁的名称
RLock lock = redissonClient.getLock("lockKey");
try {
// 尝试获取锁:参数分别是:获取锁的最大等待时间(期间会重试),锁释放时间,时间单位
// 设置了锁的释放时间后就没有看门狗机制了(redisson认为你能控制锁的过期时间)
// boolean isLock = lock.tryLock(10, 30, TimeUnit.SECONDS);
// 参数分别是:获取锁的最大等待时间(期间会重试),时间单位
// 如果没有传过期时间或传的过期时间是-1,则会有看门狗机制
boolean isLock = lock.tryLock(10, TimeUnit.SECONDS);
// 判断是否获取锁成功
if (isLock) {
System.out.println("执行业务...");
}
}finally {
// 释放锁
lock.unlock();
}
}
redisson可重入锁
**可重入锁:**同一个线程可以重复获取相同的一把锁。
redissonClient.getLock("lockKey")是可重入锁,因此下面这段代码add2方法也能获取锁成功。每一个线程都有一个线程id,redisson在add2方法获取锁的时候判断一下之前获取的这把锁是不是同一个线程,如果是同一个线程的话就可以获取锁。
@Test
public void add1() {
RLock lock = redissonClient.getLock("lockKey");
boolean isLock = lock.tryLock();
// 执行业务
add2();
// 释放锁
lock.unlock();
}
public void add2() {
RLock lock = redissonClient.getLock("lockKey");
boolean isLock = lock.tryLock();
// 执行业务
System.out.println(isLock);
// 释放锁
lock.unlock();
}
redisson可重入锁具体来说是通过利用hash结构记录线程id和重入次数。其中key是锁的名称,value里的key为线程的唯一标识,value为重入的次数。

主从一致性(红锁)
Java应用里的某个线程执行SET lock thread1 NX EX 10命令获取一个锁,由于是SET命令,因此交给主节点执行,主节点执行完后,在同步给从节点时突然宕机。

此时会在从节点里选取一个当作主节点,这时Java应用的其他线程执行SET lock thread1 NX EX 10命令,由于新的主节点并没有人获取锁因此也能获取成功。这样第一个线程和第二个线程都获取到了同一把锁。

为了解决这个问题,可以使用redis的红锁。
RedLock(红锁,redis distributed lock):不能只在一个redis实例上创建锁,应该是在多个redis实例上创建锁(n / 2 + 1),避免在一个redis实例上加锁,这样做的缺点是实现复杂,性能差,而且主节点宕机且正好未同步给从节点锁数据是低概率时间。

redis是AP思想,也就是保证高可用,可以接受最终一致性。如果想要强一致性可以使用CP思想的Zookeeper。
总结

10 Redis集群
① 主从复制
单节点Redis的并发能力是有上限的,要进一步提高Redis的并发能力,就需要搭建主从集群,实现读写分离。
通常主节点负责写操作,从节点负责读操作,通常redis是读多写少,可以让多个从节点进行读操作以增加Redis的并发能力。

下面是主从数据同步原理:
主从全量同步:
Replication Id:简称replid,是数据集的标记,id一致则说明是同一数据集。每一个master都有唯一的replid,slave则会继承master节点的replid
offset:偏移量,随着记录在repl_baklog中的数据增多而逐渐增大。slave完成同步时也会记录当前同步的offset。如果slave的offset小于master的offset,说明slave数据落后于master,需要更新。
从节点执行建立连接的操作,然后向主节点发送数据同步的请求(传递replid和offset),主节点接收到请求后先判断一下这个节点是不是第一次同步(判断replid是否一致,如果不一致则证明是第一次同步),如果是第一次同步就把主节点的版本信息(含有replid和offset)发送给从节点,然后从节点把主节点的版本信息保存在本地,这时主从节点的版本信息就一致了。同时主节点会执行bgsave命令生成RDB文件,生成以后将数据发送从节点,从节点接收到后清空本地的数据,然后加载RDB文件,此时主从的数据就同步完成了。
主节点再生成RDB文件的过程中,如果接收了客户端的其他请求,那么刚刚发送的RDB文件就不是最新的数据了,在生成RDB文件的过程中会把新的请求会记录到repl_baklog日志文件中,然后再把repl_baklog日志文件发送给从节点。从节点再执行repl_baklog日志文件后就实现了主从数据的完全同步了。

主从增量同步 (slave重启或后期数据变化)
如果第三步判断replid一致,则证明不是第一同步。假如从节点的offset是50,master的repl_baklog日志文件的offset是80,则主会把repl_baklog日志文件里50~80这部分数据发给从节点,然后从节点执行接收到的命令就可以了。

总结:

② 哨兵模式
为了保证Redis集群的高可用性,Redis提供了哨兵(Sentinel)机制来实现主从集群的自动故障恢复。哨兵也是由多台Redis节点组成的,它们也组成了一个集群。哨兵的结构和作用如下:
监控:Sentinel 会不断检查您的master和slave是否按预期工作
自动故障恢复:如果master故障,Sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主
通知:Sentinel充当Redis客户端的服务发现来源,当集群发生故障转移时,会将最新信息推送给Redis的客户端

Sentinel基于心跳机制监测服务状态,每隔1秒向集群的每个实例发送ping命令:
主观下线:如果某sentinel节点发现某实例未在规定时间响应,则认为该实例主观下线。
客观下线:若超过指定数量(quorum)的Sentinel都认为该实例主观下线,则该实例客观下线。quorum值最好超过Sentinel实例数量的一半。

哨兵选主规则
首先判断主与从节点断开时间长短,如超过指定值就排该从节点
然后判断从节点的slave-priority值,越小优先级越高
如果slave-prority一样,则判断slave节点的offset值,越大优先级越高
最后是判断slave节点的运行id大小,越小优先级越高。
redis集群(哨兵模式)脑裂
下面是正常的流程图。

假如,某个时刻,由于网络的原因,这个主节点与Sentinel处于不同的网络分区。此时Sentinel检测不到主节点了只能按照选举规则在从节点里选取一个节点作为主节点。但是老的节点并没有宕机,只是网络原因与Sentinel失联了,客户端还能正常的连接,然后继续向老的主节点添加数据。这样就出现了两个master节点,这样就称为脑裂。

经过一段时间后,网络恢复了,老的主节点被强制变为了从节点。然后老的主节点会将自己的数据清空,然后使用新的主节点的数据,这样客户端刚写的数据就丢失了。

为了解决这个问题,redis中有两个配置参数:
min-replicas-to-write 1表示最少有一个salve节点(当客户端向主节点写入数据的时候,这个主节点必须至少有一个从节点,才能接受客户端的数据,否则将会拒绝请求)min-replicas-max-lag 5表示数据复制和同步的延迟不能超过5秒
总结:

③ 分片集群
主从和哨兵可以解决高可用、高并发读的问题。但是依然有两个问题没有解决:
海量数据存储问题
高并发写的问题
使用分片集群可以解决上述问题,分片集群特征:
集群中有多个master,每个master保存不同数据
每个master都可以有多个slave节点
master之间通过ping监测彼此健康状态(不用哨兵了)
客户端请求可以访问集群任意节点,最终都会被转发到正确节点

分片集群结构-数据读写
Redis 分片集群引入了哈希槽的概念,Redis 集群有 16384 个哈希槽,每个 key通过 CRC16 校验后对 16384 取模来决定放置哪个槽,集群的每个节点负责一部分 hash 槽。
比如set name zhangsan的key是name,对name做CRC16校验后假设得到的值是666666,然后求666666除以16384的余数得到值为11306,然后将其放到对应范围内的hash槽即可。我们还可以指定对key的某一部分进行hash计算,比如set {aaa}name zhangsan则是只对aaa进行CRC16校验,这样可以将相同业务(这里时aaa业务)的数据放到同一个hash槽中。

总结:

11 Redis为什么这么快
Redis是单线程的,但是为什么还那么快?
Redis是纯内存操作,执行速度非常快
采用单线程,避免不必要的上下文切换可竞争条件,多线程还要考虑线程安全问题
使用I/O多路复用模型,非阻塞IO
能解释一下I/O多路复用模型?
Redis是纯内存操作,执行速度非常快,它的性能瓶颈是网络延迟而不是执行速度, I/O多路复用模型主要就是实现了高效的网络请求
用户空间和内核空间
Linux系统中一个进程使用的内存情况划分两部分:内核空间、用户空间
用户空间只能执行受限的命令(Ring3),而且不能直接调用系统资源,必须通过内核提供的接口来访问
内核空间可以执行特权命令(Ring0),调用一切系统资源
Linux系统为了提高IO效率,会在用户空间和内核空间都加入缓冲区:
写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备
读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区

阻塞IO
顾名思义,阻塞IO就是两个阶段都必须阻塞等待:
阶段一:
① 用户进程尝试读取数据(比如网卡数据)
② 此时数据尚未到达,内核需要等待数据
③ 此时用户进程也处于阻塞状态
阶段二:
① 数据到达并拷贝到内核缓冲区,代表已就绪
② 将内核数据拷贝到用户缓冲区
③ 拷贝过程中,用户进程依然阻塞等待
④ 拷贝完成,用户进程解除阻塞,处理数据
可以看到,阻塞IO模型中,用户进程在两个阶段都是阻塞状态。

非阻塞IO
顾名思义,非阻塞IO的recvfrom操作会立即返回结果而不是阻塞用户进程。
阶段一:
① 用户进程尝试读取数据(比如网卡数据)
② 此时数据尚未到达,内核需要等待数据
③ 返回异常给用户进程
④ 用户进程拿到error后,再次尝试读取
⑤ 循环往复,直到数据就绪
阶段二:
① 将内核数据拷贝到用户缓冲区
② 拷贝过程中,用户进程依然阻塞等待
③ 拷贝完成,用户进程解除阻塞,处理数据
可以看到,非阻塞IO模型中,用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致CPU空转,CPU使用率暴增。

IO多路复用
IO多路复用:是利用单个线程来同时监听多个Socket(多个客户端连接) ,并在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。
阶段一:
① 用户进程调用select,指定要监听的Socket集合
② 内核监听对应的多个socket
③ 任意一个或多个socket数据就绪则返回readable
④ 此过程中用户进程阻塞
阶段二:
① 用户进程找到就绪的socket
② 依次调用recvfrom读取数据
③ 内核将数据拷贝到用户空间
④ 用户进程处理数据

IO多路复用是利用单个线程来同时监听多个Socket ,并在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。不过监听Socket的方式、通知的方式又有多种实现,常见的有:
select
poll
epoll
差异:
select和poll只会通知用户进程有Socket就绪,但不确定具体是哪个Socket ,需要用户进程逐个遍历Socket来确认
epoll则会在通知用户进程Socket就绪的同时,把已就绪的Socket写入用户空间
举例:
比如有一个服务员和很多个顾客,每个顾客的桌子上都有一个开关,这个开关会连接到服务员所在位置的灯泡上,任意一个人想要点餐,则会把它所在的桌子的开关打开,这是服务员面前的灯就亮了,这时服务员就知道了某个客户需要点餐,但是服务员并不知道是哪个客户需要点餐,这时服务员只能挨个的询问:“是你点的餐吗?” 这就说早期的IO多路复用的实现方式,也就是select和poll的实现方案。
后来,客户的开关控制的就不是灯泡了,而是直接可以在计算机上显示是几号桌,这样服务员就能快速找到是哪位顾客点的餐了。

Redis网络模型
Redis通过IO多路复用来提高网络性能,并且支持各种不同的多路复用实现,并且将这些实现进行封装, 提供了统一的高性能事件库。
IO多路复用监听每一个客户端的Socket连接,然后把这些客户端的获取、修改等事件进行派发。连接应答处理器可以处理客户端的应答;命令回复处理器可以处理客户端响应;命令请求处理器可以 接收请求数据、把数据转为Redis命令、选择并执行命令把结果写入缓冲队列,然后使用命令回复处理器将请求结果响应给客户端。其中命令的解析和响应结果的输出引入了多线程(请求处理的大部分时间都耗费在了网络请求,所以在网络请求中引入了多线程),在真正执行命令时还是只有主线程去执行(内存操作不怎么耗时)。

总结:

三、MySQL篇

01 慢查询
🤔 在MySQL中,如何定位慢查询 ?
聚合查询
多表查询
表数据量过大查询
深度分页查询
表象:页面加载过慢、接口压测响应时间过长(超过1s)
方案一:开源工具
调试工具:Arthas
运维工具:Prometheus 、Skywalking

方案二:MySQL自带慢日志
慢查询日志记录了所有执行时间超过指定参数(long_query_time,单位:秒,默认10秒)的所有SQL语句的日志
如果要开启慢查询日志,需要在MySQL的配置文件(/etc/my.cnf)中配置如下信息:
## 开启MySQL慢日志查询开关
slow_query_log=1
## 设置慢日志的时间为2秒,SQL语句执行时间超过2秒,就会视为慢查询,记录慢查询日志
long_query_time=2
配置完毕之后,通过以下指令重新启动MySQL服务器进行测试,查看慢日志文件中记录的信息 /var/lib/mysql/localhost-slow.log。

总结:

一个SQL语句执行很慢, 如何分析?
可以采用 EXPLAIN 或者 DESC 命令获取 MySQL 如何执行 SELECT 语句的信息
语法:
- 直接在select语句之前加上关键字 explain / desc
EXPLAIN SELECT 字段列表 FROM 表名 WHERE 条件;

通过
key、key_len两个查看是否可能会命中索引
possible_key当前sql可能会使用到的索引key当前sql实际命中的索引key_len索引占用的大小Extra额外的优化建议
| Extra | 含义 |
|---|---|
| Using where; Using Index | 查找使用了索引,需要的数据都在索引列中能找到,不需要回表查询数据 |
| Using index condition | 查找使用了索引,但是需要回表查询数据 |
- type 这条sql的连接的类型,性能由好到差为 NULL、system、const、eq_ref、ref、range、 index、all
NULL:这条sql没有使用到表system:查询mysql中系统内置的表const:根据主键查询eq_ref:主键索引查询或唯一索引查询(只返回一条数据)ref:索引查询(有可能有多条数据)range:范围查询(走了索引但是是范围查询,比如age > 18)index:索引树扫描(遍历整个索引树查询)all:全盘扫描(不走索引)
总结:

02 索引
索引(index)是帮助MySQL高效获取数据的数据结构(有序)。在数据之外,数据库系统还维护着满足特定查找算法的数据结构(B+树),这些数据结构以某种方式引用(指向)数据, 这样就可以在这些数据结构上实现高级查找算法,这种数据结构就是索引。
以二叉树举例,只需要3次就可以查找到值为45的节点,这样极大的提高了查询的效率。

数据结构对比
MySQL默认使用的索引底层数据结构是B+树。再聊B+树之前,我们先聊聊其他的树
二叉树最好的情况下复杂度是O(logN),最坏的情况下复杂度是O(n),并不太稳定。而红黑树很稳定,复杂度是O(logN)。但是假如Mysql使用二叉树(每个节点只有两个分叉),表的数据是一千万,这样查找数据需要很多的层级。

B-Tree,B树是一种多叉路衡查找树,相对于二叉树,B树每个节点可以有多个分支,即多叉。
以一颗最大度数(max-degree)为5(5阶)的b-tree为例,那这个B树每个节点最多存储4个key,5个指针。

B+Tree是在BTree基础上的一种优化,使其更适合实现外存储索引结构,InnoDB存储引擎就是用B+Tree实现其索引结构。
B+Tree的非叶子节点只存储指针不存储数据,只有叶子节点才存储数据,叶子节点使用双向链表串联起来(方便进行范围查询)。

B树与B+树对比:
①:磁盘读写代价B+树更低;
②:查询效率B+树更加稳定;
③:B+树便于扫库和区间查询
总结:

03 聚集索引
什么是聚簇索引什么是非聚簇索引 ?
| 分类 | 含义 | 特点 |
|---|---|---|
| 聚集索引(Clustered Index,聚簇索引) | 将数据存储与索引放到了一块,索引结构的叶子节点保存了行数据 | 必须有,而且只有一个 |
| 二级索引(Secondary Index,非聚集索引) | 将数据与索引分开存储,索引结构的叶子节点关联的是对应的主键 | 可以存在多个 |
聚集索引选取规则:
如果存在主键,主键索引就是聚集索引。
如果不存在主键,将使用第一个唯一(UNIQUE)索引作为聚集索引。
如果表没有主键,或没有合适的唯一索引,则 InnoDB 会自动生成一个rowid作为隐藏的聚集索引。
例如如下数据,将id设置为主键,name设置为普通索引。聚集索引的key就是我们设置的id,叶子节点是这一整行的数据。而二级索引叶子节点存储的是聚集索引的key,也就是主键id。

比如如下查询,由于我们给查询条件name添加了索引,因此会走下面的二级索引,由于Arm比Lee小,因此去Lee左边的下级节点找,然后Arm又比Geek小。因此去Geek左边的下级节点找,此时就找到了叶子节点为15,但是由于查找的是*(所有数据),而这个二级索引没有想要的全部数据(如果是select id,name的话二级索引可以查到因此就不需要去聚集索引里查了),还要去聚集索引里找需要的数据。然后10比15小,因此去15左边的下级节点找,10和10相同去右边的下级节点找,这样就查找id为10的全部数据了。
简单来说就是根据二级索引查找到id,然后根据id去聚集索引里查找整行的数据。

总结:

04 覆盖索引
覆盖索引是指:查询使用了索引,并且需要返回的列,在该索引中已经全部能够找到 。

如下的sql查询条件是主键,因此直接使用聚集索引,聚集索引里的叶子节点存储的是一整行的数据,因此想要查询的列都能查到,所以覆盖了索引。
select * from tb_user where id = 1

如下的sql查询条件是name,由于给name设置了普通索引,因此会使用非聚集索引,想要查询的字段是id和name,由于索引的key是name值是id,因此需要的字段id和name能全部查出来,因此覆盖了索引。
select id,name from tb_user where name = ‘Arm’

如下的sql查询条件是name,但name字段的索引只能查出id和name,查不到gender,因此还要回表查询,再去聚集索引里查gender的信息,因此不是聚集索引。
select id,name,gender from tb_user where name = ‘Arm’

MYSQL超大分页处理
在数据量比较大时,如果进行limit分页查询,在查询时,越往后,分页查询效率越低。
我们一起来看看执行limit分页查询耗时对比:

因为,当在进行分页查询时,如果执行 limit 9000000,10 ,此时需要MySQL排序前9000010 记录,仅仅返回 9000000 - 9000010 的记录,其他记录丢弃,查询排序的代价非常大 。
优化思路: 一般分页查询时,通过创建 覆盖索引 能够比较好地提高性能,可以通过 覆盖索引加子查询形式进行优化

总结:

05 索引创建原则
1️⃣ 针对于数据量较大,且查询比较频繁的表建立索引。
2️⃣ 针对于常作为查询条件(where)、排序(order by)、分组(group by)操作的字段建立索引。
3️⃣ 尽量选择区分度高的列作为索引,尽量建立唯一索引,区分度越高,使用索引的效率越高。比如如下数据的地址都是北京市区分度不高就不适合创建索引。

4️⃣ 如果是字符串类型的字段,字段的长度较长,可以针对于字段的特点,建立前缀索引。

5️⃣ 尽量使用联合索引,减少单列索引,查询时,联合索引很多时候可以覆盖索引,节省存储空间,避免回表,提高查询效率。比如向下面一样索引包含name、status、address三个字段。

6️⃣ 要控制索引的数量,索引并不是多多益善,索引越多,维护索引结构的代价也就越大,会影响增删改的效率。
7️⃣ 如果索引列不能存储NULL值,请在创建表时使用NOT NULL约束它。当优化器知道每列是否包含NULL值时,它可以更好地确定哪个索引最有效地用于查询。
总结:

06 索引失效

那如何判断索引是否失效了呢?
我们可以通过执行计划explain快速判断索引是否失效
给tb_seller创建联合索引,字段顺序:name,status,address (创建索引时的顺序很重要)

1️⃣ 违反最左前缀法则
如果索引了多列,要遵守最左前缀法则。指的是查询从索引的最左前列开始,并且不跳过索引中的列。匹配最左前缀法则,就会走索引:
下面的sql可以看出只根据name查询命中了索引,长度是303;根据name和status查询也命中了索引,长度是309;根据name、status和address查询也命中了索引,长度是612

下面的查询违法最左前缀法则 , 索引失效:
第一个查询跳过了name,只查了status和address,这样就违反了最左前缀法则,此时索引就会失效。第二个也跳过了name索引也会失效。

如果符合最左法则,但是出现跳跃某一列,只有最左列索引生效:
下面的sql查询条件是name和address,跳过了中间的status,因此只有创建索引时最左边的name索引生效了,因此索引的长度是303

2️⃣ 范围查询右边的列,不能使用索引 。
第一条sql能够全部命中索引,但是第二条sql由于staus使用了范围查询,因此status后面的字段就不能使用索引了。因此name、status 是走索引的, 但是最后一个条件address 没有用到索引。

3️⃣ 不要在索引列上进行运算操作, 索引将失效。
下面的sql使用了substring截取了name,此时索引会失效。

4️⃣ 字符串不加单引号(隐式类型转换),造成索引失效。
在查询时没有对字符串加单引号, MySQL的查询优化器,会自动的进行类型转换,造成索引失效。

5️⃣ 以%开头的Like模糊查询,索引失效。如果仅仅是尾部模糊匹配,索引不会失效。如果是头部模糊匹配,索引失效。

总结:

07 SQL优化经验

表的设计优化(参考阿里开发手册《嵩山版》)
① 比如设置合适的数值(tinyint int bigint),要根据实际情况选择
② 比如设置合适的字符串类型(char和varchar)char定长效率高,varchar可变长度,效率稍低
SQL语句优化
① SELECT语句务必指明字段名称(避免直接使用select * )
② SQL语句要避免造成索引失效的写法
③ 尽量用union all代替union。union会多一次过滤(将重复的行合并成一行),效率低 (但是业务上一般都会将相同的行合并成一行)
④ 避免在where子句中对字段进行表达式操作
⑤ Join优化 能用inner join 就不用left join, right join,如必须使用 一定要以小表为驱动,内连接会对两个表进行优化,优先把小表放到外边,把大表放到里边。left join 或 right join,不会重新调整顺序 (这个使用inner join还是left join取决于具体的场景吧,inner join是取两表的交集,left join是取左表的全部和两表交集的部分,有些业务是需要求两者交集的部分,有些右表只是附加的信息可以没有啊)
主从复制、读写分离
如果数据库的使用场景读的操作比较多的时候,为了避免写的操作所造成的性能影响 可以采用读写分离的架构。
读写分离解决的是,数据库的写入,影响了查询的效率。

总结:

08 事务的特性(ACID)
事务是一组操作的集合,它是一个不可分割的工作单位,事务会把所有的操作作为一个整体一起向系统提交或撤销操作请求,即这些操作要么同时成功,要么同时失败。

ACID是什么?可以详细说一下吗?
原子性(Atomicity):事务是不可分割的最小操作单元,要么全部成功,要么全部失败。
一致性(Consistency):事务完成时,必须使所有的数据都保持一致状态。
隔离性(Isolation):数据库系统提供的隔离机制,保证事务在不受外部并发操作影响的独立环境下运行。
持久性(Durability):事务一旦提交或回滚,它对数据库中的数据的改变就是永久的。
总结:

09 并发事务 & 事务隔离级别

并发事务问题
| 问题 | 描述 |
|---|---|
| 脏读 | 一个事务读到另外一个事务还没有提交的数据。 |
| 不可重复读 | 一个事务先后读取同一条记录,但两次读取的数据不同,称之为不可重复读。 |
| 幻读 | 一个事务按照条件查询数据时,没有对应的数据行,但是在插入数据时,又发现这行数据已经存在,好像出现了”幻影”。 |
下面是一个脏读的例子:事务A开启了一个事务查询id为1的数据,然后修改了id为1的数据(还没提交)。这时又来了个事务B查询id为1的数据却查到了刚刚事务A还没有提交的数据。(如果事务A回滚了,那么事务B就是读到了不存在的数据,也就是读到了脏数据)

下面是不可重复读的例子:事务A先查询了id为1的数据,然后事务B修改并提交了id为1的数据,此时事务A再次查询id为1的数据查到了事务B提交后的数据,事务A在同一个事务中两次查询得到的数据不一样。

下面是幻读的例子:事务A查询id为1的数据发现没有,然后事务B插入并提交了id为1的数据。事务A插入id为1的数据报错了,然后事务A又查了一次发现仍然没有id为1的数据,这是明明查的没有数据却插入不进去就属于幻读。(可能有人会疑问上一个不可重复读 不是事务B提交后事务A就能查到吗?这是因为这次的隔离级别设置的是可重复读,可重复读解决了不可重复读数据的问题,使同一个事务相同的查询得到的数据一样,但是这个隔离级别还有幻读的问题)

怎么解决并发事务的问题呢?
解决方案:对事务进行隔离,下面是各隔离级别可以解决的问题。其中未提交读所有问题都存在但性能最好,串行化解决了所有问题但性能最差。
注意:事务隔离级别越高,数据越安全,但是性能越低。
| 隔离级别 | 脏读 | 不可重复读 | 幻读 |
|---|---|---|---|
| Read uncommitted 未提交读 | √ | √ | √ |
| Read committed 读已提交(Oracle默认) | × | √ | √ |
| Repeatable Read可重复读(Mysql默认) | × | × | √ |
| Serializable 串行化 | × | × | × |
串行化是一个事务执行完毕后,才能有新的事务继续执行,完全放弃了并发事务。
总结:

10 undo log 和 redo log

一定的频率刷新磁盘就有可能机器突然宕机,数据没有刷新到磁盘的问题,这样就会违反ACID里的持久性。
redo log
重做日志,记录的是事务提交时数据页的物理修改,是**用来实现事务的持久性**。
该日志文件由两部分组成:重做日志缓冲(redo log buffer)以及重做日志文件(redo log file),前者是在内存中,后者在磁盘中。当事务提交之后会把所有修改信息都存到该日志文件中, 用于在刷新脏页到磁盘,发生错误时, 进行数据恢复使用。
redo log file是有两份的,它们是循环写的,当Buffer Pool刷新到数据页后每隔一段时间也会定期清理redo log file。
当有增删改的时候,Buffer Pool会发生变化,在同时Redo log buffer会记录数据页的变化,一旦Redo log buffer发生了变化就会把它记录到磁盘文件中,就是我们说的redo log file中(这里的Redo log buffer和Redo log file都是及时写及时刷新的)。一旦Buffer Pool同步到数据页失败了就会从redo log file中同步数据。(可能有人会疑惑为什么Buffer Pool更新了不及时刷新到数据页中,而是多加了个Rodo log?这是因为从Buffer Pool到数据页是随机的磁盘IO,随机的磁盘IO效率是非常差的。而Read log日志文件是追加的,顺序IO的性能提升了很多)

undo log
回滚日志,用于记录数据被修改前的信息 , 作用包含两个 : 提供回滚 和 MVCC(多版本并发控制) 。redo log记录的是物理日志,undo log记录的是逻辑日志。
可以认为当delete一条记录时,undo log中会记录一条对应的insert记录,反之亦然。(也就是与当前操作相反的操作)
当update一条记录时,它记录一条对应相反的update记录。当执行rollback时,就可以从undo log中的逻辑记录读取到相应的内容并进行回滚。
undo log可以实现事务的一致性和原子性
总结:

11 多版本并发控制 mvcc

解释一下MVCC
全称 Multi-Version Concurrency Control,多版本并发控制。指维护一个数据的多个版本,使得读写操作没有冲突
MVCC的具体实现,主要依赖于数据库记录中的隐式字段、undo log日志、readView。
比如如下的事务5查询id为30的数据,到底应该查询到那个版本的信息呢?这时就需要用到MVCC了。

MVCC-实现原理
1️⃣ 记录中的隐藏字段

| 隐藏字段 | 含义 |
|---|---|
| DB_TRX_ID | 最近修改事务ID(事务ID会自增),记录插入这条记录或最后一次修改该记录的事务ID。 |
| DB_ROLL_PTR | 回滚指针,指向这条记录的上一个版本,用于配合undo log,指向上一个版本。 |
| DB_ROW_ID | 隐藏主键,如果表结构没有指定主键,将会生成该隐藏字段。 |
2️⃣ undo log
回滚日志,在insert、update、delete的时候产生的便于数据回滚的日志。
当insert的时候,产生的undo log日志只在回滚时需要,在事务提交后,可被立即删除。
而update、delete的时候,产生的undo log日志不仅在回滚时需要,mvcc版本访问也需要,不会立即被删除。
举例:
最开始id为30,age为30,name为A30,此时 DB_TRX_ID(事务id)为1, DB_ROLL_PTR(回滚指针)指向null。

事务2开启了事务,修改了id为30的记录,将age改为了3。此时undo log拷贝了一份修改前的数据到undo log文件中,然后记录中的age被修改为了3、DB_TRX_ID(事务id)自增为2、DB_ROLL_PTR(回滚指针)指向0x00001,也就是undo log里刚刚修改前的记录。

事务3开启了事务,修改了id为30的记录,将name改为了A3。此时undo log拷贝了一份修改前的数据到undo log文件中,然后记录中的name被修改为了A3、DB_TRX_ID(事务id)自增为3、DB_ROLL_PTR(回滚指针)指向0x00002,也就是undo log里刚刚修改前的记录(此时就可以看到组成了一个版本链)

事务4开启了事务,修改了id为30的记录,将age修改为了10。此时undo log拷贝了一份修改前的数据到undo log文件中,然后记录中的age被修改为了10、DB_TRX_ID(事务id)自增为4、DB_ROLL_PTR(回滚指针)指向0x00003,也就是undo log里刚刚修改前的记录

不同事务或相同事务对同一条记录进行修改,会导致该记录的undolog生成一条记录版本链表,链表的头部是最新的旧记录,链表尾部是最早的旧记录。
3️⃣ ReadView (读视图)
- ReadView
ReadView(读视图)是 快照读 SQL执行时MVCC提取数据的依据,记录并维护系统当前活跃的事务(未提交的)id。
- 当前读
读取的是记录的最新版本(也就是上面记录里的数据,不是undo log里的数据),读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。对于我们日常的操作,如:select ... lock in share mode (共享锁),select ... for update、update、insert、delete(排他锁)都是一种当前读。
- 快照读
简单的select(不加锁)就是快照读,快照读 读取的是记录数据的可见版本,有可能是历史数据(可能是记录里的数据也可能是undo log里的数据),不加锁,是非阻塞读。
Read Committed(读已提交):每次select,都生成一个快照读。
Repeatable Read(可重复读):开启事务后第一个select语句才是快照读的地方。(整个事务只会生成一个快照读)
ReadView中包含了四个核心字段:
| 字段 | 含义 |
|---|---|
| m_ids | 当前活跃的事务ID集合 |
| min_trx_id | 最小活跃事务ID |
| max_trx_id | 预分配事务ID,当前最大事务ID+1(因为事务ID是自增的) |
| creator_trx_id | ReadView创建者的事务ID |
比如事务5的第一个查询id为30的记录生成的ReadView为:
| 字段 | 值 |
|---|---|
| m_ids | 3,4,5 (事务2已经提交了) |
| min_trx_id | 3(当前活跃的最小事务是3,因为事务2已经提交了就不活跃了) |
| max_trx_id | 6(最大的活跃事务是事务5,预分配的事务就是5+1=6) |
| creator_trx_id | 5(创建ReadView读视图的事务是事务5) |

版本链数据访问规则:

不同的隔离级别,生成ReadView的时机不同:
READ COMMITTED(读已提交) :在事务中每一次执行快照读时生成ReadView。
REPEATABLE READ(可重复读):仅在事务中第一次执行快照读时生成ReadView,后续复用该ReadView。
RC(读已提交)隔离级别下,在事务中每一次执行快照读时生成ReadView:

然后我们把版本链中的数据从上往下找,找到第一个满足任何一条版本链数据访问规则的数据就行了。
接下来我们先看RC(读已提交)隔离级别下,事务5的第一个查询id为30的记录时应该查询到的数据。
首先我们先看第一条id为30, age为10, name为A3 的数据,此时 DB_TRX_ID 为4,然后依次判断是否满足版本链数据访问规则。4不等于5因此不满足第一条,4不小于3因此不满足第二条,4不大于6因此不满足第三条,4虽然在3~6中间 但是4也在3,4,5里因此不满足第四条。

然后我们接着找下一条数据,此时 DB_TRX_ID 为3,然后依次判断是否满足版本链数据访问规则。3不等于5因此不满足第一条,3不小于3因此不满足第二条,3不大于6因此不满足第三条,3虽然在3~6中间 但是3也在3,4,5里因此不满足第四条。

然后我们接着找下一条数据,此时 DB_TRX_ID 为2,然后依次判断是否满足版本链数据访问规则。3不等于5因此不满足第一条,2小于3因此满足第二条,条件成立 因此就可以访问该条记录,此时数据为 id为30, age为3, name为A30。

也就是前面事务2提交的数据。

接下来我们先看RC(读已提交)隔离级别下,事务5的第二个查询id为30的记录时应该查询到的数据,这时根据规则满足的就是事务为3的数据。

RR(可重复读)隔离级别下,仅在事务中第一次执行快照读时生成ReadView,后续复用该ReadView。
我们根据规则可以判断出可重复读情况下事务5两次读到的都是事务2提交后的数据。

总结:

12 Mysql 主从同步原理
MySQL主从复制的核心就是二进制日志
二进制日志(BINLOG)记录了所有的 DDL(数据定义语言)语句和 DML(数据操纵语言)语句,但不包括数据查询(SELECT、SHOW)语句。
主库负责写数据,从库负责读数据。 当主库的数据发生变化(比如是写操作),会把变化的数据写到binlog日志文件中,这时从库有个IOthread线程,它专门从binlog日志文件中去读取数据,读取完成之后就把它写到从库的Relay log中继日志中,再由从库的一个叫SQLthread线程去读取中继日志文件,把里面的命令重新执行一下,执行完成后主库和从库的数据就保持一致了。

总结:

13 分库分表
分库分表的时机:
1,前提,项目业务数据逐渐增多,或业务发展比较迅速 单表的数据量达**1000W或20G**以后
2,优化已解决不了性能问题(主从读写分离、查询索引…)
3,IO瓶颈(磁盘IO、网络IO)、CPU瓶颈(聚合查询、连接数太多)

分库分布分为垂直拆分和水平拆分,垂直拆分又分为垂直分库、垂直分表;水平拆分又分为水平分库、水平分表。
垂直分库和水平分库就是将一个数据库拆分成多个数据库,垂直分表和水平分表就是将一个表拆分成多个表。

1️⃣ 垂直分库
垂直分库:以表为依据,根据业务将不同表拆分到不同库中。
特点:
1.按业务对数据分级管理、维护、监控、扩展
2.在高并发下,提高磁盘IO和数据量连接数

2️⃣ 垂直分表
垂直分表:以字段为依据,根据字段属性将不同字段拆分到不同表中。
拆分规则:
把不常用的字段单独放在一张表
把text,blob等大字段拆分出来放在附表中
特点:
1,冷热数据分离
2,减少IO过渡争抢,两表互不影响

3️⃣ 水平分库
水平分库:将一个库的数据拆分到多个库中。
路由规则:
根据id节点取模
按id也就是范围路由,节点1(1-100万 ), 节点2(100万-200万)
......
特点:
1.解决了单库大数量,高并发的性能瓶颈问题
2.提高了系统的稳定性和可用性

4️⃣ 水平分表
水平分表:将一个表的数据拆分到多个表中 (可以在同一个库内)。
特点:
1.优化单一表数据量过大而产生的性能问题;
2.避免IO争抢并减少锁表的几率;

新的问题:
最开始我们是一个应用程序连接一个数据库。

分库分表 + 集群 后有可能会有如下问题:
分布式事务一致性问题
跨节点关联查询
跨节点分页、排序函数
主键避重
分库分表中间件主要有两个:
sharding-sphere
mycat

总结:

四、框架篇

01 bean的线程安全问题
Spring框架中的单例bean不是线程安全的
Spring框架中的bean是单例的吗?
我们可以通过@Scope注解指明当前Bean是单例还是多例。
singleton : bean在每个Spring IOC容器中只有一个实例(默认)。
prototype:一个bean的定义可以有多个实例。
@Service
@Scope("singleton")
public class UserServiceImpl implements UserService {
}
Spring bean并没有可变的状态 (比如Service类和DAO类我们不能去修改,这些不能被修改的类就是无状态的类,无状态的类没有线程安全问题,就像工具类一样我们不能修改这个类的任何状态。而示例代码中count是一个成员变量,我们可以并发的修改这个数据,因此不是无状态的,也就会有线程安全问题),所以在某种程度上说Spring的单例bean是线程安全的。

总结:

02 AOP
什么是AOP,你们项目中有没有使用到AOP?
AOP称为面向切面编程,用于将那些与业务无关,但却对多个对象产生影响的公共行为和逻辑,抽取并封装为一个可重用的模块,这个模块被命名为“切面”(Aspect),减少系统中的重复代码,降低了模块间的耦合度,同时提高了系统的可维护性。
常见的AOP使用场景:
记录操作日志
缓存处理
Spring中内置的事务处理
1️⃣ 记录操作日志思路
每次用户请求时,获取请求的用户名、请求方式、访问地址、模块名称、登录ip、操作时间,记录到数据库的日志表中。

我们可以编写一个Log注解,然后通过环绕通知获取标注了Log注解的类,然后再获取想要的信息。

2️⃣Spring中的事务是如何实现的
Spring支持编程式事务管理和声明式事务管理两种方式。
编程式事务控制:需使用TransactionTemplate来进行实现,对业务代码有侵入性,项目中很少使用
声明式事务管理:声明式事务管理建立在AOP之上的。其本质是通过AOP功能,对方法前后进行拦截,将事务处理的功能编织到拦截的方法中,也就是在目标方法开始之前加入一个事务,在执行完目标方法之后根据执行情况提交或者回滚事务。

总结:

03 事务失效
Spring中事务失效的场景有哪些?
对spring框架的深入理解、复杂业务的编码经验
异常捕获处理
抛出检查异常
非public方法
情况一:异常捕获处理

情况二:抛出检查异常

情况三:非public方法导致的事务失效

总结:

04 Spring的bean的生命周期
Spring容器在进行实例化时,会将xml配置的<bean>的信息封装成一个BeanDefinition对象

Spring根据BeanDefinition来创建Bean对象,里面有很多的属性用来描述Bean

beanClassName:bean 的类名
initMethodName:初始化方法名称
properryValues:bean 的属性值
scope:作用域
lazyInit:延迟初始化
以下是完整的Bean的生命周期:

下面是生命周期完整的代码:
User类:
@Component
public class User implements BeanNameAware, BeanFactoryAware, ApplicationContextAware, InitializingBean {
public User() {
System.out.println("User的构造方法执行了.........");
}
private String name ;
@Value("张三")
public void setName(String name) {
System.out.println("setName方法执行了.........");
}
@Override
public void setBeanName(String name) {
System.out.println("setBeanName方法执行了.........");
}
@Override
public void setBeanFactory(BeanFactory beanFactory) throws BeansException {
System.out.println("setBeanFactory方法执行了.........");
}
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
System.out.println("setApplicationContext方法执行了........");
}
@PostConstruct
public void init() {
System.out.println("@PostConstruct注解标注的init方法执行了.................");
}
@Override
public void afterPropertiesSet() throws Exception {
System.out.println("InitializingBean接口需要实现的afterPropertiesSet方法执行了........");
}
@PreDestroy
public void destory() {
System.out.println("destory方法执行了...............");
}
}
MyBeanPostProcessor类:
@Component
public class MyBeanPostProcessor implements BeanPostProcessor {
@Override
public Object postProcessBeforeInitialization(Object bean, String beanName) throws BeansException {
if (beanName.equals("user")) {
System.out.println("postProcessBeforeInitialization方法执行了->user对象初始化方法前开始增强....");
}
return bean;
}
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
if (beanName.equals("user")) {
System.out.println("postProcessAfterInitialization->user对象初始化方法后开始增强....");
//cglib代理对象
Enhancer enhancer = new Enhancer();
//设置需要增强的类
enhancer.setSuperclass(bean.getClass());
//执行回调方法,增强方法
enhancer.setCallback(new InvocationHandler() {
@Override
public Object invoke(Object o, Method method, Object[] objects) throws Throwable {
//执行目标方法
return method.invoke(method,objects);
}
});
//创建代理对象
return enhancer.create();
}
return bean;
}
}
测试方法:
public static void main(String[] args) {
ApplicationContext ctx = new AnnotationConfigApplicationContext(SpringConfig.class);
User user = ctx.getBean(User.class);
System.out.println(user);
}
控制台输出:
User的构造方法执行了.........
setName方法执行了.........
setBeanName方法执行了.........
setBeanFactory方法执行了.........
setApplicationContext方法执行了........
postProcessBeforeInitialization方法执行了->user对象初始化方法前开始增强....
@PostConstruct注解标注的init方法执行了.................
InitializingBean接口需要实现的afterPropertiesSet方法执行了........
postProcessAfterInitialization->user对象初始化方法后开始增强....
User的构造方法执行了.........
public java.lang.String java.lang.Object.toString()总结:

05 循环引用
在创建A对象的同时需要使用的B对象,在创建B对象的同时需要使用到A对象,这样就会循环引用

当然不仅仅是这种情况,A引用B,B引用C,C引用A也会触发循环引用;A引用A自己也会触发循环引用。

循环依赖可能会产生死循环问题:
首先调用A的构造函数(此时依赖注入,后置处理器,初始化方法 都没有执行),目前A对象只是一个半成品 然后初始化A对象,设置b属性,b属性是B类型的对象,需要到spring容器查找B对象,如果容器中存在B对象,直接赋值返回就行了。 如果不存在B对象,则再实例化B对象,生成一个半成品的B对象 然后再初始化B对象,设置a属性,a属性是A类型的对象,需要到spring容器查找A对象,如果容器中存在A对象,直接赋值返回就行了。 可是,此时A的生命周期还没走完,因此不是一个完整的对象,容器中肯定不存在,然后B只能去实例化A最终导致死循环。

Spring解决循环依赖是通过三级缓存,对应的三级缓存如下所示:
注意:二级缓存是第三个变量,三级缓存是第二个变量
//单实例对象注册器
public class DefaultSingletonBeanRegistry extends SimpleAliasRegistry implements SingletonBeanRegistry {
private static final int SUPPRESSED_EXCEPTIONS_LIMIT = 100;
private final Map<String, Object> singletonObjects = new ConcurrentHashMap(256); // 一级缓存
private final Map<String, ObjectFactory<?>> singletonFactories = new HashMap(16); // 三级缓存
private final Map<String, Object> earlySingletonObjects = new ConcurrentHashMap(16); // 二级缓存
}
一级缓存存放的是单例的Bean对象,多例的Bean对象是在调用的时候创建的。
| 缓存名称 | 源码名称 | 作用 |
|---|---|---|
| 一级缓存 | singletonObjects | 单例池,缓存已经经历了完整的生命周期,已经初始化完成的bean对象 |
| 二级缓存 | earlySingletonObjects | 缓存早期的bean对象(生命周期还没走完) |
| 三级缓存 | singletonFactories | 缓存的是ObjectFactory,表示对象工厂,用来创建某个对象的 |
一级缓存作用:限制bean在beanFactory中只存一份,即实现singleton scope,解决不了循环依赖
如果要想打破循环依赖, 就需要一个中间人的参与, 这个中间人就是二级缓存。
只通过一级缓存,也就是只存放经历完整生命周期的Bean对象根据上面的描述会产生死循环的问题,因此我们接下来探讨只通过两级缓存能不能解决死循环的问题。
首先实例化A生成A的原始对象,将A的原始对象存放到 earlySingletonObjects二级缓存里去,然后初始化A,在初始化A时需要注入B,此时判断B在容器中不存在,先实例化B生成B的原始对象,将B的原始对象放到 earlySingletonObjects二级缓存里去,然后初始化B,在初始化B时需要注入A,此时从二级缓存中获取A对象,然后B创建成功,将B对象存放到singletonObjects一级缓存单例池里。然后再将B注入给A,A创建完成将A对象存放到singletonObjects一级缓存单例池里,同时二级缓存里的对象就清除掉了,这样就解决了循环依赖问题。

二级缓存就解决了循环依赖问题,那么是不是就只用两级缓存就可以了呢?
假如A使用的是代理对象,按照此时的逻辑,存入到一级缓存里的A对象并不是代理的A对象(A的代理对象是在BeanPostProcessor的后置方法生成的,是在初始化方法之后执行的);两级缓存只能解决一般对象的循环引用,如果对象被增强了(也就是代理对象)此时就需要借助三级缓存。
三级缓存解决循环依赖:
首选实例化A,然后原始对象A生成一个ObjectFactory对象(这个对象工厂是专门用来产生A的),然后将A对象存放到三级缓存中。 然后初始化A时需要注入B,发现B对象不存在,此时实例化B,然后B也生成一个ObjectFactory对象并放到三级缓存中。 然后初始化B时需要注入A,然后从三级缓存中获取A对象的ObjectFactory对象,对象工厂判断如果是代理就帮你生成一个代理对象,如果是普通对象就帮你生成一个普通对象,然后将生成的对象放到二级缓存中,然后将生成的半成品的A注入给B,这样B就能创建成功了,B创建成功后放到单例池中。 然后B就可以成功注入给A了,A也就创建成功了,然后就可以把A对象也放到单例池中了,这样就解决了循环依赖问题了。
三级缓存实际上就是延迟创建需要注入的对象
代理对象不能使用二级缓存的原因是违背了Spring对代理对象的设计原则,bean对象只有初始化后才可以创建代理对象,使用二级缓存的话直接就要为对象创建代理对象,这样就会创建多个代理对象。

Spring的三级缓存帮我们解决了大部分的循环引用问题(解决了初始化时的循环引用问题),仍然还有一部分循环引用Spring框架处理不了,比如构造方法注入问题。
构造方法出现了循环依赖怎么解决?
我们可以给A的构造器里获取的b对象加一个@Lazy注解,当A对象需要B对象的时候再加载B对象就行了。

总结:


06 SpringMvc执行流程
SpringMVC的执行流程知道嘛?
Springmvc的执行流程是这个框架最核心的内容
视图阶段(老旧JSP等)
前后端分离阶段(接口开发,异步)
处理器映射器主要判断哪个请求要走哪些拦截器、哪个方法处理这个请求。处理器执行链中含有拦截器和处理请求的方法等。
处理器适配器主要负责解析处理这个请求的方法需要的参数,并将数据设置给这个目标方法的参数中,然后执行完目标方法后处理返回值数据,并将数据封装成ModelAndView。
视图解析器主要负责解析视图数据,将JSP页面需要的数据封装进去,然后生成页面返回给前端。


总结:


07 Springboot自动配置原理
@SpringBootApplication注解类里使用了@SpringBootConfiguration、@ComponentScan、@EnableAutoConfiguration三个注解

@EnableAutoConfiguration注解使用@Import注解导入了AutoConfigurationImportSelector类,这个类会加载META-INF/spring.factories文件,将这个文件里的类统一加载Spring容器里,当然并不是所有的都加载,文件里会使用@ConditionalOnXxx来判断要不要加载

总结:

08 Spring框架常见注解
Spring 的常见注解有哪些?
| 注解 | 说明 |
|---|---|
| @Component、@Controller、@Service、@Repository | 使用在类上用于实例化Bean |
| @Autowired | 使用在字段上用于根据类型依赖注入 |
| @Qualifier | 结合@Autowired一起使用用于根据名称进行依赖注入 |
| @Scope | 标注Bean的作用范围 |
| @Configuration | 指定当前类是一个 Spring 配置类,当创建容器时会从该类上加载注解 |
| @ComponentScan | 用于指定 Spring 在初始化容器时要扫描的包 |
| @Bean | 使用在方法上,标注将该方法的返回值存储到Spring容器中 |
| @Import | 使用@Import导入的类会被Spring加载到IOC容器中 |
| @Aspect、@Before、@After、@Around、@Pointcut | 用于切面编程(AOP) |
SpringMVC常见的注解有哪些?
| 注解 | 说明 |
|---|---|
| @RequestMapping | 用于映射请求路径,可以定义在类上和方法上。用于类上,则表示类中的所有的方法都是以该地址作为父路径(还有衍生的@GetMapping、@PostMapping、@PutMapping、@DeleteMapping等) |
| @RequestBody | 注解实现接收http请求的json数据,将json转换为java对象 |
| @RequestParam | 指定请求参数的名称 |
| @PathViriable | 从请求路径下中获取请求参数(/user/{id}),传递给方法的形式参数 |
| @ResponseBody | 注解实现将controller方法返回对象转化为json对象响应给客户端 |
| @RequestHeader | 获取指定的请求头数据 |
| @RestController | @Controller + @ResponseBody |
Springboot常见注解有哪些?
| 注解 | 说明 |
|---|---|
| @SpringBootApplication | SpringBoot一键配置 |
| @SpringBootConfiguration | 组合了- @Configuration注解,实现配置文件的功能 |
| @EnableAutoConfiguration | 打开自动配置的功能,也可以关闭某个自动配置的选 |
| @ComponentScan | Spring组件扫描 |
Mybatisxi相关:

09 MyBatis执行流程
1、加载核心配置文件,获取当前数据库的环境信息,加载mapper映射文件、读取别名配置、插件配置等。
2、构建SqlSessionFactory会话工厂(整个项目只有一个)用于生产SqlSession。
3、创建SqlSession,包含了执行sql语句的所有方法,每次操作一次会话,有多个SqlSession。
4、Executor执行器,真正执行数据库操作接口,也负责查询缓存的维护
5、MappedStatement对象是真正查询数据库的方法,它存放的有mapper接口方法的信息和对应xml文件的sql语句,负责接收参数,执行sql,然后封装数据。

总结:

10 Mybatis是否支持延迟加载?
Mybatis支持延迟记载,但默认没有开启
什么叫做延迟加载?

查询用户的时候,把用户所属的订单数据也查询出来,这个是立即加载
查询用户的时候,暂时不查询订单数据,当需要订单的时候,再查询订单,这个就是延迟加载
想要延迟加载只需要在需要在collection标签添加属性fetchType="lazy"就可以了

延迟加载的原理
1、使用CGLIB创建目标对象的代理对象
2、当调用目标方法user.getOrderList()时,进入拦截器invoke方法,发现user.getOrderList()是null值,执行sql查询order列表
3、把order查询上来,然后调用user.setOrderList(List<Order> orderList) ,接着完成user.getOrderList()方法的调用

总结:

11 Mybatis缓存
本地缓存,基于PerpetualCache,本质是一个HashMap。一级缓存和二级缓存本质都是本地缓存。
一级缓存:作用域是session级别(主要指的是SqlSession)
二级缓存:作用域是namespace和mapper的作用域,不依赖于session
一级缓存
一级缓存: 基于 PerpetualCache 的 HashMap 本地缓存,其存储作用域为 Session,当Session进行flush或close之后,该Session中的所有Cache就将清空,默认打开一级缓存
同一个SqlSession调用同样的方法并传递相同的参数只会执行一次sql查询操作,第二次会使用缓存的数据。

二级缓存
二级缓存是基于namespace和mapper的作用域起作用的,不是依赖于SQL session,默认也是采用 PerpetualCache,HashMap 存储

注意事项:
1,对于缓存数据更新机制,当某一个作用域(一级缓存 Session/二级缓存Namespaces)的进行了新增、修改、删除操作后,默认该作用域下所有 select 中的缓存将被 clear
2,二级缓存需要缓存的数据实现Serializable接口
3,只有会话提交或者关闭以后,一级缓存中的数据才会转移到二级缓存中
总结:

五 SpringCloud

01 Spring Cloud 5大组件
Spring Cloud 5大组件有哪些?
本题属于基础的内容考察。回答原则:简单的问题不能答错(一道面试题就能淘汰一个人)新手和老手都要注意
| 通常情况 | SpringCloudAlibaba |
|---|---|
| Eureka : 注册中心 | 注册中心/配置中心 Nacos |
| Ribbon : 负载均衡 | 负载均衡 Ribbon |
| Feign : 远程调用 | 服务调用 Feign |
| Hystrix : 服务熔断 | 服务保护 sentinel |
| Zuul/Gateway : 网关 | 服务网关 Gateway |

02 服务注册/发现
服务注册和发现是什么意思?Spring Cloud 如何实现服务注册发现?
微服务中必须要使用的组件,考察我们使用微服务的程度
注册中心的核心作用是:服务注册和发现
常见的注册中心:eureka、nocas、zookeeper
我做过的哪个微服务项目,使用了哪个注册中心
Eureka的作用
注册中心保存order-service、user-service等服务的信息,当order-service想要调用user-service时先去注册中心拉取user-service的信息,然后通过负载均衡选择一台user-service服务,然后通过Feign远程调用。服务会每30秒发送一次心跳当超过90秒未收到心跳时注册中心会将该服务从服务列表中移除掉。

总结:

Nacos的工作流程
如果服务是临时示例,则Nacos和Eureka一样,都是通过服务发送心跳实现的。如果我们将一个服务设置为非临时实例,Nacos会主动询问该非临时实例。当节点信息变更后nacos也会主动推送变更信息。

总结:

03 负载均衡
1️⃣Ribbon负载均衡流程
首先order-service发送获取用户信息的请求到Ribbon,Ribbon拉去user-service服务的服务列表,然后根据负载均衡策略选择一个服务进行访问。

2️⃣Ribbon负载均衡策略
Ribbon负载均衡策略有哪些 ?
RoundRobinRule:简单轮询服务列表来选择服务器
WeightedResponseTimeRule:按照权重来选择服务器,响应时间越长,权重越小
RandomRule:随机选择一个可用的服务器
BestAvailableRule:忽略那些短路的服务器,并选择并发数较低的服务器
RetryRule:重试机制的选择逻辑
AvailabilityFilteringRule:可用性敏感策略,先过滤非健康的,再选择连接数较小的实例
ZoneAvoidanceRule:以区域可用的服务器为基础进行服务器的选择。使用Zone对服务器进行分类,这个Zone可以理解为一个机房、一个机架等。而后再对Zone内的多个服务做轮询
3️⃣自定义负载均衡策略
如果想自定义负载均衡策略如何实现 ?
可以自己创建类实现IRule接口 , 然后再通过配置类或者配置文件配置即可 ,通过定义IRule实现可以修改负载均衡规则,有两种方式:

总结:

04 服务雪崩(熔断、降级、限流)
什么是服务雪崩,怎么解决这个问题?
服务雪崩:一个服务失败,导致整条链路的服务都失败的情形
比如服务D宕机,导致其他需要访问服务D的服务都变得不可以。

服务降级
服务降级是服务自我保护的一种方式,或者保护下游服务的一种方式,用于确保服务不会受请求突增影响变得不可用,确保服务不会崩溃
比如服务D的update方法可用,save方法不可用。如果服务A调用的是update方法时可以正常访问的;如果访问的是save方法会失败,此时我们可以加入降级的策略(实现远程调用的接口并指明失败回调即可),比如提示用户的网络有问题,获取数据失败。

服务熔断
Hystrix 熔断机制,用于监控微服务调用情况, 默认是关闭的,如果需要开启需要在引导类上添加注解:@EnableCircuitBreaker
如果检测到 10 秒内请求的失败率超过 50%,就触发熔断机制。之后每隔 5 秒重新尝试请求微服务,如果微服务不能响应,继续走熔断机制。如果微服务可达,则关闭熔断机制,恢复正常请求。

总结:

05 服务监控

skywalking
一个分布式系统的应用程序性能监控工具( Application Performance Managment ),提供了完善的链路追踪能力, apache的顶级项目(前华为产品经理吴晟主导开源)
服务(service):业务资源应用系统(微服务)
端点(endpoint):应用系统对外暴露的功能接口(接口)
实例(instance):物理机


点开慢的端点可以看到详细的信息。

点开Sql可以看到具体的sql语句

拓扑图那一栏可以清晰的看到详细的服务关系。

在告警那一栏里可以看到详细的告警信息

总结:

06 限流
为什么要限流?
1,并发的确大(突发流量)
2,防止用户恶意刷接口

限流的实现方式:
Tomcat:可以设置最大连接数
Nginx,漏桶算法
网关,令牌桶算法
自定义拦截器
1️⃣Tomcat设置最大连接数

2️⃣Nginx限流(漏桶算法)
控制速率(突发流量,使用漏桶算法)
我们把100个请求存储到漏桶中,然后我们固定的流出(比如每秒只流出两个),这样请求的速率就比较稳定了。如果设置了桶中只能存储100个请求,此时来了110个请求,多余的10个请求我们可以让其等待或直接抛弃,只有在桶中的请求才能以固定的速率得到处理。


语法:limit_req_zone key zone rate
key:定义限流对象,binary_remote_addr就是一种key,基于客户端ip限流
Zone:定义共享存储区来存储访问信息,10m可以存储16wip地址访问信息
Rate:最大访问速率,rate=10r/s 表示每秒最多请求10个请求
burst=20:相当于桶的大小
Nodelay:快速处理
控制并发连接数

llimit_conn perip 20:对应的key是 $binary_remote_addr,表示限制单个IP同时最多能持有20个连接。
llimit_conn perserver 100:对应的key是 $server_name,表示虚拟主机(server) 同时能处理并发连接的总数。
3️⃣网关限流(令牌桶算法)
令牌桶算法:固定速率生成令牌,存入令牌桶,桶满后暂停生成(漏桶算法桶里存放的是请求,令牌桶算法桶里存放的是令牌),请求来了之后需要到桶中申请令牌,当请求申请到令牌后才会被服务处理,申请不到令牌的请求则会被阻塞或丢弃。(漏桶算法是以固定的速率处理请求,一定时间内处理的请求的数量一定是平滑的。但是令牌桶中允许有突发的流量,比如令牌桶中每秒生成3个令牌,如果上一秒只有2个请求获取令牌,那么这一秒就可以有4个请求获取到令牌,因此令牌桶每秒处理的请求可能会超出令牌生成的速率。)

yml配置文件中,微服务路由设置添加局部过滤器RequestRateLimiter

key-resolver:定义限流对象( ip 、路径、参数),需代码实现,使用spel表达式获取replenishRate:令牌桶每秒填充平均速率。urstCapacity令牌桶总容量。
总结:

07 CAP 和 BASE
解释一下CAP和BASE?
分布式事务方案的指导
分布式系统设计方向
根据业务指导使用正确的技术选择
CAP定理
1998年,加州大学的计算机科学家 Eric Brewer 提出,分布式系统有三个指标:
Consistency(一致性)
Availability(可用性)
Partition tolerance (分区容错性)
Eric Brewer 说,分布式系统无法同时满足这三个指标。
这个结论就叫做 CAP 定理。

CAP定理- Consistency
Consistency(一致性):用户访问分布式系统中的任意节点,得到的数据必须一致
比如node01和node02最开始的数据都是v0,修改node01后的数据为v1后,node01需要将数据同步给node02

CAP定理- Availability
Availability (可用性):用户访问集群中的任意健康节点,必须能得到响应(是不是最新的数据是另外一回事),而不是超时或拒绝

CAP定理-Partition tolerance
Partition(分区):因为网络故障或其它原因导致分布式系统中的部分节点与其它节点失去连接,形成独立分区。
Tolerance(容错):在集群出现分区时,整个系统也要持续对外提供服务

结论:
分布式系统节点之间肯定是需要网络连接的,分区(P)是必然存在的
如果保证访问的高可用性(A),可以持续对外提供服务,但不能保证数据的强一致性--> AP
如果保证访问的数据强一致性(C),就要放弃高可用性 --> CP
BASE理论
BASE理论是对CAP的一种解决思路,包含三个思想:
Basically Available(基本可用):分布式系统在出现故障时,允许损失部分可用性,即保证核心可用。
**Soft State(软状态):**在一定时间内,允许出现中间状态,比如临时的不一致状态。
Eventually Consistent(最终一致性):虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致。
比如用户进行下单,订单服务下单成功,账号服务扣款成功,此时想扣减库存发现库存不足了,这时订单服务和账号服务需要进行回退。如果我们选择AP模式(满足可用性牺牲一部分一致性)那么就是各种事务先提交,库存不足时订单服务和账户服务事务提交了但是库存服务提交失败了,这时就出现了数据不一致的情况(软状态),这时事务协调器发现有事务失败了会通知已经提交的事务再将数据恢复过来(注意这里不是回滚事务,因为事务已经提交了,我们可以逆向操作,比如如果是新增数据就删除数据)。如果是CP模式(满足一致性牺牲一部分可用性)则是各个服务先执行但不提交,然后将各服务的执行情况上报给事务协调器,如果全部执行成功了才会通知各个服务提交事务,如果有一个失败了事务提交前会通知各个服务回滚事务,这样就达到了强一致性,但是各个事务需要等待其他事务,在这等待的过程中服务会锁定资源导致处于弱可用的状态。

总结:

08 分布式事务
Seata事务管理中有三个重要的角色:
TC (Transaction Coordinator) - **事务协调者:**维护全局和分支事务的状态,协调全局事务提交或回滚。
TM (Transaction Manager) - **事务管理器:**定义全局事务的范围、开始全局事务、提交或回滚全局事务。
RM (Resource Manager) - **资源管理器:**管理分支事务处理的资源,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚。

1️⃣Seta之XA模式
RM一阶段的工作:
①注册分支事务到TC
②执行分支业务sql但不提交
③报告执行状态到TC
TC二阶段的工作:
•TC检测各分支事务执行状态
a.如果都成功,通知所有RM提交事务
b.如果有失败,通知所有RM回滚事务
RM二阶段的工作:
•接收TC指令,提交或回滚事务

2️⃣Seta之AT模式
AT模式同样是分阶段提交的事务模型,不过缺弥补了XA模型中资源锁定周期过长的缺陷。
阶段一RM的工作:
注册分支事务
记录undo-log(数据快照)
执行业务sql并提交
报告事务状态
阶段二提交时RM的工作:
- 删除undo-log即可
阶段二回滚时RM的工作:
- 根据undo-log恢复数据到更新前

3️⃣Seta之TCC模式
1、Try:资源的检测和预留;
2、Confirm:完成资源操作业务;要求 Try 成功 Confirm 一定要能成功。
3、Cancel:预留资源释放,可以理解为try的反向操作。
TCC模式下自己需要实现Try、Confirm、Cancel的代码,代价较大但性能较好。而AT模式是通过记录undo log实现的,不需要我们自己实现回滚逻辑。XA模式是都成功才提交事务的也不需要实现回滚逻辑。

MQ实现分布式事务
比如用户申请向借呗借钱,资质审核通过后生成借款单并发送MQ消息,提示用户借款成功,5分钟到账。支付宝读取MQ消息,增加账户余额。此时如果支付宝增加余额失败了就会出现问题,不过这是小概率事件,不过小概率事件必然发生,此时只能人工解决了。
这种方式性能好,但是实时性差,对实时性要求不那么高可以使用这种方式。

总结:

09 接口幂等性
分布式服务的接口幂等性如何设计?
幂等: 多次调用方法或者接口不会改变业务状态,可以保证重复调用的结果和单次调用的结果一致。
需要幂等场景
用户重复点击(网络波动导致重复请求)
MQ消息重复
应用使用失败或超时重试机制
比如用户多次提交同一个订单,此时必须保证多次提交最终最多只有一个操作成功(即多次提交和一次提交的效果一样),如果多次点击提交就出现了多个订单显然不太合理。

接口幂等
基于RESTful API的角度对部分常见类型请求的幂等性特点进行分析
| 请求方式 | 说明 |
|---|---|
| GET | 查询操作,天然幂等 |
| POST | 新增操作,请求一次与请求多次造成的结果不同,不是幂等的 |
| PUT | 更新操作,如果是以绝对值更新,则是幂等的。如果是通过增量的方式更新,则不是幂等的 |
| DELETE | 删除操作,根据唯一值删除,是幂等的 |

解决方式:

1️⃣数据库唯一索引
第一次请求时添加一行数据,第二次请求时再想添加该id的数据就会报错,这样就解决了幂等性问题。
数据库唯一索引肯定是能解决幂等性问题的,前提是数据库有唯一索引。
2️⃣token+redis
创建商品、提交订单、转账、支付等操作
每个订单都生成唯一的token,提交订单时携带token,如果这个token合法就处理业务并删除这个token,第二次请求时再携带这个token在redis中就查不到这个token了,自然不用再处理业务,直接返回请勿重复请求就行了。

3️⃣分布式锁
每次只能有一个请求可以提交订单,这样性能不好但是肯定能解决问题,使用这种方式要控制锁的粒度越小越好。

总结:

10 任务调度
你们项目中使用了什么分布式任务调度?
首先,还是要描述当时是什么场景用了任务调度
xxl-job解决的问题
解决集群任务的重复执行问题
cron表达式定义灵活
定时任务失败了,重试和统计
任务量大,分片执行
xxl-job路由策略有哪些?
1.FIRST(第一个):固定选择第一个机器;
2.LAST(最后一个):固定选择最后一个机器;
3.ROUND(轮询)
4.RANDOM(随机):随机选择在线的机器;
5.CONSISTENT_HASH(一致性HASH):每个任务按照Hash算法固定选择某一台机器,且所有任务均匀散列在不同机器上。
6.LEAST_FREQUENTLY_USED(最不经常使用):使用频率最低的机器优先被选举;
7.LEAST_RECENTLY_USED(最近最久未使用):最久未使用的机器优先被选举;
8.FAILOVER(故障转移):按照顺序依次进行心跳检测,第一个心跳检测成功的机器选定为目标执行器并发起调度;
9.BUSYOVER(忙碌转移):按照顺序依次进行空闲检测,第一个空闲检测成功的机器选定为目标执行器并发起调度;
10.SHARDING_BROADCAST(分片广播,任务量大可以使用这种方式):广播触发对应集群中所有机器执行一次任务,同时系统自动传递分片参数;可根据分片参数开发分片任务;

xxl-job任务执行失败怎么解决?
故障转移+失败重试,查看日志分析----> 邮件告警


如果有大数据量的任务同时都需要执行,怎么解决?
执行器集群部署时,任务路由策略选择分片广播情况下,一次任务调度将会广播触发对应集群中所有执行器执行一次任务

总结:

六、消息队列

RabbitMQ常用功能:
异步发送(验证码、短信、邮件…)
MYSQL和Redis , ES之间的数据同步
分布式事务
削峰填谷
01 RabbitMQ-如何保证消息不丢失?
生产者发送的消息未到达交换机,交换机里的消息未到达队列,RabbitMQ宕机导致队列消息丢失,队列发送的消息消费者没有接收到都有可能导致消息丢失。

生产者确认机制
RabbitMQ提供了publisher confirm机制来避免消息发送到MQ过程中丢失。消息发送到MQ以后,会返回一个结果给发送者,表示消息是否处理成功

消息失败之后如何处理呢?
回调方法即时重发
记录日志
保存到数据库然后定时重发,成功发送后即刻删除表中的数据
消息持久化
MQ默认是内存存储消息,开启持久化功能可以确保缓存在MQ中的消息不丢失。

消费者确认
RabbitMQ支持消费者确认机制,即:消费者处理消息后可以向MQ发送ack回执,MQ收到ack回执后才会删除该消息。而SpringAMQP则允许配置三种确认模式:
manual:手动ack,需要在业务代码结束后,调用api发送ack。
auto:自动ack,由spring监测listener代码是否出现异常,没有异常则返回ack;抛出异常则返回nack
none:关闭ack,MQ假定消费者获取消息后会成功处理,因此消息投递后立即被删除
我们可以利用Spring的retry机制,在消费者出现异常时利用本地重试,设置重试次数,当次数达到了以后,如果消息依然失败,将消息投递到异常交换机,交由人工处理

总结:

02 RabbitMQ消息的重复消费问题
消费者正常处理完了消息,还没来得及给MQ发送确认,突然网络出现了抖动或消费者宕机;由于MQ发送的消息没有收到确认,这个消息还在MQ中,由于我们设置了重试机制,那么消费者就会重新消费这条数据。

解决方案:
每条消息设置一个唯一的标识id(业务唯一标识:支付id,订单id,文章id ….)
幂等方案:【 分布式锁、数据库锁(悲观锁、乐观锁) 】
03 RabbitMQ中死信交换机 (延迟队列)
延迟队列:进入队列的消息会被延迟消费的队列
场景:超时订单、限时优惠、定时发布
延迟队列=死信交换机+TTL(生存时间)

死信交换机
当一个队列中的消息满足下列情况之一时,可以成为死信(dead letter)span>:
消费者使用
basic.reject或basic.nack声明消费失败,并且消息的requeue参数设置为false消息是一个过期消息,超时无人消费
要投递的队列消息堆积满了,最早的消息可能成为死信
如果该队列配置了dead-letter-exchange属性,指定了一个交换机,那么队列中的死信就会投递到这个交换机中,而这个交换机称为死信交换机(Dead Letter Exchange,简称DLX)。

可以通过如下配置指定死信交换机:

TTL
TTL,也就是Time-To-Live。如果一个队列中的消息TTL结束仍未消费,则会变为死信,ttl超时分为两种情况:
消息所在的队列设置了存活时间
消息本身设置了存活时间
如果消息和队列都设置了存活时间,则哪个时间短以哪个为准。


要想实现延时队列还可以使用延迟队列插件
DelayExchange插件(该插件可以实现延时消息和定时消息),需要安装在RabbitMQ中
RabbitMQ有一个官方的插件社区,地址为:https://www.rabbitmq.com/community-plugins.html

DelayExchange的本质还是官方的三种交换机,只是添加了延迟功能。因此使用时只需要声明一个交换机,交换机的类型可以是任意类型,然后设定delayed属性为true即可。

总结:

04 RabbitMQ消息堆积
RabbitMQ如果有100万消息堆积在MQ , 如何解决(消息堆积怎么解决)
当生产者发送消息的速度超过了消费者处理消息的速度,就会导致队列中的消息堆积,直到队列存储消息达到上限。之后发送的消息就会成为死信,可能会被丢弃,这就是消息堆积问题

解决消息堆积有三种种思路:
增加更多消费者,提高消费速度
在消费者内开启线程池,让线程池处理业务逻辑,加快消息处理速度
扩大队列容积,提高堆积上限
惰性队列
惰性队列的特征如下:
接收到消息后直接存入磁盘而非内存
消费者要消费消息时才会从磁盘中读取并加载到内存
支持数百万条的消息存储
以下两种方式都可以设置这个队列的消息存放到磁盘中。

总结:

05 RabbitMQ的高可用机制(集群)
在生产环境下,使用集群来保证高可用性常用的集群有三种:普通集群、镜像集群、仲裁队列。
普通集群
普通集群,或者叫标准集群(classic cluster),具备下列特征:
会在集群的各个节点间共享部分数据,包括:交换机、队列元信息。不包含队列中的消息。
当访问集群某节点时,如果队列不在该节点,会从数据所在节点传递到当前节点并返回
队列所在节点宕机,队列中的消息就会丢失
交换机的信息各个节点都有,每个节点的队列信息是不一样的,但是每个节点有其他节点的引用信息。比如第一个节点有一个队列test.queqe1,在其他节点存在test.queqe1的引用信息(这些引用信息也叫元信息,这些引用信息并不含队列里的消息),如果一个消费者想在第三个节点找test.queqe1的消息,第三个节点会传递引用找到有test.queqe1队列的第一个节点。
该集群的缺点是如果
test.queqe1节点宕机了,消息就丢失了,test.queqe1的消息不能被消费了,我们可以用镜像队列解决这个问题。

镜像集群(常用方式)
镜像集群:本质是主从模式,具备下面的特征:
交换机、队列、队列中的消息会在各个mq的镜像节点之间同步备份。
创建队列的节点被称为该队列的主节点,备份到的其它节点叫做该队列的镜像节点。
一个队列的主节点可能是另一个队列的镜像节点
所有操作都是主节点完成,然后同步给镜像节点
主宕机后,镜像节点会替代成新的主节点
主节点的数据更新后,还没来得及更新到镜像节点 此时主节点突然宕机了,这时就会出现消息丢失,我们可以通过仲裁队列来解决这个问题,不过这种情况是小概率事件,需要看情况判断要不要处理。

仲裁队列
仲裁队列:仲裁队列是3.8版本以后才有的新功能,用来替代镜像队列,具备下列特征:
与镜像队列一样,都是主从模式,支持主从数据同步
使用非常简单,没有复杂的配置
主从同步基于Raft协议,强一致

总结:

06 Kafka是如何保证消息不丢失的
使用Kafka在消息的收发过程都会出现消息丢失 , Kafka分别给出了解决方案
生产者发送消息到Brocker丢失
消息在Brocker中存储丢失
消费者从Brocker接收消息丢失

生产者发送消息到Brocker丢失
设置异步发送
//同步发送
RecordMetadata recordMetadata = kafkaProducer.send(record).get();
//异步发送
kafkaProducer.send(record, new Callback() {
@Override
public void onCompletion(RecordMetadata recordMetadata, Exception e) {
if (e != null) {
System.out.println("消息发送失败 | 记录日志");
}
long offset = recordMetadata.offset(); // 偏移量
int partition = recordMetadata.partition(); // 分区
String topic = recordMetadata.topic();
}
});
消息重试
//设置重试次数
prop.put(ProducerConfig.RETRIES_CONFIG,10);
消息在Brocker中存储丢失
- 发送确认机制acks

| 确认机制 | 说明 |
|---|---|
| acks=0 | 生产者在成功写入消息之前不会等待任何来自服务器的响应,消息有丢失的风险,但是速度最快 |
| acks=1(默认值) | 只要集群首领节点收到消息,生产者就会收到一个来自服务器的成功响应 |
| acks=all | 只有当所有参与赋值的节点全部收到消息时,生产者才会收到一个来自服务器的成功响应 |
消费者从Brocker接收消息丢失
一个Kafka集群可以有多个Borker(Kafka实例),每一个Broker存储了多个Toplic,并且一个Topic有可能存在多个分区(比如如下示例中一个T1分别存储在了P1、p2、p3、p4四个分区,并且这几个分区可以存储在不同的Broker中,消费者组的每个消费者可能会负责不同的分区)

Kafka 中的分区机制指的是将每个主题划分成多个分区(Partition)
topic分区中消息只能由消费者组中的唯一一个消费者处理,不同的分区分配给不同的消费者(同一个消费者组)

比如consumer1负责P1和P2分区,consumer2负责P3分区,消费者消费消息时会按照偏移量来消费消息,其中P1中04是已经消费过的,511是还没被消费的。
消费者默认是自动按期提交已经消费的偏移量,默认是每隔5s提交一次 如果出现重平衡(一个消费者宕机后该消费者负责的分区将由其他消费者进行消费)的情况,可能会重复消费或丢失数据 (比如consumer2宕机之前提交消费到了3实际只消费到了1,此时就丢失了数据)。
要解决这个问题,我们可以禁用自动提交偏移量,改为手动
同步提交
异步提交
同步+异步组合提交

总结:

07 Kafka是如何保证消费的顺序性
应用场景:
即时消息中的单对单聊天和群聊,保证发送方消息发送顺序与接收方的顺序一致
充值转账两个渠道在同一个时间进行余额变更,短信通知必须要有顺序
我们可以指定使用相同的分区,或者相同的key(相同的key会得到相同的hash值,进而在相同的分区里)

总结:

08 Kafka的高可用机制有了解过嘛
集群模式
分区备份机制
集群模式

Kafka 的服务器端由被称为 Broker 的服务进程构成,即一个 Kafka 集群由多个 Broker 组成
这样如果集群中某一台机器宕机,其他机器上的 Broker 也依然能够对外提供服务。这其实就是 Kafka 提供高可用的手段之一
分区备份机制
某一个topic中有三个分区P0、P1、P2

一个topic有多个分区,每个分区有多个副本,其中有一个leader,其余的是follower,副本存储在不同的broker中
所有的分区副本的内容是都是相同的,如果leader发生故障时,会自动将其中一个follower提升为leader

ISR(in-sync replica:需要同步复制保存的follower(标明了ISR的副本,leader是通过同步请求的方式同步数据,普通副本则是异步请求的方式同步数据)
follower分为ISR副本和普通副本

如果leader失效后,需要选出新的leader,选举的原则如下:
第一:选举时优先从ISR中选定,因为这个列表中follower的数据是与leader同步的
第二:如果ISR列表中的follower都不行了,就只能从其他follower中选取
总结:

09 Kafka数据清理机制了解过嘛
在讲Kafka数据清理机制之前,我们需要先了解Kafka的文件存储机制
比如itheima这个Topic(逻辑概念)有三个分区itheima-0、itheima-1、itheima-2,这三个分区是三个不同的文件夹。每个分区内又有多个分段,每个分段内都有index、log、timeindex这三个文件,分段文件都是以偏移量命名的(方便查找)。

为什么要分段?
删除无用文件(比较老的数据)方便,提高磁盘利用率
查找数据便捷
数据清理机制
日志的清理策略有两个
1️⃣根据消息的保留时间,当消息在kafka中保存的时间超过了指定的时间(默认是7天),就会触发清理过程

2️⃣根据topic存储的数据大小,当topic所占的日志文件大小大于一定的阈值,则开始删除最久的消息。需手动开启

总结:

10 Kafka中实现高性能的设计有了解过嘛
消息分区:不受单台服务器的限制,可以不受限的处理更多的数据
顺序读写:磁盘顺序读写,提升读写效率
页缓存:把磁盘中的数据缓存到内存中,把对磁盘的访问变为对内存的访问
零拷贝:减少上下文切换及数据拷贝
消息压缩:减少磁盘IO和网络IO
分批发送:将消息打包批量发送,减少网络开销
零拷贝
生产者发送消息,Kafka肯定要将消息存储到磁盘文件中,由于用户空间没有权限调用磁盘读写 所以需要将数据拷贝到内核空间中。在内核空间中有一个页缓存,Kafka将数据存储到页缓存中,到了一定的批次以后就会把数据写入到磁盘中。当消费者想要获取数据时,Kafka需要先去内核空间的页缓存中去找,如果页缓存中没有则去磁盘文件中去读取文件中的消息,然后把消息拷贝到页缓存中,然后从页缓存中拷贝数据到Kafka,然后Kafka又从用户空间拷贝数据到内核的Socket缓冲区,然后再由Socket缓冲区拷贝到网卡,由网卡将数据发送给消费者。

Kafka知道了哪些消费者要消费了这个消息,直接把事情委托给了系统去操作,系统直接把页缓存的数据拷贝给了网卡。

总结:

七、Java集合

Java集合框架体系

01 时间/空间 复杂度分析
为什么要进行复杂度分析?
指导你编写出性能更优的代码
评判别人写的代码的好坏


常见复杂度

总结:只要代码的执行时间不随着n的增大而增大,这样的代码复杂度都是O(1)

复杂度分析就是要弄清楚代码的执行次数和数据规模n之间的关系



空间复杂度
空间复杂度全称是渐进空间复杂度,表示算法占用的额外存储空间与数据规模之间的增长关系

我们常见的空间复杂度就是O(1),O(n),O(n ^2),其他像对数阶的复杂度几乎用不到,因此空间复杂度比时间复杂度分析要简单的多。
总结:

02 ArrayList
01 数组结构
数组
数组(Array)是一种用连续的内存空间存储相同数据类型数据的线性数据结构。

数组如何获取其他元素的地址值?


操作数组的时间复杂度(查找)
1.随机查询(根据索引查询)
数组元素的访问是通过下标来访问的,计算机通过数组的首地址和寻址公式能够很快速的找到想要访问的元素

2.未知索引查询
情况一:查找数组内的元素,查找55号数据

情况二:查找排序后数组内的元素,查找55号数据

操作数组的时间复杂度(插入、删除)
数组是一段连续的内存空间,因此为了保证数组的连续性会使得数组的插入和删除的效率变的很低。(当要插入元素时后面的元素都要往后移一格,删除元素时后面的元素都要往前移一格)
最好情况下是O(1)的,最坏情况下是O(n)的,平均情况下的时间复杂度是O(n)。
总结:

02 ArrayList源码







03 ArrayList底层的实现原理是什么
底层数据结构
ArrayList底层是用动态的数组实现的
初始容量
ArrayList初始容量为0,当第一次添加数据的时候才会初始化容量为10
扩容逻辑
ArrayList在进行扩容的时候是原来容量的1.5倍,每次扩容都需要拷贝数组
添加逻辑
- 确保数组已使用长度(size)加1之后足够存下下一个数据
- 计算数组的容量,如果当前数组已使用长度+1后的大于当前的数组长度,则调用grow方法扩容(原来的1.5倍)
- 确保新增的数据有地方存储之后,则将新元素添加到位于size的位置上。
- 返回添加成功布尔值。
04 ArrayList list=new ArrayList(10)中的list扩容几次
/**
* 构造一个具有指定初始容量的空列表。
* 参数:initialCapacity - 列表的初始容量
* 抛出:IllegalArgumentException – 如果指定的初始容量为负
*/
public ArrayList(int initialCapacity) {
if (initialCapacity > 0) {
this.elementData = new Object[initialCapacity];
} else if (initialCapacity == 0) {
this.elementData = EMPTY_ELEMENTDATA;
} else {
throw new IllegalArgumentException("Illegal Capacity: “ + initialCapacity);
}
}
该语句只是声明和实例了一个 ArrayList,指定了容量为 10,未扩容
05 如何实现数组和List之间的转换
//数组转List
public static void testArray2List(){
String[] strs = {"aaa","bbb","ccc"};
List<String> list = Arrays.asList(strs);
for (String s : list) {
System.out.println(s);
}
}
//List转数组
public static void testList2Array(){
List<String> list = new ArrayList<String>();
list.add("aaa");
list.add("bbb");
list.add("ccc");
String[] array = list.toArray(new String[list.size()]);
for (String s : array) {
System.out.println(s);
}
}
数组转List ,使用JDK中java.util.Arrays工具类的asList方法
List转数组,使用List的toArray方法。无参toArray方法返回 Object数组,传入初始化长度的数组对象,返回该对象数组
面试官再问:
用Arrays.asList转List后,如果修改了数组内容,list受影响吗?
List用toArray转数组后,如果修改了List内容,数组受影响吗?
//数组转List (受影响)
public static void testArray2List(){
String[] strs = {"aaa","bbb","ccc"};
List<String> list = Arrays.asList(strs);
for (String s : list) {
System.out.println(s);
}
strs[1]="ddd";
System.out.println("================");
for (String s : list) {
System.out.println(s);
}
}
//List转数组 (不受影响)
public static void testList2Array(){
List<String> list = new ArrayList<String>();
list.add("aaa");
list.add("bbb");
list.add("ccc");
String[] array = list.toArray(new String[list.size()]);
for (String s : array) {
System.out.println(s);
}
list.add("ddd");
System.out.println("================");
for (String s : array) {
System.out.println(s);
}
}
Arrays.asList转换list之后,如果修改了数组的内容,list会受影响,因为它的底层使用的Arrays类中的一个内部类ArrayList来构造的集合,在这个集合的构造器中,把我们传入的这个集合进行了包装而已,最终指向的都是同一个内存地址
list用了toArray转数组后,如果修改了list内容,数组不会影响,当调用了toArray以后,在底层是它是进行了数组的拷贝,跟原来的元素就没啥关系了,所以即使list修改了以后,数组也不受影响
03 LinkedList

01 单向链表
链表中的每一个元素称之为结点(Node)
物理存储单元上,非连续、非顺序的存储结构
单向链表:每个结点包括两个部分:一个是存储数据元素的数据域,另一个是存储下一个结点地址的指针域。记录下个结点地址的指针叫作后继指针 next

Java代码实现
private static class Node<E> {
E item;
Node<E> next;
Node(E element, Node<E> next) {
this.item = element;
this.next = next;
}
}
链表中的某个节点为B,B的下一个节点为C。用 B.next==C 来表示

02 单向链表时间复杂度分析
查询操作

只有在查询头节点的时候不需要遍历链表,时间复杂度是O(1)
查询其他结点需要遍历链表,时间复杂度是O(n)
插入\删除操作

只有在添加和删除头节点的时候不需要遍历链表,时间复杂度是O(1)
添加或删除其他结点需要遍历链表找到对应节点后,才能完成新增或删除节点,时间复杂度是O(n)
03 双向链表
双向链表,顾名思义,它支持两个方向
每个结点不止有一个后继指针 next 指向后面的结点
有一个前驱指针 prev 指向前面的结点
private static class Node<E> {
E item;
Node<E> next;
Node<E> prev;
Node(Node<E> prev, E element, Node<E> next) {
this.item = element;
this.next = next;
this.prev = prev;
}
}

对比单链表:
双向链表需要额外的两个空间来存储后继结点和前驱结点的地址
支持双向遍历,这样也带来了双向链表操作的灵活性

总结:

04 ArrayList 和 LinkedList 的区别是什么?
1.底层数据结构
ArrayList 是动态数组的数据结构实现
LinkedList 是双向链表的数据结构实现
2.操作数据效率
ArrayList按照下标查询的时间复杂度O(1)【内存是连续的,根据寻址公式】, LinkedList不支持下标查询
查找(未知索引): ArrayList需要遍历,链表也需要链表,时间复杂度都是O(n)
新增和删除
- ArrayList尾部插入和删除,时间复杂度是O(1);其他部分增删需要挪动数组,时间复杂度是O(n)
- LinkedList头尾节点增删时间复杂度是O(1),其他都需要遍历链表,时间复杂度是O(n)

3.内存空间占用
ArrayList底层是数组,内存连续,节省内存
LinkedList 是双向链表需要存储数据,和两个指针,更占用内存
4.线程安全
ArrayList和LinkedList都不是线程安全的
如果需要保证线程安全,有两种方案:
在方法内使用,局部变量则是线程安全的
使用线程安全的ArrayList和LinkedList
List<Object> syncArrayList = Collections.synchronizedList(new ArrayList<>()); List<Object> syncLinkedList = Collections.synchronizedList(new LinkedList<>());
04 HashMap相关面试题
01 二叉树
二叉树,顾名思义,每个节点最多有两个“叉”,也就是两个子节点,分别是左子节点和右子节点。不过,二叉树并不要求每个节点都有两个子节点,有的节点只有左子节点,有的节点只有右子节点。
二叉树每个节点的左子树和右子树也分别满足二叉树的定义。

Java中有两个方式实现二叉树:数组存储,链式存储。
基于链式存储的树的节点可定义如下:

二叉树分类
在二叉树中,比较常见的二叉树有:
满二叉树
完全二叉树
二叉搜索树
红黑树
二叉搜索树
二叉搜索树(Binary Search Tree,BST)又名二叉查找树,有序二叉树或者排序二叉树,是二叉树中比较常用的一种类型
二叉查找树要求,在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值,而右子树节点的值都大于这个节点的值

二叉搜索树-时间复杂度分析
实际上由于二叉查找树的形态各异,时间复杂度也不尽相同,我画了几棵树我们来看一下插入,查找,删除的时间复杂度


总结:
1.什么是二叉树
每个节点最多有两个“叉”,分别是左子节点和右子节点。
不要求每个节点都有两个子节点,有的节点只有左子节点,有的节点只有右子节点。
二叉树每个节点的左子树和右子树也分别满足二叉树的定义
2.什么是二叉搜索树
二叉搜索树(Binary Search Tree,BST)又名二叉查找树,有序二叉树
在树中的任意一个节点,其左子树中的每个节点的值,都要小于这个节点的值而右子树节点的值都大于这个节点的值
没有键值相等的节点
通常情况下二叉树搜索的时间复杂度为O(logn)
红黑树
红黑树(Red Black Tree):也是一种自平衡的二叉搜索树(BST),之前叫做平衡二叉B树(Symmetric Binary B-Tree)

红黑树的特质
在添加或删除节点的时候,如果不符合这些性质会发生旋转,以达到所有的性质(保持平衡)
性质1:节点要么是红色,要么是黑色
性质2:根节点是黑色
性质3:叶子节点都是黑色的空节点
性质4:红黑树中红色节点的子节点都是黑色
性质5:从任一节点到叶子节点的所有路径都包含相同数目的黑色节点
红黑树的复杂度
•查找:
红黑树也是一棵BST(二叉搜索树)树,查找操作的时间复杂度为:O(log n)
•添加:
添加先要从根节点开始找到元素添加的位置,时间复杂度O(log n)
添加完成后涉及到复杂度为O(1)的旋转调整操作
故整体复杂度为:O(log n)
•删除:
首先从根节点开始找到被删除元素的位置,时间复杂度O(log n)
删除完成后涉及到复杂度为O(1)的旋转调整操作
故整体复杂度为:O(log n)
总结:
什么是红黑树?
红黑树(Red Black Tree):也是一种自平衡的二叉搜索树(BST)
所有的红黑规则都是希望红黑树能够保证平衡
红黑树的时间复杂度:查找、添加、删除都是O(logn)
02 散列表
在HashMap中的最重要的一个数据结构就是散列表,在散列表中又使用到了红黑树和链表

散列表(Hash Table)又名哈希表/Hash表,是根据键(Key)直接访问在内存存储位置值(Value)的数据结构,它是由数组演化而来的,利用了数组支持按照下标进行随机访问数据的特性

假设有100个人参加马拉松,不采用1-100的自然数对选手进行编号,编号有一定的规则比如:2023ZHBJ001,其中2023代表年份,ZH代表中国,BJ代表北京,001代表原来的编号,那此时的编号2023ZHBJ001不能直接作为数组的下标,此时应该如何实现呢?

将键(key)映射为数组下标的函数叫做散列函数。可以表示为:hashValue = hash(key)
散列函数的基本要求:
散列函数计算得到的散列值必须是大于等于0的正整数,因为hashValue需要作为数组的下标。
如果key1==key2,那么经过hash后得到的哈希值也必相同即:hash(key1) == hash(key2)
如果key1 != key2,那么经过hash后得到的哈希值也必不相同即:hash(key1) != hash(key2)
散列冲突
实际的情况下想找一个散列函数能够做到对于不同的key计算得到的散列值都不同几乎是不可能的,即便像著名的MD5,SHA等哈希算法也无法避免这一情况,这就是散列冲突(或者哈希冲突,哈希碰撞,就是指多个key映射到同一个数组下标位置)

散列冲突-链表法(拉链)
在散列表中,数组的每个下标位置我们可以称之为桶(bucket)或者槽(slot),每个桶(槽)会对应一条链表,所有散列值相同的元素我们都放到相同槽位对应的链表中。
时间复杂度
(1)插入操作,通过散列函数计算出对应的散列槽位,将其插入到对应链表中即可,插入的时间复杂度是 O(1)

(2)当查找、删除一个元素时,我们同样通过散列函数计算出对应的槽,然后遍历链表查找或者删除
平均情况下基于链表法解决冲突时查询的时间复杂度是O(1)
散列表可能会退化为链表,查询的时间复杂度就从 O(1) 退化为 O(n)
将链表法中的链表改造为其他高效的动态数据结构,比如红黑树,查询的时间复杂度是 O(logn)

将链表法中的链表改造红黑树还有一个非常重要的原因,可以防止DDos攻击
DDos 攻击:
分布式拒绝服务攻击(英文意思是Distributed Denial of Service,简称DDoS)
指处于不同位置的多个攻击者同时向一个或数个目标发动攻击,或者一个攻击者控制了位于不同位置的多台机器并利用这些机器对受害者同时实施攻击。由于攻击的发出点是分布在不同地方的,这类攻击称为分布式拒绝服务攻击,其中的攻击者可以有多个
总结:
1.什么是散列表?
散列表(Hash Table)又名哈希表/Hash表
根据键(Key)直接访问在内存存储位置值(Value)的数据结构
由数组演化而来的,利用了数组支持按照下标进行随机访问数据
2.散列冲突
散列冲突又称哈希冲突,哈希碰撞
指多个key映射到同一个数组下标位置
3.散列冲突-链表法(拉链)
数组的每个下标位置称之为桶(bucket)或者槽(slot)
每个桶(槽)会对应一条链表
hash冲突后的元素都放到相同槽位对应的链表中或红黑树中

03 说一下HashMap的实现原理?
HashMap的数据结构: 底层使用hash表数据结构,即数组和链表或红黑树
1.当我们往HashMap中put元素时,利用key的hashCode重新hash计算出当前对象的元素在数组中的下标
2.存储时,如果出现hash值相同的key,此时有两种情况。
a. 如果key相同,则覆盖原始值;
b. 如果key不同(出现冲突),则将当前的key-value放入链表或红黑树中
3.获取时,直接找到hash值对应的下标,在进一步判断key是否相同,从而找到对应值。

HashMap的jdk1.7和jdk1.8有什么区别
JDK1.8之前采用的是拉链法。拉链法:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。
jdk1.8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8) 时并且数组长度达到64时,将链表转化为红黑树,以减少搜索时间。扩容 resize( ) 时,红黑树拆分成的树的结点数小于等于临界值6个,则退化成链表
总结:
1.说一下HashMap的实现原理?
底层使用hash表数据结构,即数组+(链表 | 红黑树)
添加数据时,计算key的值确定元素在数组中的下标
- key相同则替换
- 不同则存入链表或红黑树中
获取数据通过key的hash计算数组下标获取元素
2.HashMap的jdk1.7和jdk1.8有什么区别
JDK1.8之前采用的拉链法,数组+链表
JDK1.8之后采用数组+链表+红黑树,链表长度大于8且数组长度大于64则会从链表转化为红黑树
04 HashMap的put方法的具体流程


HashMap源码分析-添加数据

HashMap的put方法的具体流程
1.判断键值对数组table是否为空或为null,否则执行resize()进行扩容(初始化)
2.根据键值key计算hash值得到数组索引
3.判断table[i]==null,条件成立,直接新建节点添加
4.如果table[i]==null ,不成立
4.1判断table[i]的首个元素是否和key一样,如果相同直接覆盖value
4.2判断table[i] 是否为treeNode,即table[i] 是否是红黑树,如果是红黑树,则直接在树中插入键值对
4.3遍历table[i],链表的尾部插入数据,然后判断链表长度是否大于8,大于8的话把链表转换为红黑树,在红黑树中执行插入操 作,遍历过程中若发现key已经存在直接覆盖value
5.插入成功后,判断实际存在的键值对数量size是否超多了最大容量threshold(数组长度*0.75),如果超过,进行扩容。
讲一讲HashMap的扩容机制

在添加元素或初始化的时候需要调用resize方法进行扩容,第一次添加数据初始化数组长度为16,以后每次每次扩容都是达到了扩容阈值(数组长度 * 0.75)
每次扩容的时候,都是扩容之前容量的2倍;
扩容之后,会新创建一个数组,需要把老数组中的数据挪动到新的数组中
- 没有hash冲突的节点,则直接使用 e.hash & (newCap - 1) 计算新数组的索引位置
- 如果是红黑树,走红黑树的添加
- 如果是链表,则需要遍历链表,可能需要拆分链表,判断(e.hash & oldCap)是否为0,该元素的位置要么停留在原始位置,要么移动到原始位置+增加的数组大小这个位置上
hashMap的寻址算法

常见复杂度表示形式
速记口诀:常对幂指阶

为何HashMap的数组长度一定是2的次幂?
计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模
扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap
1.hashMap的寻址算法
计算对象的 hashCode()
再进行调用 hash() 方法进行二次哈希, hashcode值右移16位再异或运算,让哈希分布更为均匀
最后 (capacity – 1) & hash 得到索引
2.为何HashMap的数组长度一定是2的次幂?
计算索引时效率更高:如果是 2 的 n 次幂可以使用位与运算代替取模
扩容时重新计算索引效率更高: hash & oldCap == 0 的元素留在原来位置 ,否则新位置 = 旧位置 + oldCap
hashmap在1.7情况下的多线程死循环问题
jdk7的的数据结构是:数组+链表
在数组进行扩容的时候,因为链表是头插法,在进行数据迁移的过程中,有可能导致死循环



参考回答:
在jdk1.7的hashmap中在数组进行扩容的时候,因为链表是头插法,在进行数据迁移的过程中,有可能导致死循环
比如说,现在有两个线程
线程一:读取到当前的hashmap数据,数据中一个链表,在准备扩容时,线程二介入
线程二:也读取hashmap,直接进行扩容。因为是头插法,链表的顺序会进行颠倒过来。比如原来的顺序是AB,扩容后的顺序是BA,线程二执行结束。
线程一:继续执行的时候就会出现死循环的问题。
线程一先将A移入新的链表,再将B插入到链头,由于另外一个线程的原因,B的next指向了A,所以B->A->B,形成循环。
当然,JDK 8 将扩容算法做了调整,不再将元素加入链表头(而是保持与扩容前一样的顺序),尾插法,就避免了jdk7中死循环的问题。
八、多线程(线程的基础知识)


01 线程和进程的区别?
程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至 CPU,数据加载至内存。在指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理 IO 的。
当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。

一个线程就是一个指令流,将指令流中的一条条指令以一定的顺序交给 CPU 执行
一个进程之内可以分为一到多个线程。

线程和进程二者对比
进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务
不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程)
02 并行和并发有什么区别?
单核CPU
单核CPU下线程实际还是串行执行的
操作系统中有一个组件叫做任务调度器,将cpu的时间片(windows下时间片最小约为 15 毫秒)分给不同的程序使用,只是由于cpu在线程间(时间片很短)的切换非常快,人类感觉是同时运行的 。
总结为一句话就是:微观串行,宏观并行
一般会将这种线程轮流使用CPU的做法称为并发(concurrent)

多核CPU
每个核(core)都可以调度运行线程,这时候线程可以是并行的。

并发(concurrent)是同一时间应对(dealing with)多件事情的能力
并行(parallel)是同一时间动手做(doing)多件事情的能力
家庭主妇做饭、打扫卫生、给孩子喂奶,她一个人轮流交替做这多件事,这时就是并发
家庭主妇雇了个保姆,她们一起这些事,这时既有并发,也有并行(这时会产生竞争,例如锅只有一口,一个人用锅时,另一个人就得等待)
雇了3个保姆,一个专做饭、一个专打扫卫生、一个专喂奶,互不干扰,这时是并行
总结:
并行和并发有什么区别?
现在都是多核CPU,在多核CPU下
并发是同一时间应对多件事情的能力,多个线程轮流使用一个或多个CPU
并行是同一时间动手做多件事情的能力,4核CPU同时执行4个线程
03 创建线程的方式有哪些?
共有四种方式可以创建线程,分别是:
继承Thread类
实现runnable接口
实现Callable接口
线程池创建线程
1️⃣继承Thread类
public class MyThread extends Thread {
@Override
public void run() {
System.out.println("MyThread...run...");
}
public static void main(String[] args) {
// 创建MyThread对象
MyThread t1 = new MyThread() ;
MyThread t2 = new MyThread() ;
// 调用start方法启动线程
t1.start();
t2.start();
}
}
2️⃣实现runnable接口
public class MyRunnable implements Runnable {
@Override
public void run() {
System.out.println("MyRunnable...run...");
}
public static void main(String[] args) {
// 创建MyRunnable对象
MyRunnable mr = new MyRunnable();
// 创建Thread对象
Thread t1 = new Thread(mr);
Thread t2 = new Thread(mr);
// 调用start方法启动线程
t1.start();
t2.start();
}
}
3️⃣实现Callable接口
public class MyCallable implements Callable<String> {
@Override
public String call() throws Exception {
System.out.println(Thread.currentThread().getName());
return "ok";
}
public static void main(String[] args) throws ExecutionException, InterruptedException {
// 创建MyCallable对象
MyCallable mc = new MyCallable() ;
// 创建FutureTask
FutureTask<String> ft = new FutureTask<String>(mc) ;
// 创建Thread对象
Thread t1 = new Thread(ft) ;
Thread t2 = new Thread(ft) ;
// 调用start方法启动线程
t1.start();
// 调用ft的get方法获取执行结果
String result = ft.get();
// 输出
System.out.println(result);
}
}
4️⃣线程池创建线程
public class MyExecutors implements Runnable{
@Override
public void run() {
System.out.println("MyRunnable...run...");
}
public static void main(String[] args) {
// 创建线程池对象
ExecutorService threadPool = Executors.newFixedThreadPool(3);
threadPool.submit(new MyExecutors()) ;
// 关闭线程池
threadPool.shutdown();
}
}
04 runnable 和 callable 有什么区别?
参考回答:
1.Runnable 接口run方法没有返回值
2.Callable接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果
3.Callable接口的call()方法允许抛出异常;而Runnable接口的run()方法的异常只能在内部消化,不能继续上抛
05 线程的 run()和 start()有什么区别?
start(): 用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。
run(): 封装了要被线程执行的代码,可以被调用多次。
总结:
1.创建线程的方式有哪些?
继承Thread类
实现runnable接口
实现Callable接口
线程池创建线程(项目中使用方式)
2.runnable 和 callable 有什么区别
Runnable 接口run方法没有返回值
Callable接口call方法有返回值,需要FutureTask获取结果
Callable接口的call()方法允许抛出异常;而Runnable接口的run()方法的异常只能在内部消化,不能继续上抛
3.run()和 start()有什么区别?
start(): 用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。
run(): 封装了要被线程执行的代码,可以被调用多次。
06 线程包括哪些状态,状态之间是如何变化的
线程的状态可以参考JDK中的Thread类中的枚举State
public enum State {
//尚未启动的线程的线程状态
NEW,
//可运行线程的线程状态。
RUNNABLE,
//线程阻塞等待监视器锁的线程状态。
BLOCKED,
//等待线程的线程状态
WAITING,
//具有指定等待时间的等待线程的线程状态
TIMED_WAITING,
//已终止线程的线程状态。线程已完成执行
TERMINATED;
}

private static void testWaiting() {
Thread t2 = new Thread(() -> {
synchronized (LOCK) {
logger1.debug("before waiting");
try {
LOCK.wait();
} catch (InterruptedException e) {
e.printStackTrace();
}
}
},"t2");
t2.start();
synchronized (LOCK) {
LOCK.notify();
}
}
private static void testTimedWaiting(){
Thread t1 = new Thread(() -> {
try {
Thread.sleep(500);
} catch (InterruptedException e) {
e.printStackTrace();
}
logger1.debug("running...");
},"t1");
t1.start();
}
private static void testNewRunnableTerminated() {
Thread t1 = new Thread(() -> {
logger1.debug("running...");
},"t1");
Thread t2 = new Thread(() -> {
logger1.debug("running...");
},"t2");
t1.start();
t2.start();
}
private static void testBlocked() {
Thread t1 = new Thread(() -> {
logger1.debug("before sync");
synchronized (LOCK) {
logger1.debug("in sync");
}
},"t1");
t1.start();
synchronized (LOCK) {
main.debug("running...");
}
}
总结:
1.线程包括哪些状态
新建(NEW)、可运行(RUNNABLE)、阻塞(BLOCKED)、等待( WAITING )、时间等待(TIMED_WALTING)、终止(TERMINATED)
2.线程状态之间是如何变化的
创建线程对象是新建状态
调用了start()方法转变为可执行状态
线程获取到了CPU的执行权,执行结束是终止状态
在可执行状态的过程中,如果没有获取CPU的执行权,可能会切换其他状态
- 如果没有获取锁(synchronized或lock)进入阻塞状态,获得锁再切换为可执行状态
- 如果线程调用了wait()方法进入等待状态,其他线程调用notify()唤醒后可切换为可执行状态
- 如果线程调用了sleep(50)方法,进入计时等待状态,到时间后可切换为可执行状态
07 新建 T1、T2、T3 三个线程,如何保证它们按顺序执行?
可以使用线程中的join方法解决
join() 等待线程运行结束
Thread t1 = new Thread(() -> {
System.out.println("t1");
});
Thread t2 = new Thread(() -> {
try {
t1.join();
// 加入线程t1,只有t1线程执行完毕以后,再次执行该线程
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t2");
});
Thread t3 = new Thread(() -> {
try {
t2.join();
// 加入线程t2,只有t2线程执行完毕以后,再次执行该线程
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("t3");
});
// 启动线程
t1.start();
t2.start();
t3.start();
小例子:
t.join()
阻塞调用此方法的线程进入timed_waiting
直到线程t执行完成后,此线程再继续执行
08 notify()和 notifyAll()有什么区别?
notifyAll:唤醒所有wait的线程
notify:只随机唤醒一个 wait 线程
09 java中wait和sleep方法的不同?
共同点
wait() ,wait(long) 和 sleep(long) 的效果都是让当前线程暂时放弃 CPU 的使用权,进入阻塞状态
不同点
1.方法归属不同
sleep(long) 是 Thread 的静态方法
而 wait(),wait(long) 都是 Object 的成员方法,每个对象都有
2.醒来时机不同
执行 sleep(long) 和 wait(long) 的线程都会在等待相应毫秒后醒来
wait(long) 和 wait() 还可以被 notify 唤醒,wait() 如果不唤醒就一直等下去
它们都可以被打断唤醒
3.锁特性不同(重点)
wait 方法的调用必须先获取 wait 对象的锁,而 sleep 则无此限制
wait 方法执行后会释放对象锁,允许其它线程获得该对象锁(我放弃 cpu,但你们还可以用)
而 sleep 如果在 synchronized 代码块中执行,并不会释放对象锁(我放弃 cpu,你们也用不了)
10 如何停止一个正在运行的线程?
有三种方式可以停止线程
使用退出标志,使线程正常退出,也就是当run方法完成后线程终止
使用stop方法强行终止(不推荐,方法已作废)
使用interrupt方法中断线程
- 打断阻塞的线程( sleep,wait,join )的线程,线程会抛出InterruptedException异常
- 打断正常的线程,可以根据打断状态来标记是否退出线程
八、多线程(线程中并发安全)
01 synchronized关键字的底层原理
Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其它线程再想获取这个【对象锁】时就会阻塞住
public class TicketDemo {
static Object lock = new Object();
int ticketNum = 10;
public void getTicket() {
synchronized (lock){
if (ticketNum <= 0) {
return;
}
System.out.println(Thread.currentThread().getName() + "抢到一张票,剩余:" + ticketNum);
// 非原子性操作
ticketNum--;
}
}
public static void main(String[] args) {
TicketDemo ticketDemo = new TicketDemo();
for (int i = 0; i < 20; i++) {
new Thread(() -> {
ticketDemo.getTicket();
}).start();
}
}
}


Owner:存储当前获取锁的线程的,只能有一个线程可以获取
EntryList:关联没有抢到锁的线程,处于Blocked状态的线程
WaitSet:关联调用了wait方法的线程,处于Waiting状态的线程
总结:
synchronized关键字的底层原理
Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】
它的底层由monitor实现的,monitor是jvm级别的对象( C++实现),线程获得锁需要使用对象(锁)关联monitor
在monitor内部有三个属性,分别是owner、entrylist、waitset
其中owner是关联的获得锁的线程,并且只能关联一个线程;entrylist关联的是处于阻塞状态的线程;waitset关联的是处于Waiting状态的线程
synchronized关键字的底层原理-进阶
Monitor实现的锁属于重量级锁,你了解过锁升级吗?
Monitor实现的锁属于重量级锁,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。
在JDK 1.6引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。

对象的内存结构
在HotSpot虚拟机中,对象在内存中存储的布局可分为3块区域:对象头(Header)、实例数据(Instance Data)和对齐填充

MarkWord
| Mark Word (32 bits) | state | |||
|---|---|---|---|---|
| hashcode : 25 | age : 4 | biased_lock : 0 | 01 (lock标识,占2位) | 无锁 |
| thread : 23 epoch : 2 | age : 4 | biased_lock : 1 | 01 | 偏向锁 |
| ptr_to_lock_record : 30 | 00 | 轻量级锁 | ||
| ptr_to_heavyweight_monitor : 30 | 10 | 重量级锁 | ||
| 11 | 标记为GC | |||
hashcode:25位的对象标识Hash码
age:对象分代年龄占4位
biased_lock:偏向锁标识,占1位 ,0表示没有开始偏向锁,1表示开启了偏向锁
thread:持有偏向锁的线程ID,占23位
epoch:偏向时间戳,占2位
ptr_to_lock_record:轻量级锁状态下,指向栈中锁记录的指针,占30位
ptr_to_heavyweight_monitor:重量级锁状态下,指向对象监视器Monitor的指针,占30位
Monitor重量级锁
每个 Java 对象都可以关联一个 Monitor 对象,如果使用 synchronized 给对象上锁(重量级)之后,该对象头的Mark Word 中就被设置指向 Monitor 对象的指针

轻量级锁
在很多的情况下,在Java程序运行时,同步块中的代码都是不存在竞争的,不同的线程交替的执行同步块中的代码。这种情况下,用重量级锁是没必要的。因此JVM引入了轻量级锁的概念。
static final Object obj = new Object();
public static void method1() {
synchronized( obj ) {
// 同步块 A
method2();
}
}
public static void method2() {
synchronized( obj ) {
// 同步块 B
}
}

加锁流程
1.在线程栈中创建一个Lock Record,将其obj字段指向锁对象。
2.通过CAS指令将Lock Record的地址存储在对象头的mark word中,如果对象处于无锁状态则修改成功,代表该线程获得了轻量级锁。
3.如果是当前线程已经持有该锁了,代表这是一次锁重入。设置Lock Record第一部分为null,起到了一个重入计数器的作用。
4.如果CAS修改失败,说明发生了竞争,需要膨胀为重量级锁。
解锁过程
1.遍历线程栈,找到所有obj字段等于当前锁对象的Lock Record。
2.如果Lock Record的Mark Word为null,代表这是一次重入,将obj设置为null后continue。
3.如果Lock Record的 Mark Word不为null,则利用CAS指令将对象头的mark word恢复成为无锁状态。如果失败则膨胀为重量级锁。
偏向锁
轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行 CAS 操作。
Java 6 中引入了偏向锁来做进一步优化:只有第一次使用 CAS 将线程 ID 设置到对象的 Mark Word 头,之后发现这个线程 ID 是自己的就表示没有竞争,不用重新 CAS。以后只要不发生竞争,这个对象就归该线程所有
static final Object obj = new Object();
public static void m1 () {
synchronized (obj) {
// 同步块 A
m2();
}
}
public static void m2 () {
synchronized (obj) {
// 同步块 B
m3();
}
}
public static void m3 () {
synchronized (obj) {
}
}

Monitor实现的锁属于重量级锁,你了解过锁升级吗?
Java中的synchronized有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。
| 描述 | |
|---|---|
| 重量级锁 | 底层使用的Monitor实现,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低。 |
| 轻量级锁 | 线程加锁的时间是错开的(也就是没有竞争),可以使用轻量级锁来优化。轻量级修改了对象头的锁标志,相对重量级锁性能提升很多。每次修改都是CAS操作,保证原子性 |
| 偏向锁 | 一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁,在第一次获得锁时,会有一个CAS操作,之后该线程再获取锁,只需要判断mark word中是否是自己的线程id即可,而不是开销相对较大的CAS命令 |
一旦锁发生了竞争,都会升级为重量级锁
02 你谈谈 JMM(Java内存模型)
JMM(Java Memory Model)Java内存模型,定义了共享内存中多线程程序读写操作的行为规范,通过这些规则来规范对内存的读写操作从而保证指令的正确性

总结:

03 CAS 你知道吗?
CAS的全称是: Compare And Swap(比较再交换),它体现的一种乐观锁的思想,在无锁情况下保证线程操作共享数据的原子性。
在JUC( java.util.concurrent )包下实现的很多类都用到了CAS操作
AbstractQueuedSynchronizer(AQS框架)
AtomicXXX类
CAS数据交换流程


乐观锁和悲观锁
CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
总结:
CAS 你知道吗?
CAS的全称是: Compare And Swap(比较再交换);它体现的一种乐观锁的思想,在无锁状态下保证线程操作数据的原子性。
CAS使用到的地方很多:AQS框架、AtomicXXX类
在操作共享变量的时候使用的自旋锁,效率上更高一些
CAS的底层是调用的Unsafe类中的方法,都是操作系统提供的,其他语言实现
乐观锁和悲观锁的区别
CAS 是基于乐观锁的思想:最乐观的估计,不怕别的线程来修改共享变量,就算改了也没关系,我吃亏点再重试呗。
synchronized 是基于悲观锁的思想:最悲观的估计,得防着其它线程来修改共享变量,我上了锁你们都别想改,我改完了解开锁,你们才有机会。
04 请谈谈你对 volatile 的理解
一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:
① 保证线程间的可见性
② 禁止进行指令重排序
保证线程间的可见性
用 volatile 修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见
static boolean stop = false;
public static void main(String[] args) {
new Thread(() -> {
try {
Thread.sleep(100);
} catch (InterruptedException e) {
e.printStackTrace();
}
stop = true;
System.out.println(Thread.currentThread().getName()+":modify stop to true...");
},"t1").start();
new Thread(() -> {
try {
Thread.sleep(200);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println(Thread.currentThread().getName()+":"+stop);
},"t2").start();
new Thread(() -> {
int i = 0;
while (!stop) {
i++;
}
System.out.println("stopped... c:"+ i);
},"t3").start();
}
问题分析:主要是因为在JVM虚拟机中有一个JIT(即时编译器)给代码做了优化。

解决方案一:在程序运行的时候加入vm参数-Xint表示禁用即时编译器,不推荐,得不偿失(其他程序还要使用)
解决方案二:在修饰stop变量的时候加上volatile,当前告诉 jit,不要对 volatile 修饰的变量做优化
volatile禁止指令重排序
用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果
注解@Actor保证方法内的代码在同一个线程下执行

在变量上添加volatile,禁止指令重排序,则可以解决问题


volatile使用技巧:
写变量让volatile修饰的变量的在代码最后位置
读变量让volatile修饰的变量的在代码最开始位置
总结:
请谈谈你对 volatile 的理解?
①保证线程间的可见性
用 volatile 修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见
② 禁止进行指令重排序
指令重排:用 volatile 修饰共享变量会在读、写共享变量时加入不同的屏障,阻止其他读写操作越过屏障,从而达到阻止重排序的效果
05 什么是AQS?
全称是 AbstractQueuedSynchronizer,即抽象队列同步器。它是构建锁或者其他同步组件的基础框架
AQS与Synchronized的区别
| synchronized | AQS |
|---|---|
| 关键字,c++ 语言实现 | java 语言实现 |
| 悲观锁,自动释放锁 | 悲观锁,手动开启和关闭 |
| 锁竞争激烈都是重量级锁,性能差 | 锁竞争激烈的情况下,提供了多种解决方案 |
AQS常见的实现类
ReentrantLock 阻塞式锁
Semaphore 信号量
CountDownLatch 倒计时锁
AQS-基本工作机制


多个线程共同去抢这个资源是如何保证原子性的呢?
cas设置 state 状态,保证操作的原子性

AQS是公平锁吗,还是非公平锁?
新的线程与队列中的线程共同来抢资源,是非公平锁
新的线程到队列中等待,只让队列中的head线程获取锁,是公平锁
总结:

06 ReentrantLock的实现原理
ReentrantLock翻译过来是可重入锁,相对于synchronized它具备以下特点:
可中断
可以设置超时时间
可以设置公平锁
支持多个条件变量
与synchronized一样,都支持重入
//创建锁对象
ReentrantLock lock = new ReentrantLock();
try {
// 获取锁
lock.lock();
} finally {
// 释放锁
lock.unlock();
}
ReentrantLock主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁,两者的实现类似
构造方法接受一个可选的公平参数(默认非公平锁),当设置为true时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。
查看ReentrantLock源码中的构造方法:

ReentrantLock的实现原理

线程来抢锁后使用cas的方式修改state状态,修改状态成功为1,则让exclusiveOwnerThread属性指向当前线程,获取锁成功
假如修改状态失败,则会进入双向队列中等待,head指向双向队列头部,tail指向双向队列尾部
当exclusiveOwnerThread为null的时候,则会唤醒在双向队列中等待的线程
公平锁则体现在按照先后顺序获取锁,非公平体现在不在排队的线程也可以抢锁
总结:

07 synchronized和Lock有什么区别 ?
- 语法层面
synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现
Lock 是接口,源码由 jdk 提供,用 java 语言实现
使用 synchronized 时,退出同步代码块锁会自动释放,而使用 Lock 时,需要手动调用 unlock 方法释放锁
- 功能层面
二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能
Lock 提供了许多 synchronized 不具备的功能,例如公平锁、可打断、可超时、多条件变量
Lock 有适合不同场景的实现,如 ReentrantLock, ReentrantReadWriteLock(读写锁)
- 性能层面
在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖
在竞争激烈时,Lock 的实现通常会提供更好的性能
08 死锁产生的条件是什么?
死锁:一个线程需要同时获取多把锁,这时就容易发生死锁
Object A = new Object();
Object B = new Object();
Thread t1 = new Thread(() -> {
synchronized (A) {
System.out.println("lock A");
try {
sleep(1000);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (B) {
System.out.println("lock B");
System.out.println("操作...");
}
}
}, "t1");
Thread t2 = new Thread(() -> {
synchronized (B) {
System.out.println("lock B");
try {
sleep(500);
} catch (InterruptedException e) {
throw new RuntimeException(e);
}
synchronized (A) {
System.out.println("lock A");
System.out.println("操作...");
}
}
}, "t2");
t1.start();
t2.start();
此时程序并没有结束,这种现象就是死锁现象...线程t1持有A的锁等待获取B锁,线程t2持有B的锁等待获取A的锁。

如何进行死锁诊断?
当程序出现了死锁现象,我们可以使用jdk自带的工具:jps和 jstack
jps:输出JVM中运行的进程状态信息
jstack:查看java进程内线程的堆栈信息
解决步骤如下
第一:查看运行的线程

第二,使用jstack查看线程运行的情况,下图是截图的关键信息
运行命令:jstack -l 46032

其他解决工具,可视化工具
- jconsole
用于对jvm的内存,线程,类 的监控,是一个基于 jmx 的 GUI 性能监控工具
打开方式:java 安装目录 bin目录下 直接启动 jconsole.exe 就行
- VisualVM:故障处理工具
能够监控线程,内存情况,查看方法的CPU时间和内存中的对 象,已被GC的对象,反向查看分配的堆栈
打开方式:java 安装目录 bin目录下 直接启动 jvisualvm.exe就行
总结:
1.死锁产生的条件是什么?
一个线程需要同时获取多把锁,这时就容易发生死锁
2.如何进行死锁诊断?
当程序出现了死锁现象,我们可以使用jdk自带的工具:jps和 jstack
jps:输出JVM中运行的进程状态信息
jstack:查看java进程内线程的堆栈信息,查看日志,检查是否有死锁。如果有死锁现象,需要查看具体代码分析后,可修复
可视化工具jconsole、VisualVM也可以检查死锁问题
09 聊一下ConcurrentHashMap
ConcurrentHashMap 是一种线程安全的高效Map集合
底层数据结构:
JDK1.7底层采用分段的数组+链表实现
JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。
JDK1.7中ConcurrentHashMap


JDK1.8中ConcurrentHashMap
在JDK1.8中,放弃了Segment臃肿的设计,数据结构跟HashMap的数据结构是一样的:数组+红黑树+链表
采用 CAS + Synchronized来保证并发安全进行实现
CAS控制数组节点的添加
synchronized只锁定当前链表或红黑二叉树的首节点,只要hash不冲突,就不会产生并发的问题 , 效率得到提升

总结:
聊一下ConcurrentHashMap
- 底层数据结构:
JDK1.7底层采用分段的数组+链表实现
JDK1.8 采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树
- 加锁的方式
JDK1.7采用Segment分段锁,底层使用的是ReentrantLock
JDK1.8采用CAS添加新节点,采用synchronized锁定链表或红黑二叉树的首节点,相对Segment分段锁粒度更细,性能更好
10 导致并发程序出现问题的根本原因是什么(Java程序中怎么保证多线程的执行安全)
Java并发编程三大特性
原子性
可见性
有序性
原子性:一个线程在CPU中操作不可暂停,也不可中断,要不执行完成,要不不执行
int ticketNum = 10;
public void getTicket(){
if(ticketNum <= 0){
return ;
}
System.out.println(Thread.currentThread().getName()+"抢到一张票,剩余:"+ticketNum);
// 非原子性操作
ticketNum--;
}
public static void main(String[] args) {
TicketDemo demo = new TicketDemo();
for(int i=0;i<20;i++){
new Thread(demo::getTicket).start();
}
}
不是原子操作,怎么保证原子操作呢?
1.synchronized:同步加锁
2.JUC里面的lock:加锁
int ticketNum = 10;
public synchronized void getTicket(){
if(ticketNum <= 0){
return ;
}
System.out.println(Thread.currentThread().getName()+"抢到一张票,剩余:"+ticketNum);
// 非原子性操作
ticketNum--;
}
public static void main(String[] args) {
TicketDemo demo = new TicketDemo();
for(int i=0;i<20;i++){
new Thread(demo::getTicket).start();
}
}
内存可见性:让一个线程对共享变量的修改对另一个线程可见

有序性
指令重排:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的

总结:
导致并发程序出现问题的根本原因是什么?
1.原子性 synchronized、lock
2.内存可见性 volatile、synchronized、lock
3.有序性 volatile
八、多线程(线程池)
01 说一下线程池的核心参数(线程池的执行原理知道嘛)
public ThreadPoolExecutor(int corePoolSize, // 核心线程数
int maximumPoolSize, // 最大线程数
long keepAliveTime, // 存活时间
TimeUnit unit, // 时间单位
BlockingQueue<Runnable> workQueue, // 阻塞队列
ThreadFactory threadFactory, // 线程工厂
RejectedExecutionHandler handler) // 拒绝策略
corePoolSize 核心线程数目
maximumPoolSize 最大线程数目 = (核心线程+救急线程的最大数目)
keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放
unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等
workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
threadFactory 线程工厂 - 可以定制线程对象的创建,例如设置线程名字、是否是守护线程等
handler 拒绝策略 - 当所有线程都在繁忙,workQueue 也放满时,会触发拒绝策略
线程池的执行原理知道嘛

02 线程池中有哪些常见的阻塞队列
workQueue - 当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务
1.ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO。
2.LinkedBlockingQueue:基于链表结构的有界阻塞队列,FIFO。
3.DelayedWorkQueue :是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的
4.SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。
线程池中有哪些常见的阻塞队列?
ArrayBlockingQueue的LinkedBlockingQueue区别
| LinkedBlockingQueue | ArrayBlockingQueue |
|---|---|
| 默认无界,支持有界 | 强制有界 |
| 底层是链表 | 底层是数组 |
| 是懒惰的,创建节点的时候添加数据 | 提前初始化 Node 数组 |
| 入队会生成新 Node | Node需要是提前创建好的 |
| 两把锁(头尾) | 一把锁 |

03 如何确定核心线程数
如下代码可以查看机器的CPU核数
public static void main(String[] args) { //查看机器的CPU核数 System.out.println(Runtime.getRuntime().availableProcessors()); }
IO密集型任务:文件读写、DB读写、网络请求等,核心线程数大小设置为2N+1
CPU密集型任务:计算型代码、Bitmap转换、Gson转换等,核心线程数大小设置为N+1
参考回答:
① 高并发、任务执行时间短 ➡️(U核数+1),减少线程上下文的切换
② 并发不高、任务执行时间长
IO密集型的任务 ➡️ (CPU核数 * 2 + 1)
计算密集型任务 ➡️(CPU核数+1)
③ 并发高、业务执行时间长,解决这种类型任务的关键不在于线程池而在于整体架构的设计,看看这些业务里面某些数据是否能做缓存是第一步,增加服务器是第二步,然后是线程池的设置。
04 线程池的种类有哪些
在java.util.concurrent.Executors类中提供了大量创建连接池的静态方法,常见就有四种
1.创建使用固定线程数的线程池
public static ExecutorService newFixedThreadPool(int nThreads) {
return new ThreadPoolExecutor(nThreads, nThreads,
0L, TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
核心线程数与最大线程数一样,没有救急线程
阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE
适用于任务量已知,相对耗时的任务
2.单线程化的线程池,它只会用唯一的工作线程来执行任 务,保证所有任务按照指定顺序(FIFO)执行
public static ExecutorService newSingleThreadExecutor() {
return new FinalizableDelegatedExecutorService(
new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS,new LinkedBlockingQueue<Runnable>())
);
}
核心线程数和最大线程数都是1
阻塞队列是LinkedBlockingQueue,最大容量为Integer.MAX_VALUE
适用于按照顺序执行的任务
3.可缓存线程池
public static ExecutorService newCachedThreadPool() {
return new ThreadPoolExecutor(0, Integer.MAX_VALUE,
60L, TimeUnit.SECONDS,
new SynchronousQueue<Runnable>());
}
核心线程数为0
最大线程数是Integer.MAX_VALUE
阻塞队列为SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。
适合任务数比较密集,但每个任务执行时间较短的情况
4.提供了“延迟”和“周期执行”功能的ThreadPoolExecutor。
public ScheduledThreadPoolExecutor(int corePoolSize) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS,new DelayedWorkQueue());
}
public ScheduledThreadPoolExecutor(int corePoolSize,ThreadFactory threadFactory) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue(), threadFactory);
}
public ScheduledThreadPoolExecutor(int corePoolSize,RejectedExecutionHandler handler) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue(), handler);
}
public ScheduledThreadPoolExecutor(int corePoolSize,
ThreadFactory threadFactory,
RejectedExecutionHandler handler) {
super(corePoolSize, Integer.MAX_VALUE, 0, NANOSECONDS, new DelayedWorkQueue(), threadFactory, handler);
}
总结:
线程池的种类有哪些
①newFixedThreadPool:创建一个定长线程池,可控制线程最大并发数,超出的线程会在队列中等待
②newSingleThreadExecutor:创建一个单线程化的线程池,它只会用唯一的工作线程来执行任 务,保证所有任务按照指定顺序(FIFO)执行
③newCachedThreadPool:创建一个可缓存线程池,如果线程池长度超过处理需要,可灵活回收空闲线程,若无可回收,则新建线程
④newScheduledThreadPool:可以执行延迟任务的线程池,支持定时及周期性任务执行
05 为什么不建议用Executors创建线程池
参考阿里开发手册《Java开发手册-嵩山版》

八、多线程(使用场景)
01 线程池使用场景(CountDownLatch、Future)(你们项目哪里用到了多线程)
CountDownLatch
CountDownLatch(闭锁/倒计时锁)用来进行线程同步协作,等待所有线程完成倒计时(一个或者多个线程,等待其他多个线程完成某件事情之后才能执行)
其中构造参数用来初始化等待计数值
await() 用来等待计数归零
countDown() 用来让计数减一

多线程使用场景一( es数据批量导入)
在我们项目上线之前,我们需要把数据库中的数据一次性的同步到es索引库中,但是当时的数据好像是1000万左右,一次性读取数据肯定不行(oom异常),当时我就想到可以使用线程池的方式导入,利用CountDownLatch来控制,就能避免一次性加载过多,防止内存溢出


在一个电商网站中,用户下单之后,需要查询数据,数据包含了三部分:订单信息、包含的商品、物流信息;这三块信息都在不同的微服务中进行实现的,我们如何完成这个业务呢?

多线程使用场景二(数据汇总)
在实际开发的过程中,难免需要调用多个接口来汇总数据,如果所有接口(或部分接口)的没有依赖关系,就可以使用线程池+future来提升性能
报表汇总

多线程使用场景三(异步调用)

总结:
你们项目哪里用到了多线程?
批量导入:使用了线程池+CountDownLatch批量把数据库中的数据导入到了ES(任意)中,避免OOM
数据汇总:调用多个接口来汇总数据,如果所有接口(或部分接口)的没有依赖关系,就可以使用线程池+future来提升性能
异步线程(线程池):为了避免下一级方法影响上一级方法(性能考虑),可使用异步线程调用下一个方法(不需要下一级方法返回值),可以提升方法响应时间
02 如何控制某个方法允许并发访问线程的数量
Semaphore [ˈsɛməˌfɔr] 信号量,是JUC包下的一个工具类,底层是AQS,我们可以通过其限制执行的线程数量
使用场景:
通常用于那些资源有明确访问数量限制的场景,常用于限流 。

Semaphore使用步骤
创建Semaphore对象,可以给一个容量
semaphore.acquire(): 请求一个信号量,这时候的信号量个数-1(一旦没有可使用的信号量,也即信号量个数变为负数时,再次请求的时候就会阻塞,直到其他线程释放了信号量)
semaphore.release():释放一个信号量,此时信号量个数+1
// 1. 创建 semaphore 对象
Semaphore semaphore = new Semaphore(3);
// 2. 10个线程同时运行
for (int i = 0; i < 10; i++) {
new Thread(() -> {
try {
// 3. 获取许可
semaphore.acquire();
} catch (InterruptedException e) {
e.printStackTrace();
}
try {
System.out.println("running...");
try {
Thread.sleep(1000);
} catch (InterruptedException e) {
e.printStackTrace();
}
System.out.println("end...");
} finally {
// 4. 释放许可
semaphore.release();
}
}).start();
}
总结:
如何控制某个方法允许并发访问线程的数量?
在多线程中提供了一个工具类Semaphore,信号量。在并发的情况下,可以控制方法的访问量
1.创建Semaphore对象,可以给一个容量
2.acquire()可以请求一个信号量,这时候的信号量个数-1
3.release()释放一个信号量,此时信号量个数+1
03 谈谈你对ThreadLocal的理解
ThreadLocal概述
ThreadLocal是多线程中对于解决线程安全的一个操作类,它会为每个线程都分配一个独立的线程副本从而解决了变量并发访问冲突的问题。ThreadLocal 同时实现了线程内的资源共享
案例:使用JDBC操作数据库时,会将每一个线程的Connection放入各自的ThreadLocal中,从而保证每个线程都在各自的 Connection 上进行数据库的操作,避免A线程关闭了B线程的连接。

ThreadLocal基本使用
set(value) 设置值
get() 获取值
remove() 清除值
static ThreadLocal<String> threadLocal = new ThreadLocal<>();
public static void main(String[] args) {
new Thread(() -> {
String name = Thread.currentThread().getName();
threadLocal.set("itcast");
print(name);
System.out.println(name + "-after remove : " + threadLocal.get());
}, "t1").start();
new Thread(() -> {
String name = Thread.currentThread().getName();
threadLocal.set("itheima");
print(name);
System.out.println(name + "-after remove : " + threadLocal.get());
}, "t2").start();
}
static void print(String str) {
//打印当前线程中本地内存中本地变量的值
System.out.println(str + " :" + threadLocal.get());
//清除本地内存中的本地变量
threadLocal.remove();
}
ThreadLocal的实现原理&源码解析
ThreadLocal本质来说就是一个线程内部存储类,从而让多个线程只操作自己内部的值,从而实现线程数据隔离

set方法:

get方法/remove方法

面试官:你对ThreadLocal理解的挺深的,你知道ThreadLocal的内存泄露问题吗?
Java对象中的四种引用类型:强引用、软引用、弱引用、虚引用
l强引用:最为普通的引用方式,表示一个对象处于有用且必须的状态,如果一个对象具有强引用,则GC并不会回收它。即便堆中内存不足了,宁可出现OOM,也不会对其进行回收
User user = new User();
l弱引用:表示一个对象处于可能有用且非必须的状态。在GC线程扫描内存区域时,一旦发现弱引用,就会回收到弱引用相关联的对象。对于弱引用的回收,无关内存区域是否足够,一旦发现则会被回收
User user = new User();
WeakReference weakReference = new WeakReference(user);
每一个Thread维护一个ThreadLocalMap,在ThreadLocalMap中的Entry对象继承了WeakReference。其中key为使用弱引用的ThreadLocal实例,value为线程变量的副本

总结:
谈谈你对ThreadLocal的理解?
1.ThreadLocal 可以实现【资源对象】的线程隔离,让每个线程各用各的【资源对象】,避免争用引发的线程安全问题
2.ThreadLocal 同时实现了线程内的资源共享
3.每个线程内有一个 ThreadLocalMap 类型的成员变量,用来存储资源对象
调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线
程的 ThreadLocalMap 集合中
调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值
调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值
4.ThreadLocal内存泄漏问题
ThreadLocalMap 中的 key 是弱引用,值为强引用; key 会被GC 释放内存,关联 value 的内存并不会释放。建议主动 remove 释放 key,value
九、JVM相关面试题

JVM是什么
Java Virtual Machine Java程序的运行环境(java二进制字节码的运行环境)
好处:
一次编写,到处运行
自动内存管理,垃圾回收机制

JVM由哪些部分组成,运行流程是什么?

学习什么
JVM组成
类加载器
垃圾回收
JVM实践
九、JVM(JVM组成)
01 什么是程序计数器?
程序计数器:线程私有的,内部保存的字节码的行号。用于记录正在执行的字节码指令的地址。


总结:
什么是程序计数器?
线程私有的,每个线程一份,内部保存的字节码的行号。用于记录正在执行的字节码指令的地址。
02 你能给我详细的介绍Java堆吗?
线程共享的区域:主要用来保存对象实例,数组等,当堆中没有内存空间可分配给实例,也无法再扩展时,则抛出OutOfMemoryError异常。

Java7和Java8内存结构的区别

总结:
你能给我详细的介绍Java堆吗?
线程共享的区域:主要用来保存对象实例,数组等,内存不够则抛出OutOfMemoryError异常。
组成:年轻代+老年代
年轻代被划分为三部分,Eden区和两个大小严格相同的Survivor区
老年代主要保存生命周期长的对象,一般是一些老的对象
Jdk1.7和1.8的区别
1.7中有有一个永久代,存储的是类信息、静态变量、常量、编译后的代码
1.8移除了永久代,把数据存储到了本地内存的元空间中,防止内存溢出
03 什么是虚拟机栈
Java Virtual machine Stacks (java 虚拟机栈)
每个线程运行时所需要的内存,称为虚拟机栈,先进后出
每个栈由多个栈帧(frame)组成,对应着每次方法调用时所占用的内存
每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法

1.垃圾回收是否涉及栈内存?
垃圾回收主要指就是堆内存,当栈帧弹栈以后,内存就会释放
2.栈内存分配越大越好吗?
未必,默认的栈内存通常为1024k。栈帧过大会导致线程数变少,例如,机器总内存为512m,目前能活动的线程数则为512个,如果把栈内存改为2048k,那么能活动的栈帧就会减半
3.方法内的局部变量是否线程安全?
如果方法内局部变量没有逃离方法的作用范围,它是线程安全的
如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全

栈内存溢出情况
栈帧过多导致栈内存溢出,典型问题:递归调用
栈帧过大导致栈内存溢出

总结:
1.什么是虚拟机栈
每个线程运行时所需要的内存,称为虚拟机栈
每个栈由多个栈帧(frame)组成,对应着每次方法调用时所占用的内存
每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
2.垃圾回收是否涉及栈内存?
垃圾回收主要指就是堆内存,当栈帧弹栈以后,内存就会释放
3.栈内存分配越大越好吗?
未必,默认的栈内存通常为1024k,栈帧过大会导致线程数变少
4.方法内的局部变量是否线程安全?
如果方法内局部变量没有逃离方法的作用范围,它是线程安全的
如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全
5.什么情况下会导致栈内存溢出?
栈帧过多导致栈内存溢出,典型问题:递归调用
栈帧过大导致栈内存溢出
6.堆栈的区别是什么?
栈内存一般会用来存储局部变量和方法调用,但堆内存是用来存储Java对象和数组的的。堆会GC垃圾回收,而栈不会。
栈内存是线程私有的,而堆内存是线程共有的。
两者异常错误不同,但如果栈内存或者堆内存不足都会抛出异常。
- 栈空间不足:java.lang.StackOverFlowError。
- 堆空间不足:java.lang.OutOfMemoryError。
04 能不能解释一下方法区?
方法区(Method Area)是各个线程共享的内存区域
主要存储类的信息、运行时常量池
虚拟机启动的时候创建,关闭虚拟机时释放
如果方法区域中的内存无法满足分配请求,则会抛出OutOfMemoryError: Metaspace

常量池
可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息

运行时常量池
常量池是*.class文件中的,当该类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址

1.能不能解释一下方法区?
方法区(Method Area)是各个线程共享的内存区域
主要存储类的信息、运行时常量池
虚拟机启动的时候创建,关闭虚拟机时释放
如果方法区域中的内存无法满足分配请求,则会抛出OutOfMemoryError: Metaspace
2.介绍一下运行时常量池
常量池:可以看作是一张表,虚拟机指令根据这张常量表找到要执行的类名、方法名、参数类型、字面量等信息
当类被加载,它的常量池信息就会放入运行时常量池,并把里面的符号地址变为真实地址
05 你听过直接内存吗?
直接内存:并不属于JVM中的内存结构,不由JVM进行管理。是虚拟机的系统内存,常见于 NIO 操作时,用于数据缓冲区,它分配回收成本较高,但读写性能高
举例
Java代码完成文件拷贝

常规IO的数据拷贝流程:

NIO数据拷贝流程:

总结:
你听过直接内存吗?
并不属于JVM中的内存结构,不由JVM进行管理。是虚拟机的系统内存
常见于 NIO 操作时,用于数据缓冲区,分配回收成本较高,但读写性能高,不受 JVM 内存回收管理
九、JVM(类加载器)
01 什么是类加载器,类加载器有哪些
类加载器:用于装载字节码文件(.class文件)
运行时数据区:用于分配存储空间
执行引擎:执行字节码文件或本地方法
垃圾回收器:用于对JVM中的垃圾内容进行回收

类加载器
JVM只会运行二进制文件,类加载器的作用就是将字节码文件加载到JVM中,从而让Java程序能够启动起来。

1.什么是类加载器
JVM只会运行二进制文件,类加载器的作用就是将字节码文件加载到JVM中,从而让Java程序能够启动起来。
2.类加载器有哪些
启动类加载器(BootStrap ClassLoader):加载JAVA_HOME/jre/lib目录下的库
扩展类加载器(ExtClassLoader):主要加载JAVA_HOME/jre/lib/ext目录中的类
应用类加载器(AppClassLoader):用于加载classPath下的类
自定义类加载器(CustomizeClassLoader):自定义类继承ClassLoader,实现自定义类加载规则。
02 什么是双亲委派模型?
加载某一个类,先委托上一级的加载器进行加载,如果上级加载器也有上级,则会继续向上委托,如果该类委托上级没有被加载,子加载器尝试加载该类

JVM为什么采用双亲委派机制?
(1)通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性。
(2)为了安全,保证类库API不会被修改
由于是双亲委派的机制,java.lang.String的在启动类加载器得到加载,因为在核心jre库中有其相同名字的类文件,但该类中并没有main方法。这样就能防止恶意篡改核心API库。
package java.lang;
public class String {
public static void main(String[] args) {
System.out.println("demo info");
}
}
此时执行main函数,会出现异常,在类 java.lang.String 中找不到 main 方法

总结:
1.什么是双亲委派模型?
加载某一个类,先委托上一级的加载器进行加载,如果上级加载器也有上级,则会继续向上委托,如果该类委托上级没有被加载,子加载器尝试加载该类
2.JVM为什么采用双亲委派机制?
通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性。
为了安全,保证类库API不会被修改
03 说一下类装载的执行过程?
类从加载到虚拟机中开始,直到卸载为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用和卸载这7个阶段。其中,验证、准备和解析这三个部分统称为连接(linking)

加载

通过类的全名,获取类的二进制数据流。
解析类的二进制数据流为方法区内的数据结构(Java类模型)
创建java.lang.Class类的实例,表示该类型。作为方法区这个类的各种数据的访问入口

验证


准备

static变量,分配空间在准备阶段完成(设置默认值),赋值在初始化阶段完成
static变量是final的基本类型,以及字符串常量,值已确定,赋值在准备阶段完成
static变量是final的引用类型,那么赋值也会在初始化阶段完成
public class Application {
static int b = 10;
static final int c = 20;
static final String d = "hello";
static final Object obj = new Object();
}
解析

比如:方法中调用了其他方法,方法名可以理解为符号引用,而直接引用就是使用指针直接指向方法。

初始化

如果初始化一个类的时候,其父类尚未初始化,则优先初始化其父类。
如果同时包含多个静态变量和静态代码块,则按照自上而下的顺序依次执行。
使用

JVM 开始从入口方法开始执行用户的程序代码
调用静态类成员信息(比如:静态字段、静态方法)
使用new关键字为其创建对象实例
总结:
说一下类装载的执行过程?
加载:查找和导入class文件
验证:保证加载类的准确性
准备:为类变量分配内存并设置类变量初始值
解析:把类中的符号引用转换为直接引用
初始化:对类的静态变量,静态代码块执行初始化操作
使用:JVM 开始从入口方法开始执行用户的程序代码
卸载:当用户程序代码执行完毕后,JVM便开始销毁创建的Class对象。
九、JVM(垃圾回收)
01 对象什么时候可以被垃圾器回收

简单一句就是:如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾,如果定位了垃圾,则有可能会被垃圾回收器回收。
如果要定位什么是垃圾,有两种方式来确定,第一个是引用计数法,第二个是可达性分析算法
引用计数法
一个对象被引用了一次,在当前的对象头上递增一次引用次数,如果这个对象的引用次数为0,代表这个对象可回收

当对象间出现了循环引用的话,则引用计数法就会失效


可达性分析算法
现在的虚拟机采用的都是通过可达性分析算法来确定哪些内容是垃圾。

X,Y这两个节点是可回收的
Java 虚拟机中的垃圾回收器采用可达性分析来探索所有存活的对象
扫描堆中的对象,看是否能够沿着 GC Root 对象 为起点的引用链找到该对象,找不到,表示可以回收
哪些对象可以作为 GC Root ?
虚拟机栈(栈帧中的本地变量表)中引用的对象
方法区中类静态属性引用的对象
方法区中常量引用的对象
本地方法栈中 JNI(即一般说的 Native 方法)引用的对象
public static void main(String[] args) {
Demo demo = new Demo();
demo = null;
}
public static Demo a;
public static void main(String[] args) {
Demo b = new Demo();
b.a = new Demo();
b = null;
}
public static final Demo a = new Demo();
public static void main(String[] args) {
Demo demo = new Demo();
demo = null;
}
总结:
对象什么时候可以被垃圾器回收?
如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾,如果定位了垃圾,则有可能会被垃圾回收器回收。
定位垃圾的方式有两种
引用计数法
可达性分析算法
02 JVM 垃圾回收算法有哪些?
标记清除算法
复制算法
标记整理算法
标记清除算法
标记清除算法,是将垃圾回收分为2个阶段,分别是标记和清除。
1.根据可达性分析算法得出的垃圾进行标记
2.对这些标记为可回收的内容进行垃圾回收
优点:标记和清除速度较快
缺点:碎片化较为严重,内存不连贯的

标记整理算法
优缺点同标记清除算法,解决了标记清除算法的碎片化的问题,同时,标记压缩算法多了一步,对象移动内存位置的步骤,其效率也有有一定的影响。

复制算法
优点:① 在垃圾对象多的情况下,效率较高 ② 清理后,内存无碎片
缺点:分配的2块内存空间,在同一个时刻,只能使用一半,内存使用率较低

总结:
JVM 垃圾回收算法有哪些?
标记清除算法:垃圾回收分为2个阶段,分别是标记和清除,效率高,有磁盘碎片,内存不连续
标记整理算法:标记清除算法一样,将存活对象都向内存另一端移动,然后清理边界以外的垃圾,无碎片,对象需要移动,效率低
复制算法:将原有的内存空间一分为二,每次只用其中的一块,正在使用的对象复制到另一个内存空间中,然后将该内存空间清空,交换两个内存的角色,完成垃圾的回收;无碎片,内存使用率低
03 说一下JVM中的分代回收
在java8时,堆被分为了两份:新生代和老年代【1:2】

对于新生代,内部又被分为了三个区域。
伊甸园区Eden,新生的对象都分配到这里
幸存者区survivor (分成from和to)
Eden区,from区,to区【8:1:1】
分代收集算法-工作机制

新创建的对象,都会先分配到eden区
当伊甸园内存不足,标记伊甸园与 from(现阶段没有)的存活对象
将存活对象采用复制算法复制到 to 中,复制完毕后,伊甸园和 from 内存都得到释放
经过一段时间后伊甸园的内存又出现不足,标记eden区域to区存活的对象,将存活的对象复制到from区
当幸存区对象熬过几次回收(最多15次),晋升到老年代(幸存区内存不足或大对象会导致提前晋升)
MinorGC、 Mixed GC 、 FullGC的区别是什么
名词解释:STW(Stop-The-World):暂停所有应用程序线程,等待垃圾回收的完成

MinorGC【young GC】发生在新生代的垃圾回收,暂停时间短(STW)
Mixed GC 新生代 + 老年代部分区域的垃圾回收,G1 收集器特有
FullGC: 新生代 + 老年代完整垃圾回收,暂停时间长(STW),应尽力避免
总结:
说一下JVM中的分代回收?
一、堆的区域划分
堆被分为了两份:新生代和老年代【1:2】
对于新生代,内部又被分为了三个区域。Eden区,幸存者区survivor(分成from和to)【8:1:1】
二、对象回收分代回收策略
新创建的对象,都会先分配到eden区
当伊甸园内存不足,标记伊甸园与 from(现阶段没有)的存活对象
将存活对象采用复制算法复制到to中,复制完毕后,伊甸园和 from 内存都得到释放
经过一段时间后伊甸园的内存又出现不足,标记eden区域to区存活的对象,将其复制到from区
当幸存区对象熬过几次回收(最多15次),晋升到老年代(幸存区内存不足或大对象会提前晋升)
MinorGC、 Mixed GC 、 FullGC的区别是什么?
MinorGC【young GC】发生在新生代的垃圾回收,暂停时间短(STW)
Mixed GC 新生代 + 老年代部分区域的垃圾回收,G1 收集器特有
FullGC: 新生代 + 老年代完整垃圾回收,暂停时间长(STW),应尽力避免
04 说一下JVM有哪些垃圾回收器?
在jvm中,实现了多种垃圾收集器,包括:
串行垃圾收集器
并行垃圾收集器
CMS(并发)垃圾收集器
G1垃圾收集器
串行垃圾收集器
Serial和Serial Old串行垃圾收集器,是指使用单线程进行垃圾回收,堆内存较小,适合个人电脑
Serial 作用于新生代,采用复制算法
Serial Old 作用于老年代,采用标记-整理算法
垃圾回收时,只有一个线程在工作,并且java应用中的所有线程都要暂停(STW),等待垃圾回收的完成。

并行垃圾收集器
Parallel New和Parallel Old是一个并行垃圾回收器,JDK8默认使用此垃圾回收器
Parallel New作用于新生代,采用复制算法
Parallel Old作用于老年代,采用标记-整理算法
垃圾回收时,多个线程在工作,并且java应用中的所有线程都要暂停(STW),等待垃圾回收的完成。

CMS(并发)垃圾收集器
CMS全称 Concurrent Mark Sweep,是一款并发的、使用标记-清除算法的垃圾回收器,该回收器是针对老年代垃圾回收的,是一款以获取最短回收停顿时间为目标的收集器,停顿时间短,用户体验就好。其最大特点是在进行垃圾回收时,应用仍然能正常运行。

总结:
说一下JVM有哪些垃圾回收器?
在jvm中,实现了多种垃圾收集器,包括:
串行垃圾收集器:Serial GC、Serial Old GC
并行垃圾收集器:Parallel Old GC、ParNew GC
CMS(并发)垃圾收集器:CMS GC,作用在老年代
G1垃圾收集器,作用在新生代和老年代
05 详细聊一下G1垃圾回收器
应用于新生代和老年代,在JDK9之后默认使用G1
划分成多个区域,每个区域都可以充当 eden,survivor,old, humongous,其中 humongous 专为大对象准备
采用复制算法
响应时间与吞吐量兼顾
分成三个阶段:新生代回收、并发标记、混合收集
如果并发失败(即回收速度赶不上创建新对象速度),会触发 Full GC

Young Collection(年轻代垃圾回收)
初始时,所有区域都处于空闲状态
创建了一些对象,挑出一些空闲区域作为伊甸园区存储这些对象
当伊甸园需要垃圾回收时,挑出一个空闲区域作为幸存区,用复制算法复制存活对象,需要暂停用户线程
随着时间流逝,伊甸园的内存又有不足
将伊甸园以及之前幸存区中的存活对象,采用复制算法,复制到新的幸存区,其中较老对象晋升至老年代

Young Collection + Concurrent Mark (年轻代垃圾回收+并发标记)
当老年代占用内存超过阈值(默认是45%)后,触发并发标记,这时无需暂停用户线程
并发标记之后,会有重新标记阶段解决漏标问题,此时需要暂停用户线程。
这些都完成后就知道了老年代有哪些存活对象,随后进入混合收集阶段。此时不会对所有老年代区域进行回收,而是根据暂停时间目标优先回收价值高(存活对象少)的区域(这也是 Gabage First 名称的由来)。
Mixed Collection (混合垃圾回收)
混合收集阶段中,参与复制的有 eden、survivor、old
复制完成,内存得到释放。进入下一轮的新生代回收、并发标记、混合收集
总结:
详细聊一下G1垃圾回收器?
应用于新生代和老年代,在JDK9之后默认使用G1
划分成多个区域,每个区域都可以充当 eden,survivor,old, humongous,其中 humongous 专为大对象准备
采用复制算法
响应时间与吞吐量兼顾
分成三个阶段:新生代回收(stw)、并发标记(重新标记stw)、混合收集
如果并发失败(即回收速度赶不上创建新对象速度),会触发 Full GC
06 强引用、软引用、弱引用、虚引用的区别
- 强引用:只有所有 GC Roots 对象都不通过【强引用】引用该对象,该对象才能被垃圾回收

- 软引用:仅有软引用引用该对象时,在垃圾回收后,内存仍不足时会再次出发垃圾回收

- 弱引用:仅有弱引用引用该对象时,在垃圾回收时,无论内存是否充足,都会回收弱引用对象


- 虚引用:必须配合引用队列使用,被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存

总结:
强引用、软引用、弱引用、虚引用的区别?
强引用:只要所有 GC Roots 能找到,就不会被回收
软引用:需要配合SoftReference使用,当垃圾多次回收,内存依然不够的时候会回收软引用对象
弱引用:需要配合WeakReference使用,只要进行了垃圾回收,就会把弱引用对象回收
虚引用:必须配合引用队列使用,被引用对象回收时,会将虚引用入队,由 Reference Handler 线程调用虚引用相关方法释放直接内存
九、JVM(JVM实践)
01 JVM 调优的参数可以在哪里设置参数值
war包部署在tomcat中设置
jar包部署在启动参数设置
war包部署在tomcat中设置
修改TOMCAT_HOME/bin/catalina.sh文件

jar包部署在启动参数设置
通常在linux系统下直接加参数启动springboot项目
nohup java -Xms512m -Xmx1024m -jar xxxx.jar --spring.profiles.active=prod &
nohup: 用于在系统后台不挂断地运行命令,退出终端不会影响程序的运行
参数 & :让命令在后台执行,终端退出后命令仍旧执行。
总结:
JVM 调优的参数可以在哪里设置参数值
- war包部署在tomcat中设置
修改TOMCAT_HOME/bin/catalina.sh文件
- jar包部署在启动参数设置
java -Xms512m -Xmx1024m -jar xxxx.jar
02 JVM 调优的参数都有哪些?
对于JVM调优,主要就是调整年轻代、老年代、元空间的内存空间大小及使用的垃圾回收器类型。
https://www.oracle.com/java/technologies/javase/vmoptions-jsp.html
设置堆空间大小
虚拟机栈的设置
年轻代中Eden区和两个Survivor区的大小比例
年轻代晋升老年代阈值
设置垃圾回收收集器
设置堆空间大小
设置堆的初始大小和最大大小,为了防止垃圾收集器在初始大小、最大大小之间收缩堆而产生额外的时间,通常把最大、初始大小设置为相同的值。
不指定单位默认为字节
指定单位,按照指定的单位设置
-Xms:设置堆的初始化大小
-Xmx:设置堆的最大大小
-Xms:1024
-Xms:1024k
-Xms:1024m
-Xms:1g
堆空间设置多少合适?
最大大小的默认值是物理内存的1/4,初始大小是物理内存的1/64
堆太小,可能会频繁的导致年轻代和老年代的垃圾回收,会产生stw,暂停用户线程
堆内存大肯定是好的,存在风险,假如发生了fullgc,它会扫描整个堆空间,暂停用户线程的时间长
设置参考推荐:尽量大,也要考察一下当前计算机其他程序的内存使用情况
虚拟机栈的设置
虚拟机栈的设置:每个线程默认会开启1M的内存,用于存放栈帧、调用参数、局部变量等,但一般256K就够用。通常减少每个线程的堆栈,可以产生更多的线程,但这实际上还受限于操作系统。
-Xss 对每个线程stack大小的调整,-Xss128k
- 年轻代中Eden区和两个Survivor区的大小比例
设置年轻代中Eden区和两个Survivor区的大小比例。该值如果不设置,则默认比例为8:1:1。通过增大Eden区的大小,来减少YGC发生的次数,但有时我们发现,虽然次数减少了,但Eden区满的时候,由于占用的空间较大,导致释放缓慢,此时STW的时间较长,因此需要按照程序情况去调优。
-XXSurvivorRatio=8,表示年轻代中的分配比率:survivor:eden = 2:8
- 年轻代晋升老年代阈值
默认为15、取值范围0-15
-XX:MaxTenuringThreshold=threshold
- 设置垃圾回收收集器
通过增大吞吐量提高系统性能,可以通过设置并行垃圾回收收集器。
-XX:+UseParallelGC
-XX:+UseParallelOldGC
-XX:+UseG1GC
总结:
用的 JVM 调优的参数都有哪些?
设置堆空间大小
虚拟机栈的设置
年轻代中Eden区和两个Survivor区的大小比例
年轻代晋升老年代阈值
设置垃圾回收收集器
03 说一下 JVM 调优的工具?
命令工具
jps 进程状态信息
jstack 查看java进程内线程的堆栈信息
jmap 查看堆转信息
jhat 堆转储快照分析工具
jstat JVM统计监测工具
可视化工具
jconsole 用于对jvm的内存,线程,类 的监控
VisualVM 能够监控线程,内存情况
jps:进程状态信息

jstack:查看java进程内线程的堆栈信息
jstack [option] <pid>

jmap:用于生成堆转内存快照、内存使用情况
它是一个进程或系统在某一给定的时间的快照。比如在进程崩溃时,甚至是任何时候,我们都可以通过工具将系统或某进程的内存备份出来供调试分析用。dump文件中包含了程序运行的模块信息、线程信息、堆栈调用信息、异常信息等数据,方便系统技术人员进行错误排查。
jmap -heap pid 显示Java堆的信息
jmap -dump:format=b,file=heap.hprof pid
format=b表示以hprof二进制格式转储Java堆的内存file=<filename>用于指定快照dump文件的文件名。
jstat:是JVM统计监测工具。可以用来显示垃圾回收信息、类加载信息、新生代统计信息等。

jconsole:用于对jvm的内存,线程,类 的监控,是一个基于 jmx 的 GUI 性能监控工具
打开方式:java 安装目录 bin目录下 直接启动 jconsole.exe 就行

VisualVM:能够监控线程,内存情况,查看方法的CPU时间和内存中的对 象,已被GC的对象,反向查看分配的堆栈
打开方式:java 安装目录 bin目录下 直接启动 jvisualvm.exe就行

监控程序运行情况

查看运行中的dump
Dump文件是进程的内存镜像。可以把程序的执行状态通过调试器保存到dump文件中

总结:
说一下 JVM 调优的工具?
命令工具
jps 进程状态信息
jstack 查看java进程内线程的堆栈信息
jmap 查看堆转信息
jhat 堆转储快照分析工具
jstat JVM统计监测工具
可视化工具
jconsole 用于对jvm的内存,线程,类 的监控
VisualVM 能够监控线程,内存情况
04 Java内存泄露的排查思路?


1.获取堆内存快照dump
2.VisualVM去分析dump文件
3.通过查看堆信息的情况,定位内存溢出问题
详细步骤:
1、通过jmap指定打印他的内存快照dump(Dump文件是进程的内存镜像。可以把程序的执行状态通过调试器保存到dump文件中)
使用jmap命令获取运行中程序的dump文件
jmap -dump:format=b,file=heap.hprof pid使用vm参数获取dump文件
有的情况是内存溢出之后程序则会直接中断,而jmap只能打印在运行中的程序,所以建议通过参数的方式的生成dump文件
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/home/app/dumps/
2、通过工具, VisualVM去分析dump文件,VisualVM可以加载离线的dump文件
文件-->装入--->选择dump文件即可查看堆快照信息

3、通过查看堆信息的情况,可以大概定位内存溢出是哪行代码出了问题

4、找到对应的代码,通过阅读上下文的情况,进行修复即可
总结:
java内存泄露的排查思路?
内存泄漏通常是指堆内存,通常是指一些大对象不被回收的情况
1、通过jmap或设置jvm参数获取堆内存快照dump
2、通过工具, VisualVM去分析dump文件,VisualVM可以加载离线的dump文件
3、通过查看堆信息的情况,可以大概定位内存溢出是哪行代码出了问题
4、找到对应的代码,通过阅读上下文的情况,进行修复即可
05 CPU飙高排查方案与思路?
1.使用top命令查看占用cpu的情况
top

2.通过top命令查看后,可以查看是哪一个进程占用cpu较高,上图所示的进程为:40940
3.查看进程中的线程信息
ps H -eo pid,tid,%cpu | grep 40940

通过以上分析,在进程40940中的线程40950占用cpu较高
4.可以根据线程 id 找到有问题的线程,进一步定位到问题代码的源码行号

总结:
CPU飙高排查方案与思路?
1.使用top命令查看占用cpu的情况
2.通过top命令查看后,可以查看是哪一个进程占用cpu较高
3.使用ps命令查看进程中的线程信息
4.使用jstack命令查看进程中哪些线程出现了问题,最终定位问题
十、设计模式
你之前项目中用过设计模式吗?

01 工厂方法模式
工厂模式
需求:设计一个咖啡店点餐系统。
设计一个咖啡类(Coffee),并定义其两个子类(美式咖啡【AmericanCoffee】和拿铁咖啡【LatteCoffee】);再设计一个咖啡店类(CoffeeStore),咖啡店具有点咖啡的功能。
具体类的设计如下:

开闭原则:扩展开放,对修改关闭
工厂设计模式:解耦

简单工厂模式
简单工厂包含如下角色:
抽象产品 :定义了产品的规范,描述了产品的主要特性和功能。
具体产品 :实现或者继承抽象产品的子类
具体工厂 :提供了创建产品的方法,调用者通过该方法来获取产品。

工厂方法模式
工厂方法模式的主要角色:
抽象工厂(Abstract Factory):提供了创建产品的接口,调用者通过它访问具体工厂的工厂方法来创建产品。
具体工厂(ConcreteFactory):主要是实现抽象工厂中的抽象方法,完成具体产品的创建。
抽象产品(Product):定义了产品的规范,描述了产品的主要特性和功能。
具体产品(ConcreteProduct):实现了抽象产品角色所定义的接口,由具体工厂来创建,它同具体工厂之间一一对应。

调用关系

优点:
用户只需要知道具体工厂的名称就可得到所要的产品,无须知道产品的具体创建过程;
在系统增加新的产品时只需要添加具体产品类和对应的具体工厂类,无须对原工厂进行任何修改,满足开闭原则;
缺点:
- 每增加一个产品就要增加一个具体产品类和一个对应的具体工厂类,这增加了系统的复杂度。

抽象工厂模式
工厂方法模式只考虑生产同等级的产品,抽象工厂可以处理多等级产品的生产

产品族:一个品牌下面的所有产品;例如华为下面的电脑、手机称为华为的产品族;
产品等级:多个品牌下面的同种产品;例如华为和小米都有手机电脑为一个产品等级;
抽象工厂模式是工厂方法模式的升级版本,工厂方法模式只生产一个等级的产品,而抽象工厂模式可生产多个等级的产品。
一个超级工厂创建其他工厂。该超级工厂又称为其他工厂的工厂
现咖啡店业务发生改变,不仅要生产咖啡还要生产甜点
同一个产品等级(产品分类)
咖啡:拿铁咖啡、美式咖啡
甜点:提拉米苏、抹茶慕斯
同一个风味,就是同一个产品族(相当于同一个品牌)
美式风味:美式咖啡、抹茶慕斯
意大利风味:拿铁咖啡、提拉米苏

调用关系

优点:
当一个产品族中的多个对象被设计成一起工作时,它能保证客户端始终只使用同一个产品族中的对象。
缺点:
当产品族中需要增加一个新的产品时,所有的工厂类都需要进行修改。
总结:
工厂方法模式的主要目的是解耦
1.简单工厂
所有的产品都共有一个工厂,如果新增产品,则需要修改代码,违反开闭原则
是一种编程习惯,可以借鉴这种编程思路
2.工厂方法模式
给每个产品都提供了一个工厂,让工厂专门负责对应的产品的生产,遵循开闭原则
项目中用的最多
3.抽象工厂方法模式
如果有多个纬度的产品需要配合生产时,优先建议采用抽象工厂(工厂的工厂)
一般的企业开发中的较少
02 策略模式
该模式定义了一系列算法,并将每个算法封装起来,使它们可以相互替换,且算法的变化不会影响使用算法的客户
它通过对算法进行封装,把使用算法的责任和算法的实现分割开来,并委派给不同的对象对这些算法进行管理

策略模式的主要角色如下:
抽象策略(Strategy)类:这是一个抽象角色,通常由一个接口或抽象类实现。此角色给出所有的具体策略类所需的接口。
具体策略(Concrete Strategy)类:实现了抽象策略定义的接口,提供具体的算法实现或行为。
环境(Context)类:持有一个策略类的引用,最终给客户端调用。
类图:

优点:
策略类之间可以自由切换
易于扩展
避免使用多重条件选择语句(if else),充分体现面向对象设计思想。
缺点:
客户端必须知道所有的策略类,并自行决定使用哪一个策略类。
策略模式将造成产生很多策略类
登录案例(工厂模式+策略模式)
下面是gitee的登录的入口,其中有多种方式可以进行登录
用户名密码登录
短信验证码登录
微信登录
QQ登录
....


举一反三
订单的支付策略(支付宝、微信、银行卡…)
解析不同类型excel(xls格式、xlsx格式)
打折促销(满300元9折、满500元8折、满1000元7折…)
物流运费阶梯计算(5kg以下、5-10kg、10-20kg、20kg以上)
一句话总结:只要代码中有冗长的if-else或switch分支判断都可以采用策略模式优化
1.什么是策略模式
策略模式定义了一系列算法,并将每个算法封装起来,使它们可以相互替换,且算法的变化不会影响使用算法的客户
一个系统需要动态地在几种算法中选择一种时,可将每个算法封装到策略类中
2.案例(工厂方法+策略)
介绍业务(登录、支付、解析excel、优惠等级…)
提供了很多种策略,都让spring容器管理
提供一个工厂:准备策略对象,根据参数提供对象
03 责任链设计模式
责任链模式:为了避免请求发送者与多个请求处理者耦合在一起,将所有请求的处理者通过前一对象记住其下一个对象的引用而连成一条链;当有请求发生时,可将请求沿着这条链传递,直到有对象处理它为止。

抽象处理者(Handler)角色:定义一个处理请求的接口,包含抽象处理方法和一个后继连接。
具体处理者(Concrete Handler)角色:实现抽象处理者的处理方法,判断能否处理本次请求,如果可以处理请求则处理,否则将该请求转给它的后继者。
客户类(Client)角色:创建处理链,并向链头的具体处理者对象提交请求,它不关心处理细节和请求的传递过程。

优点:
降低了对象之间的耦合度
增强了系统的可扩展性
增强了给对象指派职责的灵活性
责任链简化了对象之间的连接
责任分担
缺点:
对比较长的职责链,请求的处理可能涉及多个处理对象,系统性能将受到一定影响。
职责链建立的合理性要靠客户端来保证,增加了客户端的复杂性,可能会由于职责链的错误设置而导致系统出错,如可能会造成循环调用。
举一反三
