后端常见问题
一、基础
1.Java中的四种引用类型及其应用场景
1)强引用
特点:默认引用方式,对象不会被GC回收。
应用:常规对象创建、缓存核心数据:用户信息,订单信息
2)软引用
SoftReference<Object> ref = new SoftReference<>(new Object());特点:只有内存不足时才回收。
应用:内存敏感的缓存,大对象缓存。内存充足时利用缓存提升性能,内存紧张时,自动释放资源避免oom
3) 弱引用
解决内存泄漏,存储临时关联数据
WeakReference<Object> ref = new WeakReference<>(new Object());特点:GC时必定回收
应用:
- WeakHashMap,键为弱引用
- 监听器/回调防止内存泄漏
- ThreadLocal
4)虚引用
特点:每次GC回收。无法通过get()获取对象,需配合ReferenceQueue
应用:
- 精确控制对象销毁时机
- 代替finalize()进行资源管理
- 直接内存管理(netty、DirectByteBuffer)
2.HashMap、 ConcurrentHashMap的底层实现原理
jdk1.8 ,数据结构:数组+链表+红黑树。
| hashMap | ConcurrentHashMap | |
| 扩容时机 | 元素总数超过阈值(数组长度*0.75) | 写线程,发现目标桶头结点是ForwardingNode,表示正在迁移,则协助迁移 |
| put流程 | 如果数组为空,先初始化数组,默认长度为16 | 如果数组为空,先CAS初始化数组,默认长度为16 |
| 计算桶的位置。hashCode高16位和低16位进行异或,然后与数组长度-1相与 | ||
| 桶为空,直接插入 | 桶为空,CAS插入。 | |
| 桶不为空,遍历链表/红黑树,判断key是否相同,相同则覆盖,不同则末尾插入 | 桶不为空(上一步CAS失败,或者本身不为空):synchronized锁住头结点。 | |
| 判断头结点是否为forwardingNode,是则协助扩容。否则和左边一样的逻辑插入数据。 | ||
判断桶数据是否大于8或者小于6?大于8则尝试转为红黑树。小于6则树转为链表。 | ||
| 判断是否需要扩容 | ||
| 扩容流程 | 单线程执行 | 多线程并发执行 |
1)新建一个容量为原数组2倍的新数组 2)数据迁移,hash&数组长度,为0,数据在原位置;否则在原位置+原数组长度 3)迁移完成,将新数组引用替换旧数组引用(扩容期间整个map不可用,效率低) | 1)新建一个容量为原数组2倍的新数组 2)旧数组划分为多个区间(transferIndex),多个线程协同分批迁移数据 3)当线程进行put时,如果桶是 4)线程从后往前领取任务区间,步长根据cpu核数和数组长度动态计算。 5)当所有桶的头结点都变成forwardingNode,替换数组引用。 桶的头结点变为forwardingNode,读操作会调用find方法找到新位置 | |
3.ArrayList和LinkedList的底层差异及使用场景
ArrayList底层是数组,而LinkedList底层是链表
ArrayList适用于随机访问,适合读多写少的场景日常开发中最常用。在尾部插入时效率高,不用移动大量元素。当数组容量不足时,会自动触发扩容,默认是原来的1.5倍。扩容时需要复制数组,有一定开销。
LinkedList适用于写多读少,需要在头部尾部频繁插入/删除的场景。头尾插入时,效率高。如果是其他节点,虽然不用迁移数据,但是需要定位节点,从头遍历。比较适合做队列,先进先出
5.反射机制的原理和使用注意事项
本质是在JVM运行时,通过class对象动态获取类的元数据(如方法、字段)。绕过编译期的检查,直接通过底层native方法,操作对象
注意事项
1)性能差,无法通过jvm内联优化,高频调用会有明显瓶颈
2)破坏封装,强行访问私有成员,存在安全隐患
3)不安全,编译期无法检查错误,异常全部推迟到运行时才暴露
业务代码中尽量不用,主要用于spring框架。如果需要用,就要对method等对象做缓存,优化性能
5.1 数据导出excel时,通常使用反射进行类的字段映射,如何优化性能?
1)解决反射性能瓶颈,缓存元数据。
反射最大的开销在于每次获取字段或方法时,都需要去遍历类的元数据。我们可以建立一个全局缓存(CoucurrentHashMap),以class为key,缓存该类所有字段的Field或Method对象。这样,在首次加载某个类时仅触发一次反射,后续几十万行数据的转换查询缓存即可,性能会有质的飞跃
2)解决大数据量内存溢出问题
使用SXSSFWorkbook,或直接使用excel开源框架,easyExcel。
数据分批,在业务层做分页查询,将数据转换和写入excel的动作拆分成更小批次,构建一批,写入一批,释放一批。避免数据在内存双重驻留
6.泛型擦除机制和类型擦除带来的问题
泛型只在编译阶段有效,用于做类型安全检查。编译完成,运行时,所有泛型信息会被擦除。
即编译器会将所有泛型参数,如<T>替换为其上界Object。
问题
1)无法使用基本数据类型,泛型擦除后,被替换成Object,基本类型不是对象,只能使用包装类,这里拆箱、装箱就会带来额外的性能开销
2)无法进行instanceof判断或new实例化。因为运行时泛型信息不存在,所以代码里写if(obj instanceof List<String> )编译报错;也无法new T();
3) 容易引发隐蔽的运行时异常(其实也是反射会带来的问题),我们可以通过反射往List<String> 中塞入一个Integer,编译器不会报错,但后续遍历这个列表就会报错
二、JVM与性能调优
1.JVM内存结构:堆、栈、方法区、元空间
线程共享:堆、元空间、方法区
堆
堆是JVM中最大的一块内存,存放所有对象实例和数组。
像包装类,在-128到127之间会用到常量池,常量池也在堆中,但底层会直接复用缓存对象,不会重复创建
垃圾回收的主要区域。通常分为新生代(eden、survivor)、老年代。
方法区
存储类信息、常量、静态变量、即时编译器编译后的代码
元空间
jdk8以前方法区由永久代实现,因为永久代内存小,容易OOM。JDK8以后永久代被移除,改为由元空间实现,他使用本地内存,大大降低OOM风险
线程私有:虚拟机栈、本地方法栈、程序计数器
虚拟线程
每个方法执行都会创建一个栈帧,存放局部变量表、操作数栈、动态链接等。方法调用就入栈,结束就出栈
程序计数器
记录当前线程执行的字节码指令地址,保证线程切换后能恢复到正确位置
2.栈上分配
栈上分配的核心目的就是为了避免对象进入堆内存,从而减少 GC 压力,提升程序性能。
就是对象只在方法里有使用到,不作为外部被使用,比如作为返参,那他就会被分配到栈上
3.垃圾回收算法:标记清除、复制、标记整理
- 标记-清除:先标记出所有存活对象,然后直接清除未标记的垃圾;优点是简单,缺点是会产生大量内存碎片。
- 复制算法:将内存分为两块,每次只用一块,存活对象直接复制到另一块并清空当前块;没有内存碎片,但浪费了一半内存空间。
- 标记-整理:先标记存活对象,然后让所有存活对象向一端移动,最后清理掉边界以外的内存;解决了碎片问题,但移动对象成本较高。
4.GC Root
绝对不会被GC回收的对象可以被作为GC Root。一个对象能从任意GC Root出发,有引用链连通的,那么这个对象就不可被回收。
比如虚拟机栈中、本地方法栈中引用的对象,还有方法区中的静态变量、常量引用的对象(运行时常量池中的常量——字符串常量池的字符串)
同步锁持有的对象、JVM内部引用的关联对象:系统类加载器、class对象。
如果我们在开发中,使用静态集合存大量对象,却忘记清理,或者使用ThreadLocal没有调用remove,那么他们就会被gc root 锁住而无法被回收,最终导致内存泄漏。
5.类加载机制和双亲委派模型
类加载机制,jvm将class文件加载到内存,进行解析、验证、初始化为class对象的过程。
双亲委派,就是JVM有3类类加载器:启动类加载器、扩展类加载器、应用类加载器。
当一个类加载器收到请求时,先不自己去加载,而是交给父类,父类加载不了再自己加载。
为什么?
防止类被重复加载,防止java加载恶意类,覆盖核心类,保证java核心库的安全。
为什么打破双亲委派
过于僵化,特定场景,父加载器无法加载子类加载器路径下的类,或者需要实现动态替换。
1)如JDBC驱动加载,JDBC接口由启动类加载,但具体驱动实现是由应用类加载器加载。父类加载器无法感知子类路径下的类。
2)热部署与动态加载,Tomcat容器,需要支持同一个应用的不同版本同时运行,或者在不重启服务器的情况下替换某个类。需要使用自定义类加载器去打破,实现类的隔离与热替换。
如何实现
使用线程上下文类加载器加载驱动
自定义类加载器
6.内存泄漏的排查方法和工具使用
jstat -gcutil <pid>命令,观察老年代(Old Gen)的使用率是否持续上升且 Full GC 后无法回落。
jmap -dump:live <pid>命令。加上live参数可以只导出存活对象,减小文件体积
生成堆dump文件,使用VisualVM或MAT进行分析
7.JVM线上问题排查:CPU飙升、内存泄漏、死锁
CPU飙升通常由死循环或频繁GC引起,使用top -H定位高耗线程并结合jstack分析调用栈;
内存泄漏表现为老年代持续增长,通过jstat确认后用jmap导出堆快照,借助 MAT 分析对象引用链;
死锁导致线程互相等待,直接运行jstack即可在输出中查看 JVM 自动检测到的死锁线程及锁依赖信息。
并发编程
1.synchronized和ReentrantLock的实现原理及区别
synchronized:JVM 关键字,底层基于对象头 Mark Word 和 Monitor 监视器,通过 CAS 和自旋实现锁升级(偏向锁 -> 轻量级锁 -> 重量级锁)。
ReentrantLock:JDK 类,底层基于 AQS 框架,通过 CAS 修改 state 变量抢锁,失败则进入 CLH 队列阻塞等待。
核心区别
锁释放:前者隐式自动释放;后者必须在 finally 中手动释放。
公平性:前者只支持非公平锁;后者支持公平和非公平锁。
条件变量:前者只有单一等待队列(wait/notify);后者支持多路 Condition 精准唤醒。
锁获取:前者阻塞不可中断、无超时;后者支持响应中断( lockInterruptibly )和超时获取( tryLock )。
2.volatile关键字的作用和内存语义
保可见:一改全改,大家立刻能看到最新值。
禁重排:防止编译器和CPU乱序执行代码。
不保原子:像 i++ 这种复合操作依然不安全。
内存语义:
1)内存屏障(Memory Barrier):
写操作:在写 volatile 变量前后插入屏障(禁止普通写与后面的volatile重排, volatile 写后面的 volatile 读/写重排)。
读操作:在读 volatile 变量后插入屏障(禁止 volatile 读与后面的普通读写重排)。
2)JMM 规范约束:强制要求线程在读取 volatile 变量时必须从主内存读取,写入时必须立刻刷新到主内存,且工作内存中的缓存副本失效。
3.Java线程池的核心参数和工作原理
4.ThreadLocal的实现原理和内存泄漏问题
5.AQS原理
6.CountDownLatch、CyclicBarrier、 Semaphore的使用场景
7.线程间通信的几种方式
8.虚拟线程使用synchronized为什么会被钉住?
因为锁的归属记录在载体线程上,如果此时 JVM 强行卸载虚拟线程,载体线程就会变成“自由身”。如果这个载体线程随后去获取了其他锁,就会导致底层的锁状态混乱甚至死锁。为了保证锁的安全性,JVM 只能选择将虚拟线程死死“钉”在载体线程上,导致载体线程跟着一起傻等,无法释放 。
而ReentrantLock 底层做了特殊处理,允许虚拟线程安全卸载 。
二、Spring框架生态
1.Spring Bean的生命周期
实例化,属性赋值,初始化(initializingBean,beanPostProcessorBefore,init-method,beanPostProcessorAfter),使用bean,销毁
2.SpringlOc和DI的实现原理
控制反转,将对象创建和对象之间的依赖关系交给spring管理。
创建完的对象就放在一级缓存里。创建对象时,发现对象需要依赖其他对象时,就会去一级缓存getBean,找到后就进行set赋值,或者构造函数。
3.SpringAOP的实现原理,JDK动态代理和CGLIB区别
aop面向切面编程,跟业务无关的逻辑,比如日志,权限,事务,就使用aop统一管理。
aop底层就是动态代理,对目标类进行增强逻辑,当调用目标方法(切点)时,根据通知类型进行增强,比如前置通知,后置通知,环绕通知,异常通知等
目标类实现了接口使用jdk,否则就是gclib
4.Spring事务管理机制和传播特性
aop实现,threadLocal绑定事务管理器。
传播特性,就是当方法中存在多个事务时,要如何处理?
主要有以下几个,required,required_new,嵌套事务。
required,当前存在事务,加入当前事务,否则新建一个。内部事务回滚,外部的也跟着回滚。
转账,给A加钱,给B减钱,两个方法,要么全成功,要么全失败。减钱方法失败了,加钱方法也要回滚
required_new,当前存在事务,将事务挂起,新建一个事务。内部事务回滚,外部的不会。比如下单和记录日志。无论下单失败还是成功都要记录日志。
嵌套事务,当前存在事务,嵌套到当前事务。内部事务回滚,外部的不会。比如发放积分,下单失败,发放积分也要回滚。发放积分失败,下单不能回滚。
5.BeanFactory和ApplicationContext的区别
6.Spring中的设计模式应用
7.Spring Boot自动配置原理
8.Spring Boot启动流程
原生IOC启动流程
1. 加载配置资源
2. 解析为BeanDefinition
3. 初始化Bean工厂、执行工厂后置处理器、注册后置处理器
4. 实例化单例Bean、依赖注入、生命周期
全程只管Bean
SpringBoot启动流程
1. 初始化SpringApplication(准备监听器、初始化器)
2. 准备运行环境、加载配置文件
3. 打印Banner、创建应用上下文
4. 执行自动配置(大量自动配置类生效)
5. 刷新IOC容器(这里才开始走上面原生IOC全套流程)
6. 启动内嵌Tomcat
7. 执行收尾回调、应用就绪
9.Spring Boot Starter的工作原理
10.Spring Boot配置文件加载顺序和优先级
先外部再内部
11.如何实现Spring Boot应用的优雅停机
停止接收新请求、等待正在处理的请求完成、安全释放资源(如数据库连接、线程池等),最后退出应用 。
springBoot框架原生支持优雅停机,这是最简单且官方推荐的方式,添加配置,server.shutdown。
设置等待请求处理完毕时间
当应用接收到停止信号后,停止接收新的连接请求。
三、数据库与存储MySQL
1.MySOL索引的数据结构(B+树原理)
多路平衡查找树
B+Tree = 有序键 + 非叶只指路 + 叶存数据/主键 + 叶间链表 → 点查快、范围查更快。
✅ 高度低(3~4层) → 磁盘 IO 少
2.聚簇索引和非聚簇索引的区别
索引和数据是否在一个文件
聚簇索引
主键即聚簇索引,叶子节点存放 整行数据
表数据本身就是按主键 B+Tree 组织的。
非聚簇索引
叶子节点存 索引列值 + 对应主键值
查非索引列时需 回表(用主键再去聚簇索引查整行)
3.最左前缀原则和索引下推
最左前缀原则是“索引能不能用”的匹配规则,而索引下推是“索引用了之后如何优化”的执行机制。
- 最左前缀原则:决定了联合索引的命中范围。它要求查询条件必须从联合索引的最左边字段开始匹配,一旦遇到范围查询(如
>,<,BETWEEN,LIKE)或匹配中断,索引就会停止向右匹配。 - 索引下推(ICP):决定了回表的时机。当联合索引遇到范围查询(如 > 、 < 、 LIKE '前缀%' )导致最左前缀原则中断时,后续列虽然无法用于“索引查找”,但它们依然存在于索引树中。此时 ICP 允许引擎利用这些“后缀列”进行提前过滤,从而极大减少回表次数。
索引下推,针对联合索引的查询优化机制。在索引层面提前过滤,减少不必要的回表次数。从而显著降低磁盘 I/O,提升查询性能。
举例:
索引下推只有在联合索引“部分命中”(即发生了索引中断)时才会发挥作用。比如联合索引是(a, b, c),你查a=1 and c=2(b缺失),此时最左前缀原则导致索引只匹配到a,而剩下的c=2这个条件就会触发索引下推,在索引层直接过滤。
再比如,name是索引,查询 SELECT * FROM table WHERE name LIKE '张%' AND name != '张三';
没有ICP时,会在索引中扫描出所有姓张的记录,然后逐条回表查出所有完整数据,最后在server层,过滤不等于张三的记录。
有ICP,name != '张三' 这个条件可以直接在索引里判断(不需要回表),存储引擎会在扫描索引时,直接把 name = '张三' 的索引项剔除掉,只对剩下的记录进行回表,减少回表次数
4.SOL优化经验和慢查询分析
1)EXPLAIN 分析执行计划(核心手段)
重点关注以下核心字段:
type(访问类型):性能从优到劣依次为 const > eq_ref > ref > range > index > ALL。生产环境应严禁出现 ALL(全表扫描)。
key:实际使用的索引。如果为空,说明未使用任何索引。
rows:预估扫描的行数,数值越小效率越高。
Extra:额外信息。若出现 Using filesort(文件排序)或 Using temporary(临时表),说明存在典型性能瓶颈。
2) SQL优化实战经验
1. 索引优化策略
- 合理建索引:在高频的 WHERE、JOIN、ORDER BY、GROUP BY 字段上建立索引。
- 遵循最左前缀原则:设计复合索引时,将等值条件放在前面,范围查询和排序字段放在后面。
- 利用覆盖索引:尽量让查询的字段全部包含在索引中,避免回表操作。
- 避免索引失效:不要在索引字段上做函数运算、隐式类型转换,或进行前置模糊查询(如 LIKE '%xxx')。
2. SQL 写法重构
- 杜绝 SELECT *:只查询业务必需的字段,减少网络传输和内存消耗。
- 优化大分页:对于 LIMIT 100000, 20 这种深分页,改用游标方式(如 WHERE id > last_id LIMIT 20)或延迟关联,大幅减少扫描行数。
- 减少子查询与关联:避免复杂的关联子查询,尽量改写为 JOIN;合并结果集时,若允许重复,使用 UNION ALL 代替 UNION。
5.MySQL事务隔离级别和MVCC原理
读未提交,读已提交,可重复读,串行
MVCC,undolog版本链和readView快照。查询数据时,创建readView,然后在undolog版本链里,通过对比readView的一些属性,找到能够被当前事务可见的版本。
读已提交和可重复读的实现,区别在于生成readView的时机,一个是每次读都生成,一个是第一次读生成。
6.数据库锁机制:行锁、表锁、间隙锁
行锁包括了记录锁,间隙锁,临键锁。
对于唯一索引进行等值查询时,加的就是记录锁
对于非唯一索引的范围查询时,加的是间隙锁。锁不存在的记录,防止其他事务在范围里插入不存在的数据而产生幻读。
临键锁,锁记录和记录前的数据。
8.主从复制原理和读写分离实现
binlog实现。
三个线程,dump,io,sql。
主节点dump线程发送binlog给从节点
从节点接收binlog,io线程将其写入RelayLog中继日志
从节点sql线程从中继日志回放sql
主从延迟问题
提升从节点硬件性能
从节点避免慢查询,占用CPU
建主键,索引,避免从节点回放sql时全表扫描
使用半同步复制
Redis
1.Redis的数据结构和应用场景
2.Redis持久化机制:RDB和AOF对比
RDB 定时快照丢数据多但恢复快,AOF 实时追加数据更安全但文件大,生产用混合持久化。
RDB 不是固定间隔触发,而是基于配置的"时间窗口+变更次数"阈值触发;如果业务写入量低,可能很长时间不生成 RDB,这也是它丢数据风险的根源。
3.缓存穿透、缓存击穿、缓存雪崩的解决方案
4.Redis内存淘汰策略
5.分布式锁
6.缓存数据库一致性问题
四、消息队列与中间件消息队列
1. Kafka、 RabbitMO、RocketMO的选型对比
2.如何保证消息不丢失
3.如何保证消息顺序性
4.消息堆积的处理方案
5.延迟消息的实现方式
临时存储到延迟主题,不同延迟级别放不同队列。
定时任务投放
4.x版本
生产者发送延时消息时,通过setDelayTimeLevel方法设置延时级别,消息发送到Broker。
Broker收到延时消息后,不会将其写入真实的业务主题,而是写入系统级延时主题SCHEDULE_TOPIC_XXXX,每个延时级别对应该主题下的一个独立队列。
Broker为每个延时级别队列启动一个独立的调度线程,持续轮询队列中的消息,判断消息是否到达投递时间。
当消息到达投递时间后,调度线程会将消息从延时主题转移到用户指定的真实业务主题,此时消息对消费者可见,会被正常投递。
消费者监听业务主题,收到消息后执行对应的延迟业务逻辑。
5.x版本
定时消息的核心实现基于TimerStore定时索引存储与多级时间轮调度机制。灵活可靠
6.消息队列的事务消息
解决“本地事务(如数据库操作)”与“消息发送”之间的原子性问题,确保两者要么同时成功,要么同时失败,实现分布式事务的最终一致性。
流程
生产者发送事务消息,broker接收并存为半消息,目标主题和队列存属性,因此消费者看不到这条消息。
broker接收消息后,生产者执行本地事务,将执行结果发送给broker,broker姐收到后做相应处理,记录消息偏移量到op队列,如果是提交就投放到目标主题。
回查时间间隔
transactionTimeout:事务超时时间。Broker 在此时间之后才会开始对该事务消息进行首次回查,4.x版本通常默认是60秒。
transactionCheckInterval:回查间隔。默认值有说是60秒或30秒,具体取决于版本,Broker配置文件中通常以毫秒为单位
RocketMQ 默认最多回查 15次,如果本地事务一直返回 UNKNOW 状态,达到上限后 Broker 会停止回查,并默认回滚(丢弃)该消息 。为了防止这种极端情况导致数据不一致,我在项目中做了以下处理:”
- 将事务状态落库,回查时直接读库判断,尽量在短时间内给出明确的 Commit 或 Rollback,避免长时间处于中间状态 。
- 监控告警:通过采集 Broker 的错误日志,监控回查失败率。如果同一消息连续回查失败超过 3 次,就会触发告警,提前介入处理 。
RocketMQ 事务消息和本地消息表方案怎么选?
事务消息实现复杂度低,不需要额外建表,耦合度低,但强依赖 MQ 的支持;本地消息表需要自己建表和定时任务,业务侵入性强,但适用于任何 MQ,一致性由业务层面保证
