10w字总结后端高频常用八股(Java+全方位+原理解析)

一.Redis篇

1.缓存穿透

产生原因:查询一个不存在的数据,Redis里面没有,就去查数据库,数据库里也没有,因此也无法写回到Redis中,导致这个不存在的数据的请求每次都会访问数据库,如果有人恶意高强度访问,就会对数据库造成压力

解决办法:通常我们有两种解决办法

1.返回空值缓存,也就是当数据库没有查询到数据的时候,在Redis中保存一个key,其value为空,这样再次请求的时候,直接访问Redis,返回空值,就不会对数据库造成影响。优点是实现简单,缺点是会创建多余key,内存消耗大并且对一致性有影响

2.我们更多的是采用布隆过滤器的方式,通过Redission实现,在客户端和redis之间,设置一个过滤器,这个过滤器会初步判断数据是否存在,存在再访问redis和数据库,不存在直接返回。

布隆过滤器的底层逻辑是hash算法和一个较大的数组,数组里每个元素都是二进制的0或1,最开始都初始化为0,我们可以对数据库和redis中本来就存在的数据,进行一次预热,例如userid/1,这个1经过三次hash计算,模数组的长度得到数组下标,把元素由0改成1,这样三个数组下标就对应一个数据key存在,查找也是这个过程

但是布隆过滤器存在误判,例如userid1经过3次计算为137,userid2为248,userid3不存在,但是经过计算为124,那么就会被误判为存在,这个误判是无法完美解决的,只能通过提高数组长度来降低误判率,我们通常在设置布隆过滤器的时候都可以设置误判率,一般为百分之5,就可以让绝大部分项目接受了

初始化Redission的布隆过滤器需要设置两个参数

1.预计加入元素的大小n

2.误判率f

布隆过滤器会根据这两个参数,通过算法得到位数组的长度l和无偏hash函数的个数k

2.缓存击穿

产生原因:缓存击穿也被称为热点key问题,一般是指某个热点key,也就是经常被高强度访问的key失效的时候,在还没有来得及重建缓存这个期间,被高强度并发访问,导致数据库收到极大的压力

解决办法:

通常有两种解决办法,分别是互斥锁和逻辑过期,应对不同的业务进行不同的选择。例如在我的项目中,我是对热点评的商铺访问进行了预防缓存击穿的处理,因为是商铺,即使商家后台修改了商铺数据,对一致性的要求并不是那么高,因此我采用的方式是逻辑过期。

互斥锁的原理是,在线程访问redis缓存失败的时候,获取一个互斥锁,这个互斥锁底层通过redis的set nx 实现,如果获取锁成功,那么开始重建缓存,而其他线程如果获取锁失败,那么就等待并重试获取redis缓存。一直等到第一个获取锁的线程重建完毕redis,才能释放锁,其他线程才可以正常访问运行。优点是保证了数据库和redis的一致性,缺点是造成了阻塞,性能较差

逻辑过期的原理是,在项目启动前,对redis进行一次预热,把一些热点数据的过期时间设置为-1也就是永不过期,但是可以人工添加一个逻辑过期时间,每次请求访问redis缓存时,都需要去判断这个逻辑时间是否过期,如果过期,同样是去获取一个互斥锁,但是区别在于,线程1获取锁成功之后,是单独去开辟一个新的子线程完成缓存重建,而自己本身以及其他在缓存重建完毕之前访问的线程,在缓存重建更新好之前,都返回旧数据即可。这样的好处是,不会造成堵塞,属于高性能的解决方案,缺点是在一致性上有缺陷,会获取到脏数据

补充:另外,以上解决方案都是针对未上线的项目,也就是对击穿做出的预防,针对上线项目,如果已经出现击穿,不能直接修改代码的情况,处理也应该有所变化:

1.首先可以直接拉黑造成击穿的SQL语句,防止数据库继续被持续施压

2.其次也可以下架缓存失效的商品或者店铺,从应用层解决问题

3.缓存无法重建,那么可以手动mock缓存

4.下线产品项目,修复bug之后重新上线

3.缓存雪崩

产生原因:缓存雪崩主要是指,同一时间大量key同时失效,或者redis宕机,导致大量请求访问数据库造成数据库压力增大崩溃。

解决办法:

针对大量key同时失效这个原因,其实对所有key的过期时间加一个1-5分钟的随机时间就可以解决

针对redis宕机,有多种解决方式:

1.利用redis集群去提高可用性

2.使用降级限流策略,这种策略可以作为缓存穿透、击穿、雪崩三种情况的保底策略

3.添加多级缓存

4.双写一致性(数据库和redis数据一致性)

产生原因:更新数据库的时候,redis数据库如果没有更新,那么在redis数据超时过期之前,用户访问到的数据都是过期脏数据

解决方式:

在我的项目里,最开始采用的是延迟双删的策略,也就是更新数据库、删除缓存,然后等一段时间的延时之后,再次删除缓存的方法,来解决数据库和redis数据一致性的问题。不管是先更新数据库再删除缓存还是顺序颠倒,都会存在高并发场景下,延时期间读到脏数据的可能性,再次删除缓存也是为了避免后续读到脏数据,但是这个延时时间是不好把握的,因此我通过学习了解了其他解决方案

第一种方案也就是我认为项目中更常用的,高性能方案:采用异步通知的方式,即修改数据库之后,把消息传递给中间件,然后让中间件去通知缓存删除。例如使用MQ中间件,但是就牵扯到保证MQ中间件的可靠性问题。因此就还有另一个方案,采用阿里的canal组件实现数据一致,canal服务部署后会伪装成一个mysql的子节点,数据库更新后,canal会去读取binlog文件数据,这个文件主要是存放ddl和dml语句的记录,canal会读取这些修改数据库的语句记录,然后去更新缓存

第二种方案是针对需要高一致性项目的方案,通过Redission提供的读写锁来实现,读写锁底层也是互斥锁,通过set nx实现。在用户读数据时,添加共享锁,实现读读共享,读写互斥。在用户写操作也就是要修改数据库的时候,添加互斥锁,实现读读、读写都互斥。需要注意的是,读操作和写操作获取的锁应该是同一个

阿里的canal组件,原理是,伪装成mysql的从库,去复制主库的binlog日志文件,拿到binlog会进行解析,解析之后会对数据进行处理,一般来说常见的两种处理方式,一种是直接利用canal的客户端API(java client),监听canal server,然后把数据变化同步到目标缓存,比如redis,另一种是通过消息队列,canal可以集成消息队列,把解析后的消息交给MQ,然后配置消费者进行处理

5.redis数据持久化

redis持久化有两种方式,一种是RDB,一种是AOF

RDB,全称Redis Database Backup File 即Redis数据备份文件

原理是把内存的数据,写到磁盘上,一般来说,在配置文件中都配置了自动备份,备份的命令为save和bgsave,一个是在主进程备份,一个是在子进程备份,一般自动备份都使用的是bgsave,原理是,把主进程的页表fork到子进程,然后让子进程,通过页表的虚拟地址映射到物理地址,把内存数据备份到硬盘上,需要注意的是,fork的时候采用copy on write技术,即fork的时候,主进程可以读内存数据,如果要写数据,就需要先拷贝一份数据,再修改拷贝后的数据,以防子进程和主进程产生冲突

AOF,全程append only file 即追加文件

原理类似命令日志,就是把Redis的每一条命令都记录在AOF,AOF默认是关闭的,需要在redis.conf配置文件中开启

AOF的持久化机制和redo log还有bin log 类似,都有一个AOF缓冲区,先把命令记录到缓冲区当中,这种需要连续写,或者说需要频繁追加记录的持久化方式,都会设置缓冲区,避免频繁的IO,而RDB这种“完整拷贝”的数据,类似于全量同步,而非增量同步,不需要缓冲区的设定

同时在配置文件中我们可以设置记录的频率,也就是刷盘策略,(always、everysec、no)可以每一条都记录,也可以间隔1s,也可以让操作系统自己决定,常用的是everysec,也就是间隔1s记录一次

因为是记录命令,所以存在对多个key多次写的操作,但是最后一次写的数据才有保存意义,因此可以让AOF执行bgrewriteaof命令执行重写功能,redis会在触发阈值的时候自动重写,可以在配置文件配置阈值

重写的底层机制,其实是生成一个新的AOF文件去代替旧的AOF文件,但是新的AOF文件不需要根据旧的AOF创建,底层原理是去读取当前redis库里面值,然后用一条命令来记录这个值,比如之前有sadd animals “cat” sadd animals “dog”两条命令,就直接用sadd animals “cat” “dog”这一条命令来保存数据。这就是重写的底层原理。但是这样生成新的AOF文件需要进行大量的写入操作,redis又是单线程的,为了避免阻塞,采用的是后台子进程重写,为了避免子进程重写的时候,主进程写入新输入造成数据不一致,redis设置了一个AOF重写缓冲区,开启子进程之后,主进程的所有写命令都会记录到AOF重写缓冲区,在新AOF重写完毕后,会把AOF重写缓冲区的内容加进去,然后对新AOF改名覆盖旧AOF

综上,两种方式各有优劣,一般项目中我认为应该结合两者一起使用,RDB是二进制文件,因此体积小,宕机恢复速度快,但是数据完整性较差,AOF因为记录指令较多,体积较大,恢复时间慢,但是胜在书记保存完整。前者可以追求更快启动速度,后者对数据安全性更有保障

6.数据删除策略

两种删除策略

惰性删除:key过期之后不去管他,只有在再次访问的时候,发现过期了才会删掉

优点是对CPU友好,只有使用key的时候才需要检查。缺点是对内存不友好,过期之后还在占用内存

定期删除:每隔一段时间,就对一些key进行检查,删除里面过期的key

定期清理有两种模式

SLOW模式:定时任务,频率为10hz,每次不超过25ms

FAST模式:频率不固定,间隔不低于2ms,每次不超过1ms

优点是控制频率和持续时间,尽可能降低对主进程的影响,并且可以清理掉一直没访问的过期key,缺点是对定期的频率和持续时间不好把握

Redis一般把两种删除策略结合使用

7.数据淘汰策略

数据淘汰与数据删除不同,数据删除是数据过期后需要从内存中清理到

数据淘汰指的是,内存满了之后,要清理掉哪些内存来释放内存空间

有八种数据淘汰策略

1.noeviction:即内存满了禁止写入新数据,这是默认的淘汰策略

2.volatile-ttl:根据设置了ttl的key,优先淘汰ttl小的

3.allkeys-random:对全体key随机淘汰

4.volatile-random:对有ttl的key随机淘汰

5.allkeys-lru:对全体key,根据lru算法淘汰

6.volatile-lru

7.allkeys-lfu:对全体key,根据lfu算法淘汰

8.volatile-lfu

LRU:最近最少使用,距离上一次使用的时间越长越先淘汰

LFU:最少频率使用优先淘汰

平时用的较多的是allkeys-lru策略:保留热点访问的key,删除长时间没有访问的key

内存满了之后,会根据不同的内存淘汰策略进行处理,如果是默认的noeviction策略,那么内存满了就会报错

8.Redis分布式锁使用场景

在我的项目中,运用到分布式锁的关键功能就是优惠卷秒杀,其中为了解决一人一单问题以及超卖问题,就需要用到分布式锁。其中超卖问题可以通过乐观锁解决,但是乐观锁需要修改sql语句或者业务逻辑,影响效率。而添加jvm自带的synchronized锁,在集群模式下又无法保证线程安全问题,因此需要使用到分布式锁

这里详细介绍一下为什么集群模式下会出现问题,因为集群模式下,每个服务器都有自己的jvm,而jvm的锁是单独的,但是数据库是共享的,synchronized这个锁只能对当前服务器的线程有效,还是会出现超卖和一人多单的问题。因此我们需要一个可以在多个服务器集群环境下共享的锁,也就是分布式锁

而分布式锁的关键特点就是:多进程可见的互斥锁

9.Redis分布式锁实现原理

基于Redis中的set nx命令实现

setnx就是set if not exist,如果不存在,则set

获取锁我们往往是

set lock value nx ex 10

需要nx ex一起设置,保证互斥和超时两个功能的原子性,因为这样可以避免,设置锁之后,如果Redis服务宕机导致锁无法自动释放

释放锁就直接del key即可

在我的项目中使用Redission来实现分布式锁,底层是使用了setnx和lua脚本

Redission实现的分布式锁解决了几个痛点问题

1.解决不可重入的问题

底层逻辑是,通过Redis的hash结构的HEXISTS命令,记录锁的线程标识信息和重入次数,在重入时判断是否为当前线程,释放时,把计数-1,如果计数为0,那么就释放锁,如果不为0,表示当前线程还有其他业务在持有锁

2.解决超时释放问题,因为对锁的延迟释放时间不好把握,Redission就提供了看门狗机制,一个线程获取锁之后,会利用看门狗给当前锁续期,默认释放时间是30s,而每隔10s就会重置一次释放时间,在释放锁、或者发生异常之后,才会将看门狗给撤销掉

3.主从一致性问题

可以解决,但是效率太低了,解决逻辑就是加红锁,不在一个Redis实例上创建锁,而是在多个Redis上加锁。可以利用zookeeper来解决

10.Redis解决集群模式下session问题

在集群模式下,会有多个tomcat,每个tomcat上都有不同的session,而用户在这个服务器上登录了,把验证信息保存到了session中,在另一个服务器上获取不到session,就会出现问题。因此需要有一个多个服务器可以共享的工具,也就是Redis。我们利用随机值创建一个token作为key,然后把用户信息作为value存储到Redis,token返回给前端代替cookie,之后所有服务器都可以通过token,去Redis获取用户信息作为验证了

11.主从模式

我项目中主要使用的是主从(1主1从)+哨兵的集群方案

单节点的Redis并发能力是有上限的,为了进一步提高Redis的并发能力,可以搭配主从集群,实现读写分离

主节点负责写数据,从节点负责读数据,主节点写入数据后,把数据同步到从节点中

主从同步数据的流程:

分为两个阶段,一个是全量同步,一个是增量同步

全量同步指的是从节点第一次与主节点建立连接的时候,使用全量同步

1.从节点请求主节点同步数据,从节点会携带自己的replication_id和offset偏移量

2.主节点判断是否是第一次连接,就是看从节点的replication_id是否和主节点一致,如果不同,就是第一次连接,主节点就把自己的replication_id和offset发送给从节点,让主从信息保持一致

3.同时主节点会执行bgsave命令,生成RDB文件,发送给从节点,从节点清空自身数据之后,执行RDB文件,进行数据的复制,这样主从的数据就一致了

4.如果在生成执行RDB文件的时候,主节点又收到了请求命令,那么会把命令记录到缓冲区的repl_baklog(复制积压缓冲区)日志文件中,最后再把这个日志文件发送给从节点,用来同步数据

5.增量同步指的是,从节点slave重启,或者数据变化后从节点会请求主节点同步数据,主节点判断是否为第一次连接,发现replication id一致,就获取从节点的offset值,然后从repl_baklog日志文件中获取从节点offset之后的命令数据,发送给从节点进行数据同步

12.哨兵模式

哨兵:Sentinel

作用:监控master和slave是否正常工作,如果master故障,sentinel哨兵会将一个slave升级为master,之前的master恢复后,也以升级后的master为主。同时,在Redis集群发生故障时,哨兵会把最新的信息(新的主节点)通知给客户端

原理:每隔1s,哨兵都会向集群的各个主节点发送ping命令,接收各个节点的响应

主观下线:一个哨兵发现某实例未在规定时间内响应,就认为该实例主观下线

客观下线:超过指定数量quorum的哨兵发现某实例主观下线,就是客观下线,quorum值一般为哨兵实例数量的一半

当一个主节点被认定客观下线,就会根据选主规则确定新的主节点

  1. 判断主从断开时间长短,断开时间超过阈值就排除

  2. 判断从节点的slave-priority值,越小优先级越高

  3. 如果slave-priority值一样,判断offset,越大优先级越高,越大就表示和主节点的数据差越少

  4. 如果offset也一样,就看slave的id,id越小优先级越高

脑裂问题:简单来说就是因为网络问题,主节点依旧存活,但是没有响应哨兵,被哨兵判断为下线了,然后哨兵推选了一个新的master节点,两个master节点同时存在,但是客户端依旧在往老的master写数据,同时,老的master也没有连接从节点,导致大量数据丢失的问题

解决方案:第一可以设置最少的slave节点个数,比如设置为1,意思是客户端在往主节点写数据的时候,必须保证主节点有1个从节点

第二是设置主从数据同步的时间,避免大量数据丢失

13.redis为什么快

1.纯内存操作,数据存放在内存中,处理速度自然快

2.非阻塞IO:redis采用epoll+IO多路复用技术,并且高版本采用多线程处理网络请求,键值读写操作还是单线程

3.单线程实现:避免线程切换和竞争锁资源产生的开销

io多路复用:指的是一个线程/进程,通过监控多个io文件描述符,当描述符就绪的时候,通知线程/进程进行处理

比喻一下就是:老师检查作业,坐在讲台上,一个同学做完了,就自己上讲台让老师批改

io多路复用有三种模型:select、poll、epoll

select:通过fdset(底层数据结构是位掩码)存储fd(文件描述符),容量默认上限是1024,当有fd就绪的时候,通知线程/进程,然后通过轮训遍历的方式,找到就绪的fd

poll:在select的基础上,修改存储fd的数据结构为链表,解决了存储fd有数量上限的问题,但是还是通过轮训遍历的方式去查找就绪的fd,当fd数量过多的时候,性能就会很差

epoll:为了解决上面两种模型性能损耗的问题,采用监控+就绪存储的方式,主要利用三个函数实现,epoll_create会开辟内存空间创建一个epoll对象,作为监控中心,用来保存fd,底层数据结构初始为红黑树,epoll_ctl,向注册中心,注册、修改、删除fd,当fd就绪的时候,会把就绪的fd从红黑树移动到readyList,底层是一个双向链表实现的队列。epoll_wait,用来存储等待的线程/进程,被唤醒的时候,会直接把readyList当中就绪的描述符返回给线程/进程,不用再去遍历,解决性能问题

异步io:io操作无需靠调用者线程/进程处理,全程靠操作系统内核后台处理,然后利用回调函数通知调用者获取结果

多路复用io使用更广泛:相比异步io,兼容性更好,并且可以自定义读写时机,可控性更强,异步io是通过回调机制处理,回调的时候,内核已经把io处理完了

14.redis的使用场景

1.分布式锁

2.缓存

3.集群模式下代替Session

4.计数器和限流

INCR和DECR命令,都是原子性操作,可以实现例如网站浏览计数功能

搭配滑动窗口思想,将key的细粒度设置为ip地址,然后利用INCR统计访问次数,设置上限,同时设置TTL

就可以实现,某一段时间,针对某个ip,限流访问的功能

5.实时排行榜

基于Sorted Set实现

6.String结构统计pv:页面浏览量,HyperLoglog统计uv:独立用户数

15.常用数据结构

1.String:最基础的键值对

2.Hash:嵌套键值对,key下多个fIeld-value的结构,保存对象适合

3.List:压缩列表 + 双向链表” 的混合结构

4.Set:和java当中类似,无序不可重复

5.Sorted Set:Set基础上,每个元素都有个Score值,根据其排序 底层通过压缩列表+跳表实现

6.HyperLogLog近似统计基数(集合中不存在重复元素数量)的数据结构,相较于set,内存占用少,但是有误差

16.ZSet底层数据结构

键值对数量小于128,元素大小小于64的时候,使用压缩表,不符合条件就使用跳表

压缩表

底层是把节点紧挨到一起的压缩列表中保存,实质是双向链表,按照score值排序,查询增删复杂度为On

redis3.0之后用quicklist代替压缩表,5.0之后就引入了listpack代替quicklist

listpack代替压缩表的好处是避免了连锁更新的问题,压缩表每个节点都保存了上一个节点的长度,因此一个节点修改就可能导致后一个

节点修改。listpack则是不再保存上一个节点的长度,转而只保存自己节点的长度,避免了连锁更新的问题

跳表

本质上是在底层的一个有序双向链表基础上,添加了多层索引链表,利用空间换时间,提高了增删改查的效率

复杂度为logn

特点是,在高层索引可以快速确定目标范围,然后在下降到低层进行查找

需要注意,同一数据在不同层当中,其实是同一个对象,通过节点内的level数组,维护各层的后继指针,level数组还维护了一个span值,span值记录当前节点,距离当前层的后继节点,所跨越的节点数量,便于排名计算

在插入节点的时候,还会维护一个update数组和rank数组

update数组用来保存,插入新节点在每一层的前置节点,用于确定插入新节点的位置

rank数组用来记录update节点的排名,用于插入新节点后,更新前置节点和新节点的span值

具体更新过程:

前置节点span值 = rank[0] - rank[i] + 1

新节点span值 = 旧前置节点span值 - 新前置节点span值 + 1

插入新节点的时候,一般是,先计算各层的update和rank,然后再得到随机层数,随机系数默认为0.25,redis的跳表最高为32层,意思是,确认随机层数时,下一层的概率为上一层的4倍

确认好层数之后,根据update插入新节点,然后根据rank更新每一层前置节点span值

最终跳表size+1

17.数据分片

集群模式下,数据分片是在多个主节点当中分配数据,从节点只是主节点的副本

redis一共有16384=16*1024个哈希槽

主节点可以设置多个,比如3主节点集群,就是把16384分成三部分:0~5460,5461~10922,10923-16383

每个主节点负责一部分槽,存储一部分数据

redis会通过CRC16算法,将key计算为一个16位hash值,然后让其对16384进行取模,取模后的值用来决定当前数据在哪个主节点的槽当中

18.一致性哈希

传统hash的问题:例如hashmap,一旦扩容/缩容就需要对所有数据进行迁移

一致性hash:

将hash值构成一个环,比如2的32次方个槽位,将服务器ID/表ID/库ID,通过hash计算,得到的值,映射到环上的某个槽

然后数据存放呢,就计算hash值,然后顺时针/递增找到第一个服务器/表/库的节点,来存储数据

这样的好处是,在新增服务器/表/库之后,只需要在环上,新增一个节点,影响的也只是相邻的服务器节点

但是环太大,物理节点太少,可能导致分布不均匀,因此还需要虚拟节点,也就是给某个物理节点,几个虚拟身份。

比如节点A的hash值在100,那给他三个虚拟节点,为1000,10000,100000,这样数据接近这三个虚拟节点的时候,也会存入A节点

优点是:解决数据倾斜数据迁移量大的问题

数据倾斜:数据分布不均

数据迁移量大:比如节点D在10001,就可以把A的10000虚拟节点数据直接迁移过去,而不是对A里面的所有数据进行判断迁移

传统hash:适合节点固定的情况

一致性hash:适合节点数量频繁变动的情况

Redis槽分片:Redis集群特有,因为Redis有确认的16384固定槽位的机制

二.数据库篇

1.如何定位慢查询

慢查询就是在项目测试中,发现某个接口响应时间很慢,超过了2s以上,这个时候就需要去检查,是哪个sql出现了问题,所谓慢查询就是查询哪个sql语句执行缓慢

方法一:使用运维工具

例如skywalking,在系统中部署运维工具,就可以通过工具展示的报表中,查找到哪个接口比较慢,然后可以看到每条Sql语句的执行时间,从而定位慢查询

方法二:开启慢日志查询

如果项目中没有部署运维工具,可以开启MySQL自带的慢日志查询,在MySQL的配置文件中可以开启这个功能,并且设置Sql语句超过多少时间,就记录到日志,一般设置为2s,这样运行时间超过2s的Sql语句就会记录到日志里面,我们只需要查看日志就可以定位执行较慢的Sql语句了

2.如何分析执行慢的sql语句

采用MySQL自带的分析工具:explain

在需要分析的sql语句前添加explain,然后分析给出表的详细信息

1.先看possible_key、key_len和key字段

如果possible_key为null,说明没有可以命中的索引,需要根据where条件适当添加,或者修改where条件

key表示真正用到的索引

key_len可以判断联合索引当中,使用到了哪些列

可能存在,possible存在,key为null的情况,可能是数据量太小,使用索引不如全表扫描

2.通过type字段查看sql是否有进一步优化空间

system > const > eq_ref > ref > range > index > all;

这里举个简单的例子

 CREATE TABLE `film` (
   `id` int(11) NOT NULL AUTO_INCREMENT,
   `name` varchar(10) DEFAULT NULL,
   `remark` varchar(255) DEFAULT NULL,
   PRIMARY KEY (`id`),
   KEY `idx_name` (`name`)
 ) ENGINE=InnoDB AUTO_INCREMENT=4 DEFAULT CHARSET=utf8;
 ​
 1. explain select * from film where name = 'film1';
 2. explain select * from film where id >2;
 3. explain select id from film ;
 4. explain select remark from film ;

拿这张电影表举例

ref:表示使用非唯一索引或唯一索引的前缀进行查找 第一条sql,走name的二级索引树,根据name=‘film1’的条件,定位到某个具体的索引项

range:在某个索引树上范围查询部分记录 第二条sql,走id的主键索引,也是聚集索引,添加了范围条件,不是全索引扫描

index:非聚集索引树的全扫描 第三条sql,可能感觉很奇怪,这里查id没有走主键索引吗?其实还是走的name的二级索引,因为二级索引的B+树的节点信息会保存主键id,所以说这里查id,走的是二级索引的全扫描

all:聚集索引树的全扫描 第四条sql,可以看到,如果要查remark字段,查name的二级索引树,只能查到name和id,要查remark,会触发回表,那么就需要去查聚簇索引的全扫描了,因此是all

一般来说,查单个优化到ref,查多个优化到range

如果是index和all,那么就需要对sql或者表结构进行优化了

3.通过extra建议判断

情况较多,只列关键的指标

Using filesort:当ORDER BY子句中的列不是索引的一部分,或者索引的顺序与ORDER BY要求的顺序不一致时,就会出现这种情况

Using temporary:通常出现在含有GROUP BY、DISTINCT、ORDER BY等子句的复杂查询中,尤其是当这些子句中的列没有合适的索引时

Using index:好现象,表示为覆盖索引,没有出现回表查询

另外,我们也可以通过AI模型,将sql语句和explain给出的结果,去询问AI,给出优化建议

3.索引和索引的底层数据结构

索引常用数据结构: 1.B树/B+树:适用于大规模数据,最大优点是磁盘IO次数少,支持范围查询 2.哈希表:适用于小规模数据,最大优点是等值查询效率为O(1) 3.红黑树:hashmap底层数据结构,层树高,不适用于硬盘 4.跳表:Redis中sorted set底层数据结构,实现简单,但层数依旧高,不适用于硬盘

这里浅谈一下红黑树和跳表,因为之前有个问题是:hashmap底层为啥不用跳表

原因:跳表的优点是,底层是多层链表的结构,天然支持范围查询和排序,并且实现维护比红黑树简单,不需要考虑树结构和平衡性,但是内存开销很大,因为同样的数据,跳表会在多层出现,也就是说会保存多次,hashmap不需要范围查询和排序,为了避免内存消耗,红黑树是更好的选择

索引index就是帮助MySQL高效获取数据的一种有序的数据结构

数据库除了维护数据之外,还维护着满足特定查找算法的数据结构,其中MySQL使用的是B+树

索引可以提高数据检索的效率,降低数据的IO成本和CPU消耗

底层数据结构:B+树

B+树,是由二叉查找树、平衡二叉树、B树,这样发展而来。我们知道,MySQL的数据是保存在硬盘上的,读取数据的速度相比redis这种内存io的数据库来讲,要慢上千甚至上万倍。因此,我们需要尽可能减少从磁盘中读取数据的次数,也就是减少磁盘io次数。

磁盘中,读取都是按照磁盘块来读取的,如果使用二叉树这种结构,一个节点对应一个磁盘块,一个磁盘块就一份数据,数的高度极高,磁盘io量极大,显然是效率极低的,因此,有了B树。

B树里,每个节点称之为页,也就对应一个磁盘块,每个页里存储了更多的键值对,并且有了更多的阶,或者说子节点,导致整个树高度更低,宽度更大,磁盘io的次数也就少了很多,效率就提高了

B+树就是在B树的基础上,优化而来。

1.B+树的非叶子节点上,不再保存数据,而是只存储键值和指针。这样的好处在于,数据库页的大小是固定的,InnoDB存储引擎页的大小默认是16kb,不用存储数据,只存储键值和指针,意味着相对B树,页可以存储更多的键值,相应的,阶数就会变得更大,树就会更胖更矮。磁盘io的次数就会更少,性能就会更高。实际场景中,假设一个聚集索引,主键为bigint 8字节,指针一般6字节,那么一个非叶子节点就可以存储16kb/14字节 ≈ 1170个键值,叶子节点假设一条数据1kb,可以保存16条数据,那么3层B+树就可以保存1170 x 1170 x 16 约等于两千多万条数据。而查找这两千多w条数据,只需要2次io,因为根节点一般常驻内存

2.B+树的数据,是根据键值进行排序的,并且叶子节点通过双向链表进行连接,每页中的数据,通过单向链表进行连接。这样做的好处是,在我们进行范围查找、排序查找、分组查找等操作时,效率很高。同时,又因为所有数据都放在叶子节点当中,或者说都放在树的最底层,因此,查询所有数据的效率是相当的,非常稳定

4.什么是回表查询(聚簇索引和二级索引)

谈到回表查询就必须先引出两个概念

聚簇索引:也叫聚集索引,指的是数据和索引放在一起,B+树叶子节点保存的是整行数据,聚集索引有且只有一个,一般都是放在主键上,必须有且只能有一个

如果存在主键,那么主键索引就是聚集索引,如果没有,那么第一个唯一索引就是聚集索引,如果都没有,那么InnoDB会生成一个rowid作为隐藏的聚集索引

二级索引:也叫非聚簇索引,指的是数据没有和索引放一起,B+树叶子结点保存的是数据所在行对应的主键,可以有多个,一般程序员自己添加的索引都是二级索引

从索引B+树来看: 聚集索引树当中,叶子节点的键值是主键,数据是主键所对应的整行数据 二级索引树当中,叶子节点的键值是索引值,索引值可能只有一个,也可能是联合索引有多个,数据是当前数据行所对应的主键

介绍完两个概念,就可以介绍什么是回表查询了

这里模拟一张t_user数据表,字段有id,name,age,其中id为主键,带聚集索引,name我也添加了二级索引

那么在执行select * from t_user where id = 1这个语句的时候,因为是查询所有,而且where条件是id,就直接查聚集索引,返回整行数据

如果执行的是select * from t_user where name = ‘jack’ ,那么就会去name字段添加的二级索引查询,但由于查询的是*,就需要通过二级索引中叶子节点保存到主键值,再一次到聚集索引中查找整行数据,这个过程就是回表

简单来说就是:通过二级索引,查询到的数据不完全包含在索引中,就需要通过索引的主键值再次查询聚集索引,获得整行数据的过程

如何避免回表查询:可以使用联合索引,也就是把需要查的数据都包含在一个索引中。或者在查询的时候,修改返回字段为索引包含的字段,避免去回表查询

5.覆盖索引和超大分页问题

什么是覆盖索引:

指的是,使用索引查询的时候,返回的列必须在索引中能全部找到

例如id为主键,通过主键索引查询,其实就是聚集索引,查到的就是整行数据,性能高

如果是给name添加了二级索引,那么这个索引中能找到的列就是name以及B+树叶子节点保存的主键id,此时如果返回的列中包含了其他列,不存在于索引之中,就会引发回表查询,也就不是覆盖索引了

因此回表查询也可以成为:当这个二级索引不是覆盖索引的时候,就会产生回表查询

超大分页问题:数据量较大的时候,需要通过limit分页查询,需要对数据进行排序,效率很低

解决方案:子查询优化/inner join关联查询

先通过覆盖索引找到符合条件的id,再使用聚集索引查询数据,效率就更高

或者通过缓存分页数据进行优化

超大分页出现的原因:

简单来说就是limit 100000,1这条语句,需要查询100000+1条记录,然后丢到前100000条,返回最后1条,出现这种问题的根本原因是mysql的底层分为server层和存储引擎层,存储引擎查到第1条记录传给server层的时候,才会注意到有limit这个语句,此时server层里面会记录一个limit_count,拿到第1条记录时limit_count=1,就会要求存储引擎去查第2条、第3条、、、一直到100000条,而每次查询都会是回表查询,效率极低,server就会判断回表次数太多,不适合用索引,直接走全表扫描了,索引就会失效

解决方式其实就是通过关联查询或者子查询,避免回表查询,让server层走索引。

例如:

 select * from film limit 10000,1;
 ​
 修改为
 ​
 select * from film 
 where id >= (select id from film limit 10000, 1)
 limit 1;

分页查询的所有方案:

1.limit,常用,但是数据量大时性能差

2.子查询,先查id,再子查询where id > 子查询

3.游标分页,可以在业务逻辑层把上一次查询的last_id保存下来,下一次接着查

6.创建索引的原则

结合自己的项目

1.数据量较大,查询较为频繁的表,需要添加索引,例如点评项目中的用户表,不管是登录还是点赞关注,都会进行查询,因此这种表是适合添加索引的

2.添加索引一般添加在经常作为查询条件、排序条件、分组条件的字段上

3.尽量选择区分度高的字段创建索引,尽量创建唯一索引

4.尽量使用联合索引,这样就减少避免回表查询

5.要控制索引的数量,索引不是越多越好,虽然方便了查数据,但是会提高增删改数据的成本

7.索引失效的情况

1.违反最左前缀法则

指的是使用联合索引的时候,如果没有从最左前列开始或者跳过了中间的列查询,就有可能导致索引失效

但是现代的MySQL都有优化器,可以重新排列顺序,因此,只要查询条件中包含了联合索引内所有的索引,都不会影响索引使用

2.范围查询导致右边的列索引失效

同样是在联合索引里面,如果左边的列或者中间的列进行了范围查询,会导致右边列索引失效

3.不能再索引列上进行运算,也会导致失效

4.字符串没加单引号,类型转化导致的索引失效

5.以%开头的模糊查询导致索引失效

前三个较常见

补充:联合索引的底层结构:

按索引的顺序,从左往右,先根据第一个索引列排序,第一个索引列值相同,再根据第二个,以此类推

因此,也就可以说明,违反最左前缀法则,会导致索引失效,因为都是从最左列开始,依次往右对字段进行索引

通过也可以看出,范围查询之后,左边的列虽然有序,但是右边的列就会存在不有序的情况,无法继续使用索引

8.慢查询sql优化的经验

可以从5个方面进行优化

1.表的设计优化

可以参考阿里的开发手册,里面对表的设计做了很多规范,例如经常用到的就是对数据类型的选择

2.索引的优化

通过explain先排查是否是索引的问题,排查索引失效,亦或者是没有合适索引命中,或者说出现回表查询的情况

参考创建索引的原则

3.sql语句的优化

避免直接使用select * 容易导致回表查询

sql语句避免索引失效

union all 代替union,少一次过滤,效率更高

join优化,尽量使用内连接,外连接要以小表为驱动

4.主从复制,读写分离

5.联表过多,join过多

如果是多表join导致的,那么首先根据阿里开发手册,超过3个表禁止join,主要原因是join数量过多,即使MySQL8.0之后采用hashjoin,数据过多还是会导致慢查,较好用的解决办法是,适当的反范式,做一些数据冗余,把一些联表查的数据保存到主表当中,空间换时间,另外还可以做一张宽表临时表,存放join后的数据,可以放数据库也可以放es上

6.分库分表

分库分表分为两种

水平/横向分:指的是按行分离数据,用于解决相同类型的数据量过大的问题 ​ 垂直/纵向分:指的是按列分离数据,用于不同字段访问量差距大的情况,把访问频率多的字段分一个表

水平分库就是水平分表的升级版,在单表数据库超过500w条的时候,就可以采用水平分表了,当一个库里面,针对某个业务的数据,已经有多个表了(如果500w一个,那就是超过2个表),就可以分库了。而垂直分表分库主要是用于不同字段访问量差距大的情况,可以单独把访问频率多的字段分一个表。另外对于某个数据应该存到哪张分表哪个分库,也就是找分片键,toc的业务,可以根据用户id等区分性较强的唯一标识,进行处理,选择具体的库和表。另外,一般分表分库的数量,尽量设置为2的次幂,然后分片键可以通过唯一标识对总数量取余的方式获得

分库分表会牵扯到一个分页问题,或者说范围查询的问题,比较成熟的解决方案,有利用技术工具:es或者Shardingsphere(xiading-sifier),或者说业务妥协方案:仅展示部分结果

9.事务的特性ACID

可以结合具体场景:例如银行转账

事务:是一组操作的集合,是一个不可再分的工作单位,事务会把一系列操作作为一个整体向系统提交

事务的特性:ACID

A:原子性,即事务是不可再分的工作单位,必须全部成功或全部失败

银行转账业务中,a转账给b,不能a少了钱b没有多钱,只能a少b多或者a不少b不多

C:一致性,即事务完成过程中,不会出现数据冲突和非法数据,数据的逻辑性应该前后保持一致

即转账业务中,a少了1000,b也只能多1000,数据不能发生改动

I:隔离性,数据库提供了隔离机制,保证事务不受外界并发操作的影响

即a向b转钱,不能受到其他事务,例如a同时跟c转钱的干扰

D:持久性,事务一旦提交或者回滚,对数据库中的数据修改是永久性的,也就是会把结果保存在磁盘中,进行落盘操作

10.并发事务问题和隔离机制

并发事务问题有三种:

1.脏读:指的是事务A读取到了事务B还没有提交的数据

例如事务B有一个update操作,但是事务还没提交,事务A这边select就读取到了

2.不可重复读:指的是事务A先后读取同一条数据,但是这条数据期间被事务B修改后提交事务,读取到的不一样

这里要注意,事务B必须提交事务,然后事务A重复读取到不一样才是不可重复读

如果B没有提交事务,事务A重复读取到不一样本质是脏读问题

3.幻读:指的是事务并发过程中,事务A查询了数据之后,事务B插入或者修改了数据,事务A第二次查询数据时发现数据不一致

假设事务A查询数据1没有查询到,想要插入数据,但是并发的事务B已经插入数据了,事务A又发现数据已经存在插入不了,但是重复查询都查询不到,这就是幻读

三种并非事务问题也对应了四种隔离机制:

1.读未提交:存在脏读、不可重复读、幻读三种问题

2.读已提交:解决了脏读,但还存在不可重复读,幻读问题

3.可重复读:存在幻读问题

4.串行化:解决并发问题,但是效率很低,不再是并发事务

其中,默认的也是使用较多的是可重复读

解决幻读问题:

快照读的时候,通过mvcc解决幻读问题,当前读的时候,通过间隙锁来解决幻读问题,意思对查询范围内的间隙进行锁定,让其他事务无法在查询范围内添加新的数据,避免幻读

11.undo log 和 redo log 和 bin log的区别

undo log 主要作用:保证事务的原子性支持事务数据回滚,以及mvcc的实现 简单来说,undo log当中会保存数据的不同版本,用于事务回滚或手动回滚 另外会配合隐藏字段和readview实现mvcc,具体看12条

redo log 主要作用:保证事务的持久性,用来保存和恢复事务的数据

背景 MySQL保存数据是以页为单位的,这个在索引的底层数据结构那里提到过,一个B+树的节点就是一个页,16kb MySQL每查一条数据,就会把这条数据所在的页,全部放到buffer pool缓冲池当中 这主要是因为空间局部性原理(某个内存位置的数据被访问,那么它周围的数据未来也极有可能被访问) 因此MySQL选择,把一页的数据放到buffer pool当中,方便取连续数据,后续取数据,会先从缓冲池里取,取不到再去磁盘取

具体使用 写数据也会先写入buffer pool,并且不会立即刷盘,会把修改的这个页,标记为脏页,然后让后台线程在某个时间刷新到磁盘 问题就来了,buffer pool在内存当中,如果没有立即刷盘,MySQL挂了,数据就丢了,于是就有了redo log redo log会记录,MySQL每次对某个磁盘页做了怎样的修改,事务提交的时候,就会把redo log刷盘保存 如果MySQL挂了,buffer pool的脏页还没刷盘,就可以通过redo log恢复数据

原因 为什么写到buffer pool的时候不直接刷盘呢? 因为如果写一条数据就刷一次,buffer pool刷盘是随机io,需要找到具体各个磁盘页,然后去修改,效率很低 redo log会记录当前数据是在哪个磁盘页,做了什么样的修改 刷盘只需要在redo log日志文件后面添加就行,是顺序io,效率高

底层过程 redo log也不是直接写入磁盘的 redo log分为两个部分,redo log buffer 和 redo log file 它有012三种刷盘策略 0:事务提交的时候,数据保存到redo log buffer,按照间隔1s的频率刷盘 1:事务提交的时候,数据会直接刷盘(默认配置,最安全) 2:事务提交的时候,数据从redo log buffer保存到操作系统的page cache当中,让操作系统控制刷盘时间

bin log 主要作用:MySQL主从库的数据拷贝和MySQL备份恢复,所有引擎共有(undo/redo log是InnoDB特有的)

背景 redo log不能用来做MySQL主从库的数据拷贝和MySQL的备份恢复工作 它只能做事务提交时,MySQL挂掉,没有被刷盘的数据的恢复,或者说,事务级别的数据恢复

原因 redo log是循环写,redo log file日志文件大小固定的,写满就会从头开始写,边写边擦除 它只能恢复,事务提交但没有被刷盘的数据恢复,只是事务级别的数据记录 bin log是数据库级别的数据记录,可以进行数据库全量的数据恢复 bin log是追加写,记录的是ddl和dml的sql语句 相应的,bin log和redo log类似,也有缓存机制,也有bin log cache,刷盘机制和redo log类似,也是事务提交的时候刷盘

0:交给操作系统page cache决定 1:每次事务提交都刷盘 N:提交N次事务刷盘一次

两阶段提交

事务提交之后,binlog和redolog都要刷盘,如果一个成功,另一个失败,就会存在日志的一致性问题 为此,MySQL把redo log拆成了两个步骤:prepare和commit 具体流程如下

事务开启,undolog记录数据初始版本,redolog buffer记录日志,提交阶段,redo log进行刷盘,buffer日志提交到file当中,同时redo log进入Prepare状态,然后开始写bin log,binlog成功记录后,将redo log改为commit状态

1.如果设置Prepare状态异常:直接回滚 2.如果写bin log异常:MySQL发现redo log是Prepare状态,回滚 3.如果设置commit状态异常:MySQL认为redo log是Prepare状态,但是bin log有日志记录,还是会提交事务

说明:事务成功提交的标志是bin log刷盘成功

可以看到

要保证持久化,就需要记录日志,对于连续写的日志,应该搞一个cache,先写cache,然后异步刷盘,通过日志就可以进行备份恢复,主从同步了

12.事务隔离性如何保证(MVCC)

事务隔离性主要是通过MVCC保证的

MVCC:指的是MySQL中的多版本并发控制,指维护数据在并发场景下的多个版本,使多个事务下读写操作没有冲突

MVCC的具体实现主要依赖于数据库记录中的隐藏字段、undo log日志、readview

隐藏字段:

1.trx_id:也就是记录每个事务的id,是自增的

2.roll_pointer:回滚指针,指向当前记录的上一个版本地址,用于配合undo log

undo log:

1.保存逆向语句,用于回滚事务

2.保存版本链:多个事务并发操作一条记录的时候,记录不同事务修改数据的版本,通过roll_pointer回滚指针形成一张链表

readview:

作为MVCC读取数据的依据,readview里面保存了4个关键字段

1.creator_trx_id:创建当前readview的事务id ​ 2.m_ids:创建readview时,当前数据库还在活跃(未提交)的事务id ​ 3.min_trx_id:创建readview时,当前数据库还在活跃(未提交)的最小事务id ​ 4.max_trx_id:创建readview时,应该分配给下一个新建事务的id

通过这4个字段,readview构建了一个事务时间轴,然后结合事务id和版本链,可以对当前事务进行各版本数据的可见性判断

需要注意,不同隔离级别的快照读是不一样的

1.RC:读已提交,每次执行快照读的时候都会生成一次readview

2.RR:可重复读,仅在当前事务第一次执行快照读时生成readview,后续都会复用这个readview

快照读:通过readview,读取到的符合隔离级别的数据版本

当前读:读取最新的数据,并且会加锁,让当前数据不会被其他事务修改

解决幻读问题:

先快照读,后当前读就会出现幻读问题 事务A,快照读,查user_name = 'AAA'的数据不存在 事务B,插入了一条user_name = 'AAA'的数据 事务A,再插入,就插入不了了,然后快照读,还是不存在该数据,当前读就可以查到

要解决幻读,就需要在可重复读的隔离级别下,在一个事务操作某张表的数据的时候,其他事务不允许新增/删除当前表的数据即可 使用select for update添加间隙锁即可

13.主从同步的原理

  1. 主库在事务提交时记录数据变更到Binlog。

  2. 从库读取主库的Binlog并写入中继日志(Relay Log)。

  3. 从库重做中继日志中的事件,反映到自己的数据中。

14.三范式

第一范式:原子性,字段不可再分

第二范式:在第一范式的前提上,消除部分依赖,比如复合主键,然后某个字段只依赖于其中某一个字段

第三范式:在第二范式的前提上,消除传递依赖,比如姓名依赖主键id,身份证却依赖姓名

反范式设计:

比如说,在某公司实习时开发的APP的项目中,查询推修任务信息,有一张表,是推修表,里面有个字段是报案号,可以根据报案号,去查案件表,案件表里面有报案人的信息,这种设计本身是复合第三范式的 ​ 但是,我现在,需求是,要列表查询推修表,每个推修任务,还需要额外展示报案人的信息,并且如果要根据报案号去查案件表,需要调用外部服务,而且这个外部服务的接口,只能一次查一个,那么也就是说,我如果列表查询一次查10个,那么就需要反复调用10次外部服务,这样性能消耗很大,而且不稳定,因此我选择把案件表的部分信息(报案人姓名,身份证,手机号)保存在推修任务表当中 ​ 这样就是为了提高查询性能,而做出的反范式设计,利用数据冗余,空间换时间

上面这种反范式,是面对分库分表,跨库查询的场景

另一种反范式的场景,是联表查询过多时候的场景,在同一个库当中,联表查询数量过多,可以反范式设计

反第三范式设计存在问题,就是冗余字段的数据一致性需要保证

同库数据:数据修改时,将冗余数据一起更新,放在mysql事务里面保证原子性

跨库数据:利用分布式事务进行更新

15.MySQL的存储引擎

常见的有三种:

1.默认的存储引擎InnoDB

最大的特点是提供了ACID兼容的事务机制,支持外键约束、支持崩溃修复能力和并发控制。一般适用于对事务完整性要求较高,例如银行,还有要求并发实现,例如收票等业务。InnoDB底层使用的数据结构是B+树,提供了良好的索引性能

2.MyISAM

MySQL最早提供的存储引擎,又分为静态、动态、压缩MyISAM。特点是没有事务支持,存在读写锁,读锁共享,写锁排他,并且表结构,表数据,索引信息是分三个文件存储的,读性能高,写性能差,适合读多写少的场景

3.MEMORY

特点是数据都存储在内存中,数据处理得较快,安全性不高。对表的大小有要求,一般只能用于小表

三.框架篇

1.Spring里的bean是否线程安全

Spring里面有个注解@Scope,默认值是singleton,因此Spring的bean默认条件下是单例的

但是具有线程安全问题

在多并发环境下,多个用户去访问一个服务,容器会给每一个请求分配一个线程,通常项目里的bean,例如Service类和DAO类的bean,是不可变的,因此不存在线程安全问题,但是如果在bean中定义了可以修改的成员变量,那么就需要考虑线程安全问题,可以通过设置多例或者加锁来保证线程安全

是否有线程安全问题,与单例无关,而是取决于bean是否有状态:即是否有可以修改的成员变量

2.AOP

AOP:即面向切面编程,是spirng框架的关键,指的是那些与业务逻辑无关,但是对多个业务方法产生影响的公共行为,可以抽取为一个公共模块进行复用,降低耦合

聚焦于非业务逻辑代码的解耦和复用

spring的AOP的实现原理就是动态代理,动态代理又分为jdk动态代理和CGLIB动态代理

JDK动态代理:

AOP默认使用的代理模式,只能代理实现了接口的类。底层实现原理运用到了两个重要组件,一个是Proxy类中newProxyInstance()方法,另一个是InvocationHandler接口的实现类。

简单来说,整体流程就是,通过Proxy类的newProxyInstance方法创建代理对象,这个方法里会传入三个参数:委托类的加载器、委托类实现的接口、InstanceHandler实现类对象。通过代理对象去调用方法时,会转发给InvocationHandler的invoke方法进行调用,invoke方法中就可以添加代理逻辑,然后通过反射调用委托类的方法。

JDK动态代理生成代理对象的底层原理是通过实现委托类的接口,这也是其只能代理实现了接口的类的原因。

CGLIB动态代理:

底层是借助了ASM这个操作Java字节码的框架,通过字节码生成委托类的代理类+FastClass,FastClass机制会对类里面的方法添加索引,代理类会实现MethodInterceptor接口,实现intercept方法,其中编写代理逻辑。代理类会继承委托类,重写其中非final的方法,作为代理方法,然后代理方法中先调用intercept代理逻辑,然后通过索引调用委托类的方法

底层是借助了ASM这个操作Java字节码的框架,通过字节码生成委托类的代理类,而这个代理类继承了委托类,只有在实例化创建代理对象时,会使用到反射。同时,代理类实现MethodInterceptor接口,重写intercept方法,在该方法中添加代理逻辑。

整体流程是,通过ASM操作字节码,生成代理类,将委托类作为其父类,并为父类的非final方法生成一个代理方法,这个代理方法会首先调用MethodInterceptorintercept拦截器方法,在拦截器方法当中调用目标方法,并在其前后添加代理业务

相较于JDK动态代理,每次都要通过反射调用委托类的方法,CGLIB底层是通过FastClass机制来进行方法调用,CGLIB除了生成代理类之外,还会生成代理类的FastClass和目标类的FastClass,FastClass机制就是对类里面的方法,添加索引。调用方法就不再是通过反射机制调用,而是通过索引来调用响应方法

同时,因为代理类底层是通过继承实现,因此被final修饰的类和方法无法代理

两者对比:

JDK动态代理通过内置的API实现动态代理,创建对象快,但是调用方法需要频繁使用反射,性能较差

CGLIB动态代理,需要用到ASM框架操作字节码,创建对象较慢,但是大部分操作避免了反射的性能开销,性能更好的同时没有JDK动态代理只能代理实现接口类的限制

Spring中默认使用JDK,遇到没有实现接口的类才使用CGLIB,也可以自己配置强制使用CGLIB

项目中使用到的AOP:

1.Spring实现的事务

我们常用的是声明式事务,对业务实现的方法上加@Transaction注解即可,底层调用的还是AOP功能,对方法前后进行拦截,在执行方法前开启事务,执行方法后根据情况提交或者回滚事务

2.手动实现的切面记录日志

首先编写一个切面类,添加Aspect注解,然后通知以方法的方式进行配置,编写一个方法,然后添加通知类型的注解,比如前置通知Before,后置通知AfterReturning,还有环绕通知、异常通知、最终通知。在注解当中配置切点表达式,用于指定切面配置的具体方法。然后这个方法需要携带一个JoinPoint连接点参数,这个参数可以可以调用方法Signature(),获取目标方法的签名,这个Signature就可以干很多事,比如获取方法名、方法参数、请求类型等等,可以把这些都保存到日志当中。而环绕通知中,JoinPoint还有个方法proceed,表示执行目标方法。这就是自己定义一个切面类的过程

总的来说,AOP就是把一些重复但与业务逻辑无关的操作整合起来,方便各种业务方法进行复用,降低耦合度

3.事务失效的情况

1.异常捕获处理

在开启事务的方法中,一旦出现异常,需要抛出被事务检测到,才能进行回滚操作,如果自己在事务方法中添加了try catch进行处理异常,那么事务就捕捉不到异常,导致事务失效。

解决办法就是在catch代码块里面手动抛出一个新的异常让事物检测到即可

2.抛出检查异常

Spring默认只会回滚非检查异常,如果添加事务的方法里,出现了检查异常,例如读取文件不存在这种异常,即使抛出被事务检测到,也不会回滚,导致事务失效。

检查异常就是在编译时会进行检查,必须明确处理,通常与外部资源相关

非检查异常就是编译时不检查,可处理可不处理,通常与程序逻辑相关

解决办法就是在@Transaction注解里配置rollbackFor属性为全部类型异常Exception.class即可

3.非public方法导致事务失效

Spring开启事务需要给方法创建代理,再添加事务通知,前提条件是方法必须是public才行

解决办法就是给方法添加上public就行

如果说必须对非public的方法添加事务,可以手动通过Aspect实现切面,或者说通过编程式事务进行添加

4.bean的生命周期

  • Spring读取配置,封装BeanDefinition对象

  • 需要bean时,根据BeanDefinition调用构造方法实例化bean

  • 对bean进行依赖注入

  • 处理Aware接口(如果bean实现了各种aware接口,需要实现其方法)

  • 处理beanpostprocessor前置初始化方法

  • bean的初始化方法(可自定义初始化,也可以实现接口重写方法)

  • 处理beanpostprocessor后置初始化方法

  • 销毁bean

其中bean的后置处理器beanpostprocessor需要实现这个接口然后重写方法

一般来说,Spring自带的一些AOP功能都使用到了后置处理器,一般都是在初始化之后调用

5.循环依赖

简单来说就是通过三级缓存+@Lazy注解懒加载的方式,处理通过set方法和构造方法依赖注入的循环依赖

懒加载现在也是默认配置

循环依赖也称之为循环引用,指的是两个或者两个以上的bean,互相持有对方,形成闭环的情况

会导致创建bean的时候,陷入循环,无法创建成功

Spring是允许循环依赖存在的,因为已经通过三级缓存解决了通过set注入的循环依赖情况

这里简单介绍下三级缓存

一级缓存:单例池,主要存放已经经历完整生命周期的,初始化完毕的bean对象

二级缓存:主要存放没有经历完整生命周期的bean对象

三级缓存:主要存放的是ObjectFactory也就是bean工厂,用来创建某个对象或者某个代理对象

具体流程是:

假设两个循环依赖的bean为A和B

A先实例化对象,然后A生成一个ObjectFactory对象到三级缓存

当A需要注入B的时候去实例化B

同时B也生成一个ObjectFactory对象到三级缓存

当B需要注入A的时候

先判断A这个bean是否被后置处理器增强,如果增强那么就需要生成一个A的代理对象

生成A的代理对象之后,先存入二级缓存,并把生成的对象注入B

B就创建完成,然后存入一级缓存

然后再将B给注入A的代理对象,A创建成功,存入一级缓存

二级缓存的主要作用是,保存ObjectFactory对象创建出来的bean对象,后续不需要再创建,直接从二级缓存里面获取

三级缓存只解决了通过set方法注入的循环依赖问题,如果是通过构造方法注入,就无法解决了

因为bean的生命周期中,构造函数是第一个执行的,三级缓存就无法解决了

解决方法是:在A的构造函数中,对依赖的B对象添加@Lazy注解,进行懒加载,意思是什么时候需要使用这个对象,再进行bean对象的创建。也就是说,B对象不在A实例化的时候注入,而是后续啥时候调用B,再创建B的对象

6.spirngmvc的执行流程

首先必须知道SpringMVC的主要组成部分

1.前端控制器DispatcherServlet

2.处理器映射器HandlerMapping

3.处理器适配器HandlerAdapter

4.视图解析器ViewResolver

由于当前的项目开发,大部分已经是前后端分离,还有一小部分老项目采取旧的前后端一体化的方式

因此springmvc的执行流程也分为两种情况

执行流程的前半部分是一样的

1.首先,前端发送请求,被前端控制器给接受到

2.然后前端控制器根据请求去调用处理器映射器handlermapping

3.handlermapping找到具体的处理器,把处理器对象和处理器的拦截器打包为处理器执行链返回给DispatcherServlet

4.然后前端控制器将handler传给处理器适配器handlerAdapter去请求执行handler

5.处理器适配器调用具体的处理器方法之后就产生分支

如果是前后端分离

那么处理器适配器调用具体的处理器方法上一般都带有ResponseBody注解

通过HttpMessageConverter消息转换器接口,把结果转换为JSON响应到前端

如果是一体化项目

处理器适配器会返回modleandview给DispatcherServlet,然后DispatcherServlet会调用视图解析器ViewResolver

视图解析器返回view对象给前端控制器,然后前端控制器去渲染视图,也就是html或者jsp文件,然后响应给前端

7.SpringBoot自动配置的原理

Springboot的引导类上有个@SpringBootApplication注解

这个注解去查看源码可以发现是对另外三个注解进行了封装

@SpringbootConfiguration

@ComponentScan(扫描包的注解,默认扫描主类所在的包及其子包)

@EnableAutoConfiguration

其中EnableAutoConfiguration是Springboot自动配置化的核心注解,该注解通过@Import注解导入对应的配置选择器

底层原理是,读取了该项目和该项目引用的Jar包的classpath路径下的spring.factorise文件中所配置的类的全类名,配置类中所定义的bean会根据条件注解所指定的条件来选择是否放入spring的容器管理

条件判断会有像@ConditionalOnClass这样的注解,判断是否有对应的class文件,如果有就加载该类,把该配置类的所有bean给添加到spring容器管理

简单来说

自动配置的原理,是通过启动类上的@springbootapplication注解当中的子注解@enableautoconfiguration,这个注解就是自动装配的核心,通过@imports注解导入配置选择器AutoConfigurationImportSelector,底层是扫描classpass路径下meta-inf/spring/下的一个imports文件中声明的自动配置类,然后根据条件注解@conditional判断是否满足生效条件,实现bean的自动注册

8.常见的注解

1.spring

使用在类上用于实例化bean的注解:

@Component @Controller @Service @Repository

后面三个都是第一个的别名,只是为了更好区分,Controller用于接口层,Service用于业务层,Repository用于持久层

使用在字段上进行依赖注入的注解:

@Autowired

@Resource

一些有关切面类的注解:

@Aspect、@Before、@After、@Around、@PointCut

2.springmvc

@RequestMapping

用于映射请求路径,放在类上作为类里方法的父路径

@RequestBody和@ResponseBody

前者是将前端发送的json字符串转化为java对象接收

后者是将返回值通过消息转化器转化为json字符串响应给客户端

@RequestParam和@PathViriable

前者是传统post请求下指定请求参数的名称

后者是适用于RestFul模式下,获取请求路径的参数

@RestController

@ResponseBody+@Controller:即默认当前这个处理器类下的方法都返回json字符串

3.Springboot

@SpringbootApplication下的三个子注解

@ComponentScan

@SpringbootConfiguration

@EnableAutoConfiguration

9.Mybatis的执行流程

  • 读取Mybatis的配置文件,加载运行环境和映射文件

  • 创建会话工厂,全局一个,一般放在静态代码块里创建,类加载时执行

  • 会话工厂创建SqlSession对象,包含了执行Sql语句的所有方法

  • 操作数据库的接口Executor执行器

  • Executor执行器里面有一个MappedStatement对象,里面封装了映射信息

  • 输入参数映射到MappedStatement

  • MappedStatement去与数据库交接

  • 最后输出映射结果

10.IOC

ioc:控制反转,是一种思想,也是一种设计模式,实现的主要方式就是依赖注入

ioc其实我个人理解为就是工厂模式的升华,一种大工厂模式

ioc的容器,也就是我项目中使用的spring容器,就是一个大工厂,只不过相较于传统工厂模式,写死了生产的对象,如果要修改生产的对象,就需要去改代码,耦合度高。而ioc这种设计模式,就是把生产的对象信息给放到xml文件里面,利用反射机制,根据xml文件中的类定义生成对应的对象。

这种方式把工厂和具体生成的对象,给分离开了,降低了耦合度,灵活性更高,生产的具体对象,实在程序运行时才决定的,而不是像传统工厂模式那样,在工厂的代码里面就写好了。ioc利用到的最关键的技术就是反射+

11.依赖注入的方式

依赖注入的方式:一个是通过@autowired和@resource注解,还有一个是通过构造函数注入。前者之间的区别是

@autowired注解是spring提供的,优先根据类型匹配bean,并且默认要求这个bean必须存在,可以配置require属性修改。存在同类型bean会报错,可以通过@primary和@qualifier这俩注解,前者标注的bean优先级更高,后者可以转换成根据类名进行匹配

@resource注解是jdk提供的,可以直接设置根据name还是type进行匹配,默认是根据类名匹配

这俩本质都是通过set方式进行依赖注入的

而构造函数依赖注入与这俩不同,或者说优势的地方在于,可以避免空指针,因为从bean的生命周期可以看到,构造函数早于依赖注入,可以避免依赖确实导致的空指针,不过@autowired注解本身也默认要求bean必须存在。另一点是,不依赖spring注解,方便单元测试

12.RPC

又叫做远程过程调用,其目标就是让远程服务调用对开发者透明。

RPC本身不是具体的协议,而是一种调用范式技术思想。它的核心目标是让程序员能够像调用本地方法那样调用远程服务。

RPC框架的主要目的,是为了优化分布式场景下,远程服务的调用

特点:

1.透明性(最核心):屏蔽网络细节和序列化/反序列化,让远程调用和本地调用一样简单

2.高性能:二进制传输,长连接通信,支持异步调用

3.多集成:和Nacos、Sentinel这些组件高度集成,集注册中心,微服务保护,负载均衡等功能为一体,保证分布式应用的可靠性

RPC的序列化/反序列化,类的序列化过程

总的来讲,就是把内存当中的对象/数据,转化成字节流然后进行传输,再把字节流还原成原始对象的过程

序列化:

1.先确定序列化的范围,一般只会序列化成员变量,部分协议会序列化类的元信息(类名,方法,接口,父类)

2.会把成员变量,根据类型,转换成字节

3.根据协议,按照固定的格式进行编码(二进制,文本格式:JSON)

4.部分协议会在字节流当中添加元信息

反序列化:

1.解析字节流,提取出数据和元信息

2.创建对象实例

3.把数据反射赋值给对象实例

13.拦截器和过滤器的区别

1.来源不同,拦截器基于spring框架,过滤器基于Servlet容器

2.作用范围不同,拦截器作用于Controller层前后,过滤器作用与Servlet层前后

3.适用场景不同,拦截器只针对某个具体接口,进行业务增强,过滤器针对的是整个请求,对所有接口都会进行过滤

4.拦截器需要spring环境,过滤器不需要,拦截器实现HandlerInterceptor接口,需要重写preHandler,postHandler,afterCompletion方法,过滤器实现Filter接口,重写doFilter方法

preHandler:在Controller执行前调用

postHandler:在Controller执行后调用

afterCompletion:在请求完成后调用

四.集合篇

1.集合的类图

集合的顶级接口有两个

1.Collection单列集合

2.Map双列集合

Collection接口下,主要是List和Set集合,前者有序可重复,后者无序不可重复

其中List主要是ArrayList和LinkedList,ArrayList底层是数组,LinkedList底层是双向链表

Set主要是HashSet和TreeSet,底层分别是哈希表和红黑树

Map接口下,主要有HashMap、HashTable、ConcurrentMap、TreeMap

其中较常用的是HashMap和ConcurrentMap,这俩底层都是哈希表,前者线程不安全,后者线程安全

2.数组的数据结构

数组:开辟了一段连续的内存空间,来存放相同数据类型数据的线性数据结构

为什么数组索引或者说下标都从0开始?

这就要介绍一下数组的寻址公式了,当输入索引后,寻址公式为:数组首地址+索引乘数组元素类型的大小

baseAddress + i * datatypesize

如果索引从1开始,那么就会变成数组首地址+索引-1再乘数组数据大小

这样就会多一次减法操作,对于CPU来说多一条指令,效率就低了

数组查找元素的时间复杂度

1.通过索引查找 O(1)

2.未知下标查找O(n)

3.未知下标但排序,二分查找,O(logn)

数组新增和删除元素的时间复杂度:O(1)~O(n)

3.ArrayList底层源码

ArrayList的底层,是通过动态的数组来实现的

jdk8之后 ArrayList创建时的初始容量为0,第一次添加数据的时候才会初始化为10

ArrayList在进行扩容的时候是原来容量的1.5倍,每次扩容都需要拷贝数组

添加数据的逻辑:

先计算当前集合的size+1后,数组的容量够不够,如果够那么直接添加数据,如果不够就对数组调用grow方法进行扩容

确保新增的数据有地方存之后,再将新元素存放到下标为size的位置上(因为数组下标从0开始),返回成功布尔值

4.数组和集合之间的转换

数组转换为集合:Array.asList方法

集合转化为数组:toArray方法。无参toArray方法返回 Object数组,传入初始化长度的数组对象,返回该对象数组

例如:

String[] str = {"aaa,"bbb"};

List<String> list = Array.asList(str);

String[] arr = list.toArray(new String[list.size()]);

注意,这种方式,适用于类数组,对于基本类型数组不适用(int[])

1,用Arrays.asList转List后,如果修改了数组内容,list受影响吗

会,因为数组转list,底层是调用了Arrays类中的一个ArrayList内部类对数组中的数据进行了封装,并没有new一个ArrayList

最终集合指向的和数组是同一个内存地址

2,List用toArray转数组后,如果修改了List内容,数组受影响吗

不会,因为List使用toArray方法之后,底层是new了一个新数组,对集合的数据进行了元素的拷贝,新旧数组没有关系

5.ArrayList和LinkedList的区别

1.先分析其底层数据结构

ArrayList底层是通过动态的数组实现的,LinkedList底层是通过双向链表来实现的

2.再分析操作数据的效率

从查找上来看:

ArrayList可以根据下标查找指定元素,时间复杂度是O(1)。LinkedList不支持根据下标查询,只能遍历链表

两种List遍历查询未知索引的数据时间复杂度都是O(n)

从增删改来看

ArrayList,只有尾部插入和删除时间复杂度是O1,其他部分需要挪动数组,复杂度是On

LinkedList,从头尾插入或者在已知节点增删复杂度是O1,未知节点增删复杂度是On

3.从内存空间占用来看

ArrayList底层是数组开辟的连续内存空间,内存上较为节省

LinkedList是双向链表,除了存储数据之外,还要存储两个指向前后连接的指针,更占用内存

4.从线程安全来看

两者都不是线程安全的

通常解决方式有两种:

在方法内创建局部变量使用,避免线程安全

使用线程安全的List集合,通过Collection.synchronizedList方法,传入集合对象,可以给集合加锁,使其线程安全

一般来说,选用ArrayList较多,因为开发项目不是做算法题,我们一般都是从数据库里拿数据,用到集合的情况一般更多涉及到的是查询

数据,查询数据ArrayList更方便,同时他占用的内存也更少

6.二叉树和二叉查找树

二叉树:

每个节点两个叉,分别指向左右子节点,子节点和父节点结构相同。

个人感觉普通二叉树非常类似链表,只不过因为是二叉,每个节点都有两种走向罢了

二叉查找树:

也叫有序二叉树,和二叉树的区别就在于,存储的数据是唯一的,也是有序的

规则是,每个节点,其左子树每个节点的值都小于该节点的值,其右子树每个节点的值都大于该节点的值

通常情况下,有序二叉树查找数据的时间复杂度为Ologn

但是有一种极端情况,就是每次插入的数据都更小或者更大,导致二叉树的节点只有左节点或者只有右节点,就变成了链表,这种时候查找数据的时间复杂度就变成了On

为了避免这种情况的出现,我们引出红黑树

7.红黑树

红黑树的底层是一个可以自平衡的二叉搜索树,过去也叫平衡二叉b树

首先先讲解一下什么是平衡二叉树AVL

平衡二叉树,就是在二叉查找树的基础上,添加了一个条件:每个节点的左右子树高度差不能大于一

而之所以加这样一个条件,就是为了防止出现二叉查找树退化成链表的情况,这样查询数据的复杂度就很不稳定

而为了满足这个条件,再插入新数据导致左右子树高度差大于一时,就需要对二叉树进行旋转,来保证其平衡性

讲完了平衡二叉树,再来讲红黑树

红黑树具有5个规则

1.每个节点不是红色就是黑色

2.根节点必须是黑色

3.叶子节点必须都是黑色

4.红节点的子节点都必须是黑色--可以推导出红节点的父节点必须是黑色,以及不能出现连续的两个红节点

5.从任一节点到叶子节点的所有路径都包含相同数目的黑色节点

需要注意,红黑树的叶子节点都是黑色空结点

这里就需要解释,为什么要有这几个规则

红黑树自己也是一个可以自平衡的二叉查找树,但是他和AVL不一样的是,他没有像AVL那样提出一个明确的平衡因子概念,也就是AVL的

左右子树高度不能相差超过1这条规则,它只通过自己的5条规则就维持了平衡的结构,解决了非平衡树的问题

红黑树的优势在哪?

既然AVL已经能解决非平衡树的问题,为什么HashMap还要使用红黑树呢

主要原因是因为红黑树的插入删除节点比AVL更好操作控制,虽然AVl的时间复杂度优于红黑树,但是差距不大,在目前cpu性能够强的背景下可以忽略不计,因此红黑树整体性能优于AVL

主要体现在,红黑树对数据增删时候的操作更方便,相比AVL来说,旋转更少,有时候不需要旋转,直接修改节点颜色就能满足5条规则

因此简单来说就是,红黑树相比AVL,对于二叉树的平衡要求没那么严格,但是调整效率更高,牺牲部分平衡性换来的是增删节点时,减少了旋转的操作,提高了调整的效率整体性能优于AVL

只有在搜索的次数远大于增删次数时,才会选择AVL

8.散列表/哈希表

HashMap的底层数据结构:哈希表,由数组和链表或红黑树组成

根据key直接访问内存存储的value

其原理是,将key通过Hash函数HashCode计算出hash值,对应当前元素的数组下标,然后通过数组下标去数组里获取value

但是这样就会产生哈希冲突:即多个不同的key,通过Hash函数的计算,指向同一个数组下标

解决方案:拉链法(即添加链表或者红黑树的结构)

指的是数组每个下标位置称之为bucket桶或者slot槽

每个槽会对应一条链表,但是链表的搜素效率很低,因此使用红黑树的结构,至于为什么不使用AVL,上一节有讲解

这样一来,出现哈希冲突的value,就放在相同槽位的链表或者红黑树节点中即可

存取原理:

在存储数据时,如果存在hash值相同的key,有两种情况

a.如果key相同,则覆盖初始的value

b.如果key不相同,就把当前的key-value放入当前下标对应槽的链表或者红黑树中

在获取数据时,直接找到hash值对应的下标,然后判断key是否相同,在链表或红黑树中遍历,找到key相同对应的value

jdk1.8之后HashMap的变化:

  • JDK1.8之前采用的是拉链法。拉链法:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。

  • jdk1.8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8) 时并且数组长度达到64时,将链表转化为红黑树,以减少搜索时间。扩容 resize( ) 时,红黑树拆分成的树的结点数小于等于临界值6个,则退化成链表

9.HashMap的put流程

  • HashMap是懒惰加载,意思是创建HashMap对象的时候不会初始化数组

  • 无参构造中,设置了加载因子是0.75,意思是,实际键值对到达数组最大容量的0.75时,对数组进行扩容,扩容到2倍

具体流程:

  1. 判断键值对数组table是否为空,也就是是否初始化,如果没有,那么就进行初始化,默认数组容量是16

  2. key进行hash计算后,通过hash值得到数组下标i

  3. 如果table[i] == null ,直接新建节点添加

  4. 如果table[i] == null 不成立

  5. 4.1 判断table[i]的首个节点的key是否和添加的key一样,如果一样,直接覆盖value

  6. 4.2 如果首个节点不一样,那么判断tabl[i]是否是红黑树,是就直接在树中插入节点

  7. 4.3 如果不是红黑树,那么就是链表,插入链表的尾部。并且判断链表长度是否超过8,数组长度是否超过64,如果满足就把链表改为红黑树

  8. 插入成功后,判断实际存在的键值对数量size是否超过阈值,数组长度*0.75,超过就进行扩容

扩容机制

  • 初始化和添加元素的时候,都需要判断是否使用resize方法进行扩容,初始化长度为16

  • 每次扩容都是扩容到之前的两倍,newCap = oldCap * 2

  • 扩容之后,创建一个新的数组,把旧数组数据挪到新数组中

    • 没有hash冲突的点,使用 e.hash & (newCap - 1) 计算新数组的索引位置

    • 如果是红黑树,走红黑树的添加

    • 如果是链表,可能需要拆分链表,需要遍历链表的每一个节点,通过判断(e.hash & oldCap)是否为0,决定元素留在本来的位置还是移动到新的位置:原下标+oldCap

      这里解释一下为什么判断(e.hash & oldCap)是否为0,其作用其实本质上是判断e.hash这个值与oldCap和newCap取模是否一致,具体的可以去查看源码:

      先new两个链表头,分别对应低位和高位,低位就是存放扩容后不改变位置的节点,高位存放扩容后改变位置的节点,在遍历完链表后,(e.hash & oldCap)==0的节点放入低位,其余放入高位,然后低位的链表放入本来的下标位置,高位的链表,放入原下标+oldCap的位置

&and与运算,等同取模,也就是%,只不过效率更高

10.HashMap的寻址算法

  • 通过hash(key)这个方法,获取key的hash值

  • hash这个方法里面,先获取hashcode(key)的值,然后右移16位,然后进行异或运算,获取hash值,这个也称之为扰动算法,主要目的是为了让hash值更均匀,减少hash冲突

  • 获取hash值之后,再与数组长度-1进行and与运算代替取模,获取最终的存储下标

这里就补充到,为什么HashMap的数组长度一定得是2的次幂

1.2的次幂方便进行与运算代替取模

2.方便进行与运算,可以让计算下标时还有进行扩容重新计算下标时,也就是需要利用到数组长度的时候,与运算代替取模,效率更高

11.HashSet和HashMap的区别

一个是Set集合,存储对象,一个是Map,存储的是键值对

HashSet底层是用HashMap实现的,用HashMap的key保存元素值,而value默认是一个Object虚拟对象

12.HashTable和HashMap的区别

hashtable之所以线程安全,是因为其底层方法都被synchronized修饰了,因此才线程安全

13.HashMap总结

8-10条全部总结一下

1.HashMap底层数据结构 数组+链表+红黑树 通过拉链法,解决Hash冲突问题

jdk1.8之后HashMap的变化:

  • JDK1.8之前采用的是拉链法。拉链法:将链表和数组相结合。也就是说创建一个链表数组,数组中每一格就是一个链表。若遇到哈希冲突,则将冲突的值加到链表中即可。

  • jdk1.8在解决哈希冲突时有了较大的变化,当链表长度大于阈值(默认为8) 时并且数组长度达到64时,将链表转化为红黑树,以减少搜索时间。扩容 resize( ) 时,红黑树拆分成的树的结点数小于等于临界值6个,则退化成链表

hashmap之所以由红黑树退化到链表的阈值不是8而是6,是因为红黑树和链表之间的反复转换是很消耗性能的,如果设置为7或8,那么很容易因为阈值相同,在7~8个元素之间波动导致来回转换,消耗性能

惰性加载:new的时候不会初始化数组,而是插入数据的时候初始化,初始化容量为16

2.扩容机制

HashMap的加载因子为0.75,指的是,如果size超过了数组长度*0.75,就会触发扩容机制

HashMap的扩容机制,每次都是扩容为之前数组长度的2倍,保证数组长度为2的次幂,这是HashMap按位与获取数组下标的基本条件

扩容过程:

new一个新数组,然后把旧数组的数据进行迁移

旧数组的数据下标,移动到新数组只有两种情况:1.还是原下标位置;2.原下标位置+旧数组大小

1.没有hash冲突的数据:数据hash值 & (oldcap - 1)获得新数组下标,本质上就是重新通过按位与做一次取模操作

2.有hash冲突

a.链表:遍历链表,通过 数据hash值 & oldcap 是否为0,可以判断出数据到新数组的位置,新建两个高低位链表对应两个位置,把不同情况的数据放入对应新链表,然后把链表插入新数组

b.红黑树:遍历红黑树,仍然通过 数据hash值 & oldcap 是否为0,把原本的红黑树,分成两棵子树,然后判断子树的节点数是否小于等于6,否则退化成链表,再插入新数组

3.寻址算法

二次hash,或者说扰动算法

先把数据通过hashcode()方法,获取初步hash值,然后把初值右移16位后的值与初值进行异或与运算,获得终值

这种方式,可以让hash值分布更平均,降低hash冲突的概率

然后把终值与 数组长度-1 进行按位与操作,获取数组下标

4.完整put流程

1.判断hashmap的table是否为空,如果为空,先初始化数组,容量为16

2.通过寻址算法,获取到数组下标

3.判断当前桶是否为空,如果为空,直接插入新节点

4.如果当前桶不为空,说明出现了hash冲突,解决hash冲突:

a.先判断当前桶的首个节点的key是否和插入数据的key相同,如果相同,替换value,如果不同,走后续流程

b.桶内为链表:遍历链表,如果有节点的key相同,替换value,如果都不同,那么插入新节点到链表尾部,然后判断链表长度是否大于8,如果大于8,并且数组长度大于64,链表进化为红黑树,如果数组长度小于64,提前触发数组扩容

c.桶内为红黑树:遍历红黑树节点,有key相同的替换value,没有就走红黑树的新增节点操作

5.解决之后,判断当前hashmap的size是否超过数组长度 * 加载因子0.75,如果超过,触发扩容机制

这里对首个节点判断,主要是因为首个节点,作为第一个插入进来的数据,出现hash冲突的概率最高,并且处理之后,后续不论是链表还是红黑树,都不需要再处理首节点

五.消息队列篇

1.为什么要使用消息队列

简单来说,使用消息队列的根本原因:是实现,微服务分布式场景下,通过异步调用的方式代替同步调用

同步调用:Java当中直接通过OpenFeign调用其他服务方法,Go当中通过Provider调用其他服务方法

异步调用:发送者(原调用项目)发送消息到MQ,然后接受者(被调用项目)监听MQ消费消息

好处:

1.耦合度更低

2.性能更好,原本流程无需阻塞,发个消息就可以继续本来流程

3.拓展性强

4.故障隔离,消费者方失败也不影响原本流程

5.流量削峰

缺点:

1.依赖MQ的可靠性

2.维护麻烦

例如

1.项目当中的用户秒杀券下单,判断库存足够后,发个消息,异步创建订单就行,原流程就可以直接返回成功

2.实习项目中,按照规则引擎审批通过的修理厂,可以直接返回审批通过,发个消息,让微服务异步完成注册+通知即可

2.RabbitMQ如何保证消息可靠性

保证MQ消息的可靠性无非是从三个方面入手

1.发送者可靠性

2.MQ的可靠性

3.消费者的可靠性

其中,比较关键的就是消费者的可靠性保证

3.生产者可靠性

简单来说,有以下两种方式保证

1.生产者重试机制

SpringAMAP提供了重试机制,在配置文件当中可以合理配置等待时间和重试次数

主要用于解决,因为网络问题导致发送消息时,与MQ连接断开的情况

2.生产者确认机制

分为Publisher Confirm和Publisher Return两种

前者是,在消息发送到MQ之后,用来判断是否成功,成功返回ACK,失败返回NACK

后者是,在消息发送失败后,返回异常信息

生产者确认机制有同步/异步两种回执模式,推荐异步

主要用于解决:

a.MQ内部异常

b.发送到MQ,但是找不到交换机异常

c.找到交换机,但是未找到合适的队列

可以看到,后两种情况,都是编程异常,是我们可以排查避免的,而第一种是MQ内部的问题,概率极小

而且开启生产者确认机制很消耗MQ性能,所以说一般业务场景无需开启

4.MQ可靠性

1.数据的持久化

可以在控制台的Exchange和Queues页面当中配置交换机,队列,消息的持久化

RabbitMQ的刷盘和AOF,redolog,binlog类似,都是先保存到缓冲区,然后按一定的刷盘策略刷盘,默认异步,存在风险,可以改为同步

另外,RabbitMQ的队列采用镜像队列的方式,类似主从集群和kafka的副本,对队列的消息进行备份,master挂了之后,会选择slave队列进行代替

2.LazyQueue机制:解决消息积压问题

队列当中的消息,过去默认是存放在内存当中的,方便消息的收发,但是存在问题

a.消费者宕机导致消息堆积

b.消费者处理速度不够,发送量过大

c.消费者业务堵塞

简单来讲就是,消费者来不及处理队列当中的消息时,队列如果是内存存储,就会导致内存占用过高的问题

解决方法就是LazyQueue惰性队列机制:

  • 接收到消息后直接存入磁盘而非内存

  • 消费者要消费消息时才会从磁盘中读取并加载到内存(也就是懒加载,最多2048条)

  • 支持数百万条的消息存储

RabbitMQ从3.12之后,队列都是默认惰性队列了

5.消费者可靠性(关键)

消费者这一块存在的问题

  • 消息投递的过程中出现了网络故障

  • 消费者接收到消息后突然宕机

  • 消费者接收到消息后,因处理不当导致异常

  • ...

第一步必须解决的是:RabbitMQ必须知道消费者的处理状态

1.消费者确认机制

和生产者类似,RabbitMQ消费者处理完消息后,会发送给MQ一个回执(生产者确认机制是MQ发送回执给生产者)

回执有三种:

a.ack,表示消费成功,MQ删除该消息 b.nack,表示消费失败,MQ重新投递该消息 c.reject,消费失败,但是MQ同样删除该消息

SpringAMQP实现了消息确认机制,对应三种模式配置

  • none:不处理。即消息投递给消费者后立刻ack,消息会立刻从MQ删除。非常不安全,不建议使用

  • manual:手动模式。需要自己在业务代码中调用api,发送ackreject,存在业务入侵,但更灵活

  • auto:自动模式。SpringAMQP利用AOP对我们的消息处理逻辑做了环绕增强,当业务正常执行时则自动返回ack. 当业务出现异常时,根据异常判断返回不同结果:

    • 如果是业务异常,会自动返回nack

    • 如果是消息处理或校验异常,自动返回reject;

auto模式下,返回reject的异常,通常有转格式异常,方法参数错误异常等即使重新发送消息,还是会报错的异常

2.消费者重试机制

可以看到,消费者确认机制里面,我们一般都是配置manual或者auto,存在部分消息,如果失败,返回nack回到队列,然后重复消费的情况,极端情况是,某些消息永远无法执行成功,那么消息就会无限循环,MQ的压力飙升,性能就会下降

因此有消费者重试机制,即在配置文件当中配置,开启本地重试,也和生产者的重试机制类似,配置失败等待时间和重试次数

超过重试次数的消息就会被reject

3.失败处理策略

对于消息可靠性要求高的情况下,重试几次后就把数据reject抛弃掉,是很不合适的

因此Spring是允许自定义,超过重试次数之后的失败处理策略的,这个策略是由MessageRecovery接口来定义的,它有3个不同实现:

  • RejectAndDontRequeueRecoverer:重试耗尽后,直接reject,丢弃消息。默认就是这种方式

  • ImmediateRequeueMessageRecoverer:重试耗尽后,返回nack,消息重新入队

  • RepublishMessageRecoverer:重试耗尽后,将失败消息投递到指定的交换机

比较优雅的一种处理方案是最后的RepublishMessageRecoverer,失败后将消息投递到一个指定的,专门存放异常消息的队列,后续由人工集中处理。

6.兜底方案:定时任务

虽然我们利用各种机制尽可能增加了消息的可靠性,但也不好说能保证消息100%的可靠。万一真的MQ通知失败该怎么办呢?

通常我们采取的措施就是利用定时任务定期查询,例如每隔20秒就查询一次,并判断任务状态。如果发现任务已经完成,则再立刻更新状态即可

举个例子:

从实习项目当中举例:

修理厂入驻审批成功后,发送消息,App服务这边消费消息,可以写一个定时任务去查修理厂主表,对入驻成功的修理厂,进行定时判断App服务这边是否注册

只不过我具体的实现不是这样,在后续我会记录

7.延迟消息

实现方式:

1.死信交换机+TTL

什么是死信:

  • 消费者使用basic.rejectbasic.nack声明消费失败,并且消息的requeue参数设置为false(不能重新入队)

  • 消息是一个过期消息,超时无人消费

  • 要投递的队列消息满了,无法投递

如果一个队列A当中的某条消息成为死信,并且队列A配置了dead-letter-exchanger属性指定了一个交换机,那么这些死信就会被投放到这个交换机当中,此交换机就是死信交换机,死信交换机也会设置RoutingKey,指定一个队列作为死信队列

死信交换机的作用:

  1. 收集那些因处理失败而被拒绝的消息

  2. 收集那些因队列满了而被拒绝的消息

  3. 收集因TTL(有效期)到期的消息

可以看到,前两个作用,其实和失败处理策略当中的RepublishMessageRecoverer很像,只不过一个是重试次数超过阈值,而死信交换机是TTL到期

两者的适用场景不同 死信交换机是用于消息被拒绝且无法入队,TTL过期,或者队列溢出的时候用 RepublishMessageRecoverer用于重试次数过多,业务逻辑异常的时候用,例如数据库临时不可用等情况

还有一点需要注意,这个TTL并非完全准确,因为一个消息即使过期,也是在排队的,只有到队首的时候,才会被处理

2.延迟消息插件

DelayExchange插件,这玩意就是RabbitMQ自带的,也不需要引入额外依赖了

通过注解或者Bean配置好延迟交换机

然后发消息的时候必须通过x-delay属性设定延迟时间(setDelay方法)

需要注意,这个插件底层是在本地维护了一个数据库表,通过CPU去计时,因此延迟时间不能设置过长,不然延迟消息多了,CPU开销就很大

经典的使用场景就是,超时支付问题

我们本来的支付方式是,下单并且支付后,才会发送MQ消息,然后监听消费者拿到消息,异步更新订单状态

现在我们修改成,下单之后,创建一个延迟消息,设置一个TTL,比如30分钟

延迟队列不设置监听消费者,那么消息到期后,进入死信交换机和死信队列,死信队列的监听消费者,会对这些消息进行统一处理,如果已支付,那么返回ack删除消息,如果未支付,那么会删除订单,恢复库存,再删除消息

同时保留原本的消息队列,支付后,会发送一个MQ消息,然后让其队列的监听消费者,对已支付的订单进行处理

可以理解为,延迟队列就是在原本的队列基础上,再加了一个计时器队列,创建订单的时候就会进入延迟队列,中途已支付的消息,会进入之前的即时队列,最终,所有消息都会进入死信交换机和死信队列,已支付的订单会被忽略,没支付的会被处理

8.项目中的MQ实战

业务场景: 原本的旧服务当中,业务逻辑是通过规则引擎判断修理厂是否入驻成功 现在要加一个新的功能:入驻成功的修理厂,需要给其注册一个APP账户 而APP这一块的服务在新服务上

实现过程:

1.原本服务中,入驻成功后,会直接在主表插入数据,记录is_send_msg字段为false,记录发送消息是否成功,这俩操作通过事务保证原子性,为了不阻塞原本流程,直接通过RabbitMQ消息队列,异步注册APP账户

2.原服务作为生产者,把修理厂编码和员工电话,garageCode、staffPhone字段,传入MQ,因为注册App账户需要用到,这里会利用生产者重试+确认机制,尽可能的保证生产者的可靠性,如果确认机制发现MQ内部出现异常,或者因为网络问题,重试次数抵达上限,会通过is_send_msg字段,记录当前修理厂是否发送消息注册App,用作定时任务补偿操作。这里的重试机制,有可能会导致原本服务的流程阻塞,因此我选择,开辟一个线程池去执行重试操作,避免主线程堵塞

3.App服务作为消费者,监听队列,我设置的消费者确认模式为SpringAMQP提供的manual,可以手动选择回执ack还是reject,拿到garageCode字段之后,第一步是先去判断注册条件:从修理厂主表中查询当前garageCode数据是否存在,因为入驻成功的修理厂,都会在主表当中记录,如果主表当中不存在,消息会直接reject,抛弃掉消息,这种情况很极端,因为旧服务当中的逻辑是,主表插入修理厂数据之后才会发消息

4.如果主表当中数据存在,我会先去register表当中查,当前garageCode是否注册过,用作幂等性判断,如果没有注册过,我会在register表中新建一条数据,如果注册过,说明这有可能是一次补偿操作,那么就直接拿到之前注册的这条数据,但不管如何,只要这个判断幂等性的方法里面没有报错(因为都是数据库操作,查询/新增),我都认为这条消息处理完毕,因为我已经在register表当中记录了,然后消息会返回ack,如果中途出现了错误,那么只可能是数据库操作问题,概率极小,有可能是网络问题,返回nack,触发消费者重试机制。

5.如果消费者重试机制到达次数上限,说明有可能是数据库崩了,此时,我通过失败处理策略RepublishMessageRecoverer,将该消息投递到一个专门存放异常消息的队列,交给人工处理

6.顺利通过第4步之后,我会拿到一条register表的新/旧数据,然后根据这个数据,继续完成后续的注册、短信通知操作

7.同时,我编写了定时任务,定时去查register表,因为消息队列那里,所有ack,也就是消费成功的数据,在register都有保存,剩下的因为极端情况数据库崩溃,没有保存的数据,也都在异常消息的队列当中,我在register表当中还保存了其他字段:注册是否成功,短信通知是否成功,以及一个补偿计数器,这个定时任务会去查注册、短信通知没有成功,且补偿次数小于5的数据,然后重复业务逻辑,进行补偿操作,同时补偿次数会加1

8.这里之所以会设计字段,记录注册是否成功和短信通知是否成功,是因为这俩都是调用的外部服务,为了保证业务的可靠性,所以说利用这种状态持久化+熔断处理的方式实现

AI生成的,利用线程池异步实现生产者重试机制

 @Service
 public class GarageRegisterService {
 ​
     // 独立线程池处理消息发送重试,与主业务线程池隔离
     @Bean
     public ExecutorService messageSendExecutor() {
         return new ThreadPoolExecutor(
             5, 10,
             60L, TimeUnit.SECONDS,
             new LinkedBlockingQueue<>(1000),
             new ThreadFactory() {
                 private final AtomicInteger counter = new AtomicInteger(0);
                 @Override
                 public Thread newThread(Runnable r) {
                     return new Thread(r, "message-send-worker-" + counter.incrementAndGet());
                 }
             },
             new ThreadPoolExecutor.CallerRunsPolicy() // 队列满时让提交者线程执行,避免消息丢失
         );
     }
 ​
     @Autowired
     private ExecutorService messageSendExecutor;
 ​
     @Autowired
     private RabbitTemplate rabbitTemplate;
     
     @Autowired
     private RetryTemplate retryTemplate;
     
     @Autowired
     private GarageRepository garageRepository;
 ​
     /**
      * 修理厂入驻成功后,异步触发APP注册消息发送(带重试)
      */
     @Transactional
     public void afterGarageRegistered(Garage garage) {
         // 1. 主流程中仅更新状态为"待发送",确保事务内快速完成
         garage.setIsSendMsg(false);
         garageRepository.save(garage);
 ​
         // 2. 将消息发送与重试逻辑提交到独立线程池,不阻塞主流程
         messageSendExecutor.submit(() -> {
             try {
                 // 3. 执行带重试的消息发送
                 sendRegisterMessageWithRetry(garage);
             } catch (Exception e) {
                 log.error("消息发送最终失败,garageCode: {}", garage.getGarageCode(), e);
                 // 此处无需更新is_send_msg,定时任务会基于false值进行补偿
             }
         });
     }
 ​
     /**
      * 带重试的消息发送逻辑
      */
     private void sendRegisterMessageWithRetry(Garage garage) {
         retryTemplate.execute(context -> {
             // 构建消息
             GarageRegisterMessage message = new GarageRegisterMessage();
             message.setGarageCode(garage.getGarageCode());
             message.setStaffPhone(garage.getStaffPhone());
 ​
             // 发送消息(同步等待MQ确认)
             rabbitTemplate.convertAndSend(
                 "garage.exchange", 
                 "garage.register.app", 
                 message,
                 new CorrelationData(garage.getGarageCode())
             );
             return null;
         });
 ​
         // 4. 发送成功后,更新状态(独立线程中执行,不影响主流程)
         garage.setIsSendMsg(true);
         garageRepository.save(garage);
     }
 }

9.RabbitMQ和Kafka的区别

rabbitmq相较于kafka,两者最大的区别就是吞吐量不同,前者吞吐量低,万级到十万级,后者吞吐量高,百万级。并且前者支持延时任务,死信队列等功能,后者不支持

rabbitmq专注于配置灵活的交换机路由配置,有4种核心交换机

Derect:按routing key精准匹配队列 Topic:利用通配符匹配存在单个/多个词的队列 Fanout:忽略routing key ,发送给所有绑定的队列 Headers:忽略routing key,根据消息头的键值对匹配

kafka之所以快有以下三个原因

1.顺序io:broker当中每一个分区,存储为一组日志文件,读写都是顺序io,速度更快 2.批量操作:生产者和消费者,提交和拉取消息都是批量操作 3.零拷贝技术:使用操作系统的零拷贝技术,减少数据传输到网络接口时,数据拷贝的次数

10.kafka消息可靠性和消息积压

首先要了解kafka的中间件结构,消息队列分为生产者,MQ,消费者

MQ也就是broker,kafka的broker是brokers集群模式,消息会根据topic进行分类,每个topic类的消息,会被拆分成多个分区partition,存储到各个broker节点当中,实现负载均衡。

kafka消息的可靠性,主要通过生产者确认机制,消费者设置手动提交ack,然后broker设置本地持久化,并且合理配置副本数。这个副本数,就是某个消息存储到某个broker的某个分区上时,如果这个broker节点挂了,消息就会丢失,因此会copy一个副本,也就是备份,存放到其他broker当中,这个副本只负责数据备份,读写还是在原来的leader分区上进行

kafka消息积压的问题,我认为是分为预防和解决两个方面。

预防主要是合理的分配broker当中队列分区和消费者组当中的消费者数量,因为消费者和分区是一一对应的关系,消费者配置过多并不能解决消息积压的问题。

如果已经产生消息积压,那么需要判断,如果消费者数量小于分区数,可以适当添加消费者数量,如果消费者数量到达上限,那么应该优先排查是否是消费者的业务逻辑太慢,因为慢SQL,大文件等问题导致消费者消费速度太慢。另外可以适当添加分区数和消费者数量,提高并发消费能力,缓解消息积压问题

消息积压问题,不管是rabbitmq还是kafka,解决都是从三个方向入手。

1.减少生产者生产效率,对生产端进行限流和降级处理。

2.提高消费者效率,rabbitmq可以增加消费者数量,kafka可以在消费者组实例在不超过分区数时添加消费者实例,如果抵达上限,可以添加分区数量和消费者实例,缓解压力。

3.对队列消息进行处理,rabbitmq设置优先级队列,让核心消息先处理,kafka设置偏移量为最新,优先处理实时消息,控制topic数据保留时间,定期清理积压消息

11.kafka实现延迟队列

kafka本身不支持延迟队列的功能,但是可以通过ZoomKeeper的时间轮的方式去实现

简单来说,就是设置一个延迟消息topic

消息最开始,需要携带过期时间戳存入延迟消息topic

服务端开启时间轮组件,按照一定的时间频率,去扫描延迟消息topic当中的信息,本质上是检查消息是否抵达过期时间

过期的消息,会被重新投放到正式的topic当中进行延时后的消费

和RabbitMQ的延迟队列不同,RabbitMQ的延迟队列,是专门开启一个新的延迟队列,设置TTL,过期之后,会自动进入死信交换机然后交给死信队列进行处理。只需要生产者把消息投入死信交换机,消费者监听死信队列即可

12.kafka和zookeeper

kafka会搭配zookeeper使用,zookeeper的作用主要是类似于Redis的哨兵模式,监听Redis集群一样,zk会监听broker集群,确保某个broker挂掉的时候,让其中丢失的leader分区,能找到副本进行替代。另一个作用是存储集群数据,例如topic分区分配,以及broker列表等信息

一句话总结就是,zk对kafka来说,是一个哨兵+配置文件的组合体

13.分布式事务

一般来说利用分布式事务框架解决:Seata

三个组成角色:

  • TC (Transaction Coordinator) - 事务协调者:维护全局和分支事务的状态,协调全局事务提交或回滚。

  • TM (Transaction Manager) - 事务管理器:定义全局事务的范围、开始全局事务、提交或回滚全局事务。

  • RM (Resource Manager) - 资源管理器:管理分支事务,与TC交谈以注册分支事务和报告分支事务的状态,并驱动分支事务提交或回滚

简单来说,TC事务协调者是一个监控管理中心,用来把控全局状态,TM是全局事务的具体执行者,RM是分支事务的管理者

TM发送指令,给TC,TC再协调RM进行操作

常用分布式解决方案:XA和AT

XA:分两阶段,第一阶段,事务协调者让所以资源管理者执行本地事务,本地事务完成后会把执行状态告诉协调者,并且事务不提交,第二阶段,协调者判断所有资源管理者的事务执行状态,如果都成功,则通知所有分支事务提交,有一个失败,那就通知分支事务回滚,特点是,第一阶段,所有分支事务不会提交,数据库会被锁住

AT:和XA不同,AT分支事务执行后会直接提交,并且通过undo log保存数据快照,也就是之前的数据版本,不会对数据库的资源一直锁住,提交事务后,同样告诉协调者执行状态,如果都成功,通知RM删除undo log,如果有失败,利用undo log进行事务回滚

最大区别就是AT不会让事务不提交,数据库不会锁住,性能更好

TCC:补偿事务,把分布式事务分为三个阶段:Try、comfirm、cancel,尝试,确认,补偿,尝试阶段,会对资源进行一个预占锁定,保证后续可操作,确认阶段,基于预占的资源进行处理,补偿则是如果确认阶段失败,会对尝试阶段的预占数据进行释放。同样和AT模式一样是保证最终一致性,但是因为确认和补偿可能进行多次,需要做好幂等性保证,而且高业务入侵,需要手动编写try、comfirm、cancel方法

SAGA:核心是把长事务拆解成一个个短事务,对每个短事务编写补偿操作,然后对多个短事务进行正向执行,一个执行完,通知下一个执行,某一个执行失败,就会进行反向补偿。业务入侵弱,只需要编写补偿代码,适合长事务长期

14.事务消息

RocketMQ就提供了事务消息的功能,底层实现逻辑是

1.发送半事务消息,这个消息不能被消费

2.执行本地事务

3.本地事务成功,发送提交指令,MQ将半事务消息标记为可消费 本地事务失败,发送回滚指令,MQ将半事务消息删除 状态未知,MQ会定时回查生产者的事务状态,根据回查结果决定提交还是回滚

rabbitMQ的事务消息

  1. 开启RabbitMQ事务

  2. 执行本地事务

  3. 发送消息到MQ,如果事务成功且消息发送成功,提交事务,任一失败都会回滚事务

六.多线程篇

1.什么是进程和线程

进程(Process) 是计算机中的程序关于某数据集合上的一次运行活动,是系统进行资源分配和调度的基本单位,是操作系统结构的基础。进程是线程的容器。

线程(thread) 是操作系统能够进行运算调度的最小单位。它被包含在进程之中,是进程中的实际运作单位。

进程:执行一段程序,开启一项工作,就是进程

线程:一段进程里,可以同时包含多个线程,例如一项工作,可以多个人同时合作去完成

线程切换的过程

  1. 保存当前线程的上下文:操作系统首先保存当前线程的上下文,将当前线程的寄存器、PC 和 SP 等信息保存到内存中,以便稍后恢复。

  2. 选择下一个线程:操作系统从就绪队列中选择下一个要运行的线程。这个选择可以基于调度算法,如先来先服务(FCFS)或轮转调度(Round Robin)。

  3. 恢复下一个线程的上下文:操作系统从内存中加载下一个线程的上下文,包括寄存器的值、PC 和 SP 等。

2.什么是并发和并行

并发:多个线程轮流使用一个或者多个CPU

并行:有多个CPU,分别负责一个线程

3.创建线程的四种方式

1.继承Thread类

2.实现runnable接口

3.实现Callable接口

4.利用线程池创建线程

Callable接口call方法有返回值,并且允许抛出异常。

返回值通过FutureTask类的get方法获取

线程池可以通过Excutors类创建jdk提供的4种默认线程池,也可以通过ThreadPoolExcutor自定义线程池的7个核心参数创建线程池

线程池执行任务的方法有:execute和submit,submit会返回Future类的对象,可以通过Future类的get方法获取结果和异常处理

java8之后,有Future类的升级版,CompletableFuture

其特点是

1.相较于Future的get方法,需要阻塞等待结果,CompletableFuture通过whenComplete这种回调方法回调获取,无需阻塞

2.Future只能单个任务异步执行,CompletableFuture支持多任务串行thencompose和并行执行allof

3.Future通过get方法捕获异常,CompletableFuture可以链式处理异常

4.线程池

核心参数7个

  1. corePoolSize 核心线程数目 - 池中会保留的最多线程数

  2. maximumPoolSize 最大线程数目 - 核心线程+救急线程的最大数目

  3. keepAliveTime 生存时间 - 救急线程的生存时间,生存时间内没有新任务,此线程资源会释放

  4. unit 时间单位 - 救急线程的生存时间单位,如秒、毫秒等

  5. workQueue - 阻塞队列,当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务 救急线程是按需逐个创建的,直到达到最大线程数上限

  6. threadFactory 线程工厂 - 自定义线程创建的过程,自定义线程属性(名称,优先级,守护线程,异常处理器等)

  7. handler 拒绝策略 - 当所有线程都在繁忙,阻塞队列也放满时,会触发拒绝策略

我们通过银行的场景来记忆:

某日银行只开启了3个柜台,这就是核心线程数目

还有2个柜台没开,一共5个柜台,这就是最大线程数目

银行的等待座位假设有3个,这就是阻塞队列

如果等待座位坐满了,又来了新的顾客,那么就会开启额外柜台,这就是救急的线程(救急线程是按需逐个创建的,直到达到最大线程数上限

如果等待的顾客已经少于等于3了,那么多的柜台就可以暂时关闭了,这就是生存时间

如果5个柜台都在工作,等待的人也超过3个了,那么多的顾客就知道被拒绝,这就是拒绝策略

如果救急线程到达存活时间,但是还有任务没完成,那么救急线程会先完成任务,只有救急线程处于空闲状态,并且到达存活时间,才会被结束

线程池默认也是懒加载的,初始化的时候不会创建任何线程,一个任务进来才会创建一个核心线程 但是面临高并发项目的时候,也可以调用prestartCoreThread()和prestartAllCoreThread()方法提前创建1个或全部核心线程

线程池的优点:

提高线程的使用率:如果我们自己手动创建线程,每次创建还要释放,然后使用又要重新创建

提高线程的响应速度:每次创建线程池之后,线程对象已经在池内创建好,直接用即可

便于统一管理

可以控制最大并发数量

阻塞队列的种类

比较常见的有4个,用的最多是ArrayBlockingQueue和LinkedBlockingQueue

1.ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO。

2.LinkedBlockingQueue:基于链表结构的有界阻塞队列,FIFO。

3.DelayedWorkQueue :是一个带延迟时间的队列,数据入队会保存一个延迟时间,只有延迟时间<=0的时候,才会出队,底层通过最小堆的数据结构实现,按照剩余延迟时间排序,延迟时间短的在前

4.SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。

关键区别是:

一个强制有界,一个默认无界

数据结构不一样,一个基于数组,一个基于链表

锁的数量不一样,ArrayBlockingQueue是1把锁,读写互斥,LinkedBlockingQueue是2把锁,读写分离,并发性能更好

核心线程数量

① 高并发、任务执行时间短 -->( CPU核数+1 ),减少线程上下文的切换

② 并发不高、任务执行时间长

  • IO密集型的任务(文件读写,DB读写) --> (CPU核数 * 2 + 1)

  • 计算密集型任务(计算型代码,Bitmap转化) --> ( CPU核数+1 )

线程池种类

在jdk中默认提供了4中方式创建线程池

1.FixedThreadPool 特点是构造时可以指定核心线程数量,最大线程数量等于核心线程数量,没有救急线程,但是阻塞队列的长度是Integer.MAX,阻塞队列默认为LinkedBlockingQueue,适用于已经确定好需要多少个线程的业务

2.CachedThreadPool 特点是,核心线程数为0,但是最大线程数是Integer.MAX,阻塞队列是SynchronousQueue不存储元素,也就是说,只要有新的任务进来,需要多的线程,就开启新的救急线程,如果此时不需要多的线程,那么存活时间到了,救急线程也会释放。适合高并发时间短的业务

3.ScheduleThreadPool 特点是自定义核心线程数,使用的是DelayWorkQueue,可以实现周期执行还有定时执行等功能

4.SingleThreadExcutor 特点是核心线程和最大线程都是1,阻塞队列长度为Integer.MAX,意思是全程只有一个线程在执行任务,适用于可以保证任务执行顺序的情况

一般推荐使用ThreadPoolExecutor来自定义创建线程池,这样可以明确规定线程池的参数,避免资源的耗尽。

如果使用Executors创建线程,他允许的请求队列/创建线程的默认长度是Integer.MAX_VALUE,可能导致大量请求或者线程堆积,导致OOM内存溢出

拒绝策略种类

1.AbortPolicy:默认的拒绝策略,直接抛出异常,停止运行

2.CallerRunsPolicy:让调用线程自己执行该任务

3.DiscardPolicy:直接丢弃掉新提交的任务,不报异常

4.DIscardOldestPolicy:丢弃掉阻塞队列当中最旧的任务,优先处理新任务

5.ConcurrentHashMap和HashMap的区别

前者是线程安全的

jdk1.7(1.8之前):采用 “Segment 数组 + HashEntry 数组 + 链表” 的双层结构。

- 外层是 Segment 数组(默认大小 16),每个 Segment 本质是一个独立的哈希表,包含 HashEntry 数组(链表数组)。

- 数据存储在 HashEntry 中,HashEntry 包含 key、value、next 指针和 volatile 修饰的 value(保证可见性)

锁机制:基于 “分段锁(Segment 锁)”

- 每个 Segment 是一个独立的可重入锁(ReentrantLock),对 Segment 的操作需要先获取该锁。

- 不同 Segment 上的操作可以并发执行,互不干扰。

锁的细粒度比较粗,同时支持最多16个线程并发

哈希算法:第一次哈希,确定Segment数组下标,第二次哈希确定HashEntry数组下标

扩容机制:针对每个Segment单独扩容成之前的两倍

1.8之后ConcurrentHashMap底层的数据结构就和HashMap一样了:数组+链表+红黑树

放弃了Segment臃肿的设计,采用 CAS + Synchronized来保证并发安全进行实现

1.CAS思想控制数组的初始化、扩容、新节点的插入

  • 初始化表时使用 CAS 确保多个线程不会重复初始化

    • 初始化表的时候,检查table是否为null,如果为 null,尝试通过 CAS 将 table 设置为新的数组。

  • 计算size()的时候保证并发安全

    • 维护 baseCount变量,保证元素计数不会出现并发问题

  • 扩容

    • 对负责扩容的线程设置线程标识,只有一个线程可以初始化新表

    • 转移数据的时候,多个线程会协作转移,迁移时通过CAS保证线程安全,也就是迁移前会检查

2.synchronized只锁定当前链表或红黑二叉树的首节点,保证同一个桶内数据操作的并发安全,不影响其他桶的数据操作,降低锁的细粒度,提高了并发效率

6.CAS

CAS的全称是: Compare And Swap(比较再交换),是一种用来实现乐观锁思想的技术

在CAS中有三个值:

V:要更新的变量var

E:预期值expect

N:新值new

过程就是比较V和E是否相等,如果相等,将V改为N,如果不等,那么说明其他线程已经修改了V,大多情况下CAS与自旋锁一起使用,那么当前线程如果判断V和E不相等,即获取锁失败,会不断循环尝试获取锁的状态,尝试获取锁,这就是自旋锁

可以看出,CAS的底层操作就两个,比较V和E,修改V等于N,那么为了保证多线程的安全问题,必须保证这两个操作的原子性。而Java中CAS的原子性,是通过硬件指令的原子性实现的。在Java中,使用到CAS的方法在JUC包下的Atomic包的Unsafe类下的被native修饰的方法,底层是交给jvm用c或c++语言实现,具体实现是靠操作系统和CPU

据我了解CPU实现原子指令的方式主要有两种:总线锁定缓存锁定,前者通过Lock#信号,后者通过缓存一致性协议

自旋锁1.6之后默认开启,默认自旋次数是10次,超过自旋次数之后,会让出CPU进入等待状态

另外,CAS常常也伴随着volatile关键字使用,因为被volatile修饰的变量多线程可见,同时,也禁止了指令重排序的可能性

CAS的典型问题:ABA

CAS作为一种乐观锁的思想,底层是先比较内存中的值和我们预期的值是否相同,再进行数据修改

但是会出现一个问题,线程1的期望值为A,想要对其进行修改,但是期间线程2先将这个A改成了B,再修改回了A,此时,线程1进行CAS操作也能正常通过判断。

举个简单例子,用户余额有100元,多线程操作下,会安排线程去扣除50元,线程1检查余额为100之前,其实线程2已经扣除过50了,但是又并发操作又转进去50,导致此时余额还是100,线程1就会进行重复扣款的操作

解决方法:Java中提供了版本标识和mark标记两种方式解决ABA问题

版本标识:在原本的判断原值和期盼值的条件基础上加上判断版本标识是否一致

mark标记:判断值是否被修改过,使用较少

举个例子:

ConcurrentHashMap就是通过CAS自旋锁以及syn来保证线程安全的

我们知道ConcurrentHashMap在jdk1.8之后就不再采取过去多段数组+链表的设计,底层的数据结构转变为何HashMap一样了。而CAS呢,保证了初始化数组、插入新节点、数组扩容等操作的线程安全问题

通过查看底层源码就可以发现,首先,初始化的table以及扩容时创建的新table都是被volatile修饰多线程可见的,同时,初始化的时候,会先判断table是否已经创建,如果table==null或table长度为0,该线程才会初始化数组,这就是典型的CAS运用,可以有效提高性能。但是会面临ABA还有自旋锁占用内存的问题

sync锁在ConcurrentHashMap中呢,细粒度控制在链表和红黑树的首节点上,在CAS锁插入新节点失败的时候,意味着其他线程修改了位置,此时才会退而求其次使用sync锁。或者说,sync锁加在首节点上,只用来处理多线程环境下,多个线程操作数据发生Hash冲突的情况,因此极大避免了线程挂起等阻塞式操作的开销。

另外我了解过的知识当中,sync锁升级机制中的轻量级锁、偏向锁、Lock接口的ReentrantLock实现类底层也都使用了CAS自旋锁

7.volatile

volatile 是一个关键字,可以修饰类的成员变量、类的静态成员变量,主要有两个功能

第一:保证了不同线程对这个变量进行操作时的可见性

即一个线程修改了某个变量的值,这新值对其他线程来说是立即可见的,volatile关键字会强制将修改的值立即写入主存。

第二: 禁止进行指令重排序,可以保证代码执行有序性。

底层实现原理是,添加了一个内存屏障,通过插入内存屏障禁止在内存屏障前后的指令执行重排序优化

阻止指令重排序功能,例如在通过双重检查锁定实现的单例模式下,需要对instance加上volatile修饰

因为创建对象时,分为三步:1.分配内存空间,2.初始化对象,3.赋值引用

正常时123的顺序,但是编译器或CPU可能为了优化性能而去指令重排序,顺序改为132,那么赋值引用后,instance就不为空了,其他

线程来获取单例对象时就会拿到一个半成品

 public class Singleton {
     // 未用volatile修饰
     private static Singleton instance; 
 ​
     private Singleton() {}
 ​
     public static Singleton getInstance() {
         // 第一次检查:如果instance不为null,直接返回(避免频繁加锁)
         if (instance == null) { 
             synchronized (Singleton.class) {
                 // 第二次检查:防止多线程同时进入第一次检查后,重复创建对象
                 if (instance == null) { 
                     // 问题出在这里!
                     instance = new Singleton(); 
                 }
             }
         }
         return instance;
     }
 }

8.synchronized和Lock有什么区别

第一,语法层面

  • synchronized 是关键字,源码在 jvm 中,用 c++ 语言实现,退出同步代码块锁会自动释放

  • Lock 是接口,源码由 jdk 提供,用 java 语言实现,需要手动调用 unlock 方法释放锁

第二,功能层面

  • 二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能

  • Lock 特点是一个显示的锁,相对于jvm提供的synchronized隐式的获取释放锁,提供了trylock和unlock方法,可以让程序员手动显式地进行获取释放锁,并且提供了许多 synchronized 不具备的功能,例如获取等待状态、公平锁、可打断、可超时、多条件变量,同时Lock 可以实现不同的场景,如 ReentrantLock, ReentrantReadWriteLock

第三,性能层面

  • 在没有竞争时,synchronized 做了很多优化,如偏向锁、轻量级锁,性能不赖

  • 在竞争激烈时,Lock 的实现通常会提供更好的性能

统合来看,需要根据不同的场景来选择不同的锁的使用。

9.ThreadLocal的理解

ThreadLocal 主要功能有两个,第一个是可以实现资源对象的线程隔离,让每个线程各用各的资源对象,避免争用引发的线程安全问题,第二个是实现了线程内的资源共享

在ThreadLocal内部维护了一个 ThreadLocalMap 类型的成员变量,用来存储资源对象

当我们调用 set 方法,就是以 ThreadLocal 自己作为 key,资源对象作为 value,放入当前线程的 ThreadLocalMap 集合中

当调用 get 方法,就是以 ThreadLocal 自己作为 key,到当前线程中查找关联的资源值

当调用 remove 方法,就是以 ThreadLocal 自己作为 key,移除当前线程关联的资源值

ThreadLocal会导致内存溢出

是因为ThreadLocalMap 中的 key 被设计为弱引用,value是一个强引用。弱引用会被GC自动回收,强引用不会

因此在使用ThreadLocal 时都把它作为静态变量(即强引用),因此无法被动依靠 GC 回收,建议主动的remove 释放 key,这样就能避免内存溢出。

10.锁升级+AQS+ReentrantLock

java中的锁一般有两种

synchronized

有偏向锁、轻量级锁、重量级锁三种形式,分别对应了锁只被一个线程持有、不同线程交替持有锁、多线程竞争锁三种情况。

锁升级机制,其实主要是因为,很多时候不存在高并发场景,面对不同的压力,使用不同的锁

偏向锁:一段很长的时间内都只被一个线程使用锁,可以使用了偏向锁。第一个线程进来时,jvm会把mark word的锁标示位设置为偏向锁,将线程的ID保存到mark word当中,后续只需要判断mark word中的id是否是自己的,如果是自己的,那么直接获取锁,如果不是自己的,那么会走一个偏向锁撤销的操作,检查原持有线程是否已释放锁,若已释放,则将mark word的线程标识更新为新线程标识,如果未释放,那么升级为轻量级锁。当一个线程释放锁之后,mark word不会马上删除线程标识

轻量级锁:当第二个线程进来的时候,偏向锁就会升级成轻量级锁,第二个线程如果拿不到锁,就会CAS+自旋,不断获取锁线程,考虑到的是,只有几个线程竞争锁,并且持有时间不长的情况,每次拿锁都通过CAS+自旋,而并非使用重量级锁的阻塞线程,因为阻塞+获取锁+释放,涉及到用户态到内核态切换,开销太大了。但是自旋也会占用CPU资源,所以轻量级只适用于少量,或者说两个线程的情况,自旋时间是自适应的,取决于上个线程自旋的时间

可以看到,偏向锁和轻量级锁是通过线程id+CAS自旋锁的方式,避免了用户态切换到内核态的操作系统级别的操作,来提升低并发场景下的效率,在CAS自旋锁超时未获取到锁,或者有更多线程进入竞争的时候,就会升级为重量级锁

重量级锁:升级到重量级锁,是因为线程过多,或者说某个线程占用资源过长,CAS自旋锁会占用CPU资源。升级到重量级锁,对象头的mark word的指针就会指向锁监视器monitor。monitor当中有4个关键属性,用来控制锁的获取,重入,阻塞等操作 a.owner:记录当前锁的持有线程 b.recursions:记录锁的重入次数 c.EntryList:保存竞争失败,进入阻塞状态的线程 d.WaitSet:管理调用了wait方法的线程

锁释放时,EntryList当中的线程会被唤醒,是非公平的竞争

一个锁初始的时候,就是偏向锁,一旦锁升级,状态不可逆,不会退化,主要是为了避免经常状态切换的开销

Lock

底层是java自己实现的,特点是一个显示的锁,相对于jvm提供的synchronized隐式的获取释放锁,提供了trylock和unlock方法,可以让程序员手动显式地进行获取释放锁

这个锁的核心基础,是AQS,全称是 AbstractQueuedSynchronizer,是一个并发工具接口

常见的实现类:

ReentrantLock 阻塞式锁

CountDownLatch 倒计时锁

  • 在AQS中维护了一个使用了volatile修饰的state属性来表示资源的状态,0表示无锁,1表示有锁

  • 提供了基于 FIFO 的等待队列,底层结构是双向链表,类似于 Monitor 的 EntryList

线程0来了以后,去尝试修改state属性,如果发现state属性是0,就修改state状态为1,表示线程0抢锁成功

线程1和线程2也会先尝试修改state属性,发现state的值已经是1了,有其他线程持有锁,它们都会到FIFO队列中进行等待

如果出现多个线程同时去修改state属性的情况,使用的cas自旋锁来保证原子性,确保只能有一个线程修改成功,修改失败的线程将会进入FIFO队列中等待

让volatile修饰state属性表示资源状态,state属性应该是int类型,这样可以支持记录重入次数和可支持同时访问的线程数量,以此实现可重入和可共享的功能。通过unsafe类提供的cas自旋锁,保证修改state状态的原子性,自旋次数超过,修改失败的线程,放到基于双向链表实现的等待队列当中被等待唤醒。至此就已经实现了一个简单的并发工具

那么AQS框架下的锁和与syc所的不同之处,体现在以下方面

1.可尝试 在业务场景当中,存在两种情况: a.尝试获取资源,获取不到就进行其他操作,在AQS当中对应tryAcquire()方法,原方法直接返回异常,交给上层代码重写 b.业务必须获取资源,获取不到就一直等待,在AQS当中对应acquire()方法,该方法当中会调用tryAcquire方法获取资源,获取不到就入队阻塞 对应ReentrantLock就是有tryLock方法尝试获取锁,获取不到就进行其他操作

2.公平锁 实现类只需要重写tryAcquire方法就可以实现 ReentrantLock当中FairSync和NonfairSync的子类都重写了tryAcquire方法,分别对应公平锁和非公平锁,公平锁依赖AQS的等待队列,实现先进先出获取锁的方式,非公平锁就运行新线程插队抢锁 Sync锁是非公平的

3.可共享 通过设置state的初始值来实现,state初始值为3,那么就有3个线程可以共享,为0时新线程就没办法获取共享资源了 state也可以用作一个累加器,来实现锁的可重入,重入一次,state+1

4.多条件控制 创建condition对象,用来管理条件队列,线程使用了condition.await方法,会被放到condition的等待队列当中 同时condition可以设置多个,配合不同condition对象的signal方法实现多条件精细的唤醒通知

5.高并发场景下,性能更好 在讲Lock和Sync锁区别的时候提到,Lock的高并发场景下性能更好 原因体现在,Sync锁加锁要用到底层原语Mutex,每次线程的切换都需要用户态切换到内核态,开销很大 AQS通过CAS和等待队列的方式,可以极大程度上减少用户态切换到内核态的开销 同时,因为是一个抽象的并发工具接口,可以很好的被上层进行重写和自定义,扩展性更强

6.读写锁

依旧是靠维护state变量,state变量为32位int值,高16位作为读锁计数器,低16位作为写锁计数器 读锁获取时,检查写锁计数器是否为0 写锁获取时,检查读锁计数器是否为0

11.ReentrantLock

和synchronized的区别:

可中断、显式锁、多条件变量、可以实现公平锁

构造方法有两种:

无参构造:默认非公平锁:新的线程和阻塞队列中的线程一起抢锁

有参构造:传入true,设置为公平锁:只让队列中的head,也就是等待最久的线程获取锁

底层是通过AQS+CAS实现的

线程通过CAS修改state,属性exclusiveOwnerThread会指向抢到锁的线程

没抢到锁的线程会进入AQS的等待队列

释放锁之后,exclusiveOwnerThread为空,唤醒等待队列的线程来抢锁

如果说设置了公平锁,那么就会让队列中的head线程获取锁

12.死锁产生的条件和死锁诊断

产生条件:一个线程同时要获取多把锁的时候,两个线程,互相占有了对方需要的锁

用多线程银行转账业务举例

有有两个用户对象A和B

现在需要开启两个线程t1,t1让A给B转账,t2让B给A转账

为了保证线程安全,t1和t2都需要获取A和B对象的锁

那么就可能出现,t1获取了A的锁,t2获取了B的锁,但是都无法获取对方持有的锁,导致死锁

解决办法:给锁添加顺序,例如通过对象的hash值,给锁添加顺序,保证先到的A的锁,才能再抢到B的锁,就避免了死锁

诊断死锁:

方法1:通过jdk自带的工具:jps和jstack

jps可以查看当前java程序的进程id,然后通过jstack查询这个进程,就可以展示死锁问题和具体代码行

方法2:通过可视化工具:例如VisualVM等

13.线程的状态

初始状态:创建了但没start

运行状态:调用start

阻塞状态:获取锁失败,进入阻塞队列

等待状态:调用了wait方法

超时等待:调用了sleep/wait方法,并且配置了等待时间

终止:线程任务执行完毕

14.Callable和Runnable的区别

Callable接口call方法有返回值,调用futuretask.get可以获取,并且允许抛出异常。

15.JMM汇总

JMM:Java Memory Mode ,即Java内存模型

主要作用是用来解决Java程序,多线程并发时的三个经典问题:可见性,有序性,原子性

在多核CPU架构当中,每个CPU有多级缓存,一二级线程独占,三级线程共享,缓存之后是内存,也叫主存,然后就是硬盘

从缓存当中读取数据,肯定比去主存读取快,但是会存在问题

可见性:对于一共享数据,一个线程修改后,缓存没有和主存同步,导致其他线程读取到脏数据

有序性:编译器,CPU进行指令重排序,导致执行顺序和代码不一致

原子性:操作必须全部执行不可中断,例如典型的超卖问题,线程一检测到库存为1,执行到一半CPU时间片用完,另一个线程占用CPU也检测到库存为1,当CPU重新轮到线程一,就会出现超卖

不同操作系统利用各自的内存模型,解决了这三个问题,但是Java为了实现可跨平台性,需要有一套自己的内存模型

JMM定义了一个内存交互规则,把CPU的寄存器和缓存等线程私有部分,统一抽象为工作内存,JMM规定,所有变量存储在主存,线程操作变量需要从主存当中加载到自己的工作内存当中,操作完成之后再写回主存。另外还设置了内存屏障以及happens before规则,简单来讲,就是只要a比b先发生,a的结果就对b可见。Java在上面这一系列规则之上,抽象出来了三个主要工具,来保证可见性,原子性,有序性。

sync锁保证可见性,原子性,有序性

volatile关键字保证可见性,有序性

cas保证原子性

严格意义上来讲,JMM本身是没有提供原子性保证的,内存交互规则,内存屏障,happened before规则只能保证可见性和有序性,原子性本质上是利用锁,或者atomic包下的原子类,通过CAS保证原子性,CAS的原子性,是通过CPU和操作系统的锁定保证

16.单例模式的多种实现

单例模式,指的是,整个应用只存在一个实例对象,例如,Spring当中的bean就是单例的

具体实现

1.饿汉式

简单,但是类加载时创建,如果这个对象用不到,浪费内存

 public class Singleton {
     private static final Singleton INSTANCE = new Singleton();
 ​
     private Singleton() {
     }
 ​
     public static Singleton getInstance() {
         return INSTANCE;
     }
 }

2.静态内部类创建

通过静态内部类创建,利用其延迟加载的机制,只有在调用时才会创建

 public class Singleton {
 ​
     private Singleton() {
     }
 ​
     private static class SingletonHolder {
         private static final Singleton INSTANCE = new Singleton();
     }
 ​
     public static Singleton getInstance() {
         return SingletonHolder.INSTANCE;
     }
 }

3.双重检查锁

可以传参数

但是实现较复杂,而且有锁的开销

 public class Singleton {
 ​
     private String config;
 ​
     private Singleton(String config) {
         this.config = config;
     }
 ​
     private static volatile Singleton INSTANCE;
 ​
     public static Singleton getInstance(String config) {
         if (INSTANCE == null) {
             synchronized (Singleton.class) {
                 if (INSTANCE == null) {
                     INSTANCE = new Singleton(config);
                 }
             }
         }
         return INSTANCE;
     }
 }

4.枚举类实现

极简,但是依旧是类加载时初始化实例

 public enum Singleton {
     // 唯一枚举实例,即为单例对象
     INSTANCE;
     
     // 可以添加其他方法
     public void doSomething() {
         // 业务逻辑
     }
 }

17.sleep和wait的区别

sleep是被Thread线程调用,wait是被对象调用

sleep不会让线程释放锁,并且会到时间自动唤醒

wait会让线程释放锁,可以设置超时唤醒,也可以被notify方法主动唤醒

sleep主要用于让线程暂停,模拟延迟

wait主要用于线程间协作,比如线程A等待线程B的操作

18.Go当中的协程

协程:由用户态控制协程的切换,每个协程都依赖线程的CPU时间片运行,在单个线程内,进行上下文保存切换

Go当中的协程,是由M:N调度模型进行管理的

M:N调度模型有4个关键角色

G:Goroutine,协程本身,类似于一个缩小化的线程

M:Machine,线程,一个线程同时只能执行一个协程,是协程执行的载体

P:Process,连接G和M的桥梁,管理准备就绪的协程队列LRQ,给M线程分配协程运行,还有一个全局队列GRQ

G:GoMaxProcess,控制P的数量,一般等于CPU核心数

M:N模型的核心,就是让大量的协程,去复用少量的线程,由此减少用户态到内核态的切换开销

总流程是,一个协程被创建,加入LRQ,如果当前P的LRQ满了,放入GRQ。每个P会绑定一个线程,然后分配协程给线程执行

如果协程执行无阻塞,在时间片耗尽后会保存上下文放回LRQ,然后Process挑选另一个协程执行

如果协程被阻塞,那么Process会解绑当前线程,去选择其他空闲线程执行,当协程阻塞结束会重新进入LRQ

总的来说,协程之所以快,有三个原因

1.M:N模型,让大量协程,复用少量线程,减少内核切换开销

2.协程之间的切换,都是内核态操作,由Runtime调度器完成,效率微秒级

3.CPU利用充分:协程阻塞时,Process会直接断开线程绑定,选择空闲线程执行,某个P的LRQ为空时,会去其他P或者GRQ当中选择协程执行

七.计网篇

1.OSI七层模型和TCP/IP四层模型

OSI

应⽤层,负责给应⽤程序提供统⼀的接⼝; 表示层,负责把数据转换成兼容另⼀个系统能识别的格式; 会话层,负责建⽴、管理和终⽌表示层实体之间的通信会话; 传输层,负责端到端的数据传输; ⽹络层,负责数据的路由、转发、分片 数据链路层,负责数据的封帧和差错检测,以及 MAC 寻址; 物理层,负责在物理⽹络中传输数据帧;

TCP/IP

应用层:定义数据格式和交互规则,与用户交互(HTTP、FTP、SMTP、DNS)

传输层:处理端口号,保证数据可靠交付到具体应用(TCP、UDP)

网络层:处理IP地址,决定数据从一个网络发送到另一个网络(IP)

网络接口层:物理传输,上层数据转换为信号交给硬件,也把信号转换成数据交给上层

一个http请求后,数据包抵达服务器的全流程:

  1. 应用层(HTTP):浏览器将请求内容(如请求方法、URL、参数等)封装成 HTTP 请求报文,交给下一层。

  2. 传输层(TCP/UDP):若用 TCP(HTTP 默认),会给 HTTP 报文添加源端口(随机)目标端口(如 80/443),形成 TCP 段,同时通过三次握手建立可靠连接。

  3. 网络层(IP):为 TCP 段添加源 IP 地址(本地设备)和目标 IP 地址(服务器),封装成 IP 数据包,然后查询路由表,确定下一跳设备(如路由器)。

  4. 数据链路层(MAC):给 IP 数据包添加源 MAC 地址(本地网卡)和下一跳设备的 MAC 地址(通过 ARP 协议获取),形成数据帧,通过网卡发送到物理网络。

  5. 物理层(硬件):将数据帧转换为电信号 / 光信号,通过网线、光纤等物理介质传输,经过多台路由器转发后,最终抵达服务器所在网络。

简单来说:应用层把请求内容封装成HTTP请求报文,然后传输层给HTTP报文,添加源和目标端口,形成TCP段,然后三次握手建立连接,网络层再给TCP段添加源和目标IP地址,封装成IP数据包,查询路由表,决定线路,数据链路层给IP数据包添加源MAC地址和下一跳的MAC地址,形成数据帧,最后由物理层把数据帧转换成信号进行传递

应用层:HTTP报文 传输层:源+目标端口,TCP段,三次握手 网络层:源+目标IP,IP数据包,确定路由路线 数据链路层:源MAC+目标MAC地址,数据帧(MAC是物理地址,由IP逻辑地址通过ARP地址解析协议转换成MAC物理地址) 物理层:数据帧转换成物理信号传递

2.三次握手

1、第一次握手: 客户端给服务器发送一个 SYN 报文,报文携带序列号,然后客户端进入SYN_SEND状态,服务器没有收到请求,处于LISTEN监听状态,

2、第二次握手:服务器收到 SYN 报文之后,会应答一个 SYN+ACK 报文,ACK报文携带序列号为客户端发的SYN报文序列号+1,SYN报文同样携带一个序列号,服务器进入SYN_RCVD(收到received)状态,此时服务器知道自己能发能收

3、第三次握手:客户端收到 SYN+ACK 报文之后,会回应一个 ACK 报文,ACK报文携带SYN序列号+1的序列号,客户端进入ESTABLISHED已连接状态,知道自己和服务器都能发能收。

4、服务器收到 ACK 报文之后,也更新为ESTABLISHED已连接状态,知道双方能发能收,三次握手建立完成。

作用是为了确认双方的接收与发送能力是否正常。

第一次握手:服务器收到客户端发送的网络包,确认了客户端发送能力,服务器接受能力正常

第二次握手:服务器发包,客户端接受,确认了服务器发送能力,客户端接受能力正常

为什么要第三次握手?

因为服务器发包,并不知道客户端是否收到,无法确实客户端接受能力正常

因此客户端接受到之后,回应一个包,让服务器知道客户端接受能力正常

因此,需要三次握手才能让双方确认彼此的接收与发送能力是否正常。

3.四次挥手

挥手请求可以是Client端,也可以是Server端发起的,我们假设是Client端发起:

第一次挥手:客户端向服务器发送FIN报文,报文中指定一个序列号,此时客户端处于FIN_WAIT1状态

第二次挥手:服务器收到FIN之后,会发送ACK报文,ACK的序列号为客户端序列号值+1,表示收到客户端的报文,此时处于CLOSE_WAIT状态,客户端收到ACK后处于FIN_WAIT2状态

第三次挥手:服务器向客户端发送FIN报文,指定一个序列号。此时服务端处于 LAST_ACK 的状态。

第四次挥手:客户端收到FIN之后,会发送ACK报文,ACK的序列号为服务器序列号值+1,表示收到服务器的请求,此时客户端处于一个TIME_WAIT状态,需要等一段时间,确保服务器收到自己的ACK之后再关闭连接进入CLOSE状态

之所以是四次挥手,是因为可以把断开连接分为两个部分,主要是因为FIN报文和ACK报文发送的时效性问题。TCP协议是双全工的,也就是说,数据可以在两个方向上独立传输,建立连接三次握手是为了确认双方状态,四次挥手同样是为了确认双方状态。发送ACK报文是为了满足TCP协议的要求,一方收到FIN就必须马上响应ACK,而发送FIN报文,是当前这一方确认应用层已经处理完数据,才会进行发送,也就是说第二次回收响应ACK报文,和第三次挥手发生FIN报文,严格意义上来说不能合并为一次发送。

TIME_WAIT的时间为2MSL(Max Segment LifeTime),MSL为报文段的最长寿命时间,为什么呢? 首先,TIME_WAIT是为了等待被动断开的一侧,接收到最后一个ACK,按照上面的例子,就是服务器等待最后一个ACK 服务器接收到ACK之后,就会由LAST_ACK状态,转变为CLOSE状态 客户端会等待2MSL之后,进入CLOSE状态,但是客户端需要保证服务器接收到ACK,有两种情况 1.服务器接收到了发送的ACK,ACK报文最长存活时间为MSL,也就是说服务器在MSL之内能够断开连接 2.服务器没在MSL之内接收到ACK,会超时重传FIN,一来一去,最长为2MSL,也是最坏的情况 服务器在发送FIN,客户端发送最后一个ACK的时候,都会重置超时重传计时器,超时时间为2MSL

4.TCP和UDP的区别

(1)TCP是可靠传输,UDP是不可靠传输,前者有确认重传机制,后者没有;

(2)TCP面向连接,UDP无连接;

(3)TCP传输数据有序,UDP不保证数据的有序性;

(4)TCP不保存数据边界,面向字节流,UDP保留数据边界。面向字节报;

(5)TCP传输速度相对UDP较慢;

(6)TCP有流量控制和拥塞控制,UDP没有;

(7)TCP是重量级协议,UDP是轻量级协议;

(8)TCP首部较长20字节,UDP首部较短8字节;

TCP应用场景:

效率要求相对低,但对准确性要求相对高的场景。举几个例子:文件传输(准确高要求高、但是速度可以相对慢)、接受邮件、远程登录。

UDP应用场景:

效率要求相对高,对准确性要求相对低的场景。举几个例子:QQ聊天、在线视频、网络语音电话(即时通讯,速度要求高,但是出现偶尔断续不是太大问题,并且此处完全不可以使用重发机制)、广播通信(广播、多播)

TCP的可靠性如何保证

1.通过三次握手,建立稳定连接

2.TCP报文段当中加入序列号,保证有序性

3.接收方返回ACK,作为消息确认机制

4.超时重传机制,如果长时间没有返回ACK,会重新传递消息,超时时间根据RTT往返时间确认

5.流量控制和拥塞控制

UDP既然是无连接,不可靠的,如何保证可靠性

在应用层,通过代码,实现消息确认、重传、排序、流量控制等机制

市面上有成熟开源的可靠UDP方案:KCP、QUIC

5.HTTP和HTTPS的区别

1.HTTPS需要用到CA证书,通常是要付费的

2.HTTP是超文本传输协议,明文传输,HTTPS利用ssl和tls进行对称和非对称加密传输,需要消耗更多CPU和内存资源

3.连接方式不同,端口也不一样,一个是80一个是443

http是一个超文本传输协议,是明文传输,容易被通过抓包窃取和篡改

因为,http请求,在网络层,决定下一跳的路由节点,从请求到达服务器的过程当中,会经过多个路由节点,某个节点如果开启抓包功能,就可以监听截取请求的内容,甚至修改请求的内容,根本原因是因为http是明文传输

而https呢,是http + ssl/tls的进行对称或非对称加密的加密传输,可以说是http的安全升级版。但也因为需要加密,因此https的性能会略慢一点。另外两者的端口不一样,一个是80一个是443。目前来说,绝大部分网页都是使用https,因为涉及到用户信息和一些敏感数据,只有一些静态网页可能使用http

简单来说,HTTPS最重要的区别就是,使用了对称和非对称加密,相较于明文传输的HTTP协议,速度较慢,消耗资源较多,但胜在安全

解释一下对称加密和非对称加密

对称加密:加密的密钥和解密的密钥是一样的。

非对称加密:加密的密钥和解密的密钥不一致,有两种情况1.公钥加密,私钥解密 2.私钥加密,公钥解密。

HTTPS的加密方式是两种结合的,加密过程使用的非对称加密,而内容传输上使用的是对称加密

HTTPS握手具体流程

证书验证阶段:

1.客户端发送请求,Client Hello,携带TLS版本和一个client random随机码给服务器

2.服务器保存client random,回应Server Hello,响应TLS版本,加密套件,server random随机码

3.服务器会发送CA证书和公钥给客户端

4.客户端验证CA证书

利用公钥解密CA证书上的数字签名,验证证书内容是否被篡改,保证服务器证书的真实性

数据传输阶段:

1.客户端生成一个随机的预主秘钥 pre -master-secret,利用公钥加密后发给服务器

2.服务器通过私钥进行解密,然后双方都利用client random,server random,以及随机秘钥三者共同生成会话秘钥,也就是对称加密

3.客户端和服务器相互告知准备就绪

4.后续利用会话秘钥对HTTP数据加密传输

中间人问题:出现一个中间人,夹在客户端和服务器中间,冒充客户端和服务器,窃取资源。因此需要CA证书,只有正确的服务器才能传递给客户端证书,客户端会判断证书的可靠性。如果说中间人窃取了证书,那么同样会因为没有服务器的私钥,无法获取到构建对称加密的key,而导致无法窃取资源

6.粘包

产生原因:

1.TCP是基于字节流的,没有边界

2.TCP首部没有表示数据长度的字段

一个数据包中包含了发送端发送的两个数据包的信息,这种现象即为粘包。

解决办法:

  1. 特殊字符控制;

  2. 在包头首都添加数据包的长度。

如果使用 netty 的话,就有专门的编码器和解码器解决拆包和粘包问题了。

7.TCP的协议

HTTP:超文本传输协议

FTP:文件传输协议

SMTP:简单邮件传输协议

TELNET:远程登录

SSH:安全外壳协议

8.UDP的协议

DNS:域名解析协议

TFTP:简单文件传输协议

SNMP:简单网络管理协议

9.tcp的流量控制和拥塞控制

流量控制:为了解决发送方和接收方速度不同而导致的数据丢失问题,当发送方发送的太快,接收方来不及接受就会导致数据丢失;

方式:滑动窗口

简单来说就是接收方会将自己可以接受的缓冲区大小存入TCP首部的rwnd(received_window)字段传给发送端,发送端就不会发送超过这个限额的数据。而之所以称之为滑动窗口,是因为这个缓冲区一旦面临溢出情况,窗口值就会缩小,也就是说,这个窗口是被接收端动态控制的

拥塞控制:为了解决过多的数据注入到网络导致网络崩溃和超负荷问题

方式:拥塞窗口+慢开始门限

和流量控制不同,流量控制,是根据接收端的反馈,来动态调整发送端的发送速度

拥塞控制,是发送端,主动去感知网络状态,通过拥塞窗口与慢开始门限的对比去选择采取不同算法调整拥塞窗口cwnd

实际发送窗口取rwnd和cwnd的较小值

算法:

1.慢开始:TCP连接开始时,cwnd默认大小为1,每次传输完成(接收到ACK)就翻倍,指数增长,一直到慢开始门限的阈值,慢开始门限的初始值由操作系统决定,一般是64kb

2.拥塞避免:拥塞窗口增长到大于等于慢开始门限后使用,每次传输完成拥塞窗口+1MSS,变为线性增长

MSS:Max Segment Size,TCP报文段最大大小,在三次握手中确定

当网络发生拥塞,慢开始门限变为 cwnd/2 导致丢包有两种情况

1.因为发送端收到3个重复的ACK,检测到丢包,说明网络轻度拥塞,会进行快重传和快回复

快重传:发送端收到3个重复的ACK之后会立即重传丢失的数据包

快恢复:把cwnd拥塞窗口设置为新慢开始门限+3MSS,进入拥塞避免阶段

2.如果因为超时导致丢包,说明网络拥塞严重,会重置cwnd,重新进入慢开始阶段

10.get请求和post请求的区别

1.get请求是客户端从服务器获取数据,post请求是向服务器传输数据

2.get请求安全性较差,数据会衔接在url的后面,post安全性较高,数据不会显示在url上,而是保存在Request-body请求体当中

3.get请求会被浏览器缓存,历史记录能够查到,也可以被作为书签,post请求不可以

4.get请求的url存在长度限制,最大长度是2048个字节,并且限制使用ASCII码的字符,post请求没有限制,允许使用二进制数据

5.get请求只发送一个数据包,header和data一起发,post请求会先发header,服务器响应100后再发data,才会响应200

不过火狐浏览器的post请求只发一次包

11.输入URL到页面显示的过程

1.输入网址,浏览器提取信息(协议,域名,端口号,查询参数)

2.根据域名,从浏览器缓存,DNS缓存,本地hosts文件查有没有对应的IP地址,如果都没有,会向DNS服务器发送解析请求,获取域名对应的IP地址

3.获取到IP地址后,如果是TCP协议,那么会通过三次握手建立TCP连接

4.连接好后,浏览器发送HTTP请求给服务器

HTTP请求到服务器,正常服务器肯定不是本地服务器,因此会经历以下过程

应用层发送HTTP请求 然后传输层添加源端口和目标端口,封装成TCP段 然后网络层会添加源IP地址和目标IP地址,封装成IP数据包 然后数据链路层会根据IP数据包,通过ARP协议,把IP地址转换成MAC物理地址,然后封装成数据帧 最终交给物理层转换成物理信号,打到服务器

因为是跨网络传输,因此中间会存在多个路由,IP和MAC地址,都是指向下一跳的位置,经过多跳传输后,打到服务器

5.服务器接受请求,后端代码运行,然后返回一个HTTP响应到本机浏览器

这个响应流程,就是HTTP请求到服务器的逆向过程,源和目标信息对称

6.浏览器构建DOM树和CSS树,然后根据DOM和CSS树构建渲染树,根据渲染树布局渲染页面

7.四次挥手断开连接

八.JVM虚拟机

1.主要组成部分和运行流程

  • ClassLoader(类加载器)

  • Runtime Data Area(运行时数据区,内存分区)

  • Execution Engine(执行引擎)

  • Native Method Library(本地库接口)

流程:

1.类加载器将java代码编译后的为字节码转换成JVM的Class对象,存入方法区

2.运行时数据区负责存储相关数据

3.CPU无法执行字节码,执行引擎将字节码转化成底层系统指令,再交给CPU执行

4.一些方法需要调用其他语言的本地库接口

2.类加载器

类加载器(ClassLoader)的主要作用就是将字节码文件加载到JVM中,从而让Java程序能够启动起来。

类加载的顺序:

加载:将类的字节码加载到内存

链接(验证、准备、解析):确保类的结构合法可执行

初始化:执行类的初始化代码:静态变量赋值,静态代码块

  • 启动类加载器(BootStrap ClassLoader):

    该类并不继承ClassLoader类,其是由C++编写实现。用于加载JAVA_HOME/jre/lib目录下的类库。

  • 扩展类加载器(ExtClassLoader):

    该类是ClassLoader的子类,主要加载JAVA_HOME/jre/lib/ext目录中的类库。

  • 应用类加载器(AppClassLoader):

    该类是ClassLoader的子类,主要用于加载classPath下的类,也就是加载开发者自己编写的Java类。

  • 自定义类加载器:

    开发者自定义类继承ClassLoader,重写loadClass()方法,就可以打破双亲委派机制,实现自定义类加载规则。

初始化顺序:

3.什么是双亲委派机制

如果一个类加载器在接到加载类的请求时,它首先不会自己尝试去加载这个类,而是把这个请求任务委托给父类加载器去完成,依次递归,如果父类加载器可以完成类加载任务,就返回成功;只有父类加载器无法完成此加载任务时,才由下一级去加载。

目的:

1.避免重复加载

2.防止恶意篡改核心API库,lang包和util包的类只能启动类加载器才能加载

举个例子

项目里自己实现一个String类,可以编译通过吗?

1.如果是在自定义包下,可以,但是使用时必须带包名

2.如果我们尝试自定义一个 java.lang.String 类(注意包名是 java.lang),那么因为双亲委派机制,最终会委托给我们的启动类加载器进行加载,加载的是java自己的String类,我们自定义的String不会被加载

我实际测试过,根本就没办法创建一个java.lang的包,会直接报错,无法编译

4.内存分区(运行时数据区)

具体包含5个分区,而5个分区又分为两大类

4.1.线程私有区

特点是:随线程创建和销毁,无GC垃圾回收

a.程序计数器PC

记录线程字节码的行号,当前线程CPU占用结束,下一次轮到当前线程占用CPU的时候,就需要通过程序计数器,找到上次运行到的位置,继续执行

b.虚拟机栈

虚拟机栈是开辟每个线程所需要的内存,由多个栈帧组成,栈溢出抛出StackOverFlowError异常

每个方法执行时都会创建一个栈帧,其中包括 局部变量表、操作数栈、动态连接、返回地址等信息

栈帧会随着方法的调用到结束,入栈和出栈,无需GC

c.本地方法栈

和虚拟机栈类似,但是是为native方法(非Java编写的底层方法,例如CAS的方法)服务的

4.2.线程共享区

特点是:所有线程共享,存在GC垃圾回收

a.堆

内存分区当中最大的一块,用来存储对象实例和数组。堆满抛出OutOfMemoryError异常

java8之前,堆有三个部分,年轻代,老年代,永久代,年轻代又分为一个Eden区,两个大小相同的survive区

java8之后,取消了永久代,因为永久代里面保存的数据不会被回收,容易造成OOM内存溢出,转而变成了存储在本地内存的元空间。主要保存类信息、静态变量、常量等

b.方法区(堆的一部分)

Java8之后,实现方式由永久代变为元空间,存储在本地内存,主要用于保存类信息、静态变量、常量等

方法区如果出现内存溢出/本地内存不足,也会报OutOfMemoryError异常

5.垃圾回收机制:GC

其实就是java提供的自动的垃圾回收机制

触发gc一般是由垃圾回收器自己调度

或者调用System.gc

System.gc并不会百分百触发gc,它只是向 JVM 发送一个 “建议执行垃圾回收” 的信号,最终是否执行、何时执行,完全由 JVM 自主决定

如果说一个对象没有被任何的引用指向它了,那么就会被定义为垃圾,被定义为垃圾就有可能被垃圾回收器回收

定义方法主要是:引用计数法和可达性分析算法,现在主要使用后者,原理是判断某对象是否与根对象有直接或间接的引用,如果没有被引用,则可以当做垃圾回收。其中有个finalize方法,每个对象在第一次可回收时会执行一次,在方法中可以设置当前对象和根对象GC roots关联,关联了就不会回收了。这个对象第二次可回收时,就不会执行这个方法,直接被回收

垃圾回收算法:

标记清除算法:将存活对象标记,清除没有标记的对象,会导致极大的内存碎片问题

复制算法:把内存区分成2份(From和To区),把From区的存活对象Copy到To区,清除全部From区,避免内存碎片问题,但是实际只能使用一半的内存区

标记整理算法:标记清除算法的优化,把存活对象朝着一侧移动,然后清除边界外的区域,解决了内存碎片的问题

分代收集算法:主要使用

java8之后,堆被分为新生代和老年代:默认占比为1:2

新生代又分为3个部分:Eden、s0、s1,默认占比为8:1:1

具体工作机制是:

一个新建的对象会放入新生代中的Eden区域,Eden区要满的时候,触发younggc

younggc之后,存活的对象会存入s0,Eden清空,存活对象年龄+1

第二次触发younggc的时候,会把s0和Eden区的存活对象,存入s1,然后清空,存活对象年龄+1

再一次触发younggc的时候,会把s1和Eden区的存活对象,存入s0,然后清空,存活对象年龄+1

可以理解为,s0和s1就是临时用来相互存放每一轮的存活对象的

当存活对象年龄到达默认值15之后,就会放入老年代,特殊情况是如果有大对象出现,触发younggc就会直接放入老年代

老年代满了之后,就会触发FullGC,同时回收新生代和老年代

垃圾回收器:

串行垃圾回收器:线程都暂停,一个线程进行垃圾回收

并行垃圾回收器:java8默认:线程都暂停,多个线程进行垃圾回收

CMS并发垃圾回收器:针对老年代,其中一个线程进行垃圾回收,其他线程并发运行

G1垃圾回收器:java9之后默认:将堆划分为多个区域,每个区域都可以充当Eden、survivor、old、 humongous区。采用复制算法。

主要采用两种垃圾回收模式:younggc和mixedgc

特点是mixedgc,回收老年代数据的时候,选取的是释放空间最大的区域进行回收,保证释放更多的内存空间

优点是:STW时间少,吞吐量高

6.cms和g1详情

cms:并发垃圾回收器

基于标记清除算法,针对老年代的垃圾回收器,主要特点是以获取最短回收停顿时间为目标

过程主要分为:初始标记和并发标记,然后是需要停顿的重新标记,最后是并发清理

初始标记:标记与gc roots根对象之间关联的对象,需要stw

并发标记:标记gc roots根对象直接和间接关联的对象,并发不需要stw

重新标记:并发标记的时候,产生了新的与gc roots关联的对象,或者说原本可达的对象不可达了,无需标记,需要重新处理,需要stw

并发清除:清除没有被标记的对象,并发操作

优点:大部分操作是并发执行,不会造成停顿,整体回收流程停顿时间很短,不影响应用运行

缺点:并发意味着会占用一定CPU资源、并且并发清理的时候会产生部分新的垃圾(浮动垃圾)、另外因为使用的是标记清除算法,会导致出现内存碎片

ps:最终会清除的垃圾,是在重新标记阶段就决定好了的,因此在并发清除阶段,只会又浮动垃圾产生,不会存在,有新的对象进入,但没有被标记,误删的情况

G1:java9之后的默认垃圾回收器

新生代基于复制算法,老年代基于标记整理算法,同时遵循分代收集算法的一种垃圾回收器

主要的特点我认为有这几个

1.使用的是分代收集算法的升级版,我称之为逻辑分代,物理不分代,表现为将堆内存分成了默认为2048份大小相同的region区,每个region区都可以当做Eden区、survive区、老年代和大对象区,其作用是在垃圾回收的时候,会选取垃圾更多的region区,最大效率地进行垃圾回收,这也是其名字的来源garbage first

2.最大的特点,是可以设置最长的停顿时间,或者说期望停顿时间,每次停顿回收时都会进行垃圾回收时间预测

3.吞吐量大,效率高,体现在对于新生代和老年代的回收都是少量多次,新生代默认阈值为Eden区的60%,老年代的默认阈值是45%就会进行young gc和mixed gc,极大地提高了吞吐率,同时也可以缓解full gc的出现概率

4.也采用并发标记,并行回收的机制,可以减少停顿时间

g1垃圾回收器的工作流程主要分为两个部分

1.针对新生代的young gc

Eden区使用率到达设置阈值60%,或者G1计算出回收时间接近用户设定的最大停顿时间,那么就会对新生代进行回收,基于的是复制算法,回收Eden区和survive区,将幸存对象年龄+1放入新survive区或者老年代。

2.针对老年代的mixed gc

老年代使用率达到阈值45%或者分配大对象时会进行mixed gc,针对年轻代和部分老年代,选取策略是垃圾对象最多的region区,确保在指定的期望停顿时间内释放更多的内存对象

主要流程分为:初始标记、并发标记、最终标记、筛选回收

初始标记,需要短暂停顿,标记gc root根对象直接关联的对象

并发标记,不会造成停顿,通过可达性分析算法,标记出所有存活的对象,时间较长,但是并发标记的时候,用户线程没有暂停,会出现多标和漏标的问题,多标会导致浮动垃圾,下次回收就好,漏标就会很严重,导致本来存活的对象被回收,因此有了最终标记

最终标记,需要暂停用户线程,通过SATB修正并发标记期间产生变动的对象

筛选回收,也需要暂停用户线程,将存活对象复制到新的region区,回收旧的region区

优点:能充分利用多核处理器的优势,减少垃圾收集。利用标记整理算法,解决了cms的内存碎片问题。最关键的,是提供了用户可以设置期望的停顿时间

缺点:G1需要记忆集来记录新生代和老年代之间的引用关系,这种数据结构需要占用大量内存,同时维护成本很高,执行负载也会更高,影响效率

两种垃圾回收器,cms适用于小内存以及需要停顿时间尽可能少的应用,G1适用于内存较大,或者需要设置期望停顿时间的应用

大小应用的划分界限一般在6-8之间

据我所知,jdk13就把cms移除了,现在大部分情况都是使用g1

7.四种引用

1.强引用:不会被垃圾回收器回收

2.软引用:内存不够时才会被垃圾回收器回收

3.弱引用:垃圾回收器在回收时会回收

4.虚引用:根本不算做被持有,配合引用队列使用,主要是用于跟踪对象被垃圾回收的时机

8.jdk各版本特性

常用的Java版本,分别是jdk8,17,21,这三个版本都是长期支持的版本,每个版本较为突出的特性为

jdk8:引入lambda表达式,stream API,提升编程效率

jdk17:引入密封类,模式匹配,优化zgc垃圾回收器

jdk21:引入虚拟线程,支持百万级并发,不再是让OS调度,减少用户态到内核态切换的性能消耗

9.Go的垃圾回收器GC

Go1.8之后采用的是三色收集法和混合写屏障

同样分为四个阶段

准备标记:需要STW,开启写屏障

并发标记:无需STW,三色标记法跟踪处理

标记终止:需要STW,关闭写屏障

并发清理:无需STW

触发时机:

1.堆内存达到上一次GC后堆大小的2倍

2.距离上次GC间隔一段时间后,动态计算

3.初始化阈值,刚启动时,达到某个阈值会GC

4.堆内存不足时

10.三色标记法

三色标记法,将原本需要stw的标记阶段,转变为可以进行并发标记,提升了gc的效率。底层原理是,在初始标记阶段,会把根对象都标记为灰色,根对象就是被栈引用,静态变量引用的对象,其他对象被标记为初始的白色

然后进入并发标记阶段,会进行一个循环,从灰色对象集合当中,拿出一个灰色对象,遍历其所有引用的对象,都标记为灰色,加入灰色对象集合,然后把开始的那个灰色对象,标记为黑色。这样不断循环直到灰色对象集合为空,剩下的白色对象就被认定为垃圾

但是这样存在一个问题,会出现漏标的情况。漏标出现必须同时满足以下两个条件:1.黑色对象新增引用,指向一个白色对象,2.这个白色对象原本被指向的灰色对象,删除了引用,只有满足以上两种条件的对象,才会有漏标情况出现

为了解决这个问题,不同垃圾回收器有不同解决方案

cms:增量更新方案,避免条件1,当黑色对象新增引用指向白色对象时,会把黑色对象退化成灰色对象,强制让其重新进入循环

g1:SATB初始快照,避免条件2,通过初始快照,保留并发标记开始时,灰色对象引用指向白色对象的场景,即使后来删除,也会进行恢复,以此来保证绝不漏标,但是会产生多标,导致浮动垃圾

不管是cms退化成灰色的黑色对象被重新遍历,还是g1利用SATB恢复原本被灰色引用的白色对象,都是在重新标记阶段进行处理,需要stw

而go的gc垃圾回收器,同样使用三色标记法,并且通过混合写屏障来解决漏标问题。简单来说,就是在并发标记阶段,黑色对象新增对一个白色对象的引用,会让这个白色对象直接变为灰色,灰色对象删除引用时,判断引用指向是否为白色对象,如果是,把白色对象修改为灰色。和Java当中的gc最大的区别就是,混合写屏障直接把可能被漏标的白色对象修改为灰色,然后加入后续的循环,而并发在最终标记的stw阶段进行处理,stw只有在写屏障开启和结束后短暂进行,都是纳秒级别,因此stw时间很短,性能很高

九.项目难点介绍

1.AI大模型应用项目

1.1.会话记忆+历史功能

SpringAI自带了会话记忆功能,有一个接口叫ChatMemory,实现这个接口,然后在Configuration类当中配置ChatClient类,配置ChatMemory的实现类的对象就可以实现会话功能。

这个接口的两个关键方法:add和get,前者负责把会话加入历史,后者负责通过chatId,获取到具体某个对话的历史

SpringAI自带的实现类是MessageWindowChatMemory,把会话历史保存在内存当中

前端每次发送请求,除了发送prompt之外,还会发送chatId,为了保证每次会话的记忆隔离开,需要在Controller的地方,传入chatId,这样大模型会通过chatId,读取不同会话的历史记录,保证会话记忆能够隔离开

我通过实现ChatMemory这个接口,重新实现了add和get方法,add方法将历史会话信息保存到数据库当中,数据库会保存chatId和Message,get方法通过chatId获取到Message信息

1.2.提示词工程

通过设计提示词,然后在Configuration类当中配置ChatClient类即可

设计提示词工程,也有许多策略

1.用分隔符标记输入内容,防止用户恶意输入,产生prompt注入的效果

2.提供示例,规范输出风格和输入格式,例如JSON格式

3.设定模型的角色,减少幻觉

4.限制编造,或者说给出降级测试,例如“若不确定,回答无相关信息”

5.其他的提示词攻击防范,例如不能回答违法有害内容,不能泄露训练数据,不能回答过于复杂,超出token限制的问题等

1.3.FunctionCalling实现

FC简单来讲,指的是大模型的一个功能,支持调用外部工具的功能,它的完整流程是

1.用户发送问题,组织好prompt之后,发送给大模型

2.大模型根据prompt判断是否需要调用外部工具,如果不需要,那么就会和聊天一样直接返回

3.如果需要调用外部工具,那么会返回方法名和从prompt当中提取的方法需要的参数

4.程序根据大模型返回的信息,去执行方法,获取结果

5.程序会把结果重新发送给大模型

6.大模型拿到结果,重新组织回复

其中,由后端开发实现的步骤,就是其中外部工具的具体实现

实现外部工具,本质上就是定义一个Tools类,然后实现各个Tool方法

需要用到两个关键注解:@Tool和@ToolParam

Tool注解当中,一个关键属性为description,这个属性就是当前方法的功能描述

ToolParam注解当中,除了description描述字段含义,还有一个required,表示当前字段是否刚需

这些配置,会在用户提问时,和会话历史一起发送给大模型,成为大模型判断是否调用外部工具的依据

当然,FunctionCalling是存在很多挑战的,例如,我们设计的方法很复杂,参数要求很严格,用户输入的具体情况是不确定的,针对这种问题,我们可以更详细的去配置prompt提示词工程,设计调用规则,以及降级策略,比如参数缺少的情况下,告诉用户,需要额外补充参数,然后在提示词当中设置好,什么情况下调用什么方法

简单来说,FunctionCalling的实现,就是在本来后端开发的基础上,去配置Tool和ToolParam注解,让大模型知道方法和字段的功能含义,然后配置提示词的调用规则,让大模型能清楚的知道什么时候可以去调用这些外部方法,获取到结果后组织回答用户

1.4.RAG的实现

RAG原理其实就是给大模型外挂一个知识库,这个知识库当中,可以是专业领域的知识,也可以是企业私有的数据以及规则等

而我们很明显,不能把知识库的信息也作为提示词发给大模型,这样消耗的token就太大了,超出限制

因此,我们的一个实现原理是,把用户的提示词,拿到知识库当中进行匹配,找到相关的信息,然后把这些信息组装成提示词,再交给大模型,这里就需要用到向量模型,而并非文本检索,因为我们要的是意思相近,而非文字匹配

向量模型的作用,是把文字拆分成片段,转换成坐标,然后把提示词与片段的坐标取欧氏距离余弦距离,前者越小越相似,后者越大越相似

除了向量模型之外,还需要向量数据库帮我们保存向量数据,SpringAI提供了很多向量数据库,我采用的是Redis的,使用向量数据库需要先把文档转换成Document类型的对象,SpringAI提供了各种文档转换成Document的方式,我这里是PDF文档,两种方式转化,一个是按页分,一个是按章节分,把PDF转换成Document对象的List,就可以通过向量数据库的add方法,添加到向量数据库当中

完整流程是,一个对话,我们先传文件,文件名与chatId绑定保存到数据库,文件会保存到本地,实现会话历史功能,然后会判断是否是第一次上传该文件,如果是,会把当前文件保存到向量数据库

会把当前向量数据库配置到Configuration当中,以后提问,都会从向量数据库当中优先匹配信息

1.5.MCP

首先是FC和MCP的区别

FC准确的说是大模型的一种功能:可以调用外部服务

MCP是一个协议,规定了外部服务也就是Tools的编码规范,可以将Tools打包成MCP Server,让大模型可以方便调用

从开发的语法层面来说,写MCP Server和写FC的方法,没有任何区别,都是@Tool和@ToolParam注解

在大模型应用当中,可以配置MCP Client,也就是让这个应用拥有调用MCP的功能

目前SpringAI支持两种通信模式:SSH和STDIO,前者的特点是具有实时性,需要MCP Server处于时刻启动的状态,通过HTTP连接,去调用MCP的服务,后者基于传统的IO模式,不具备实时性,适合一些脚本工具的调用

还有一种Streamable HTTP ,未来的发展方向,基于HTTP的流式传输,适用于大文件的分段传输,客户端可以边接收边处理,并发效率更高

2.点评项目

2.1.登录验证(Session共享问题)

网页输入手机号,发送post请求给服务器,服务器这边接收请求后,提取出手机号,先对手机号通过正则表达式进行格式判断,判断成功后,因为如果采用邮件形式,需要用到第三方工具,本身与业务无关,因此我选择直接生成一个6位随机数,作为验证码,打印到控制台,以手机号为key,保存到Session

之后,浏览器再输入验证码,发送登录的请求,服务器这边,验证手机号,并从Session中取出验证码进行比对,如果成功,那么就去查询数据库是否存在当前手机号对应的用户。如果没有,就新建一个,如果有,那么就将用户信息保存到我们写好的一个简单用户类UserDTO里面,这个类保存的是一些非敏感用户信息,只有用户的id,昵称。然后把简单用户类对象给保存到Session里面,Session的id是用UUID工具生成的token,最后把token返回给前端

但是Session保存登录验证信息在服务器并发环境下会有问题,因此引入了redis,替换了Session,在本来的业务中,将验证码还有简单用户对象,都转而保存在redis当中

同时,编写双层拦截器,第一层为token刷新拦截器,针对所有请求和页面,验证token是否能获取到redis中的用户信息,成功与否都会放行,成功的话会把用户信息保存到ThreadLocal当中,方便其他业务使用,同时刷新redis里面的token有效时间。然后第二层为登录验证的拦截器,部分请求和页面需要保证有登录认证,判断token是否能获取用户信息,如果认证失败,返回401错误

拦截器:自建一个拦截器类实现Handleininterceptor接口和方法,然后在实现WebMvcConfigurer接口的MvcConfig类里进行配置

完整流程

1.登录时,用户输入手机号获取验证码,通过uuid生成6位验证码,发送给用户,然后以手机号作为key,把验证码保存到redis

2.用户输入验证码,再次请求,通过手机号,获取Redis当中的验证码,进行对比,相同则登录成功

3.登录成功后,会把用户的一些基本信息,例如UserId保存到Redis,生成一个随机token作为key,返回给前端

4.每次请求,携带token,拦截器利用token从Redis当中获取用户信息,获取到就保存到ThreadLocal当中,方便后续使用,获取不到则登录态拦截

2.2.商铺缓存(缓存三剑客+一致性)

在我的热点评项目中,商铺页面肯定是会被经常访问的,如果每次访问都要去对数据库进行读操作,效率是极低的。因此,引入了redis作为缓存工具,其原理就是将数据库查询到的商铺信息,保存在shop类的对象中,然后将shop对象,通过JSONUtil工具类转化为json字符串,保存到redis当中。然后以后每次访问商铺页面,都会优先去缓存里面,尝试获取商铺信息,如果获取不到,再去数据库中获取,从数据库中获取到后,重新保存在redis缓存当中,然后响应给前端

实际测试下来,响应速度得到了极大的提升,由本来的1.6s提升至0.2s,约90%的性能提升

既然使用了redis缓存,好处是提高了读写效率,降低了响应时间,那么肯定是有代价的,代价就是需要解决缓存常见的三个问题:缓存穿透、缓存击穿、缓存雪崩以及使用redis缓存的一个底层问题:数据一致性问题

下面吟唱八股就好

2.3.秒杀券抢购(乐观锁+分布式锁实现+Redission)

实现这个功能,基础逻辑是,用户点击抢购,发送请求到服务器,服务器会将当前的用户id、秒杀券id,以及订单id等信息保存到数据库的订单表里面。

第一个问题:订单id的设计,如果直接使用mysql的自增id,那么会存在两个问题:

1.id规律太明显,容易被外人发现统计出订单量

2.存在单表数据量的限制,订单的量会越来越多,mysql的单表容量不能超过500w,数据量过大就会分表,分表之后逻辑上也是一张表,id也不能重复

因此,我们需要自己去设计一个全局唯一ID生成器

为了安全性,我们将64位的id分为3部分

1位符号位永远为0

31位的时间截:当前时间减去开始时间截,以秒为单位,31位可以用69年

32位的序列号:用一个自增的count表示,同时这也是存在redis当中,记录订单数的value

每生成一个全局ID,count就自增1,而redis中,这个count的key,我使用精确到天的日期格式来取名 order:年:月:日,这样还可以方便查看每日的订单数量

实现了全局ID生成器,我们就可以实现秒杀券抢购的基础逻辑

客户端发送请求携带秒杀券的id,后端拿到秒杀券id后对数据库进行查询,获取当前秒杀券的信息

1.判断秒杀券时间是否开启或结束

2.判断秒杀券的库存是否充足

3.如果是,则扣减库存,创建订单对象。

4.生成订单id,从ThreadLocal获取用户id,将订单id、用户id、秒杀券id存入订单对象中,然后把订单数据保存到数据库表中

5.返回订单id

至此,我们的基本业务流程就实现了,接下来我们需要解决2个关键问题

1.超卖问题

出现的原因是,在多线程并发环境下,加入库存只剩1了,第一个线程判断库存还有之后,开始进行扣减库存的操作,数据库还没有来得及更新数据库的库存之前,有多个线程同样判断当前库存还有剩余,导致超卖

解决方式:乐观锁,在更新数据库的时候,添加一个where条件即可,也就是在修改数据之前判断库存是否大于0即可

2.一人一单问题

为了避免一个人抢多张优惠券,我们在需要加一个判断,就是看当前订单表里面,查看userid与当前这个请求的userid相同的记录,如果发现有,表示当前用户已经抢过优惠券,不能再抢了

但是,在并发环境下,会出现和超卖一样的问题,就是在修改数据库完成之前,有多个线程通过了判断,还是会导致一人多单的问题。而这个问题就没办法通过乐观锁解决,需要使用到悲观锁。具体方法是,将一人一单的业务逻辑单独创建一个方法,然后传入userid,在用户上添加锁,保证锁颗粒度为单个用户。

悲观锁解决了单机情况下的一人一单问题,但是在服务器集群环境下没有解决,原因是,服务器集群环境下,每个tomcat有自己的jvm,每个jvm的syn锁是单独的,也就是说,syn锁只能锁住单机的多线程,其他服务器的线程锁不住。因此就需要分布式锁

最开始呢,我是自己通过redis的setnx命令自建了一个分布式锁,实现原理其实和之前解决缓存击穿时候,编写的互斥锁相似。但是实际运用中会出现问题,就是会出现锁误删的情况。什么意思呢?就是说,线程1获取到锁之后,中途因为网络原因或者业务流程过长的原因,导致锁的ttl过期释放了,线程2拿到了锁,线程2没释放之前,线程1的业务跑完了,释放了线程2的锁,这就是锁误删的情况。为了解决这个问题,我选择在setnx的时候,将线程标识存入value之中,释放锁的时候,需要判断线程标识与redis中分布式锁的线程标识是否一致,如果一致才能对锁进行释放

但是即使如此,在更加极端的情况下也会出现误删问题,就是在判断完线程标识之后,正准备释放锁,结果出现了阻塞,例如垃圾回收时导致的停顿,此时恰好锁的ttl就过期释放了,还是会出现误删问题。那么解决办法就是引入lua脚本,使用lua脚本去操作redis,就能保证判断标识和删锁是一个原子性操作了

还有一个难点是,因为我们把删减库存还有后续的下单业务给单独划分成一个方法,在获取锁之后,直接去调用这个方法。因为涉及到多张表的操作,我们会在这个下单业务的方法上添加事务,但是这里就会产生一个问题,spring的注解式事务,底层是通过AOP实现的,AOP底层又是通过代理对象实现的,如果直接调用这个方法,本质上是this调用,因此我们需要获取到原始的代理对象,然后让代理对象来调用下单的方法

最终的业务流程是,客户端发送请求,后端接收请求之后,通过秒杀券的id去获取当前秒杀券的信息,判断秒杀时间和初步判断库存剩余,判断通过后,会创建我自己编写的分布式锁对象,然后尝试获取锁,这个锁的key是根据当前线程的ThreadLocal中获取的Userid,用来控制这个锁的细粒度为Userid,value就是当前线程的线程标识。成功获取锁之后,就需要去调用创建订单的createOrder方法,需要先获取原始代理对象,然后用代理对象调用,保证事务生效。最后在final代码块里释放锁,释放锁的操作通过lua脚本执行,分别是判断锁的线程标识和当前线程标识是否一致,一致再释放锁

我自己编写的redis分布式锁还存在三个关键问题,第一,不可重入,第二,如果业务时间过长,锁会超时释放,第三,主从一致性问题

后续就是直接使用Redission给我们提供好的分布式锁,吟唱一下Redission提供的分布式锁的优点

2.4.秒杀券优化-异步秒杀(RabbitMQ)

为什么要实现异步秒杀?

因为之前的秒杀券抢购流程:查询优惠券,判断优惠券时间,判断库存,判断一人一单,删减库存,添加订单等众多操作,并且这些操作都是同一个线程串行完成的,并且需要多次访问数据库,每一次下单都耗时极高,通过jmeter测试,1000个线程并发压力环境下发送1000次请求的平均耗时为500毫秒,吞吐量为900,效率很低,因此我采取异步抢购秒杀券的方式对其进行优化

我发现,判断用户下单成功的关键条件只有两个,一个是判断库存是否充足,一个是判断当前用户是否已经下单用来保证一人一单。只要这两个条件成立,那么就可以创建订单

所以,优化思路就是利用redis内存读取高性能的特性,对库存和一人一单进行判断,判断成功之后,让其他线程在后台去完成数据库的修改操作

这里呢,对于异步的处理,就有两种方案,第一种就是使用jvm自带的阻塞队列,但是阻塞队列的长度有限,高并发环境下容易造成内存溢出,并且数据安全没有保证,因为阻塞队列的订单信息是保存在内存中的,没有持久化的保证

因此,我选择使用消息队列的方式来实现保存订单,异步修改数据库的操作

具体过程呢,是添加秒杀券的时候,需要将秒杀券的id作为key,库存总数作为value保存到redis当中,同时,需要利用redis当中的set数据类型,来保存当前秒杀券已经抢购过的userid,为了保证判断的原子性,我们需要在lua脚本中,完成库存的判断,以及当前线程获取的Userid是否在set中,使用的是set的sismember方法。判断成功之后呢,我们需要将订单id、用户id、秒杀券id这个三个关键信息,发送到消息队列当中,至此,就完成了第一步,完成下单的判断

然后就是实现异步线程去获取消息队列的数据,然后修改数据库了。具体流程是,我创建了一个singlethreadExcutor单线程线程池,保证处理任务的有序性。同时,在秒杀券抢购的实现类里,编写了一个init方法加上postConstruct注解,保证这个类初始化时就会执行init方法,方法里就是调用这个线程去处理Stream的消息队列。消息队列的消费相关,可以看消息队列篇

至此,异步秒杀优化的整体流程就全部完成,通过jmeter的测试,1000次请求的平均耗时降低到了110ms,吞吐量呢也上升到了2000,大大提高了在并发压力环境下的性能

2.5.点赞排行榜功能

基本业务流程就是,点赞的时候,为了防止同一个用户无限点赞,我们需要利用set集合来保存点赞过的用户,访问某篇笔记的时候,也需要判断当前点赞的set集合里面是否存在当前用户,如果存在,再次点击就变为取消点赞,点赞数就会-1

但是点开笔记详情的时候,我希望展示的点赞用户信息,是按照点赞先后顺序排列的,就类似QQ微信朋友圈的点赞信息一样,会有一个顺序排列。为了完成这个功能,就需要使用到Zset或者sortedset这个数据结构,代替set进行优化

其中较为关键的部分,就是设置Zset的score值,我直接使用system.currentTimeMills当前时间来指定score值,这样从小到大排列,最前面的就是最先点赞的用户

还有一个问题,就是点开笔记详情的时候,我们通过Zset的zrange命令获取前5个点赞的用户信息,但是保存在zset里面的只有用户id,因此,这里还需要去通过用户id去数据库里面拿到具体的用户信息,然后按照顺序放入UserDTO的集合里面,才可以返回

十.场景题

1.sql优化器不走索引

什么场景下会导致,设置了索引但是没有命中,还是走全表

1.索引区分度太低,过滤能力差

2.数据量太大,查找结果集占数据总量比例过高,例如“查找10w人当中,年龄超过10岁的”

3.超大分页问题,limit语句,出现多次回表查询

2.数据迁移

在实际项目的场景下

修理厂表因为扩展了业务,添加了新的字段,新数据存在新字段,旧数据没有新字段,如何才能在不影响现有业务的基础上,对旧数据进行更新

采用分批同步+双写的方式解决

思想是:创建一个新的数据表,把新数据同时写入旧表和新表,同时分批地将旧表数据同步并且更新到我们的新表,检查完数据全部同步成功后,新表代替旧表,删除旧表

3.因为并发量过大导致的慢查问题

高峰时期,并发量来到10w+,系统产生卡顿,如何优化

这个题本质上就是解决因为并发量过大导致的慢查问题

1.流量控制:利用微服务保护工具例如Sentinel对非核心查询接口(查询历史记录/购物车)做限流,限制qps,并且设置到达阈值时触发降级策略

2.优化数据库:分库分表+读写分离,一般分表分库的数量设置为2的次幂,假设16个库,利用用户id取余或者说hash分库的方式,对数据进行水平的分库分表,然后设置一主多从的方式,实现读写分离,把查询压力分摊到多个数据库

3.使用缓存:Redis缓存是必然要用到的,对订单信息,支付信息等关键查询信息进行缓存,如果还不够可以使用多级缓存,加上本地缓存的方式

4.既然使用了分库分表,肯定会存在一个数据聚合的搜索问题,那么可以使用es或者Shardingsphere这种成熟的工具来解决

4.大促期间的线程池设计

基本思想为:核心线程抗住基本流量,最大线程抗住峰值流量,等待队列削封,拒绝策略兜底

首先,先估计促销期间的qps,假设基本qps为2000,峰值为10000,单线程处理能力为20(1/最大响应时间)

那么,核心线程数就为2000/20 = 100个

最大线程数量应该为预估峰值再上浮个20-30%,也就是10000*1.2/20 = 600个

等待队列可以设置一个有界队列,容量为最大线程数*5也就是3000

拒绝策略,肯定不能使用默认的丢弃策略,可以使用CallerRunsPolicy,让提交任务的线程执行任务,或者自定义拒绝策略,返回“当前系统繁忙,请稍后重试”作为降级处理方案

5.jvm调优

首先,jvm调优是一个很严肃的事情,需要结合具体的业务场景还有具体配置进行分析

原则是先监测再调优,先查看GC日志,还有监控平台,去知道具体出现的问题

1.OOM内存溢出

判断是堆内存溢出还是元空间溢出,还是线程创建过多,对应去调整堆内存大小,元空间大小,还有线程池策略

2.Full GC频繁

Full gc出现一般来说主要分为两类原因:1.内存不足 2.回收效率低

内存不足:

a.老年代内存不足:堆内存太小,大对象太多直接进入老年代

b.内存泄漏:利用内存快照工具检查内存占用大且无释放路径的对象

c.元空间不足:大量反射,动态代理,JSON序列化反序列化

回收效率低:

主要体现在gc时间,stw过长

选择更好的垃圾回收器

具体场景:

商品详情页服务的Full GC问题

分析:因为商品详情页会经常被访问,并发量大,并且详情页信息过多,大概率是大对象,会被直接放入老年代,导致老年代短时间被占满,触发Full GC

解决方法:

1.调整堆内存,年轻代内存,元空间内存

2.提高大对象阈值,让中等对象,先放新生代

3.如果使用了本地缓存,那么需要检查是否合理设置缓存过期时间

6.商品数量少,qps高

基本方案:

模拟抢演唱会票等系统的机制

1.前端添加验证码这种排队机制,分散qps

2.针对相同ip,可以设计Nginx限流,比如1s只准1次

3.消息队列或者线程池阻塞队列削峰

4.redis保证同一用户只能下一次单

5.库存放到Redis里面处理,原子性保证

6.数据库SQL语句乐观锁兜底,加入where条件库存>0

更直接方案:

黑白名单过滤,10000个请求,根据用户id,进行哈希取模的操作

例如用户id,取得hash值之后,直接对10取模,只放行模为1的请求,直接过滤掉9/10的qps,提升90%效率

还可以添加用户画像,比如一个用户7天只能有消费,可以添加标记,直接放行

7.如何做一个API的审批筛选

针对每个用户/每个ip,我限制其1分钟只能访问10次

这个问题的关键是,我们需要理由滑动窗口的思想,灵活的统计,任意一个60s间隔内的访问次数

解决的核心逻辑:利用 ZSet 的有序性(通过 Score 存储时间戳)和范围查询能力,实现对任意 60 秒窗口内访问次数的精准统计

  1. ZSet 的设计

    • key:用户 / IP 标识(如 rate_limit:192.168.1.1),用于区分不同主体。

    • member:每次访问的唯一标识(通常用时间戳,或时间戳 + 随机数避免重复)。

    • score:访问发生的时间戳(毫秒级),确保 ZSet 按时间有序排列。

  2. 统计逻辑:当需要判断 “当前时间往前推 60 秒内的访问次数” 时,只需:

    • 计算窗口起始时间 window_start = 当前时间戳 - 60*1000(毫秒)。

    • ZCOUNT key window_start +inf 统计 Score 在 [window_start, 当前时间] 范围内的 member 数量,这就是 60 秒内的访问次数。

简单说,ZSet 的 Score 为时间戳提供了 “天然排序”,范围查询(ZCOUNT/ZREMRANGEBYSCORE)则直接实现了 “滑动窗口内数据的筛选与统计”,这正是这种方案的核心优势

十一.Java语法相关

1.基本数据类型

整数型

1.byte:1字节,8位:-128~127

2.short:2字节,16位:-2^15~2^15-1

3.int:4字节,32位:-2^31~2^31-1

4.long:8字节,64位:-2^63~2^63-1

浮点型

1.float:4字节,32位:-2^31~2^31-1

2.double:8字节,64位:-2^63~2^63-1

字符类型

1.char:2字节,16位,0~2^16-1

布尔类型

1.boolean:1字节,1位

整数型和浮点型的最高位都表示正负,0为正,1为负,这也是为什么n位的范围是-2^(n-1)~2^(n-1)

2.包装类的比较

两个Integer对象,值都为150,是否相等?

==:比较的是内存地址,不相等

equals:Integer重写了equals方法,比较的是值,相等

3.==和equals的区别

==用于比较的是,两个对象的内存地址

equals方法默认与==一样,是比较内存地址,但是equals方法可以重写

例如String类就重写了equals方法,比较的是两个字符串的内容是否相同,而不是比较其内存地址

4.static、final、this、super关键字

1.static:将修饰的东西与类本身相关联,而非与类的实例对象相关联

可以修饰成员变量、方法、常量、类,被修饰的变量和方法被称之为静态变量和静态方法、被static修饰的类一定是静态内部类等

特点是:被static修饰的方法和变量,不依赖于当前类的任一对象,是所有实例对象共享的

并且,静态方法和属性变量的调用,不需要new对象,可以直接通过类名访问

2.final:核心是不可变,变量不可变,方法不可变

final关键字的含义是:最终 特点是不可被修改

可以修饰属性、方法、类

修饰的属性变为常量,需要初始化且不可被修改

修饰的方法无法被重写

修饰的类也无法被继承

3.this:指向当前类

与super相对应,代表当前对象的引用,访问当前类的成员变量、方法或其他构造方法

不过现在很多时候,this都可以省略,加上this可读性更强

4.super:指向父类

super主要是用于在子类中引用父类的成员变量、方法或构造函数

super.用于访问父类的成员变量和方法

super()用于调用父类的构造方法

5.String、StringBuffer、StringBuilder

首先,先把String和后两个分类

String是不可变的字符串对象,这个类是被final修饰过的,特点是创建之后不可变,如果对其进行修改,本质上并不会改变原有对象,而是创建一个新的对象。

这种类型适合创建一个固定的字符串,如果要对其进行频繁修改,那么会创建多个对象,影响性能

StringBuffer和StringBuilder都是可变字符串类型

两者都是AbstractStringBuilder的子类,底层其实是维护了一个字符类型的数组

最大的区别是StringBuffer是线程安全的,因为这个类的方法上都加了syn关键字,StringBuilder没有

Buffer适用于多线程环境下创建一个会被频繁修改的字符串,Builder适用于单线程环境,因为没有syn修饰,性能要高于Buffer

6.equals和hashcode

equals相等,hashcode一定相等

hashcode相等,equals不一定相等

equals默认比较的是内存地址,内存地址相等,表示两个对象是相同的,hashcode一定相等

hashcode相等,有可能是两个不同内存地址对象发生hash冲突导致hashcode相等,因此equals不一定相等

为了保证第一条规律,重写equals方法的时候,一定也要重写hashcode方法,不然就会造成,两者equals相等(或者说被我们认作相同的对象),但是hashcode不等,这样存储在哈希表中的位置就不一样了,违反了哈希表的唯一性原则

7.异常和错误的区别

异常:Exception

错误:Error

Error错误,是程序无法处理的,我们无法提前预料的,一般来说,都是虚拟机出现的问题,例如堆内存溢出的OutOfMemoryError,栈内存溢出的StackOverFlowError

Exception异常,是程序可以处理,我们可以提前预料,对其进行捕捉和向上抛出的。

又分为运行异常和编译异常

运行异常是运行时产生的异常,也是通常我们遇到的异常,常见的有算术异常,空指针异常、数组下标越界异常、类型转化异常等

编译异常指的是编译时发生的异常,常见的类似超时、文件读取找不到等异常,特点是如果不处理,程序无法编译

除了这两种分类外,还可以分为检查异常和非检查异常,检查异常和编译异常类似,非检查异常包含运行异常和错误,他们的出现主要是因为,spring的事务机制,默认只能捕捉非检查异常,对于编译异常不会进行捕捉

8.创建实例的过程

 String s = new String("abc");

这行代码jvm中的过程

1.检查常量池

jvm检查字符串常量池中是否存在"abc"

如果存在,直接将常量池的引用赋值给s

如果不存在,那么在常量池中新建一个字符串对象为“abc”

2.堆内存分配

因为有new,因此jvm会在堆内存中为String对象分配一个新的内存空间

3.初始化

初始化String对象,并将常量池中的 "abc" 复制到这个新的对象中

4.引用赋值

将新创建的对象引用赋值给s

所以说这行代码其实是创建了两个对象,一个堆内存对象,一个常量池对象

9.方法重载和方法重写的区别

方法重写:

指的是子类继承父类之后,对父类允许子类访问的方法,进行个性化的改写,但是返回值和形参都不能进行改变。

方法重载:

指的是在一个类里面,写多个方法名相同的方法,但是参数不同,返回类型可以相同,也可以不同

简单来讲,方法重写是发生在父类和子类之间的,让子类可以自定义继承到的父类方法。方法重写,是发生在同一个类当中的,针对某个相同的方法,根据不同的参数,走不同的代码块。使用最广泛的就是构造方法的重载

PS.补充八股

1.谈谈cookie、session、token、JWT

Cookie

简单来说,就是客户端在发送登录请求给服务器之后,服务器生成一段字符串给客户端保存,下次访问网站时,客户端通过这个字符串来识别该用户,省去了登录的麻烦

Session

和Cookie不同的是,把登录信息保存在了服务器,采用键值对的结构,生成一个key与登录信息关联,把key返回给浏览器作为Cookie。下次请求时,浏览器把key传给服务器,服务器通过key获取保存在服务器上的的登录信息,这就是Session,Cookie保存的其实是SessionID

Token

Cookie只在网页中存在,但是我们在小程序APP里面也需要保存登录信息,因此有了我们自己创建维护的Cookie,也就是Token,Token就是客户端保存的SessionId,名字我们也不用Cookie来表示,而是Authorization。但是如果把用户信息都存放在Session,也就是单个服务器里面,在分布式服务器下就会出现问题,因此,一般都会把会话信息放到Redis这种共享的服务器上

JWT

全称叫json web token

如果把登录信息放到Redis里面,依赖性就会太强,如果Redis出现问题,会导致分布式服务器集群全部失效,同时,一些边缘化的服务用不到Redis。

因此,引入了JWT,JWT就是一种无状态的Token,和我们之前的方式不同,意思是不再把登录信息保存在服务器上,而是把登录信息通过JWT的方式存储到客户端

JWT由三个部分组成

Header:头部

保存令牌类型:jwt 和签名的算法:HS256

Payload:载荷

存放部分不关键的用户信息和过期时间

Signature:签名

将Header和Payload经过Base64编码之后,加上程序员自己提供的秘钥,两者通过Header设置的签名算法进行签名,形成Signature

这三部分组成了JWT,也就是Token

验证JWT的流程:

在登录之后,会把这个信息返回给客户端保存,以后的请求都会包含这个信息。

后续收到请求的时候,会对JWT进行拆分,拆分成三部分

对头部和载荷进行解码,然后根据头部信息指定的算法,将解码后的信息与服务器自己保存的秘钥进行签名,然后把签名与JWT中拆分得到的签名比较,如果一致,证明有效

然后检查载荷信息,例如JWT是否过期

总结:

其实会发现,JWT本质上就是一个升级版的自定义的Cookie,相比而言,JWT有秘钥的存在,比Cookie更加安全,而且支持移动端。同时,把登录信息保存在服务器也解决了集群环境下的认证问题,同时,对服务器的依赖也没有使用Redis缓存那样强。

但是其实可以发现,验证的关键,从过去验证Session和Redis,变成了对秘钥的验证,那么秘钥也会出现集群模式下的不共享问题,对此的解决方案是使用专门的秘钥管理系统

2.排序算法

排序的指标

时间复杂度

空间复杂度

排序的稳定性

对于相同元素,排序之后相对位置不发生改变

举个例子:同一个商品id,生产日期不同,假设我们已经按照生产日期排好序,那么我们再按照商品id排序,那么稳定排序算法,就会让相同id聚在一起,并且生产日期有序排列

原地排序:指的是排序是否需要额外的辅助空间

2.1 选择排序

概念:从未排序的区域里,选择最小的元素,放到排序区的末尾

时间复杂度:n^2

空间复杂度:1

稳定性:不稳定排序算法

原地排序:是

2.2 *冒泡排序

概念:重复地遍历待排序的列表,比较相邻的元素并交换它们的位置,每一轮都让最后一个成为最大/最小的元素

时间复杂度:n—n^2

空间复杂度:1

稳定性:稳定

原地排序:是

2.3 *插入排序

概念:将arr[i]与前面的元素比较,前面的元素比他大则前面的元素向右移动,比他小则在该元素的后面插入

时间复杂度:n—n^2

空间复杂度:1

稳定性:稳定

原地排序:是

原数组越有序,效率越高

2.4 希尔排序

概念:设置一个gap为初始分组数量,将数组分为gap个组,每组单独进行插入排序,然后缩小gap,直到gap=1时,进行最后一次排序

时间复杂度:不好计算,因为gap的取值方法很多

空间:1

稳定性:不稳定

2.5 *快速排序(最常用)

概念:任取待排序元素序列中的某元素作为基准值,按照该排序码将待排序集合分割成两子序列,左子序列中所有元素均小于基准值,右子序列中所有元素均大于基准值,然后最左右子序列重复该过程,直到所有元素都排列在相应位置上为止。

时间复杂度:nlogn

空间复杂度:logn

稳定性:不稳定

具体步骤:

选定一个基准值,最好选定最左边或者最右边,选中间会给自己找麻烦。

确定两个指针left 和right 分别从左边和右边向中间遍历数组。

如果选最右边为基准值,那么left指针先走(这里很关键,如果选最左边为基准,就需要right指针先走)

left指针如果遇到大于基准值的数就停下来。

然后右边的指针再走,遇到小于基准值的数就停下来。

交换left和right指针对应位置的值。

重复以上步骤,直到left = right ,最后将基准值与left(right)位置的值交换。

2.6堆排序

特点:基于完全二叉树,分为大根堆和小根堆,大根堆就是每个节点的值都大于等于其孩子结点,小根堆就是每个节点的值都小于等于其孩子结点

排序方式:升序用大根堆,降序用小根堆

每次取出堆顶元素:最大值/最小值,然后将末尾的数放入堆顶,然后调整堆,让堆顶重新变为最大/最小值

特点:无序中选择最大最小值变得简单

3.如何判断问题是前端还是后端

1xx :接收的请求正在处理

100:客户端应继续请求

101:切换协议

2xx:成功

200:请求成功

201:创建成功并创建新的资源

202:已接收请求但是未处理完成

204:服务器成功处理但无返回内容

3xx:重定向状态码

301:永久重定向

302:临时重定向

4xx:客户端错误(前端出错)

400:请求语法出错

401:需要认证/认证失败

403:被服务器拒绝,禁止访问

404:无法找到请求资源

405:请求方法出错

5xx:服务器错误(后端出错)

500:服务器内部错误,检查代码

503:服务器过载或维护

4.多线程转账业务手撕

 import java.util.concurrent.ExecutorService;
 import java.util.concurrent.Executors;
 import java.util.concurrent.TimeUnit;
 ​
 public class BankTransferWithThreadPool {
 ​
     // 主方法,程序的入口点
     public static void main(String[] args) throws InterruptedException {
         // 创建两个银行账户,初始余额分别为1000和2000
         BankAccount account1 = new BankAccount(1000);
         BankAccount account2 = new BankAccount(2000);
 ​
         // 创建一个固定大小的线程池,大小为2
         ExecutorService executorService = Executors.newFixedThreadPool(2);
 ​
         // 提交转账任务到线程池
         executorService.submit(() -> transfer(account1, account2, 500)); // 从account1转账500到account2
         executorService.submit(() -> transfer(account2, account1, 300)); // 从account2转账300到account1
 ​
         // 关闭线程池,不再接受新任务,但已提交的任务会继续执行
         executorService.shutdown();
 ​
         // 等待线程池中的所有任务完成执行,最多等待1小时
         executorService.awaitTermination(1, TimeUnit.HOURS);
 ​
         // 打印最终的账户余额
         System.out.println(account1);
         System.out.println(account2);
     }
 ​
     // 转账方法,从from账户转账amount金额到to账户
     private static void transfer(BankAccount from, BankAccount to, int amount) {
         // 通过比较两个账户对象的哈希值来决定锁定的顺序,避免死锁
         BankAccount first = (from.hashCode() > to.hashCode()) ? from : to;
         BankAccount second = (from.hashCode() > to.hashCode()) ? to : from;
 ​
         // 锁定第一个账户
         synchronized (first) {
             // 锁定第二个账户
             synchronized (second) {
                 // 检查源账户是否有足够的余额
                 if (from.getBalance() >= amount) {
                     // 从源账户取款
                     from.withdraw(amount);
                     // 向目标账户存款
                     to.deposit(amount);
                     // 输出转账信息
                     System.out.println(amount + " transferred from " + from + " to " + to);
                 } else {
                     // 如果余额不足,输出错误信息
                     System.out.println("Insufficient funds in " + from);
                 }
             }
         }
     }
 ​
     // 银行账户类
     static class BankAccount {
         private int balance; // 账户余额
 ​
         // 构造方法,初始化账户余额
         public BankAccount(int initialBalance) {
             this.balance = initialBalance;
         }
 ​
         // 存款方法
         public void deposit(int amount) {
             balance += amount;
         }
 ​
         // 取款方法
         public void withdraw(int amount) {
             balance -= amount;
         }
 ​
         // 获取账户余额
         public int getBalance() {
             return balance;
         }
 ​
         // 重写toString方法,方便打印账户信息
         @Override
         public String toString() {
             return "BankAccount{" +
                     "balance=" + balance +
                     '}';
         }
     }
 }

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值