# 💬 面经
# 八股
# Mysql
MySQL 的 NULL 值是怎么存放的?
🙋🏻♂️ 回答:
MySQL 的 Compact 行格式中会用「NULL值列表」来标记值为 NULL 的列,NULL 值并不会存储在行格式中的真实数据部分。
「NULL值列表」会占用 1 字节空间,当表中所有字段都定义成 NOT NULL,行格式中就不会有「NULL值列表」 1 字节的空间。
🎯 要点:
- 独占表空间文件
*.ibd
- 表空间 (Tablespace) → 段 (Segment) → 区 (Extent) → 页 (Page, 16KB) → 行 (row)
- Mysql 行格式
- 类型
- Compact (MySQL 5.1 版本之后,行格式默认设置成 Compact)
- Compressed
- Dynamic (MySQL5.7 版本之后,默认使用 Dynamic 行格式)
- Compact = 「变长字段长度列表」+「NULL值列表」+「记录头信息」+「真实数据」
- 类型
MySQL 怎么知道 varchar(n) 实际占用数据的大小?
🙋🏻♂️ 回答:
MySQL 的 Compact 行格式中会用「变长字段长度列表」存储变长字段实际占用的数据大小。
varchar(n) 中 n 最大取值为多少?
🙋🏻♂️ 回答:
我们需要在建表时根据表字段的数量和类型来计算 n
的最大取值。
因为一行记录最大能存储 65535
字节的数据,这个是包含「变长字段字节数列表」和「NULL值列表」所占用的字节数。所以, 我们在算 varchar(n)
中 n
最大值时,需要减去这两个列表所占用的字节数。
假设,如果一张表只有一个 varchar(n)
字段,且允许为 NULL
,字符集为 ascii
。varchar(n)
中 n
最大取值为 65532。
计算公式:65535 - 「变长字段字节数列表」所占用的字节数 - 「NULL值列表」所占用的字节数 = 65535 - 2 - 1 = 65532
。
如果有多个字段的话,要保证 所有字段的长度 + 变长字段字节数列表所占用的字节数 + NULL值列表所占用的字节数 ≤ 65535
。
行溢出后,MySQL 是怎么处理的?
🙋🏻♂️ 回答:
如果一个数据页存不了一条记录,InnoDB 存储引擎会自动将溢出的数据存放到「溢出页」中。
- Compact 行格式针对行溢出的处理是这样的:当发生行溢出时,在记录的真实数据处只会保存该列的一部分数据,而把剩余的数据放在「溢出页」中,然后真实数据处用 20 字节 存储指向溢出页的地址,从而可以找到剩余数据所在的页。
- Compressed 和 Dynamic 这两种格式采用完全的行溢出方式,记录的真实数据处不会存储该列的一部分数据,只存储 20 个字节的指针来指向溢出页。而实际的数据都存储在溢出页中。
事务的隔离级别有哪些?
🙋🏻♂️ 回答:
SQL 标准提出了四种隔离级别来规避这些现象,隔离级别越高,性能效率就越低,这四个隔离级别如下:
- 读未提交(read uncommitted),指一个事务还没提交时,它做的变更就能被其他事务看到;
- 读提交(read committed),指一个事务提交之后,它做的变更才能被其他事务看到;
- 可重复读(repeatable read),指一个事务执行过程中看到的数据,一直跟这个事务启动时看到的数据是一致的,MySQL InnoDB 引擎的默认隔离级别;
- 串行化(serializable );会对记录加上读写锁,在多个事务对这条记录进行读写操作时,如果发生了读写冲突的时候,后访问的事务必须等前一个事务执行完成,才能继续执行;
这四种隔离级别是用来规避并发事务引发的问题 (脏读、不可重复读、幻读) 的,针对不同隔离级别,并发事务时可能发生的现象也会不同。

如上图所示:
- 在「读未提交」隔离级别下,可能发生脏读、不可重复读和幻读现象;
- 在「读提交」隔离级别下,可能发生不可重复读和幻读现象,但是不可能发生脏读现象;
- 在「可重复读」隔离级别下,可能发生幻读现象,但是不可能脏读和不可重复读现象;
- 在「串行化」隔离级别下,脏读、不可重复读和幻读现象都不可能会发生。
MySQL事务的实现原理是什么?
🙋🏻♂️ 回答:
MySQL事务的实现原理,包括事务的ACID特性、MVCC机制、行锁和表锁的使用、undo log和radio log的作用等。通过保证原子性、一致性、隔离性和持久性,实现了事务的正确性和数据的一致性。视频还提到了一些设计思想,如利用内存缓冲区的方式优化磁盘I0性能等。对于准备面试的同学来说,这个视频提供了很好的参考和学习资源。
来源:https://www.bilibili.com/video/BV1am421L7Co
# 面经
# 饿了么 - 2024.04.10
说说 OAuthn2 ?
OAuth2 → Open Authorization 网络授权开放标准,允许三方系统在不使用用户名和密码的情况下,访问另一个服务的资源。
OAuth2 组成:
- 用户
- 用户代理(浏览器)
- 授权服务器(如百度)
- 资源服务器
OAuth2 模式:
- 授权码模式
- 用户 → 用户代理 → 三方应用(微信扫描登录)
- 三方应用 → 授权服务器 → 授权页面
- 用户点击“确认授权” → 三方授权服务器拿到授权码
- 三方应用根据授权码 → 授权服务器 → 得到用户登录的 Token(用户令牌)
- 三方应用根据 Token 去资源服务器拉取资源。
- 资源服务器验证 Token 真伪,将资源给三方服务器。
- 简单模式:简化授权码模式,用户确认授权之后,直接获取到 Token(而不是先获取授权码,再获取 Token)。
- 密码模式:用户高度信任三方平台,将资源服务器的用户名和密码存储在三方平台,然后由三方平台根据用户名和密码访问资源服务器,获取资源。
- 客户端模式:三方系统直接和资源服务器进行通讯。
说说 JWT ?
JWT → JSON Web Token 开放流程定义的方式,用于网络之间安全地传输信息,例如用户验证、授权、信息交换等。
JWT 组成:
- Header → 存储加密算法
- Payload(负载信息) → 用户信息
- Signature(签名) → 加密字符(效验 Token 有效性)
JWT 流程:
- 用户将用户名和密码传到后台服务器;
- 后台服务器验证用户名和密码 → JWT 算法生成 Token (包含上述3部分内容) → 发送给前端
- 前端将 JWT Token 以 Header 的形式传递给后端
- 后端拿到 JWT Token 进行效验:加密算法 + JWT 服务器私钥 + Payload = 生成 Signature。如果 生成的 Signature 与 JWT Token Signature 相等,说明这是一个有效的登录(用户信息 Payload)
注:JWT 用于企业内部,OAuth 用于外部系统的互相调用
创建订单前流程?
创建订单前流程:
- 幂等性问题
- 效验用户权限(用户有没有登录)
- 用户是否支付(购买订单)
- 库存是否足够
- 其他信息判断(如:配送信息等)
# 阿里巴巴(2) - 2024.04.13
你知道跳表吗?
知道。 挑拨(跳跃表 SkipList)最常用的场景是 Redis 里面 ZSet 的底层实现。 跳表本质为多个链表。
Redis 基本数据结构有了解吗
Redis 五大基本数据结构:
- String -> 简单动态字符串
- Hash -> 哈希表
- List -> 双向链表
- Set -> 哈希表 + 数组
- ZSet -> 压缩表(ZipList) / 紧凑列表(ListPack) + 跳跃表(SkipList)
你介绍一下 MVCC ?
MVCC 多版本并发控制 -> 为 Mysql 快照读 提供数据以及一套规则来让事务实现可重复读和解决大部分幻读(快照读的幻读问题)
MVCC 核心原理:
- undo log(表数据 +
trx_id
+ 下一版数据指针):数据的历史版本 - ReadView
参考:
说说 Spring 中的 IoC?
IoC -> (对象)控制(权)反转。 IoC 实现 -> DI, 依赖注入。 IoC 原理:反射机制(在程序运行期间,动态地获取和操作类的机制)
说说 Spring 中的 AOP?
回答思路:定义、使用、作用、底层实现
定义:AOP 面向切面编程 -> 将某一类问题集中处理。 AOP 实现/使用:
- Step1. 添加AOP依赖。
- Step2. 定义切面 ->
@Aspect
- Step3. 定义切点(配置拦截规则) ->
Pointcut
- Step1. 定义执行动作 ->
Advice
前置通知/后置通知/环绕通知
Spring 原理:动态代理
- JDK Proxy
- CGLib (SpringBoot 默认用)
说说 SpringBoot 自动配置原理?
SpringBoot 自动装配流程:
- 运行添加
@SpringBootApplication
类,main
方法 - 加载自动装配地类清单:SpringBoot jar
spring.factories
文件 - 查看自动装配类地装配条件:
@Configuration
+@Condition
RPC 了解吗?
RPC 远程过程调用:像调用本地方法一样来调用远程方法机制。
RPC 特点:
- 执行高效(偏底层,自定义协议,对数据进行压缩...)
- 跨平台(win 调用 linux ...)
- 跨语言
RPC 使用场景:
- 分布式系统调用
- 微服务之间的调用
- 云计算(RPC 实现客户端和云服务器之间的调用)
说说拥塞控制?
拥塞控制是 TCP 特性之一,保证 TCP 在高负载网络环境下,数据可以平台传输的一种机制。
简单来说,拥塞控制根据当前网络情况,决定发送消息的速度的一种机制。
拥塞控制的实现:
- 慢启动
- 拥塞控制(门限值)
- 快重试
- 快恢复
我看你在项目中使用了 RabbitMQ,那你知道什么情况下会使用消息中间件呢?
RabbitMQ 使用场景:
- 解耦系统
- 异步通信
- 削峰填谷(消费者按照我定义的速度均匀消费)
- 大数据下的日志处理
- 消息通知和广播
RabbitMQ 如何避免消息丢失?
RabbitMQ 避免消息丢失:
- 持久化
- 集群部署
- 消息确认
- 生产者消息确认
- 消费者消息确认
平时有用过一些设计模式吗?
常见的设计模式:
- 单例模式:Spring/SpringBoot-Bean
- 工厂模式:线程池使用线程共产创建
- 代理模式:Spring AOP
- 发布-订阅模式:MQ
- 观察者模式:Spring Event
- 策略模式:支付渠道(微信/支付宝/...)
- 责任链模式:拦截器链/过滤器链(参数效验、登录状态效验、权限效验、...)
- 门面模式/适配器模式:我们只操作
slf4j
->slf4j
对接操作log4j
、logback
。前者是门面,后者是适配器
怎么样进行性能优化?
常见优化手段:
- 程序优化
- 并发编程
- 单例模式(不要重复创建)
- 数据库优化
- 优化索引使用
- Mysql 集群模式
- 主从默认
- 数据分片模式 -> 分库分表
- 使用大数据数据库,TiDB
- 架构优化
- 单机 -> 分布式/微服务
- JVM 优化
- JVM 参数优化(堆多大、元空间多大、...)
- JVM 垃圾收集器的选择
- 使用多级缓存
- 本地缓存
- 分布式缓存
- 浏览器缓存
- CDN 缓存
- Nginx 缓存
- 其他优化:
- 硬件优化
- 网络优化
- 程序异步处理
你觉得 java 未来的就业前景怎么样?
AI加持、需求是否更改、...
思路:
- 独立思考、讲现状
- 积极的
- 开放心态
- 拥抱变化的心态
- 展现热爱技术的特质
# 滴滴一面 - 2024.04.16
4. 说说 TCP 为什么需要四次挥手?
5.
# 滴滴二面 - 2024.04.18
1. 介绍一下 MySQL 的索引
2. 联合索引(a,b,c)使用 b>=xxx and a = x 会使用联合索引吗?
3. 介绍一下 MySQL 的日志
4. redo log 怎么保持持久性?
6. 能不能只用 bin log 不用 redo log?
不能,bin log 是用于数据传输,持久化是主库的操作,一个日志不能表示两种状态
7. 说说事务的ACID特性
一致性:事务会从一个一致状态,变为另一个一致状态
8. 四个事务隔离级别
9. 可重复读是怎么实现的?
可重复读是由 MVCC 实现。MVCC 机制会给每个事务分配一个事务ID,并未每条数据记录保存它所属的事务版本信息
10. 介绍一下 MVCC 原理
MVCC 解决 不可重复读问题 和 大部分幻读问题(可重复读级别下的当前读)
MVCC 原理:
- undo log 链:历史数据,TRX_ID(操作此条数据时的id),下一条数据的引用
- ReadView:create_trx_id(创建事务时的id)、min_trx_id(最小的事务id)、max_trx_id(最大的事务id)、m_ids(所有活跃事务的id)
MVCC ReadView 对比执行流程:
- create_trx_id == TRX_ID -> 当前事务的操作,直接查询到当前的这条信息
- TRX_ID < min_trx_id -> 早期数据,直接查询到当前的这条信息
- TRX_ID > max_trx_id -> 最新数据,看不到
- min_trx_id < TRX_ID < max_trx_id(执行中):
- TRX_ID 在 m_ids:TRX_ID的事务正在执行中(事务还没提交),看不到
- TRX_ID 不在 m_ids:TRX_ID的事务执行完了,能看到
11. 介绍一下 MySQL 中的锁
MySQL 锁:
- 全局锁:所有数据库加锁(适用场景:全库备份)
- 表级锁
- 行级锁
- 表锁和行锁之间的锁:
- 间隙锁
- 临建锁
- 意向锁
特征分类:
- 独占锁:select for update
- 共享锁:lock in share mode (写写、写读加锁,读读不加锁)
12. 如果有一个字段的值是0或者1,适合建索引吗?
不适合。当记录只有0或1的时候就相当于全表扫描
13. 讲一下 SQL 优化方法
MySQL 优化的方法:
- 正确使用 SQL(占用更小的带宽,传输性能更高,可能会触发索引覆盖)
- 正确创建和使用索引
- 使用正确的字段类型(建表时,评估字段该用什么类型保存最合适)
- 使用 MySQL 集群:
- 主从同步
- 分库分表
- 分布式数据库,例如 TiDB
14. 如何解决深度分页的问题?
深度分页问题:指的是查询的数据位于数据库的尾部,导致查询很慢的问题。
深度分页的解决方案:
- 创建索引,最好能实现索引覆盖
- 应用层添加缓存+数据预处理(数据预热)
- 优化 MySQL 框架:使用读写分离
- 将数据存储到 NoSQL 中(数据冗余):es/mongodb高效地提升深度分页效率
MySQL 数据库同步 NoSQL:监控 bin log 日志 -> 通过阿里巴巴提供的 Canal 将数据同步到 -> es/mongodb
15. Redis 的 ZSet 底层是怎么实现的?
ZSet底层实现:
- Redis 7.0 之前:ziplist(压缩列表)+ SkipList(跳跃表)
- Redis 7.0+ :listpack(紧凑列表) + SkipList(跳跃表)
16. 手撕算法:在旋转排序数组中找一个数
原题:https://leetcode.cn/problems/search-in-rotated-sorted-array/description/?envType=study-plan-v2&envId=top-100-liked
# 字节跳动 - 2024.04.20
1. 浏览器键入网址后的执行流程
2. HTTP为什么是无状态的?
3. synchronized 是怎么实现可重入的?
1️⃣ 答:
synchronized
可重入原理是通过 JVM 内部维护一个锁对象(锁)的计数器来实现的。
首先,可重入性是针对同一个线程(锁程)多次获取同一把「锁对象」的情况。每个「锁对象」都有一个关联的 monitor(监视器),monitor 里包含了一个计数器。
当线程尝试进入 synchronized
修饰的同步代码块/方法/..时,会先去查看 synchronized
「锁对象」的计数器:
- 情况1:如果计数器为 0,表示无锁。
- 当线程会立即获取到「锁对象」,也与对应的 monitor 和计数器产生关联,计数器 + 1;
- 其他线程不能再获取「锁对象」,进入同步队列(SynchronizedQueue)等待。
- 情况2:如果计数器不为 0,并且该线程已经关联了该「锁对象」的 monitor,说明已经拿到了锁对象的所有权。
- 则表示该线程重入了这把锁,计数器 +1;
- 随着重入的次数,计数器一直累加。
- 相反的,退出同步代码块时,计数器 -1,直到计数器为 0 时,表示该线程释放了锁。
- 情况3:如果计数器不为 0,而该线程并没有关联 monitor,说明锁被别的线程持有,该线程需要等待锁的释放。
2️⃣ 总结
总的来说,可重入原理是通过 JVM 内部维护一个锁对象(锁)的计数器来实现的。当线程重复获取同一把锁时,计数器会相应增加 1;当线程释放锁时,计数器会相应减少 1。只有当计数器为 0 时,锁才会被真正释放,从而允许其他线程获取该锁。这种机制确保了线程安全,并提高了程序的并发性能。
3️⃣ 更底层
synchronized
更底层的关于可重入的实现,是 C++ (JVM 底层通过 C++ 实现) 通过ObjectMonitor
实现了synchronized
,ObjectMonitor
用_count
字段(计数器)来实现可重入。
4. Mysql 索引的底层实现?
注意:在MySQL中,索引属于存储引擎级别的概念,不同存储引擎对索引的实现方式是不同的,最常用的存储引擎有 MyISAM 和 InnoDB。
一般情况下,我们说的 Mysql 的存储引擎默认是 InnoDB(InnoDB 是在 MySQL 5.5 之后成为默认的 MySQL 存储引擎),在后续存储引擎都指代为 InnoDB。
1️⃣ 答:
Mysql 索引底层是通过「B+ 树」来实现。
2️⃣ 细节
在数据库中,「索引」的定义,就是帮助存储引擎快速获取/查询「数据」的一种数据结构,形象的说就是“索引是数据的目录”。
所谓的存储引擎,说白了就是如何 存储数据、如何 为存储的数据建立索引 和 如何更新、查询数据 等技术的实现方法。
找到数据对应的索引,基本上相当于找到数据,所以 如何快速索引 成为了一个问题,Mysql 中通过「B+ 树」来建立索引,从而形成 B+ 索引树。
从物理存储的角度来看,索引分为:
- 聚簇索引(主键索引):聚簇索引「B+ 树」的叶子节点存放的是实际数据,所有完整的用户记录都存放在聚簇索引的「B+ 树」的叶子节点里
- 二级索引(辅助索引):二级索引「B+ 树」的叶子节点存放的是主键值,而不是实际数据。
- 如果在查询时使用了二级索引,并且查询的数据能在二级索引里直接查询到,那么就不需要回表,这个过程就是覆盖索引;
- 如果查询的数据不在二级索引里,就会先检索二级索引,找到对应的叶子节点,获取到主键值后,然后通过主键值再检索聚簇索引,就能查询到数据了,这个过程就是回表。
在创建表时,存储引擎默认会创建一个主键索引,也就是聚簇索引,其它索引都属于二级索引。
3️⃣ 扩展问题
- MySQL 索引:索引为什么使用 B+树?答案 (opens new window)
- 从最普通的二叉查找树开始,逐步说明各种树解决的问题以及面临的新问题,从而说明 MySQL 为什么选择 B+ 树 作为索引结构
- MySQL 索引:索引失效有哪些情况?答案 (opens new window)
- 对索引使用左
like %xx
或左右like %xx%
模糊匹配 - 对索引使用函数
- 对索引进行表达式计算
- 对索引隐式类型转换(索引字段是字符串,输入是数字时失效,因为 MySQL 在遇到字符串和数字比较的时候,会自动把字符串转为数字)
- 联合索引非最左匹配
WHERE
子句中的OR
(如果在OR
前的条件列是索引列,而在OR
后的条件列不是索引列,那么索引会失效)
- 对索引使用左
- MySQL 索引:有什么优化索引的方法?答案 (opens new window)
- 前缀索引优化;
- 覆盖索引优化;
- 主键索引最好是自增的;
- 防止索引失效;
5. Mysql 如何保证原子性?
把问题转换为:Mysql Innodb 存储引擎层事务的原子性。
1️⃣ 答:
通过 undo log
日志来保证「事务」的原子性。
undo log
是一种用于撤销回退的日志,称为「回滚日志」。
- 在「事务」没提交之前,每当 InnoDB 引擎对一条「记录」进行操作(修改、删除、新增)时,会把回滚时需要的信息都记到
undo log
里; - 当「事务」回滚时,可以利用
undo log
来进行回滚。简单来说,就是读取undo log
里的数据,然后做原先相反操作。
比如:
- 在插入一条「记录」时,要把这条「记录」的主键值记下来,这样之后回滚时只需要把这个主键值对应的「记录」删掉就好了;
- 在删除一条「记录」时,要把这条「记录」中的内容都记下来,这样之后回滚时再把由这些内容组成的「记录」插入到表中就好了;
- 在更新一条「记录」时,要把被更新的列的旧值记下来,这样之后回滚时再把这些列更新为旧值就好了。
2️⃣ 细节
事务(Transaction)的四个特性(ACID):
- 原子性(Atomicity):一个事务中的所有操作,要么全部完成,要么全部不完成
- 一致性(Consistency):是指事务操作前和操作后,数据满足完整性约束,数据库保持一致性状态
- 隔离性(Isolation):隔离性可以防止多个事务并发执行时由于交叉执行而导致数据的不一致
- 持久性(Durability):事务处理结束后,对数据的修改就是永久的,即便系统故障也不会丢失
InnoDB 引擎通过什么技术来保证事务的这四个特性的呢?
- 原子性是通过
undo log
(回滚日志) 来保证的; - 隔离性是通过 MVCC(多版本并发控制) 或锁机制来保证的;
- 持久性是通过
redo log
(重做日志)来保证的; - 一致性则是通过持久性+原子性+隔离性来保证;
6. 事务的隔离级别有哪些?
一般在 Java 中,使用最多的就是 Mysql 数据库,所以这里就回答 Mysql 事务的隔离级别。(必须有这个前提,因为每个数据库的事务隔离级别不一样)。
1️⃣ 答:
MySQL 事务用四种隔离级别来规避「脏读、不可重复读、幻读」现象,隔离级别越高,性能效率就越低,这四个隔离级别如下:
- 读未提交(read uncommitted):允许事务读取到其他事务尚未提交的数据。存在「脏读、不可重复读、幻读」问题。
- 读已提交(read committed):要求当前事务只能读取到其他事务已提交的数据。不存在「脏读」问题,但存在「不可重复读、幻读」问题。
- 可重复读(repeatable read):一旦事务开始读取数据,读取的数据跟这个事务启动时看到的数据都是一致的。不存在「不可重复读」问题,但可能存在「幻读」问题。(「可重复读」是 Mysql 默认的事务隔离级别) 。
- 串行化(serializable ):它是最高的隔离级别,每个时刻最多只有一个事务在执行,即排队执行。不存在「脏读、不可重复读、幻读」问题。(但是串行化的执行效率低,一般不会设置成该隔离级别)
四个隔离级别下存在的问题如下图所示:
⚠ 注意,在 SQL 标准的「可重复读」隔离级别下,是存在「幻读」问题的。但在 MySQL 的「可重复读」隔离级别下,可以很大程度上避免「幻读」现象的发生,解决「幻读」的方案有两种:
- 针对快照读(普通
select
语句),是通过 MVCC(多版本并发控制,通过 Read View +undo log
实现) 方式解决了「幻读」 - 针对当前读(
select ... for update
等语句),是通过 next-key lock(记录锁+间隙锁)方式解决了「幻读」
2️⃣ 扩展问题
首先,在 Mysql 中,只有 InnoDB 引擎支持「事务」。
1. 「事务」为什么会有隔离级别?
引入「事务」隔离级别的主要原因是,确保并发执行的「事务」之间的隔离性(Isolation)和一致性(Consistency)。在 Mysql 中,多个「事务」可能会同时运行并尝试访问或修改相同的数据。如果没有适当的隔离机制,在同时处理多个并发「事务」的时候,就可能出现脏读(dirty read)、不可重复读(non-repeatable read)、幻读(phantom read)的问题。
2. 并行「事务」会引发什么问题?
1)脏读(dirty read)
如果一个事务读到了另一个未提交事务修改过的数据 ,就意味着发生了「脏读」现象。
举个栗子🌰:
假设有 A 和 B 这两个事务同时在处理,事务 A 先开始从数据库中读取小林的余额数据,然后再执行更新操作,如果此时事务 A 还没有提交事务,而此时正好事务 B 也从数据库中读取小林的余额数据,那么事务 B 读取到的余额数据是刚才事务 A 更新后的数据,即使没有提交事务。
因为事务 A 还没提交事务,也就是它随时可能发生回滚操作,如果在上面这种情况事务 A 发生了回滚,那么事务 B 刚才得到的数据就是过期的数据,这种现象就被称为「脏读」。
2)不可重复读(non-repeatable read)
在一个事务内多次读取同一个数据,如果出现前后两次读到的数据不一样的情况,就意味着发生了「不可重复读」现象。
举个栗子🌰:
假设有 A 和 B 这两个事务同时在处理,事务 A 先开始从数据库中读取小林的余额数据,然后继续执行代码逻辑处理,在这过程中如果事务 B 更新了这条数据,并提交了事务,那么当事务 A 再次读取该数据时,就会发现前后两次读到的数据是不一致的,这种现象就被称为「不可重复读」。
3)幻读(phantom read)
在一个事务内多次查询某个符合查询条件的记录数量,如果出现(同样的条件下)前后两次查询到的记录数量不一样的情况,就意味着发生了「幻读」现象。
举个栗子🌰:
假设有 A 和 B 这两个事务同时在处理,事务 A 先开始从数据库查询账户余额大于 100 万的记录,发现共有 5 条,然后事务 B 也按相同的搜索条件也是查询出了 5 条记录。
接下来,事务 A 插入了一条余额超过 100 万的账号,并提交了事务,此时数据库超过 100 万余额的账号个数就变为 6。
然后事务 B 再次查询账户余额大于 100 万的记录,此时查询到的记录数量有 6 条,发现和前一次读到的记录数量不一样了,就感觉发生了幻觉一样,这种现象就被称为「幻读」。
3. 四种隔离级别具体是如何实现的?
- 对于「读未提交」隔离级别的事务来说,因为可以读到未提交事务修改的数据,所以直接读取最新的数据就好了;
- 对于「串行化」隔离级别的事务来说,通过加读写锁的方式来避免并行访问;
- 对于「读提交」和「可重复读」隔离级别的事务来说,它们是通过 Read View 来实现的,它们的区别在于创建 Read View 的时机不同,大家可以把 Read View 理解成一个数据快照,就像相机拍照那样,定格某一时刻的风景。
- 「读提交」隔离级别是在每个语句执行前都会重新生成一个 Read View,
- 「可重复读」隔离级别是启动「事务」时生成一个 Read View,然后整个事务期间都在用这个 Read View。
更详细查看文章:事务隔离级别是怎么实现的? (opens new window)
7. 「读未提交」的底层原理?
1️⃣ 答:
读未提交(Read Uncommitted),采取的是“读不加锁、写加锁”:
- 事务读不加锁,不阻塞其他事务的读和写
- 事务写阻塞其他事务写,但不阻塞其他事务读
SELECT statements(读取语句) are performed in a nonlocking fashion, but a possible earlier version of a row might be used. Thus, using this isolation level, such reads are not consistent. This is also called a dirty read(脏读). Otherwise, this isolation level works like READ COMMITTED(读已提交).
—— 官方文档
参考:
8. 说说 Redis 数据类型有哪些?
1️⃣ 答:
常用的5种数据类型:
数据类型 | 数据结构 | 应用场景 |
---|---|---|
String | SDS(简单动态字符串) | • 缓存对象 • 常规计数 • 分布式锁 • 共享session信息 |
List | • (Redis3.0) 双向链表 or 压缩列表 • (Redis3.2+) quicklist | • 消息队列(但有两个问题:1.生产者需要自行实现全局唯一 ID;2.不能以消费组形式消费数据) |
Hash | • (Redis3.0) 哈希表 or 压缩列表 • (Redis7.0+) 哈希表 or listpack | • 缓存对象 • 购物车 |
Set | • (Redis3.0) 哈希表 or 整数集合 | • 聚合计算(并集、交集、差集)场景,比如点赞、共同关注、抽奖活动等 |
ZSet | • (Redis3.0) 跳表 or 压缩列表 • (Redis7.0+) 跳表 or listpack | • 排序场景,比如排行榜、电话和姓名排序等 |
其数据类型与底层数据结构的关系如图:
随便版本的更新,新增了4种数据类型:
数据类型 | 数据结构 | 应用场景 |
---|---|---|
BitMap (Redis 2.2新增) | String类型(二进制的字节数组) | • 二值状态统计的场景,比如签到、判断用户登陆状态、连续签到用户总数等 |
HyperLogLog (Redis 2.8新增) | • 海量数据基数统计的场景,比如百万级网页 UV 计数等 | |
GEO (Redis 3.2新增) | Sorted Set | • 存储地理位置信息的场景,比如滴滴叫车 |
Stream (Redis 5.0新增) | • 消息队列,相比于基于 List 类型实现的消息队列,有这两个特有的特性:自动生成全局唯一消息ID,支持以消费组形式消费数据 |
参考:
9. 说说 Redis 中 ZSet 的底层实现
1️⃣ 答:
Zset 类型的底层数据结构是由 压缩列表/listpack 或 跳表 实现:
- 如果有序集合的元素个数小于 128 个,并且每个元素的值小于 64 字节时,Redis 会使用压缩列表作为 Zset 类型的底层数据结构;
- 注: 在 Redis 7.0 中,压缩列表数据结构已经废弃了,交由 listpack 数据结构来实现了。
- 如果有序集合的元素不满足上面的条件,Redis 会使用跳表作为 Zset 类型的底层数据结构;
三种数据结构:
- 跳表:跳表是在链表的基础上改进过来的,相当于一种多层的有序链表,这样的好处是能快读定位数据。
- 压缩列表:压缩列表是 Redis 为了节约内存而开发的,它是由连续内存块组成的顺序型数据结构,有点类似于数组。
- listpack:采用了压缩列表的很多优秀的设计,还是用一块连续的内存空间来紧凑地保存数据。与压缩列表对比,listpack没有压缩列表中记录前一个节点长度的字段
prevlen
了,只记录当前节点的长度len
。(prevlen
有可能导致连锁更新问题)
参考:
10. Redis 中用什么数据结构实现延迟消息队列?
⚠ 注意,延迟消息队列 和 消息队列 的实现完全不一样!
1️⃣ 答:
使用 ZSet 实现延迟消息队列,将延迟执行的时间存储在 ZSet 的分值(Score),使用一个线程一直循环扫描 ZSet 分值,如果当前时间大于等于 ZSet 分值,说明当前的任务要执行了。
https://blog.csdn.net/liuerpeng1904/article/details/134204808
11. 说说 Redis 的 RDB 持久化策略
1️⃣ 答:
(1)概念
RDB 持久化策略, 全称 Redis DataBase,将 Redis 某一时刻的数据(快照/snapshot)保存到磁盘上,以二进制的方式存储于 RDB 日志文件中。
(2)持久化触发方式
- 手动触发:通过执行
save
或bgsave
命令来触发:save
命令会在主线程执行生成 RDB 文件,会阻塞主线程;bgsave
命令则会创建一个子进程来执行生成 RDB 文件,从而避免阻塞主进程。
- 自动触发:根据 Redis 配置文件中的 save 选项来自动触发
# 在 900 秒内如果有至少 1 个键被改动,则自动触发 RDB 持久化 # 配置名虽然是 save,实际上执行的是 bgsave 命令 save 900 1 # 在 300 秒内如果有至少 10 个键被改动... save 300 10 # 在 60 秒内如果有至少 10000 个键被改动... save 60 10000 # 关闭RDB快照功能 save ""
1
2
3
4
5
6
7
8
91
2
3
4
5
6
7
8
9
(3)RDB 日志文件加载方式
RDB 文件的加载工作是在服务器启动时自动执行的,Redis 并没有提供专门用于加载 RDB 文件的命令。
(4)RDB 优点
- RBD 日志文件是紧凑的二进制文件(使用LZF算法进行压缩),非常适合用于备份和数据传输
- 数据恢复速度很快(远远快于AOF方式)
- RBD 配置比较灵活,提供了 save 选项自定义快照触发间隔
(5)RDB 缺点
- RDB方式实时性不够,无法做到秒级的持久化,丢失风险很大
- 每次调用bgsave都需要fork子进程,fork子进程属于重量级操作,频繁执行成本较高(RDB 是全量的持久化)
(6)配置参数
# 指定 RDB 文件的保存目录
dir ./
# 指定 RDB 文件的名称
dbfilename dump.rdb
# RDB 持久化触发条件
save 900 1
save 300 10
save 60 10000
# 如果持久化出错,主进程是否停止写入
stop-writes-on-bgsave-error yes
# 是否压缩
rdbcompression yes
# 导入时是否检查
rdbchecksum yes
2
3
4
5
6
7
8
9
10
11
12
13
14
2
3
4
5
6
7
8
9
10
11
12
13
14
2️⃣ AOF 持久化
(1)概念
虽说 Redis 是内存数据库,但是它为数据的持久化提供了两个技术,分别是 AOF 日志 和 RDB 快照:
- AOF 文件记录的是命令操作的日志,而不是实际的数据
- AOF(Append Only File) 日志 只会记录写操作命令到日志 中。
- RDB 快照就是记录某一个瞬间的内存数据(实际数据)
这两种技术都会用各用一个日志文件来记录信息,但是记录的内容是不同的:
- AOF 文件的内容是操作命令;
- RDB 文件的内容是二进制数据。
如图所示,Redis 的 AOF 是写后日志,即“先执行命令把数据写入内存,再记录命令到日志”。但其实很多数据库采用的是写前日志(WAL),例如 MySQL,通过写前日志和两阶段提交,实现数据和逻辑的一致性。
(2)配置参数
# 表示是否开启 AOF 持久化(默认no)
appendonly yes
# AOF 持久化文件的名称
appendfilename "appendonly.aof"
# AOF文件的保存位置和RDB文件的位置相同,都是通过dir参数设置的
dir ./
# 同步策略 always/everysec/no
appendfsync everysec
# aof重写期间是否同步
no-appendfsync-on-rewrite no
# 重写触发配置
auto-aof-rewrite-percentage 100 # (当前aof文件大小 - 上一次重写后aof文件大小) / 上一次重写后aof文件大小
auto-aof-rewrite-min-size 64mb # 重写时文件的最小大小
# 加载aof出错如何处理
aof-load-truncated yes
# 文件重写策略
aof-rewrite-incremental-fsync yes
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Q:在重写日志整个过程时,主线程有哪些地方会被阻塞?
A:1) fork子进程时,需要拷贝虚拟页表,会对主线程阻塞。2) 主进程有bigkey写入时,操作系统会创建页面的副本,并拷贝原有的数据,会对主线程阻塞。3) 子进程重写日志完成后,主进程追加aof重写缓冲区时可能会对主线程阻塞。
参考:
12. 使用 Redis 如何实现分布式锁?
1️⃣ 答:
Redis 的 SET 命令有个 NX
参数表示 “ key
不存在才插入”,可以用它来实现分布式锁:
- 如果
key
不存在,则显示插入成功,可以用来表示加锁成功; - 如果
key
存在,则会显示插入失败,可以用来表示加锁失败。
1)加锁
分布式锁命令:
SET lock_key unique_value NX PX 10000
lock_key
就是上面提到的key
unique_value
是客户端生成的唯一的标识,区分来自不同客户端的锁操作NX
代表只在lock_key
不存在时,才对lock_key
进行设置操作PX 10000
表示设置lock_key
的过期时间为10s
,这是为了避免客户端发生异常而无法释放锁。
2)解锁
解锁的过程就是将 lock_key
键删除,但不能乱删,要保证执行删除操作的客户端就是加锁的客户端。所以,解锁的时候,我们要先判断锁的 unique_value
是否为加锁客户端,是的话,才将 lock_key
键删除。
解锁有两个操作,这时就需要 Lua 脚本来保证解锁的原子性(执行时具备原子性,因为 Lua 脚本是单线程的):
// 先比较 unique_value 是否相等,避免锁的误释放
// ARGV[1] 即执行删除操作的客户端
// get lock_key 即持有锁的客户端
if redis.call("get", KEYS[1]) == ARGV[1] then
// 释放锁(del lock_key)
return redis.call("del", KEYS[1])
else
return 0
end
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
这样一来,就通过使用 SET 命令和 Lua 脚本在 Redis 单节点上完成了分布式锁的加锁和解锁。
13. 除了 Redis 还有哪种实现分布式锁的方式?
可以使用 Zookeeper 实现分布式锁 → 多个请求同时创建一个临时节点的方式来实现分布式锁,临时节点就是锁,而临时节点只有一个请求能创建成功。
14. Redis 集群是如何分片的?这种分片算法有什么好处?
1️⃣ 答:
Redis集群/Redis-cluster:
Redis-cluster对外是一个整体(相当于单Redis),每个master只负责存储整个数据集的一部分(而单Redis/主从复制/哨兵机制都是存储整个数据集)。这样的话,原本存储的整个数据集现在都分散到多台Redis机器上,称为分片。
Redis集群分片算法:哈希槽分片算法
Redis-cluster没有使用一致性哈希算法 (opens new window),而是引入了哈希槽(Hash Slot)的概念,Redis-cluster中有 16384() 个哈希槽。
如上图所示,有:
- Redis-cluster中的每个节点负责一部分槽哈希槽。
- 例如集群中有三个master,则可能的一种哈希槽分配管理:
- Redis1 负责 0-5500 号哈希槽
- Redis2 负责 5501-11000 号哈希槽
- Redis3 负责 11001-16384 号哈希槽
- 例如集群中有三个master,则可能的一种哈希槽分配管理:
- 每个 key 通过 CRC16 校验后对 16383 取模来决定放置哪个槽。
HASH_SLOT = CRC16(key) mod 16384
11
Hash槽分片算法优点:
- 防止数据倾斜(一致性哈希算法存在该问题):将哈希槽尽量平均分给每个节点
- 分工更均衡:
- 删除一个节点:删除节点的哈希槽平分到其余的每个节点
- 添加一个节点:将每个节点的一部分哈希槽转移分配到新增节点
集群的优点
- 高可用:由于数据分布在多个节点上,即使其中一个节点发生故障,其他节点仍然可以继续工作,提供高可用性。
- 高可拓展:可以根据需求动态添加或移除节点
参考
15. 说说 JVM 的内存分布
1️⃣ 答:
JVM 内存布局:
- Java虚拟机栈 → 线程私有,FILO,由一个个栈帧组成,存储线程执行的函数,包含函数的局部变量、中间计算结果、函数调用和返回
- 本地方法栈 → 线程私有,与Java虚拟机栈的功能一致,不过该栈为本地方法(C++实现)服务。在 HotSpot 中,Java虚拟机栈和本地方法栈合二为一(用同一块内存空间)。
- 程序计数器 → 线程私有,记录线程执行位置,主要两个作用:1)在线程运行时,字节码解释器通过获取和改变程序计数器来控制代码的执行流程;2)在多线程的情况下,程序计数器记录线程执行位置,从而在线程上下文切换时,可以知道切换回来的线程上次运行到哪里了。
- 堆 → 存放实例对象,几乎所有的对象实例以及数组都在这里分配内存。堆是JVM管理的内存中最大的一块,也是垃圾收集器管理的主要区域。
- 方法区 → 存储类和常量的信息,包括类的定义、方法的定义、字段的定义以及字节码指令等
更详细可查看:腾讯qq-一面-Q3
16. 这些内存区域中有垃圾回收的是哪些地方?
1️⃣ 答:
JVM 垃圾回收区域:
- 堆(几乎所有的对象实例以及数组都在这里分配内存,对象大部分都朝生夕死,需要回收)
- 方法区(类或方法卸载时需要回收)
17. Spring 的 AOP 是如何实现的?
1️⃣ 答:
Spring AOP 由动态代理实现 AOP:
- JDK Proxy (Spring默认使用)
- CGLib (SpringBoot默认使用)
Spring AOP 属于运行时增强,而 AspectJ 是编译时增强。 Spring AOP 基于代理(Proxying),而 AspectJ 基于字节码操作(Bytecode Manipulation)。
使用AspectJ来做切入点解析和匹配。但是,AOP在运行时仍旧是纯的Spring AOP,并不依赖于AspectJ的编译器或者织入器(weaver)。
Spring AOP的实现方式是动态织入,动态织入的方式是在运行时动态将要增强的代码织入到目标类中,这样往往是通过动态代理技术完成的;如Java JDK的动态代理(Proxy,底层通过反射实现)或者CGLIB的动态代理(底层通过继承实现),Spring AOP采用的就是基于运行时增强的代理技术。
18. Kafka 吞吐量高得原理是啥?
19. Kafka 如何保证数据不丢失?
20. 手撕算法:恢复乱序数组
恢复乱序数组 → 有序数组
排序算法:
- 冒泡排序
- 插入排序
- 归并排序(分治法)
- 快速排序
# 腾讯QQ(一面) - 2024.04.23
1. Java 当中形参是数组或者对象的话,修改形参会对实参影响吗?
1️⃣ 答:
在 Java 中,将实参传递给方法的方式是值传递,对于数组或者对象,分两种情况考虑:
- 如果对形参变量重新赋值(
new
新的数组或对象),则不会影响实参; - 如果(形参变量不重新赋值)通过形参修改数组中的元素或对象的属性,则会影响实参。
这是因为当参数是数组或对象时,实参变量保存的是数组或对象的地址值(地址值也是值),传递给形参的值也是地址值,此时形参和实参保存的地址值相同,指向的数组或对象是一样的。
所以,如果不对形参重新赋值而只修改形参(数组或对象)里的属性,则会修改到地址值指向的数组的元素或对象的属性,而实参保存的地址值与形参一致,因此影响了实参;如果直接对形参重新赋值,则该形参保存的是新的地址值(与实参的不同),后续对形参的修改都不会影响实参。
2. 说说 Java 的序列化?
1️⃣ 答:
Java 序列化是指将 Java 对象转换为字节序列(二进制字节流)的过程,以便在网络上传输或将其持久化(写入持久存储,如文件或数据库)。
Java 序列化通常涉及到 Serializable
接口和 ObjectOutputStream
、ObjectInputStream
类。
Serializable
接口:序列化的类需要实现Serializable
接口,这个接口没有任何方法,它只是作为一个标记接口(marker interface)使用,表示该类创建的对象是可以被序列化的,但是如果不去实现它而进行序列化的话,会抛出异常。如果类的某个属性不可序列化,则必须将其标记为transient
,以告诉 Java 在序列化时忽略这个属性。ObjectOutputStream
类:通常使用ObjectOutputStream
类的writeObject
方法来完成序列化(将对象转换为字节序列)。ObjectInputStream
类:通常使用ObjectInputStream
类的readObject
方法来完成反序列化(将字节序列转换为对象)。
2️⃣ 细节
序列化的注意事项:
- 安全性:序列化涉及将对象转换为字节流,这可能会暴露对象的内部状态。因此,在序列化对象之前,应确保对象不包含敏感信息,或者已经采取了适当的安全措施。
- 版本控制:当类的定义发生变化时(例如,添加或删除字段),已经序列化的对象可能无法正确地反序列化。Java提供了一种称为 serialVersionUID 的机制来处理这个问题。当类的定义改变时,应更新此字段的值。
- 性能:序列化过程可能会消耗较多的CPU和内存资源,尤其是在处理大型对象或复杂对象图时。因此,在性能敏感的应用程序中,应谨慎使用序列化。
- 自定义序列化:如果需要更细粒度的控制序列化过程,可以通过实现
java.io.Externalizable
接口或提供writeObject
和readObject
方法的私有实现来完成。 - 瞬态变量:被标记为
transient
的变量在序列化过程中会被忽略,这可以用于排除敏感数据或不需要持久化的数据。
一个常用的序列化技术:JSON 序列化。JSON(JavaScript Object Notation)是一种轻量级的数据交换格式,易于人阅读和编写,同时也易于机器解析和生成。在Java中,可以使用如Jackson、Gson等库将Java对象转换为JSON格式的字符串,或将JSON字符串转换为Java对象。这种方式通常用于HTTP请求的发送和接收,或者作为配置文件等数据的存储格式。
关于 Java 序列化的10个面试问题:参考文章 (opens new window)
3. 说说 JVM 内存模型,每个区域是干什么的?
⚠️ 注意,要区分 JVM 内存模型(JMM) 和 JVM 内存结构!两者是完全不同的东西。
这个问题问得有点问题,一般 JVM 内存结构才讲究区域,JVM 内存模型并没有这样子的说法。所以我个人觉得这里问的其实是 JVM 内存结构,
那问题就改成:说说 JVM 内存结构,每个区域是干什么的?
1️⃣ 答:
JVM内存结构主要指的是 JVM 运行时数据区域(Runtime Data Area)。在执行 Java 程序的过程中,JVM 会把它管理的内存划分成若干个不同的数据区域。
- 线程私有 (这些内存结构的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡):
- 虚拟机栈 / VM Stack:主管 Java 程序的运行,它保存单个线程中的方法的局部变量、部分结果,并参与方法的调用和返回。
- 本地方法栈 / Native Method Stack:作用与虚拟机栈相似,但该栈为本地方法(C++实现)服务,用于保存本地方法执行时使用到的变量、局部结果、方法的调用和返回。
- 程序计数器 / Program Counter Register:相当于当前线程执行的行号指示器。
- 在线程运行时,字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了
- 线程共享
- 堆 / Heap:存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。堆是JVM 管理的内存中最大的一块,也是垃圾收集器管理的主要区域。
- 方法区 / Method Area (No-Heap):方法区是 JVM 中的一个逻辑区域,用于存储类的结构信息,包括类的定义、方法的定义、字段的定义以及字节码指令。
- 直接内存 / Direct Memory (非运行时数据区的一部分):一个特殊的内存缓冲区,通过 JNI 的方式在本地内存上分配的。
2️⃣ 细节
JDK 1.7(及以下):
首先,按照线程私有和线程共享的特性区分:
- 线程私有:这些内存结构的生命周期和线程相同,随着线程的创建而创建,随着线程的死亡而死亡
- 虚拟机栈 / VM Stack:主管 Java 程序的运行,它保存单个线程中的方法的局部变量、部分结果,并参与方法的调用和返回。
- 栈包含一个个栈帧(Stack Frame),一次方法调用就会有一个栈帧入栈,方法执行结束则栈帧出栈
- 可能的异常:1)栈固定大小时可能
StackOverflowError
;2)栈动态扩展时可能OutOfMemoryError
- 本地方法栈 / Native Method Stack:作用与虚拟机栈一样,但该栈为本地方法(C++实现)服务,用于保存本地方法执行时使用到的变量、链接、返回等。
- 虚拟机栈为虚拟机执行 Java 方法 (也就是字节码)服务,而本地方法栈则为虚拟机使用到的 Native 方法服务
- 在 HotSpot 虚拟机中,本地方法栈 和 虚拟机栈 合二为一
- 程序计数器 / Program Counter Register:一块较小的内存空间,可以看作是当前线程所执行的字节码的行号指示器。
- 在线程运行时,字节码解释器通过改变程序计数器来依次读取指令,从而实现代码的流程控制
- 在多线程的情况下,程序计数器用于记录当前线程执行的位置,从而当线程被切换回来的时候能够知道该线程上次运行到哪儿了
- 虚拟机栈 / VM Stack:主管 Java 程序的运行,它保存单个线程中的方法的局部变量、部分结果,并参与方法的调用和返回。
- 线程共享
- 堆 / Heap:存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。堆是JVM 管理的内存中最大的一块,也是垃圾收集器管理的主要区域
- 字符串常量池 / String Constant Pool:针对字符串(String 类)专门开辟的一块区域,存放字符串常量,为了避免字符串的重复创建,以及提升性能和减少内存消耗。
- ...
- 方法区 / Method Area (No-Heap)
- 运行时常量池 / Runtime Constant Pool:在运行时期间,JVM 会将字节码文件中的常量池加载到内存中,存放在运行时常量池中。
- Class 文件中的常量池表(Constant Pool Table),存放编译期生成的各种字面量(Literal)和符号引用(Symbolic Reference)的
- 常量池是在字节码文件中,而运行时常量池在 JVM 中
- ...
- 运行时常量池 / Runtime Constant Pool:在运行时期间,JVM 会将字节码文件中的常量池加载到内存中,存放在运行时常量池中。
- 直接内存 / Direct Memory (非运行时数据区的一部分)
- 堆 / Heap:存放对象实例,几乎所有的对象实例以及数组都在这里分配内存。堆是JVM 管理的内存中最大的一块,也是垃圾收集器管理的主要区域
JDK 1.8(及以上):
大体上与上述相同,有几个区别
- JDK1.7 之前,字符串常量池存放在永久代(方法区)。JDK1.7 字符串常量池和静态变量从永久代移动了 Java 堆中。
- ...
4. 垃圾回收算法有哪些?
1️⃣ 答:
- 标记-清除算法
「标记-清除」(Mark-and-Sweep)算法分为“标记(Mark)”和“清除(Sweep)”阶段:首先标记出所有不需要回收的对象,在标记完成后统一回收掉所有没有被标记的对象。
它是最基础的收集算法,后续的算法都是对其不足进行改进得到。这种垃圾收集算法会带来两个明显的问题:
- 效率问题:标记和清除两个过程效率都不高。
- 空间问题:标记清除后会产生大量不连续的内存碎片。
- 复制算法
为了解决「标记-清除」算法的效率和内存碎片问题,复制(Copying)收集算法出现了。它可以将内存分为大小相同的两块,每次使用其中的一块。当这一块的内存使用完后,就将还存活的对象复制到另一块去,然后再把使用的空间一次清理掉。这样就使每次的内存回收都是对内存区间的一半进行回收。
虽然改进了标记-清除算法,但依然存在下面这些问题:
- 可用内存变小:可用内存缩小为原来的一半。
- 不适合老年代:如果存活对象数量比较大,复制性能会变得很差。
现在的商业虚拟机都采用这种收集算法来回收新生代,但是并不是将新生代划分为大小相等的两块,而是分为一块较大的 Eden 空间和两块较小的 Survivor 空间,每次使用 Eden 空间和其中一块 Survivor。在回收时,将 Eden 和 Survivor 中还存活着的对象一次性复制到另一块 Survivor 空间上,最后清理 Eden 和使用过的那一块 Survivor。
HotSpot 虚拟机的 Eden 和 Survivor 的大小比例默认为 8:1,保证了内存的利用率达到 90%。如果每次回收有多于 10% 的对象存活,那么一块 Survivor 空间就不够用了,此时需要依赖于老年代进行分配担保,也就是借用老年代的空间存储放不下的对象。
- 标记-整理算法
「标记-整理」(Mark-and-Compact)算法是根据老年代的特点提出的一种标记算法,标记过程仍然与「标记-清除」算法一样,但后续步骤不是直接对可回收对象回收,而是让所有存活的对象向一端移动,然后直接清理掉端边界以外的内存。
由于多了整理这一步,因此效率也不高,适合老年代这种垃圾回收频率不是很高的场景。
- 分代收集算法
当前虚拟机的垃圾收集都采用分代收集算法,这种算法没有什么新的思想,只是根据对象存活周期的不同将内存分为几块。一般将 Java 堆分为新生代和老年代,这样我们就可以根据各个年代的特点选择合适的垃圾收集算法。
比如:
- 新生代中,每次收集都会有大量对象死去,所以可以选择复制算法,只需要付出少量对象的复制成本就可以完成每次垃圾收集。
- 而老年代的对象存活几率是比较高的,而且没有额外的空间对它进行分配担保,所以我们必须选择「标记-清除」或「标记-整理」算法进行垃圾收集。
2️⃣ 扩展问题
Q: 延伸面试问题: HotSpot 为什么要分为新生代和老年代?
A: 根据上面的对分代收集算法的介绍回答
Q&A: 分代垃圾回收算法执行过程:
- 初始态:对象分配在Eden区,S0、S1区几乎为空。
- 随着程序的运行,越来越多的对象被分配在Eden区。
- 当Eden放不下时,就会发生MinorGC(即YoungGC),此时,会先标识出不可达的垃圾对象,然后将可达的对象移动到S0区,并将不可达的对象清理掉。这时候,Eden区就是空的了。在这个过程中,使用了标记清理算法及标记复制算法。
- 随着Eden放不下时,会再次触发minorGC,和上一步一样,先标记。这个时候,Eden和S0区可能都有垃圾对象了,而S1区是空的。这个时候,会直接将Eden和S0区的对象直接搬到S1区,然后将Eden与S0区的垃圾对象清理掉。经历这一轮的MinorGC后,Eden与S0区为空。
- 随着程序的运行,Eden空间会被分配殆尽,这时会重复刚才MinorGC的过程,不过此时,S0区是空的,S0和S1区域会互换,此时存活的对象会从Eden和S1区,向S0区移动。然后Eden和S1区中的垃圾会被清除,这一轮完成之后,这两个区域为空。
- 在程序运行过程中,虽然大多数对象都会很快消亡,但仍然存在一些存活时间较长的对象,对于这些对象,在S0和S1区中反复移动,会造成一定的性能开销,降低GC的效率。因此引入了对象晋升的行为。
- 当对象在新生代的Eden、S0、S1区域之间,每次从一个区域移动到另一个区域时,年龄都会加一,在达到一定的阈值后,如果该对象仍然存活,该对象将会晋升到老年代。
- 如果老年代也被分配完毕后,就会出现MajorGC(即Full GC),由于老年代通常对象比较多,因此标记-整理算法的耗时较长,因此会出现STW现象,因此大多数应用都会尽量减少或着避免出现Full GC的原因。
Q: 垃圾回收器有哪些?
A: 以下是 HotSpot 虚拟机中的 7 个垃圾收集器,连线表示垃圾收集器可以配合使用。
前置知识:
- 单线程&多线程: 「单线程」指的是垃圾收集器只使用一个线程进行收集,而「多线程」使用多个线程;
- 串行&并行: 「串行」指的是垃圾收集器与用户程序交替执行,这意味着在执行垃圾收集的时候需要停顿用户程序(必须暂停其他所有的工作线程("Stop The World"));「并行」指的是垃圾收集器和用户程序同时执行。除了 CMS 和 G1 之外,其它垃圾收集器都是以串行的方式执行。
垃圾回收器:
- Serial 收集器
它是单线程的收集器,只会使用一个线程进行、以串行的方式执行垃圾收集工作。
它是 Client 模式下的默认新生代收集器,因为在用户的桌面应用场景下,分配给虚拟机管理的内存一般来说不会很大。Serial 收集器收集几十兆甚至一两百兆的新生代停顿时间可以控制在一百多毫秒以内,只要不是太频繁,这点停顿是可以接受的。
它的优点是简单高效,对于单个 CPU 环境来说,由于没有线程交互的开销,因此拥有最高的单线程收集效率。
- ParNew 收集器
ParNew 收集器 其实就是 Serial 收集器的多线程版本,除了使用多线程进行垃圾收集外,其余行为(控制参数、收集算法、回收策略等等)和 Serial 收集器完全一样。
它是许多运行在 Server 模式下的虚拟机的首要选择,除了 Serial 收集器外,只有它能与 CMS 收集器配合工作(真正意义上的并发收集器)。
- Parallel Scavenge 收集器
与 ParNew 一样是多线程收集器。
其它收集器关注点是尽可能缩短垃圾收集时用户线程的停顿时间,而它的目标是达到一个可控制的吞吐量,它被称为 “吞吐量优先”收集器。所谓吞吐量就是 CPU 中用于运行用户代码的时间与 CPU 总消耗时间的比值。
Parallel Scavenge 收集器提供了很多参数供用户找到最合适的停顿时间或最大吞吐量,如果对于收集器运作不太了解,手工优化存在困难的时候,则可以通过一个开关参数打开 GC 自适应的调节策略(GC Ergonomics),就不需要手动指定新生代的大小(-Xmn)、Eden 和 Survivor 区的比例、晋升老年代对象年龄等细节参数了。虚拟机会根据当前系统的运行情况收集性能监控信息,动态调整这些参数以提供最合适的停顿时间或者最大的吞吐量。
- Serial Old 收集器
Serial 收集器的老年代版本,单线程收集器,也是给 Client 模式下的虚拟机使用。
如果用在 Server 模式下,它有两大用途:
- 在 JDK 1.5 以及之前版本(Parallel Old 诞生以前)中与 Parallel Scavenge 收集器搭配使用。
- 作为 CMS 收集器的后备预案,在并发收集发生 Concurrent Mode Failure 时使用。
- Parallel Old 收集器
Parallel Scavenge 收集器的老年代版本,在注重吞吐量以及 CPU 资源敏感的场合,都可以优先考虑 Parallel Scavenge + Parallel Old 收集器。
- CMS 收集器
CMS(Concurrent Mark Sweep),Mark Sweep 指的是标记-清除算法。
CMS 收集器是一种以获取最短回收停顿时间为目标的收集器。它非常符合在注重用户体验的应用上使用。
CMS 收集器是 HotSpot 虚拟机第一款真正意义上的并发收集器,它第一次实现了让垃圾收集线程与用户线程(基本上)同时工作。
它的运作过程相比于前面几种垃圾收集器来说更加复杂一些。整个过程分为四个步骤:
- Step1 初始标记: 暂停所有的其他线程,并记录下 GC Roots 能直接关联到的对象,速度很快;(需要停顿)
- Step2 并发标记: 同时开启 GC 和用户线程,用一个闭包结构去记录可达对象。但在这个阶段结束,这个闭包结构并不能保证包含当前所有的可达对象。因为用户线程可能会不断的更新引用域,所以 GC 线程无法保证可达性分析的实时性。所以这个算法里会跟踪记录这些发生引用更新的地方;(不需要停顿)
- Step3 重新标记: 为了修正 Step2并发标记期间 因用户程序继续运行而导致标记产生变动的那一部分对象的标记记录;(需要停顿)
- Step4 并发清除: 开启用户线程,同时 GC 线程开始对未标记的区域做清扫。(不需要进行停顿)
从它的名字就可以看出它是一款优秀的垃圾收集器,主要优点:并发收集、低停顿。
但是它有下面三个明显的缺点:1)对 CPU 资源敏感;2)无法处理浮动垃圾;3)它使用的回收算法-“标记-清除”算法会导致收集结束时会有大量空间碎片产生。
从 JDK9 开始,CMS 收集器已被弃用(G1 垃圾收集器成为了默认的垃圾收集器)。
- G1 收集器
G1 (Garbage-First) 是一款面向服务器的垃圾收集器,主要针对配备多颗处理器及大容量内存的机器。 以极高概率满足 GC 停顿时间要求的同时,还具备高吞吐量性能特征。
堆被分为新生代和老年代,其它收集器进行收集的范围都是整个新生代或者老年代,而 G1 可以直接对新生代和老年代一起回收。
G1 把堆划分成多个大小相等的独立区域(Region),新生代和老年代不再物理隔离,如图所示:
通过引入 Region 的概念,从而将原来的一整块内存空间划分成多个的小空间,使得每个小空间可以单独进行垃圾回收。
G1 收集器通过记录每个 Region 垃圾回收时间以及回收所获得的空间(这两个值是通过过去回收的经验获得),并在后台维护了一个优先列表,每次根据允许的收集时间,优先选择回收价值最大的 Region(这也就是它的名字 Garbage-First 的由来) 。这种使用 Region 划分内存空间以及有优先级的区域回收方式,保证了 G1 收集器在有限时间内可以尽可能高的收集效率(把内存化整为零)。
每个 Region 都有一个 Remembered Set,用来记录该 Region 对象的引用对象所在的 Region。通过使用 Remembered Set,在做可达性分析的时候就可以避免全堆扫描。
如果不计算维护 Remembered Set 的操作,G1 收集器的运作大致分为以下几个步骤:
- Step1 初始标记
- Step2 并发标记
- Step3 最终标记:为了修正在并发标记期间因用户程序继续运作而导致标记产生变动的那一部分标记记录,虚拟机将这段时间对象变化记录在线程的 Remembered Set Logs 里面,最终标记阶段需要把 Remembered Set Logs 的数据合并到 Remembered Set 中。这阶段需要停顿线程,但是可并行执行。
- Step4 筛选回收:首先对各个 Region 中的回收价值和成本进行排序,根据用户所期望的 GC 停顿时间来制定回收计划。此阶段其实也可以做到与用户程序一起并发执行,但是因为只回收一部分 Region,时间是用户可控制的,而且停顿用户线程将大幅度提高收集效率。
被视为 JDK1.7 中 HotSpot 虚拟机的一个重要进化特征。它具备以下特点:
- 并行与并发:G1 能充分利用 CPU、多核环境下的硬件优势,使用多个 CPU(CPU 或者 CPU 核心)来缩短 Stop-The-World 停顿时间。部分其他收集器原本需要停顿 Java 线程执行的 GC 动作,G1 收集器仍然可以通过并发的方式让 java 程序继续执行。
- 分代收集:虽然 G1 可以不需要其他收集器配合就能独立管理整个 GC 堆,但是还是保留了分代的概念。
- 空间整合:与 CMS 的“标记-清除”算法不同,G1 从整体来看是基于“标记-整理”算法实现的收集器;从局部上来看是基于“标记-复制”算法实现的。
- 可预测的停顿:这是 G1 相对于 CMS 的另一个大优势,降低停顿时间是 G1 和 CMS 共同的关注点,但 G1 除了追求低停顿外,还能建立可预测的停顿时间模型,能让使用者明确指定在一个长度为 M 毫秒的时间片段内,消耗在垃圾收集上的时间不得超过 N 毫秒。
从 JDK9 开始,G1 垃圾收集器成为了默认的垃圾收集器。
5. 垃圾回收算法为什么只用于堆?
1️⃣ 答:
垃圾回收算法主要应用于堆(Heap):
- 堆是 JVM 中用于存储对象实例和数组的主要区域,这些对象在程序运行时动态创建和销毁,其生命周期是最不稳定的。因此,需要垃圾回收机制来自动追踪和回收不再使用的对象,以释放内存。
- 栈内存的特性:相比之下,栈内存用于存储方法调用的局部变量和方法的执行上下文。栈上的数据具有明确的生命周期,通常与方法的执行周期相对应。当方法执行完毕并返回时,栈帧被弹出,其所占用的内存自动释放。由于栈内存的这种自动管理特性,不需要垃圾回收算法来介入。
6. 什么是新生代?什么是老年代?
1️⃣ 答:
新生代(Young Generation)和老年代(Old Generation)是堆内存的两个主要部分,用于存储对象。这种内存划分有助于优化垃圾回收的性能。
新生代
- 主要用于存放新创建的对象。新生代会分为三个区域:
- Eden(伊甸园,80%)
- S0(Survivor 0 区,10%)
- S1(Survivor 1 区,10%)
老年代
- 一方面,用于存放那些在新生代中经历了多次垃圾回收仍然存活的对象。这些对象通常生命周期较长。
- 另一方面,大对象直接进入老年代,这是内存分配策略之一。
- (延伸)当老年代空间不足以容纳新存活的对象时,会触发 Full GC,这种GC通常比Minor GC耗时更长,因为它涉及的对象更多,且可能需要暂停所有应用线程。
2️⃣ 扩展问题
- Minor GC 和 Full GC 有什么不同?
答:
- 回收对象/区域:
- Minor GC 只对新生代进行垃圾收集,这个区域通常用于存放新创建的对象。Minor GC 主要回收新生代中不再被引用的对象;
- Full GC 则针对整个堆内存进行回收,包括新生代和老年代。
- 发生频率:Minor GC 通常比 Full GC 更频繁。
- 新生代中的对象具有较短的生命周期,意味着新生代中会产生大量的垃圾对象,需要频繁地进行垃圾回收以释放内存空间,因此触发 Minor GC 的频率比较高;
- 相比之下,老年代中的对象通常具有较长的生命周期,相对稳定一些,而且 Full GC 的触发条件相对苛刻,因此 Full GC 的执行频率相对较低。
- 对系统性能的影响:
- Minor GC 只涉及新生代内存的回收,其执行速度通常较快,对系统性能的影响较小;
- Full GC 涉及整个堆内存的回收,对系统的性能和响应时间产生较大的影响。
细节:
(1)GC 种类
针对 HotSpot VM 的实现,它里面的 GC 其实准确分类只有两大种:
- 部分收集 (Partial GC):
- 新生代收集(Minor GC / Young GC):只对新生代进行垃圾收集;
- 老年代收集(Major GC / Old GC):只对老年代进行垃圾收集。
- 目前,只有 CMS GC 会有单独收集老年代的行为
- 需要注意的是 Major GC 在有的语境中也用于指代整堆收集;
- 混合收集(Mixed GC):对整个新生代和部分老年代进行垃圾收集。
- 整堆收集 (Full GC):收集整个 Java 堆和方法区。
(2)Full GC 的触发条件
对于 Minor GC,其触发条件非常简单,当 Eden 空间满时,就将触发一次 Minor GC。
而 Full GC 则相对复杂,有以下条件:
- 调用
System.gc()
- 老年代空间不足
- 空间分配担保失败
- JDK 1.7 及以前的永久代空间不足
Concurrent Mode Failure
- GC 如何判断回收的垃圾对象?
堆中几乎放着所有的对象实例,对堆垃圾回收前的第一步就是要判断哪些对象已经死亡(即不能再被任何途径使用的对象):
方法1:引用计数法
给对象中添加一个引用计数器:
- 每当有一个地方引用它,计数器就加 1;
- 当引用失效,计数器就减 1;
- 任何时候计数器为 0 的对象就是不可能再被使用的。
优点:实现简单,效率高
缺点:目前主流的虚拟机中并没有选择这个算法来管理内存,其最主要的原因是它很难解决对象之间循环引用的问题。
方法2:可达性分析法
这个算法的基本思想就是通过一系列的称为 “GC Roots” 的对象作为起点,从这些节点开始向下搜索,节点所走过的路径称为引用链,当一个对象到 GC Roots 没有任何引用链相连的话,则证明此对象是不可用的,需要被回收。
哪些对象可以作为 GC Roots 呢?
- 虚拟机栈(栈帧中的局部变量表)中引用的对象
- 本地方法栈(Native 方法)中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 所有被同步锁持有的对象
- JNI(Java Native Interface)引用的对象
7. 老年代当中的对象有什么特点?
1️⃣ 答:
老年代中的对象的特点:
- 生命周期更长:老年代主要用于存放那些在新生代中经历了多次垃圾回收仍然存活的对象。这些对象通常已经证明了它们的长期价值,因此被移至老年代以继续保留。
- 对象通常比较大
- 内存分配策略之一:大对象直接进入老年代
- ...
- 对象的稳定性更高:由于老年代中的对象存活时间较长,因此这些对象相对稳定,不会像新生代中的对象那样频繁地被创建和销毁。
8. 创建线程的方式有哪些?
1️⃣ 答:
Java 中创建线程的三种标准方式:
- 继承
Thread
类,重写run()
方法
// 1) 继承 `Thread` 类
public class MyThread extends Thread {
// 2) 重写 `run()` 方法
@Override
public void run() {
// ...
}
}
2
3
4
5
6
7
8
2
3
4
5
6
7
8
class Main {
public static void main(String[] args) {
MyThread instance = new MyThread(); // 3) 创建线程对象
instance.start();
}
}
2
3
4
5
6
2
3
4
5
6
- 实现
Runnable
接口,重写run()
方法
// 1) 实现 `Runnable` 接口
public class MyRunnable implements Runnable {
// 2) 重写 `run()` 方法
@Override
public void run() {
// ...
}
}
2
3
4
5
6
7
8
2
3
4
5
6
7
8
class Main {
public static void main(String[] args) {
MyRunnable instance = new MyRunnable(); // 3) 创建线程对象
Thread thread = new Thread(instance);
thread.start();
}
}
2
3
4
5
6
7
2
3
4
5
6
7
- (前两种方式都不能拿到线程的返回值) 实现
Callable
接口,重写call()
方法,返回值通过FutureTask
进行封装
// 1) 实现 `Callable` 接口,返回值为 Integer
public class MyCallable implements Callable<Integer> {
// 2) 重写 `call()` 方法
@Override
public Integer call() throws Exception {
// ...
return 123;
}
}
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
class Main {
public static void main(String[] args) {
MyCallable instance = new MyCallable(); // 3) 创建线程对象
FutureTask<Integer> ft = new FutureTask<>(instance);
Thread thread = new Thread(ft);
thread.start();
System.out.println(ft.get());
}
}
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
还有两种有歧义的方式(不建议回答,但可以了解,并明白它们的本质):
- Lambda表达式实现
Runnable
接口的方法 - 使用线程池创建:
- 1)通过
ThreadPoolExecutor
构造函数创建(✅推荐)new ThreadPoolExecutor(...);
- 2)通过工具类
Executors
来创建(❎不推荐)Executors.newCachedThreadPool(...);
Executors.newFixedThreadPool(...);
Executors.newSingleThreadExecutor(...);
Executors.newScheduledThreadPool(...);
- 1)通过
9. 说说线程池的参数
Executors 线程池的类结构关系:
1️⃣ 答:
线程池实现类 ThreadPoolExecutor
是 Executor 框架最核心的类。
ThreadPoolExecutor
类中提供的四个构造方法。我们来看最长的那个,其余三个都是在这个构造方法的基础上产生(其他几个构造方法说白点都是给定某些默认参数的构造方法比如默认制定拒绝策略是什么)。
线程池 ThreadPoolExecutor
的7个参数:
- 线程池的核心线程数量:
int corePoolSize
- 线程池的最大线程数(核心线程+临时线程数):
int maximumPoolSize
- 临时线程的最大空闲时间(超过这个时间,临时线程就释放掉):
long keepAliveTime
- 参数3的时间单位(秒/天/...):
TimeUnit unit
- 线程池任务队列(是一个阻塞队列,当线程数达到核心线程数后,会将任务存储在阻塞队列中):
BlockingQueue<Runnable> workQueue
- 线程工厂(创建线程所用的工厂):
ThreadFactory threadFactory
- 拒绝策略(当队列已满并且线程数量达到最大线程数量时,会调用该方法处理任务):
RejectedExecutionHandler handler
源码如下(忽略细节):
public ThreadPoolExecutor(
int corePoolSize, // 核心线程数量
int maximumPoolSize, // 最大线程数
long keepAliveTime, // 最大空闲时间
TimeUnit unit, // 时间单位
BlockingQueue<Runnable> workQueue, // 阻塞/任务队列
ThreadFactory threadFactory, // 线程工厂
RejectedExecutionHandler handler // 拒绝策略
) {
...
}
2
3
4
5
6
7
8
9
10
11
2
3
4
5
6
7
8
9
10
11
2️⃣ 细节 关于线程池,参考:JavaGuide|线程池 (opens new window)
10. 线程池当中有一条空的核心线程?任务来了之后会怎么做?
问题:线程池的核心线程设置为0,任务来了之后怎么做?
1️⃣ 答:
核心线程数设置为0,当任务来了之后,也会创建一个线程来执行任务,
2️⃣ 细节
线程池原理分析(参考 JavaGuide)
为了搞懂线程池的原理,我们需要首先分析一下 execute
方法,该方法的实现在 ThreadPoolExecutor
类中。
// 存放线程池的运行状态 (runState) 和线程池内有效线程的数量 (workerCount)
private final AtomicInteger ctl = new AtomicInteger(ctlOf(RUNNING, 0));
private static int workerCountOf(int c) {
return c & CAPACITY;
}
//任务队列
private final BlockingQueue<Runnable> workQueue;
public void execute(Runnable command) {
// 如果任务为null,则抛出异常。
if (command == null)
throw new NullPointerException();
// ctl 中保存的线程池当前的一些状态信息
int c = ctl.get();
// 下面会涉及到 3 步 操作
// 1.首先判断当前线程池中执行的任务数量是否小于 corePoolSize
// 如果小于的话,通过addWorker(command, true)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。
if (workerCountOf(c) < corePoolSize) {
if (addWorker(command, true))
return;
c = ctl.get();
}
// 2.如果当前执行的任务数量大于等于 corePoolSize 的时候就会走到这里,表明创建新的线程失败。
// 通过 isRunning 方法判断线程池状态,线程池处于 RUNNING 状态并且队列可以加入任务,该任务才会被加入进去
if (isRunning(c) && workQueue.offer(command)) {
int recheck = ctl.get();
// 再次获取线程池状态,如果线程池状态不是 RUNNING 状态就需要从任务队列中移除任务,并尝试判断线程是否全部执行完毕。同时执行拒绝策略。
if (!isRunning(recheck) && remove(command))
reject(command);
// 如果当前工作线程数量为0,新创建一个线程并执行。
else if (workerCountOf(recheck) == 0)
addWorker(null, false);
}
//3. 通过addWorker(command, false)新建一个线程,并将任务(command)添加到该线程中;然后,启动该线程从而执行任务。
// 传入 false 代表增加线程时判断当前线程数是否少于 maxPoolSize
//如果addWorker(command, false)执行失败,则通过reject()执行相应的拒绝策略的内容。
else if (!addWorker(command, false))
reject(command);
}
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
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
详见:JavaGuide (opens new window)
线程池处理任务的流程:
- 如果当前运行的线程数小于核心线程数,那么就会新建一个核心线程
addWorker(command, true)
来执行任务。 - 如果当前运行的线程数等于或大于核心线程数,但是小于最大线程数:
- 那么就把该任务放入到任务队列里等待执行
- 如果当前运行的线程数为0,则新建一个临时线程
addWorker(null, false);
,
- 如果向任务队列投放任务失败(任务队列已经满了),但是当前运行的线程数是小于最大线程数的,就新建一个临时线程来执行任务。
- 如果当前运行的线程数已经等同于最大线程数了,新建线程将会使当前运行的线程超出最大线程数,那么当前任务会被拒绝,饱和策略会调用
RejectedExecutionHandler.rejectedExecution()
方法。
12. 说说 HashMap 的特性?
1️⃣ 答:
HashMap 的特性:
- 高效读写性能:基于 Hash 表映射的数据结构,底层由数组+链表/红黑树实现。
- 无序性:不能保证先进先出的迭代顺序。(
LinkedHashMap
可以,采用双向链表(doubly-linked list)的形式将所有entry
连接起来,这样可以保证元素的迭代顺序跟插入顺序相同) - 键唯一,值不唯一,键值允许为
null
- 非线程安全的容器
2️⃣ 细节
更多:
13. HashMap 中索引是怎么计算的?
1️⃣ 答:
HashMap 索引计算的公式:HashMap 通过 key 的 hashCode 经过扰动函数处理过后得到 hash 值,然后通过 hash(key) & length-1
判断当前元素存放的位置(这里的 length
指的是数组的长度)
hash
扰动函数是为了防止一些实现比较差的 hashCode()
方法,换句话说使用扰动函数之后可以减少碰撞。
HashMap hash 函数:
- JDK 1.7 之前:
static int hash(int h) {
// This function ensures that hashCodes that differ only by
// constant multiples at each bit position have a bounded
// number of collisions (approximately 8 at default load factor).
// >>>:无符号右移,忽略符号位,空位都以0补齐
h ^= (h >>> 20) ^ (h >>> 12);
return h ^ (h >>> 7) ^ (h >>> 4);
}
2
3
4
5
6
7
8
2
3
4
5
6
7
8
- JDK 1.8+ :
static final int hash(Object key) {
int h;
return (key == null) ? 0 : (h = key.hashCode()) ^ (h >>> 16);
}
2
3
4
2
3
4
14. HashMap 的扩容因子是多少?
1️⃣ 答:
HashMap 的默认扩容因子是 0.75(性能和占用空间的一种平衡)
2️⃣ 细节
以下是 HashMap 的其他默认属性
public class HashMap<K,V> extends AbstractMap<K,V> implements Map<K,V>, Cloneable, Serializable {
// 序列号
private static final long serialVersionUID = 362498820763181265L;
// 默认的初始容量是16
static final int DEFAULT_INITIAL_CAPACITY = 1 << 4;
// 最大容量
static final int MAXIMUM_CAPACITY = 1 << 30;
// 默认的负载因子
static final float DEFAULT_LOAD_FACTOR = 0.75f;
// 当桶(bucket)上的结点数大于等于这个值时会转成红黑树
static final int TREEIFY_THRESHOLD = 8;
// 当桶(bucket)上的结点数小于等于这个值时树转链表
static final int UNTREEIFY_THRESHOLD = 6;
// 桶中结构转化为红黑树对应的table的最小容量
static final int MIN_TREEIFY_CAPACITY = 64;
// 存储元素的数组,总是2的幂次倍
transient Node<k,v>[] table;
// 存放具体元素的集
transient Set<map.entry<k,v>> entrySet;
// 存放元素的个数,注意这个不等于数组的长度。
transient int size;
// 每次扩容和更改map结构的计数器
transient int modCount;
// 阈值(容量*负载因子) 当实际大小超过阈值时,会进行扩容
int threshold;
// 负载因子
final float loadFactor;
}
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
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
15. OSI 七层模型各自的作用?
1️⃣ 答:
OSI 七层模型,从上往下:
- 应用层/Application:为计算机用户和应用程序提供服务,例如 DNS(域名解析)
- 表示层/Presentation:(TCP/IP模型里没有) 负责数据处理(编码解码、加密解密、压缩解压)
- 会话层/Session:(TCP/IP模型里没有) 管理(建立、维护、重连)应用程序之间的会话
- 传输层/Transport:为两台主机进程之间的通信提供通用的数据传输服务
- 网络层/Network:负责路由选择(路由和寻址,决定数据在网络的游走路径)
- 数据链路层/DataLink:桢编码和误差纠正控制
- 物理层/Physical:设备层面,透明地传输比特流(硬件基础)
16. TCP 为什么是可靠的?
1️⃣ 答:
TCP 可靠性保证机制:
- 对失序数据包重新排序以及去重:TCP 为了保证不发生丢包,就给每个包一个序列号,有了序列号能够将接收到的数据根据序列号排序,并且去掉重复序列号的数据就可以实现数据包去重。
- 校验和:TCP 将保持它首部和数据的检验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到段的检验和有差错,TCP 将丢弃这个报文段和不确认收到此报文段。
- 重传机制:在数据包丢失或延迟的情况下,重新发送数据包,直到收到对方的确认应答(ACK)。
- 流量控制:TCP 连接的每一方都有固定大小的缓冲空间,TCP 的接收端只允许发送端发送接收端缓冲区能接纳的数据。当接收方来不及处理发送方的数据,能提示发送方降低发送的速率,防止包丢失。TCP 使用的流量控制协议是可变大小的滑动窗口协议(TCP 利用滑动窗口实现流量控制)。
- 拥塞控制:当网络拥塞时,减少数据的发送。TCP 在发送数据的时候,需要考虑两个因素:一是接收方的接收能力,二是网络的拥塞程度。发送方发送数据的大小是滑动窗口和拥塞窗口的最小值,这样可以保证发送方既不会超过接收方的接收能力,也不会造成网络的过度拥塞。
- 接收方的接收能力由滑动窗口表示,表示接收方还有多少缓冲区可以用来接收数据。
- 网络的拥塞程度由拥塞窗口表示,它是发送方根据网络状况自己维护的一个值,表示发送方认为可以在网络中传输的数据量。
2️⃣ 细节
- 应用层(Application layer)的主要任务就是负责向两台终端设备的应用程序之间提供信息交换服务
- 传输层(Transport layer)的主要任务就是负责向两台终端设备的进程之间的通信提供通用的数据传输服务
传输层常见协议:
- TCP(Transmission Control Protocol,传输控制协议 ):提供 面向连接 的,可靠 的数据传输服务。
- UDP(User Datagram Protocol,用户数据协议):提供 无连接 的,尽最大努力 的数据传输服务(不保证数据传输的可靠性),简单高效。
17. TCP 二次握手会出现什么问题?
常见问题:为什么是三次握手?不是两次、四次?
比较常回答的是:“因为三次握手才能保证双方具有接收和发送的能力。”
这回答是没问题,但这回答是片面的,并没有说出主要的原因。
1️⃣ 答:
简洁: TCP 两次握手时,服务端不能证明自己的发送能力和对方的接收能力。
详细:
TCP 使用三次握手建立连接的最主要原因就是防止「历史连接」初始化了连接。
如果是两次握手连接,就无法阻止「历史连接」,因为在两次握手的情况下,服务端没有中间状态给客户端来阻止「历史连接」,导致服务端可能建立一个「历史连接」,造成资源浪费。
🔰 不使用「两次握手」和「四次握手」的原因:
- 「两次握手」:无法防止历史连接的建立,会造成双方资源的浪费,也无法可靠的同步双方序列号;
- 「四次握手」:三次握手就已经理论上最少可靠连接建立,所以不需要使用更多的通信次数。
18. 说说 TCP 三次握手的过程?
1️⃣ 答:
TCP 三次握手的过程:
- 一开始,客户端A和服务端B都处于 CLOSE 状态。先是服务端B主动监听某个端口,处于 LISTEN 状态
- 客户端A给服务端B发送请求,之后客户端A状态变更为 SYN-SEND,发送的消息:
SYN=1, seq=x
(x
是客户端A随机初始化的序号) - 服务端B接收并响应客户端A,之后服务端B状态变更为 SYN-RCVD,发送的消息:
SYN=1, ACK=1, seq=y, ack=x+1
(y
是服务端B随机初始化的序号) - 客户端A接收并响应服务端B(最后一个应答报文),之后客户端A状态变更为 ESTABLISHED,发送的消息:
ACK=1, seq=x+1, ack=y+1
- 服务端B收到客户端A的应答报文后,状态变更为 ESTABLISHED
一旦完成三次握手,双方都处于 ESTABLISHED 状态,此时连接就已建立完成,客户端A和服务端B就可以相互发送数据了。
注:SYN
和 ACK
是标志位状态,seq
是序列号(32位),ack
是确认应答号(32位)。
如图:
19. 说说 TCP 四次挥手的过程?
1️⃣ 答:
双方都可以主动断开连接,断开连接后主机中的「资源」将被释放。以客户端主动关闭连接为例,TCP 四次挥手的过程:
- 一开始,客户端A和服务端B都处于 ESTABLISHED 状态,现在客户端A主动关闭连接
- 客户端A给服务端B发送断开请求,之后客户端A状态变更为 FIN_WAIT_1,发送的消息:
FIN=1, seq=u
(u
等于客户端A前面已传送过的数据的最后一个字节的序号+1) - 服务端B接收并响应客户端A(发送ACK应答报文),之后服务端B状态变更为 CLOSE_WAIT,发送的消息:
ACK=1, seq=v, ack=u+1
(v
等于服务端B前面已传送过的数据的最后一个字节的序号+1) - 客户端A收到服务端B的ACK应答报文后,状态变更为 FIN_WAIT_2
(等待服务端B处理完数据后...)
- 服务端B给客户端A发送断开请求,之后服务端B状态变更为 LAST_ACK,发送的消息:
FIN=1, ACK=1, seq=w, ack=u+1
(在CLOSE_WAIT状态可能又发送了一些数据,因此序号变成w
) - 客户端A接收并响应服务端B(发送ACK应答报文),之后客户端A状态变更为 TIME_WAIT,发送消息:
ACK=1, seq=u+1, ack=w+1
- 服务端B收到客户端A的应答报文后,状态变更为 CLOSE,至此服务端已经完成连接的关闭
- 客户端A在经过 2MSL(MSL,最长报文段寿命) 时间后,状态变更为 CLOSE,至此客户端也完成连接的关闭。
注:FIN
和 ACK
是标志位状态,seq
是序列号(32位),ack
是确认应答号(32位)。
如图:
20. 键入网址到显示网页,这个过程发生了什么?
1️⃣ 答:
URL 执行流程:
- 在「浏览器B」中输入指定网页的 URL,首先会进行正确性效验。
- URL(Uniform Resource Locators,统一资源定位符)= 协议+域名/IP+端口+资源路径+参数+锚点
- 「浏览器B」通过 DNS 协议,获取「服务器S」域名对应的 IP 地址。
- 「浏览器B」根据 IP 地址和端口号,向「服务器S」发起一个 TCP 连接请求。
- 「浏览器B」在 TCP 连接上,向「服务器S」发送一个 HTTP 请求报文,请求获取网页的内容。
- 「服务器S」收到 HTTP 请求报文后,处理请求,并返回 HTTP 响应报文给「浏览器B」。
- 「浏览器B」收到 HTTP 响应报文后,解析响应体中的 HTML 代码,渲染网页的结构和样式,同时根据 HTML 中的其他资源的 URL(如图片、CSS、JS 等),再次发起 HTTP 请求,获取这些资源的内容,直到网页完全加载显示。
- HTML 页面中会请求一些后端API,「服务器S」执行业务流程,并响应结果给客户端
- 「浏览器B」在不需要和「服务器S」通信时,可以主动关闭 TCP 连接,或者等待「服务器S」的关闭请求。
参考:
21. 描述一下归并排序和快速排序的实现?
归并排序:分治法。 快排三大特征: 1)找基准值;2)分区排序
# 腾讯QQ(二面) - 2024.04.25
1. Java 中类加载的过程?
1️⃣ 答:
类加载过程:
- Step1 加载/Loading:查找并加载类的二进制字节流;
- Step2 验证/Verifcation:确保被加载的类的正确性;
- Step3 准备/Preparation:为类的静态变量分配内存,并将其初始化为默认值;
- Step4 解析/Resolution:把类中的符号引用转换为直接引用;
- Step5 初始化/Initialization:执行初始化方法
<clinit>()
,为类的静态变量赋予正确的初始值
2️⃣ 细节
2. 解释一下Java中的强引用
JDK1.2 之前,Java 中引用的定义很传统:如果 reference 类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用。
JDK1.2 以后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱)
1️⃣ 答:
强引用(StrongReference)
(1)概念
大部分引用实际上都是强引用,这是使用最普遍的引用。如果一个对象具有强引用,那就类似于必不可少的生活用品,垃圾回收器绝不会回收它。当内存空间不足,Java 虚拟机宁愿抛出 OutOfMemoryError
错误,使程序异常终止,也不会靠随意回收具有强引用的对象来解决内存不足问题。
(2)创建方式:
Object obj = new Object();
3. 解释一下Java中的其他几种引用类型
JDK1.2 之前,Java 中引用的定义很传统:如果 reference 类型的数据存储的数值代表的是另一块内存的起始地址,就称这块内存代表一个引用。
JDK1.2 以后,Java 对引用的概念进行了扩充,将引用分为强引用、软引用、弱引用、虚引用四种(引用强度逐渐减弱)
1️⃣ 答:
Java 具有四种强度不同的引用类型:
- 强引用(StrongReference)
- 被强引用关联的对象不会被回收
- 创建方式:
Object obj = new Object();
11
- 软引用(SoftReference)
- 被软引用关联的对象只有在内存不够的情况下才会被回收
- 创建方式:
Object obj = new Object(); SoftReference<Object> sf = new SoftReference<Object>(obj); obj = null; // 把强引用 obj 释放掉,使对象只被软引用 sf 关联
1
2
31
2
3
- 弱引用(WeakReference)
- 被弱引用关联的对象一定会被回收,也就是说它只能存活到下一次垃圾回收发生之前。
- 创建方式:
Object obj = new Object(); WeakReference<Object> wf = new WeakReference<Object>(obj); obj = null;
1
2
31
2
3
- 虚引用(PhantomReference)
- 形同虚设,如果一个对象仅持有虚引用,那么它就和没有任何引用一样,在任何时候都可能被垃圾回收。
- 虚引用主要用来跟踪对象被垃圾回收的活动。
- 创建方式:
Object obj = new Object(); PhantomReference<Object> pf = new PhantomReference<Object>(obj); obj = null;
1
2
31
2
3
在程序设计中一般很少使用弱引用与虚引用,使用软引用的情况较多,这是因为软引用可以加速 JVM 对垃圾内存的回收速度,可以维护系统的运行安全,防止内存溢出(OutOfMemory
)等问题的产生。
4. 内存泄漏是啥?
1️⃣ 答:
内存泄漏是指应用程序中分配的内存(通常是堆内存)在不再需要时未能正确释放。这些未释放的内存块会积累,最终导致应用程序消耗过多的内存资源,甚至可能导致应用程序崩溃或变得非常缓慢。内存泄漏通常是由于不正确的对象引用管理或资源未正确释放而导致的。
2️⃣ 细节
深入探讨Java面试中内存泄漏:如何识别、预防和解决 (opens new window)
3️⃣ 扩展问题
- ThreadLocal 内存泄露问题是怎么导致的?
ThreadLocalMap
中使用的 key
为 ThreadLocal
的弱引用,而 value
是强引用。所以,如果 ThreadLocal
没有被外部强引用的情况下,在垃圾回收的时候,key
会被清理掉,而 value
不会被清理掉。
这样一来,ThreadLocalMap
中就会出现 key
为 null
的 Entry
。假如我们不做任何措施的话,value
永远无法被 GC 回收,这个时候就可能会产生内存泄露。ThreadLocalMap
实现中已经考虑了这种情况,在调用 set()
、get()
、remove()
方法的时候,会清理掉 key
为 null
的记录。使用完 ThreadLocal
方法后最好手动调用 remove()
方法
static class Entry extends WeakReference<ThreadLocal<?>> {
/** The value associated with this ThreadLocal. */
Object value;
Entry(ThreadLocal<?> k, Object v) {
super(k);
value = v;
}
}
2
3
4
5
6
7
8
9
2
3
4
5
6
7
8
9
5. 说说进程和线程的区别?
1️⃣ 答:
(1)概念
- 进程(Process) 是指计算机中正在运行的一个程序实例。举例:你打开的微信就是一个进程。
- 线程(Thread) 也被称为轻量级进程,更加轻量。多个线程可以在同一个进程中同时执行,并且共享进程的资源,比如内存空间、文件句柄、网络连接等。举例:你打开的微信里就有一个线程专门用来拉取别人发你的最新的消息。
在早期的操作系统中都是以进程作为独立运行的基本单位,直到后面,计算机科学家们又提出了更小的能独立运行的基本单位,也就是线程。
(2)区别
- 基本单位:进程是操作系统分配资源的基本单位;线程是程序执行(CPU调度)的基本单位;
- 包含关系:一个进程可以包含一个或多个线程;线程不能包含进程;
- 资源占有:进程拥有一个完整的资源平台(独立的内存空间和资源);线程只独享必不可少的资源(如寄存器和栈),共享进程的内存和资源;
- 状态:进程和线程都具有就绪、阻塞、执行三种基本状态,同样具有状态之间的转换关系;
- 开销:线程能减少并发执行的时间和空间开销(进程切换的成本比较大;线程切换成本比较小);
对于“线程相比进程能减少开销”,体现在:
- 创建开销:线程的创建时间比进程快,因为进程在创建的过程中,还需要资源管理信息,比如内存管理信息、文件管理信息,而线程在创建的过程中,不会涉及这些资源管理信息,而是共享它们;
- 销毁开销:线程的终止时间比进程快,因为线程释放的资源相比进程少很多;
- 切换开销:同一个进程内的线程切换比进程切换快,因为线程具有相同的地址空间(虚拟内存共享),这意味着同一个进程的线程都具有同一个页表,那么在切换的时候不需要切换页表。而对于进程之间的切换,切换的时候要把页表给切换掉,而页表的切换过程开销是比较大的;
- 交互开销:由于同一进程的各线程间共享内存和文件资源,那么在线程之间数据传递的时候,就不需要经过内核了,这就使得线程之间的数据交互效率更高了;
6. 什么是线程安全?
1️⃣ 答:
在Java中,线程安全主要指的是多线程访问同一段代码或数据时,不会产生不确定的结果(程序执行符合预期)。
**换句话说:**在多线程并发执行的情况下,一段程序的运行结果,和单线程下运行它产生的结果是一致的,跟线程数和运行次数没有关系。
具体来说,如果一个类或一段代码在多线程环境下执行时,能够表现出正确的行为,并且其结果符合预期,不会因为多线程的交替执行而导致数据不一致或其他不可预测的问题,那么这个类或代码就被认为是线程安全的。
线程安全不是一个非真即假的命题,可以将共享数据按照安全程度的强弱顺序分成以下五类: 不可变、绝对线程安全、相对线程安全、线程兼容和线程对立。
线程安全三方面/并发三要素:①原子性、②可见性 和 ③有序性
- 线程安全体现在这三个方面
- 原子性:一次操作或者多次操作,要么所有的操作全部都得到执行并且不会受到任何因素的干扰而中断,要么都不执行;
- 可见性:指一个线程对共享变量的修改能够立即被其他线程所感知;
- 有序性:保证了线程内的操作按照代码的顺序执行,防止指令重排序优化导致的问题。
- 并发问题也是因为这三个方面遭到破坏
- 原子性:时分复用引起
- 可见性:CPU缓存引起
- 有序性:重排序引起
2️⃣ 细节
更多线程安全的相关知识:
7. Java 中保证线程安全的手段有哪些?
1️⃣ 答:
保证线程安全手段:
Java并发安全可以从两大类概括,
1)不需要共享数据的场景
只需要做好线程隔离就行。比如,如果需要传递参数,就使用 ThreadLocal
;如果不需要参数传递,就定义为局部变量。
最重要的一个应用实例就是经典 Web 交互模型中的“一个请求对应一个服务器线程”(Thread-per-Request)的处理方式
2)需要共享数据的场景
- 加锁(阻塞同步/互斥同步)
- 单机锁(保证同一时刻只有一个线程操作数据):
synchronized
、ReentrantLock
- 分布式锁:
Redisson
、Zookeeper
- 读写锁:读不占用锁,写占用锁,在读多写少的并发场景提高性能,例如
ReentrantReadWriteLock
- 单机锁(保证同一时刻只有一个线程操作数据):
- 不加锁(非阻塞同步)
- CAS
- AtomicInteger
- ABA
- 使用线程安全的容器
CurrentHashMap
CopyOnWriteArrayList
8. 什么是死锁?产生死锁的条件有哪些?
1️⃣ 答:
死锁(Deadlock)描述的是这样一种情况:多个进程/线程同时被阻塞,它们中的一个或者全部都在等待某个资源被释放。由于进程/线程被无限期地阻塞,因此程序不可能正常终止。
—— JavaGuide
死锁指两个以上的运算单元(进程、线程、虚拟线程/协程),都在等待对方释放资源,但没有一方提前释放资源,所造成的阻塞现象就叫做死锁。
产生死锁的四个必要条件:
- 互斥条件:资源必须处于非共享模式,即一次只有一个进程可以使用。如果另一进程申请该资源,那么必须等待直到该资源被释放为止。
- 占有并等待条件:一个进程至少应该占有一个资源,并等待另一资源,而该资源被其他进程所占有。
- 不可剥夺条件:资源不能被抢占。只能在持有资源的进程完成任务后,该资源才会被释放。
- 环路等待条件:有一组等待进程
{P0, P1,..., Pn}
,P0
等待的资源被P1
占有,P1
等待的资源被P2
占有,……,Pn-1
等待的资源被Pn
占有,Pn
等待的资源被P0
占有。
⚠️注意:这四个条件是产生死锁的必要条件,也就是说只要系统发生死锁,这些条件必然成立,而只要上述条件之一不满足,就不会发生死锁。
9. 解决死锁的方法有哪些?
解决死锁的方法可以从多个角度去分析,一般的情况下,有 1)预防;2)避免;3)检测;4)解除 四种。
1)预防 — 是采用某种策略,限制并发进程对资源的请求,从而使得死锁的必要条件在系统执行的任何时间上都不满足。
2)避免 — 系统在分配资源时,根据资源的使用情况提前做出预测,从而避免死锁的发生
3)检测 — 是指系统设有专门的机构,当死锁发生时,该机构能够检测死锁的发生,并精确地确定与死锁有关的进程和资源。
4)解除 — 是与检测相配套的一种措施,用于将进程从死锁状态下解脱出来。
1️⃣ 答:
Java中解决死锁的方法:
- 打破环路等待条件:顺序锁(获取锁的顺序是一致),按序申请资源来预防。按某一顺序申请资源,释放资源则反序释放。
- 打破占有并等待条件:
- 一次性申请所有的资源
- 使用
ReentrantLock
的tryLock
方法
- 打破不可剥夺条件:占用部分资源的线程进一步申请其他资源时,如果申请不到,可以主动释放它占有的资源
2️⃣ 细节
更详细的内容参考:操作系统常见面试题总结(上) (opens new window)
10. 说说 TCP 三次握手?
1️⃣ 答:
TCP 三次握手的过程:
- 一开始,客户端A和服务端B都处于 CLOSE 状态。先是服务端B主动监听某个端口,处于 LISTEN 状态
- 客户端A给服务端B发送请求,之后客户端A状态变更为 SYN-SEND,发送的消息:
SYN=1, seq=x
(x
是客户端A随机初始化的序号) - 服务端B接收并响应客户端A,之后服务端B状态变更为 SYN-RCVD,发送的消息:
SYN=1, ACK=1, seq=y, ack=x+1
(y
是服务端B随机初始化的序号) - 客户端A接收并响应服务端B(最后一个应答报文),之后客户端A状态变更为 ESTABLISHED,发送的消息:
ACK=1, seq=x+1, ack=y+1
- 服务端B收到客户端A的应答报文后,状态变更为 ESTABLISHED
一旦完成三次握手,双方都处于 ESTABLISHED 状态,此时连接就已建立完成,客户端A和服务端B就可以相互发送数据了。
注:SYN
和 ACK
是标志位状态,seq
是序列号(32位),ack
是确认应答号(32位)。
如图:
11. 说说 TCP 四次挥手?
1️⃣ 答:
双方都可以主动断开连接,断开连接后主机中的「资源」将被释放。以客户端主动关闭连接为例,TCP 四次挥手的过程:
- 一开始,客户端A和服务端B都处于 ESTABLISHED 状态,现在客户端A主动关闭连接
- 客户端A给服务端B发送断开请求,之后客户端A状态变更为 FIN_WAIT_1,发送的消息:
FIN=1, seq=u
(u
等于客户端A前面已传送过的数据的最后一个字节的序号+1) - 服务端B接收并响应客户端A(发送ACK应答报文),之后服务端B状态变更为 CLOSE_WAIT,发送的消息:
ACK=1, seq=v, ack=u+1
(v
等于服务端B前面已传送过的数据的最后一个字节的序号+1) - 客户端A收到服务端B的ACK应答报文后,状态变更为 FIN_WAIT_2
(等待服务端B处理完数据后...)
- 服务端B给客户端A发送断开请求,之后服务端B状态变更为 LAST_ACK,发送的消息:
FIN=1, ACK=1, seq=w, ack=u+1
(在CLOSE_WAIT状态可能又发送了一些数据,因此序号变成w
) - 客户端A接收并响应服务端B(发送ACK应答报文),之后客户端A状态变更为 TIME_WAIT,发送消息:
ACK=1, seq=u+1, ack=w+1
- 服务端B收到客户端A的应答报文后,状态变更为 CLOSE,至此服务端已经完成连接的关闭
- 客户端A在经过 2MSL(MSL,最长报文段寿命) 时间后,状态变更为 CLOSE,至此客户端也完成连接的关闭。
注:FIN
和 ACK
是标志位状态,seq
是序列号(32位),ack
是确认应答号(32位)。
如图:
2️⃣ 扩展问题
- 为什么第四次挥手客户端需要等待 2*MSL(报文段最长寿命)时间后才进入 CLOSED 状态?
MSL 是 Maximum Segment Lifetime,报文最大生存时间,它是任何报文在网络上存在的最长时间,超过这个时间报文将被丢弃。
第四次挥手时,客户端发送给服务器的 ACK 有可能丢失,如果服务端因为某些原因而没有收到 ACK 的话,服务端就会重发 FIN,如果客户端在 2*MSL 的时间内收到了 FIN,就会重新发送 ACK 并再次等待 2MSL,防止 Server 没有收到 ACK 而不断重发 FIN。
网络中可能存在来自发送方的数据包,当这些发送方的数据包被接收方处理后又会向对方发送响应,所以一来一回需要等待 2 倍的时间。
12. TCP和UDP的区别
1️⃣ 答:
- 连接
- TCP 是面向连接的传输层协议,传输数据前先要建立连接。
- UDP 是不需要连接,即刻传输数据。
- 服务对象
- TCP 是一对一的两点服务,即一条连接只有两个端点。
- UDP 支持一对一、一对多、多对多的交互通信
- 可靠性
- TCP 是可靠交付数据的,数据可以无差错、不丢失、不重复、按序到达。
- UDP 是尽最大努力交付,不保证可靠交付数据。
- 拥塞控制、流量控制
- TCP 有拥塞控制和流量控制机制,保证数据传输的安全性。
- UDP 则没有,即使网络非常拥堵了,也不会影响 UDP 的发送速率。
- 首部开销
- TCP 首部长度较长,会有一定的开销,首部在没有使用「选项」字段时是 20 个字节,如果使用了「选项」字段则会变长的。
- UDP 首部只有 8 个字节,并且是固定不变的,开销较小。
- 传输方式
- TCP 是流式传输,没有边界,但保证顺序和可靠。
- UDP 是一个包一个包的发送,是有边界的,但可能会丢包和乱序。
- 分片不同
- TCP 的数据大小如果大于 MSS 大小,则会在传输层进行分片,目标主机收到后,也同样在传输层组装 TCP 数据包,如果中途丢失了一个分片,只需要传输丢失的这个分片。
- UDP 的数据大小如果大于 MTU 大小,则会在 IP 层进行分片,目标主机收到后,在 IP 层组装完数据,接着再传给传输层。
2️⃣ 细节
TCP 和 UDP 应用场景:
由于 TCP 是面向连接,能保证数据的可靠性交付,因此经常用于:
- FTP 文件传输;
- HTTP / HTTPS;
由于 UDP 面向无连接,它可以随时发送数据,再加上 UDP 本身的处理既简单又高效,因此经常用于:
- 包总量较少的通信,如 DNS 、SNMP 等;
- 视频、音频等多媒体通信;
- 广播通信;
13. 如何把 UDP 变成类似于TCP那样可靠的协议?
TCP保证可靠性的机制:腾讯QQ(一面)
TCP 可靠性保证机制:
- 对失序数据包重新排序以及去重:TCP 为了保证不发生丢包,就给每个包一个序列号,有了序列号能够将接收到的数据根据序列号排序,并且去掉重复序列号的数据就可以实现数据包去重。
- 校验和:TCP 将保持它首部和数据的检验和。这是一个端到端的检验和,目的是检测数据在传输过程中的任何变化。如果收到段的检验和有差错,TCP 将丢弃这个报文段和不确认收到此报文段。
- 重传机制:在数据包丢失或延迟的情况下,重新发送数据包,直到收到对方的确认应答(ACK)。
- 流量控制:TCP 连接的每一方都有固定大小的缓冲空间,TCP 的接收端只允许发送端发送接收端缓冲区能接纳的数据。当接收方来不及处理发送方的数据,能提示发送方降低发送的速率,防止包丢失。TCP 使用的流量控制协议是可变大小的滑动窗口协议(TCP 利用滑动窗口实现流量控制)。
- 拥塞控制:当网络拥塞时,减少数据的发送。TCP 在发送数据的时候,需要考虑两个因素:一是接收方的接收能力,二是网络的拥塞程度。发送方发送数据的大小是滑动窗口和拥塞窗口的最小值,这样可以保证发送方既不会超过接收方的接收能力,也不会造成网络的过度拥塞。
- 接收方的接收能力由滑动窗口表示,表示接收方还有多少缓冲区可以用来接收数据。
- 网络的拥塞程度由拥塞窗口表示,它是发送方根据网络状况自己维护的一个值,表示发送方认为可以在网络中传输的数据量。
14. 说说 HashMap 添加流程?
1️⃣ 答:
HashMap 的 put(key, value)
添加方法:
- 计算
key
的hash
值(通过hash(key)
计算):- JDK 1.7:
- (1)
h ^= (h >>> 20) ^ (h >>> 12);
- (2)
h ^ (h >>> 7) ^ (h >>> 4);
- (1)
- JDK 1.8:
((h = key.hashCode()) ^ (h >>> 16))
- JDK 1.7:
- 定位
key
存储位置 bucket 下标:index = hash & (table.length - 1)
; - 插入操作,分情况:
- 情况1 bucket 为空桶:直接插入
- 情况2 bucket 非空桶:快速判断第一个节点是否与插入的
key
相同,相同则插入,不同则继续...(JDK 1.8 要先判断是链表还是红黑树,再接着遍历)key
不存在:将键值存储到链表/红黑树尾部;(Java7 是插入到链表的最前面)key
存在:遍历链表/红黑树比较 key,如果存在 key,更新 value;
- 判断当前 bucket 链表是否需要升级为红黑树:链表长度大于 8 ,并且数组大于 64,则升级为红黑树;
- 扩容判断:
++size > threshold
成立,则进行扩容操作;(Java7 是先扩容后插入新值的,Java8 先插值再扩容) - 将执行结果返回
15. 说说快速排序
快速排序核心思想是选择一个基准元素(通常是第一个元素),将数组中的元素和基准值进行对比,小于基准值的放在基准值左边,大于基准值的放在基准值的右边,这就做分区排序,然后再进行递归。
快速排序3大步:
- 找基准值
- 分区排序
- 递归排序
16. 设计模式
常见的设计模式:
- 单例模式:Spring/SpringBoot-Bean
- 工厂模式:线程池使用线程共产创建
- 代理模式:Spring AOP
- 发布-订阅模式:MQ
- 观察者模式:Spring Event
- 策略模式:支付渠道(微信/支付宝/...)
- 责任链模式:拦截器链/过滤器链(参数效验、登录状态效验、权限效验、...)
- 门面模式/适配器模式:我们只操作
slf4j
->slf4j
对接操作log4j
、logback
。前者(程序员调用slf4j
)是门面模式,后者是适配器模式
参考:
- https://blog.csdn.net/qq_45196093/article/details/130392953
17. 手撕二叉树的右视图
1️⃣ 答:
题目:LeetCode 199.二叉树的右视图 (opens new window)
思路1:使用层序遍历(广度优先搜索),将每一层最后一个节点加入到列表,并返回
- Step1: 初始时,先将根节点加入队列,此时队列里存放了第一层的节点
- Step2: 队列里存放着一层的节点,此时队尾存放的就是这一层的最后一个节点,将队尾节点放入列表
- Step3: 遍历 Step2 的队列,每次出队,将节点的孩子按“左-右”顺序加入队列
- Step4: Step3 遍历完成,返回 Step2 执行。循环往复,直到二叉树的最后一层。最后列表里存放着每一层的最右侧节点,即二叉树的右视图。
class Solution {
public List<Integer> rightSideView(TreeNode root) {
List<Integer> ans = new ArrayList<>(); // 列表
if (root != null) {
Deque<TreeNode> que = new ArrayDeque<TreeNode>();
que.offer(root); // Step1
while (!que.isEmpty()) {
int size = que.size(); // Step2
while (size -- > 0) { // Step3
TreeNode t = que.pop();
if (t.left != null) que.offer(t.left);
if (t.right != null) que.offer(t.right);
if (size == 0) ans.add(t.val);
}
}
}
return ans; // Step4
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
思路2:
- 利用 DFS(深度优先搜索) 特性,在遍历时先进入右节点;
- 进入每一层时,最先进入的肯定是这一层的最右侧的节点
class Solution {
List<Integer> ans = new ArrayList<>();
public List<Integer> rightSideView(TreeNode root) {
dfs(root, 0);
return ans;
}
private void dfs(TreeNode root, int depth) {
if (root == null) return;
// 这里利用 dfs 特性,在下面遍历时先进入右节点;
// 进入每一层时,最先进入的肯定是这一层的最右侧的节点
if (ans.size() == depth) ans.add(root.val);
dfs(root.right, depth + 1);
dfs(root.left, depth + 1);
}
}
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 海康威视 - 2024.05.19
1. ArrayList 和 LinkedList 区别
- 底层实现不同:ArrayList 底层通过数组实现,LinkedList 底层通过双向链表实现
- 查询时间复杂度:根据下标查询时,ArrayList 查询时间复杂度 ,LinkedList 查询时间复杂度
- 修改效率不同:ArrayList 修改时间复杂度 ,LinkedList 修改时间复杂度
- 内存布局不同:ArrayList 内存空间连续,LinkedList 空间不一定连续,且占用更多内存(存储前后指针)。
- 线程安全:ArrayList 和 ArrayList 都不是线程安全的。
2. 说说 Bean 的生命周期
Bean 主要生命周期:
- 实例化:为Bean分配内存空间,从无到有
- 属性赋值:创建了Bean对象后,就会进行属性赋值(使用DI/依赖注入)
- 初始化:
- 初始化的前置方法
- 执行初始化方法
- 初始化的后置方法
- 使用
- 销毁
- https://pdai.tech/md/spring/spring-x-framework-ioc-source-3.html#spring-bean%E7%94%9F%E5%91%BD%E5%91%A8%E6%9C%9F%E6%B5%81%E7%A8%8B
- https://www.bilibili.com/video/BV1rt4y1u7q5/?p=47
3. 说说 IoC 设计原理/设计思想
IoC(Inversion of Control, 控制反转)是一种设计思想/原则,用于降低代码间的耦合度。 IoC 核心思想是将对象的依赖关系从程序内部转移到外部容器,由外部容器负责对象的实例化、配置和组装。这样做的好处是减少了对象之间的直接依赖,增加了系统的灵活性和可扩展性。
4. 数据库的事务隔离级别
MySQL 事务隔离级别:
- 读未提交:一个事务可以读取到另一个事务尚未提交的数据。
- 读已提交:
- 可重复读:MySQL默认隔离级别。一直跟这个事务启动时看到的数据是一致的
- 串行化:事务来了就排队去执行。
5. 说说 CAS 乐观锁的核心流程
CAS 操作的三个值:
V
: 要修改的变量的内存位置A
: 预期旧值B
: 新值
执行流程:
- 执行前:记录
V
和A
值 - 执行时:获取
V
目前的值和A
值进行对比(Compare),如果相等,则将B
设置到V
上;如果不相等,通过自旋进行下一轮的对比并替换。
- https://pdai.tech/md/java/thread/java-thread-x-juc-AtomicInteger.html#cas
6. 了解 Kafka 吗?
了解。
定义:Kafka 是一个开源的分布式流处理平台和消息系统。
特性:高性能、可扩展、高可用。支持功能相对比较少,如不支持优先级队列、死信队列、延迟队列、不能推送消息(只能拉取消息)等等。
组成:一个 Kafka 集群 => 多个 Broker => 一个 Broker 有多个 Topic => 一个 Topic 有多个分区 Partition。
使用:Kafka + SpringBoot / SpringCloud
- 添加 Kafka 依赖
- 配置 Kafka 连接信息
- 发送消息:使用 KafkaTemplate 对象提供
send("topic", value)
- 接收消息:
@KafkaListener(id="xxx", topics="topic")
7. 双亲委派是解决了什么问题?
解决问题有以下几个:
- 避免类的重复加载
- 避免了类冲突
- 提升了类的安全性:由上层类固定加载
8. 业务流程编排用了什么开源框架?
问题:开源的工作流程引擎有哪些?
开源的工作流程引擎:
- Activiti
- Flowable
9. 业务灵活多变通常会使用哪种设计模式?
策略模式。
策略模式比较灵活,定义一系列算法,将每一个算法独立封装,并且让它们可以相互替换。通过策略模式可以在运行时,...
10. 假如在实际工作中,有一个用户想突然申请一个特殊点的权限,属于特例情况,该怎么设计?
特殊权限的特例操作流程如下:
- 需求和风险评估 → 必要时要向上报备和申请
- 创建一个临时角色,赋值动态权限
- 对该角色/用户进行日志记录和实时监控
- 使用完成后,及时回收角色及其权限
11. 为什么要用 Shiro 框架?
Shiro 使用简单、操作灵活、不依赖某一个具体框架、功能相对全面。
12. Shiro 中的三大核心组件是怎样协同工作的?
Shiro 三大核心组件:
- Subject: 代表当前请求的“用户”
- SecurityManager: 负责内部组件管理和协调
- Realm: 数据源,负责处理身份认证和授权数据。
协同流程:
- 用户名和密码封装成用户对象,由 Subject 交给 SecurityManager
- SecurityManager 将认证请求委托给已配置 Realm 进行处理
- Realm 将结果返回给 SecurityManager 和 Subject
13. 在进行读取 Excel 表数据读取并插入的时候,有没有遇到什么难题?
小问题有以下几个:
- Excel 不规范使用时,数据类型转换出错问题
- 性能问题:
- 读取性能问题:数据量太大 → 数据分批读取、使用并发编程分段处理
- 写入性能问题:使用零拷贝技术 + 异步写入 + 并发编程 + 数据分批处理
- 数据映射问题:excel 列名和数据库表中的列名不同的问题
- 其他异常问题:通过合理的记录异常日志、监控系统观测发现和解决问题
14. 使用线程池时,怎么保证每个线程速度的均衡?
速度均衡问题解决方案:
- 设置合理任务粒度:任务力度不能太粗,任务不均匀导致执行速度不均衡
- 设置合理的线程数量:线程数太多会造成过度竞争,从而导致性能问题
- 线程执行任务状态的监控:及时发现任务异常和阻塞的情况,保证正常和线程均衡的执行效率
15. 怎么防止线程池中某个线程异常崩溃?
防止线程池中某个线程异常崩溃:
- 给线程池添加任务时使用 submit 方法,而不是 execute 方法。因为 execute 遇到异常之后会销毁线程、创建新的线程加入到线程池中。
- 使用线程池的监控工具(如 Hippo4j)监控线程池的运行,及时发现、定位、分析、处理问题
16. 怎么防止线程池中某个任务阻塞?
防止线程池中某个任务阻塞:
- 对线程池的任务做埋点时间统计
- 给线程设置超时时间,配合 Future 对象 future.get(2, TimeUnit.SECONDS) 设置超时时间
- 定期检查线程池的执行状态
- 使用线程池的监控工具(如 Hippo4j)监控线程池的运行,及时发现、定位、分析、处理问题