Redis篇

Redis使用场景-缓存

缓存穿透

缓存穿透:查询一个不存在的数据,mysql查询不到数据也不会直接写入缓存,就会导致每次请求都查数据库。

  • 解决方案一:缓存空数据,查询返回的数据为空,仍把这个空结果进行缓存
    • 优点:简单
    • 缺点:消耗内存,可能会发生不一致的问题(后面万一这个数据真的被添加上数据库,此时缓存中还存在,就会发生不一致)

  • 解决方案二:布隆过滤器(主要作用:拦截不存在的数据),查询请求首先进入布隆过滤器中,布隆过滤器中存在再查缓存,不存在直接返回。

    • 缓存预热时,预热布隆过滤器,指的是有一批热点数据,在添加缓存中,顺便也添加到布隆过滤器中。
    • 优点:内存占用比较少,没有多余key
    • 缺点:实现复杂,存在误判(只能判断数据一定不存在或者可能存在,无法判断数据一定存在

布隆过滤器详细实现:

底层是用位图-bitmap

布隆过滤器的误判问题:

id1和id2的数据是存在的,id3的数据是不存在的,但是通过布隆过滤器的哈希操作,发现id为3的是存在的。

误判率:数组越小误判率就越大,数组越大误判率就越小,但是同时也带来了更多的内存消耗。

布隆过滤器具体的实现方案有:Redission和Guava

缓存雪崩

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

解决方案:

  • 给不同的key的ttl设置随机值
  • 利用Redis集群提高服务的可用性(哨兵模式、集群模式,服务宕机)
  • 给缓存业务添加降级限流策略
  • 给业务添加多级缓存

缓存击穿

缓存击穿:一个热点key刚好过期了,然后大量对该key的请求会一起达到数据库中,造成数据库短时间内被击穿。

解决方案一:互斥锁

  • 特点:强一致、性能差

解决方案二:逻辑过期(不设置过期时间)

  • 特点:高可用、性能优

双写一致性

面试官可能会问:redis作为缓存,mysql的数据如何与redis进行同步呢?(双写一致性)

回答这个问题前,一定要明确自己的业务背景,是一致性要求高的还是允许延迟一致的。

双写一致性:当修改了数据库的数据也要同时更新缓存的数据,缓存和数据库的数据要保存一致。

  • 读操作:缓存命中,直接返回;缓存未命中查询数据库,写入缓存,设定超时时间

  • 写操作:延迟双删(这里涉及到一个问题,是先删除缓存还是先修改数据库)

    • 先删除缓存,再操作数据库:可能面临的问题,线程1和线程2,线程1先删除缓存,然后线程2查询缓存发现没有,接着查询数据库并且将数据库的写入缓存,最后线程1修改数据库。(缓存和数据库不一致)

    • 先操作数据库,再删除缓存:线程1和线程2,线程1先查询缓存,发现未命中,查询数据库,线程2先更新数据库,删除缓存,最后线程1写入缓存。(缓存和数据库不一致)

    • 为什么要删除两次缓存? 为了降低脏数据的出现

    • 为什么要延迟删除?一般情况下数据库是主从一致的,所以我们需要延时一会,让主节点同步到从节点。(由于延时的话也不好确定具体的时间,所以也存在脏数据的风险)

若要保证强一致性:采用加锁的方式

进一步优化:由于缓存中的数据一般是读多写少的,所以可以采用读写锁的方式(读数据的时候,加上共享锁,写数据的时候加上排他锁)

读写锁

  • 共享锁:读锁readLock,加锁之后,其他线程可以共享读操作
  • 排他锁:独占锁writeLock,加锁之后,阻塞其他线程读写操作

如果允许短暂的不一致,那么实现方案很多,也是目前主流的

允许延迟一致的业务,采用异步通知

  • 方案1:使用MQ中间件,更新数据之后,通知缓存删除
  • 利用canal中间件,不需要修改业务代码,伪装成MySQL的一个从节点,canal通过读取binlog数据更新缓存

缓存持久化

在redis中提供了两种数据持久化的方式:RDB、AOF

RDB(Redis Database Backup file-Redis数据备份文件),也叫Redis数据快照。简单的来说就是将内存中的所有数据都记录到磁盘中。当Redis实例故障重启后,从磁盘读取快照文件,恢复数据。

RDB手动保存:

  • save命令:直接在主进程执行快照,期间会阻塞所有客户端的请求
  • bgsave命令:后台异步执行快照,主进程会fork一个子进程负责生成快照,不阻塞主进程处理请求。

Redis内部有触发RDB的机制,可以在redis.conf中找到,内容如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
################################ SNAPSHOTTING  ################################
#
# Save the DB on disk:
#
# save <seconds> <changes>
#
# Will save the DB if both the given number of seconds and the given
# number of write operations against the DB occurred.
#
# In the example below the behaviour will be to save:
# after 900 sec (15 min) if at least 1 key changed
# after 300 sec (5 min) if at least 10 keys changed
# after 60 sec if at least 10000 keys changed
#
# Note: you can disable saving completely by commenting out all "save" lines.
#
# It is also possible to remove all the previously configured save
# points by adding a save directive with a single empty string argument
# like in the following example:
#
# save ""

save 900 1
save 300 10
save 60 10000

设置成这样的原因:可以分别应对不同写入频率的场景(低频率、中频率、高频率)

RDB的执行原理:bgsave开始会fork主进程得到子进程,子进程共享主进程的内存数据。完成fork后读取内存数据并写入RDB文件。

AOF(Append Only FIle-追加文件),redis处理的每一个写命令都会记录到AOF文件,可以看作是命令日志文件。

AOF默认是关闭的,还需啊哟修改redis.conf配置文件来开启AOF。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# AOF and RDB persistence can be enabled at the same time without problems.
# If the AOF is enabled on startup Redis will load the AOF, that is the file
# with the better durability guarantees.
#
# Please check http://redis.io/topics/persistence for more information.

appendonly no

# The name of the append only file (default: "appendonly.aof")

appendfilename "appendonly.aof"

# 表示每执行一次写命令,立即记录到AOF文件
# appendfsync always
# 写命令执行完成后先放入AOF缓冲区,然后每隔1s将缓冲区数据写到AOF文件,是默认方案
appendfsync everysec
# 写命令执行完先放入AOF缓冲区,由操作系统决定何时将缓冲区内容写回磁盘
# appendfsync no
配置项 刷盘时机 优点 缺点
always 同步刷盘 可靠性高,几乎不丢失数据 性能影响大
everysec 每秒刷盘 性能适中 最多丢失1s数据
no 操作系统控制 性能最好 可靠性较差,可能丢失大量数据

AOF因为是记录命令,AOF文件会比RDB文件大得多,而且AOF会记录对同一个key的多次写操作(比如修改name多次,但是只有最后一次有意义),但只有最后一次写操作才有意义,通过bgrewriteaof命令,可以让AOF文件执行重新功能,用最少的命令达到相同效果。

Redis也会在触发阈值时自动去重写AOF文件,阈值也可以在conf文件中配置:

1
2
3
4
# AOF文件比上次文件增长超过多少百分比则触发重写
auto-aof-rewrite-percentage 100
# AOF文件体积最小多大以上才触发重写
auto-aof-rewrite-min-size 64mb

RDB与AOF对比

RDB AOF
持久化方式 定期对整个内存拍照 记录每一次执行的命令
数据完整性 不完整,两次备份之间会丢失 相对完整,取决于刷盘策略
文件大小 会有压缩,文件体积小 记录命令,文件体积很大
宕机恢复速度 很快(文件小,恢复快) 慢(文件大,恢复慢)
数据恢复优先级(和数据完整性相关) 低,因为数据完整性不如AOF 高,因为数据完整性更高
系统资源占用 高,大量CPU和内存消耗 低,主要是磁盘IO资源,但AOF重写(宕机恢复数据)时会占用大量CPU和内存资源
使用场景 可以容忍数分钟的数据丢失,追求更快的启动速度 对数据安全性要求较高常见

数据过期策略

面试官可能会问:假如redis的key过期之后,会立即删除吗?

Redis对数据设置数据的有效时间,数据过期以后,就需要将数据从内存中删除掉。可以按照不同的规则进行删除,这种删除规则就被称之为数据的删除策略。

redis中主要有两种删除了策略:

  • 惰性删除:设置该key过期时间后,我们不去管他,当需要该key时,我们再检查其是否过期,如果过期,我们就删除掉它,反之返回该key。
    • 优点:对CPU友好,只会在使用该key时才会进行过期检查,对于很多用不到的key不用浪费时间进行过期检查。
    • 缺点:对内存不友好,如果一个key已经过期,但是一直没有使用,那么该key就会一直存在内存中,内存永不释放。
  • 定期删除:每隔一段时间,我们对一些key进行检查,删除里面过期的key(从一定数量的数据库去除一定数量的随机key进行检查,并删除其中过期key)
    • 定期删除有两种模式:SLOW模式和FAST模式
      • SLOW模式是定时任务,执行频率默认为10hz,每次不超过25ms,以通过修改配置文件redis.conf的hz选项来调整这个次数
      • FAST模式执行频率不固定,但两次间隔不低于2ms,每次耗时不超过1ms
    • 优点:可以通过限制删除操作执行时长和频率来减少删除操作对CPU的影响。另外定期删除,也能有效释放过期键占用的内存
    • 缺点:难以确定删除操作执行的时长和频率

在Redis中,过期删除策略是惰性删除+定期删除两种策略进行配合使用

数据淘汰策略

面试问题:假如缓存过多,内存是有限的,内存被占满了怎么办?

数据的淘汰策略:当Redis中的内存不够用时,此时再向Redis添加新的key,那么Redis就会按照某一种规则将内存中的数据删除掉,这种数据的删除规则被称之为内存的淘汰策略。

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算法进行淘汰

LRU(Least Recently Used)最近最少使用。用当前时间减去最后一次访问时间,这个值越大则淘汰优先级越高。

例:key1是在3s之前访问的,key2是在9s之前访问的,删除的就是key2

LFU(Least Frequently Used)最少频率使用。会统计每个key的访问频率,值越小淘汰优先级越高。

例:key1最近5s访问了4次,key2最近5s访问了9次,删除的就是key1

面试例题:

问:数据库有1000万数据,Redis只能缓存20w数据,如何保证Redis中的数据都是热点数据?

答:使用allkeys-lru淘汰策略

问:Redis的内存用完了会发生什么?

答:主要看淘汰策略,如果是默认的配置,会直接报错。

Redis分布式锁

分布式锁在黑马点评中主要是抢券场景,抢券流程中会先查询优惠券,判断库存是否充足(充足则扣减库存,否则返回异常结果)。当多线程并发的时候,如果没有锁很容易发生超卖问题

实际情况中,同一份代码被部署在多个tomcat中,集群模式,如果使用synchronized锁,只是对一个服务加锁(synchronized加的当前jvm的),只能解决同一个jvm下的互斥问题,解决不了多个jvm之间的互斥问题,所以就需要分布式锁

Redis实现分布式锁主要利用Redis的setnx命令,setnx是SET if not exists(如果不存在,则SET)的简写。

  • 获取锁:

    1
    2
    # 添加锁,NX是互斥、EX是设置超时时间
    SET lock value NX EX 10
  • 释放锁:

1
2
# 释放锁
DEL key

分布式锁执行流程:

加锁成功之后会单开一个线程进行监控,被称为看门狗,会给这个线程续期,每隔releaseTime/3的时间做一次续期(releaseTime默认是30s)。释放锁的操作是手动的,释放时需要通知一下看门狗。

当另一个线程尝试获取锁,如果获取失败,会通过while循环,不断尝试获取锁。

Redission中加锁、设置过期时间等操作都是基于lua脚本完成。(保证原子性)

Redis的分布式锁是不可重入的,Redission实现的分布式锁是可重入的(通过判断是否为同一个线程来判断是否可以重入)。

Redission实现的分布式锁——主从一致性

  • 存在的问题:如果一个应用写数据,在主节点加锁,主节点还没来得及给从节点同步数据,主节点宕机了。此时会选出新的主节点,万一一个新的应用继续获取锁,由于之前的数据没有同步过来,所以也能获取锁成功,这时出现了两个线程同时持有了同一把锁(没有互斥性)。
  • 解决:RedLock(红锁):不能在一个Redis实例上创建锁,应该是在多个Redis实例上创建锁(n/2 +1),避免在一个redis实例上加锁。
    • 但一般也不经常使用,因为实现复杂、性能差、运维繁琐,并且节点也不是那么容易宕机,redis是AP思想(高可用),如果要保证数据的强一致性,建议使用zookeeper实现的分布式锁。(CP)

Redis其他面试问题

面试问题:Redis集群有哪些方案,知道嘛

集群方案:

  • 主从复制
  • 哨兵模式
  • 分片集群

可能涉及到的其他问题:

  • redis主从数据同步的流程是什么?
  • 怎么保证redis的高并发高可用?
  • 你们使用的redis是单点还是集群,哪种集群?
  • Redis分片集群中数据是怎么存储和读取的?
  • Redis集群脑裂,该怎么解决呢?

主从复制

单节点Redis的并发能力是有上限的,要进一步提高Redis的并发能力,就需要搭建主从集群,实现读写分离。(一般主节点负责写操作,从节点负责读操作,因为读多写少)

主从数据同步原理

主从全量同步:

因为可能出现,主节点发送RDB文件的时候,主节点的数据又发生了变化,所以还需要记录RDB期间所有命令道repl_baklog中。

Replication Id:用来判断是不是第一次同步,(一开始都是主节点,初始化的Replication Id各不相同,然后建立主从关系后从节点会继承主节点的Replication Id)

offset:用来判断slave是否要更新(可以理解为版本号)

主从增量同步(slave重启或后期数据变化):

哨兵模式

主从复制保证不了Redis的高可用,主节点宕机后,就丧失了写数据的能力。

Redis提供了哨兵(Sentinel)机制来实现主从集群的自动故障恢复。

  • 监控:哨兵会不断检查你的master和slave是否按预期工作。
  • 自动故障恢复:如果master故障,sentinel会将一个slave提升为master。当故障实例恢复后也以新的master为主。
  • 通知:Sentinel充当Redis客户端的服务发现源,当集群发生故障转移时,会将最新信息推送给Redis的客户端。

接下来,引出哨兵是怎么监控的——心跳机制

Sentine基于心跳机制监测服务状态,每隔1s向集群的每个实例发送ping命令:

  • 主观下线:如果某Sentinel节点发现某实例未在规定时间响应,则认为该实例主观下线。
  • 客观下线:若超过指定数量(quorum)的Sentinel都认为该实例主观下线,则该实例客观下线。quorum值最好超过Sentinel实例数量的一半。

如果主节点宕机,哨兵就会选择一个从节点作为主节点。

哨兵选主规则:

  • 首先判断主与从节点断开时间长短,如超过指定值就排除该从节点
  • 然后判断从节点的slave-priority值,越小优先级越高
  • 如果salve-prority一样,则判断slave节点的offset值,越大优先级越高
  • 最后是判断slave节点的运行id大小,越小优先级越高。

哨兵模式可能出现的问题——脑裂

脑裂:

以下这个包含主从复制和哨兵模式

然后由于网络原因,主节点与哨兵处于不同的网络分区,那么现在哨兵只能去检测从节点。此时哨兵会选主,选择出一个主节点。

此时存在两个主节点,客户端还连接着老的主节点,会持续向老的主节点写入数据,万一此时网络恢复了,大家都共处一个网络,此时哨兵会将老的主节点降级为从节点。然后主节点会同步数据,此时老的主节点会清空之前写入的数据,也就丢失了。

解决脑裂问题:

可以通过配置来解决

redis中有两个配置参数:

  • min-replicas-to-write 1:表示最少的额slave节点为1个
  • min-replicas-max-lag 5:表示数据复制和同步的延迟不能超过5s

发生脑裂之后,满足不了以上两个要求,也就会拒绝客户端的请求了。

redis单节点的写操作的并发是8万左右,读操作的并发是在10万左右。

分片集群

主从和哨兵可以解决高可用、高并发读的问题。但是依然有两个问题没有解决:

  • 海量数据存储问题
  • 高并发写的问题

使用分片集群可以解决上述问题,分片集群特征:

  • 集群中有多个master,每个master保存不同数据(高并发写也解决了,因为每个master都能写)
  • 每个master都可以有多个slave节点(高并发读也解决了,每个slave都能读)
  • master之间通过ping监测彼此健康状态(不用之前的哨兵了,master来充当哨兵)
  • 客户端请求可以访问集群任意节点,最终都会被转发到正确节点

那是怎么能转发到正确的节点,路由的呢?

分片集群结构——数据读写

Redis分片集群引入了哈希槽的概念,Redis集群有16384个哈希槽,每个key通过CRC16校验后对16384取模来决定放置哪个槽,集群的每个节点负责一部分hash槽。

(不同数量的master节点分配的方式也不同,如果想将数据存储在某一特定哈希槽中,可以通过加上有效部分来实现,比如图中大括号中的aaa)

Redis是单线程的,但是为什么还那么快?

用户空间和内核空间

Linux系统中一个进程使用的内存情况划分两部分:内核空间、用户空间

  • 用户空间只能执行受限的命令,而且不能直接调用系统资源,必须通过内核提供的接口来访问
  • 内核空间可以执行特权命令,调用一切系统资源

Linux系统为了提高IO效率,会在用户空间和内核空间都加入缓冲区:

  • 写数据时,要把用户缓冲数据拷贝到内核缓冲区,然后写入设备
  • 读数据时,要从设备读取数据到内核缓冲区,然后拷贝到用户缓冲区

想要提升速度,要针对以上的两个方面进行优化:(接下来提到的IO模型也就是从这两方面有些优化)

  • 减少无效的等待(等待数据就绪的时间)
  • 减少用户空间和内核空间之间数据的拷贝

阻塞IO

顾名思义,阻塞IO就是两个阶段都必须阻塞等待:

阶段一:

  • 1.用户进程尝试读取数据(比如网卡数据)
  • 2.此时数据尚未到达,内核需要等待数据
  • 3.此时用户进程也处于阻塞状态

阶段二:

  • 1.数据到达并拷贝到内核缓冲区,代表已就绪
  • 2.将内核数据拷贝到用户缓冲区
  • 3.拷贝过程中,用户进程依然阻塞等待
  • 4.拷贝完成后,用户进程解除阻塞,处理数据

可以看到,阻塞IO模型中,用户进程在两个阶段都是阻塞状态。

非阻塞IO

非阻塞IO的recvfrom操作会立即返回结果而不是阻塞用户进程。

阶段一:

  • 1.用户进程尝试读取数据(比如网卡数据)
  • 2.此时数据尚未到达,内核需要等待数据
  • 3.返回异常给用户进程
  • 4.用户进程拿到error后,再次尝试读取
  • 5.循环往复,指导数据就绪

阶段二:

  • 1.将内核数据拷贝到用户缓冲区
  • 2.拷贝过程中,用户进程依然阻塞等待
  • 3.拷贝完成后,用户进程解除阻塞,处理数据

可以看到,非阻塞IO模型中,用户进程在第一个阶段是非阻塞,第二个阶段是阻塞状态。虽然是非阻塞,但性能并没有得到提高。而且忙等机制会导致CPU空转,CPU使用率暴增。

IO多路复用

是利用单个线程来同时监听多个Socket(Socket是客户端的连接),并在某个Socket可读、可写时得到通知,从而避免无效的等待,充分利用CPU资源。

阶段一:

  • 1.用户进程调用select,指定要监听的Socket集合
  • 2.内核监听对应的多个Socket
  • 3.任意一个或多个Socket数据就绪则返回readable
  • 4.此过程中用户进程阻塞

阶段二:

  • 1.用户进程找到就绪的Socket
  • 2.依次调用recvfrom读取数据
  • 3.内核将数据拷贝到用户空间
  • 4.用户进程处理数据

通知的视线方式又有多种实现,在linux下,有:

  • select
  • poll
  • epoll

差异:

  • select和poll只会通知用户进程有Socket就绪,但不确定具体是哪个Socket,需要用户进程逐个遍历Socket来确认
  • epoll则会在通知用户进程Socket就绪的同时,把已就绪的Socket写入用户空间

Redis网络模型

Redis通过IO多路复用来提高网络性能,并且支持各种不同的多路复用实现,并且将这些实现进行封装,提供了统一的高性能事件库。

IO多路复用监听各种Socket连接,并且将已经就绪的连接通知,然后事件派发,有多个事件处理器:连接应答处理器(处理客户端请求的应答)、命令回复处理器(处理客户端响应的)、命令请求处理器(接收客户端参数,转换redis指令,输出结果,结果还需要使用命令回复处理器返回给客户端)

以上影响性能的主要是IO,(影响性能很多都是IO),为了解决这个性能问题,引入了多线程,主要是:命令解析和响应结果的输出。其他都是单线程。

MySQL篇

优化

如何定位慢查询

面试问题:在MySQL,如何定位慢查询?

慢查询一般出现在:

  • 聚合查询
  • 多表查询
  • 表数据量过大查询
  • 深度分页查询

表现形式:页面加载过慢、接口压测响应时间过长(超过1s)

如何确定是因为SQL导致的,并且找出是哪个执行慢的SQL

如何定位慢查询?

方案一:开源工具

  • 调试工具:Arthas
  • 运维工具:Prometheus、Skywalking

方案二:MySQL自带慢日志

慢查询日志记录了所有执行时间超过指定参数(long_query_time,单位:秒,默认10秒)的所有SQL语句的日志,如果要开启慢查询日志,需要在MySQL的配置文件中配置如下信息:(默认是没开启的)

1
2
3
4
# 开启MySQL慢日志查询开关
slow_query_log=1
# 设置慢日志的时间为2s,SQL语句执行时间超过2s,就会视为慢查询,记录慢查询日志
long_query_time=2

在生产阶段不会开启慢日志查询,因为会损耗MySQL的性能。

SQL语句执行很慢,如何分析呢?

  • 聚合查询
  • 多表查询
  • 表数据量过大查询
  • 深度分页查询

以上前三个都可以通过SQL执行计划找到慢的原因。

可以使用EXLPAIN或者DESC命令获取MySQL如何执行SELECT语句的信息。

语法:

1
2
# 直接在select语句之前加上关键字 explain / desc
EXPLAIN select 字段列表 from 表名 where 条件;

获取到的信息需要掌握的:

  • possible_key:当前sql可能会使用到的索引

  • key:当前sql实际命中的索引

  • key_len:索引占用的大小

    • 可通过key_len和key查看命中了哪些列
  • Extra:额外的优化建议

    • Extra 含义
      Using where; Using Index 查找使用了索引,需要的数据都在索引列中能找到,不需要回表查询数据
      Using index condition 查找使用了索引,但是需要回表查询数据
  • type:这条sql的连接的类型,性能由好到差为NULL、system、const、eq_ref、ref、range、index、all

    • NULL——代表执行期间没有使用到表
    • system——执行使用的表是mysql中内置的表
    • const——根据主键查询
    • eq_ref——主键索引查询或唯一索引查询
    • ref——索引查询
    • range——范围查询
    • index——索引树扫描
    • all——全盘扫描

    一般index和all就表示需要优化

    假设我们有一张用户表 users,并建立了一个复合索引 idx_name_age_status

    1
    2
    3
    4
    5
    6
    7
    8
    CREATE TABLE `users` (
    `id` int(11) NOT NULL AUTO_INCREMENT,
    `name` varchar(100) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
    `age` int(11) DEFAULT NULL,
    `status` varchar(20) CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci DEFAULT NULL,
    PRIMARY KEY (`id`),
    KEY `idx_name_age_status` (`name`,`age`,`status`)
    ) ENGINE=InnoDB;

    我们来分析几个不同的查询:

    例1:使用索引的第一列 (name)

    1
    EXPLAIN SELECT * FROM users WHERE name = 'John';
    • key: idx_name_age_status (命中了这个复合索引)
    • key_len 计算:
      • namevarchar(100) 且允许 NULL,字符集为 utf8mb4
      • 长度 = 100 * 4 + 2 (可变长度) + 1 (可为 NULL) = 403
    • 结论:key_len 为 403,说明只使用了复合索引的第一个字段 name

    例2:使用索引的前两列 (name 和 age)

    1
    EXPLAIN SELECT * FROM users WHERE name = 'John' AND age = 30;
    • key: idx_name_age_status
    • key_len 计算:
      • name: 403 字节 (同上)
      • age: 是 int 且允许 NULL。
      • 长度 = 4 (int) + 1 (可为 NULL) = 5
      • 总长度 = 403 (name) + 5 (age) = 408
    • 结论:key_len 为 408,说明高效地使用了复合索引的前两个字段。

索引概念

像下图中左边的情况,如果没有索引,需要找age为45的数据,那么就会从上往下依次对比,找到45之后还会继续向下扫描,直至扫描完。

有了索引像下图中右边的情况,可以通过二叉树快速找到45,所以索引是提高查找效率的。

数据结构对比(为什么用B+树而不是其他数据结构)

如果存在红黑树中,当数据量特别大,由于红黑树也是二叉树,所以会导致红黑树特别高,查找效率变低。

B树:B-Tree,B树是一种多叉路平衡查找树,相对于二叉树,B树每个节点可以有多个分支,即多叉。

以一颗最大度数为5(5阶)的b-tree为例,那么这个B树每个节点最多存储4个key。(因为B树遵守若一个节点有k个子节点,则该节点必须存储k-1个关键字,一个节点存储有k-1个关键字,指针插空,刚好就是k个指针,k个子节点)

B+树:B+Tree是BTree基础上的一种优化,使其更适合实现外存储索引结构,InnoDB存储引擎就是使用B+Tree实现其索引结构。非叶子节点不存储数据,只有叶子节点才会存储数据。

B+树与B树对比:

  • 磁盘读写代价,B+树更低(由于非叶子节点不存储数据,只存储指针)
  • 查询效率B+树更加稳定(因为都要到叶子节点上获取数据,所以路径都要到叶子节点上)
  • B+树便于扫库和区间扫描(叶子节点之间是通过双向指针连接,进行范围查询的时候更加便捷)

聚簇索引和非聚簇索引

分类 含义 特点
聚集索引 将数据存储与索引放到了一块,索引结构的叶子节点保存了行数据 必须有,而且只有一个(一个表只能有一个)
二级索引(也叫非聚集索引) 将数据与索引分开存储,索引结构的叶子节点关联的是对应的主键 可以存在多个

聚集索引的选取规则:

  • 如果存在主键,主键索引就是聚集索引
  • 如果不存在主键,将使用第一个唯一索引作为聚集索引
  • 如果表没有主键,或没有合适的唯一索引,则InnoDB会自动生成一个rowid作为隐藏的聚集索引

如图所示,由于id是主键,所以是聚集索引,key也就是id值,然后叶子节点存储的是整行的数据。下面的二级索引是name,key存储的是name,叶子节点存储的数据是对应的主键。

先通过二级索引(非聚集索引)找到对应的主键值,再通过主键再到聚集索引中拿到整行的值,这个过程就是回表查询。

覆盖索引

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

上述例子中,第二个SQL语句,是找二级索引name,由于name中会记录name和对应的主键id,所以返回的都能在该索引中找到,故而是覆盖索引。

第二条sql语句的返回值在该索引中都能找到。

问题:MySQL超大分页

在数据量比较大时,如果进行limit分页查询,在查询时,越往后,分页查询效率越低。

解决这个问题的方法——通过覆盖索引

优化思路:一般分页查询时,通过创建覆盖索引能够比较好地提升性能,可以通过覆盖索引加子查询的形式进行优化。

以上这个例子:子查询中使用了覆盖索引,保证了查一个索引就能得到符合条件的id,然后联表筛选出符合条件的。

(之前的是11s,提升到7s了)

覆盖索引的使用例子:“假设我们有一张 student 学生表,它包含很多字段,比如 idnameagegenderaddressscore 等等。”

“现在有一个非常高频率的业务需求:根据学生的姓名来查询他的年龄。对应的 SQL 语句可能是:

1
SELECT name, age FROM student WHERE name = '张三';

“如果我们在 name 字段上建立一个普通的单列索引,那么这个查询过程会是:

  1. name 索引的 B+Tree 中找到 '张三' 对应的记录,获取到这条记录的主键 id
  2. 通过这个主键 id,回到主键索引(聚簇索引)的 B+Tree 中进行一次回表查询,最终定位到完整的行数据。
  3. 从这行数据中取出 age 字段返回。

虽然用到了索引,但发生了一次回表,产生了额外的磁盘 I/O。

“为了极致地优化这个高频查询,我们可以创建一个联合索引 idx_name_age (name, age)。”

“这时,再执行同样的查询,过程就变成了:

  1. idx_name_age 这个联合索引的 B+Tree 中查找 name = '张三' 的记录。
  2. 因为这条索引的叶子节点不仅存储了 name,还存储了 age所有需要的数据在这个索引树上已经全部包含了
  3. 引擎直接在索引中就能取到 age 的值并返回,完全不需要再回表到主键索引去查找整行数据

“这就是覆盖索引。它的优势非常明显:

  1. 性能提升:消除了回表带来的额外磁盘 I/O,查询速度更快。
  2. 减少负载:对聚簇索引的压力更小,在高并发场景下尤其有效。

索引创建原则

索引创建原则有哪些?

  • 1.针对于数据量较大,且查询比较频繁的表建立索引。(单表超过10万数据,增加用户体验)
  • 2.针对于常作为查询条件(where)、排序(order by)、分组(group by)操作的字段建立索引
  • 3.尽量选择区分度高的列作为索引,尽量建立唯一索引,区分度越高,使用索引的效率越高。
    • 比如下图中地址字段区分度明显不高
  • 4.如果是字符串类型的字段,字段的长度较长,可以针对于字段的特点,建立前缀索引。
    • 如下图中sell_point字段,内容比较长,如果作为索引,内存占用大。(所以可以将字段前面的一部分当做索引,即前缀索引)
  • 5.尽量使用联合索引,减少单列索引,查询时,联合索引很多时候可以覆盖索引,节省存储空间,避免回表,提高查询效率。
  • 6.要控制索引的数量,索引越多,维护索引结构的代价也就越大,会影响增删改的效率(每次增删改,都要去修改相应的索引)。
  • 7.如果索引列不能存储NULL值,请在创建表时使用NOT NULL约束它。当优化器知道每列是否包含NULL值时,它可以更好地确定哪个索引最有效地用于查询。

什么情况下索引会失效?

  • 1.违反最左前缀法则:如果索引了多列,要遵守最左前缀法则。指的是查询从索引的最左前列开始,并且不跳过索引中的列。匹配最左前缀法则,走索引。
    • 从下图和上图的对比可以观察出索引失效了
  • 2.范围查询右边的列,不能使用索引。
  • 3.不要在索引列上进行运算操作,索引将失效
  • 4.字符串不加单引号,造成索引失效
  • 5.以%开头的Like模糊查询,索引失效。如果仅仅是尾部模糊匹配,索引不会失效;如果是头部模糊匹配,索引失效。

谈一谈你对SQL优化的经验

索引优化可以参考创建原则和避免索引失效。

表的设计优化(参考阿里开发手册《嵩山版》)

  • 比如设置合适的数值(tinyint int bigint),要根据实际情况选择。
  • 比如设置合适的字符串类型(char和varchar),char定长效率高,varchar可变长度,效率稍低。

SQL语句优化

  • select语句务必指明字段名称(避免直接使用select *)
    • select *可能会导致回表查询。
  • SQL语句要避免造成索引失效的写法
  • 尽量使用union all代替union,union会多一次过滤,效率低
    • select * from t_user where id > 2 union all | union select * from t_user where id < 5
    • 如上边的例子,加入有重复的数据,union all会将重复的数据重复展示,而union会过滤掉重复的数据,使其只有一个。
  • 避免在where子句中对字段进行表达式操作
    • 表达式,参考之前的索引失效的情况,索引列上有运算符会失效。
  • Join优化,能用inner join就不用left join、right join,如果必须使用一定要以小表为驱动,内连接会对两个表进行优化,优先把小表放到外边,把达标放到里边。left join或right join,不会重新调整顺序。
    • for(int i = 0; i < 3; i++){for(int j = 0; j < 1000; j++){ }}
    • 上面的代码,外循环是3次,内循环是1000次,我们可以认为外循环是一个小表,内循环是一个大表。如果是小表在外面,那么MySQL会执行3次表的连接,每一次连接执行1000次判断操作。反过来,如果是小表在里面,会有1000次的连接,每次3次的操作。

对于上述的join优化的场景解释:

假设场景:

  • 小表 user(用户表):100 行,存储用户 ID(user_id,主键)。
  • 大表 order(订单表):100 万行,存储订单 ID 和用户 ID(user_id,普通索引)。

需求:查询所有用户的订单信息(包括没有订单的用户),用 user left join order on user.user_id = order.user_id

情况 1:小表 user 作为驱动表(正确做法)

  • 外层循环:遍历 user 的 100 行(驱动行)。
  • 内层循环:对每一行 user_id,到 order 表中通过索引查找匹配的订单(单次查找成本低,假设 0.1ms)。

总耗时 ≈ 100 行 × 0.1ms / 行 = 10ms

情况 2:大表 order 作为驱动表(错误做法)

如果写成 order left join user(逻辑错误,且性能极差):

  • 外层循环:遍历 order 的 100 万行(驱动行)。
  • 内层循环:对每一行 user_id,到 user 表中查找匹配的用户(即使 user 表小,单次查找 0.01ms)。

总耗时 ≈ 100 万行 × 0.01ms / 行 = 10,000ms(10 秒)

主从复制、读写分离

如果数据库的使用场景读的操作比较多的时候,为了避免写的操作所造成的性能影响,可以采用读写分离的架构,读写分离解决的是,数据库的写入,影响了查询的效率。

事务

事务的特性

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

特性:

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

并发事务问题和隔离级别

并发事务问题:

问题 描述
脏读 一个事务读到另外一个事务还没有提交的数据
不可重复读 一个事务先后读取同一条记录,但两次读取的数据不同
幻读 一个事务按照条件查询数据时,没有对应的数据行,但是在插入数据时,又发现这行数据已经存在,好像出现了“幻影”

幻读:事务A一开始查询数据库,发现没有id为1的数据,后面事务B插入一条id为1的数据,后面事务A插入的时候发现插入失败,之后事务A查询id为1的数据,发现依旧是没有。(因为前提是默认解决了不可重复读的问题)

事务的隔离级别:

隔离级别(后面表示能解决的问题) 脏读 不可重复读 幻读
读未提交 × × ×
读已提交 × ×
可重复读(默认) ×
串行化

注意:事务隔离级别越高,数据越安全,但是性能越低

undo log和redo log的区别

缓冲池(buffer pool):主内存中的一个区域,里面可以缓存磁盘上经常操作的真实数据,在执行增删改查操作时,先操作缓冲池中的数据(若缓冲池没有数据,则从磁盘加载并缓存),以一定频率刷新到磁盘,从而减少磁盘IO,加快处理速度。

数据页(page):是InnoDB存储引擎磁盘管理的最小单元,每个页的大小默认为16KB,页中存储的是行数据。

redo log

重做日志,记录的是事务提交时数据页的物理修改,是用来实现事务的持久性。

该日志文件由两部分组成:重做日志缓冲(redo log buffer)以及重做日志文件(redo log file),前者是在内存中,后者是在磁盘中。当事务提交之后会把所有修改信息都存到该日志文件中,用于在刷新脏页到磁盘,发生错误时,进行数据恢复使用。

不用redo log日志,比如当Buffer Pool页发生变化时,直接进行同步,这样做会有严重的性能问题。因为在我们操作的时候,可能有多条记录,当我们保存数据到磁盘时,都是随机的磁盘IO,性能是非常低的;使用redo log是顺序的磁盘IO,因为日志文件都是追加的。

当刷新脏页数据到磁盘时,假如发生了错误,那么这时就可以使用redo log进行数据的恢复,就能够实现事务的持久性。

WAL:先写日志

undo log

回滚日志,用于记录数据被修改前的信息,作用包含两个:提供回滚和MVCC(多版本并发控制)。undo log和redo log记录物理日志不一样,它是逻辑日志

  • 可以认为当delete一条记录时,undo log中会记录一条对应的insert记录,反之亦然。
  • 当update一条记录时,它记录一条对应相反的update记录。当执行rollback时,就可以从undo log中的逻辑记录读取到相应的内容并进行回滚。

undo log可以实现事务的一致性和原子性

MVCC

事务的ACID,还剩隔离性,可能会问隔离性怎么保证?

  • 锁:排它锁(平时使用的insert、update、delete都会自动去添加这个排它锁)
  • MVCC

MVCC:全称是Multi-Version Concurrency Control,多版本并发控制。指维护一个数据的多个版本,使得读写操作没有冲突。

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

例子:

事务5中的查询到底查询的是哪个事务的记录?因为同时访问的有四个(事务2、3、4、5)

记录中的隐藏字段

隐藏字段 含义
DB_TRX_ID 最近修改事务ID,记录插入这条记录或最后一次修改该记录的事务ID
DB_ROLL_PTR 回滚指针,指向这条记录的上一个版本,用于配合undo log,指向上一个版本
DB_ROW_ID 隐藏主键,如果表结构没有指定主键,将会生成该隐藏字段

undo log

回滚日志,在insert、update、delete的时候产生的便于数据回滚的日志。

当insert的时候,产生的undo log日志只在回滚时需要,在事务提交后,可被立即删除。

而update、delete的时候,产生的undo log日志不仅在回滚时需要,mvcc版本访问也需要,不会立即被删除。

undo log版本链

readview

readview(读视图)是快照SQL执行时MVCC提取数据的依据,记录并维护系统当前活跃的事务(未提交的)id。

  • 当前读:读取的是记录的最新版本,读取时还要保证其他并发事务不能修改当前记录,会对读取的记录进行加锁。对于我们日常的操作,如:select...lock in share mode(共享锁)、select...for update、insert、delete(排他锁)都是一种当前读。
  • 快照读:简单的select(不加锁)就是快照读,快照读,读取的是记录数据的可见版本,有可能是历史数据,不加锁,是非阻塞读。
    • Read Committed:每次select,都生成一个快照读
    • Repetable Read:开启事务后第一个select语句才是快照读的地方

ReadView中包含了四个核心字段:

字段 含义
m_ids 当前活跃的事务ID集合(因为可能存在多个事务未提交)
min_trx_id 最小活跃事务ID
max_trx_id 预分配事务ID(由于事务ID是自增的,指的是下一个要生成的事务ID),当前最大事务ID+1(因为事务ID是自增的)
creator_trx_id ReadView创建者的事务ID

版本链数据访问规则:

(trx_id代表当前事务id)

  • 1.trx_id == creator_trx_id? 该版本的记录对当前事务可见
  • 2.trx_id < min_trx_id? 该版本的记录对当前事务可见——说明数据已经提交了
  • 3.trx_id > max_trx_id? 该版本对当前事务不可见
  • 4.min_trx_id <= trx_id <= max_trx_id? 还需判断是否在m_ids中,在m_ids中说明该版本对当前事务不可见(未提交),反之则可见。

不同的隔离级别,生成ReadView的时机不同:

  • READ COMMITTED:在事务中每一次执行快照读时生成ReadView
  • REPETABLE READ:仅在事务中第一次执行快照读时生成ReadView,后续复用该ReadView

判断能获取哪些个版本的数据?

RC级别下:

第一个ReadView能访问事务2

第二个ReadView能访问事务3

RR级别下:

第一个ReadView能访问事务2

第二个ReadView能访问事务2

主从同步原理

MySQL主从复制的核心是二进制日志。

二进制日志(Binlog)记录了所有DDL(数据定义语言,如create、alter、drop等)语句和DML(数据操纵语言,如insert、update、delete等)语句,但不包括数据查询(Select、show)语句。

复制分为三步:

  • 1.Master主库在事务提交时,会把数据变更记录在二进制日志文件Binlog中。
  • 2.从库读取主库的二进制日志文件Binlog,写入到从库中继日志Relay log中。
  • 3.slave重做中继日志中的事件,将改变反映它自己的数据。

分库分表

主从是分担了访问压力,分库分表是解决了存储压力。

分库分表的时机:

  • 前提,项目业务数据逐渐增多,或业务发展比较迅速。——单表的数据量达1000W或20G以后
  • 优化已解决布隆性能问题(主从读写分离、查询索引...)
  • IO瓶颈(磁盘IO、网络IO)、CPU瓶颈(聚合查询、连接数太多)

拆分策略

  • 垂直拆分
    • 垂直分库
    • 垂直分表
  • 水平拆分
    • 水平分库
    • 水平分表

垂直分库

以表为依据,根据业务将不同表拆分到不同库中。(如下图中的,用户、订单、商品)

特点:

  • 按业务对数据分级管理、维护、监控、扩展
  • 在高并发下,提高磁盘IO和数据量连接数

微服务中,每个微服务都对应着一个数据库

垂直分表

以字段为依据,根据字段属性将不同字段拆分到不同表中。

拆分规则:

  • 把不常用的字段单独放在一张表
  • 把text、blob等大字段拆分出来放在附表中(如下图中的description字段,文本内容很多)

特点:

  • 冷热数据分离(如下图)
  • 减少IO过渡争抢,两表互不影响(因为如果不分表,查询商品的时候会把详情查询出来,而详情内容较多,会争抢)

水平分库

将一个库的数据拆分到多个库中。

现在的java应用一般都是连接一个库,那如果需要数据,应该怎么找到对应的库呢?有相应的路由规则

路由规则方案:

  • 1.根据id取模
  • 2.按id也就是范围路由,节点1(1-100万)、节点2(100万-200万)
  • ...

水平分库的特点:

  • 解决了单库大数量,高并发的性能瓶颈问题
  • 提高了系统的稳定性和可用性

水平分表

将一个表的数据拆分到多个表中(可以在同一个库内)

特点:

  • 优化单一表数据量过大而产生的性能问题
  • 避免IO争抢并减少锁表的几率

分库之后的问题:

  • 分布式事务一致性问题
  • 跨节点关联查询
  • 跨节点分页、排序函数
  • 主键避重(水平分表中,每个表的主键都是自增,那么怎么避免主键重复)

在应用程序和数据库之间增加一个中间件可以很好的解决以上这些问题。

中间件:

  • sharding-sphere
  • mycat

框架篇

Spring

Spring框架中的单例bean是线程安全的吗?

可以通过Scope去设置,默认是单例

  • singleton:bean在每个Spring IOC容器中只有一个实例
  • prototype:一个bean的定义可以有多个实例

不是线程安全的。

成员方法需考虑线程安全,局部变量没有线程安全问题

无状态:判断当前成员变量能否被修改,不能修改得就是无状态。

AOP相关面试题

AOP称为面向切面编程,用于将那些与业务无关,但却对多个无底线产生影响的公共行为和逻辑,抽取并封装为一个可重用的模块,这个模块被命名为“切面(Aspect)”,减少系统中的重复代码,降低了模块间的耦合度,同时提高了系统的可维护性。

常见的AOP使用场景:

  • 记录操作日志
  • 缓存处理
  • Spring中内置的事务处理

Spring中的事务是如何实现的?

Spring支持编程式事务管理和声明式事务管理两种方式。

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

事务失效的场景

  • 异常捕获处理
  • 抛出检查异常
  • 非public方法

异常捕获处理

原因:事务通知只有捉到了目标抛出的异常,才能进行后续的回滚处理,如果目标自己处理掉异常,事务通知无法知晓。

解决:在catch块添加throw new RuntimeException(e)抛出

抛出检查异常

原因:Spring默认只会回滚非检查异常

解决:配置rollbackFor属性@Transactional(rollbackFor=Exception.class0)

非public方法

原因:Spring为方法创建代理、添加事务通知、前提条件是该方法是public的

解决:改为public方法

Spring的bean的生命周期

BeanDefinition

Spring容器在进行实例化时,会将xml配置的<bean>的信息封装成一个BeanDefinition对象,Spring根据BeanDefinition来创建Bean对象,里面有很多属性用来描述Bean。

  • beanClassName:bean的类名
  • initMethdodName: 初始化方法名称
  • propertyValues:bean的属性值
  • scope:作用域
  • lazyInit:延迟初始化

Spring中的循环引用

问题:

循环依赖:

Spring解决循环依赖是通过三级缓存,对应的三级缓存如下所示:

缓存名称 源码名称 作用
一级缓存 singletonObjects 单例池,缓存已经经历了完整的生命周期,已经初始化完成的bean对象
二级缓存 earlySingletonObjects 缓存早期的bean对象(生命周期还没走完)
三级缓存 singletonFactories 缓存的是ObjectFactory,表示对象工厂,用来创建某个对象的

一级缓存解决不了循环依赖(没有走完生命周期的bean对象)

一级缓存加二级缓存:

循环依赖,一开始创建A,创建A的时候发现依赖注入中需要B,所以去创建B,创建AB两个半成品对象,都会放到二级缓存中;B创建初始化成功会存储到一级缓存中,二级缓存中的原始对象B会被清除;然后A创建初始化成功,A也会放入一级缓存中,二级缓存中的原始对象A也会清除。

为什么要引入三级缓存?

关键问题:如果只有两级缓存(一级成品+二级半成品),能不能解决问题?

答案是:能,但前提是没有AOP代理! 如果Bean需要被代理(比如用了@Transactional或@Async),Spring会在初始化阶段生成代理对象。此时,如果只有两级缓存:

  • 在填充属性时,注入的是原始对象,而不是代理对象。

  • 最终放入一级缓存的代理对象和已注入的原始对象不一致,导致严重问题!

问题:好像没有二级缓存也行?

如果没有二级缓存,使用ObjectFactory创建出来的对象可能是多例的,处理起来麻烦。

有些循环引用问题Spring解决不了,需要手动解决

SpringMVC

SpringMVC的执行流程

SpringBoot

自动配置原理

  • SpringBootConfiguration:该注解与@Configuration注解作用相同,用来声明当前也是一个配置类
  • @ComponentScan:组件扫描,默认扫描当前引导类所在包及其子包
  • @EnableAutoConfiguration:SpringBoot实现自动化配置的核心注解

@Bean将当前方法的返回值放到容器中,所以我们可以自动注入RedisTemplate

常见注解

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 用于切面编程

SpringMVC常见的注解有哪些?

注解 说明
@RequestMapping 用于映射请求路径,可以定义在类上和方法上。用于类上,则表示类中所有的方法都是以该地址作为父路径。
@ReqeustBody 注解实现接收http请求的json数据,将json数据转换为java对象
@RequestParam 指定请求参数的名称
@PathViriable 从请求路径下获取请求参数(/user/{id}),传递给方法的形式参数
@ResponseBody 注解实现将controller方法返回对象转化为json对象响应给客户端
@RequestHeader 获取指定的请求头数据
@RestController @Controller + @ResponseBody

SpringBoot常见的注解有哪些?

注解 说明
@SpringBootConfiguration 组合了-@Configuration注解,实现配置文件的功能
@EnableAutoConfiguration 打开自动配置的功能,也可以关闭某个自动配置的选项

MyBatis

执行流程

配置文件(SpringBoot中这些配置在yml、yaml、properties文中中): 加载环境配置和加载一些映射文件

resultMaps存储的是最后Sql返回的结果

延迟加载使用及原理

MyBatis支持延迟加载,但默认没有开启。

延迟加载

延迟加载的原理

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

一级、二级缓存

一级缓存:基于PerpetualCache的HashMap本地缓存,其存储作用域为Session,当Session进行flush或close之后,该Session中的所有Cache就将清空,默认打开一级缓存。

如果是不同的SqlSession,就会执行两次了

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

注意事项:

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

微服务

SpringCloud

SpringCloud五大组件有哪些?

  • Eureka: 注册中心
  • Ribbon: 负载均衡
  • Feign: 远程调用
  • Hystrix: 服务熔断
  • Zuul/Gateway: 网关

注册中心

注册中心的核心作用是:服务注册和发现

常见的注册中心:eureka、nacos、zookeeper

服务提供者的每一个实例需要心跳续约

如果发现8083实例宕机了,那么注册中心和服务消费者拉取到的服务信息都会发生变化

nacos

负载均衡

Ribbon负载均衡测量

  • RounRobinRule:简单轮询服务列表来选择服务器
  • WeightedResponseTimeRule:按照权重来选择服务器,响应时间越长,权重越小
  • RandomRule:随机选择一个可用的服务器
  • BestAvailableRule:忽略那些短路的服务器,并选择并发数较低的服务器
  • RetryRule:重试机制的选择逻辑
  • AvailablityFilteringRule:可用性敏感策略,先过滤非健康的,再选择连接数较小的实例
  • ZoneAvoidanceRule:以区域可用的服务器为基础进行服务器的选择。使用Zone对服务器进行分类,这个Zone可以理解为一个机房、一个机架等。而后再对Zone内的多个服务做轮询。

掌握不了这么多的话,以上黑体的四个要掌握

自定义负载均衡策略

服务雪崩

服务雪崩:一个服务失败,导致整条链路的服务都失败的情形

解决方案:Hystix 服务熔断降级

预防:限流

服务降级

服务降级是服务自我保护的一种方式,或者保护下游服务的一种方式,用于确保服务不会受请求突增影响变得不可用,确保服务不会崩溃。

服务熔断

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

微服务的监控

问题定位:当服务链路中某个微服务宕机后能够迅速定位到。

性能分析:当服务链路中某个微服务响应时间长后能够迅速定位到。

服务关系:微服务之间的调用关系。

服务告警

  • SpringBoot-admin
  • prometheus + Grafana
  • zipkin
  • skywalking(用的较多)

skywalking

一个分布式系统的应用程序性能监控工具(Application Performance Managment),提供了完整的链路追踪能力,apache的顶级项目。

  • 服务:业务资源应用系统(微服务)
  • 端点:应用系统对外暴露的功能接口(接口)
  • 实例:物理机

问题定位:

性能分析:

服务关系:

服务告警:

业务问题

微服务限流

你们项目中有没有做过限流?怎么做的?

为什么要限流?

  • 并发的确大(突发流量)
  • 防止用户恶意刷接口

限流的实现方式:

  • Tomcat:可以设置最大连接数(只能在Tomcat内部生效,分布式不太行)
  • Nginx,漏桶算法
  • 网关,令牌桶算法
  • 自定义拦截器

Nginx限流

控制速率

控制并发连接数

网关限流

令牌桶和漏桶不同:

  • 令牌桶可能以超出令牌生成速率的速率处理请求;(原因:令牌桶桶满了,这时候没有请求,下次来的请求可以使用桶中满的令牌,用了,令牌桶还能令牌生成。比如令牌桶能存储3个,生成是3个每秒,最高能处理6个请求1s)

  • 漏桶以固定速率漏出请求。

QPS:每秒查询数

分布式系统理论—CAP和BASE

CAP定理

  • Consistency(一致性):用户访问分布式系统中的任意节点,得到的数据必须一致。
  • Availability(可用性):用户访问集群中的任意健康节点,必须能得到响应,而不是超时或拒绝。
  • Partition tolerance(分区容错性):
    • Partition(分区):因为网络故障或其他原因导致分布式系统中的部分节点与其他节点失去连接,形成独立分区。
    • Tolerance(容错):在集群出现分区时,整个系统也要持续对外提供服务。
  • 结论
    • 分布式系统节点之间肯定是需要网络连接的,分区(P)是必然存在的
    • 如果保证访问的高可用性(A),可以持续对外提供服务,但不能保证数据的强一致性-->AP
    • 如果保证访问数据的强一致性(C),就要放弃高可用性-->CP

分布式系统无法同时满足这三个指标。

BASE理论

BASE理论是对CAP的一种解决思路,包含三个思想:

  • Basically Available(基本可用):分布式系统在出现故障时,允许损失部分可用性,即保证核心可用。
  • Soft State(软状态):在一定时间内,允许出现中间状态,比如临时的不一致状态。
  • Eventually Consistent(最终一致性):虽然无法保证强一致性,但是在软状态结束后,最终达到数据一致。

分布式事务解决方案

Seata框架(XA、AT、TCC)

Seata事务管理中有三个重要的角色:

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

类似CAP中的CP模式:

Seata的XA模式

AT模式(平时开发推荐的),类似与AP模式

TCC模式,AP模式

资源预留、Confirm、Cancel这三步都要用代码实现,前面两种模式基本都是框架实现,代码耦合度低。

MQ分布式事务

异步操作,实时性没那么好

分布式服务的接口幂等性如何设计?

幂等:多次调用方法或者接口不会改变业务状态,可以保证重复调用的结果和单次调用的结果一致。

需要幂等场景:

  • 用户重复点击(网络波动)
  • MQ消息重复
  • 应用使用失败或超时重试机制

token+redis

分布式锁

你的项目中使用了什么分布式任务调度?

xxl-job

首先,还是要描述当时是什么场景用了任务调度。

xxl-job解决的问题:

  • 解决集群任务的重复执行问题
  • cron表达式定义灵活
  • 定时任务失败了,重试和统计
  • 任务量大,分片执行

xxl-job路由策略有哪些?

有很多任务都需要执行,两个实例都能执行,每个任务都只需要找一台机器执行即可,找机器的方式就是路由策略。

轮询:任务1找实例1,任务2找实例2,任务3找实例1,任务4找实例2...

xxl-job任务执行失败怎么解决?

如果有大数据量的任务同时都需要执行,怎么解决?

消息中间件

RabbitMQ

如何保证消息不丢失?

RabbitMQ使用场景:

  • 异步发送(验证码、短信、邮件)
  • MySQL和Redis、ES之间的数据同步
  • 分布式事务
  • 削峰填谷

生产者确认机制

消息持久化

消费者确认

RabbitMQ消息的重复消费问题如何解决的?

RabbitMQ中死信交换机?(RabbitMQ延迟队列有了解过吗)

延迟队列:进入队列的消息会被延迟消费的队列

场景:超时订单、限时优惠、定时发布

延迟队列=死信交换机+TTL

死信交换机

当一个队列中的消息满足下列情况之一时,可以成为死信(dead letter):

  • 消费者使用basic.reject或basic.nack声明消费失败,并且消息的requeue参数设置为false
  • 消息是一个过期消息,超时无人消费
  • 要投递的队列消息堆积满了,最早的消息可能成为死信

如果该队列配置了dead-letter-exchange属性,指定了一个交换机,那么队列中的死信就会投递到这个交换机中,而这个交换机成为死信交换机(Dead Letter Exchange, 简称DLX)

TTL

TTL,也就是Time-To-Live。如果一个队列中的消息TTL结束仍未消费,就会变成死信,ttl超时分两种情况:

  • 消息所在的队列设置了存活时间
  • 消息本身设置了存活时间

消息本身的存活时间和队列设置的存活时间,哪个短以哪个为准。

延迟队列也可以通过延迟队列插件来实现:

RabbitMQ如果有100万消息堆积在MQ,如何解决?(消息堆积如何解决)

当生产者发送消息的速度超过了消费者处理消息的速度,就会导致队列中的消息堆积,直到队列存储消息达到上限。之后发送的消息就会成为死信,可能会被丢弃,这就是消息堆积问题。

解决消息堆积有三种思路:

  • 增加更多消费者,提高消费速度
  • 在消费者内开启线程池加快消息处理速度
  • 扩大队列容积,提高堆积上限(需要用到惰性队列)

惰性队列

惰性队列的特征如下:

  • 接收到消息后直接存入磁盘而非内存
  • 消费者要消费消息时才会从磁盘读取并加载到内存
  • 支持数百万条的消息存储

两种配置方式:

RabbitMQ高可用机制有了解过吗?

在生产环境下,使用集群来保证高可用性

  • 普通集群、镜像集群、仲裁队列

普通集群

普通集群,或者叫标准集群,具备下列特征:

  • 会在集群的各个节点间共享部分数据,包括:交换机、队列元信息。不包含队列中的消息。
  • 当访问集群某节点时,如果队列不在该节点,会从数据所在节点传递到当前节点并返回
  • 队列所在节点宕机,队列中的消息就会丢失

每个节点包含其他节点的队列引用信息,也称队列元信息

镜像模式集群

镜像集群:本质是主从模式,具备下面的特征:

  • 交换机、队列、队列中的消息会在各个MQ的镜像节点之间同步备份
  • 创建队列的节点被称为该队列的主节点,备份到的其他节点叫做该队列的镜像节点
    • 比如节点三创建了队列三,那么节点三就是主节点,存储了队列三的节点一是镜像节点。
  • 一个队列的主节点可能是另一个队列的镜像节点
  • 所有操作都是主节点完成,然后同步给镜像节点
  • 主节点宕机后,镜像节点会替代成为新的主节点

仲裁队列

仲裁队列:仲裁队列是3.8版本以后才有的新功能,用来替代镜像队列,具备下列特征:

  • 与镜像队列一样,都是主从模式,支持主从数据同步
  • 使用非常简单,没有复杂的配置
  • 主从同步基于Raft协议,强一致

配置:

Kafka

Kafka是如何保证消息不丢失?

使用Kafa在消息的收发过程都会出现消息丢失,Kafka分别给出了解决方案

  • 生产者发送消息到Broker丢失
  • 消息在Broker中存储丢失
  • 消费者从Broker接收消息丢失
    • 重平衡:某个消费者宕机,该消费者需要消费的内容交由其他消费者消费

Kafka是如何保证消费的顺序性?

应用场景:

  • 即时消息中的单对单聊天和群聊,保证发送方消息发送顺序与接收方顺序一致
  • 充值转账两个渠道在同一个时间进行余额变更,短信通知必须要有顺序

设置分区号相同或者key相同(根据key的哈希来找分区)

Kafka高可用机制了解过吗?

  • 集群模式
  • 分区备份机制

一个分区存在多个副本,并且副本保存在不同的Broker中

Kafka数据清理机制了解过吗?

Kafka文件存储机制

数据清理机制

Kafka实现高性能的设计有了解过吗?

  • 消息分区:不受单台服务器的限制,可以不受限的处理更多的数据
  • 顺序读写:磁盘顺序读写,提升读写效率
  • 页缓存:把磁盘中的数据缓存到内存中,把对磁盘的访问变为对内存的访问
  • 零拷贝:减少上下文切换及数据拷贝
  • 消息压缩:减少磁盘IO和网络IO
  • 分批发送:将消息打包批量发送,减少网络开销

零拷贝

一般正常的流程需要拷贝四次:磁盘到页缓存,页缓存到用户空间,用户空间到Socket缓冲区,Socket缓冲区到网卡

零拷贝通过DMA(Direct Memory Access)技术把文件内容复制到内核空间中的Read Buffer, 接着把包含数据位置和长度信息的文件描述符加载到Socket Buffer中,DMA引擎直接可以把 数据从内核空间中传递给网卡设备。在这个流程中,数据只经历了两次拷贝就发送到了网卡中,并且减少了2次cpu的上下文切换

常见集合篇

ArrayList

底层是数组

链表:物理存储单元上,非连续、非顺序的存储结构

数组:在物理存储单元上,是连续、顺序的存储结构

ArrayList源码分析

成员变量

  • 如果是DEFAULTCAPACITY_EMPTY_ELEMENTDATA(无参构造的空集合): 首次添加元素时,会直接将容量扩容至默认值 10(符合用户对 "无参构造默认容量 10" 的预期)。
  • 如果是EMPTY_ELEMENTDATA(指定容量 0 的空集合): 首次添加元素时,容量会根据添加的元素数量动态计算(例如添加 1 个元素则容量为 1,添加 5 个则为 5),不会默认扩容到 10(尊重用户明确指定的 0 容量意图)。

如果只用一个空数组,无法区分 " 用户明确 总结 如果只用一个空数组,无法区分 "用户明确指定 0 容量" 和 "默认无参构造" 两种场景,会导致首次扩容逻辑无法精准匹配用户意图:

  • 若统一扩为 10,会浪费new ArrayList(0)场景的内存(用户明确要 0 容量,却强制给 10);
  • 若统一按实际需求扩容,会违背new ArrayList()默认容量 10 的设计约定。

构造方法

copyOf,复制数组

添加和扩容操作

1
2
3
4
5
6
List<Integer> list = new ArrayList<Integer>();
List.add(1);
for(int i = 2; i <= 10; i++) {
list.add(i);
}
list.add(11);

第一次添加数据

1
2
List<Integer> list = new ArrayList<Integer>();
List.add(1);

第一次添加,size为0,添加完之后size为1

第二次至第十次添加数据

1
2
3
for(int i = 2; i <= 10; i++) {
list.add(i);
}

第二次添加,size为1,添加完之后,size为2

第十次添加,size为9,添加完之后,size为10

不会进行扩容

第十一次添加数据

1
list.add(11);

第十一次添加,size为10,添加完之后,size为11

扩容到了15

ArrayList底层的实现原理是什么?

  • ArrayList底层是用动态数组实现的
  • ArrayList初始容量为0,当第一次添加数据的时候才会初始化容量为10。(没有使用带初始化容量的构造方式的话)
  • ArrayList在进行扩容的时候是原来容量的1.5倍,每次扩容都需要拷贝数组。
  • ArrayList在添加数据的时候
    • 确保数组已使用长度(size)加1之后足够存下下一个数据
    • 计算数组的容量,如果当前数组已使用长度+1后大于当前数组长度,则调用grow方法扩容(1.5倍)
    • 确保新增的数据有地方存储之后,则将新元素添加到位于size的位置上
    • 返回添加成功布尔值

附加面试题:

ArrayList list = new ArraryList(10)中的list扩容几次?

如何实现数组和List之间的转换?

数组转List,用Arrays.asList()

List转数组,用toArray()

asList()底层:

requireNonNull():对象引用为空就返回异常,不为空返回对象引用

所以可以看出,asList是单纯的将数组引用复制给了List,修改数组,List也会影响

toArray()底层:

1
2
3
4
5
6
7
8
9
10
11
12
public <T> T[] toArray(T[] a) {
if (a.length < size)
// Make a new array of a's runtime type, but my contents:
// 如果a的长度不够,则新建一个数组,并且将List中的elementData数组的内容全部复制过去并返回新建数组
return (T[]) Arrays.copyOf(elementData, size, a.getClass());
// 如果a的长度大于等于的话,拷贝相应位置的元素
System.arraycopy(elementData, 0, a, 0, size);
if (a.length > size)
// 将size位置的元素设置为null,后面的不管,为了性能考虑,也能符合API契约
a[size] = null;
return a;
}

可以发现,拷贝到了一个新的数组或a,不是引用,所以原来List的变化不会影响数组变化。

ArrayList和LinkedList的区别是什么?

分为以下几个方面回答:

  • 底层数据结构
  • 操作数据效率
  • 占用空间
  • 线程是否安全

HashMap

HashMap的实现原理

HashMap的put方法的具体流程

默认初始化长度为16

next,当发生冲突时,拉链法用到

懒加载,默认加载因子为0.75

上面的流程图中看起来红黑树相比于链表不用判断key是否存在,但实际上是由于源码中红黑树的逻辑直接调用了putTreeVal方法,判断是否存在方法里面实现;而链表的判断key是否存在的逻辑是裸露的

put源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
public V put(K key, V value) {
// hash(key),key的哈希值,用来确定数组中的位置
return putVal(hash(key), key, value, false, true);
}

final V putVal(int hash, K key, V value, boolean onlyIfAbsent,
boolean evict) {
Node<K,V>[] tab; Node<K,V> p; int n, i;
//判断数组是否未初始化
if ((tab = table) == null || (n = tab.length) == 0)
//如果未初始化,调用resize方法 进行初始化
n = (tab = resize()).length;
//通过 & 运算求出该数据(key)的数组下标并判断该下标位置是否有数据
if ((p = tab[i = (n - 1) & hash]) == null)
//如果没有,直接将数据放在该下标位置
tab[i] = newNode(hash, key, value, null);
//该数组下标有数据的情况
else {
Node<K,V> e; K k;
//判断该位置数据的key和新来的数据是否一样
if (p.hash == hash &&
((k = p.key) == key || (key != null && key.equals(k))))
//如果一样,证明为修改操作,该节点的数据赋值给e,后边会用到
e = p;
//判断是不是红黑树
else if (p instanceof TreeNode)
//如果是红黑树的话,进行红黑树的操作
e = ((TreeNode<K,V>)p).putTreeVal(this, tab, hash, key, value);
//新数据和当前数组既不相同,也不是红黑树节点,证明是链表
else {
//遍历链表
for (int binCount = 0; ; ++binCount) {
//判断next节点,如果为空的话,证明遍历到链表尾部了
if ((e = p.next) == null) {
//把新值放入链表尾部
p.next = newNode(hash, key, value, null);
//因为新插入了一条数据,所以判断链表长度是不是大于等于8
if (binCount >= TREEIFY_THRESHOLD - 1) // -1 for 1st
//如果是,进行转换红黑树操作
treeifyBin(tab, hash);
break;
}
//判断链表当中有数据相同的值,如果一样,证明为修改操作
if (e.hash == hash &&
((k = e.key) == key || (key != null && key.equals(k))))
break;
//把下一个节点赋值为当前节点
p = e;
}
}
//判断e是否为空(e值为修改操作存放原数据的变量)
if (e != null) { // existing mapping for key
//不为空的话证明是修改操作,取出老值
V oldValue = e.value;
//一定会执行 onlyIfAbsent传进来的是false
if (!onlyIfAbsent || oldValue == null)
//将新值赋值当前节点
e.value = value;
afterNodeAccess(e);
//返回老值
return oldValue;
}
}
//计数器,计算当前节点的修改次数
++modCount;
//当前数组中的数据数量如果大于扩容阈值
if (++size > threshold)
//进行扩容操作
resize();
//空方法
afterNodeInsertion(evict);
//添加操作时 返回空值
return null;
}

HashMap的扩容机制

扩容源码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
//扩容、初始化数组
final Node<K,V>[] resize() {
Node<K,V>[] oldTab = table;
//如果当前数组为null的时候,把oldCap老数组容量设置为0
int oldCap = (oldTab == null) ? 0 : oldTab.length;
//老的扩容阈值
int oldThr = threshold;
int newCap, newThr = 0;
//判断数组容量是否大于0,大于0说明数组已经初始化
if (oldCap > 0) {
//判断当前数组长度是否大于最大数组长度
if (oldCap >= MAXIMUM_CAPACITY) {
//如果是,将扩容阈值直接设置为int类型的最大数值并直接返回
threshold = Integer.MAX_VALUE;
return oldTab;
}
//如果在最大长度范围内,则需要扩容 OldCap << 1等价于oldCap*2
//运算过后判断是不是最大值并且oldCap需要大于16
else if ((newCap = oldCap << 1) < MAXIMUM_CAPACITY &&
oldCap >= DEFAULT_INITIAL_CAPACITY)
newThr = oldThr << 1; // double threshold 等价于oldThr*2
}
//如果oldCap<0,但是已经初始化了,像把元素删除完之后的情况,那么它的临界值肯定还存在, 如果是首次初始化,它的临界值则为0
else if (oldThr > 0) // initial capacity was placed in threshold
newCap = oldThr;
//数组未初始化的情况,将阈值和扩容因子都设置为默认值
else { // zero initial threshold signifies using defaults
newCap = DEFAULT_INITIAL_CAPACITY;
newThr = (int)(DEFAULT_LOAD_FACTOR * DEFAULT_INITIAL_CAPACITY);
}
//初始化容量小于16的时候,扩容阈值是没有赋值的
if (newThr == 0) {
//创建阈值
float ft = (float)newCap * loadFactor;
//判断新容量和新阈值是否大于最大容量
newThr = (newCap < MAXIMUM_CAPACITY && ft < (float)MAXIMUM_CAPACITY ?
(int)ft : Integer.MAX_VALUE);
}
//计算出来的阈值赋值
threshold = newThr;
@SuppressWarnings({"rawtypes","unchecked"})
//根据上边计算得出的容量 创建新的数组
Node<K,V>[] newTab = (Node<K,V>[])new Node[newCap];
//赋值
table = newTab;
//扩容操作,判断不为空证明不是初始化数组
if (oldTab != null) {
//遍历数组
for (int j = 0; j < oldCap; ++j) {
Node<K,V> e;
//判断当前下标为j的数组如果不为空的话赋值个e,进行下一步操作
if ((e = oldTab[j]) != null) {
//将数组位置置空
oldTab[j] = null;
//判断是否有下个节点
if (e.next == null)
//如果没有,就重新计算在新数组中的下标并放进去
newTab[e.hash & (newCap - 1)] = e;
//有下个节点的情况,并且判断是否已经树化
else if (e instanceof TreeNode)
//进行红黑树的操作
((TreeNode<K,V>)e).split(this, newTab, j, oldCap);
//有下个节点的情况,并且没有树化(链表形式)
else {
//比如老数组容量是16,那下标就为0-15
//扩容操作*2,容量就变为32,下标为0-31
//低位:0-15,高位16-31
//定义了四个变量
// 低位头 低位尾
Node<K,V> loHead = null, loTail = null;
// 高位头 高位尾
Node<K,V> hiHead = null, hiTail = null;
//下个节点
Node<K,V> next;
//循环遍历
do {
//取出next节点
next = e.next;
//通过 与操作 计算得出结果为0
if ((e.hash & oldCap) == 0) {
//如果低位尾为null,证明当前数组位置为空,没有任何数据
if (loTail == null)
//将e值放入低位头
loHead = e;
//低位尾不为null,证明已经有数据了
else
//将数据放入next节点
loTail.next = e;
//记录低位尾数据
loTail = e;
}
//通过 与操作 计算得出结果不为0
else {
//如果高位尾为null,证明当前数组位置为空,没有任何数据
if (hiTail == null)
//将e值放入高位头
hiHead = e;
//高位尾不为null,证明已经有数据了
else
//将数据放入next节点
hiTail.next = e;
//记录高位尾数据
hiTail = e;
}

}
//如果e不为空,证明没有到链表尾部,继续执行循环
while ((e = next) != null);
//低位尾如果记录的有数据,是链表
if (loTail != null) {
//将下一个元素置空
loTail.next = null;
//将低位头放入新数组的原下标位置
newTab[j] = loHead;
}
//高位尾如果记录的有数据,是链表
if (hiTail != null) {
//将下一个元素置空
hiTail.next = null;
//将高位头放入新数组的(原下标+原数组容量)位置
newTab[j + oldCap] = hiHead;
}
}
}
}
}
//返回新的数组对象
return newTab;
}

HashMap的寻址算法

只有数组长度为2的n次幂的时候,按位与运算才能代替取模

为何HashMap的数组长度一定是2的n次幂?

  • 计算索引时效率更高:如果是2的n次幂可以使用位与运算代替取模
  • 扩容时重新计算索引效率更高:hash & oldCap == 0的元素留在原来位置,否则新位置=旧位置+oldCap

HashMap在1.7情况下的多线程死循环问题

并发编程篇

线程基础

进程和线程的区别?

程序由指令和数据组成,但这些指令要运行,数据要读写,就必须将指令加载至CPU, 数据加载至内存。在 指令运行过程中还需要用到磁盘、网络等设备。进程就是用来加载指令、管理内存、管理IO的。

当一个程序被运行,从磁盘加载这个程序的代码至内存,这时就开启了一个进程。

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

二者对比:

  • 进程是正在运行程序的实例,进程中包含了线程,每个线程执行不同的任务
  • 不同的进程使用不同的内存空间,在当前进程下的所有线程可以共享内存空间
  • 线程更轻量,线程上下文切换成本一般上要比进程上下文切换低(上下文切换指的是从一个线程切换到另一个线程

并行和并发有什么区别?

单核CPU下:

  • 单核CPU下线程实际还是串行执行的
  • 操作系统中有一个组件叫做任务调度器,将cpu的时间片(windows下时间片最小约为15毫秒)分给不同的程序使用,只是由于cpu在线程间(时间片很短)的切换非常快,人类感觉是同时运行的。
  • 总结为一句话就是:微观串行,宏观并行
  • 一般会将这种线程轮流使用CPU的做法称为并发

多核CPU:

每个核都可以调度运行线程。这时候线程可以是并行的。

并发和并行有什么区别?

  • 并发是同一时间应对多件事情的能力
  • 并行是同一时间动手做多件事情的能力

线程创建的方式有哪些?

共有四种方式可以创建线程,分别是:

  • 继承Thread类
  • 实现Runnable接口
  • 实现Callable接口
  • 线程池创建线程

继承Thread类:

实现Runnable接口:

实现Callable接口:

线程池创建线程:

追问:使用runnable和callable都可以创建线程,它们有什么区别?

  • Runnable接口run方法没有返回值;Callablef接口call方法有返回值,是个泛型,和Future、FutureTask配合可以用来获取异步执行的结果
  • Callable接口的call()方法允许抛出异常;而Runnablef接口的run()方法的异常只能在内部消化,不能继续上抛

追问:在启动线程的时候,可以使用run方法吗?run()和start()有什么区别?

可以,启动线程前可以调用run方法,相当于一个普通方法。

区别:

  • start():用来启动线程,通过该线程调用run方法执行run方法中所定义的逻辑代码。start方法只能被调用一次。
  • run():封装了要被线程执行的代码,可以被调用多次。

线程包括哪些状态,状态之间是如何变化的?

新建T1、T2、T3三个线程,如何保证它们按顺序执行?

可以使用线程中的join方法解决。

join() 等待线程运行结束

t.join()阻塞调用此方法的线程进入timed_waiting,直到线程t执行完成后,此线程再继续执行。

notify()和notifyAll()有什么区别?

  • notifyAll():唤醒所有wait的线程
  • notify():只随机唤醒一个wait线程

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,你们也用不了)

如何停止一个正在运行的线程?

有三种方式可以停止线程:

  • 使用退出标志,使线程正常退出,也就是当ru方法完成后线程终止
  • 使用stop方法强行终止(不推荐,方法已作废)
  • 使用interrupt方法中断线程
    • 打断阻塞的线程(sleep, wait, join)的线程,线程会抛出InterruptedException异常
    • 打断正常的线程,可以根据打断状态来标记是否退出线程

线程安全

synchronized关键字的底层原理

Synchronized【对象锁】采用互斥的方式让同一时刻至多只有一个线程能持有【对象锁】,其他线程再想获取这个【对象锁】时就会阻塞住。

synchronized底层原理进阶

Monitor实现的锁属于重量级锁,你了解过锁升级吗?

  • Monitor实现的锁属于重量级锁,里面涉及到了用户态和内核态的切换、进程的上下文切换,成本较高,性能比较低
  • 在JDK1.6引入了两种新型锁机制:偏向锁和轻量级锁,它们的引入是为了解决在没有多线程竞争或基本没有竞争的场景下因使用传统锁机制带来的性能开销问题。

对象的内存结构

HotSpot是jvm虚拟机的一种实现

轻量级锁:

可重入:

偏向锁:

轻量级锁在没有竞争时(就自己这个线程),每次重入仍然需要执行CAS操作。 Java6中引入了偏向锁来做进一步优化:只有第一次使用CAS将线程ID设置到对象的Mark Word头,之后发现这个线程D是自己的就表示没有竞争,不用重新CAS。以后只要不发生竞争,这个对象就归该线程所有。

重入的时候:

你谈谈JMM(Java内存模型)

CAS你知道吗?

CAS(Compare And Swap),它体现的一种乐观锁的思想,在无锁情况下保证线程操作共享数据的原子性。

在JUC(java.util.concurrent)包下实现的很多类都用到了CAS操作

  • AbstractQueuedSynchronizer(AQS框架)
  • AtomicXXX类

CAS底层实现

以JDK8中java.util.concurrent.atomic包下的AtomicInteger类的getAndAdd(int delta)方法为例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public final int getAndAdd(int delta) {
return unsafe.getAndAddInt(this, valueOffset, delta);
}
// 调用unsafe对象的getAndAddInt方法
// setup to use Unsafe.compareAndSwapInt for updates
private static final Unsafe unsafe = Unsafe.getUnsafe();

/**
* var1:表示你想进行操作的对象,var2:你想操作对象中的某个字段的偏移量,var4:你想增加的值
*/
public final int getAndAddInt(Object var1, long var2, int var4) {
int var5;
do {
var5 = this.getIntVolatile(var1, var2);
} while(!this.compareAndSwapInt(var1, var2, var5, var5 + var4));
// 如果对象 var1 在内存地址 var2 处的值等于预期值 var5,则将该位置的值更新为 var5 + var4

return var5;
}

/*
为什么要把获取“旧值”v 的操作放到循环体内呢?
这也好理解。前面我们说了,CAS 如果旧值 V 不等于预期值 E,就会更新失败。说明旧的值发生了变化。那我们当然需要返回的是被其他线程改变之后的旧值了,因此放在了 do 循环体内。
*/

CAS底层依赖于一个Unsafe类来直接调用操作系统底层的CAS指令(原子的)

乐观锁和悲观锁

  • CAS是基于乐观锁的思想
  • Synchronized是基于悲观锁的思想

悲观锁:悲观锁总是假设最坏的情况,认为共享资源每次被访问的时候就会出现问题(比如共享数据被修改),所以每次在获取资源操作的时候都会上锁,这样其他线程想拿到这个资源就会阻塞直到锁被上一个持有者释放。也就是说,共享资源每次只给一个线程使用,其它线程阻塞,用完后再把资源转让给其它线程

乐观锁:乐观锁总是假设最好的情况,认为共享资源每次被访问的时候不会出现问题,线程可以不停地执行,无需加锁也无需等待,只是在提交修改的时候去验证对应的资源(也就是数据)是否被其它线程修改了(具体方法可以使用版本号机制或 CAS 算法)。

请你谈谈对volatile的理解

一旦一个共享变量(类的成员变量、类的静态成员变量)被volatile修饰之后,那么就具备了两层语义:

  • 保证线程间的可见性
  • 禁止进行指令重排序

保证线程间的可见性

用volatile修饰共享变量,能够防止编译器等优化发生,让一个线程对共享变量的修改对另一个线程可见。

禁止指令重排序

什么是AQS

AQS(AbstractQueuedSynchronizer),即抽象队列同步器。它是构建锁或者其他同步组件的基础框架

synchronized AQS
关键字,c++语言实现 java语言实现
悲观锁,自动释放锁 悲观锁,手动开启和关闭
锁竞争激烈都是重量级锁,性能差 锁竞争激烈的情况下,提供了多种解决方案

AQS常见的实现类:

  • ReentrantLock——阻塞式锁
  • Semaphore——信号量
  • CountDownLatch——倒计时锁

线程0先持有了锁(会将state从0修改为1,用volatile修饰,保证了可见性),后续线程1和线程2获取锁会获取失败;这时候会将线程1和线程2放入队列(FIFO,双向链表实现的 ),当线程0释放锁,会从队列中唤醒head指向的线程持有锁。

AQS-多个线程共同去抢这个资源是如何保证原子性的呢

线程0和线程4共同去抢锁,是使用cas保证操作的原子性,线程4未抢到锁,所以加入队列

AQS是公平锁吗,还是非公平锁?

既可以实现公平锁,也可以实现非公平锁

ReentrantLock的实现原理

ReentrantLock翻译过来就是可重入锁,相对于Synchronized它具备以下特点:

  • 可中断
    • ReentrantLock提供了lockInterruptibly()方法,允许线程在等待锁的过程中响应中断。
      • 当线程 A 通过lockInterruptibly()获取锁时,如果锁已被其他线程持有,线程 A 会进入等待状态。此时若其他线程调用线程 A 的interrupt()方法,线程 A 会立即抛出InterruptedException,并停止等待锁,从而可以在异常处理中做后续操作(如释放资源、结束任务)。
    • synchronized是 JVM 层面实现的锁,线程在等待synchronized锁时无法响应中断。
      • 当线程 B 尝试获取synchronized锁(但锁被其他线程持有)时,线程 B 会进入阻塞状态。此时即使其他线程调用线程 B 的interrupt()方法,线程 B 也不会停止等待锁,只会记录中断状态。直到线程 B 最终获得锁后,才能通过Thread.interrupted()检测到中断状态。
  • 可以设置超时时间
    • Synchronized没有获取锁的时候,只能进入阻塞状态等待
    • ReentrantLock可以设置超时时间,没有获取锁,过了超时时间可以放弃获取锁
  • 可以设置公平锁
    • Synchronized只支持非公平锁
    • ReentrantLock支持公平锁,也支持非公平锁
  • 支持多个条件变量
  • 与Synchronized一样,都支持可重入

实现原理:

ReentrantLock主要利用CAS+AQS队列来实现。它支持公平锁和非公平锁,两者的实现类似构造方法接受一个可选的公平参数(默认非公平锁),当设置为tue时,表示公平锁,否则为非公平锁。公平锁的效率往往没有非公平锁的效率高,在许多线程访问的情况下,公平锁表现出较低的吞吐量。 查看ReentrantLock源码中的构造方法:

右侧的Sync是左侧的NonfairSync和FairSync的父类

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

Synchronized和Lock有什么区别?

  • 语法层面

synchronized是关键字,源码在jvm中,用c++语言实现; Lock是接口,源码由jdk提供,用java语言实现; 使用synchronized时,退出同步代码块锁会自动释放,而使用Lock时,需要手动调用unlock方法释放锁

  • 功能层面

二者均属于悲观锁、都具备基本的互斥、同步、锁重入功能 Lock提供了许多synchronized不具备的功能,例如公平锁、可打断、可超时、多条件变量 Lock有适合不同场景的实现,如ReentrantLock,ReentrantReadWriteLock(读写锁)

  • 性能层面

在没有竞争时,Synchronized做了很多优化,如偏向锁、轻量级锁,性能不赖

在竞争激烈时,Lock的实现通常会提供更好的性能

死锁产生的条件是什么?

死锁:一个线程需要同时获取多把锁,这是就容易发生死锁。

如何进行死锁诊断?

当程序出现了死锁现象,我们可以使用jdk自带的工具:jps和jstack

  • jps:输出JVM进行的进程状态信息
  • jstack:查看java进程内线程的堆栈信息

其他工具——可视化工具

  • jconsole

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

  • VisualVM:故障处理工具

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

聊一下ConcurrentHashMap

ConcurrentHashMap是一种线程安全的高效Map集合 底层数据结构:

  • JDK1.7底层采用分段的数组+链表实现
  • JDK1.8采用的数据结构跟HashMap1.8的结构一样,数组+链表/红黑二叉树。

JDK1.7

通过hash(key) 确定在Segment数组的位置,然后把该位置用ReentrantLock锁住,

JDK1.8

在JDK1.8中,放弃了Segment臃肿的设计,数据结构跟HashMap的数据结构是一样的:数组+红黑树+链表,采用CAS+Synchronized来保证并发安全进行实现

  • CAS控制数组节点的添加
  • synchronized只锁定当前链表或红黑二叉树的首节点,只要hash不冲突,就不会产生并发的问题,效率得到提升

导致并发程序出现问题的根本原因是什么(Java程序中怎么保证多线程的执行安全)

Java编程的三大特性:

  • 原子性:一个线程在CPU操作不可暂停,也不可中断,要不执行完成,要不不执行
    • 解决:
  • 可见性:让一个线程对共享变量的修改对另一个线程可见
    • 一般都是加volatile,使用Synchronized和Lock也能实现,但是性能低
  • 有序性
    • 指令重排:处理器为了提高程序运行效率,可能会对输入代码进行优化,它不保证程序中各个语句的执行先后顺序同 代码中的顺序一致,但是它会保证程序最终执行结果和代码顺序执行的结果是一致的

线程池

说一下线程池的核心参数(线程池的执行原理知道吗)

1
2
3
4
5
6
7
8
9
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也放满时,会触发拒绝策略

如果没有活跃的任务,救急线程超过了生存时间就会释放,核心线程一直都会存在。

线程池的执行原理知道吗

如果核心或临时线程(救急线程)执行完成任务后会检查阻塞队列中是否有需要执行的线程

线程池中有哪些常见的阻塞队列

workQueue-当没有空闲核心线程时,新来任务会加入到此队列排队,队列满会创建救急线程执行任务

  • ArrayBlockingQueue:基于数组结构的有界阻塞队列,FIFO。
  • Linked BlockingQueue:基于链表结构的有界阻塞队列,FIFO。
  • DelayedWorkQueue:是一个优先级队列,它可以保证每次出队的任务都是当前队列中执行时间最靠前的
  • SynchronousQueue:不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。

重点掌握前两个

ArrayBlockingQueue和LinkedBlockingQueue的区别

ArrayBlockingQueue LinkedBlockingQueue
强制有界 默认无界,支持有界
底层是数组 底层是链表
提前初始化Node数组 是懒惰的,创建节点的时候添加数据
Node需要是提前创建好的 入队会生成新Node
一把锁 两把锁(头尾)

建议创建LinkedBlockingQueue的时候指定大小,不然会默认创建为Integer.MAX_VALUE大小(无界)

LinkedBlockingQueue性能更好,两把锁,而ArrayBlockingQueue一把锁,入队和出队都是一把锁,所以相对来说效率低

如何确定核心线程数

IO密集型任务:一般来说,文件读写、DB读写、网络请求等——核心线程数大小设置为2N+1(N为CPU核数)

CPU密集型任务:一般来说,计算型代码、Bitmap转换、Gson转换等——核心线程大小设置为N+1

1
2
3
4
public static void main(String[] args) {
// 查看机器的CPU核数
System.out.println(Runtime.getRuntime().availableProcessors());
}

线程池的种类有哪些

在java.util.concurrent.Executors类中提供了大量创建连接池的静态方法,常见就有四种 1.创建使用固定线程数的线程池

1
2
3
4
5
public static ExecutorService newFixedThreadPool(int nThreads){
return new ThreadPoolExecutor(nThreads,nThreads,
0L,TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>());
}
  • 核心线程数与最大线程数一样,没有救急线程
  • 阻塞队列是LinkedBlockingQueue, 最大容量为Integer.MAX_VALUE

适用于任务量已知,相对耗时的任务

2.单线程化的线程池,它只会用唯一的工作线程来执行任务,保证所有任务按照指定顺序(FIFO)执行

1
2
3
4
5
6
public static ExecutorService newSingleThreadExecutor(){
return new FinalizableDelegatedExecutorService
(new ThreadPoolExecutor(1,1,
0L,TimeUnit.MILLISECONDS,
new LinkedBlockingQueue<Runnable>()));
}
  • 核心线程数和最大线程数都是1
  • 阻塞队列是LinkedBlockingQueue, 最大容量为Integer.MAX_VALUE

适用于按照顺序执行的任务

3.可缓存线程池

1
2
3
4
5
public static ExecutorService newCachedThreadPool(){
return new ThreadPoolExecutor(0,Integer.MAX_VALUE,
60L,TimeUnit.SECONDS.
new SynchronousQueue<Runnable>());
}
  • 核心线程数为0
  • 最大线程数是Integer.MAX_VALUE
  • 阻塞队列为SynchronousQueue: 不存储元素的阻塞队列,每个插入操作都必须等待一个移出操作。

适合任务数比较密集,但每个任务执行时间较短的情况(不然会创建大量的线程占用内存)

4.提供了"延迟”和“周期执行”功能的ThreadPoolExecutor。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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);
}

为什么不建议Executors创建线程池?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
//以下三个不推荐
//创建一个固定大小的线程池,核心线程数和最大线程数都是3
ExecutorService executorService = Executors.newFixedThreadPool(3);

// 单个线程池,核心线程数和最大线程数都是1
ExecutorService exec = Executors.newSingleThreadxecutor();

//创建一个缓存的线程,没有核心线程数,最大线程数为Integer.MAX_VALUE
ExecutorService exec = Executors.newCachedTweadPool();

// 推荐
// 括号中的需要自己指定参数
ThreadPoolExecutor threadPool = new ThreadPoolExecuotr(int corePoolSize,
int maximumPoolSize,
long keepAliveTime,
TimeUnit unit,
BlockingQueue<Runnable> workQueue,
ThreadFactory threadFactory,
RejectedExecutionHandler handler);

使用场景

线程池使用场景(CountDownLatch、Future)(你们项目哪里用到了多线程)

CountDownLatch

CountDownLatch(闭锁/倒计时锁)用来进行线程同步协作,等待所有线程完成倒计时(一个或者多个线程,等待 他多个线程完成某件事情之后才能执行)

  • 其中构造参数用来初始化等待计数值
  • await()用来等待计数归零
  • countDown()用来让计数减一

案例一(es数据批量导入)

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

整体流程就是通过CountDownLatch+线程池配合去执行

详细实现流程:

案例二(数据汇总)

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

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

  • 报表汇总

案例三(异步调用)

在进行搜索的时候,需要保存用户的搜索记录,而搜索记录不能影响用户的正常搜索,我们通常会开启一个线程去执行历史记录的保存,在新开启的线程在执行的过程中,可以利用线程提交任务

如何控制某个方法允许并发访问线程的数量

Semaphore信号量,是JUC包下的一个工具类,底层是AQS,我们可以通过其限制执行的线程数量 使用场景: 通常用于那些资源有明确访问数量限制的场景,常用于限流。

其他

谈谈你对ThreadLocal的理解

ThreadLocal是多线程中对于解决线程安全的一个操作类,它会为每个线程都分配一个独立的线程副本从而解决了变量并发访问冲突的问题。ThreadLocal同时实现了线程内的资源共享

案例:使用DBC操作数据库时,会将每一个线程的Connection放入各自的ThreadLocal中,从而保证每个线程都在各自的Connection上进行数据库的操作,避免A线程关闭了B线程的连接。

ThreadLocal基本使用

ThreadLocal的实现原理&源码解析

set方法:

get/remove方法:

ThreadLocal-内存泄漏问题

Java对象中的四种引用类型:强引用、软引用、弱引用、虚引用

  • 强引用:最为普通的引用方式,表示一个对象处于有用且必须的状态,如果一个对象具有强引用,则GC并不会回收它。即便堆中内存不足了,宁可出现OOM,也不会对其进行回收。

    1
    User user = new User();
  • 弱引用:表示一个对象处于可能有用且非必须的状态。在GC线程扫描内存区域时,一旦发现弱引用,就会回收到弱引用相关联的对象。对于弱引用的回收,无关内存区域是否足够,一旦发现则会被回收

    1
    2
    User user = new User();
    WeakReference weakReference = new WeakReference(user);

每一个Thread维护一个ThreadLocalMap, 在ThreadLocalMap中的Entry对象继承了WeakReference。其中key为使用弱引用的ThreadLocal实例,value为线程变量的副本

JVM虚拟机篇

JVM组成

JVM介绍、运行流程

JVM(Java Virtual Machine), Java程序的运行环境(java二进制字节码的运行环境)

好处:

  • 一次编写,处处运行
  • 自动内存管理,垃圾回收机制

什么是程序计数器?

程序计数器:线程私有的,内部保存的字节码的行号。用于记录正在执行的字节码指令的地址。

如图例子:线程1一开始执行到第10行,此时会记录到程序计数器中,后面时间片被线程2抢占,线程2执行到第9行,此时返回线程1,因为之前程序计数器中保存了,所以线程1会从第10行开始。

你能给我详细的介绍Java堆吗?

Java堆是线程共享的区域:主要用来保存对象实例,数组等,当堆中没有内存空间可分配给实例,也无法再扩展时,则抛出OutOfMemoryError异常。

年轻代被划分为三部分,Eden区和两个大小严格相同的Survivor区,根据JVM的策略,在经过几次垃圾收集后,仍然存活于Survivor的对象将被移动到老年代区间。

老年代主要保存生命周期长的对象,一般是一些老的对象。

元空间保存的类信息、静态变量、常量、编译后的代码。

Java7和Java8的JVM的内存结构的不同?

Java8把堆中的方法区/永久代放到了本地内存的元空间中

为什么这么做?因为永久代的内存是固定大小的(可以手动配置),随着Java应用的发展(如大量使用动态代理、反射、字节码增强框架等),会加载大量类信息,很容易出现OOM,而元空间使用本地内存(Native Memory),其大小默认取决于系统可用内存,理论上受限于操作系统的内存上限,无需手动设置初始大小,可动态扩展,大幅减少了内存溢出风险。

什么是虚拟机栈?

  • 每个线程运行时所需要的内存,称为虚拟机栈,先进后出。
  • 每个栈由多个栈帧(frame)组成,对应着每次方法调用时所占用的内存
  • 每个线程只能有一个活动栈帧,对应着当前正在执行的那个方法
    • 如下图中的栈帧1即是活动栈帧(栈顶的)
    • 栈帧1调用了栈帧2,栈帧2在栈顶,那么栈帧2是活动栈帧

问题:

1.垃圾回收是否涉及栈内存?

垃圾回收主要指的是堆内存,当栈帧弹栈以后,内存就会释放。

2.栈内存分配越大越好吗?

未必,默认的栈内存通常为1024k

栈帧过大会导致线程数变少,例如,机器总内存为512m,目前能活动的线程数为512个,如果把栈内存改为2048k,那么能活动的栈帧就会减半。

3.方法内的局部变量是否线程安全?

  • 如果方法内局部变量没有逃离方法的作用范围,它是线程安全的
  • 如果是局部变量引用了对象,并逃离方法的作用范围,需要考虑线程安全

m1的局部变量没有逃离作用范围,所以是线程安全的。

m2的形参(也是局部变量)和m3的返回值(也是局部变量)都有可能被多线程共同调用。

栈内存溢出情况

  • 栈帧过多导致栈内存溢出,典型:递归调用
  • 栈帧过大导致栈内存溢出

堆和栈的区别是什么?

  • 栈内存一般会用来存储局部变量和方法调用,但堆内存是用来存储java对象和数组的。堆内存会GC垃圾回收,而栈不会。
  • 栈内存是线程私有的,而堆内存是线程共有的。
  • 两者异常错误不同,但如果堆内存或栈内存不足都会抛出异常。
    • 堆空间不足:java.langOutOfMemoryError
    • 栈空间不足:java.lang.StackOverFlowError

能不能解释一下方法区?

  • 方法区是各个线程共享的内存区域
  • 主要存储类的信息、运行时常量池
  • 虚拟机启动的时候创建,关闭虚拟机时释放
  • 如果方法区域中的内存无法满足分配请求,则会抛出OutOfMemoryError:Metaspace

常量池

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

运行时常量池

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

你听过直接内存吗?

直接内存:并不属于JVM中的内存结构,不由JVM进行管理。是虚拟机的系统内存,常见与NIO操作,用于数据缓冲区,它分配回收成本较高,但读写性能高。

常规IO:

常规IO:CPU在内核态时,需要把从磁盘读的数据复制到系统缓存区,因为java不能直接读,所以还需要复制到java缓冲区。

NIO:

类加载器

什么是类加载器?

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

什么是双亲委派模型?

加载某一个类,先委托上一级的加载器进行加载,如果上级加载器也有上级,则会继续向上委托,如果该类委托上级没有被加载,子加载器尝试加载该类。

JVM为什么采用双亲委派机制?

  • 通过双亲委派机制可以避免某一个类被重复加载,当父类已经加载后则无需重复加载,保证唯一性。
  • 为了安全,保证类库API不会被修改

说一下类装载的执行过程?

类从加载到虚拟机中开始,直到卸载为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用和卸载这7个阶段。其中,验证、准备和解析这三个部分统称为连接。

加载

验证

准备

解析

初始化

使用

垃圾回收

对象什么时候可以被垃圾器回收?

如果一个或多个对象没有任何的引用指向它了,那么这个对象现在就是垃圾,如果定位了垃圾,则有可能会被垃圾回收器回收。

怎么判断一个对象是否需要被回收,通过:

  • 引用计数法
  • 可达性分析算法

引用计数法

一开始String demo = new String("123");,堆中的对象被引用了,ref=1,后面String demo = null,没有引用了,ref=0

问题:

为什么是2?一开始创建的时候有引用,加1,后面a.instance=b; b.instance=a;,加1

可达性分析算法

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

1. 虚拟机栈(栈帧中的局部变量)中引用的对象

  • 为什么是Root: 正在被执行的方法中的局部变量引用的对象必须是存活的,否则程序会立即出错。

  • 举例

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    public class MyClass {
    public void myMethod() {
    // 当myMethod被执行时,localObject是一个局部变量,存放在虚拟机栈中
    // 它引用的Object对象就是一个GC Root
    Object localObject = new Object();

    // ... 一些操作
    // 当myMethod()执行完毕,它的栈帧被弹出,localObject引用消失,
    // 此时它之前引用的Object对象就不再被GC Root引用,下次GC时会被回收。
    }
    }

2. 方法区中静态属性(static)引用的对象

  • 为什么是Root: 类的静态变量属于类本身,生命周期与类相同,通常不会被回收。

  • 举例

    1
    2
    3
    4
    5
    6
    7
    8
    public class MyClass {
    // staticList是静态变量,存放在方法区。它本身就是一个GC Root。
    private static List<Object> staticList = new ArrayList<>();

    public void addToStaticList(Object obj) {
    staticList.add(obj); // 被添加到staticList中的对象,会一直存活
    }
    }

    这是一个非常常见的内存泄漏场景:如果你不小心将一个大型对象(如一个很大的集合)放入一个静态集合中,并且之后忘记移除它,那么这个对象将永远无法被回收,因为始终有一个GC Root(静态变量staticList)引用着它。

3. 方法区中常量(final static)引用的对象

  • 为什么是Root: 常量池中的引用,例如字符串字面量,也属于GC Roots。

  • 举例

    1
    2
    3
    4
    5
    public class Constants {
    // MAX_VALUE是基本类型,不在堆里,不算对象。
    // VALUE_OBJ是常量,它引用着堆中的一个String对象,这个引用是GC Root。
    public static final String VALUE_OBJ = "I-am-a-constant";
    }

    常量 VALUE_OBJ 引用的字符串 "I-am-a-constant" 会一直存活在字符串常量池中。

4. 本地方法栈中JNI(即通常所说的Native方法)引用的对象

  • 为什么是Root: 通过JNI(Java Native Interface)调用的本地代码(C/C++)创建的对象。这些对象可能被本地代码使用,JVM无法准确跟踪其引用关系,因此保守地将其全部视为根。

  • 举例

    1
    2
    3
    4
    5
    6
    7
    public class NativeExample {
    // 这是一个native方法,实现是在C/C++代码中
    public native nativeMethod();

    // 在C/C++代码中,可能会创建一些Java对象(使用JNI函数如NewObject)
    // 这些对象被本地代码的上下文所引用,JVM会将其标记为GC Root,防止被意外回收。
    }

JVM垃圾回收算法有哪些?

  • 标记清除算法
  • 复制算法
  • 标记整理算法

标记清除算法

标记整理算法

标记阶段同标记清除算法

将左边区域的存活的对象复制到右边的区域,然后把左边区域清空

说一下JVM中的分代回收

分代收集算法

工作机制

1:Eden区存满了对象,采用复制算法,假设标记了A对象是存活,则A对象要复制到to中

2:经过一段时间,Eden区又存满了对象,会标记Eden区和to区存活的对象(假设是1和A),将其复制到from区中

3:经过一段时间后,Eden区又存满了对象,假设此时存活的对象是w,假设A已经熬过15次回收,会将其挪动到老年代。

MinorGC、MixedGC、FullGC的区别是什么?

说一下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, 是一款并发的、使用标记-清除算法的垃圾回收器,该回收器是针对老年代垃圾回收的,是一款以获取最短回收停顿时间为目标的收集器,停顿时间短,用户体验就好。其最大特点是在进行垃圾回收时,应用仍然能正常运行。

如右侧的图,初始标记是标记和GC Roots直接关联的对象(A)

并发标记是这个阶段会从“初始标记”阶段标记到的对象(A)开始,深度遍历整个对象图,标记所有可达的存活对象(B、C、D)。

重新标记是用于修正“并发标记”阶段因用户线程运行而产生的变动。特别是要处理那些新产生的引用关系不再使用的引用关系,确保所有存活对象都被正确标记。(比如图中的X)

详细聊一下G1垃圾回收器

  • 应用于新生代和老年代,在JDK9之后默认使用G1
  • 划分成多个区域,每个区域都可以充当eden, survivor, old, humongous, 其中humongous专为大对象准备
  • 采用复制算法
  • 响应时间与吞吐量兼顾
  • 分成三个阶段:新生代回收、并发标记、混合收集
  • 如果并发失败(即回收速度赶不上创建新对象速度),会触发Full GC

年轻代垃圾回收

会将eden区存活的对象复制到幸存区,然后eden区释放

eden区和之前幸存区的对象复制到新的幸存区,然后eden区和之前的幸存区释放

年轻代垃圾回收+并发标记

混合垃圾回收

如果对象太大,会放在humongous区,专为大对象准备

强引用、软引用、弱引用、虚引用的区别

JVM实践

JVM调优的参数可以在哪里设置?

  • war包部署在tomcat中设置
  • jar包部署在启动参数设置

war包部署在tomcat中设置

jar包部署在启动参数设置

JVM调优的参数都有哪些?

对于JVM调优,主要就是调整年轻代、老年代、元空间的内存空间大小及使用的垃圾回收器类型。 https://www.oracle.com/java/technologies/javase/vmoptions-jsp.html

  • 设置堆空间大小
  • 虚拟机栈的设置
  • 年轻代中Eden区和两个Survivor区的大小比例
  • 年轻代晋升老年代阈值
  • 设置垃圾回收收集器

设置堆空间大小

虚拟机栈的设置

年轻代中Eden区和两个Survivor区的大小比例

年轻代晋升老年代阈值

设置垃圾回收收集器

说一下JVM调优的工具

  • 命令工具
    • jps:进程状态信息
    • jstack:查看java进程内线程的堆栈信息
    • jmap:查看堆转信息
    • jhat:堆转储快照分析工具
    • jstat:JVM统计监测工具
  • 可视化工具
    • jconsole:用于对jvm的内存,线程,类的监控
    • VisualVM:能够监控线程,内存情况

例:显示了某一个java运行的堆信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
C:\Users\yuhon>jmap -heap 53280
Attaching to process ID 53280, please wait...
Debugger attached successfully.
Server compiler detected.
JVM version is 25.321-b07

using thread-local object allocation.
Parallel GC with 8 thread(s) //并行的垃圾回收器

Heap Configuration: //堆配置
MinHeapFreeRatio = 0 //空闲堆空间的最小百分比
MaxHeapFreeRatio = 100 //空闲堆空间的最大百分比
MaxHeapSize = 8524922880 (8130.0MB) //堆空间允许的最大值
NewSize = 178257920 (170.0MB) //新生代堆空间的默认值
MaxNewSize = 2841640960 (2710.0MB) //新生代堆空间允许的最大值
OldSize = 356515840 (340.0MB) //老年代堆空间的默认值
NewRatio = 2 //新生代与老年代的堆空间比值,表示新生代:老年代=1:2
SurvivorRatio = 8 //两个Survivor区和Eden区的堆空间比值为8,表示S0:S1:Eden=1:1:8
MetaspaceSize = 21807104 (20.796875MB) //元空间的默认值
CompressedClassSpaceSize = 1073741824 (1024.0MB) //压缩类使用空间大小
MaxMetaspaceSize = 17592186044415 MB //元空间允许的最大值
G1HeapRegionSize = 0 (0.0MB)//在使用 G1 垃圾回收算法时,JVM 会将 Heap 空间分隔为若干个 Region,该参数用来指定每个 Region 空间的大小。

Heap Usage:
PS Young Generation
Eden Space: //Eden使用情况
capacity = 134217728 (128.0MB)
used = 10737496 (10.240074157714844MB)
free = 123480232 (117.75992584228516MB)
8.000057935714722% used
From Space: //Survivor-From 使用情况
capacity = 22020096 (21.0MB)
used = 0 (0.0MB)
free = 22020096 (21.0MB)
0.0% used
To Space: //Survivor-To 使用情况
capacity = 22020096 (21.0MB)
used = 0 (0.0MB)
free = 22020096 (21.0MB)
0.0% used
PS Old Generation //老年代 使用情况
capacity = 356515840 (340.0MB)
used = 0 (0.0MB)
free = 356515840 (340.0MB)
0.0% used

3185 interned Strings occupying 261264 bytes.

监控程序运行情况

查看运行中的dump

查看堆中的信息

Java内存泄漏的排查思路

可能有虚拟机栈、元空间、堆,一般堆发生内存泄漏更多

如果在服务器上:

CPU飙高排查方案与思路

企业场景篇

设计模式

工厂设计模式

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

简单工厂模式

简单工厂包含如下角色:

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

工厂方法模式

工厂方法模式的主要角色:

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

抽象工厂模式

策略模式

责任链设计模式

常见使用场景:

常见技术场景

单点登录这块怎么实现的?

权限认证是如何实现的?

5张表的情况:

上传数据的安全性你们怎么控制?

用户发送请求,这些请求要经过网络传输,主要是是指在网络中传输如何保证安全。

遇到了哪些比较棘手的问题?怎么解决的?

例子:比如之前讲的登录,有很多种登录方式,如果单纯用if-else实现,后面新增登录方式的话,都得修改代码,耦合性高;经过不断调研,采用了策略模式+工厂设计模式。

你们项目中日志怎么采集的?

查看日志的命令

生产环境问题怎么排查

怎么快速定位系统的瓶颈?