面试
Java 后端面试复习重点清单
1. 第一优先级:必须背熟
Java 基础与集合
重点背:
HashMap 底层结构、put 流程、扩容机制
1.7是数组加链表,链表采用头插法
1.8以及之后,底层是数组链表加红黑树,链表采用尾插法,扩容因子是0.75,初始长度是16,新添加元素时候,首先使用Object类的hashcode方法计算新添加元素的hash值,拿hash值取余当前数组长度,得到数组的下标索引,然后判断当前下标索引处是否有元素,如果没元素直接添加,如果有元素则使用object的equals方法判断两个元素的值是否相等,如果相等则不添加,如果不等则使用拉链法解决hash冲突,将元素继续往下挂,当链表长度大于8,数组长度大于等于64,将自动转为红黑树。如果树节点数量小于等于6,红黑树会退化为链表
ConcurrentHashMap 如何保证线程安全
1.7之前采用分段锁
1.7之后使用cas+syncharonized锁
ArrayList 扩容机制
初始容量是0,第一次添加元素的时候,初始化容量为10,新添加元素时,首先判断当前元素+1是否大于当前数组长度,如果大于当前数组长度,则触发扩容,,扩容后的数组长度是当前数组长度的1.5被,新的数组用Array.copyof()把旧数组的元素全部复制进去,再把新元素存入新数组尾部
String 为什么不可变
因为string底层使用private fanil修饰的,他是不可被修改的,也是不可变的,对他进行修改时,其实是创建了一个新的string字符串,然后然后将变量引用地址指向新的字符串达到修改的效果,不能被继承,避免子类破坏不可变的特性
equals 和 hashCode 的关系
两个对象 equals 相等,hashCode 一定要相等;但 hashCode 相等,equals 不一定相等,这就是哈希冲突。所以重写 equals 必须重写 hashCode,否则把对象存进 HashMap、HashSet 这种基于哈希的集合时,两个 equals 相等的对象算出不同的 hash,会被当成两个不同的 key,导致存进去取不出来。
反射、注解、泛型擦除
反射:运行时动态拿到类的信息(属性、方法、构造器),还能创建对象、调用方法。Spring 的 IOC、动态代理底层都依赖反射,缺点是性能比直接调用差,还会破坏封装。
注解:本质是一种标记、元数据,本身不做事,配合反射或 AOP 在运行时被读取后再处理,比如 @Autowired、自定义注解。通过 @Retention 分为源码级、编译级、运行时三种。
泛型擦除:Java 泛型只是编译期的语法糖,编译后类型参数会被擦除成 Object 或上界类型,运行时其实没有泛型信息。所以
List<String>和List<Integer>运行时是同一个 class,也不能对泛型做 instanceof、不能 new T[]。Java 异常体系
顶层是 Throwable,往下分两大类。
Error:JVM 层面的严重错误,程序自己处理不了,比如 OutOfMemoryError、StackOverflowError。
Exception:程序可以处理的异常,又分两类。一类是编译期异常,也叫受检异常,必须显式 try-catch 或 throws,比如 IOException、SQLException;另一类是运行时异常 RuntimeException,编译器不强制处理,比如空指针、数组越界、类型转换异常。
常见场景题:
HashMap 为什么线程不安全?
他没有添加任何同步锁,所以说在多线程并发读写,扩容,插入时会产生数据丢失,死循环,数据错乱等问题,所以线程不安全,解决方案的话,使用hashtable,他底层使用的是syncharonized关键字,但是粒度太大,性能差,或者使用concurrentHashMap,底层使用分段锁和cas+syncharonized优化,高并发首选
ConcurrentHashMap 和 HashMap 有什么区别?
concurrentHashMap线程安全的,1.7底层使用分段锁。1.8使用cas+syncharonized关键字进行优化
hashmap是线程不安全的,单线程的情况下使用效率比较高,多线程并发put,扩容,查询不安全
equals 必须重写 hashCode?
重写 equals 必须重写 hashCode,为了遵守 Java 对象相等规范,防止对象存入 HashMap、HashSet 等哈希集合时出现存取异常。
自定义注解 + AOP 在项目里怎么用?
项目里用自定义注解配合aop处理横切逻辑,例如操作日志,权限校验,防重复提交,先定义运行时注解做标识,Aop通过@annotation切点拦截带注解的方法,再通知中读取注解配置执行公共逻辑,业务方法添加注解完成增强,实现业务与通用代码解耦
2. Spring / Spring Boot
重点背:
IOC / DI 原理
ioc是将创建和管理对象的能力交给springIoc容器进行管理,di是依赖注入,是ioc的具体实现,容器自动装配bean之间的依赖,解耦对象关系
Bean 生命周期
第一步进行实例化,spring通过反射调用构造方法创建出bean对象
第二步进行依赖注入,给带@autowired的属性自动赋值填充
第三步执行初始化相关逻辑,处理完这个bean就完全就绪,可以正常拿出来使用
第四步就是等到spring容器关闭的时候,会执行对应的销毁方法,释放资源
三级缓存解决循环依赖
一级缓存放的是已经创建好,能直接使用的成品对象
二级缓存放的是刚new出来,还没完善的半成品bena,防止重复创建代理对象
三级缓存是存对象的工厂,需要时生成普通对象或者是Aop代理对象
解决循环依赖的方法是,a依赖b,b依赖a,先创建a的空对象,把a的工厂放入三级缓存,给a赋值的时候发现需要b,开始创建b,同理把b的工厂放进三级缓存,发现b赋值的时候需要a,从三级缓存取出a的早期对象放到二级缓存,完成b的注入,b创建完成之后放入一级缓存,回头给a注入b,a就初始化完成进入一级缓存,循环依赖的问题就解决了
Spring AOP 原理
springAOP就是面向切面编程,用来抽取重复的代码,不改动原有业务方法,对方法只做增强,不做改变,底层依靠动态代理来实现
有两种代理规则:
1:目标实现了接口,就使用jdk动态代理,运行时生成接口实现类拦截方法
2:目标没实现接口,就使用cglib,运行时生成目标类的子类重写方法拦截
aop底层基于动态代理,有接口用jdk,没接口用cglib代理,容器创建bean时候判断是否需要切面增强,需要就生成代理对象,实现方法前置,后置等增强逻辑,用来统一处理日志,权限这类公共逻辑。
JDK 动态代理和 CGLIB 区别
jdk要求目标类必须实现接口,运行时生成一个实现该接口的代理类来拦截方法
cglib不需要目标类实现代理接口,运行时,生成一个该类的代理对象
有接口默认就是jdk代理,没有实现接口就是cglib代理
Spring 事务传播机制
事务的传播机制指的就是,多个事务之间互相调用时,如何处理已有事务,新开事务的规则,一共7种,
required 默认 有事务就加入事务,没有就创建一个事务,也是最常见
required-new不管外面有没有事务,一定新开独立事务,内外事务不影响,互不回滚
supports (苏ps)有事务就跟着跑,没事务就以非实物运行
not-supported(苏pd)以无实物 运行
never(奈沃)必须不能有实物,有事务就报错
Spring 事务失效场景
方法不是public修饰的,事务的底层是aop,aop只能拦截public方法生成的代理,解决方法就是在方法上添加@Transaction
try捕获异常,并没有把异常往上抛,spring事务没感知异常,所以不生效,解决方案就是catch里面手动new runtimeException()抛出异常
事务传播属性错误,not-supported和never等,是不支持事务的,解决方法是根据业务改成required或者required-new
Spring MVC 执行流程
浏览器发送请求,前端控制器拦截所有请求,调用handerMapping,根据请求url,找到对应的controller,调用controller里面的业务方法,执行controller处理请求,调用service,封装数据,返回数据,视图解析器拿到视图名称,定位真实页面路径,进行视图渲染,然后将渲染完毕的页面响应给客户端展示。
拦截器和过滤器区别
Filter过滤器,拦截所有请求,包括静态资源,jsp,图片,接口都拦
Interceptor拦截器,只拦截进入controller的请求,静态资源默认不拦截
全局异常处理、参数校验
实现方法就是@RestControllerAdvice,全局切面,捕获所有@RestController抛出的异常
@ExceptionHandler,指定拦截某种异常,统一封装返回给前端格式
常见场景题:
Spring 三级缓存为什么能解决循环依赖?
Spring 三级缓存核心是提前暴露实例化后的半成品 Bean。创建 A 时先把 A 的工厂放入三级缓存,A 需要 B 就去创建 B;B 又需要 A,此时从三级缓存取出 A 的提前引用完成 B 装配;B 创建完再回来装配 A,解决循环引用。引入三级缓存主要是为了适配 AOP 代理,避免对象不一致问题。
为什么构造器循环依赖解决不了?
构造器循环依赖无法解决,是因为创建对象时必须在构造方法入参拿到依赖,双方对象都还没实例化,进不了三级缓存做提前暴露;而 set 注入可以先实例化空对象存入缓存,再填充属性,从而解决循环依赖。
AOP 是怎么实现的?
底层基于动态代理,有两种代理方式,目标类实现了接口的,使用jdk代理,运行时创建实现接口的代理类,如果目标类没有实现接口,就使用cglib,运行时生成目标类的代理对象,spring启动时,容器创建bean时,先看下是否需要切面增强,如果需要的话再根据前置,后置,和一些公共配置,实现日志控制,权限控制等等。
同类方法调用为什么事务失效?
事务是依靠动态代理的,同类调用的话,是调用的原生对象,不走代理拦截,事务逻辑无法切入,所以事务失效,改用自身注入,通过代理对象调用就能解决
异常被 catch 后事务为什么不回滚?
异常被捕获后,并没有往上继续抛,所以事务感知不到,如果try-catch捕获异常之后不抛出,代理拿不到异常,判断业务执行正常吗,就会提交事务,不会回滚。
JWT 登录拦截器怎么实现?
用户登录成功之后生成jwt令牌返回给前端,自定义拦截器,判断非空校验合法性,有效就执行,无效就拦截返回401,在mvc配置类注册拦截器,排除登录等白名单接口,同时用treadlocal存放当前登录的用户,用完及时清理。
3. MySQL
重点背:
B+ 树索引
叶子节点存储的是表中具体的数据,非叶子节点存储的是主键和索引的对应关系,通过非叶子节点,可以快速定位到叶子节点,所以查询效率快,只有叶子存数据,层级更低,范围查询高效,分页友好,mysql默认就是选用b+树
聚簇索引和非聚簇索引
聚簇索引也被称为主键索引,叶子节点存放整条完整行数据,索引和数据捆绑在一起,一张表只能有一个聚簇索引
非聚簇索引也被称为二级索引,叶子节点只存储主键值,如果需要查询表中具体的数据,需要回表
回表
回表就是查询时,用的是非聚簇索引,但是非聚簇索引只能查询出主键值,如果需要查询表中具体的数据,就需要拿着主键,使用聚簇索引去查询具体的数据,这个过程就被称为回表
最左前缀原则
联合索引,遵循最左匹配原则,按照创建时字段顺序排序,查询必须使用索引最左侧第一个字段,索引才会生效,如果中间出现范围查询,范围后面的字段索引全部失效
针对联合索引的情况
比如按照a b c三个字段的顺序设置了索引
查询条件中,只要包含a字段,索引就会生效,与判断条件的顺序无关
索引失效场景
不满足最左匹配原则
使用模糊查询时候,以通配符开头
字段做运算,函数操作
隐式类型转换
使用不等于。not in,or等不合理写法
explain 分析 SQL
explain加在sql语句前面用来查询,sql执行情况,是否走索引,索引好不好,是否慢查询,定位sql性能问题
事务隔离级别(从低到高)
读未提交(Read Uncommitted):最低,几乎不隔离,脏读、不可重复读、幻读都会出现
读已提交(Read Committed):解决脏读,还存在不可重复读、幻读,Oracle 默认这个级别
可重复读(Repeatable Read):解决脏读、不可重复读,理论上还有幻读,但 InnoDB 靠 MVCC 加间隙锁基本把幻读也解决了,MySQL 默认这个级别
串行化(Serializable):最高,所有问题都解决,但相当于事务串行执行,并发性能最差
三个问题解释:脏读是读到别的事务还没提交的数据;不可重复读是同一个事务里两次读同一行,值被别人改了导致不一样;幻读是同一个事务里两次按条件查询,行数变了,别人插入或删除了符合条件的行
MVCC
多版本并发控制,修改数据时,保留旧版本,普通查询读旧版本不加锁,修改删除走最新数据加锁,读写互不堵塞,依靠undo log存旧版本,readview判断数据可见性,存在可重复读隔离级别下避免脏读和不可重复读,提升数据库并发能力
undo log / redo log / binlog
undolog是回滚日志,用于事务失败时撤销修改,同时存储数据历史版本职称mvcc
redolog重做日志,修改先写该日志,宕机后依靠他恢复未落地的数据,保证事务的持久性
binlog 服务层的二进制日志,记录所有增删改的逻辑语句,主要用于主从复制和误删后的时间点数据恢复
深分页优化
用游标分页,也可以使用延迟关联优化回表,极端场景改为es,业务上禁止随意跳页也能规避问题
常见场景题:
一个接口查询慢怎么排查?
可以使用explain分析执行计划,查看是否出现了全表扫描,索引失效,深分页,回表过多,文件排序等问题
explain 主要看哪些字段?
type
key
key_len
rows
extra
哪些情况会导致索引失效?
联合索引不满足最左前缀
模糊查询时,以通配符开头
隐式类型转换
使用!=,><,notin,is not null,
索引字段做计算
为什么深分页慢?
偏移量offset很大时,mysql必须从头一条条扫描并丢弃前面的offset条数据,直到找到需要取的数据,扫描行数极多,io开销大所以很慢。
异步导出为什么不能直接 limit offset?
异步导出数据量大,使用 limit offset 分页一方面偏移量持续变大形成深分页,查询越来越慢;另一方面导出期间表数据发生增删改,会造成分页错位,出现数据重复或漏导;一般采用主键 id 游标分页分批查询解决该问题。
4. Redis
重点背:
Redis 常用数据结构
string
zset
set
hash
list
String、Hash、ZSet 使用场景
String 适合简单缓存、计数器、分布式锁;Hash 适合存储对象,支持局部字段更新,避免整对象序列化;ZSet 自带分值排序,多用于排行榜、延时队列等需要有序场景。
Redis 为什么快
Redis 速度快主要因为数据基于内存读写;采用单线程避免锁竞争,搭配 IO 多路复用高效处理大量连接;底层优化的数据结构查询效率高,整体执行开销很小。
缓存穿透、击穿、雪崩
穿透:查询的数据数据库中不存在,缓存也不存在,每次查询都打到数据库,简单来说就是一直查询不存在的数据,解决方案,使用布隆过滤器,判断是否一定不存在,或者缓存中存短期的空数值
击穿:热门key突然过期,大量请求直接打到数据库,造成数据库压力倍增,解决方案,热门key不设置过期时间,加分布式锁,让同一时间只有一个请求查询数据库,其他请求在外等待,查到的数据回写到数据库。
雪崩:短时间内大量key过期,大量请求打到数据库,解决方案,key设置随机过期时间,加锁
缓存一致性
缓存一般只做最终一致性,优先采用先更数据库,再删缓存的方案,并发问题可以采用延迟双删缩小不一致的时间,分布式环境通过redisson分布式锁,串行修改,redisson具备可重入,看门狗续期,lua脚本保证原子性,等特性,也可以借助mq重试,canal监听binlog异步同步缓存做兜底
Redisson 分布式锁
原生redis锁,通过setnx自己实现锁,但是存在很多缺陷,死锁,不可重入,锁释放不是原子性操作,业务超市,锁提前释放等问题
核心特征:
可重入,同一个线程可以多次加同一把锁,内部用hash结构记录加锁次数,解锁次数减一,计数器到0释放锁,避免把自己锁死
看门狗:默认锁30秒到期,但是看门狗,每10秒检查下业务执行完了没,后台每隔十秒就会查询下锁的状态,防止任务没跑完锁释放
lua脚本:加锁,判断锁属性,释放锁等逻辑,全部写在一段lua脚本中,执行多条命令不会打断,保证操作的原子性,防止误删别人的锁,支持异步加锁,除了普通可重入锁,还有读写锁,联锁,红锁,公平锁等适配不同场景
看门狗机制
看门狗:默认锁30秒到期,但是看门狗,每10秒检查下业务执行完了没,后台每隔十秒就会查询下锁的状态,防止任务没跑完锁释放
RDB / AOF
RDB是快照,将数据以快照的形式进行存储,恢复比较快,但是缺点是可能数据丢失会比较多
AOF是日志,将数据以日志的形式追加记录,数据更安全,文件偏大且恢复慢,线上通常两者配合使用兼顾安全性和恢复效率
常见场景题:
热点产品信息为什么放 Redis?
热点商品,访问量大,如果不放在缓存,那么大量请求都打到数据库,数据库压力太大,存在缓存中,缓存是在内存中,多线程读写快,秒杀等场景,redis高并发能力更强,起到流量削峰,防止流量冲击数据库造成雪崩,
缓存和数据库不一致怎么办?
先采用先改库再删缓存的基础方案,并发问题用延迟双删缓解;删除失败借助 MQ 重试或 Canal 监听 binlog 异步同步;高并发加分布式锁规避读写竞争;最后定时巡检校对做兜底,实现数据最终一致性。
先删缓存还是先更新数据库?
通常情况下,先更新数据库,再删缓存,该方案并发脏数据概率最低,如果是先删缓存,再更新数据库的话,极易出现别的线程读取旧数据回填缓存,造成长期不一致,删除缓存失败的话可以使用mq或者canal进行兜底,并发高的场景可以使用延时双删
Redisson 分布式锁怎么实现?
基于lua脚本保证原子性,采用hash结构实现可重入,加上看门狗机制,实现锁的自动续期,避免锁提前释放,获取失败自动自选,安全解决分布式并发争抢问题
防重复下单锁 key 怎么设计?
生成防重令牌,下单前接口生成全局唯一的token返回前端,下单时必须携带该token,订单支付成功,删除该key,同一个token只能成功一次,拦截重复点击,重复提交,数据库层面的话,设计业务订单表,唯一主键,如果重复提交就抛异常,先查在更新,进行操作之前,先查订单状态,如果订单状态已经完成,就抛异常,没有完成就正常执行业务逻辑
JWT 为什么要配合 Redis?
原生jwt生成之后,无法主动失效,退出登录,改密,这些场景无法立刻作废令牌,搭配redis可以存储有效令牌或维护失效黑名单,实现主动注销,账号强制下线,单点登录,弥补jwt不可撤销的短板
5. RabbitMQ
重点背:
Exchange、Queue、RoutingKey
Exchange 交换机:生产者的消息先发到交换机,交换机本身不存消息,只负责按规则把消息转发到队列。
Queue 队列:真正存消息的地方,消费者从队列里取消息。
RoutingKey 路由键:生产者发消息时带的一个字符串,交换机根据它和绑定关系 BindingKey 决定把消息投到哪个队列。
交换机类型
direct
topic
fanout
head
支持的一些工作模式
普通队列:一个生产者对应一个队列,一个队列对应一个消费者
工厂模式:一个队列有多个消费者。但一条消息只能被一个消费者消费,轮询,公平分配
发布订阅模式:fanout,多个队列,同一个消息可以发送给多个消费者
路由模式:direct,多个队列,路由按照key,将消息发送给不同的队列
主题模式:topic,多个队列,路由key使用通配符,单个单词用*,多个单词或0个用#
Rabbitmq如何保证消息不丢失
主要分为三个部分:生产者,队列,消费者
生产者开启消费确认,消息发送到borker之后,如果成功就返回ack,失败的话就重试,记录失败日志做兜底
队列,开启消息持久化,和交换机持久化,防止数据丢失,关闭自动删除队列
消费者,关闭自动ack,开启手动ack,自动ack是消息发送之后,自动删除,如果出现服务不可用的情况,可能会造成数据丢失,开启手动ack,只有执行成功之后,才手动ack
同时使用兜底,使用消息幂等,根据全局唯一id,存储信息,解决重复投递副作用
Rabbitmq如何保证消息不被重复消费
先说原因,为什么会重复消费,消息已经处理完了,但是ack回执丢了,broker收不到确认,一段时间后重复投递该消息
不是mq的bug,是网络超时,服务宕机导致的必然现象,mq本身就无法杜绝重试投递,只能靠业务做幂等
有三种解决方案,
数据库唯一索引,也是最稳妥,最常用的方案
发送消息时,携带全局唯一业务号,执行更新和插入操作时,设置唯一索引,如果重复插入,就直接抛异常
二是用redis,执行之前先查redis中订单是否存在,如果已经存在,就直接return,不做处理,如果不存在,先写入redis,然后执行相应的业务逻辑,执行完成之后不需要额外的操作,如果失败的话允许删除key重试
第三种的话使用条件更新sql实现业务逻辑幂等,设置条件更新sql,只有满足前置状态才修改,重复执行无副作用
死信队列
死信队列就是一个消息一直失败,达到最大重试次数之后,不会继续重试,这条消息就变成死信,不能再进原队列消费,准们存放死信的队列就叫死信队列
延迟队列
消息发送成功之后,不会被立马消费,而是等到指定时间,时间到了之后才会被消费者获取,常用于定时类业务,场景的话就是订单超时不支付,自动关单
消息幂等
解决的方案有多种,第一种使用数据库唯一索引,设置全局唯一业务单号,为唯一索引,在执行更新或者插入操作时,如果重复插入就抛异常
第二种使用redis,简单老说就是先插后操作,先查询redis中业务订单是否存在,如果存在直接return,不做处理,如果不存在,先将订单存入redis,然后设置过期时间,在执行相应的业务逻辑,执行成功不做额外操作,如果执行失败允许删除key进行重试
第三种就是使用条件更新sql做天然幂等,使用条件更新sql,必须满足前置条件才允许操作,重复操作不影响数据
消息堆积处理
堆积一般是消费速度跟不上生产速度。先定位是消费者挂了还是消费太慢:消费者故障就先修复重启;消费太慢就临时扩容——加消费者实例、调大并发 prefetch、优化消费逻辑,比如批量处理、异步落库。实在堆太多,可以新建一个临时队列和一批临时消费者,先把消息快速转移出来慢慢处理。事后要加监控和限流,从根上避免。
常见场景题:
RabbitMQ 如何实现 15 分钟超时关单?
下单成功后往延迟队列发一条消息,延迟 15 分钟。到点消息被消费者拿到,先查这笔订单状态:如果还是待支付,就关单、回滚库存;如果已经支付了,直接忽略。实现方式两种:一种是 TTL 加死信队列,给消息设 15 分钟过期时间,过期后转发到死信交换机再被消费;另一种是装 rabbitmq-delayed-message-exchange 延迟插件,直接发延迟消息,更简单直观。
为什么不用定时任务扫表?
定时任务扫表有几个问题:一是有延迟,比如每分钟扫一次,关单最多差一分钟,不够精准;二是数据量大时全表扫 status 等于待支付 很慢,给数据库压力大;三是多实例部署要额外加分布式锁防止重复扫。延迟队列是订单级精准触发,到点就处理,不用轮询全表,实时性和性能都更好。
支付成功和超时关单同时发生怎么办?
靠订单状态加锁保证只有一个成功。关单和支付回调都要先判断当前状态再改:关单用条件更新 update 订单 set status=已关闭 where id=? and status=待支付,如果这时候已经支付了,status 不是待支付,更新影响 0 行,关单失败,不会覆盖已支付状态;支付回调同理也校验状态。再加数据库行锁或 Redisson 分布式锁串行化,杜绝并发改坏。
MQ 消息丢了怎么办?
分三段保证:生产端开启 confirm 确认机制,消息到了 broker 才算成功,失败重试加记日志兜底;broker 端队列、交换机、消息都开持久化,防止宕机丢数据;消费端关掉自动 ack、改手动 ack,业务处理成功才 ack,失败重试或进死信。三段都保住,消息基本不会丢。
MQ 重复消费怎么办?
重复消费没法从 MQ 层面根治,网络超时、ack 丢失都会导致重投,只能靠消费端做幂等。常用三种:数据库唯一索引,带全局唯一业务号,重复插入直接报错;Redis 先查后做,存过就 return;条件更新 SQL,满足前置状态才改,重复执行无副作用。最稳的是数据库唯一索引。
消息堆积怎么排查?
先看管理台或监控的队列积压数和消费速率。判断原因:是消费者全挂了,消费速率为 0,还是消费太慢,生产大于消费。消费者挂了就修复重启;消费太慢就扩容消费者、调大 prefetch 并发、优化消费逻辑;如果是某条消息一直失败卡住,把它扔进死信队列别阻塞后面的。堆积太严重可以临时加机器加临时队列快速泄洪。
6. 项目核心场景:订单、幂等、状态机
重点背:
状态机是什么
状态机就是把一个业务对象所有可能的状态,以及状态之间允许怎么流转,提前定义成一套规则。核心是「当前状态 + 触发事件 = 下一个状态」,不符合规则的流转直接拦掉。订单就是典型:待支付、已支付、已发货、已完成、已取消,每一步只能按规则走。
订单状态流转
典型链路:待支付 →(支付成功)→ 已支付 →(发货)→ 已发货 →(确认收货)→ 已完成;待支付 →(超时或用户取消)→ 已取消。每个箭头都是一个允许的流转,其他跳法都算非法。
非法状态拦截
改状态前先校验当前状态是否允许流转到目标状态。比如已完成的订单不能再取消,已取消的不能再支付。实现上要么用状态机框架,比如 Spring StateMachine,要么简单点用条件更新 SQL:update 订单 set status=新状态 where id=? and status=允许的前置状态,影响 0 行就说明状态不对,抛异常。
重复下单处理
防重令牌加数据库唯一约束。进下单页先发一个全局唯一 token,下单时带上,后端用 Redis 校验 token 只能用一次,Lua 保证原子;同时订单表对业务唯一键,比如用户加商品加活动,加唯一索引,重复插入直接报错兜底。
支付回调幂等
第三方支付回调可能重复通知。处理时先查这笔订单状态,如果已经是已支付就直接返回成功、不重复处理;改状态用条件更新 where status=待支付,保证只成功一次。再配合支付流水号唯一索引防重复入账。
MQ 消费幂等
消息可能重复投递,消费端做幂等:全局唯一消息 id 或业务号,配数据库唯一索引、Redis 去重、或条件更新 SQL,重复消费不产生副作用。
分布式锁防并发
同一笔订单的并发操作,比如重复支付、支付和关单同时来,用 Redisson 分布式锁,按订单 id 加锁,串行处理,避免并发把状态改乱。锁只是兜底,核心还是状态判断加条件更新。
常见场景题:
订单有哪些状态?
一般是:待支付、已支付(待发货)、已发货、已完成、已取消或已关闭,退款场景再加退款中、已退款。具体看业务,但核心就这几个。
待支付到已支付怎么流转?
用户支付成功后,支付平台回调通知,后端校验签名和金额,确认无误后用条件更新把订单从待支付改成已支付:update set status=已支付 where id=? and status=待支付,同时记录支付流水、扣减库存,再发 MQ 通知后续发货等流程。
已支付订单还能取消吗?
能,但走的是退款、取消流程,不是简单改状态。已支付订单取消要触发退款,状态流转到退款中,再到已退款或已取消,还要回滚库存。跟未支付订单直接关单不是一回事。
用户重复点击下单怎么防?
前端按钮置灰是最外层;核心靠后端:防重 token 一次性、订单业务唯一索引、分布式锁,三层保证同一次下单只生成一个订单。
支付回调重复通知怎么处理?
幂等处理:先查订单状态,已支付就直接返回成功;没支付才处理,并用条件更新 where status=待支付 保证只成功一次;支付流水号加唯一索引防重复入账。这样回调来多少次结果都一样。
状态机相比 if else 有什么好处?
if-else 散落各处,状态一多就变成一堆嵌套判断,容易漏、难维护,加状态就要改一片。状态机把哪些状态能流转到哪些状态集中成一份规则,流转合法性统一校验,新增状态改配置就行,清晰、可控、不容易出非法流转。
7. ElasticSearch
重点背:
倒排索引
普通索引是文档到词,倒排索引反过来是词到文档列表。ES 先把文档内容分词,然后记录每个词出现在哪些文档里。搜的时候拿关键词直接查到文档列表,不用全表扫,所以全文检索特别快,这是 ES 快的核心。
text 和 keyword 区别
text 会被分词,适合全文搜索,比如商品标题、描述,支持模糊匹配,但不能直接排序聚合;keyword 不分词,整个字段当一个整体,适合精确匹配、排序、聚合,比如分类、品牌、状态、id。实际经常一个字段同时建 text 加 keyword,用 fields 子字段兼顾两种需求。
分词器
把一段文本切成一个个词的组件。英文按空格切,中文要专门的分词器,常用 IK 分词器,分 ik_max_word 细粒度尽可能多切、和 ik_smart 粗粒度智能切。建索引和查询用的分词器要匹配,不然搜不准。
分片和副本
分片 shard:把一个索引的数据水平拆成多份,分布到不同节点,解决单机存不下、提升并发,主分片数创建后不能改。副本 replica:主分片的备份,主挂了副本顶上保证高可用,同时能分担查询压力,副本数可以随时改。
refresh 机制
ES 写入不是实时可见的。数据先进内存缓冲区,默认每 1 秒 refresh 一次,把缓冲区数据生成 segment 变成可搜索状态。所以 ES 是近实时 NRT,写完大概 1 秒后才搜得到。可以手动 refresh,但频繁 refresh 影响性能。
ES 深分页问题
from 加 size 深分页时,每个分片都要取 from+size 条再汇总排序,翻到很后面代价极大,ES 默认限制 from+size 不能超过 10000。解决:实时翻页用 search_after,大批量导出用 scroll。
search_after
深分页方案,不用 from,而是带上上一页最后一条的排序值作为游标,查排在它后面的数据。每次只取一页量,没有深分页的性能问题,适合下一页式无限翻页,缺点是不能随机跳页。
MySQL 和 ES 数据同步
常见几种:同步双写,改库时同步写 ES,简单但耦合、影响性能;MQ 异步,改库后发消息,消费端更新 ES,解耦;订阅 binlog,用 Canal 监听 MySQL binlog 同步到 ES,业务无侵入,最常用;定时任务全量或增量兜底。一般 Canal 加 MQ 组合,再加定时全量兜底。
常见场景题:
为什么家具搜索用 ES?
商品搜索要按标题、描述做全文模糊匹配,还要分词、相关度排序、高亮,MySQL 的 like 百分号关键词百分号 用不了索引、全表扫、性能差,也不支持分词和相关度。ES 天生为全文检索设计,倒排索引加分词加打分排序,还能扛高并发,所以搜索走 ES。
关键词、分类、品牌、价格区间怎么组合查询?
用 bool 组合查询:关键词用 must 加 match 做全文匹配、参与打分;分类、品牌这种精确条件用 filter 加 term 不打分、可缓存、快;价格区间用 filter 加 range。filter 不算相关度还能缓存,所以精确过滤都放 filter,只有关键词放 must,性能最好。
ES 索引怎么设计?
按业务定字段和类型:需要搜索的标题、描述用 text 加 IK 分词;需要精确过滤、排序、聚合的分类、品牌、价格、状态、id 用 keyword、integer、double;不搜的字段设 index:false 省空间。合理设分片副本数,别过度分片。字段类型一旦建好不能改,要提前规划,改了得重建索引。
MySQL 数据怎么同步到 ES?
主流用 Canal 监听 MySQL 的 binlog,把增删改事件发到 MQ,消费端更新 ES,业务代码零侵入、准实时。再配一个定时全量同步做兜底,防止中间漏消息导致长期不一致。小项目也可以直接在业务里发 MQ 异步双写。
ES 和 MySQL 不一致怎么办?
ES 本身只保证最终一致。做法:同步失败的消息进 MQ 重试、进死信人工处理;加定时任务全量或增量比对,把 ES 校准回来;以 MySQL 为准,ES 只做搜索,查详情还是回 MySQL,降低不一致影响。
ES 查询慢怎么排查?
先看是不是深分页 from 太大,换 search_after;看查询有没有滥用 wildcard、正则、脚本这些慢查询;精确过滤是不是放进了 filter 能缓存;分片是否过多或数据倾斜;是不是 text 字段做了聚合、排序,该用 keyword。还可以用 profile API 看每一步耗时定位。
8. 异步导出 / FastExcel / MinIO / SSE
重点背:
同步导出为什么会超时
数据量大时,一次请求里要查几十万上百万行、写 Excel、再返回,整个过程几十秒甚至几分钟。HTTP 请求、Nginx、网关都有超时时间,一般几十秒,还没导完连接就断了,所以同步导出扛不住大数据量。
同步导出为什么会 OOM
传统 POI 导出会把所有数据和整个 Excel 对象全加载到内存里,几十万行一次性堆在堆内存,很容易把堆撑爆触发 OOM,数据量越大越危险。
异步导出整体架构
用户点导出,后端立刻建一条导出任务记录、状态待处理、返回任务 id,请求秒回;真正的导出丢给异步线程或 MQ 后台慢慢做:分页查数据,FastExcel 流式写文件,上传 MinIO,更新任务状态为成功并存下载地址;前端通过 SSE 或轮询拿进度和结果,完成后给下载链接。核心就是提交和执行分离。
export_task 表设计
记录每个导出任务。核心字段:任务 id、用户 id、任务类型或导出条件(存查询参数)、状态(待处理、处理中、成功、失败)、进度、文件地址(MinIO 的 url)、失败原因、总条数、创建时间、完成时间。前端靠它查进度,服务重启靠它恢复。
任务状态流转
待处理 →(开始执行)→ 处理中 →(成功)→ 已完成;处理中 →(异常)→ 失败。失败的可以重试重新回到处理中。每一步都更新 export_task 的状态和进度。
分页读取
绝不能一次性把全部数据查进内存。用分页分批查,每次查一批比如 5000 条,写一批,释放一批。而且要用游标分页 where id 大于 上次最大 id,而不是 limit offset,避免深分页越来越慢。
FastExcel 流式写入
FastExcel 是 EasyExcel 的升级,流式写 Excel,不把整个文件加载到内存,一批数据写一批、刷到输出流就释放,内存占用恒定很低,几百万行也不 OOM。这是异步导出不爆内存的关键。
MinIO 上传
导出的文件不落在应用服务器本地,多实例、重启会丢,而是上传到 MinIO 对象存储,拿到一个文件地址存进 export_task。用户下载时从 MinIO 拿,支持大文件、可扩展。
SSE 推送
导出进度和完成通知用 SSE,Server-Sent Events,服务端单向持续往前端推进度,比前端一直轮询更实时、更省资源。导完推一条完成消息带下载链接。
轮询降级
SSE 依赖长连接,可能被网关、代理断开或不支持。所以要兜底:SSE 连不上或断了,前端自动降级成定时轮询接口查任务状态,保证无论如何都能拿到最终结果。
常见场景题:
异步导出整体流程是什么?
用户点导出,后端建 export_task 记录、待处理、返回任务 id、请求立即返回;异步任务开始:改状态处理中,游标分页分批查数据,FastExcel 流式写入,写完上传 MinIO,更新状态成功并存下载地址;前端 SSE 收到完成通知,或轮询查到成功,拿到下载链接下载。
export_task 表有哪些字段?
任务 id、用户 id、导出类型、查询条件参数、状态、进度、总条数、文件地址、失败原因、创建时间、完成时间。关键是状态和文件地址,前端查进度、服务重启恢复都靠它。
为什么用 FastExcel?
传统 POI 全量加载内存,大数据量 OOM;FastExcel 是流式写入,内存占用恒定,一批写一批释放,几百万行也不爆内存,而且 API 简单、性能好。所以大数据量导出用它。
导出中途失败怎么办?
捕获异常,把 export_task 状态改成失败并记下失败原因,已上传的半成品文件清理掉;前端收到失败状态提示用户;支持手动或自动重试,从头重新执行该任务。因为查询条件都存在任务表里,重试很方便。
服务重启后任务怎么恢复?
任务状态都持久化在 export_task 表。服务启动时扫一遍状态是处理中的任务,也就是重启前没跑完的,重新拉起继续执行,或者先标记失败再重试。因为导出条件、进度都存库了,不依赖内存,所以能恢复。
SSE 断开怎么办?
前端监听到 SSE 连接断开 onerror,自动重连;重连也不行就降级成轮询查任务状态。同时任务结果都在 export_task 表里,SSE 只是通知手段,断了也不影响最终能拿到结果。
为什么还要轮询兜底?
SSE 是长连接,可能被 Nginx、网关超时断开,或某些环境不支持,不能保证百分百送达。轮询作为兜底:SSE 失效就定时查任务状态接口,保证用户最终一定能知道结果、拿到文件。可靠性优先。
MinIO 上传失败怎么办?
上传做重试几次,还失败就把任务标记失败、记原因,别让任务卡在处理中;文件生成成功但上传失败的,重试上传即可,不用重新导。也可以先本地暂存再异步补传。关键是状态要落到失败,前端能感知。
9. JWT / RBAC / 登录权限
重点背:
JWT 三段结构
三段用点分隔:Header 头部,声明算法和类型;Payload 载荷,存用户 id、角色、过期时间等业务信息,注意它不加密只是 Base64,别放敏感数据;Signature 签名,把前两段用密钥按指定算法算出来的。前两段能被解开看,安全靠签名。
JWT 防篡改原理
签名等于用服务端密钥对 Header 加 Payload 做加密算法。别人改了 Payload,比如把角色改成 admin,但没有密钥算不出新签名,服务端用密钥重新算一遍对不上,就判定被篡改、拒绝。所以能防篡改,但防不了被看到,别放密码这类敏感信息。
JWT 过期与续期
Payload 里有过期时间 exp,过期就失效。续期常用双 token:一个短期 access token 用于请求,一个长期 refresh token 用于刷新;access 过期时用 refresh 换新的 access,避免用户频繁重新登录。也可以无感刷新:快过期时后端返回新 token。
Redis 存 token 的作用
JWT 一旦签发,服务端无状态、没法主动失效。把 token 或登录态存 Redis 就能主动控制:退出登录删掉、改密码强制下线、单点登录顶掉旧的、做黑名单。相当于给无状态的 JWT 加了一个可控的开关,弥补不能撤销的短板。
拦截器鉴权流程
自定义拦截器拦所有需要登录的接口:从请求头 Authorization 取 token,校验签名和是否过期,再查 Redis 确认 token 有效没被踢,通过就解析出用户信息放进 ThreadLocal 用户上下文,放行;任何一步不过返回 401。登录、注册等接口加白名单放行。请求结束清理 ThreadLocal。
RBAC 权限模型
基于角色的权限控制。核心三张表:用户、角色、权限,权限可以是菜单、按钮、接口,用户和角色多对多、角色和权限多对多。用户通过角色间接拥有权限,不直接给用户配权限。加人、调权限只改关联关系,灵活好维护。
用户上下文
当前登录用户的信息,id、用户名、角色等。拦截器解析 token 后把用户信息存进 ThreadLocal,后续业务任何地方都能直接取当前用户,不用层层传参。注意请求结束一定要 remove 清理,防止线程池复用导致数据串了、内存泄漏。
常见场景题:
登录认证流程怎么设计?
用户提交账号密码,后端校验,密码要加盐哈希比如 BCrypt 比对,通过就生成 JWT,把登录态存 Redis,返回 token 给前端;前端存起来比如 localStorage,之后每次请求头带 token;拦截器校验 token 加 Redis 放行。退出就删 Redis 里的 token。
为什么 JWT 还要存 Redis?
纯 JWT 无状态、不能主动失效,退出登录、改密码、强制下线这些场景没法立刻作废旧 token。存 Redis 后校验时多查一步 Redis,退出就删、改密就清、单点登录就覆盖,实现主动注销和强制下线,弥补 JWT 不可撤销的短板。
用户退出登录怎么让 token 失效?
退出时把 Redis 里存的这个用户的 token 或登录态删掉,或加入黑名单。拦截器校验时发现 Redis 里没有或在黑名单,即使 JWT 本身签名有效、没过期,也判定失效返回 401,这样就实现了主动失效。
权限标识怎么返回给前端?
登录后把用户拥有的角色和权限标识,比如 user:add、order:export 这种字符串,一起返回给前端。前端根据权限标识控制菜单、按钮的显示隐藏;但这只是前端体验,真正的拦截还得靠后端接口鉴权,前端能绕过。
后端如何获取当前登录用户?
拦截器解析 token 后把用户信息放进 ThreadLocal,封装个工具类比如 UserContext.getCurrentUser 在业务任意层直接取,不用一路把 userId 当参数传。请求结束在拦截器 afterCompletion 里 remove 清理。
管理员和普通用户权限如何隔离?
靠 RBAC:给管理员和普通用户分不同角色,角色绑不同权限。接口层做鉴权,常用注解加 AOP,比如 @PreAuthorize 或自定义 @RequirePermission,方法执行前校验当前用户有没有这个权限,没有就拦掉。数据层面还可以做数据权限,普通用户只能看自己的数据。
10. SpringAI / RAG
重点背:
SpringAI 是什么
Spring 官方的 AI 应用开发框架,把调用大模型、向量数据库、RAG、Function Calling 这些能力封装成统一的 Spring 风格 API,像用普通 Spring 组件一样接入 OpenAI、通义千问等各家大模型,屏蔽底层差异,不用自己拼 HTTP 请求。
大模型调用流程
构造提示词 prompt,通过 SpringAI 的 ChatClient 把 prompt 发给大模型,模型返回结果,解析加工后返回给用户。可以配上下文多轮、system 角色设定、参数比如温度。底层就是调各家大模型的 API。
多轮对话上下文
大模型本身无记忆,每次请求都是独立的。要实现多轮,得把历史对话,用户问加 AI 答,一起拼进这次的 prompt 发过去,模型才知道上下文。历史一般按会话 id 存 Redis;太长会超 token 上限,要做截断或摘要。
RAG 是什么
检索增强生成。大模型不懂你的私有数据比如公司知识库,RAG 的做法是:先把用户问题去知识库检索出相关内容,再把这些内容加问题一起给大模型,让它基于检索到的资料回答。既利用了模型的表达能力,又保证答案基于真实资料、不瞎编。
文档切分
知识库文档太长不能整篇丢给模型,超 token、检索也不准,要切成一小块一小块 chunk。按段落、句子或固定长度切,块之间留一点重叠,避免把完整语义切断。切分粒度直接影响检索质量。
向量化
把文字通过 Embedding 模型转成一串数字向量,语义相近的文本向量距离也近。文档切块后每块都向量化存进向量库;用户提问也向量化,然后去向量库找最相近的块。这是语义检索的基础,比关键词匹配更懂意思。
知识库检索
用户问题向量化后,去向量数据库比如 Milvus、Redis、PGVector 做相似度检索,取出最相关的 topN 个文档块,作为参考资料拼进 prompt。这一步决定了喂给模型的资料准不准。
提示词工程
设计好 prompt 让模型输出更符合预期。常用:system 角色设定,比如你是家居客服只根据资料回答;把检索到的资料放进去;明确要求,不知道就说不知道、别编;给格式示例。RAG 里提示词很关键,直接影响回答质量和是否幻觉。
转人工机制
AI 答不了或用户不满意时转人工。触发条件:模型置信度低、检索不到相关资料、用户明确要人工、涉及投诉售后等敏感场景。做法是识别到这些情况就把会话转给人工客服,比如接入工单或客服系统,保证体验兜底。
常见场景题:
AI 客服整体流程是什么?
用户提问,问题向量化,去知识库向量检索出相关资料,把资料加历史对话加问题拼成 prompt,调大模型生成回答,返回用户。答不了或命中敏感场景就转人工。这就是一个 RAG 加多轮加转人工的客服闭环。
RAG 知识库怎么搭建?
四步:一收集整理文档,商品资料、售后政策等;二文档切分成 chunk;三每块用 Embedding 向量化,存进向量数据库;四提问时问题向量化、检索 topN、拼进 prompt 给大模型。后续文档更新要同步更新向量库。
知识库文档怎么切分?
按语义切,常见按段落或标题切,或固定字数比如每 300 到 500 字切,块之间保留一定重叠 overlap 避免切断上下文。切太大检索不精准、浪费 token,切太小丢失语义,要结合文档结构调。
如何避免大模型胡说?
核心靠 RAG:强制它基于检索到的资料回答,prompt 里明确要求只根据给定资料回答,资料里没有就说不知道、不要编造。再配合降低温度参数、检索不到就不硬答直接转人工或兜底话术、对关键信息做校验。RAG 加约束提示词是防幻觉的主要手段。
多轮对话上下文怎么保存?
按会话 id 把历史对话存 Redis 设过期时间,每次请求带上历史一起发给模型。对话太长超 token 就做处理:只保留最近 N 轮,或把早期对话用模型总结成摘要再拼,兼顾上下文和 token 限制。
AI 接口超时怎么降级?
大模型接口慢或挂了要兜底:设合理超时时间,超时或失败就降级,返回预设话术比如客服繁忙请稍后、转人工、或走缓存的常见问答;加重试限次数和熔断,别让 AI 拖垮整个服务。异步加流式返回也能改善体验。
Function Calling 怎么调用业务接口?
给大模型注册一批可调用的函数工具,描述清楚每个函数干嘛、参数是什么。模型理解用户意图后不直接答,而是返回要调哪个函数、传什么参数,后端据此调真实业务接口比如查订单、查物流,把结果再给模型组织成自然语言回答。这样 AI 就能查实时业务数据,而不只是聊天。
11. WebSocket / SSE
重点背:
WebSocket 原理
基于 TCP 的全双工通信协议。先通过一次 HTTP 请求带 Upgrade 头握手,把连接升级成 WebSocket,之后这条连接一直保持,客户端和服务端可以随时互相主动发消息,不用每次都重新建连接,适合实时双向通信。
WebSocket 和 HTTP 区别
HTTP 是请求响应、单向、短连接,只能客户端发起、服务端被动答,答完就断;WebSocket 是长连接、全双工,握手后一直连着,双方都能主动推消息。HTTP 每次都带完整头开销大,WebSocket 建立后数据帧很轻。要服务端主动推、频繁实时交互就用 WebSocket。
WebSocket 心跳
长连接可能因为网络问题、代理超时悄悄断了但双方不知道,也就是假死。心跳就是定时发一个 ping,对方回 pong,确认连接还活着;一段时间收不到就判定断开、触发重连。同时心跳还能防止空闲连接被中间设备、Nginx 超时关掉。
WebSocket 鉴权
WebSocket 握手是 HTTP,可以在握手阶段鉴权:通过 URL 参数或 header 带上 token,握手时校验,不通过就拒绝连接。因为浏览器 WebSocket 不好加自定义 header,常把 token 放连接 URL 的参数里,或连上后第一条消息发 token 认证。
SSE 原理
Server-Sent Events,基于 HTTP 的服务端单向推送。客户端发一个请求建立连接,服务端保持连接不关闭,持续往客户端推数据流 text/event-stream。只能服务端到客户端单向,浏览器原生支持自动重连,适合服务端单向通知的场景。
SSE 和 WebSocket 区别
方向:SSE 单向只服务端推客户端,WebSocket 双向。协议:SSE 就是普通 HTTP,轻量、自带重连、实现简单;WebSocket 是独立协议要握手升级。场景:只需服务端推比如进度、通知、行情用 SSE 就够;需要双向频繁交互比如聊天、游戏、协同用 WebSocket。SSE 更简单,WebSocket 更强。
断线重连
长连接都要处理断线。客户端监听到连接断开就自动重连,一般加退避策略,隔几秒重试、逐渐拉长间隔,别猛重连打垮服务;SSE 浏览器原生自动重连,WebSocket 要自己写重连逻辑。重连后可能要补拉断线期间漏掉的消息。
常见场景题:
安装派单为什么用 WebSocket?
派单要服务端主动、实时把新订单推给对应的安装师傅,而且师傅端可能要回应,接单或拒单,是双向实时交互。轮询有延迟又浪费,SSE 只能单向推,所以用 WebSocket 全双工长连接,推得快、还能双向沟通。
导出通知为什么用 SSE?
导出进度、完成通知是服务端单向推给前端,前端不用往回发,场景简单。SSE 就是普通 HTTP、实现轻量、浏览器自带重连,完全够用,没必要上更重的 WebSocket。单向推送首选 SSE。
SSE 和 WebSocket 区别是什么?
一句话:SSE 是 HTTP 上的服务端单向推送、轻量自带重连;WebSocket 是独立协议的全双工双向通信、更强但更重。单向通知用 SSE,双向交互用 WebSocket。
WebSocket 连接断了怎么办?
靠心跳检测 ping pong 及时发现断开,客户端自动重连带退避,重连后重新鉴权、恢复订阅,并补拉断线期间的消息。服务端也要清理断开连接对应的会话资源。
多实例部署下 WebSocket 怎么推送?
连接是有状态的,用户 A 连在实例 1,消息可能从实例 2 产生,实例 2 找不到 A 的连接。解决:用 Redis 发布订阅或 MQ 做广播,消息发出来所有实例都收到,持有该用户连接的实例负责推给他;或者用专门的推送网关、中间件统一管理连接。核心是解决跨实例找连接的问题。
为什么 SSE 还要配合轮询兜底?
SSE 是长连接,可能被 Nginx、网关超时断开,或个别浏览器、环境不支持,不能百分百保证送达。所以兜底:SSE 失效就降级成定时轮询查状态,保证用户最终一定能拿到结果,比如导出完成、下载链接。可靠性优先。
12. JVM 和线上排查
重点背:
JVM 内存结构
主要分几块:堆,存对象实例,是 GC 主战场,分年轻代 Eden 加 Survivor 和老年代;方法区或元空间,存类信息、常量、静态变量,JDK8 后元空间用本地内存;虚拟机栈,每个线程私有,存栈帧、局部变量、方法调用;本地方法栈;程序计数器,记录当前执行位置。堆和方法区线程共享,栈和计数器线程私有。
类加载机制
类从被加载到卸载的生命周期:加载、验证、准备、解析、初始化、使用、卸载。加载是把 class 字节码读进来生成 Class 对象;验证保证字节码合法;准备给静态变量分配内存赋默认值;解析把符号引用换成直接引用;初始化执行静态代码块、静态变量赋值。用到才加载,也就是懒加载。
双亲委派
类加载器加载类时,先把请求往上委托给父加载器,父加载器能加载就加载,加载不了才自己来。顺序往上委托:自定义、应用类加载器、扩展类加载器、启动类加载器,再逐级往下尝试。好处是避免类被重复加载,保证核心类比如 java.lang.String 不会被篡改替换,更安全。
Minor GC / Full GC
Minor GC 也叫 YGC:只回收年轻代,对象在 Eden 放不下就触发,频繁但快。Full GC:回收整个堆,年轻代加老年代,再加方法区,慢、STW 停顿长。老年代满、方法区满、大对象直接进老年代放不下等会触发 Full GC。调优目标就是减少 Full GC 频率和停顿。
OOM 排查
先看是哪种 OOM:堆 OOM,Java heap space,一般是对象太多或内存泄漏;元空间 OOM 是类加载太多。排查:加 -XX:+HeapDumpOnOutOfMemoryError 让它 OOM 时自动导 dump,再用 MAT 或 jvisualvm 分析 dump,找占内存最多的对象和引用链,定位泄漏点。也可以 jmap 手动导堆。
CPU 飙高排查
经典四步:top 找到 CPU 高的 Java 进程 pid,top -Hp pid 找到该进程里 CPU 高的线程 tid,把 tid 转成 16 进制,jstack pid 打印线程栈、搜这个 16 进制线程,看它卡在哪段代码。常见原因:死循环、频繁 GC、大量计算、锁竞争。
jstack、jmap、jstat 基本用法
jstack:打印线程堆栈,排查 CPU 高、死锁、线程卡住。jmap:导出堆内存快照 jmap -dump 或看对象统计 jmap -histo,排查内存问题。jstat:看 JVM 运行时统计,尤其 GC 情况,jstat -gc pid 1000 看各代内存和 GC 次数、耗时,判断 GC 是否频繁。
常见场景题:
线上接口突然变慢怎么排查?
分层排查:先看监控,接口耗时、QPS、错误率,定位是哪个接口或环节。常见方向:数据库慢 SQL,查慢查询日志、explain;缓存失效或穿透;下游接口超时;GC 频繁导致停顿,jstat 看 GC;CPU 或内存打满;线程池满、锁等待,jstack 看。从 SQL 和 GC 这两个高发点先查,再逐步缩小。
CPU 飙高怎么排查?
top 定位高 CPU 的进程 pid,top -Hp pid 定位高 CPU 的线程,printf 把线程 id 转 16 进制,jstack pid 找到对应线程栈看卡在哪。一般是死循环、频繁 Full GC、大量计算或序列化、锁自旋。如果是 GC 引起的,再去查内存和 GC。
内存一直上涨怎么排查?
大概率内存泄漏,对象一直被引用无法回收。用 jstat 看 GC 后老年代是否降不下来,jmap 导 dump,用 MAT 分析找占用大、数量异常的对象和 GC Root 引用链,定位是谁一直持有。常见:静态集合只加不删、ThreadLocal 没 remove、连接或流没关、缓存无上限。
Full GC 频繁怎么处理?
先 jstat -gc 确认 Full GC 频率和耗时,再分析原因:堆太小、内存泄漏导致老年代一直满、大对象频繁进老年代、元空间不足、或代码疯狂创建对象。对应处理:调堆大小和比例、修内存泄漏、优化大对象、调元空间、必要时换更合适的 GC 收集器比如 G1、ZGC。根因还是别让老年代快速被填满。
OOM 后怎么分析 dump 文件?
提前配 -XX:+HeapDumpOnOutOfMemoryError 和 -XX:HeapDumpPath 让 OOM 自动生成堆 dump。用 MAT,Memory Analyzer,打开 dump,看 Leak Suspects 报告、Dominator Tree 找占内存最大的对象,顺着 GC Root 引用链找到是谁持有它不放,定位泄漏代码。也可以 jmap -histo 快速看对象数量分布。
推荐复习顺序
第 1 阶段:必须先背
- Spring 事务和 AOP
- Redis 缓存和分布式锁
- RabbitMQ 延迟队列和幂等
- MySQL 索引和慢 SQL
- 订单状态机和支付并发
第 2 阶段:结合项目背
- 异步导出
- ES 搜索
- JWT 登录鉴权
- WebSocket / SSE
- SpringAI / RAG
第 3 阶段:补基础
- Java 集合
- 并发基础
- JVM
- Spring Cloud 基础
最应该重点准备的 10 道场景题
- 你们订单超时关单是怎么实现的?
- 支付成功和超时关单同时发生怎么办?
- 用户重复点击下单怎么防止重复订单?
- RabbitMQ 消息重复消费怎么保证幂等?
- 异步导出为什么不会 OOM?
- SSE 断开后怎么处理?
- Redis 缓存和 MySQL 数据不一致怎么办?
- ES 和 MySQL 数据不一致怎么办?
- JWT + Redis 登录流程怎么设计?
- RAG 知识库是怎么搭建的?
最危险的雷点
如果没准备好,简历里这些点容易被问崩:
- SpringAI / RAG
- RabbitMQ 延迟队列
- 支付回调幂等
- Redisson 分布式锁
- 异步导出
- FastExcel
- ES 索引同步
- SSE / WebSocket
- Spring 事务失效
- MySQL 慢 SQL 优化
建议先把这些讲熟,再去背其他低频八股。