《我想进大厂》面试题总结大全(上)
前言
然后陆续发了一个月,慢慢的把这些笔记都发到公众号上了,只不过后面发现其实这不是原创啊,没办法,于是乎,就重新再次整理,画图,这个过程也可以算是一个重新提高自己的过程。毕竟,给自己看的明白和能让别人能明白还是不一样的。
今天,2020 年最后一天,几天前突然发烧了,体温一会上去一会下来的,熬了几天发现扛不住了,去医院一检查,好家伙,肺炎了,挂水 10 天,请了几天假,顺便就趁着今天最后一天把这 4 个多月以来写的还不错的整理出来了,超过 5 万字了,4 个多月 5 万多字也不少了,哈哈。

能说下 myisam 和 innodb 的区别吗?
myisam 引擎是 5.1 版本之前的默认引擎,支持全文检索、压缩、空间函数等,但是不支持事务和行级锁,所以一般用于有大量查询少量插入的场景来使用,而且 myisam 不支持外键,并且索引和数据是分开存储的。
innodb 是基于聚簇索引建立的,和 myisam 相反它支持事务、外键,并且通过 MVCC 来支持高并发,索引和数据存储在一起。
说下 mysql 的索引有哪些吧,聚簇和非聚簇索引又是什么?
索引按照数据结构来说主要包含 B+树和 Hash 索引。
假设我们有张表,结构如下:
create table user( id int(11) not null, age int(11) not null, primary key(id), key(age) );
B+树是左小右大的顺序存储结构,节点只包含 id 索引列,而叶子节点包含索引列和数据,这种数据和索引在一起存储的索引方式叫做聚簇索引,一张表只能有一个聚簇索引。假设没有定义主键,InnoDB 会选择一个唯一的非空索引代替,如果没有的话则会隐式定义一个主键作为聚簇索引。

这是主键聚簇索引存储的结构,那么非聚簇索引的结构是什么样子呢?非聚簇索引(二级索引)保存的是主键 id 值,这一点和 myisam 保存的是数据地址是不同的。

最终,我们一张图看看 InnoDB 和 Myisam 聚簇和非聚簇索引的区别

那你知道什么是覆盖索引和回表吗?
覆盖索引指的是在一次查询中,如果一个索引包含或者说覆盖所有需要查询的字段的值,我们就称之为覆盖索引,而不再需要回表查询。
而要确定一个查询是否是覆盖索引,我们只需要 explain sql 语句看 Extra 的结果是否是“Using index”即可。
以上面的 user 表来举例,我们再增加一个 name 字段,然后做一些查询试试。
explain select * from user where age=1; //查询的name无法从索引数据获取 explain select id,age from user where age=1; //可以直接从索引获取
锁的类型有哪些呢
mysql 锁分为共享锁和排他锁,也叫做读锁和写锁。
读锁是共享的,可以通过 lock in share mode 实现,这时候只能读不能写。
写锁是排他的,它会阻塞其他的写锁和读锁。从颗粒度来区分,可以分为表锁和行锁两种。
表锁会锁定整张表并且阻塞其他用户对该表的所有读写操作,比如 alter 修改表结构的时候会锁表。
行锁又可以分为乐观锁和悲观锁,悲观锁可以通过 for update 实现,乐观锁则通过版本号实现。
你能说下事务的基本特性和隔离级别吗?
事务基本特性 ACID 分别是:
原子性指的是一个事务中的操作要么全部成功,要么全部失败。
一致性指的是数据库总是从一个一致性的状态转换到另外一个一致性的状态。比如 A 转账给 B100 块钱,假设中间 sql 执行过程中系统崩溃 A 也不会损失 100 块,因为事务没有提交,修改也就不会保存到数据库。
隔离性指的是一个事务的修改在最终提交前,对其他事务是不可见的。
持久性指的是一旦事务提交,所做的修改就会永久保存到数据库中。
而隔离性有 4 个隔离级别,分别是:
read uncommit 读未提交,可能会读到其他事务未提交的数据,也叫做脏读。
用户本来应该读取到 id=1 的用户 age 应该是 10,结果读取到了其他事务还没有提交的事务,结果读取结果 age=20,这就是脏读。

read commit 读已提交,两次读取结果不一致,叫做不可重复读。
不可重复读解决了脏读的问题,他只会读取已经提交的事务。
用户开启事务读取 id=1 用户,查询到 age=10,再次读取发现结果=20,在同一个事务里同一个查询读取到不同的结果叫做不可重复读。

repeatable read 可重复复读,这是 mysql 的默认级别,就是每次读取结果都一样,但是有可能产生幻读。
serializable 串行,一般是不会使用的,他会给每一行读取的数据加锁,会导致大量超时和锁竞争的问题。
那 ACID 靠什么保证的呢?
A 原子性由 undo log 日志保证,它记录了需要回滚的日志信息,事务回滚时撤销已经执行成功的 sql
C 一致性一般由代码层面来保证
I 隔离性由 MVCC 来保证
D 持久性由内存+redo log 来保证,mysql 修改数据同时在内存和 redo log 记录这次操作,事务提交的时候通过 redo log 刷盘,宕机的时候可以从 redo log 恢复
那你说说什么是幻读,什么是 MVCC?
要说幻读,首先要了解 MVCC,MVCC 叫做多版本并发控制,实际上就是保存了数据在某个时间节点的快照。
我们每行数实际上隐藏了两列,创建时间版本号,过期(删除)时间版本号,每开始一个新的事务,版本号都会自动递增。
还是拿上面的 user 表举例子,假设我们插入两条数据,他们实际上应该长这样。
idnamecreate_versiondelete_version1 张三 12 李四 2
这时候假设小明去执行查询,此时 current_version=3
select * from user where id<=3;
同时,小红在这时候开启事务去修改 id=1 的记录,current_version=4
update user set name='张三三' where id=1;
执行成功后的结果是这样的
idnamecreate_versiondelete_version1 张三 12 李四 21 张三三 4
如果这时候还有小黑在删除 id=2 的数据,current_version=5,执行后结果是这样的。
idnamecreate_versiondelete_version1 张三 12 李四 251 张三三 4
由于 MVCC 的原理是查找创建版本小于或等于当前事务版本,删除版本为空或者大于当前事务版本,小明的真实的查询应该是这样
select * from user where id<=3 and create_version<=3 and (delete_version>3 or delete_version is null);
所以小明最后查询到的 id=1 的名字还是'张三',并且 id=2 的记录也能查询到。这样做是为了保证事务读取的数据是在事务开始前就已经存在的,要么是事务自己插入或者修改的。
明白 MVCC 原理,我们来说什么是幻读就简单多了。举一个常见的场景,用户注册时,我们先查询用户名是否存在,不存在就插入,假定用户名是唯一索引。
- 小明开启事务 current_version=6 查询名字为'王五'的记录,发现不存在。
- 小红开启事务 current_version=7 插入一条数据,结果是这样:
idNamecreate_versiondelete_version1 张三 12 李四 23 王五 7
- 小明执行插入名字'王五'的记录,发现唯一索引冲突,无法插入,这就是幻读。
那你知道什么是间隙锁吗?
间隙锁是可重复读级别下才会有的锁,结合 MVCC 和间隙锁可以解决幻读的问题。我们还是以 user 举例,假设现在 user 表有几条记录
idAge110220330
当我们执行:
begin; select * from user where age=20 for update; begin; insert into user(age) values(10); #成功 insert into user(age) values(11); #失败 insert into user(age) values(20); #失败 insert into user(age) values(21); #失败 insert into user(age) values(30); #失败
只有 10 可以插入成功,那么因为表的间隙 mysql 自动帮我们生成了区间(左开右闭)
(negative infinity,10],(10,20],(20,30],(30,positive infinity)
由于 20 存在记录,所以(10,20],(20,30]区间都被锁定了无法插入、删除。
如果查询 21 呢?就会根据 21 定位到(20,30)的区间(都是开区间)。
需要注意的是唯一索引是不会有间隙索引的。
你们数据量级多大?分库分表怎么做的?
首先分库分表分为垂直和水平两个方式,一般来说我们拆分的顺序是先垂直后水平。
垂直分库
基于现在微服务拆分来说,都是已经做到了垂直分库了

垂直分表
如果表字段比较多,将不常用的、数据较大的等等做拆分

水平分表
首先根据业务场景来决定使用什么字段作为分表字段(sharding_key),比如我们现在日订单 1000 万,我们大部分的场景来源于 C 端,我们可以用 user_id 作为 sharding_key,数据查询支持到最近 3 个月的订单,超过 3 个月的做归档处理,那么 3 个月的数据量就是 9 亿,可以分 1024 张表,那么每张表的数据大概就在 100 万左右。
比如用户 id 为 100,那我们都经过 hash(100),然后对 1024 取模,就可以落到对应的表上了。
那分表后的 ID 怎么保证唯一性的呢?
因为我们主键默认都是自增的,那么分表之后的主键在不同表就肯定会有冲突了。有几个办法考虑:
- 设定步长,比如 1-1024 张表我们设定 1024 的基础步长,这样主键落到不同的表就不会冲突了。
- 分布式 ID,自己实现一套分布式 ID 生成算法或者使用开源的比如雪花算法这种
- 分表后不使用主键作为查询依据,而是每张表单独新增一个字段作为唯一主键使用,比如订单表订单号是唯一的,不管最终落在哪张表都基于订单号作为查询依据,更新也一样。
分表后非 sharding_key 的查询怎么处理呢?
- 可以做一个 mapping 表,比如这时候商家要查询订单列表怎么办呢?不带 user_id 查询的话你总不能扫全表吧?所以我们可以做一个映射关系表,保存商家和用户的关系,查询的时候先通过商家查询到用户列表,再通过 user_id 去查询。
- 打宽表,一般而言,商户端对数据实时性要求并不是很高,比如查询订单列表,可以把订单表同步到离线(实时)数仓,再基于数仓去做成一张宽表,再基于其他如 es 提供查询服务。
- 数据量不是很大的话,比如后台的一些查询之类的,也可以通过多线程扫表,然后再聚合结果的方式来做。或者异步的形式也是可以的。
List>> taskList = Lists.newArrayList(); for (int shardingIndex = 0; shardingIndex < 1024; shardingIndex++) { taskList.add(() -> (userMapper.getProcessingAccountList(shardingIndex))); } List list = null; try { list = taskExecutor.executeTask(taskList); } catch (Exception e) { //do something } public class TaskExecutor { public List executeTask(Collection> tasks) throws Exception { List result = Lists.newArrayList(); List> futures = ExecutorUtil.invokeAll(tasks); for (Future future : futures) { result.add(future.get()); } return result; } }
说说 mysql 主从同步怎么做的吧?
首先先了解 mysql 主从同步的原理
- master 提交完事务后,写入 binlog
- slave 连接到 master,获取 binlog
- master 创建 dump 线程,推送 binglog 到 slave
- slave 启动一个 IO 线程读取同步过来的 master 的 binlog,记录到 relay log 中继日志中
- slave 再开启一个 sql 线程读取 relay log 事件并在 slave 执行,完成同步
- slave 记录自己的 binglog

由于 mysql 默认的复制方式是异步的,主库把日志发送给从库后不关心从库是否已经处理,这样会产生一个问题就是假设主库挂了,从库处理失败了,这时候从库升为主库后,日志就丢失了。由此产生两个概念。
全同步复制
主库写入 binlog 后强制同步日志到从库,所有的从库都执行完成后才返回给客户端,但是很显然这个方式的话性能会受到严重影响。
半同步复制
和全同步不同的是,半同步复制的逻辑是这样,从库写入日志成功后返回 ACK 确认给主库,主库收到至少一个从库的确认就认为写操作完成。
那主从的延迟怎么解决呢?
- 针对特定的业务场景,读写请求都强制走主库
- 读请求走从库,如果没有数据,去主库做二次查询
Redis
说说 Redis 基本数据类型有哪些吧
- 字符串:redis 没有直接使用 C 语言传统的字符串表示,而是自己实现的叫做简单动态字符串 SDS 的抽象类型。C 语言的字符串不记录自身的长度信息,而 SDS 则保存了长度信息,这样将获取字符串长度的时间由 O(N)降低到了 O(1),同时可以避免缓冲区溢出和减少修改字符串长度时所需的内存重分配次数。
- 链表 linkedlist:redis 链表是一个双向无环链表结构,很多发布订阅、慢查询、监视器功能都是使用到了链表来实现,每个链表的节点由一个 listNode 结构来表示,每个节点都有指向前置节点和后置节点的指针,同时表头节点的前置和后置节点都指向 NULL。
- 字典 hashtable:用于保存键值对的抽象数据结构。redis 使用 hash 表作为底层实现,每个字典带有两个 hash 表,供平时使用和 rehash 时使用,hash 表使用链地址法来解决键冲突,被分配到同一个索引位置的多个键值对会形成一个单向链表,在对 hash 表进行扩容或者缩容的时候,为了服务的可用性,rehash 的过程不是一次性完成的,而是渐进式的。
- 跳跃表 skiplist:跳跃表是有序集合的底层实现之一,redis 中在实现有序集合键和集群节点的内部结构中都是用到了跳跃表。redis 跳跃表由 zskiplist 和 zskiplistNode 组成,zskiplist 用于保存跳跃表信息(表头、表尾节点、长度等),zskiplistNode 用于表示表跳跃节点,每个跳跃表的层高都是 1-32 的随机数,在同一个跳跃表中,多个节点可以包含相同的分值,但是每个节点的成员对象必须是唯一的,节点按照分值大小排序,如果分值相同,则按照成员对象的大小排序。
- 整数集合 intset:用于保存整数值的集合抽象数据结构,不会出现重复元素,底层实现为数组。
- 压缩列表 ziplist:压缩列表是为节约内存而开发的顺序性数据结构,他可以包含多个节点,每个节点可以保存一个字节数组或者整数值。
基于这些基础的数据结构,redis 封装了自己的对象系统,包含字符串对象 string、列表对象 list、哈希对象 hash、集合对象 set、有序集合对象 zset,每种对象都用到了至少一种基础的数据结构。
redis 通过 encoding 属性设置对象的编码形式来提升灵活性和效率,基于不同的场景 redis 会自动做出优化。不同对象的编码如下:
- 字符串对象 string:int 整数、embstr 编码的简单动态字符串、raw 简单动态字符串
- 列表对象 list:ziplist、linkedlist
- 哈希对象 hash:ziplist、hashtable
- 集合对象 set:intset、hashtable
- 有序集合对象 zset:ziplist、skiplist
Redis 为什么快呢?
redis 的速度非常的快,单机的 redis 就可以支撑每秒 10 几万的并发,相对于 mysql 来说,性能是 mysql 的几十倍。速度快的原因主要有几点:
- 完全基于内存操作
- C 语言实现,优化过的数据结构,基于几种基础的数据结构,redis 做了大量的优化,性能极高
- 使用单线程,无上下文的切换成本
- 基于非阻塞的 IO 多路复用机制
那为什么 Redis6.0 之后又改用多线程呢?
redis 使用多线程并非是完全摒弃单线程,redis 还是使用单线程模型来处理客户端的请求,只是使用多线程来处理数据的读写和协议解析,执行命令还是使用单线程。
这样做的目的是因为 redis 的性能瓶颈在于网络 IO 而非 CPU,使用多线程能提升 IO 读写的效率,从而整体提高 redis 的性能。
(见网络篇下单线程 Reactor 模型)
知道什么是热 key 吗?热 key 问题怎么解决?
所谓热 key 问题就是,突然有几十万的请求去访问 redis 上的某个特定 key,那么这样会造成流量过于集中,达到物理网卡上限,从而导致这台 redis 的服务器宕机引发雪崩。

针对热 key 的解决方案:
- 提前把热 key 打散到不同的服务器,降低压力
- 加入二级缓存,提前加载热 key 数据到内存中,如果 redis 宕机,走内存查询
什么是缓存击穿、缓存穿透、缓存雪崩?
缓存击穿
缓存击穿的概念就是单个 key 并发访问过高,过期时导致所有请求直接打到 db 上,这个和热 key 的问题比较类似,只是说的点在于过期导致请求全部打到 DB 上而已。
解决方案:
- 加锁更新,比如请求查询 A,发现缓存中没有,对 A 这个 key 加锁,同时去数据库查询数据,写入缓存,再返回给用户,这样后面的请求就可以从缓存中拿到数据了。
- 将过期时间组合写在 value 中,通过异步的方式不断的刷新过期时间,防止此类现象。

缓存穿透
缓存穿透是指查询不存在缓存中的数据,每次请求都会打到 DB,就像缓存不存在一样。

针对这个问题,加一层布隆过滤器。布隆过滤器的原理是在你存入数据的时候,会通过散列函数将它映射为一个位数组中的 K 个点,同时把他们置为 1。
这样当用户再次来查询 A,而 A 在布隆过滤器值为 0,直接返回,就不会产生击穿请求打到 DB 了。
显然,使用布隆过滤器之后会有一个问题就是误判,因为它本身是一个数组,可能会有多个值落到同一个位置,那么理论上来说只要我们的数组长度够长,误判的概率就会越低,这种问题就根据实际情况来就好了。

缓存雪崩
当某一时刻发生大规模的缓存失效的情况,比如你的缓存服务宕机了,会有大量的请求进来直接打到 DB 上,这样可能导致整个系统的崩溃,称为雪崩。雪崩和击穿、热 key 的问题不太一样的是,他是指大规模的缓存都过期失效了。

针对雪崩几个解决方案:
- 针对不同 key 设置不同的过期时间,避免同时过期
- 限流,如果 redis 宕机,可以限流,避免同时刻大量请求打崩 DB
- 二级缓存,同热 key 的方案。
Redis 的过期策略有哪些?
redis 主要有 2 种过期删除策略
惰性删除
惰性删除指的是当我们查询 key 的时候才对 key 进行检测,如果已经达到过期时间,则删除。显然,他有一个缺点就是如果这些过期的 key 没有被访问,那么他就一直无法被删除,而且一直占用内存。

定期删除
定期删除指的是 redis 每隔一段时间对数据库做一次检查,删除里面的过期 key。由于不可能对所有 key 去做轮询来删除,所以 redis 会每次随机取一些 key 去做检查和删除。
那么定期+惰性都没有删除过期的 key 怎么办?
假设 redis 每次定期随机查询 key 的时候没有删掉,这些 key 也没有做查询的话,就会导致这些 key 一直保存在 redis 里面无法被删除,这时候就会走到 redis 的内存淘汰机制。
- volatile-lru:从已设置过期时间的 key 中,移出最近最少使用的 key 进行淘汰
- volatile-ttl:从已设置过期时间的 key 中,移出将要过期的 key
- volatile-random:从已设置过期时间的 key 中随机选择 key 淘汰
- allkeys-lru:从 key 中选择最近最少使用的进行淘汰
- allkeys-random:从 key 中随机选择 key 进行淘汰
- noeviction:当内存达到阈值的时候,新写入操作报错
持久化方式有哪些?有什么区别?
redis 持久化方案分为 RDB 和 AOF 两种。
RDB
RDB 持久化可以手动执行也可以根据配置定期执行,它的作用是将某个时间点上的数据库状态保存到 RDB 文件中,RDB 文件是一个压缩的二进制文件,通过它可以还原某个时刻数据库的状态。由于 RDB 文件是保存在硬盘上的,所以即使 redis 崩溃或者退出,只要 RDB 文件存在,就可以用它来恢复还原数据库的状态。
可以通过 SAVE 或者 BGSAVE 来生成 RDB 文件。
SAVE 命令会阻塞 redis 进程,直到 RDB 文件生成完毕,在进程阻塞期间,redis 不能处理任何命令请求,这显然是不合适的。
BGSAVE 则是会 fork 出一个子进程,然后由子进程去负责生成 RDB 文件,父进程还可以继续处理命令请求,不会阻塞进程。
AOF
AOF 和 RDB 不同,AOF 是通过保存 redis 服务器所执行的写命令来记录数据库状态的。
AOF 通过追加、写入、同步三个步骤来实现持久化机制。
- 当 AOF 持久化处于激活状态,服务器执行完写命令之后,写命令将会被追加 append 到 aof_buf 缓冲区的末尾
- 在服务器每结束一个事件循环之前,将会调用 flushAppendOnlyFile 函数决定是否要将 aof_buf 的内容保存到 AOF 文件中,可以通过配置 appendfsync 来决定。
always ##aof_buf 内容写入并同步到 AOF 文件 everysec ##将 aof_buf 中内容写入到 AOF 文件,如果上次同步 AOF 文件时间距离现在超过1秒,则再次对 AOF 文件进行同步 no ##将 aof_buf 内容写入 AOF 文件,但是并不对 AOF 文件进行同步,同步时间由操作系统决定
如果不设置,默认选项将会是 everysec,因为 always 来说虽然最安全(只会丢失一次事件循环的写命令),但是性能较差,而 everysec 模式只不过会可能丢失 1 秒钟的数据,而 no 模式的效率和 everysec 相仿,但是会丢失上次同步 AOF 文件之后的所有写命令数据。
怎么实现 Redis 的高可用?
要想实现高可用,一台机器肯定是不够的,而 redis 要保证高可用,有 2 个可选方案。
主从架构
主从模式是最简单的实现高可用的方案,核心就是主从同步。主从同步的原理如下:
- slave 发送 sync 命令到 master
- master 收到 sync 之后,执行 bgsave,生成 RDB 全量文件
- master 把 slave 的写命令记录到缓存
- bgsave 执行完毕之后,发送 RDB 文件到 slave,slave 执行
- master 发送缓存中的写命令到 slave,slave 执行

这里我写的这个命令是 sync,但是在 redis2.8 版本之后已经使用 psync 来替代 sync 了,原因是 sync 命令非常消耗系统资源,而 psync 的效率更高。
哨兵
基于主从方案的缺点还是很明显的,假设 master 宕机,那么就不能写入数据,那么 slave 也就失去了作用,整个架构就不可用了,除非你手动切换,主要原因就是因为没有自动故障转移机制。而哨兵(sentinel)的功能比单纯的主从架构全面的多了,它具备自动故障转移、集群监控、消息通知等功能。

哨兵可以同时监视多个主从服务器,并且在被监视的 master 下线时,自动将某个 slave 提升为 master,然后由新的 master 继续接收命令。整个过程如下:
- 初始化 sentinel,将普通的 redis 代码替换成 sentinel 专用代码
- 初始化 masters 字典和服务器信息,服务器信息主要保存 ip:port,并记录实例的地址和 ID
- 创建和 master 的两个连接,命令连接和订阅连接,并且订阅 sentinel:hello 频道
- 每隔 10 秒向 master 发送 info 命令,获取 master 和它下面所有 slave 的当前信息
- 当发现 master 有新的 slave 之后,sentinel 和新的 slave 同样建立两个连接,同时每个 10 秒发送 info 命令,更新 master 信息
- sentinel 每隔 1 秒向所有服务器发送 ping 命令,如果某台服务器在配置的响应时间内连续返回无效回复,将会被标记为下线状态
- 选举出领头 sentinel,领头 sentinel 需要半数以上的 sentinel 同意
- 领头 sentinel 从已下线的的 master 所有 slave 中挑选一个,将其转换为 master
- 让所有的 slave 改为从新的 master 复制数据
- 将原来的 master 设置为新的 master 的从服务器,当原来 master 重新回复连接时,就变成了新 master 的从服务器
sentinel 会每隔 1 秒向所有实例(包括主从服务器和其他 sentinel)发送 ping 命令,并且根据回复判断是否已经下线,这种方式叫做主观下线。当判断为主观下线时,就会向其他监视的 sentinel 询问,如果超过半数的投票认为已经是下线状态,则会标记为客观下线状态,同时触发故障转移。
能说说 redis 集群的原理吗?
如果说依靠哨兵可以实现 redis 的高可用,如果还想在支持高并发同时容纳海量的数据,那就需要 redis 集群。redis 集群是 redis 提供的分布式数据存储方案,集群通过数据分片 sharding 来进行数据的共享,同时提供复制和故障转移的功能。
节点
一个 redis 集群由多个节点 node 组成,而多个 node 之间通过 cluster meet 命令来进行连接,节点的握手过程:
- 节点 A 收到客户端的 cluster meet 命令
- A 根据收到的 IP 地址和端口号,向 B 发送一条 meet 消息
- 节点 B 收到 meet 消息返回 pong
- A 知道 B 收到了 meet 消息,返回一条 ping 消息,握手成功
- 最后,节点 A 将会通过 gossip 协议把节点 B 的信息传播给集群中的其他节点,其他节点也将和 B 进行握手

槽 slot
redis 通过集群分片的形式来保存数据,整个集群数据库被分为 16384 个 slot,集群中的每个节点可以处理 0-16384 个 slot,当数据库 16384 个 slot 都有节点在处理时,集群处于上线状态,反之只要有一个 slot 没有得到处理都会处理下线状态。通过 cluster addslots 命令可以将 slot 指派给对应节点处理。
slot 是一个位数组,数组的长度是 16384/8=2048,而数组的每一位用 1 表示被节点处理,0 表示不处理,如图所示的话表示 A 节点处理 0-7 的 slot。

当客户端向节点发送命令,如果刚好找到 slot 属于当前节点,那么节点就执行命令,反之,则会返回一个 MOVED 命令到客户端指引客户端转向正确的节点。(MOVED 过程是自动的)

如果增加或者移出节点,对于 slot 的重新分配也是非常方便的,redis 提供了工具帮助实现 slot 的迁移,整个过程是完全在线的,不需要停止服务。
故障转移
如果节点 A 向节点 B 发送 ping 消息,节点 B 没有在规定的时间内响应 pong,那么节点 A 会标记节点 B 为 pfail 疑似下线状态,同时把 B 的状态通过消息的形式发送给其他节点,如果超过半数以上的节点都标记 B 为 pfail 状态,B 就会被标记为 fail 下线状态,此时将会发生故障转移,优先从复制数据较多的从节点选择一个成为主节点,并且接管下线节点的 slot,整个过程和哨兵非常类似,都是基于 Raft 协议做选举。
了解 Redis 事务机制吗?
redis 通过 MULTI、EXEC、WATCH 等命令来实现事务机制,事务执行过程将一系列多个命令按照顺序一次性执行,并且在执行期间,事务不会被中断,也不会去执行客户端的其他请求,直到所有命令执行完毕。事务的执行过程如下:
- 服务端收到客户端请求,事务以 MULTI 开始
- 如果客户端正处于事务状态,则会把事务放入队列同时返回给客户端 QUEUED,反之则直接执行这个命令
- 当收到客户端 EXEC 命令时,WATCH 命令监视整个事务中的 key 是否有被修改,如果有则返回空回复到客户端表示失败,否则 redis 会遍历整个事务队列,执行队列中保存的所有命令,最后返回结果给客户端
WATCH 的机制本身是一个 CAS 的机制,被监视的 key 会被保存到一个链表中,如果某个 key 被修改,那么 REDIS_DIRTY_CAS 标志将会被打开,这时服务器会拒绝执行事务。
消息队列 MQ
你们为什么使用 mq?具体的使用场景是什么?
mq 的作用很简单,削峰填谷。以电商交易下单的场景来说,正向交易的过程可能涉及到创建订单、扣减库存、扣减活动预算、扣减积分等等。每个接口的耗时如果是 100ms,那么理论上整个下单的链路就需要耗费 400ms,这个时间显然是太长了。

如果这些操作全部同步处理的话,首先调用链路太长影响接口性能,其次分布式事务的问题很难处理,这时候像扣减预算和积分这种对实时一致性要求没有那么高的请求,完全就可以通过 mq 异步的方式去处理了。同时,考虑到异步带来的不一致的问题,我们可以通过 job 去重试保证接口调用成功,而且一般公司都会有核对的平台,比如下单成功但是未扣减积分的这种问题可以通过核对作为兜底的处理方案。

使用 mq 之后我们的链路变简单了,同时异步发送消息我们的整个系统的抗压能力也上升了。
那你们使用什么 mq?基于什么做的选型?
我们主要调研了几个主流的 mq,kafka、rabbitmq、rocketmq、activemq,选型我们主要基于以下几个点去考虑:
- 由于我们系统的 qps 压力比较大,所以性能是首要考虑的要素。
- 开发语言,由于我们的开发语言是 java,主要是为了方便二次开发。
- 对于高并发的业务场景是必须的,所以需要支持分布式架构的设计。
- 功能全面,由于不同的业务场景,可能会用到顺序消息、事务消息等。
基于以上几个考虑,我们最终选择了 RocketMQ。

你上面提到异步发送,那消息可靠性怎么保证?
消息丢失可能发生在生产者发送消息、MQ 本身丢失消息、消费者丢失消息 3 个方面。
生产者丢失
生产者丢失消息的可能点在于程序发送失败抛异常了没有重试处理,或者发送的过程成功但是过程中网络闪断 MQ 没收到,消息就丢失了。
由于同步发送的一般不会出现这样使用方式,所以我们就不考虑同步发送的问题,我们基于异步发送的场景来说。
异步发送分为两个方式:异步有回调和异步无回调,无回调的方式,生产者发送完后不管结果可能就会造成消息丢失,而通过异步发送+回调通知+本地消息表的形式我们就可以做出一个解决方案。以下单的场景举例。
- 下单后先保存本地数据和 MQ 消息表,这时候消息的状态是发送中,如果本地事务失败,那么下单失败,事务回滚。
- 下单成功,直接返回客户端成功,异步发送 MQ 消息
- MQ 回调通知消息发送结果,对应更新数据库 MQ 发送状态
- JOB 轮询超过一定时间(时间根据业务配置)还未发送成功的消息去重试
- 在监控平台配置或者 JOB 程序处理超过一定次数一直发送不成功的消息,告警,人工介入。

一般而言,对于大部分场景来说异步回调的形式就可以了,只有那种需要完全保证不能丢失消息的场景我们做一套完整的解决方案。
MQ 丢失
如果生产者保证消息发送到 MQ,而 MQ 收到消息后还在内存中,这时候宕机了又没来得及同步给从节点,就有可能导致消息丢失。
比如 RocketMQ:
RocketMQ 分为同步刷盘和异步刷盘两种方式,默认的是异步刷盘,就有可能导致消息还未刷到硬盘上就丢失了,可以通过设置为同步刷盘的方式来保证消息可靠性,这样即使 MQ 挂了,恢复的时候也可以从磁盘中去恢复消息。
比如 Kafka 也可以通过配置做到:
acks=all 只有参与复制的所有节点全部收到消息,才返回生产者成功。这样的话除非所有的节点都挂了,消息才会丢失。 replication.factor=N,设置大于1的数,这会要求每个 partion 至少有2个副本 min.insync.replicas=N,设置大于1的数,这会要求 leader 至少感知到一个 follower 还保持着连接 retries=N,设置一个非常大的值,让生产者发送失败一直重试
虽然我们可以通过配置的方式来达到 MQ 本身高可用的目的,但是都对性能有损耗,怎样配置需要根据业务做出权衡。
消费者丢失
消费者丢失消息的场景:消费者刚收到消息,此时服务器宕机,MQ 认为消费者已经消费,不会重复发送消息,消息丢失。
RocketMQ 默认是需要消费者回复 ack 确认,而 kafka 需要手动开启配置关闭自动 offset。
消费方不返回 ack 确认,重发的机制根据 MQ 类型的不同发送时间间隔、次数都不尽相同,如果重试超过次数之后会进入死信队列,需要手工来处理了。(Kafka 没有这些)

你说到消费者消费失败的问题,那么如果一直消费失败导致消息积压怎么处理?
因为考虑到时消费者消费一直出错的问题,那么我们可以从以下几个角度来考虑:
- 消费者出错,肯定是程序或者其他问题导致的,如果容易修复,先把问题修复,让 consumer 恢复正常消费
- 如果时间来不及处理很麻烦,做转发处理,写一个临时的 consumer 消费方案,先把消息消费,然后再转发到一个新的 topic 和 MQ 资源,这个新的 topic 的机器资源单独申请,要能承载住当前积压的消息
- 处理完积压数据后,修复 consumer,去消费新的 MQ 和现有的 MQ 数据,新 MQ 消费完成后恢复原状

那如果消息积压达到磁盘上限,消息被删除了怎么办?
这。。。他妈都删除了我有啥办法啊。。。冷静,再想想。。有了。

最初,我们发送的消息记录是落库保存了的,而转发发送的数据也保存了,那么我们就可以通过这部分数据来找到丢失的那部分数据,再单独跑个脚本重发就可以了。如果转发的程序没有落库,那就和消费方的记录去做对比,只是过程会更艰难一点。
说了这么多,那你说说 RocketMQ 实现原理吧?
RocketMQ 由 NameServer 注册中心集群、Producer 生产者集群、Consumer 消费者集群和若干 Broker(RocketMQ 进程)组成,它的架构原理是这样的:
- Broker 在启动的时候去向所有的 NameServer 注册,并保持长连接,每 30s 发送一次心跳
- Producer 在发送消息的时候从 NameServer 获取 Broker 服务器地址,根据负载均衡算法选择一台服务器来发送消息
- Conusmer 消费消息的时候同样从 NameServer 获取 Broker 地址,然后主动拉取消息来消费

为什么 RocketMQ 不使用 Zookeeper 作为注册中心呢?
我认为有以下几个点是不使用 zookeeper 的原因:
- 根据 CAP 理论,同时最多只能满足两个点,而 zookeeper 满足的是 CP,也就是说 zookeeper 并不能保证服务的可用性,zookeeper 在进行选举的时候,整个选举的时间太长,期间整个集群都处于不可用的状态,而这对于一个注册中心来说肯定是不能接受的,作为服务发现来说就应该是为可用性而设计。
- 基于性能的考虑,NameServer 本身的实现非常轻量,而且可以通过增加机器的方式水平扩展,增加集群的抗压能力,而 zookeeper 的写是不可扩展的,而 zookeeper 要解决这个问题只能通过划分领域,划分多个 zookeeper 集群来解决,首先操作起来太复杂,其次这样还是又违反了 CAP 中的 A 的设计,导致服务之间是不连通的。
- 持久化的机制来带的问题,ZooKeeper 的 ZAB 协议对每一个写请求,会在每个 ZooKeeper 节点上保持写一个事务日志,同时再加上定期的将内存数据镜像(Snapshot)到磁盘来保证数据的一致性和持久性,而对于一个简单的服务发现的场景来说,这其实没有太大的必要,这个实现方案太重了。而且本身存储的数据应该是高度定制化的。
- 消息发送应该弱依赖注册中心,而 RocketMQ 的设计理念也正是基于此,生产者在第一次发送消息的时候从 NameServer 获取到 Broker 地址后缓存到本地,如果 NameServer 整个集群不可用,短时间内对于生产者和消费者并不会产生太大影响。
那 Broker 是怎么保存数据的呢?
RocketMQ 主要的存储文件包括 commitlog 文件、consumequeue 文件、indexfile 文件。
Broker 在收到消息之后,会把消息保存到 commitlog 的文件当中,而同时在分布式的存储当中,每个 broker 都会保存一部分 topic 的数据,同时,每个 topic 对应的 messagequeue 下都会生成 consumequeue 文件用于保存 commitlog 的物理位置偏移量 offset,indexfile 中会保存 key 和 offset 的对应关系。

CommitLog 文件保存于${Rocket_Home}/store/commitlog 目录中,从图中我们可以明显看出来文件名的偏移量,每个文件默认 1G,写满后自动生成一个新的文件。

由于同一个 topic 的消息并不是连续的存储在 commitlog 中,消费者如果直接从 commitlog 获取消息效率非常低,所以通过 consumequeue 保存 commitlog 中消息的偏移量的物理地址,这样消费者在消费的时候先从 consumequeue 中根据偏移量定位到具体的 commitlog 物理文件,然后根据一定的规则(offset 和文件大小取模)在 commitlog 中快速定位。

Master 和 Slave 之间是怎么同步数据的呢?
而消息在 master 和 slave 之间的同步是根据 raft 协议来进行的:
- 在 broker 收到消息后,会被标记为 uncommitted 状态
- 然后会把消息发送给所有的 slave
- slave 在收到消息之后返回 ack 响应给 master
- master 在收到超过半数的 ack 之后,把消息标记为 committed
- 发送 committed 消息给所有 slave,slave 也修改状态为 committed
你知道 RocketMQ 为什么速度快吗?
是因为使用了顺序存储、Page Cache 和异步刷盘。
- 我们在写入 commitlog 的时候是顺序写入的,这样比随机写入的性能就会提高很多
- 写入 commitlog 的时候并不是直接写入磁盘,而是先写入操作系统的 PageCache
- 最后由操作系统异步将缓存中的数据刷到磁盘
什么是事务、半事务消息?怎么实现的?
事务消息就是 MQ 提供的类似 XA 的分布式事务能力,通过事务消息可以达到分布式事务的最终一致性。
半事务消息就是 MQ 收到了生产者的消息,但是没有收到二次确认,不能投递的消息。
实现原理如下:
- 生产者先发送一条半事务消息到 MQ
- MQ 收到消息后返回 ack 确认
- 生产者开始执行本地事务
- 如果事务执行成功发送 commit 到 MQ,失败发送 rollback
- 如果 MQ 长时间未收到生产者的二次确认 commit 或者 rollback,MQ 对生产者发起消息回查
- 生产者查询事务执行最终状态
- 根据查询事务状态再次提交二次确认
最终,如果 MQ 收到二次确认 commit,就可以把消息投递给消费者,反之如果是 rollback,消息会保存下来并且在 3 天后被删除。

Spring
说说 Spring 里用到了哪些设计模式?
单例模式
:Spring 中的 Bean 默认情况下都是单例的。无需多说。
工厂模式
:工厂模式主要是通过 BeanFactory 和 ApplicationContext 来生产 Bean 对象。
代理模式
:最常见的 AOP 的实现方式就是通过代理来实现,Spring 主要是使用 JDK 动态代理和 CGLIB 代理。
模板方法模式
:主要是一些对数据库操作的类用到,比如 JdbcTemplate、JpaTemplate,因为查询数据库的建立连接、执行查询、关闭连接几个过程,非常适用于模板方法。
谈谈你对 IOC 和 AOP 的理解?他们的实现原理是什么?
IOC 叫做控制反转,指的是通过 Spring 来管理对象的创建、配置和生命周期,这样相当于把控制权交给了 Spring,不需要人工来管理对象之间复杂的依赖关系,这样做的好处就是解耦。在 Spring 里面,主要提供了 BeanFactory 和 ApplicationContext 两种 IOC 容器,通过他们来实现对 Bean 的管理。
AOP 叫做面向切面编程,他是一个编程范式,目的就是提高代码的模块性。Srping AOP 基于动态代理的方式实现,如果是实现了接口的话就会使用 JDK 动态代理,反之则使用 CGLIB 代理,Spring 中 AOP 的应用主要体现在 事务、日志、异常处理等方面,通过在代码的前后做一些增强处理,可以实现对业务逻辑的隔离,提高代码的模块化能力,同时也是解耦。Spring 主要提供了 Aspect 切面、JoinPoint 连接点、PointCut 切入点、Advice 增强等实现方式。
JDK 动态代理和 CGLIB 代理有什么区别?
JDK 动态代理主要是针对类实现了某个接口,AOP 则会使用 JDK 动态代理。他基于反射的机制实现,生成一个实现同样接口的一个代理类,然后通过重写方法的方式,实现对代码的增强。
而如果某个类没有实现接口,AOP 则会使用 CGLIB 代理。他的底层原理是基于 asm 第三方框架,通过修改字节码生成成成一个子类,然后重写父类的方法,实现对代码的增强。
Spring AOP 和 AspectJ AOP 有什么区别?
Spring AOP 基于动态代理实现,属于运行时增强。
AspectJ 则属于编译时增强,主要有 3 种方式:
- 编译时织入:指的是增强的代码和源代码我们都有,直接使用 AspectJ 编译器编译就行了,编译之后生成一个新的类,他也会作为一个正常的 Java 类装载到 JVM。
- 编译后织入:指的是代码已经被编译成 class 文件或者已经打成 jar 包,这时候要增强的话,就是编译后织入,比如你依赖了第三方的类库,又想对他增强的话,就可以通过这种方式。

- 加载时织入:指的是在 JVM 加载类的时候进行织入。
总结下来的话,就是 Spring AOP 只能在运行时织入,不需要单独编译,性能相比 AspectJ 编译织入的方式慢,而 AspectJ 只支持编译前后和类加载时织入,性能更好,功能更加强大。
FactoryBean 和 BeanFactory 有什么区别?
BeanFactory 是 Bean 的工厂, ApplicationContext 的父类,IOC 容器的核心,负责生产和管理 Bean 对象。
FactoryBean 是 Bean,可以通过实现 FactoryBean 接口定制实例化 Bean 的逻辑,通过代理一个 Bean 对象,对方法前后做一些操作。
SpringBean 的生命周期说说?
SpringBean 生命周期简单概括为 4 个阶段:
- 实例化,创建一个 Bean 对象
- 填充属性,为属性赋值
- 初始化
- 如果实现了
xxxAware
接口,通过不同类型的 Aware 接口拿到 Spring 容器的资源 - 如果实现了 BeanPostProcessor 接口,则会回调该接口的
postProcessBeforeInitialzation
和postProcessAfterInitialization
方法 - 如果配置了
init-method
方法,则会执行init-method
配置的方法
- 销毁
- 容器关闭后,如果 Bean 实现了
DisposableBean
接口,则会回调该接口的destroy
方法 - 如果配置了
destroy-method
方法,则会执行destroy-method
配置的方法

Spring 是怎么解决循环依赖的?
首先,Spring 解决循环依赖有两个前提条件:
- 不全是构造器方式的循环依赖
- 必须是单例
基于上面的问题,我们知道 Bean 的生命周期,本质上解决循环依赖的问题就是三级缓存,通过三级缓存提前拿到未初始化的对象。
第一级缓存:用来保存实例化、初始化都完成的对象
第二级缓存:用来保存实例化完成,但是未初始化完成的对象
第三级缓存:用来保存一个对象工厂,提供一个匿名内部类,用于创建二级缓存中的对象

假设一个简单的循环依赖场景,A、B 互相依赖。

A 对象的创建过程:
- 创建对象 A,实例化的时候把 A 对象工厂放入三级缓存

- A 注入属性时,发现依赖 B,转而去实例化 B
- 同样创建对象 B,注入属性时发现依赖 A,一次从一级到三级缓存查询 A,从三级缓存通过对象工厂拿到 A,把 A 放入二级缓存,同时删除三级缓存中的 A,此时,B 已经实例化并且初始化完成,把 B 放入一级缓存。

- 接着继续创建 A,顺利从一级缓存拿到实例化且初始化完成的 B 对象,A 对象创建也完成,删除二级缓存中的 A,同时把 A 放入一级缓存
- 最后,一级缓存中保存着实例化、初始化都完成的 A、B 对象

因此,由于把实例化和初始化的流程分开了,所以如果都是用构造器的话,就没法分离这个操作,所以都是构造器的话就无法解决循环依赖的问题了。
为什么要三级缓存?二级不行吗?
不可以,主要是为了生成代理对象。
因为三级缓存中放的是生成具体对象的匿名内部类,他可以生成代理对象,也可以是普通的实例对象。
使用三级缓存主要是为了保证不管什么时候使用的都是一个对象。
假设只有二级缓存的情况,往二级缓存中放的显示一个普通的 Bean 对象,BeanPostProcessor
去生成代理对象之后,覆盖掉二级缓存中的普通 Bean 对象,那么多线程环境下可能取到的对象就不一致了。

Spring 事务传播机制有哪些?
- PROPAGATION_REQUIRED:如果当前没有事务,就创建一个新事务,如果当前存在事务,就加入该事务,这也是通常我们的默认选择。
- PROPAGATION_REQUIRES_NEW:创建新事务,无论当前存不存在事务,都创建新事务。
- PROPAGATION_NESTED:如果当前存在事务,则在嵌套事务内执行。如果当前没有事务,则按 REQUIRED 属性执行。
- PROPAGATION_NOT_SUPPORTED:以非事务方式执行操作,如果当前存在事务,就把当前事务挂起。
- PROPAGATION_NEVER:以非事务方式执行,如果当前存在事务,则抛出异常。
- PROPAGATION_MANDATORY:支持当前事务,如果当前存在事务,就加入该事务,如果当前不存在事务,就抛出异常。
- PROPAGATION_SUPPORTS:支持当前事务,如果当前存在事务,就加入该事务,如果当前不存在事务,就以非事务执行。‘
最后,说说 Spring Boot 启动流程吧?
这个流程,网上一搜基本都是这张图了,我也不想再画一遍了。那其实主要的流程就几个步骤:
- 准备环境,根据不同的环境创建不同的 Environment
- 准备、加载上下文,为不同的环境选择不同的 Spring Context,然后加载资源,配置 Bean
- 初始化,这个阶段刷新 Spring Context,启动应用
- 最后结束流程

Java 基础
说说进程和线程的区别?
进程是程序的一次执行,是系统进行资源分配和调度的独立单位,他的作用是是程序能够并发执行提高资源利用率和吞吐率。
由于进程是资源分配和调度的基本单位,因为进程的创建、销毁、切换产生大量的时间和空间的开销,进程的数量不能太多,而线程是比进程更小的能独立运行的基本单位,他是进程的一个实体,可以减少程序并发执行时的时间和空间开销,使得操作系统具有更好的并发性。
线程基本不拥有系统资源,只有一些运行时必不可少的资源,比如程序计数器、寄存器和栈,进程则占有堆、栈。
知道 synchronized 原理吗?
synchronized 是 java 提供的原子性内置锁,这种内置的并且使用者看不到的锁也被称为监视器锁,使用 synchronized 之后,会在编译之后在同步的代码块前后加上 monitorenter 和 monitorexit 字节码指令,他依赖操作系统底层互斥锁实现。他的作用主要就是实现原子性操作和解决共享变量的内存可见性问题。
执行 monitorenter 指令时会尝试获取对象锁,如果对象没有被锁定或者已经获得了锁,锁的计数器+1。此时其他竞争锁的线程则会进入等待队列中。
执行 monitorexit 指令时则会把计数器-1,当计数器值为 0 时,则锁释放,处于等待队列中的线程再继续竞争锁。
synchronized 是排它锁,当一个线程获得锁之后,其他线程必须等待该线程释放锁后才能获得锁,而且由于 Java 中的线程和操作系统原生线程是一一对应的,线程被阻塞或者唤醒时时会从用户态切换到内核态,这种转换非常消耗性能。
从内存语义来说,加锁的过程会清除工作内存中的共享变量,再从主内存读取,而释放锁的过程则是将工作内存中的共享变量写回主内存。
实际上大部分时候我认为说到 monitorenter 就行了,但是为了更清楚的描述,还是再具体一点。
如果再深入到源码来说,synchronized 实际上有两个队列 waitSet 和 entryList。
- 当多个线程进入同步代码块时,首先进入 entryList
- 有一个线程获取到 monitor 锁后,就赋值给当前线程,并且计数器+1
- 如果线程调用 wait 方法,将释放锁,当前线程置为 null,计数器-1,同时进入 waitSet 等待被唤醒,调用 notify 或者 notifyAll 之后又会进入 entryList 竞争锁
- 如果线程执行完毕,同样释放锁,计数器-1,当前线程置为 null

那锁的优化机制了解吗?
从 JDK1.6 版本之后,synchronized 本身也在不断优化锁的机制,有些情况下他并不会是一个很重量级的锁了。优化机制包括自适应锁、自旋锁、锁消除、锁粗化、轻量级锁和偏向锁。
锁的状态从低到高依次为无锁->偏向锁->轻量级锁->重量级锁,升级的过程就是从低到高,降级在一定条件也是有可能发生的。
自旋锁:由于大部分时候,锁被占用的时间很短,共享变量的锁定时间也很短,所有没有必要挂起线程,用户态和内核态的来回上下文切换严重影响性能。自旋的概念就是让线程执行一个忙循环,可以理解为就是啥也不干,防止从用户态转入内核态,自旋锁可以通过设置-XX:+UseSpining 来开启,自旋的默认次数是 10 次,可以使用-XX:PreBlockSpin 设置。
自适应锁:自适应锁就是自适应的自旋锁,自旋的时间不是固定时间,而是由前一次在同一个锁上的自旋时间和锁的持有者状态来决定。
锁消除:锁消除指的是 JVM 检测到一些同步的代码块,完全不存在数据竞争的场景,也就是不需要加锁,就会进行锁消除。
锁粗化:锁粗化指的是有很多操作都是对同一个对象进行加锁,就会把锁的同步范围扩展到整个操作序列之外。
偏向锁:当线程访问同步块获取锁时,会在对象头和栈帧中的锁记录里存储偏向锁的线程 ID,之后这个线程再次进入同步块时都不需要 CAS 来加锁和解锁了,偏向锁会永远偏向第一个获得锁的线程,如果后续没有其他线程获得过这个锁,持有锁的线程就永远不需要进行同步,反之,当有其他线程竞争偏向锁时,持有偏向锁的线程就会释放偏向锁。可以用过设置-XX:+UseBiasedLocking 开启偏向锁。
轻量级锁:JVM 的对象的对象头中包含有一些锁的标志位,代码进入同步块的时候,JVM 将会使用 CAS 方式来尝试获取锁,如果更新成功则会把对象头中的状态位标记为轻量级锁,如果更新失败,当前线程就尝试自旋来获得锁。
整个锁升级的过程非常复杂,我尽力去除一些无用的环节,简单来描述整个升级的机制。
简单点说,偏向锁就是通过对象头的偏向线程 ID 来对比,甚至都不需要 CAS 了,而轻量级锁主要就是通过 CAS 修改对象头锁记录和自旋来实现,重量级锁则是除了拥有锁的线程其他全部阻塞。

那对象头具体都包含哪些内容?
在我们常用的 Hotspot 虚拟机中,对象在内存中布局实际包含 3 个部分:
- 对象头
- 实例数据
- 对齐填充
而对象头包含两部分内容,Mark Word 中的内容会随着锁标志位而发生变化,所以只说存储结构就好了。
- 对象自身运行时所需的数据,也被称为 Mark Word,也就是用于轻量级锁和偏向锁的关键点。具体的内容包含对象的 hashcode、分代年龄、轻量级锁指针、重量级锁指针、GC 标记、偏向锁线程 ID、偏向锁时间戳。
- 存储类型指针,也就是指向类的元数据的指针,通过这个指针才能确定对象是属于哪个类的实例。
如果是数组的话,则还包含了数组的长度

对于加锁,那再说下 ReentrantLock 原理?他和 synchronized 有什么区别?
相比于 synchronized,ReentrantLock 需要显式的获取锁和释放锁,相对现在基本都是用 JDK7 和 JDK8 的版本,ReentrantLock 的效率和 synchronized 区别基本可以持平了。他们的主要区别有以下几点:
- 等待可中断,当持有锁的线程长时间不释放锁的时候,等待中的线程可以选择放弃等待,转而处理其他的任务。
- 公平锁:synchronized 和 ReentrantLock 默认都是非公平锁,但是 ReentrantLock 可以通过构造函数传参改变。只不过使用公平锁的话会导致性能急剧下降。
- 绑定多个条件:ReentrantLock 可以同时绑定多个 Condition 条件对象。
ReentrantLock 基于 AQS(AbstractQueuedSynchronizer 抽象队列同步器)实现。别说了,我知道问题了,AQS 原理我来讲。
AQS 内部维护一个 state 状态位,尝试加锁的时候通过 CAS(CompareAndSwap)修改值,如果成功设置为 1,并且把当前线程 ID 赋值,则代表加锁成功,一旦获取到锁,其他的线程将会被阻塞进入阻塞队列自旋,获得锁的线程释放锁的时候将会唤醒阻塞队列中的线程,释放锁的时候则会把 state 重新置为 0,同时当前线程 ID 置为空。

CAS 的原理呢?
CAS 叫做 CompareAndSwap,比较并交换,主要是通过处理器的指令来保证操作的原子性,它包含三个操作数:
- 变量内存地址,V 表示
- 旧的预期值,A 表示
- 准备设置的新值,B 表示
当执行 CAS 指令时,只有当 V 等于 A 时,才会用 B 去更新 V 的值,否则就不会执行更新操作。
那么 CAS 有什么缺点吗?
CAS 的缺点主要有 3 点:
ABA 问题:ABA 的问题指的是在 CAS 更新的过程中,当读取到的值是 A,然后准备赋值的时候仍然是 A,但是实际上有可能 A 的值被改成了 B,然后又被改回了 A,这个 CAS 更新的漏洞就叫做 ABA。只是 ABA 的问题大部分场景下都不影响并发的最终效果。
Java 中有 AtomicStampedReference 来解决这个问题,他加入了预期标志和更新后标志两个字段,更新时不光检查值,还要检查当前的标志是否等于预期标志,全部相等的话才会更新。
循环时间长开销大:自旋 CAS 的方式如果长时间不成功,会给 CPU 带来很大的开销。
只能保证一个共享变量的原子操作:只对一个共享变量操作可以保证原子性,但是多个则不行,多个可以通过 AtomicReference 来处理或者使用锁 synchronized 实现。
好,说说 HashMap 原理吧?
HashMap 主要由数组和链表组成,他不是线程安全的。核心的点就是 put 插入数据的过程,get 查询数据以及扩容的方式。JDK1.7 和 1.8 的主要区别在于头插和尾插方式的修改,头插容易导致 HashMap 链表死循环,并且 1.8 之后加入红黑树对性能有提升。
put 插入数据流程
往 map 插入元素的时候首先通过对 key hash 然后与数组长度-1 进行与运算((n-1)&hash),都是 2 的次幂所以等同于取模,但是位运算的效率更高。找到数组中的位置之后,如果数组中没有元素直接存入,反之则判断 key 是否相同,key 相同就覆盖,否则就会插入到链表的尾部,如果链表的长度超过 8,则会转换成红黑树,最后判断数组长度是否超过默认的长度*负载因子也就是 12,超过则进行扩容。

get 查询数据
查询数据相对来说就比较简单了,首先计算出 hash 值,然后去数组查询,是红黑树就去红黑树查,链表就遍历链表查询就可以了。
resize 扩容过程
扩容的过程就是对 key 重新计算 hash,然后把数据拷贝到新的数组。
那多线程环境怎么使用 Map 呢?ConcurrentHashmap 了解过吗?
多线程环境可以使用 Collections.synchronizedMap 同步加锁的方式,还可以使用 HashTable,但是同步的方式显然性能不达标,而 ConurrentHashMap 更适合高并发场景使用。
ConcurrentHashmap 在 JDK1.7 和 1.8 的版本改动比较大,1.7 使用 Segment+HashEntry 分段锁的方式实现,1.8 则抛弃了 Segment,改为使用 CAS+synchronized+Node 实现,同样也加入了红黑树,避免链表过长导致性能的问题。
1.7 分段锁
从结构上说,1.7 版本的 ConcurrentHashMap 采用分段锁机制,里面包含一个 Segment 数组,Segment 继承与 ReentrantLock,Segment 则包含 HashEntry 的数组,HashEntry 本身就是一个链表的结构,具有保存 key、value 的能力能指向下一个节点的指针。
实际上就是相当于每个 Segment 都是一个 HashMap,默认的 Segment 长度是 16,也就是支持 16 个线程的并发写,Segment 之间相互不会受到影响。

put 流程
其实发现整个流程和 HashMap 非常类似,只不过是先定位到具体的 Segment,然后通过 ReentrantLock 去操作而已,后面的流程我就简化了,因为和 HashMap 基本上是一样的。
- 计算 hash,定位到 segment,segment 如果是空就先初始化
- 使用 ReentrantLock 加锁,如果获取锁失败则尝试自旋,自旋超过次数就阻塞获取,保证一定获取锁成功
- 遍历 HashEntry,就是和 HashMap 一样,数组中 key 和 hash 一样就直接替换,不存在就再插入链表,链表同样

get 流程
get 也很简单,key 通过 hash 定位到 segment,再遍历链表定位到具体的元素上,需要注意的是 value 是 volatile 的,所以 get 是不需要加锁的。
1.8CAS+synchronized
1.8 抛弃分段锁,转为用 CAS+synchronized 来实现,同样 HashEntry 改为 Node,也加入了红黑树的实现。主要还是看 put 的流程。

put 流程
- 首先计算 hash,遍历 node 数组,如果 node 是空的话,就通过 CAS+自旋的方式初始化
- 如果当前数组位置是空则直接通过 CAS 自旋写入数据
- 如果 hash==MOVED,说明需要扩容,执行扩容
- 如果都不满足,就使用 synchronized 写入数据,写入数据同样判断链表、红黑树,链表写入和 HashMap 的方式一样,key hash 一样就覆盖,反之就尾插法,链表长度超过 8 就转换成红黑树

get 查询
get 很简单,通过 key 计算 hash,如果 key hash 相同就返回,如果是红黑树按照红黑树获取,都不是就遍历链表获取。
volatile 原理知道吗?
相比 synchronized 的加锁方式来解决共享变量的内存可见性问题,volatile 就是更轻量的选择,他没有上下文切换的额外开销成本。使用 volatile 声明的变量,可以确保值被更新的时候对其他线程立刻可见。volatile 使用内存屏障来保证不会发生指令重排,解决了内存可见性的问题。
我们知道,线程都是从主内存中读取共享变量到工作内存来操作,完成之后再把结果写会主内存,但是这样就会带来可见性问题。举个例子,假设现在我们是两级缓存的双核 CPU 架构,包含 L1、L2 两级缓存。
- 线程 A 首先获取变量 X 的值,由于最初两级缓存都是空,所以直接从主内存中读取 X,假设 X 初始值为 0,线程 A 读取之后把 X 值都修改为 1,同时写回主内存。这时候缓存和主内存的情况如下图。

- 线程 B 也同样读取变量 X 的值,由于 L2 缓存已经有缓存 X=1,所以直接从 L2 缓存读取,之后线程 B 把 X 修改为 2,同时写回 L2 和主内存。这时候的 X 值入下图所示。
- 那么线程 A 如果再想获取变量 X 的值,因为 L1 缓存已经有 x=1 了,所以这时候变量内存不可见问题就产生了,B 修改为 2 的值对 A 来说没有感知。

那么,如果 X 变量用 volatile 修饰的话,当线程 A 再次读取变量 X 的话,CPU 就会根据缓存一致性协议强制线程 A 重新从主内存加载最新的值到自己的工作内存,而不是直接用缓存中的值。
再来说内存屏障的问题,volatile 修饰之后会加入不同的内存屏障来保证可见性的问题能正确执行。这里写的屏障基于书中提供的内容,但是实际上由于 CPU 架构不同,重排序的策略不同,提供的内存屏障也不一样,比如 x86 平台上,只有 StoreLoad 一种内存屏障。
- StoreStore 屏障,保证上面的普通写不和 volatile 写发生重排序
- StoreLoad 屏障,保证 volatile 写与后面可能的 volatile 读写不发生重排序
- LoadLoad 屏障,禁止 volatile 读与后面的普通读重排序
- LoadStore 屏障,禁止 volatile 读和后面的普通写重排序

那么说说你对 JMM 内存模型的理解?为什么需要 JMM?
本身随着 CPU 和内存的发展速度差异的问题,导致 CPU 的速度远快于内存,所以现在的 CPU 加入了高速缓存,高速缓存一般可以分为 L1、L2、L3 三级缓存。基于上面的例子我们知道了这导致了缓存一致性的问题,所以加入了缓存一致性协议,同时导致了内存可见性的问题,而编译器和 CPU 的重排序导致了原子性和有序性的问题,JMM 内存模型正是对多线程操作下的一系列规范约束,因为不可能让陈雇员的代码去兼容所有的 CPU,通过 JMM 我们才屏蔽了不同硬件和操作系统内存的访问差异,这样保证了 Java 程序在不同的平台下达到一致的内存访问效果,同时也是保证在高效并发的时候程序能够正确执行。

原子性:Java 内存模型通过 read、load、assign、use、store、write 来保证原子性操作,此外还有 lock 和 unlock,直接对应着 synchronized 关键字的 monitorenter 和 monitorexit 字节码指令。
可见性:可见性的问题在上面的回答已经说过,Java 保证可见性可以认为通过 volatile、synchronized、final 来实现。
有序性:由于处理器和编译器的重排序导致的有序性问题,Java 通过 volatile、synchronized 来保证。
happen-before 规则
虽然指令重排提高了并发的性能,但是 Java 虚拟机会对指令重排做出一些规则限制,并不能让所有的指令都随意的改变执行位置,主要有以下几点:
- 单线程每个操作,happen-before 于该线程中任意后续操作
- volatile 写 happen-before 与后续对这个变量的读
- synchronized 解锁 happen-before 后续对这个锁的加锁
- final 变量的写 happen-before 于 final 域对象的读,happen-before 后续对 final 变量的读
- 传递性规则,A 先于 B,B 先于 C,那么 A 一定先于 C 发生
说了半天,到底工作内存和主内存是什么?
主内存可以认为就是物理内存,Java 内存模型中实际就是虚拟机内存的一部分。而工作内存就是 CPU 缓存,他有可能是寄存器也有可能是 L1\L2\L3 缓存,都是有可能的。
说说 ThreadLocal 原理?
ThreadLocal 可以理解为线程本地变量,他会在每个线程都创建一个副本,那么在线程之间访问内部副本变量就行了,做到了线程之间互相隔离,相比于 synchronized 的做法是用空间来换时间。
ThreadLocal 有一个静态内部类 ThreadLocalMap,ThreadLocalMap 又包含了一个 Entry 数组,Entry 本身是一个弱引用,他的 key 是指向 ThreadLocal 的弱引用,Entry 具备了保存 key value 键值对的能力。
弱引用的目的是为了防止内存泄露,如果是强引用那么 ThreadLocal 对象除非线程结束否则始终无法被回收,弱引用则会在下一次 GC 的时候被回收。
但是这样还是会存在内存泄露的问题,假如 key 和 ThreadLocal 对象被回收之后,entry 中就存在 key 为 null,但是 value 有值的 entry 对象,但是永远没办法被访问到,同样除非线程结束运行。
但是只要 ThreadLocal 使用恰当,在使用完之后调用 remove 方法删除 Entry 对象,实际上是不会出现这个问题的。

那引用类型有哪些?有什么区别?
引用类型主要分为强软弱虚四种:
- 强引用指的就是代码中普遍存在的赋值方式,比如 A a = new A()这种。强引用关联的对象,永远不会被 GC 回收。
- 软引用可以用 SoftReference 来描述,指的是那些有用但是不是必须要的对象。系统在发生内存溢出前会对这类引用的对象进行回收。
- 弱引用可以用 WeakReference 来描述,他的强度比软引用更低一点,弱引用的对象下一次 GC 的时候一定会被回收,而不管内存是否足够。
- 虚引用也被称作幻影引用,是最弱的引用关系,可以用 PhantomReference 来描述,他必须和 ReferenceQueue 一起使用,同样的当发生 GC 的时候,虚引用也会被回收。可以用虚引用来管理堆外内存。
线程池原理知道吗?
首先线程池有几个核心的参数概念:
- 最大线程数 maximumPoolSize
- 核心线程数 corePoolSize
- 活跃时间 keepAliveTime
- 阻塞队列 workQueue
- 拒绝策略 RejectedExecutionHandler
当提交一个新任务到线程池时,具体的执行流程如下:
- 当我们提交任务,线程池会根据 corePoolSize 大小创建若干任务数量线程执行任务
- 当任务的数量超过 corePoolSize 数量,后续的任务将会进入阻塞队列阻塞排队
- 当阻塞队列也满了之后,那么将会继续创建(maximumPoolSize-corePoolSize)个数量的线程来执行任务,如果任务处理完成,maximumPoolSize-corePoolSize 额外创建的线程等待 keepAliveTime 之后被自动销毁
- 如果达到 maximumPoolSize,阻塞队列还是满的状态,那么将根据不同的拒绝策略对应处理

拒绝策略有哪些?
主要有 4 种拒绝策略:
- AbortPolicy:直接丢弃任务,抛出异常,这是默认策略
- CallerRunsPolicy:只用调用者所在的线程来处理任务
- DiscardOldestPolicy:丢弃等待队列中最旧的任务,并执行当前任务
- DiscardPolicy:直接丢弃任务,也不抛出异常
多线程&并发
CountDownLatch
CountDownLatch 适用于在多线程的场景需要等待所有子线程全部执行完毕之后再做操作的场景。
举个例子,早上部门开会,有人在上厕所,这时候需要等待所有人从厕所回来之后才能开始会议。
public class CountDownLatchTest { private static int num = 3; private static CountDownLatch countDownLatch = new CountDownLatch(num); private static ExecutorService executorService = Executors.newFixedThreadPool(num); public static void main(String[] args) throws Exception{ executorService.submit(() -> { System.out.println("A 在上厕所"); try { Thread.sleep(4000); } catch (InterruptedException e) { e.printStackTrace(); }finally { countDownLatch.countDown(); System.out.println("A 上完了"); } }); executorService.submit(()->{ System.out.println("B 在上厕所"); try { Thread.sleep(2000); } catch (InterruptedException e) { e.printStackTrace(); }finally { countDownLatch.countDown(); System.out.println("B 上完了"); } }); executorService.submit(()->{ System.out.println("C 在上厕所"); try { Thread.sleep(3000); } catch (InterruptedException e) { e.printStackTrace(); }finally { countDownLatch.countDown(); System.out.println("C 上完了"); } }); System.out.println("等待所有人从厕所回来开会..."); countDownLatch.await(); System.out.println("所有人都好了,开始开会..."); executorService.shutdown(); } }
代码执行结果:
A 在上厕所 B 在上厕所 等待所有人从厕所回来开会... C 在上厕所 B 上完了 C 上完了 A 上完了 所有人都好了,开始开会...
初始化一个 CountDownLatch 实例传参 3,因为我们有 3 个子线程,每次子线程执行完毕之后调用 countDown()方法给计数器-1,主线程调用 await()方法后会被阻塞,直到最后计数器变为 0,await()方法返回,执行完毕。他和 join()方法的区别就是 join 会阻塞子线程直到运行结束,而 CountDownLatch 可以在任何时候让 await()返回,而且用 ExecutorService 没法用 join 了,相比起来,CountDownLatch 更灵活。
CountDownLatch 基于 AQS 实现,volatile 变量 state 维持倒数状态,多线程共享变量可见。
- CountDownLatch 通过构造函数初始化传入参数实际为 AQS 的 state 变量赋值,维持计数器倒数状态
- 当主线程调用 await()方法时,当前线程会被阻塞,当 state 不为 0 时进入 AQS 阻塞队列等待。
- 其他线程调用 countDown()时,state 值原子性递减,当 state 值为 0 的时候,唤醒所有调用 await()方法阻塞的线程
CyclicBarrier
CyclicBarrier 叫做回环屏障,它的作用是让一组线程全部达到一个状态之后再全部同时执行,而且他有一个特点就是所有线程执行完毕之后是可以重用的。
public class CyclicBarrierTest { private static int num = 3; private static CyclicBarrier cyclicBarrier = new CyclicBarrier(num, () -> { System.out.println("所有人都好了,开始开会..."); System.out.println("-------------------"); }); private static ExecutorService executorService = Executors.newFixedThreadPool(num); public static void main(String[] args) throws Exception{ executorService.submit(() -> { System.out.println("A 在上厕所"); try { Thread.sleep(4000); System.out.println("A 上完了"); cyclicBarrier.await(); System.out.println("会议结束,A 退出"); } catch (Exception e) { e.printStackTrace(); }finally { } }); executorService.submit(()->{ System.out.println("B 在上厕所"); try { Thread.sleep(2000); System.out.println("B 上完了"); cyclicBarrier.await(); System.out.println("会议结束,B 退出"); } catch (Exception e) { e.printStackTrace(); }finally { } }); executorService.submit(()->{ System.out.println("C 在上厕所"); try { Thread.sleep(3000); System.out.println("C 上完了"); cyclicBarrier.await(); System.out.println("会议结束,C 退出"); } catch (Exception e) { e.printStackTrace(); }finally { } }); executorService.shutdown(); } }
输出结果为:
A 在上厕所 B 在上厕所 C 在上厕所 B 上完了 C 上完了 A 上完了 所有人都好了,开始开会... ------------------- 会议结束,A 退出 会议结束,B 退出 会议结束,C 退出
从结果来看和 CountDownLatch 非常相似,初始化传入 3 个线程和一个任务,线程调用 await()之后进入阻塞,计数器-1,当计数器为 0 时,就去执行 CyclicBarrier 中构造函数的任务,当任务执行完毕后,唤醒所有阻塞中的线程。这验证了 CyclicBarrier让一组线程全部达到一个状态之后再全部同时执行的效果。
再举个例子来验证 CyclicBarrier 可重用的效果。
public class CyclicBarrierTest2 { private static int num = 3; private static CyclicBarrier cyclicBarrier = new CyclicBarrier(num, () -> { System.out.println("-------------------"); }); private static ExecutorService executorService = Executors.newFixedThreadPool(num); public static void main(String[] args) throws Exception { executorService.submit(() -> { System.out.println("A 在上厕所"); try { Thread.sleep(4000); System.out.println("A 上完了"); cyclicBarrier.await(); System.out.println("会议结束,A 退出,开始撸代码"); cyclicBarrier.await(); System.out.println("C 工作结束,下班回家"); cyclicBarrier.await(); } catch (Exception e) { e.printStackTrace(); } finally { } }); executorService.submit(() -> { System.out.println("B 在上厕所"); try { Thread.sleep(2000); System.out.println("B 上完了"); cyclicBarrier.await(); System.out.println("会议结束,B 退出,开始摸鱼"); cyclicBarrier.await(); System.out.println("B 摸鱼结束,下班回家"); cyclicBarrier.await(); } catch (Exception e) { e.printStackTrace(); } finally { } }); executorService.submit(() -> { System.out.println("C 在上厕所"); try { Thread.sleep(3000); System.out.println("C 上完了"); cyclicBarrier.await(); System.out.println("会议结束,C 退出,开始摸鱼"); cyclicBarrier.await(); System.out.println("C 摸鱼结束,下班回家"); cyclicBarrier.await(); } catch (Exception e) { e.printStackTrace(); } finally { } }); executorService.shutdown(); } }
输出结果:
A 在上厕所 B 在上厕所 C 在上厕所 B 上完了 C 上完了 A 上完了 ------------------- 会议结束,A 退出,开始撸代码 会议结束,B 退出,开始摸鱼 会议结束,C 退出,开始摸鱼 ------------------- C 摸鱼结束,下班回家 C 工作结束,下班回家 B 摸鱼结束,下班回家 -------------------
从结果来看,每个子线程调用 await()计数器减为 0 之后才开始继续一起往下执行,会议结束之后一起进入摸鱼状态,最后一天结束一起下班,这就是可重用。
CyclicBarrier 还是基于 AQS 实现的,内部维护 parties 记录总线程数,count 用于计数,最开始 count=parties,调用 await()之后 count 原子递减,当 count 为 0 之后,再次将 parties 赋值给 count,这就是复用的原理。
- 当子线程调用 await()方法时,获取独占锁,同时对 count 递减,进入阻塞队列,然后释放锁
- 当第一个线程被阻塞同时释放锁之后,其他子线程竞争获取锁,操作同 1
- 直到最后 count 为 0,执行 CyclicBarrier 构造函数中的任务,执行完毕之后子线程继续向下执行
Semaphore
Semaphore 叫做信号量,和前面两个不同的是,他的计数器是递增的。
public class SemaphoreTest { private static int num = 3; private static int initNum = 0; private static Semaphore semaphore = new Semaphore(initNum); private static ExecutorService executorService = Executors.newFixedThreadPool(num); public static void main(String[] args) throws Exception{ executorService.submit(() -> { System.out.println("A 在上厕所"); try { Thread.sleep(4000); semaphore.release(); System.out.println("A 上完了"); } catch (Exception e) { e.printStackTrace(); }finally { } }); executorService.submit(()->{ System.out.println("B 在上厕所"); try { Thread.sleep(2000); semaphore.release(); System.out.println("B 上完了"); } catch (Exception e) { e.printStackTrace(); }finally { } }); executorService.submit(()->{ System.out.println("C 在上厕所"); try { Thread.sleep(3000); semaphore.release(); System.out.println("C 上完了"); } catch (Exception e) { e.printStackTrace(); }finally { } }); System.out.println("等待所有人从厕所回来开会..."); semaphore.acquire(num); System.out.println("所有人都好了,开始开会..."); executorService.shutdown(); } }
输出结果为:
A 在上厕所 B 在上厕所 等待所有人从厕所回来开会... C 在上厕所 B 上完了 C 上完了 A 上完了 所有人都好了,开始开会...
稍微和前两个有点区别,构造函数传入的初始值为 0,当子线程调用 release()方法时,计数器递增,主线程 acquire()传参为 3 则说明主线程一直阻塞,直到计数器为 3 才会返回。
Semaphore 还还还是基于 AQS 实现的,同时获取信号量有公平和非公平两种策略
- 主线程调用 acquire()方法时,用当前信号量值-需要获取的值,如果小于 0,则进入同步阻塞队列,大于 0 则通过 CAS 设置当前信号量为剩余值,同时返回剩余值
- 子线程调用 release()给当前信号量值计数器+1(增加的值数量由传参决定),同时不停的尝试因为调用 acquire()进入阻塞的线程
由于篇幅原因,文章被分为上下两篇。详见《我想进大厂》面试题总结大全(下)