5年Java面试题及面试经验
参考离职原因
离职原因1:组织架构频繁调整,导致技术沉淀不足,我更需要在平台的业务稳定性和技术积累潜力。
离职原因2:业务进入稳定期,技术迭代更多是维护而非创新,我希望可以突破现有技术边界,实现更大价值。
离职原因3:公司在最近的季度出现百亿级别的扩损,启动了组织架构优化,我所在的业务线被整体砍掉,不是个人绩效问题,我的绩效一直都很好。
您有什么要问我的
- 您觉得这个岗位具体需要什么样的能力?
- 您工作中的一天是怎么度过的?(上下班和午休时间、有没有早会/周会/日报、需求迭代周期)
- 项目中的技术栈有哪些?入职后使用 Java 或 Python 语言占比多少?
不懂如何回答
“这个问题我目前没有深入研究过”
“从我的理解来看,这个问题可能涉及 XX 和 XX 两个方向,我之前接触过 XX 相关的内容,大概逻辑是 XXX,但针对这个具体场景的细节我还没掌握”
“面试官您能否给我一些关于这个问题的提示或思路?我也想借此机会学习一下”
你的职业规划
我的规划是:1-2年成为团队核心落地技术骨干,主导完成团队核心项目,(重点突破JVM深度优化、高并发场景治理);
3-5年向架构师转型,主导设计高可用架构;长期目标是成为领域专家,例如通过开源项目解决行业共性问题(分布式事务框架),定期复盘确保技术方向与行业需求同步。
生产环境机器配置
四个机房:北京 保定 苏州 广州
机器内存 20G ,CPU 80核,磁盘 250G,带宽 20MB/s,线程上限 2500
JVM:堆内存 14G ,直接内存 2G,元空间 512M,线程内存 512KB
G1 GC:JDK 17 默认收集器,Region 8M,年轻代最小 30% 最大占比 50%,保留 15% 内存防晋升失败,老年代区域存活对象阈值 85%,启动混合GC的堆占用率 45%
举例技术难点及解决
在免费会员卡限时抢购活动中,瞬时并发量峰值达到5W+ QPS,接口出现大面积超时报警。通过全链路日志埋点的耗时分析发现:Redis调用占接口总耗时的80%,且对应Redis节点CPU使用率飙升至95%。进一步定位代码后确认:所有请求集中访问存储会员卡信息的单个大Hash Key,导致Redis单分片负载过高,成为整个接口的性能瓶颈。
针对该问题,我采取了两层核心优化策略:
- 本地缓存兜底:引入Caffeine本地缓存缓存热点会员卡数据,配置“随机过期时间(5~10分钟)”规避缓存雪崩风险,同时开启
refreshAfterWrite异步定时刷新缓存,避免缓存过期后大量请求击穿至Redis; - Redis大Key分片:将原单一大Hash Key拆分为0~9共10个分片Key,针对数字型用户ID,先转为Long类型哈希值避免hashCode溢出,再通过“用户ID哈希值 % 10”的方式均匀分片,彻底打散单Key的访问压力。
优化后上线效果显著:Redis节点CPU负载从95%降至35%,接口超时率从报警阈值(5%)降至0,平稳支撑了5W+ QPS的瞬时并发。
- 为什么不直接用户ID取模,而是哈希值取模?
- 直接取模存在负载不均的风险,哈希后取模能彻底打散数据分布。
- 为什么选择用户ID分片而不是其他维度?
- 同一用户请求总落在同一分片,不需要多分片遍历
- 用户 ID 唯一、必传,改造成本低,分布均匀
- 如何保证本地缓存与Redis的数据一致性?
- “最终一致性” 即可满足业务诉求(无需强一致性)
- 强一致性方案:基于 Redis 的发布订阅 Pub/Sub,实现本地缓存的全局同步失效。要求更高考虑用 MQ(支持重试、死信队列,避免通知丢失)。
缓存和数据库一致性
为什么会出现不一致? 核心原因就是“更新数据库”和“更新缓存”这两个操作,不是原子性的——中间可能断网、服务宕机,导致只做了一步,没做第二步。常见的有两种错误操作顺序:
- 先更缓存,再更数据库:比如先把缓存里的库存改成0,结果更新数据库的时候服务宕机了——数据库库存还是10,缓存是0,不一致;
- 先更数据库,再更缓存:比如先把数据库库存改成0,结果更新缓存的时候网络断了——缓存还是10,数据库是0,也不一致;
还有个情况是缓存过期:缓存到时间自动失效了,新请求会查数据库再更缓存,但如果这时候有并发更新,也可能导致短暂不一致。
方案1:最常用的——更新数据库 + 删除缓存(Cache Aside 策略)
这是业界最推荐的方案,简单可靠,步骤就两步:
- 第一步:先更新数据库(比如把库存从10改成0);
- 第二步:再删除缓存里的对应Key(比如删掉
stock:1001这个缓存);
这样做的好处是:就算第二步删缓存失败了,也没关系——等缓存过期后,新请求会去查数据库,再把最新数据加载到缓存里,自动修正不一致。
这里要注意两个坑:
- 坑1:删缓存失败怎么办? 可以搞个重试机制——比如用消息队列,把要删的缓存Key发到MQ里,消费端重试删除,直到成功;或者定时任务扫描,发现数据库和缓存数据不一致,就修复。
- 坑2:并发读写导致的短暂不一致:比如刚删完缓存,还没等新请求加载数据,又有一个更新请求来了——这时候可能会把旧数据写回缓存?解决办法是缓存更新的时候加锁,或者用Redis的
SET命令加个短暂过期时间,避免旧数据长期存在。
方案2:适合读多写少的——更新数据库 + 延迟更新缓存
如果业务是读特别多、写很少(比如商品详情),可以在删缓存之后,延迟几秒再更新缓存,步骤是:
- 更新数据库;
- 删除缓存;
- 延迟500ms(根据业务调整),再去数据库查最新数据,更新到缓存里;
延迟的目的是等所有并发读请求都结束,避免刚删缓存,就有读请求把旧数据写回去。这种方案比直接更缓存更安全,因为延迟期间的读请求会查数据库,拿到的是最新数据。
方案3:强一致性要求的——分布式锁 + 串行化操作
如果业务对一致性要求极高(比如金融转账),就得用分布式锁把“读+写”操作串行化,步骤是:
- 不管是更新数据还是查询数据,都先获取分布式锁(比如Redis的
SET NX); - 拿到锁之后,更新数据库→删除缓存;或者查数据库→更新缓存;
- 释放锁;
这样能保证同一时间只有一个请求在操作数据,完全避免并发导致的不一致。但缺点是性能会下降,毕竟串行化了,适合并发不高但一致性要求高的场景。
最后补充:怎么避免过度设计?
- 能接受最终一致性的,就用方案1:大部分业务(比如电商商品、用户信息)都能接受“短暂不一致”,比如缓存删了之后,有1秒的时间读请求查数据库,这对用户来说没感知;
- 不要做“更新数据库后直接更新缓存”:这种操作很容易出问题,比如两个并发更新请求,可能会把旧数据覆盖新数据;
- 兜底方案:定时校准:不管用哪种方案,都可以搞个定时任务(比如每分钟),对比数据库和缓存的关键数据,不一致就修复——这是最后一道保险。
总结一下,解决缓存和数据库不一致的核心就是:优先选“更数据库+删缓存”,配合重试和定时校准;强一致性场景用分布式锁;尽量避免直接更新缓存。
Java基础知识
问:方法重写(Override)和重载(Overload)的核心区别?
重写:子类重写父类同名方法,方法签名(名称 + 参数列表 + 返回值)一致,需满足访问权限更宽松、异常范围更小;
重载:同一类中同名方法,参数列表(个数 / 类型 / 顺序)不同,与返回值无关。
问:抽象类和接口的核心区别(JDK8+)?
抽象类:可含抽象方法 + 普通方法 / 成员变量,有构造方法,单继承;
接口:JDK8 + 可含默认方法(default)、静态方法,所有变量默认public static final,无构造方法,多实现。
问:Java 8 引入接口默认方法的原因?
Java 8 引入接口默认方法(也叫扩展方法),核心目的是在不破坏现有实现类的前提下,为接口新增功能(解决接口升级的兼容性问题)。
假设一个接口已被多个类实现,若直接新增抽象方法,所有实现类都必须修改以实现新方法,成本极高;而默认方法提供了 “默认实现”,实现类无需改动即可兼容。
问:多态的实现条件?
继承 或 实现 + 方法重写 + 父类引用指向子类对象(如Animal cat = new Cat();)。
问:Java 泛型的核心作用是什么?
编译期类型检查,避免强制类型转换,实现代码复用(如通用集合 / 工具类)。
问:泛型擦除是什么?会带来什么问题?
JVM 在运行时会擦除泛型类型信息(仅保留原始类型,比如 List
问题:无法获取泛型实际类型、不能实例化泛型类对象(T obj = new T();)、基本类型不能作为泛型参数(List
**问:List
可以通过反射调用add(Object)可以传入任意类型,但在运行时可能会抛出ClassCastException转换异常。
问:final 关键字的三种用法?
修饰类(不可继承)、修饰方法(不可重写)、修饰变量(不可修改,基本类型值不可变,引用类型地址不可变)。
问:String、StringBuffer、StringBuilder 的区别?
String:不可变字符序列,拼接会创建新对象,效率低;
StringBuffer:可变字符序列,线程安全(synchronized),效率中等;
StringBuilder:可变字符序列,非线程安全,效率最高。
问:Java 值传递和引用传递的区别?
Java 只有值传递:
基本类型:传递值的副本,方法内修改不影响原变量;
引用类型:传递引用地址的副本,方法内修改对象属性会影响原对象,但修改引用指向不影响原引用。
问:Checked Exception(受检异常)和 Unchecked Exception(非受检异常)的区别?
受检异常:编译期必须处理(try-catch/throws),如 IOException、SQLException;
非受检异常:运行时异常,编译期无需强制处理,如 NullPointerException、ArrayIndexOutOfBoundsException。
问:finally 块一定会执行吗?
不一定;若执行到System.exit(0)、线程死亡、JVM 崩溃,finally 块不会执行。
Java 类加载原理
面试官您好,我理解的Java类加载机制,说白了就是JVM把.class文件里的代码,加载到内存里变成可以用的类对象的过程。它不是一次性把所有类都加载进来,而是“懒加载”——用到的时候才加载,比如你new一个对象、调用一个静态方法,这时候才会触发类加载。
整个过程分两大块:类加载的时机和类加载的步骤。
先说说类加载的时机,主要是触发了“主动使用”的时候才会加载,常见的主动使用场景有这么几个:
- 新建类的实例,比如
new User(); - 调用类的静态方法或者静态变量,比如
User.getName()、User.age; - 反射调用类的方法,比如
Class.forName("com.test.User"); - 加载一个类的时候,如果它的父类还没加载,会先加载父类;
- 执行main方法的那个类(程序入口类),肯定是第一个被加载的。
除了这些主动使用的情况,其他比如引用静态常量(final修饰的),不会触发类加载,因为常量在编译期就被放到常量池里了,不用加载类就能用。
然后是类加载的核心步骤:
-
第一步:加载
这一步就是JVM找到.class文件,把文件里的二进制数据读进内存,然后在内存里生成一个代表这个类的Class对象。
Java的类加载器是“双亲委派模型”:就是加载一个类的时候,先让父加载器去加载,父加载器加载不了,自己再加载。比如我们自己写的类,会先交给应用类加载器,它会找扩展类加载器,扩展类加载器再找启动类加载器;只有启动类加载器加载不了(比如不是JDK核心类),才会一层层往下走,自己加载。这样做是为了安全,避免我们自己写个java.lang.String把系统的String类替换掉。 - 第二步:链接
链接又分三个小步骤,都是对类的数据做处理:- 首先是验证:检查.class文件是不是合法的,有没有被篡改过,符不符合Java的语法规范,比如魔数对不对、版本号兼容不兼容,这一步是为了保证安全,不让恶意代码混进来。
- 然后是准备:给类的静态变量分配内存,并且设置默认值。(比如
public static int age = 18;,这一步会给age分配内存,默认值设成0,而不是18——18是后面初始化阶段才会赋值的。) - 最后是解析:把类里的符号引用(比如代码里写的
User user,这里的User就是符号引用),替换成直接引用(就是内存里的地址),比如知道User类在内存的哪个位置,后续调用的时候直接找这个地址。
-
第三步:初始化
这一步才是给静态变量赋真正的值,还有执行静态代码块。初始化的顺序是:先父类,再子类;先静态变量,再静态代码块。 -
第四步:使用
类加载完成后,就可以正常用了——new对象、调方法、用静态变量,都在这一步。 - 第五步:卸载
当一个类不再被使用,而且加载它的类加载器也被回收了,JVM就会把这个类的Class对象从内存里删掉,释放资源。不过一般我们写的应用很少会触发类卸载,除非是一些动态加载类的场景,比如热部署。
最后再补充个关键点:类加载器的双亲委派模型不是绝对的,可以打破。比如Tomcat的类加载器就没完全遵守,因为Tomcat要部署多个web应用,每个应用的类要隔离,不能互相影响,所以它自己实现了类加载器,优先加载自己应用里的类,再找父加载器。
总结一下,Java类加载机制就是“按需加载,分五步走,靠双亲委派保证安全”,核心就是把.class文件变成内存里能用的类对象。
JVM 内存区域
- 堆内存:
- 年轻代:Eden区:新对象首先分配在这里。 Survivor区(From和To):经过Minor GC后存活的对象会移到Survivor区。
- 老年代:15次MinorGC后;Survivor区空间不足;大对象直接进入老年代;老年代对象引用。
- 永久代或元空间:存储类信息、常量、静态变量等(Java 8之前是永久代,Java 8及之后是元空间)。
- 方法区:存储类信息、常量、静态变量等,Java 8及之后由元空间实现。
- 栈内存:存储局部变量和方法调用,不涉及GC。
- 本地方法栈:用于Native方法,不涉及GC。
- 程序计数器:每一个线程都必须有一个独立的程序计数器,用于记录下一条要运行的指令。不涉及GC。
GC 算法
| GC算法 | 核心逻辑 | 应用场景 | 优点 | 缺点 |
|---|---|---|---|---|
| 引用计数法 | 为每个对象维护引用计数器,计数为0时回收 | Java未使用 | 即时回收,暂停时间短 | 无法解决循环引用,计数器开销大 |
| 标记-清除 | 分两阶段:标记可达对象,清除未标记对象 | 老年代GC(需结合其他算法优化) | 实现简单,兼容性好 | 内存碎片化,效率低 |
| 复制算法 | 将内存分为两块,存活对象从From复制到To,清空From | 新生代GC(Eden和Survivor区) | 无碎片化,分配效率高 | 内存利用率低,复制成本高(存活率高时) |
| 标记-整理 | 标记可达对象后,将所有存活对象压缩到内存一端,消除碎片 | 老年代GC(CMS、G1的标记阶段) | 无碎片化,内存利用率高 | 整理阶段开销大,移动对象成本高 |
| 分代收集 | 根据对象生命周期分新生代(复制算法)和老年代(标记-整理/清除) | JVM堆内存管理(默认策略) | 针对不同对象优化,提升吞吐量 | 需额外管理分代信息 |
| 分区收集 | 将堆划分为多个独立区域,每次仅回收部分区域,控制停顿时间 | G1、ZGC等现代垃圾回收器 | 减少单次GC影响范围,提升吞吐量 | 实现复杂,需额外区域管理 |
GC 类型
| GC类型 | 定义 | 触发条件 | 行为 | 补充说明(区别/适用范围) |
|---|---|---|---|---|
| Minor GC | 仅回收年轻代(Eden区 + Survivor区) | Eden区满时触发 | 1. 存活对象从Eden区和From Survivor区复制到To Survivor区; 2. 对象年龄达到阈值(默认15)时晋升到老年代 |
适用所有分代垃圾收集器(如Serial、Parallel、CMS、G1) |
| Major GC | 传统分代收集器(如CMS、Serial Old、Parallel Old)中仅回收老年代的GC | 老年代空间不足时触发 | 1. CMS:并发标记清除,避免长时间STW,但存在内存碎片; 2. Serial Old/Parallel Old:标记-整理/清除,伴随长时间STW |
- |
| Mixed GC | G1收集器中同时回收年轻代和部分老年代Region的GC | 老年代Region占用超过堆内存阈值(默认45%) | 1. 基于全局并发标记结果,选择高垃圾比例的Region回收; 2. 与年轻代GC合并执行,优先回收高价值Region |
1. 与Major GC区别:同时处理年轻代+部分老年代,Major GC仅处理老年代; 2. 无内存碎片(复制算法) |
| Full GC | 回收整个堆内存(年轻代+老年代)和方法区(元空间) | 1. 老年代空间不足(Major GC失败后); 2. 方法区(元空间)不足; 3. 显式调用System.gc() |
1. 所有应用线程暂停(STW),耗时较长; 2. G1中为单线程Serial Old算法,性能极差 |
需尽量避免触发 |
GC Root
- 虚拟机栈中的局部变量
- 方法区中的静态变量
- 方法区中的常量
- 本地方法栈中的 JNI 引用
- Java 虚拟机内部的引用
- 被同步锁(synchronized)持有的对象
- 活跃线程中的对象
G1 垃圾回收过程
面试官您好,我理解的G1垃圾回收器,是JDK 9之后的默认GC,它的核心目标是在高内存场景下,兼顾吞吐量和低延迟——不像CMS只追低延迟,也不像Parallel GC只追吞吐量,属于“全能型”GC。
G1的核心是把堆内存分成很多大小相等的独立Region(区域,默认2048个),不管是新生代还是老年代,都不再是连续的一块,而是分散在这些Region里。每个Region可以动态切换角色,比如这次是新生代Eden区,下次可能变成老年代区,灵活性很高。
G1的回收过程,主要分年轻代回收(Young GC) 和混合回收(Mixed GC) 两大块,还有一个并发标记的环节,整体是个循环的过程:
一、 年轻代回收(Young GC)——常规操作,频率高
- G1的新生代也是由Eden区和Survivor区的Region组成的。当Eden区的Region占满了,就会触发Young GC。
- 回收的时候,G1会暂停所有用户线程(也就是STW,Stop The World),然后把Eden区和Survivor区里存活的对象,复制到新的Survivor区或者晋升到老年代的Region里。
- 回收完之后,清空原来的Eden和旧Survivor区的Region,这些Region就变成空闲的,等着下次分配新对象。
- 这个过程很快,因为新生代的对象大多是“朝生夕死”的,存活的对象少,复制起来开销小。
二、 并发标记——后台偷偷做,不怎么影响用户线程
当老年代的内存占用达到阈值(默认45%) 时,就会触发并发标记流程,这个过程和用户线程并行执行,几乎不暂停:
- 初始标记:这里会短暂STW,只标记一下GC Roots直接关联的对象(比如栈里的引用、静态变量),速度很快,用户几乎感知不到。
- 并发标记:这一步完全和用户线程一起跑,从初始标记的对象出发,遍历整个对象图,标记出所有存活的对象。期间用户线程还在正常创建、销毁对象,G1会记录下这些变化。
- 最终标记:又一次短暂STW,处理并发标记期间用户线程产生的“变化记录”,把遗漏的存活对象标记完,这个停顿时间也很短。
- 筛选回收:这一步会计算每个Region的“回收价值”——也就是这个Region里有多少垃圾、回收能释放多少内存,然后按价值从高到低排序,优先回收价值最高的Region。这样就能保证在用户指定的最大停顿时间内,回收最多的内存。
三、 混合回收(Mixed GC)——重点回收老年代,核心操作
并发标记结束后,就会触发混合回收,这个过程也是STW的,但停顿时间可以控制:
- 混合回收不只是回收老年代的Region,还会顺带回收新生代的Region(相当于一次Young GC + 老年代部分Region回收)。
- G1会根据之前算的“回收价值”,挑选一批老年代里垃圾最多的Region,和新生代一起回收——把这些Region里的存活对象复制到其他空闲Region,然后清空这些Region。
- 混合回收不是一次把所有老年代Region都回收完,而是分多次进行,每次都控制停顿时间不超过用户设置的阈值,这样就不会出现像CMS那样长时间的STW。
最后补充G1的两个关键特点,面试加分
- 可预测的停顿时间:用户可以通过参数
-XX:MaxGCPauseMillis设置最大停顿时间,G1会在这个时间内,优先回收价值最高的Region,保证延迟可控,特别适合响应时间敏感的业务(比如电商、金融)。 - 避免内存碎片:因为G1是复制算法,回收的时候会把存活对象复制到连续的Region里,不像CMS用标记清除算法会产生大量内存碎片,也就不用频繁做Full GC来整理碎片。
- Remembered Set:为了判断 Region 里对象是否存活,G1 给每个 Region 都配了一个Remembered Set(RS),它的作用就是记录其他 Region 对当前 Region 内对象的引用关系,避免全堆扫描,提升回收效率。
- 三色标记算法:G1 的并发标记阶段,是用三色标记来标记存活对象的,流程是:所有对象默认为白色,先标记 GC Roots 直接关联的对象(变成灰色),然后从灰色对象出发,遍历它的子对象(子对象变灰色,自己变黑色),直到没有灰色对象为止。
- 漏标:并发标记时,用户线程还在运行,可能会导致 “漏标” —— 把存活的对象误标为垃圾。G1 解决漏标的方案是 写屏障 + SATB(快照原子化):
- 简单说,SATB 会在并发标记开始时,给整个对象图拍个 “快照”,保证标记的是快照时刻的对象状态;
- 同时配合写屏障,记录下并发标记期间对象引用的变化(比如新增的引用、删除的引用);
- 最终标记阶段,会根据这些记录修正标记结果,避免漏标,保证没有存活对象被误回收。
CMS与G1的区别
| 对比维度 | CMS(Concurrent Mark Sweep) | G1(Garbage-First) |
|---|---|---|
| 应用范围 | 仅负责老年代垃圾回收,必须搭配新生代收集器(如 ParNew)使用 | 全堆收集器,可同时管理新生代和老年代,无需搭配其他收集器 |
| 内存模型 | 物理分代:堆内存被划分为固定大小的新生代、老年代、永久代(JDK8 后为元空间),区域边界固定 | 逻辑分代:堆被划分为多个大小相等的 Region(默认 1~32MB),新生代、老年代 Region 混合分布,逻辑上区分,物理上不隔离 |
| 回收算法 | 基于 标记-清除算法:清除垃圾后不整理内存,会产生内存碎片,长期运行易触发 Full GC | 基于 标记-整理+复制算法:回收时将存活对象复制到空 Region,自动整理内存,无内存碎片问题 |
| 停顿控制 | 无法精确控制停顿时间,仅能减少 STW 时长;大堆下停顿不可预测,容易出现卡顿 | 支持通过 -XX:MaxGCPauseMillis 指定最大停顿时间目标,G1 会动态选择回收的 Region 数量,确保停顿时间达标 |
| 适用场景 | 低延迟优先、中小堆内存(<6GB) 的场景,如 Web 服务、轻量级应用 | 大堆内存(>6GB,支持 TB 级)、兼顾吞吐量和延迟的场景,如大数据、中间件、微服务集群 |
| 核心痛点 | 1. 内存碎片导致 Full GC 频繁; 2. 并发阶段产生浮动垃圾,需预留内存(默认老年代 68%); 3. 依赖新生代收集器,大堆下效率低 |
1. Region 划分和 Remembered Set 维护有额外开销; 2. 小堆场景下性能不如 CMS |
| 关键依赖 | 需配合 ParNew 作为新生代收集器,依赖新生代 GC 时的 STW 扫描跨代引用 | 依赖 Remembered Set 记录跨 Region 引用,避免全堆扫描,提升回收效率 |
强软弱虚引用
- 强引用:最常见的引用类型,不会被 GC 回收。
- 软引用:内存不足时回收,适合缓存。
- 弱引用:发生 GC 时回收,适合临时缓存或监听器。
- 虚引用:无法获取对象,需与引用队列(
ReferenceQueue)配合使用,用于跟踪对象回收状态。 - 引用队列(ReferenceQueue):当引用对象被回收时,引用会被加入引用队列,方便后续处理。
ThreadLocal
作用:为每个线程提供独立的变量副本,实现线程内数据隔离,避免多线程共享变量的线程安全问题,常用于存储线程上下文信息(如用户登录态、数据库连接、事务管理)。
原理:每个Thread对象内部维护一个 ThreadLocalMap,ThreadLocal 作为该 Map 的 key,变量副本作为 value; 调用set()时,实际是往当前线程的ThreadLocalMap中存入键值对,get()则是从当前线程的 Map 中取值。
问:ThreadLocalMap 的 key 为什么是弱引用?
ThreadLocal 作为 Map 的 key 采用弱引用,是为了避免内存泄漏:当ThreadLocal外部强引用被回收后,弱引用的 key 会被 GC 自动清理,防止因 key 无法回收导致 value 长期驻留内存;但仅靠弱引用不够,仍需手动调用remove()释放 value。
问:ThreadLocal 为什么会发生内存泄漏?如何避免?
内存泄漏原因:若ThreadLocal外部引用失效,key(弱引用)会被 GC 回收,但 value(强引用)仍和Thread绑定,若线程长期存活(如线程池核心线程),value 会一直占用内存。
避免方案:使用完ThreadLocal后必须手动调用remove()释放 value。
问:ThreadLocal 在父子线程间能共享数据吗?如何实现父子线程数据传递?
普通 ThreadLocal 无法实现父子线程数据共享,因为父子线程有各自的 ThreadLocalMap;可使用InheritableThreadLocal,它能让子线程继承父线程的 ThreadLocal 变量副本。
原理是当父线程通过new Thread()创建子线程时,会触发Thread类构造方法中的init()逻辑:若父线程的inheritableThreadLocals不为空,会将父线程该 Map 中的数据浅拷贝到子线程的inheritableThreadLocals中。
需要注意:由于数据继承仅发生在子线程创建时,若父线程在子线程启动后修改 InheritableThreadLocal 的值,子线程不会感知到新值。
另外,浅拷贝特性会导致父子线程共享引用类型的 value 对象,可能引发线程安全问题。
HashMap
“HashMap是Java中基于哈希表实现的键值对存储容器,实现了Map接口,允许键和值为null(键仅允许一个null,值可多个),底层是「数组+链表+红黑树」的组合结构,非线程安全,查找、插入、删除的平均时间复杂度为O(1)。”
一、底层结构(从JDK 1.8核心实现讲起)
1.核心组成
- 数组(Node[] table):哈希桶数组,每个元素是链表/红黑树的头节点,数组下标由key的哈希值计算得出;
- 链表(Node<K,V>):解决哈希冲突(不同key计算出相同下标),当链表长度≤8时,用链表存储冲突元素;
- 红黑树(TreeNode<K,V>):当链表长度>8且数组长度≥64时,链表转为红黑树(若数组长度<64,先扩容而非转树),降低冲突时的查询复杂度(从O(n)→O(log n))。
2.哈希值计算与下标定位
“HashMap定位数组下标分两步:
① 计算key的哈希值:hash = key.hashCode() ^ (key.hashCode() >>> 16)(扰动函数),目的是把hashCode的高16位和低16位混合,减少哈希冲突(因为数组长度通常较小,高16位参与运算能提升散列性);
② 计算数组下标:index = hash & (table.length - 1)(替代取模运算,更高效),前提是数组长度为2的幂次,这也是HashMap扩容必须翻倍的原因。”
三、核心机制
1. 哈希冲突解决
“HashMap用「链地址法」解决哈希冲突(区别于开放地址法):相同下标的key会被放到同一个哈希桶的链表/红黑树中;
补充:JDK 1.7只用数组+链表,JDK 1.8引入红黑树优化,因为当链表过长时,查询效率会退化到O(n),红黑树能保证最坏情况下的查询效率。”
2. 扩容机制(resize)
(1)扩容触发条件
- 首次put元素时,数组初始化(默认初始容量16,加载因子0.75);
- 后续put时,若「元素个数 > 数组长度 × 加载因子」,或「链表转树时数组长度<64」,触发扩容。
(2)扩容核心逻辑
“扩容时数组长度翻倍(保证2的幂次),重新计算所有元素的下标(因为下标依赖数组长度);
JDK 1.8优化:扩容时无需重新计算hash,只需判断原hash的「扩容长度对应的二进制位」是0还是1——0则下标不变,1则下标=原下标+旧数组长度,减少哈希值计算开销;
加载因子0.75是时间/空间的平衡:太小(如0.5)会导致数组频繁扩容,空间浪费;太大(如1.0)会导致哈希冲突加剧,查询变慢。”
3. put方法执行流程
“put的完整步骤:
① 判断数组是否为空,为空则初始化(首次扩容);
② 计算key的hash值和数组下标,若下标位置为空,直接新建Node放入;
③ 若下标位置有元素:
- 若key的hash和equals都匹配(命中),替换旧值;
- 若不匹配,判断是红黑树/链表:红黑树则插入树节点,链表则尾插(JDK 1.7是头插,会导致并发扩容死循环),插入后检查链表长度是否>8,满足则转红黑树;
④ 插入后判断元素个数是否超过阈值,超过则扩容。”
四、关键特性
1. 线程安全问题
“HashMap非线程安全,并发场景下会出现问题:
- JDK 1.7:并发扩容时,头插法会导致链表成环,引发死循环;
- JDK 1.8:修复了成环问题,但仍可能出现数据覆盖(如两个线程同时put,一个线程覆盖另一个的结果);
替代方案:并发场景用ConcurrentHashMap(分段锁/CAS+Synchronized优化),而非Hashtable(全表锁,性能差)。”
2. 允许null值的细节
“键允许一个null(因为null的hash值固定为0,下标为0),值可以多个null;Hashtable不允许键/值为null,因为Hashtable的put方法会直接抛NullPointerException。”
3. 遍历方式与快速失败(fail-fast)
“HashMap的遍历(如Iterator)采用快速失败机制:遍历过程中若修改HashMap结构(put/remove),会抛出ConcurrentModificationException; 原因是遍历依赖modCount(修改次数),每次结构修改modCount++,遍历前记录expectedModCount,若两者不一致则抛异常;
注意:快速失败是「尽最大努力」检测,非绝对保证(比如单线程遍历中remove元素,通过迭代器的remove方法则不会抛异常)。”
五、JDK 1.7 vs JDK 1.8 核心差异
| 维度 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 底层结构 | 数组+链表 | 数组+链表+红黑树 |
| 插入方式 | 链表头插(扩容易成环) | 链表尾插(避免成环) |
| 哈希计算 | 多次扰动(4次位运算) | 一次扰动(hashCode^高16位) |
| 扩容后下标 | 重新计算hash | 按二进制位判断(0则不变,1则+旧长度) |
| 失败机制 | 快速失败 | 快速失败 |
六、常见面试追问的应答
1. “为什么HashMap的数组长度必须是2的幂次?”
“核心是为了用「hash & (length-1)」替代取模(%),提升效率;且2的幂次减1的二进制全是1,能让hash值的所有位都参与下标计算,减少哈希冲突(若长度非2的幂次,length-1的二进制有0位,会导致部分hash位失效,冲突概率升高)。”
2. “为什么链表转红黑树的阈值是8,红黑树转链表是6?”
“这是基于「泊松分布」的统计:链表长度达到8的概率极低(约0.00000006),此时转树能优化性能;转回阈值设为6而非8,是为了避免频繁在链表和红黑树之间切换(比如长度在8附近波动时,减少转换开销)。”
3. “HashMap和ConcurrentHashMap的区别?”
“ConcurrentHashMap是线程安全的HashMap,核心差异:
① JDK 1.7:采用分段锁(Segment数组),每个Segment对应一个HashMap,锁粒度是Segment,比Hashtable的全表锁高效;
② JDK 1.8:抛弃Segment,用「CAS+Synchronized」锁定哈希桶节点,锁粒度更小(仅锁定冲突的链表/红黑树头节点),且底层结构和HashMap一致(数组+链表+红黑树);
③ 不允许键/值为null(HashMap允许),因为并发场景下null无法区分「未赋值」和「值为null」,易引发歧义。”
ConcurrentHashMap
“ConcurrentHashMap是Java并发包(java.util.concurrent)下的线程安全键值对容器,实现了Map接口,专为高并发场景设计——既保证了多线程下的数据一致性,又通过细粒度锁/无锁机制避免了Hashtable全表锁的性能问题,底层基于哈希表实现,查询、插入、删除的平均时间复杂度仍为O(1)。”
二、核心:JDK 1.7 vs 1.8 实现差异
| 维度 | JDK 1.7 | JDK 1.8 |
|---|---|---|
| 底层结构 | 分段数组(Segment[])+ 哈希桶(数组+链表) | 哈希桶数组(Node[])+ 链表+红黑树(和HashMap 1.8结构一致) |
| 线程安全核心 | 分段锁(ReentrantLock + Segment) | CAS + Synchronized(锁定哈希桶节点) |
| 并发度 | 由Segment数组长度决定(默认16),最多16个线程并发 | 无固定并发度,理论上支持与CPU核心数匹配的并发量 |
| null值支持 | 不允许键/值为null | 不允许键/值为null |
| 扩容机制 | 单个Segment独立扩容(不影响其他Segment) | 全局扩容,扩容时支持并发读、分段扩容 |
1. JDK 1.7 实现细节(分段锁思想)
“JDK 1.7的ConcurrentHashMap核心是「分段锁」:
① 整体结构是Segment数组(默认长度16),每个Segment是一个独立的「小HashMap」,自带ReentrantLock锁;
② 当线程操作某个key时,只会锁定该key所在的Segment,而非整个容器——比如线程A操作Segment[0]的key,线程B可同时操作Segment[1]的key,实现16个线程的并发操作;
③ 缺点:Segment数组长度固定(默认16),并发度受限;且Segment本身是重量级锁,性能不如1.8的细粒度锁。”
2. JDK 1.8 实现细节(细粒度锁+CAS优化)
“JDK 1.8抛弃了Segment,直接复用HashMap 1.8的「数组+链表+红黑树」结构,线程安全靠「CAS + Synchronized」保证,核心逻辑:
① 无竞争时(哈希桶为空):用CAS原子操作插入节点,无需加锁;
② 有竞争时(哈希桶已有元素):用Synchronized锁定「哈希桶的头节点」——仅锁定冲突的链表/红黑树,而非整个数组,锁粒度比1.7的Segment更细;
③ 红黑树优化:当链表长度>8且数组长度≥64时,链表转红黑树,提升冲突时的查询效率(O(log n));
④ 扩容优化:扩容时采用「分段扩容」,同时支持并发读,扩容期间不阻塞所有操作,仅阻塞扩容的哈希桶写入。”
三、核心机制(面试重点,讲清“怎么保证线程安全”)
1. 线程安全的具体实现(JDK 1.8 核心)
“JDK 1.8的ConcurrentHashMap在put/扩容等关键操作中保证线程安全:
① put操作:
- 第一步:计算key的hash值和数组下标,若哈希桶为空,用CAS原子操作插入节点;
- 第二步:若哈希桶已有节点,用Synchronized锁定头节点,然后遍历链表/红黑树:匹配key则替换值,不匹配则尾插节点;
- 第三步:插入后检查链表长度,满足条件则转红黑树,同时判断是否需要扩容;
② 扩容操作:
- 触发条件:和HashMap类似(元素个数>数组长度×加载因子0.75);
- 扩容逻辑:扩容时数组长度翻倍,采用「多线程分段扩容」——每个线程负责一部分哈希桶的迁移,迁移期间该哈希桶的读操作仍可执行,写操作阻塞,大幅提升扩容效率。”
2. 为什么不允许null值?
“ConcurrentHashMap和HashMap不同,不允许键/值为null,核心原因是「并发场景下null无法区分状态」:
- 比如调用get(key)返回null,无法判断是「key不存在」还是「key对应的值就是null」;
- 单线程的HashMap可以通过containsKey(key)区分,但并发场景下,containsKey和get之间可能有其他线程修改数据,结果不可靠;
- Hashtable也不允许null值,ConcurrentHashMap延续了这一设计,避免并发歧义。”
3. 快速失败(fail-fast)vs 弱一致性(fail-safe)
“ConcurrentHashMap的遍历采用「弱一致性」(fail-safe),而非HashMap的快速失败(fail-fast):
- 快速失败:HashMap遍历中修改结构会抛ConcurrentModificationException,依赖modCount检测;
- 弱一致性:ConcurrentHashMap用迭代器遍历时,会读取遍历开始时的快照数据,遍历过程中其他线程修改数据,不会抛异常,但可能读取到旧数据(保证最终一致性,而非实时一致性);
- 原因:ConcurrentHashMap的迭代器基于Unsafe类的CAS操作获取数据,避免了modCount的强校验,适配并发场景。”
四、关键对比(面试必问,体现差异化理解)
1. ConcurrentHashMap vs HashMap
| 维度 | ConcurrentHashMap | HashMap |
|---|---|---|
| 线程安全 | 线程安全(CAS+Synchronized/分段锁) | 非线程安全(并发会数据覆盖/成环) |
| null值 | 不允许键/值为null | 允许键一个null、值多个null |
| 遍历机制 | 弱一致性(fail-safe) | 快速失败(fail-fast) |
| 底层结构 | JDK1.8:数组+链表+红黑树 | JDK1.8:数组+链表+红黑树 |
| 扩容 | 并发扩容(分段迁移) | 单线程扩容(阻塞) |
2. ConcurrentHashMap vs Hashtable
| 维度 | ConcurrentHashMap | Hashtable |
|---|---|---|
| 锁机制 | 细粒度锁(Segment/节点锁) | 全表锁(Synchronized修饰方法) |
| 并发性能 | 高(支持多线程并发读写) | 低(单线程独占) |
| 底层结构 | JDK1.8:数组+链表+红黑树 | 数组+链表(无红黑树优化) |
| null值 | 不允许 | 不允许 |
五、常见面试追问的应答(提前准备)
1. “JDK 1.8为什么用Synchronized而非ReentrantLock?”
“核心是JDK 1.6后Synchronized的优化(偏向锁、轻量级锁、重量级锁),性能已接近ReentrantLock,且:
① Synchronized是JVM层面的锁,虚拟机可做更多优化(如锁消除、锁粗化);
② ReentrantLock需手动释放锁(unlock),易遗漏导致死锁,Synchronized自动释放,更安全;
③ 结合CAS使用时,Synchronized仅锁定冲突节点,粒度足够细,无需ReentrantLock的复杂功能(如公平锁、条件变量)。”
2. “ConcurrentHashMap的并发度是多少?”
“JDK 1.7:并发度由Segment数组长度决定(默认16),最多16个线程并发;
JDK 1.8:无固定并发度——锁粒度是哈希桶节点,理论上有多少个不冲突的哈希桶,就支持多少线程并发,并发度远高于1.7。”
3. “ConcurrentHashMap能保证强一致性吗?”
“不能,它保证的是「最终一致性」:
- 比如线程A更新了某个key,线程B可能在短时间内读取到旧值(因为迭代器是快照读);
- 若需强一致性,需额外加锁(如全局锁),但会丧失并发性能,此时建议用其他方案(如数据库事务)。”
volatile
一、volatile 要解决的问题
- 缓存可见性问题 多线程环境下,每个线程会将主内存变量加载到自身的CPU缓存/工作内存中,线程修改变量时仅更新本地缓存,未及时同步到主内存;同时其他线程仍读取旧的本地缓存值,导致线程间变量状态不一致。
- 指令重排序问题 编译器和CPU为提升执行效率,会对无数据依赖的指令进行重排序,在多线程场景下,这种重排序可能破坏代码的执行逻辑(如单例模式的双重检查锁中,实例初始化与地址赋值的重排序会导致线程获取到未完全初始化的对象)。
注意:volatile 无法解决原子性问题,对于i++这类复合操作(读-改-写),仍会因多线程并发操作出现数据异常。
二、volatile 的核心作用
- 保证变量可见性 线程修改 volatile 变量后,新值会立即刷新到主内存;其他线程读取该变量时,会强制从主内存加载,不再使用本地缓存,确保变量状态在线程间实时同步。
- 禁止指令重排序 对 volatile 变量的读写操作会阻止编译器和CPU的指令重排序优化,保证代码执行顺序与逻辑顺序一致,避免因重排序引发的并发逻辑错误。
三、volatile 的底层实现原理
- 内存屏障机制
JVM会为 volatile 变量的读写操作插入特定内存屏障,约束指令执行顺序:
- 写操作:在 volatile 变量写指令前后插入
StoreStore屏障(确保之前的普通变量写操作先同步到主内存)和StoreLoad屏障(防止写操作与后续读操作重排序); - 读操作:在 volatile 变量读指令前后插入
LoadLoad屏障(防止读操作与之前的读操作重排序)和LoadStore屏障(防止读操作与后续写操作重排序)。
- 写操作:在 volatile 变量写指令前后插入
- CPU缓存一致性协议 基于MESI等缓存一致性协议,当CPU修改 volatile 变量对应的缓存行时,会通过总线嗅探机制通知其他CPU的对应缓存行失效,迫使其他线程从主内存重新加载变量,实现多CPU核心间的缓存状态一致。
乐观锁/悲观锁
| 维度 | 乐观锁 | 悲观锁 |
|---|---|---|
| 核心思想 | 假定不会发生并发冲突,仅在提交时校验是否被修改 | 假定一定会发生并发冲突,操作前先获取锁独占资源 |
| 实现方式 | 1. 版本号机制(如数据库的version字段) 2. CAS算法(如Java原子类AtomicInteger) |
1. 数据库行锁/表锁 2. Java的synchronized、Lock接口 |
| 阻塞性 | 非阻塞,冲突时仅返回失败或重试,不阻塞线程 | 阻塞,未获取锁的线程会进入等待状态 |
| 性能开销 | 无锁竞争时开销极低,冲突频繁时因重试产生额外消耗 | 存在锁的获取/释放、线程阻塞唤醒的开销,并发低时也有固定损耗 |
| 适用场景 | 读多写少、并发冲突概率低的场景(如商品库存查询、用户信息读取) | 写多读少、并发冲突概率高的场景(如金融交易、订单扣减) |
CAS(乐观锁)
无锁机制:通过 CPU 原子指令直接操作内存,无需加锁。
三步操作:比较内存值(V)与预期值(A),若相等则更新为新值(B);否则重试(自旋)。
原子性保障:由硬件指令保证比较和交换的原子性。
缺点: 1)有ABA问题,需通过版本号解决(AtomicStampedReference)。 2)高竞争时自旋浪费。 3)仅支持单个变量的原子操作,多变量原子性需封装为对象(AtomicReference)。
synchronized(悲观锁)
一、基础定义与核心作用
synchronized是Java原生的内置锁(也叫监视器锁),属于悲观锁范畴,核心作用是保证多线程环境下的原子性、可见性和有序性,解决线程安全问题。
- 原子性:通过独占锁机制确保同步代码块/方法内的操作整体执行,不会被其他线程打断;
- 可见性:释放锁时会将线程本地缓存刷新到主内存,获取锁时会从主内存加载最新数据;
- 有序性:通过内存屏障和锁的互斥性,隐式禁止指令重排序,保证代码执行顺序与逻辑一致。
二、不同位置的锁对象
- 修饰实例方法:锁定当前类的实例对象(this),不同实例的锁相互独立,多线程操作同一实例的同步方法才会竞争锁;
- 修饰静态方法:锁定当前类的Class对象,该锁全局唯一,所有线程调用该类同步静态方法都会竞争同一把锁,与实例无关;
- 修饰代码块:可灵活指定锁对象(如
this、类的Class对象、自定义对象),其中锁自定义对象能实现更细粒度的资源管控,减少锁竞争。
三、底层实现原理
- 对象头与Monitor关联:每个Java对象都关联一个Monitor(管程),
synchronized的底层依赖Monitor实现锁的互斥。同步方法通过ACC_SYNCHRONIZED访问标志触发锁获取,同步代码块通过monitorenter/monitorexit指令完成加锁与解锁; - Monitor核心结构:包含EntrySet(竞争锁失败的线程等待队列)、WaitSet(调用
wait()的线程等待队列)和持有锁的线程标识,释放锁时会唤醒EntrySet线程重新竞争。
四、JDK 6后的锁升级优化
- 偏向锁:单线程重复加锁时,通过Mark Word记录线程ID,实现零开销加锁,避免CAS操作;
- 轻量级锁:多线程交替竞争时,线程在栈中创建Lock Record,通过CAS修改Mark Word获取锁,竞争时采用自适应自旋重试,减少阻塞开销;
- 重量级锁:竞争激烈时升级为重量级锁,依托操作系统Mutex实现,线程竞争失败会进入内核态阻塞,存在用户态/内核态切换开销。
五、优缺点与使用场景
- 优点:使用简单、自动加锁解锁、支持重入,无需手动管理锁的生命周期;
- 缺点:锁升级后不可降级,重量级锁性能开销高,且无法实现公平锁、超时获取锁等灵活功能;
- 适用场景:并发竞争不激烈、追求代码简洁性的场景;若需更灵活的锁控制(如公平锁、非阻塞获取),可搭配
ReentrantLock使用。
AQS原理
一、核心定位与作用
AQS即AbstractQueuedSynchronizer(抽象队列同步器),是Java并发包(java.util.concurrent)的核心底层框架,为ReentrantLock、CountDownLatch、Semaphore等同步组件提供统一的同步实现模板,其核心作用是封装锁的获取/释放、线程排队等待的通用逻辑,让开发者无需重复实现底层同步机制。
二、底层核心结构
- 同步状态(state):通过一个
volatile int类型的state变量表示同步状态,不同同步组件可自定义state的含义(如ReentrantLock中state=0表示无锁,state>0表示重入次数;Semaphore中state表示可用许可数),且通过CAS保证state的原子性修改。 - CLH双向同步队列:当线程获取锁失败时,会被封装为Node节点(包含线程引用、等待状态等),加入到CLH队列尾部排队等待,队列采用双向链表结构,通过CAS实现节点的入队/出队,保证并发下的队列安全。
- Node节点的等待状态:Node包含
CANCELLED(取消等待)、SIGNAL(后续节点需唤醒)、CONDITION(条件等待)等状态,用于控制线程的等待与唤醒逻辑。
三、核心实现机制
- 独占式同步(如ReentrantLock非公平锁)
- 获取锁:线程先尝试通过CAS修改
state获取锁,成功则直接持有;失败则封装为Node加入CLH队列,进入自旋等待,同时判断前驱节点是否为头节点,若为头节点则再次尝试获取锁,获取失败则阻塞,等待前驱节点唤醒。 - 释放锁:线程修改
state释放锁,然后唤醒CLH队列的后继节点,使其重新参与锁竞争。
- 获取锁:线程先尝试通过CAS修改
- 共享式同步(如Semaphore、CountDownLatch)
- 获取资源:线程尝试减少
state(如Semaphore获取许可),若state仍满足条件则成功;失败则加入CLH队列等待,被唤醒后会继续传播唤醒后续节点(支持多线程同时获取资源)。 - 释放资源:线程增加
state(如Semaphore释放许可),并唤醒队列中等待的线程,实现资源的共享释放。
- 获取资源:线程尝试减少
- 条件等待机制:AQS还关联ConditionObject,支持线程在条件队列中等待(调用
await())和唤醒(调用signal()),条件队列与CLH同步队列可实现节点的转移。
四、典型应用场景
- 独占锁场景:ReentrantLock基于AQS的独占式模式实现,支持公平/非公平锁,通过
state记录重入次数; - 共享资源管控:Semaphore基于共享式模式,用
state表示许可数,实现多线程的资源限流; - 线程同步:CountDownLatch通过共享式模式,初始化
state为计数器值,线程调用countDown()递减state,await()等待state变为0实现同步; - 读写分离:ReentrantReadWriteLock基于AQS实现,通过
state高位表示读锁计数、低位表示写锁,区分读写锁的独占与共享逻辑。
五、优缺点总结
- 优点:统一封装同步底层逻辑,降低并发组件的实现成本;基于
volatile和CAS保证并发安全,支持独占/共享等多种同步模式,灵活性高; - 缺点:作为抽象框架,自定义同步组件需重写
tryAcquire()/tryRelease()等核心方法,对开发者技术要求较高;队列节点的自旋和阻塞存在一定性能开销。
Java 线程池
一、核心定位与作用
Java线程池是java.util.concurrent包下管理线程生命周期的工具,基于池化思想实现线程复用,核心作用是解决线程频繁创建/销毁的性能开销、统一管控线程资源(如并发数)、实现任务的有序执行,同时支持任务排队、拒绝等高级能力,是高并发场景下的核心组件。
二、核心参数(ThreadPoolExecutor的7个核心参数,面试高频)
- corePoolSize:核心线程数,线程池长期存活的线程数量,即使空闲也不会销毁(除非设置
allowCoreThreadTimeOut=true); - maximumPoolSize:最大线程数,线程池允许创建的最大线程总数,用于应对任务高峰;
- keepAliveTime:非核心线程的空闲存活时间,超过该时间的空闲非核心线程会被销毁;
- unit:
keepAliveTime的时间单位(如TimeUnit.SECONDS); - workQueue:任务等待队列,核心线程满时,新任务会进入队列排队,常见类型有
ArrayBlockingQueue(有界)、LinkedBlockingQueue(无界)、SynchronousQueue(直接传递); - threadFactory:线程工厂,用于自定义线程的创建逻辑(如设置线程名、优先级);
- handler:拒绝策略,当线程数达
maximumPoolSize且队列满时,对新任务的处理策略。
三、核心工作原理(任务提交到执行的完整流程)
- 任务提交后,先判断核心线程池是否已满:未满则创建核心线程执行任务;
- 若核心线程池已满,判断任务队列是否已满:未满则将任务加入队列等待;
- 若队列已满,判断线程数是否达maximumPoolSize:未达则创建非核心线程执行任务;
- 若线程数已达最大值,则触发拒绝策略处理任务。
四、拒绝策略(4种内置策略+自定义)
- AbortPolicy(默认):直接抛出
RejectedExecutionException,拒绝任务,适用于需感知任务提交失败的场景; - CallerRunsPolicy:由提交任务的线程自行执行任务,降低系统负载,适用于并发量不高的场景;
- DiscardPolicy:直接丢弃新任务,无任何提示,适用于非核心任务;
- DiscardOldestPolicy:丢弃队列中最久的任务,将新任务加入队列,适用于任务时效性强的场景;
- 补充:可实现
RejectedExecutionHandler自定义拒绝策略(如任务持久化、异步重试)。
五、常见线程池(Executors工具类创建的4种线程池)
- FixedThreadPool:核心线程数=最大线程数,使用无界队列,易因任务堆积导致OOM;
- CachedThreadPool:核心线程数为0,最大线程数无上限,非核心线程空闲1分钟销毁,适用于短期高频任务,易因线程过多导致CPU耗尽;
- SingleThreadExecutor:单线程执行任务,保证任务串行执行,无界队列易OOM;
- ScheduledThreadPool:支持定时/周期性任务,适用于任务调度场景;
- 关键提醒:阿里开发手册不建议用
Executors创建线程池,推荐手动创建ThreadPoolExecutor,便于精准管控核心参数。
六、使用注意事项与优化建议
- 参数配置:核心线程数可按业务类型配置(CPU密集型=CPU核心数+1,IO密集型=CPU核心数×2),队列建议用有界队列避免OOM;
- 资源回收:线程池使用完毕需调用
shutdown()/shutdownNow()关闭,避免线程泄露; - 监控与调优:通过线程池的
getActiveCount()、getQueueSize()等方法监控状态,结合业务压力动态调整核心参数; - 任务特性:避免向线程池提交耗时过长的任务,防止阻塞核心线程导致任务堆积。
Java 线程状态
| Java线程状态(Thread.State) | 对应CPU层面线程状态 | 核心关联逻辑 | 触发场景 |
|---|---|---|---|
| NEW(新建) | 无对应CPU状态 | 仅为JVM层面的对象实例,未向操作系统注册线程,不占用CPU资源 | 线程对象已创建但未调用start() |
| RUNNABLE(可运行) | 就绪(Ready) 运行(Running) |
- 就绪:线程已被操作系统调度器纳入队列,等待CPU时间片 - 运行:线程正在占用CPU核心执行指令 JVM的RUNNABLE包含这两个CPU子状态,由OS调度切换 |
调用start()后;线程获取锁/被唤醒后;时间片用完切回就绪 |
| BLOCKED(阻塞) | 阻塞(Blocked) (内核态锁等待) |
线程因竞争synchronized锁进入内核态等待队列,此时不占用CPU,OS会将其从就绪队列移除 |
线程请求synchronized锁失败,等待其他线程释放锁 |
| WAITING(等待) | 休眠(Sleeping) (内核态/用户态等待) |
线程因wait()/join()/park()进入等待,CPU不再调度该线程,需其他线程主动唤醒 |
调用Object.wait()(无超时)、Thread.join()(无超时)、LockSupport.park() |
| TIMED_WAITING(超时等待) | 休眠(Sleeping) (带超时的内核态等待) |
线程进入带超时的等待队列,超时后OS会自动唤醒,期间不占用CPU | 调用Thread.sleep(long)、Object.wait(long)、Thread.join(long) |
| TERMINATED(终止) | 无对应CPU状态 | 线程已销毁,OS回收其CPU调度资源,JVM层面标记为终止状态 | run()执行完毕或线程因异常退出 |
Spring循环依赖
一、先明确循环依赖的定义与产生场景
- 核心定义
- Spring循环依赖指两个或多个Bean之间互相持有对方的引用(如A依赖B,B又依赖A;或A→B→C→A的闭环依赖),若不处理会导致Bean创建时陷入死循环,最终抛出
BeanCurrentlyInCreationException。
- Spring循环依赖指两个或多个Bean之间互相持有对方的引用(如A依赖B,B又依赖A;或A→B→C→A的闭环依赖),若不处理会导致Bean创建时陷入死循环,最终抛出
- 常见产生场景
- 构造器注入:Bean的构造方法中依赖对方(如A的构造器传B,B的构造器传A);
- 字段/setter注入:Bean的成员变量或setter方法依赖对方(如A有B类型字段,B有A类型字段);
- 多例Bean循环依赖:原型(
prototype)作用域的Bean互相依赖。
二、Spring解决循环依赖的核心原理
Spring仅能解决单例Bean的字段/setter注入场景的循环依赖,核心依托三级缓存实现,三级缓存的结构和作用如下:
- 三级缓存的定义
- 一级缓存(singletonObjects):存储完全初始化完成的单例Bean,供容器直接获取;
- 二级缓存(earlySingletonObjects):存储提前暴露的Bean实例(未完成属性填充和初始化的半成品Bean);
- 三级缓存(singletonFactories):存储Bean的工厂对象(ObjectFactory),用于生成Bean的早期实例,同时支持AOP代理的提前创建。
- 循环依赖的解决流程(以A依赖B、B依赖A为例)
- 容器创建BeanA,实例化后(未填充属性、未执行初始化),将A的ObjectFactory存入三级缓存;
- 填充BeanA的属性时,发现依赖BeanB,转而创建BeanB;
- 容器创建BeanB,实例化后将B的ObjectFactory存入三级缓存;
- 填充BeanB的属性时,发现依赖BeanA,先从一级缓存查A(无),再查二级缓存(无),最后查三级缓存获取A的ObjectFactory,生成A的早期实例存入二级缓存(移除三级缓存),并将A的早期实例注入BeanB;
- BeanB完成属性填充和初始化,存入一级缓存;
- 回到BeanA的属性填充,从一级缓存获取已初始化的BeanB注入,BeanA完成初始化后存入一级缓存(移除二级缓存)。
三、Spring无法解决的循环依赖场景(关键限制)
- 构造器注入的循环依赖:Bean实例化前就需要依赖对方,此时无早期实例可暴露,无法解决;
- 多例(prototype)Bean的循环依赖:多例Bean每次请求都会新建,容器不缓存其实例,无三级缓存的支撑;
- 单例Bean但未开启循环依赖(默认开启,可通过
allowCircularReferences=false关闭):Spring 2.6+后默认关闭循环依赖,需手动开启才能支持。
四、循环依赖的规避与优化方案
- 开发层面规避
- 优先使用接口隔离和依赖注入分层,拆解闭环依赖(如引入中间层C,让A和B都依赖C);
- 避免构造器注入的循环依赖,改用setter或字段注入;
- 对非必要的循环依赖,通过
@Lazy延迟加载依赖Bean,打破初始化闭环。
- 配置层面处理
- 若需支持构造器循环依赖,可通过
@Autowired结合@Lazy实现(延迟获取构造器参数); - 开启
allowCircularReferences(Spring 2.6+),确保单例字段注入的循环依赖可被解决。
- 若需支持构造器循环依赖,可通过
五、总结
Spring通过三级缓存巧妙解决了单例Bean字段/setter注入的循环依赖,核心是“先暴露引用,后属性赋值”;但该机制有明确的适用范围,实际开发中应优先规避循环依赖,而非依赖容器的解决能力,以保证代码的高内聚低耦合。
Spring事务原理/失效/传播机制
一、Spring事务的核心原理
- 底层实现基础
Spring事务核心基于AOP(面向切面编程)和动态代理实现,当Bean的方法被
@Transactional注解标记时,Spring会为其生成代理对象,事务逻辑通过环绕通知织入方法的执行流程中。 - 核心执行流程
- 事务开启:代理对象调用方法前,通过
PlatformTransactionManager创建事务,获取数据库连接并设置autoCommit=false,同时将连接绑定到ThreadLocal(保证同一线程内操作使用同一连接); - 事务执行:执行目标方法的业务逻辑,若逻辑正常完成则触发事务提交;若抛出
RuntimeException或Error(默认回滚规则),则触发事务回滚; - 事务结束:提交/回滚后释放数据库连接,解除
ThreadLocal的绑定,恢复连接的原有属性。
- 事务开启:代理对象调用方法前,通过
- 关键组件
TransactionManager:事务管理器,不同数据源对应不同实现(如JDBC用DataSourceTransactionManager,ORM框架用HibernateTransactionManager);TransactionDefinition:定义事务属性(隔离级别、传播行为、超时时间等);TransactionStatus:记录事务的实时状态(是否新建、是否可回滚等)。
二、Spring事务的传播机制
事务传播机制定义了多个事务方法嵌套调用时,事务的协同规则,Spring定义了7种传播行为,核心常用的如下:
| 传播行为 | 核心逻辑 | 适用场景 |
|---|---|---|
| REQUIRED(默认) | 有事务则加入当前事务,无事务则新建事务 | 绝大多数业务操作,保证原子性 |
| REQUIRES_NEW | 无论是否有事务,都新建独立事务,原有事务暂停,新事务执行完毕后恢复原有事务 | 日志记录、第三方接口调用等独立操作 |
| SUPPORTS | 有事务则加入,无事务则以非事务方式执行 | 可选事务的查询类操作 |
| NOT_SUPPORTED | 以非事务方式执行,原有事务暂停,执行完毕后恢复原有事务 | 无需事务的耗时非核心操作 |
| NEVER | 必须在非事务环境执行,若存在事务则抛出异常 | 严格禁止事务的场景 |
| MANDATORY | 必须在事务环境执行,若无事务则抛出异常 | 强依赖事务的核心业务操作 |
| NESTED | 有事务则嵌套子事务(基于保存点),无事务则新建事务,子事务回滚不影响父事务 | 需部分回滚的复杂业务 |
三、Spring事务失效的常见场景及原因
- 代理对象失效导致的失效
- 内部方法调用:同类中非事务方法调用事务方法(如
a()调用@Transactional b()),因未走代理对象,事务逻辑无法织入; - Bean未被Spring管理:如用
new关键字创建实例,而非通过Spring容器获取,无代理对象; - 代理类型不匹配:如Bean被标记为
final(无法生成动态代理)、使用JDK动态代理时目标类未实现接口(默认用CGLIB,若强制JDK代理则失效)。
- 内部方法调用:同类中非事务方法调用事务方法(如
- 事务属性配置不当导致的失效
- 异常类型不匹配:默认仅回滚
RuntimeException和Error,若抛出CheckedException(如IOException)且未配置rollbackFor,事务不会回滚; - 手动捕获异常未抛出:方法内
try-catch异常但未重新抛出,事务管理器无法感知异常,不会触发回滚; - 传播行为配置错误:如配置
SUPPORTS/NOT_SUPPORTED,在无外层事务时以非事务执行; - 数据库不支持事务:如使用MySQL的
MyISAM存储引擎(无事务能力),事务配置无效。
- 异常类型不匹配:默认仅回滚
四、事务失效的解决方案
- 解决代理失效
- 避免内部调用:可注入自身Bean(如
@Autowired private XxxService self)通过代理对象调用事务方法,或拆分为不同类的方法; - 确保Bean被Spring管理:统一通过
@Component等注解声明Bean,从容器获取实例; - 适配代理类型:避免给事务方法所在类加
final,或指定proxyTargetClass=true强制CGLIB代理。
- 避免内部调用:可注入自身Bean(如
- 解决配置/异常问题
- 明确异常回滚规则:通过
rollbackFor = Exception.class指定回滚所有异常; - 异常需向上抛出:若必须捕获异常,需在
catch块中通过TransactionAspectSupport.currentTransactionStatus().setRollbackOnly()手动标记回滚; - 检查数据库引擎:MySQL需切换为
InnoDB,确保支持事务; - 合理配置传播行为:根据业务场景选择合适的传播机制,避免误用
SUPPORTS等非强制事务的行为。
- 明确异常回滚规则:通过
五、总结
Spring事务通过AOP动态代理实现了声明式事务管理,简化了事务操作;但需注意代理的生效条件和事务属性的正确配置,实际开发中要规避内部调用、异常捕获等失效场景,同时根据业务需求选择合适的传播机制,保证事务的原子性和一致性。
动态代理
一、核心定义与价值
动态代理是Java中在运行时动态生成代理类的设计模式,它能在不修改目标类源码的前提下,为目标对象的方法添加增强逻辑(如日志、事务、权限校验),核心价值是实现业务逻辑与横切逻辑的解耦,是AOP(面向切面编程)的底层实现基础。
二、动态代理的两种核心实现方式
Java中动态代理主要分为JDK动态代理和CGLIB动态代理:
1.JDK动态代理
- 底层原理:基于Java反射机制的
java.lang.reflect.Proxy类和InvocationHandler接口实现,要求目标类必须实现至少一个接口。 - 核心流程
- 自定义类实现
InvocationHandler接口,重写invoke()方法,在该方法中编写增强逻辑并调用目标方法; - 通过
Proxy.newProxyInstance()方法,传入类加载器、目标类接口数组、自定义InvocationHandler,动态生成代理类实例; - 调用代理对象方法时,会自动触发
InvocationHandler的invoke()方法,完成增强与目标方法的执行。
- 自定义类实现
2.CGLIB动态代理
- 底层原理:基于ASM字节码操作框架实现,无需目标类实现接口,通过动态生成目标类的子类并重写其方法来实现增强。
- 核心流程
- 自定义类实现
MethodInterceptor接口,重写intercept()方法,编写增强逻辑并通过MethodProxy调用目标方法; - 通过
Enhancer类设置父类(目标类)和回调函数(自定义MethodInterceptor),创建代理类实例; - 调用代理对象方法时,会触发
intercept()方法,实现方法增强。
- 自定义类实现
两者核心区别对照表
| 维度 | JDK动态代理 | CGLIB动态代理 |
|---|---|---|
| 依赖条件 | 目标类必须实现接口 | 目标类无需实现接口,不能是final类 |
| 底层技术 | Java反射 | ASM字节码生成 |
| 性能 | 反射调用有一定开销,JDK8后优化明显 | 字节码直接操作,底层调用性能更高 |
| 增强范围 | 仅能增强接口中定义的方法 | 可增强目标类的所有非final方法 |
三、动态代理的底层核心机制
- JDK动态代理:
Proxy.newProxyInstance()会在运行时生成一个实现目标接口的代理类字节码,该类继承自Proxy并实现目标接口,其所有方法都会调用InvocationHandler的invoke()方法,实现增强逻辑的统一织入。 - CGLIB动态代理:
Enhancer会通过ASM框架生成目标类的子类字节码,子类会重写目标类的非final方法,在重写方法中回调MethodInterceptor的intercept()方法,从而完成方法增强。
四、典型应用场景
- Spring AOP:默认优先使用JDK动态代理(目标类有接口时),无接口时自动切换为CGLIB代理,实现事务管理、日志记录、权限控制等横切逻辑;
- MyBatis:通过JDK动态代理为Mapper接口生成代理实现类,将接口方法与SQL语句绑定,完成数据库操作的代理执行;
- RPC框架(如Dubbo):利用动态代理封装远程调用的网络通信逻辑,让调用方像调用本地方法一样调用远程服务。
五、优缺点总结
- 优点
- 无侵入式增强:无需修改目标类源码,降低代码耦合;
- 灵活扩展:可在运行时动态指定增强逻辑,适配不同业务需求;
- 复用性强:横切逻辑可统一维护,提升代码复用率。
- 缺点
- JDK动态代理受限于接口,对无接口类无法增强;
- CGLIB无法增强final类和final方法;
- 反射或字节码操作存在一定性能开销(JDK8后已大幅优化,日常场景可忽略)。
六、补充进阶认知
Spring 5.x后默认对CGLIB代理做了优化,同时支持通过proxyTargetClass=true强制使用CGLIB代理;JDK 17新增的InvocationHandler增强接口进一步提升了动态代理的灵活性。
SpringBoot
一、核心定位与价值
SpringBoot是基于Spring框架的快速开发脚手架,核心定位是简化Spring应用的搭建、配置与部署,通过“约定优于配置”的设计理念,解决了传统Spring应用配置繁琐、依赖管理复杂、部署流程重的痛点,让开发者聚焦业务逻辑而非框架配置,大幅提升开发效率。
二、核心特性
- 自动配置(AutoConfiguration)
这是SpringBoot的核心特性,通过
spring-boot-autoconfigure模块,根据类路径下的依赖、环境变量、配置文件等,自动完成Bean的注册和组件初始化(如引入spring-boot-starter-web则自动配置Tomcat、SpringMVC相关Bean),无需手动编写XML或JavaConfig配置。 - 起步依赖(Starter Dependencies)
将常用功能的依赖打包为统一的starter(如
spring-boot-starter-web、spring-boot-starter-data-jpa),开发者只需引入对应starter,即可自动管理依赖版本,避免版本冲突问题。 - 内嵌服务器
内置Tomcat、Jetty、Undertow等Web服务器,无需单独部署服务器,可直接通过
java -jar命令启动应用,简化部署流程。 - Actuator监控
提供
spring-boot-starter-actuator组件,可快速监控应用健康状态、内存使用、接口调用情况等,支持自定义监控指标,方便线上运维。 - 外部化配置
支持
application.properties/yaml、环境变量、命令行参数等多种配置方式,且支持多环境配置(如application-dev.yml、application-prod.yml),配置优先级清晰,便于环境切换。
三、底层核心原理
- 自动配置的实现逻辑
- 核心注解:
@SpringBootApplication是组合注解,包含@SpringBootConfiguration(标识配置类)、@EnableAutoConfiguration(开启自动配置)、@ComponentScan(组件扫描); - @EnableAutoConfiguration:通过
AutoConfigurationImportSelector加载META-INF/spring.factories文件中配置的自动配置类(如WebMvcAutoConfiguration); - 条件注解:自动配置类通过
@ConditionalOnClass(类存在)、@ConditionalOnMissingBean(Bean未注册)、@ConditionalOnProperty(配置匹配)等条件注解,实现“按需配置”,保证配置的灵活性。
- 核心注解:
- 内嵌服务器的启动流程
SpringBoot启动时,通过
ServletWebServerFactory自动创建内嵌服务器,将SpringMVC的DispatcherServlet注册到服务器,绑定端口后启动,实现应用的独立运行。
四、工程实践与最佳实践
- 项目结构规范
遵循SpringBoot推荐的包结构(主类放在根包下,子包按功能划分
controller、service、mapper、entity等),保证组件扫描的有效性。 - 多环境配置
通过
spring.profiles.active=dev指定激活的环境,结合@Profile注解实现Bean的环境差异化注册,避免环境间配置混淆。 - 依赖管理
优先使用官方starter,自定义依赖时通过
dependencyManagement统一管控版本;避免引入冗余依赖,减少包体积和冲突风险。 - 性能与安全优化
- 替换内嵌Tomcat为Undertow提升并发性能;
- 通过
spring-boot-starter-security实现接口权限控制; - 开启配置加密(如Jasypt)保护敏感配置(数据库密码等)。
- 部署与运维
- 打包为可执行Jar包,通过
java -jar直接部署; - 结合Docker容器化部署,提升环境一致性;
- 利用Actuator+Prometheus/Grafana搭建监控体系,实时感知应用状态。
- 打包为可执行Jar包,通过
五、进阶扩展
- 自定义Starter
可根据业务需求封装自定义starter(如通用日志、权限组件),通过
spring.factories配置自动配置类,实现组件的复用和快速集成。 - 自动配置的扩展与覆盖
若默认自动配置不满足需求,可通过自定义Bean(标注
@Primary)覆盖自动配置的Bean,或通过@EnableConfigurationProperties绑定自定义配置。 - 与云原生的整合 SpringBoot可无缝对接SpringCloud(如Eureka、Nacos、Gateway),实现微服务架构的搭建;支持K8s部署,适配云原生环境。
六、总结
SpringBoot通过自动配置和起步依赖大幅降低了Spring应用的开发门槛,是微服务和快速开发的首选框架;实际使用中需结合最佳实践规范项目结构、管控配置和依赖,同时可通过自定义扩展适配复杂业务场景。
SQL执行过程
1.客户端与服务端建立连接(连接器)
- 客户端通过TCP连接MySQL服务端,连接器负责验证账号密码、分配权限,连接成功后维护“连接池”(避免频繁创建销毁连接);
- 若连接长时间无操作,会触发超时(默认8小时),连接器会主动断开连接;
- 核心作用:鉴权、管理连接,决定该连接能操作哪些库/表。
2.查询缓存(MySQL 8.0已移除,需提及)
- 连接器认证通过后,先检查查询缓存:若SQL语句(如
SELECT * FROM user WHERE id=1)和结果已缓存,直接返回结果,无需后续步骤; - 移除原因:缓存失效成本高(表数据变更会清空整个表的缓存),命中率低,性能收益远低于维护成本;
- 补充:若面试提到MySQL 5.7及以下,需说明该环节;8.0后直接跳过。
3.SQL解析与优化(分析器→优化器)
(1)分析器(词法+语法解析)
- 词法解析:把SQL拆分为最小单元(如关键字SELECT、表名user、字段id、条件=1),识别“谁是表、谁是字段、谁是条件”;
- 语法解析:校验SQL语法是否合法(如关键字拼写错误、表名不存在),若报错(如
select * from usr),会返回“1054 - 未知列/表”错误; - 核心输出:生成“语法树”(描述SQL要做什么)。
(2)优化器(执行计划生成)
- 核心目标:在多个可行的执行方案中选择“最优执行计划”(如多表关联时选哪个表作为驱动表、索引选择哪个);
- 举例:
SELECT * FROM order JOIN user ON order.user_id=user.id WHERE user.name='张三',优化器会决定:先查user表(用name索引)还是先查order表,以及用哪个索引; - 核心输出:生成“执行计划”(如使用idx_user_name索引、驱动表为user)。
4.执行器执行SQL(与存储引擎交互)
- 执行器先校验连接权限(如是否有user表的查询权限),权限通过后调用存储引擎(如InnoDB)的接口执行操作;
- 「读SQL(SELECT)」执行逻辑:
- 调用存储引擎接口,根据执行计划找数据(如通过主键索引找id=1的行);
- 若有数据则返回,无则返回空;
- 将结果返回给客户端(若开启缓存,会同时写入查询缓存,8.0后无此步骤);
- 「写SQL(INSERT/UPDATE/DELETE)」执行逻辑:
- 先开启事务(隐式/显式),调用存储引擎接口修改数据;
- 记录redo log(保证崩溃恢复)、undo log(保证回滚);
- 若开启binlog,会将修改记录写入binlog;
- 事务提交(两阶段提交:prepare→commit),最终返回执行结果。
核心优化点
主动补充“影响SQL执行效率的关键环节”,体现工程思维:
- 分析器/优化器环节:避免SQL语法错误、尽量让优化器选择最优索引(如避免使用
select *、where 1=1、函数操作索引字段); - 执行器环节:
- 读SQL:优先使用主键/覆盖索引,避免回表查询;
- 写SQL:控制事务大小,避免大事务导致redo log刷盘阻塞;
- 连接器环节:使用连接池(如Druid),避免频繁创建连接,设置合理的连接超时时间。
总结
一条SQL的执行过程,本质是MySQL服务层“解析-优化-规划”、存储引擎层“执行-返回”的协同过程;读和写SQL的核心差异在执行器环节(读侧重查数据,写侧重改数据+日志保证一致性)。理解该流程,能针对性优化SQL执行效率(如索引优化、避免缓存失效、控制事务)。
InnoDB 存储引擎
一、核心定位与核心价值
InnoDB是MySQL默认的事务型存储引擎(MySQL 5.5后取代MyISAM成为默认),主打“事务一致性、崩溃恢复、行级锁”,核心适配OLTP(在线事务处理)场景(如电商订单、金融交易、用户系统),解决MyISAM无事务、表级锁、崩溃易丢数据的痛点。
二、底层核心架构
1.内存架构(核心是缓冲池)
| 组件 | 核心作用 |
|---|---|
| 缓冲池(Buffer Pool) | 缓存磁盘中的数据页、索引页、undo页等,优先从内存读写(命中率是核心性能指标); 按LRU算法管理页的冷热,避免频繁刷盘/加载; |
| 重做日志缓冲(Log Buffer) | 缓存redo log(重做日志),默认每秒刷盘一次,事务提交时可强制刷盘(innodb_flush_log_at_trx_commit=1); |
| 自适应哈希索引(AHI) | 对热点索引页构建哈希索引,提升等值查询效率(自动创建,无需手动配置); |
| 锁结构/事务信息 | 存储行锁、表锁的状态,以及当前活跃事务的信息(如事务ID、隔离级别); |
2.磁盘架构
| 组件 | 核心作用 |
|---|---|
| 表空间(Tablespace) | 存储表数据和索引: - 共享表空间(ibdata1):默认存储所有表数据,易碎片化; - 独立表空间(.ibd文件):每张表单独存储,便于管理/迁移; |
| 重做日志(redo log) | 物理日志(记录“数据页做了什么修改”),保证崩溃恢复(WAL预写日志机制),固定大小的循环文件; |
| 回滚日志(undo log) | 逻辑日志(记录“数据修改前的状态”),用于事务回滚、MVCC多版本控制; |
| 归档日志(binlog) | 注:binlog属于MySQL服务层,InnoDB依赖它实现主从同步、数据恢复; |
三、核心特性
1.支持ACID事务(核心)
- 基于redo log(保证持久性)、undo log(保证原子性)、锁+MVCC(保证隔离性)实现ACID;
- 支持4种事务隔离级别(默认RR可重复读),通过MVCC解决幻读问题(区别于其他引擎的锁机制)。
2.行级锁(而非表级锁)
- 锁粒度细化到行,高并发下冲突概率低(如秒杀场景修改单条订单数据,仅锁住该行,不阻塞其他订单);
- 行锁基于索引实现(无索引会退化为表锁),核心类型:共享锁(S锁,读)、排他锁(X锁,写),以及意向锁(IS/IX,减少锁检查开销)。
3.MVCC(多版本并发控制)
- 核心原理:通过undo log为每行数据维护多个版本,读操作(快照读)无需加锁,直接读取历史版本,实现“读不阻塞写、写不阻塞读”;
- 实现依赖:每行数据的隐藏列(DB_TRX_ID:最后修改事务ID、DB_ROLL_PTR:指向undo log的指针)。
4.崩溃恢复(Crash Safe)
- 基于redo log的WAL机制:事务提交时先写redo log(内存+磁盘),再修改缓冲池数据,最后异步刷盘到表空间;
- 数据库崩溃重启时,通过redo log恢复未刷盘的修改,通过undo log回滚未提交的事务,保证数据一致性。
5.聚簇索引(Clustered Index)
- 主键索引(聚簇索引)的叶子节点直接存储整行数据,二级索引的叶子节点存储主键值(查询需回表);
- 无主键时,InnoDB会自动生成6字节的隐式主键,因此建议手动设置自增主键(减少索引碎片)。
四、关键机制
1.WAL(预写日志)机制
- 核心逻辑:“先写日志,后写磁盘”,避免每次修改都刷盘(磁盘IO成本高),提升写性能;
- redo log刷盘策略:通过
innodb_flush_log_at_trx_commit控制(1=事务提交时刷盘,最安全;0=每秒刷盘;2=仅刷到操作系统缓存)。
2.缓冲池刷盘策略
- 脏页(缓冲池中修改未刷盘的页)通过后台线程异步刷盘,触发条件:
- ① 缓冲池空间不足;
- ② redo log写满;
- ③ 定时刷盘(默认1秒);
- ④ 事务提交(可选)。
3.死锁检测与处理
- InnoDB自动检测死锁(通过锁等待图),并回滚代价最小的事务;
- 可通过
show engine innodb status查看死锁日志,优化SQL减少锁竞争。
五、优化方向
- 缓冲池优化:设置
innodb_buffer_pool_size为物理内存的50%~70%(核心优化项),开启缓冲池预热(innodb_buffer_pool_dump_at_shutdown); - 表空间优化:启用独立表空间(
innodb_file_per_table=1),定期用optimize table整理碎片; - 锁优化:避免长事务、无索引更新,合理使用乐观锁(版本号)减少行锁冲突;
- 日志优化:设置合理的redo log大小(建议1~4GB),避免频繁切换日志文件;binlog选择row格式,提升主从同步一致性;
- 索引优化:创建合适的主键/二级索引,避免回表查询(用覆盖索引),控制索引数量(减少维护成本)。
六、与MyISAM的核心对比(面试常追问)
| 特性 | InnoDB | MyISAM |
|---|---|---|
| 事务支持 | 支持ACID | 不支持 |
| 锁粒度 | 行级锁(索引失效为表锁) | 表级锁 |
| 崩溃恢复 | 支持Crash Safe | 不支持,易丢数据 |
| 索引类型 | 聚簇索引 | 非聚簇索引 |
| 适用场景 | OLTP(高并发事务) | OLAP(只读统计查询) |
七、总结
InnoDB是MySQL面向高并发事务场景的核心存储引擎,通过缓冲池、redo log、MVCC、行级锁等机制,兼顾了性能与数据一致性;实际使用中需重点优化缓冲池、索引、锁机制,适配业务的并发和事务需求。
ACID 事务特性
ACID是数据库事务的四大核心特性,保证了事务在并发场景下的一致性和可靠性,InnoDB通过多机制协同实现完整的ACID特性。
1.A(Atomicity)—— 原子性
- 核心定义:事务是不可分割的最小执行单元,要么全部执行成功,要么全部失败回滚(“要么全做,要么全不做”);
- 业务案例:转账场景(A扣100元,B加100元),若A扣钱后B加钱失败,需回滚A的扣钱操作,保证原子性;
- InnoDB底层实现:依赖undo log(回滚日志)
- 事务执行时,InnoDB会记录每一步修改的“反向操作”到undo log(如INSERT对应DELETE、UPDATE对应反向UPDATE);
- 事务失败时,通过undo log回滚到事务执行前的状态;事务成功时,undo log不会立即删除,用于MVCC多版本读取。
2.C(Consistency)—— 一致性
- 核心定义:事务执行前后,数据库的“业务规则约束”保持不变(如转账前后A+B的总金额不变、库存不能为负);
- 业务案例:抢购活动中,用户成功下单后,库存数=原库存-1,且库存不能小于0,无论事务成功/失败,库存规则始终成立;
- InnoDB底层保障:一致性是ACID的最终目标,由A/I/D三大特性共同保障:
- 原子性保证事务要么全成要么全败,避免部分修改导致规则破坏;
- 隔离性避免并发事务互相干扰导致规则失效;
- 持久性保证修改落地,不会因崩溃丢失导致规则异常;
- 额外补充:业务层需通过约束(如字段非空、外键、触发器)或代码校验(如库存预检查)强化一致性。
3.I(Isolation)—— 隔离性
- 核心定义:多个并发事务之间相互隔离,一个事务的执行不会被其他事务干扰;
- 核心背景:无隔离性会引发脏读、不可重复读、幻读三大问题(面试必提);
- 脏读:一个事务读取了另一个未提交事务修改的数据
- 不可重复读:同一事务中,多次读取同一数据,结果不一致(因其他事务提交了修改)
- 幻读:同一事务中,多次按相同条件查询,结果行数不一致(因其他事务提交了新增或删除)
- InnoDB底层实现:
- 基础:锁机制(行级锁/表级锁、共享锁S/排他锁X),避免并发写操作冲突;
- 核心:MVCC(多版本并发控制),通过undo log维护数据的多个版本,实现“快照读”(读不加锁),解决读-写冲突;
-
隔离级别:InnoDB支持4种隔离级别(SQL标准),默认RR(可重复读):
隔离级别 脏读 不可重复读 幻读 核心特点 读未提交(RU) ✅ ✅ ✅ 性能最高,一致性最差 读已提交(RC) ❌ ✅ ✅ 主流数据库默认(如Oracle) 可重复读(RR) ❌ ❌ ❌ InnoDB默认,通过临键锁+MVCC解决幻读 串行化(Serializable) ❌ ❌ ❌ 性能最差,完全串行执行
4.D(Durability)—— 持久性
- 核心定义:事务提交后,对数据库的修改永久生效,即使数据库崩溃、服务器宕机,修改也不会丢失;
- 业务案例:用户支付成功后,订单状态改为“已支付”,即使服务器立刻宕机,重启后该状态仍保留;
- InnoDB底层实现:依赖redo log(重做日志)+ WAL(预写日志)机制
- WAL核心逻辑:“先写日志,后写磁盘”——事务提交时,先将修改记录写入redo log(内存+磁盘),再异步刷新到表空间(磁盘);
- redo log是物理日志(记录“哪个数据页做了什么修改”),固定大小循环写入;数据库崩溃重启时,通过redo log恢复未刷盘的修改,保证持久性;
- 关键参数:
innodb_flush_log_at_trx_commit(1=事务提交时强制刷盘,最安全;0=每秒刷盘;2=刷到操作系统缓存),生产环境建议设为1。
三、ACID特性的关联与核心逻辑(加分项)
- 原子性+持久性:保证事务“执行结果不丢、不偏”;
- 隔离性:保证并发场景下原子性和一致性不被破坏;
- 一致性:是最终目标,原子性、隔离性、持久性都是为了达成一致性的手段。
四、异常场景的ACID保障(体现实战)
- 数据库崩溃:通过redo log恢复已提交事务的修改,通过undo log回滚未提交事务,保证原子性+持久性;
- 并发事务冲突:通过锁机制避免写冲突,通过MVCC避免读-写冲突,保证隔离性+一致性;
- 网络中断:事务未提交则自动回滚(原子性),已提交则redo log保证修改不丢(持久性)。
五、总结
ACID是数据库事务的核心保障,其中原子性依赖undo log,持久性依赖redo log+WAL,隔离性依赖锁+MVCC,而一致性是最终目标,由前三者共同支撑。InnoDB作为MySQL的事务型引擎,通过这些底层机制,在高并发场景下兼顾了ACID特性和性能,是OLTP场景的最优选择。
RR 隔离级别下解决幻读
在 InnoDB 的 可重复读(RR)隔离级别 下,解决幻读的核心方案是 临键锁(Next-Key Lock)机制,结合 MVCC(多版本并发控制) 共同作用,从写阻塞和读一致性两个维度彻底解决幻读问题。
一、什么是幻读
幻读是在同一个事务内,多次执行完全相同的范围查询语句,得到的结果集行数不一致。导致这种不一致的原因,是其他并发事务执行了插入新行或删除行的操作并提交,且这些行恰好落在当前事务的查询范围内。
- 例子:事务 A 执行
SELECT * FROM user WHERE age > 20,返回 2 条数据;
事务 B 插入一条age=25的数据并提交;
事务 A 再次执行相同查询,返回 3 条数据 → 出现幻读。
二、RR 级别解决幻读的核心机制
1.临键锁(Next-Key Lock):阻塞写操作,防止新行插入
这是 InnoDB 解决幻读的核心锁机制,仅在 RR 隔离级别下生效。
- 本质:行锁 + 间隙锁 的组合锁,锁定的是当前索引记录 + 下一个索引记录之间的间隙。
- 作用:不仅锁定查询命中的行,还会锁定行之间的间隙,阻止其他事务在间隙中插入新行,从根源上杜绝幻读的产生。
- 实例
假设
user表的id索引记录为10,20,30,事务 A 执行:SELECT * FROM user WHERE id > 10 AND id < 30 FOR UPDATE;此时 InnoDB 会加 临键锁,锁定范围是
(10,20] + (20,30):(10,20]:行锁,锁定id=20的记录;(20,30):间隙锁,锁定20~30之间的空隙。 其他事务无法修改id=20的记录,也无法插入id在10~30之间的新行(如id=15/25),从而避免了幻读。
2.间隙锁(Gap Lock):临键锁的关键组成部分 间隙锁是临键锁的子集,专门锁定索引记录之间的空隙,不锁定具体行。
- 触发条件:仅在 RR 隔离级别下,使用范围查询(
> / < / BETWEEN / IN)且命中索引时触发; 若使用等值查询且命中唯一索引,间隙锁会退化为行锁(减少锁粒度,提高并发)。 - 注意:无索引时,间隙锁会退化为表锁,导致全表阻塞。
3.MVCC(多版本并发控制):保证读一致性 MVCC 主要用于读操作的一致性,与锁机制互补:
- 对于快照读(普通
SELECT语句),MVCC 会通过 undo log 读取事务启动时的快照版本,不会看到其他事务提交的新行; - 对于当前读(
SELECT ... FOR UPDATE/LOCK IN SHARE MODE、INSERT/UPDATE/DELETE),则会触发临键锁,阻塞其他事务的写入。
三、RR 级别 vs RC 级别:为什么 RC 解决不了幻读
- 读已提交(RC)隔离级别:InnoDB 会关闭间隙锁,仅保留行锁,因此无法阻止其他事务插入新行,会出现幻读;
- 可重复读(RR)隔离级别:通过临键锁 + MVCC 的组合,既保证了写操作的阻塞,又保证了读操作的一致性,彻底解决幻读。
四、面试高频延伸问题
- 为什么 RR 级别下等值查询不会触发间隙锁? → 若等值查询命中唯一索引(如主键),InnoDB 会优化锁粒度,将临键锁退化为行锁,避免不必要的间隙锁定,提高并发。
- 间隙锁会阻塞哪些操作? → 仅阻塞插入操作,不会阻塞其他事务对间隙外的行的读写。
- MVCC 和临键锁的关系? → MVCC 负责快照读的一致性,临键锁负责当前读的写阻塞,两者结合解决 RR 级别下的幻读。
InnoDB 有哪些锁
一、按锁粒度划分(核心维度)
- 行级锁(Row-Level Lock)
- 特点:锁定数据行,粒度最小,并发性能最高,是 InnoDB 默认锁粒度。
- 适用场景:高并发的增删改(
INSERT/UPDATE/DELETE)场景。 - 底层实现:依赖 聚簇索引,通过锁定索引记录来实现行锁;无索引时会退化为表锁。
- 细分类型:
- 共享锁(S 锁):读锁,多个事务可共享持有,允许读但不允许修改。
- 加锁方式:
SELECT ... LOCK IN SHARE MODE;
- 加锁方式:
- 排他锁(X 锁):写锁,一个事务持有后,其他事务无法加任何锁,阻塞读和写。
- 加锁方式:
SELECT ... FOR UPDATE;/ 隐式加锁(INSERT/UPDATE/DELETE)
- 加锁方式:
- 共享锁(S 锁):读锁,多个事务可共享持有,允许读但不允许修改。
- 表级锁(Table-Level Lock)
- 特点:锁定整张表,粒度最大,并发性能最低,InnoDB 仅在特定场景下使用。
- 适用场景:
- 无索引的
UPDATE/DELETE(行锁退化为表锁); ALTER TABLE等 DDL 操作(MDL 元数据锁本质是表锁)。
- 无索引的
- MDL 锁(元数据锁):重点面试考点
- 作用:保证 DDL 和 DML 操作的一致性,防止修改表结构时并发读写。
- 分类:共享 MDL 锁(读操作持有)、排他 MDL 锁(DDL 操作持有)。
- 问题:长事务持有 MDL 锁会阻塞 DDL,导致表卡死。
二、按锁类型划分(结合事务行为)
- 乐观锁
- 特点:非数据库内置锁,基于 版本控制 实现,无锁竞争,适合读多写少场景。
- 实现方式:
- 版本号字段:
UPDATE table SET ..., version = version + 1 WHERE id = ? AND version = ?; - 时间戳字段:类似版本号逻辑。
- 版本号字段:
- 面试考点:乐观锁 vs 悲观锁的区别(乐观锁无阻塞,悲观锁有阻塞;乐观锁适合高并发读,悲观锁适合高并发写)。
- 悲观锁
- 特点:数据库内置锁(行锁/表锁),认为并发冲突一定会发生,先加锁再操作。
- 对应锁:前面提到的 S 锁、X 锁、表锁都属于悲观锁。
三、意向锁(Intention Lock)
面试核心考点:InnoDB 为了协调行锁和表锁的关系而引入的 表级锁,分为两种:
- 意向共享锁(IS 锁)
- 作用:事务在给某行加 S 锁前,必须先给表加 IS 锁。
- 特点:IS 锁之间可以共享,不阻塞其他 IS 锁。
- 意向排他锁(IX 锁)
- 作用:事务在给某行加 X 锁前,必须先给表加 IX 锁。
-
特点:IX 锁与其他锁的兼容性规则:
锁类型 IS 锁 IX 锁 S 锁 X 锁 IS 锁 兼容 兼容 兼容 冲突 IX 锁 兼容 兼容 冲突 冲突
核心目的:避免表锁检查时逐行遍历,提高锁检查效率。例如:
- 当一个事务想给表加 X 锁时,只需检查表是否有 IX/IS 锁,无需检查每一行的行锁。
四、特殊锁机制
- 间隙锁(Gap Lock)
- 作用:锁定索引记录之间的间隙,防止 幻读(解决 RR 隔离级别下的幻读问题)。
- 触发条件:RR 隔离级别下,使用范围查询(如
> / < / BETWEEN)时触发。注意:若使用等值查询且命中唯一索引,间隙锁会退化为行锁(减少锁粒度,提高并发)。无索引时,间隙锁会退化为表锁,导致全表阻塞。 - 例子:
SELECT * FROM user WHERE id > 10 FOR UPDATE;会锁定id > 10的间隙,防止插入id=11的新行。
- 临键锁(Next-Key Lock)
- 作用:行锁 + 间隙锁 的组合,是 InnoDB RR 隔离级别下默认的锁算法。
- 锁定范围:当前索引记录 + 下一个索引记录之间的间隙。
- 例子:索引记录为
10,20,30,查询id <= 20时,临键锁会锁定(10,20] + (20,30)间隙。
- 插入意向锁(Insert Intention Lock)
- 作用:多个事务同时插入同一间隙时,允许并发插入,提高性能。
- 特点:插入意向锁之间互不阻塞,仅与间隙锁/临键锁冲突。
MySQL索引
- 主键索引(B+Tree)InnoDB 的聚簇索引,叶子节点存储行数据。
- 普通索引(B+Tree)非聚簇索引,叶子节点存储主键值(需要回表查询)。
- 唯一索引(B+Tree)结构与普通 B+Tree 索引相同,但强制值唯一。
- 组合索引(B+Tree)多个字段的 B+Tree 联合索引,遵循最左前缀原则。
- 前缀索引(B+Tree)仅对字段的前缀部分构建 B+Tree 索引。
- 全文索引(倒排索引)通过分词和关键词映射实现全文搜索。
- 空间索引(R-Tree)用于地理空间数据的多维索引。
B+Tree优点:
- 适合磁盘存储:B+Tree 的非叶子节点不存储数据,单节点可存储更多键值,树高更低,减少磁盘 I/O 次数。(B-Tree所有节点存储数据,范围查询时需要回溯到上层节点)
- 范围查询高效:叶子节点的有序链表结构,适合范围查询和排序操作。
- 数据一致性:InnoDB 的聚簇索引(B+Tree)将数据与索引绑定,保证事务和数据一致性。
MySQL索引失效:
- 左模糊查询(
LIKE '%xx')。 - 函数操作。
- 类型隐式转换。(当查询条件中的数据类型与索引字段类型不一致时,数据库会进行隐式类型转换,导致索引失效。)
- 联合索引最左匹配失效。
- 使用
OR条件。(如果OR条件的某一侧没有索引,整个查询可能无法使用索引。) NOT或!=操作。- 数据分布不均匀。(如果索引字段的值大部分是相同的,可能退化为全表扫描)
分库分表
分表:解决单表数据过大问题。适合场景是数据量大但并发不高(如日志表),但跨表查询复杂。
分库:解决数据库连接数/IO瓶颈。适合场景是高并发但单表数据量不大(如用户鉴权系统),但事务一致性难保证。
分库+分表:同时解决数据量+高并发。适用场景是海量数据+高TPS场景(如电商订单、支付流水),但架构复杂,运维成本高。
如果数据增长可预估(如年增百万),优先通过索引优化/读写分离/冷热分离解决,避免过度设计。
CAP理论
- CAP定义:
- Consistency(一致性):所有节点访问同一份最新数据。
- Availability(可用性):每次请求都能获取非错误响应(不保证数据最新)。
- Partition Tolerance(分区容忍性):网络分区时系统仍能继续运行。
- 矛盾点:网络分区(P)必须被容忍,实际中需在C和A之间权衡。
- 高频追问:
- “CP/AP系统”是否完全放弃另一特性?
- 答:并非完全放弃,而是分区发生时优先保障C或A(如Redis集群脑裂时可能牺牲A)。
- “CP/AP系统”是否完全放弃另一特性?
BASE理论
- BASE核心:
- Basically Available(基本可用):允许降级(如返回缓存旧数据)。
- Soft State(软状态):允许中间状态(如订单支付中)。
- Eventually Consistent(最终一致):通过异步同步达成一致。
- 与CAP关系:
- BASE是AP系统的实践指导,通过牺牲强一致性换取可用性。
缓存击穿/穿透/雪崩
三者都是缓存失效导致的高并发问题,最终都会让大量请求绕过缓存直接打在数据库上,引发数据库压力飙升甚至宕机,但三者的失效场景、原因完全不同。
1. 缓存穿透(Cache Penetration)
(1)核心定义
指请求查询的数据在缓存和数据库中都不存在,每次请求都会穿透缓存直达数据库,且这类请求通常是高频的(如恶意攻击、查询不存在的用户ID)。
- 典型场景:黑客用大量不存在的
user_id发起查询,缓存中无数据,每次都查数据库,数据库反复查询空结果,最终被压垮。
(2)解决方案(从易到难,多层防护)
- 方案1:缓存空值/默认值
查询数据库无结果时,将空值或默认值存入缓存,并设置短期过期时间(如1分钟),避免缓存内存浪费。
✅ 注意:空值过期时间不能太长,否则新增数据后会出现缓存不一致。 - 方案2:布隆过滤器(Bloom Filter)
对数据库中所有存在的主键(如用户ID、商品ID)构建布隆过滤器,请求先经过过滤器校验:- 若过滤器判定不存在,直接返回,无需查缓存和数据库;
- 若判定可能存在,再走缓存→数据库流程。
✅ 适用场景:数据量大、查询不存在数据的请求极多的场景(如电商商品ID查询)。
- 方案3:业务层参数校验
提前拦截非法请求(如用户ID为负数、超出合理范围),从源头减少无效查询。
2. 缓存击穿(Cache Breakdown)
(1)核心定义
指某一个热点Key突然失效(过期/被删除),此时大量并发请求同时访问这个Key,瞬间击穿缓存直达数据库,导致数据库压力骤增。
- 典型场景:秒杀活动的商品Key、热点新闻的详情Key,过期瞬间有上万请求打向数据库。
- 和缓存穿透的区别:击穿的Key在数据库中存在,只是缓存刚好失效;穿透的Key缓存和数据库都不存在。
(2)解决方案(核心是“避免热点Key失效后并发查库”)
- 方案1:热点Key永不过期
对绝对热点的Key(如秒杀商品),设置为永不过期,由业务层主动更新缓存(如商品售罄后手动删除缓存)。 - 方案2:互斥锁(分布式锁)
当缓存失效时,不是所有请求都去查数据库,而是只有一个请求获取分布式锁,去数据库查询并更新缓存,其他请求等待锁释放后直接查缓存。 - 方案3:热点数据提前预热
活动开始前,手动将热点Key加载到缓存中,并设置合理的过期时间(避开活动高峰)。
3. 缓存雪崩(Cache Avalanche)
(1)核心定义
指大量缓存Key在同一时间段内集中失效(如过期时间设置相同),导致海量请求瞬间打向数据库,引发数据库雪崩式宕机。
- 典型场景:缓存服务重启、所有Key的过期时间都设为1小时且同一时间上线,1小时后所有Key集体过期。
- 和击穿的区别:雪崩是大量Key集体失效,击穿是单个热点Key失效。
(2)解决方案(核心是“避免缓存Key集中过期”)
- 方案1:过期时间添加随机值
给每个Key的过期时间加上一个随机值(如3600 ± 600秒),打散Key的过期时间,避免集中失效。 - 方案2:缓存集群高可用
部署Redis主从+哨兵或Redis Cluster集群,避免缓存服务单点故障导致的全盘失效;同时开启持久化(RDB+AOF),防止缓存重启后数据丢失。 - 方案3:多级缓存架构
引入本地缓存(如Caffeine)+ 分布式缓存(如Redis) 双层缓存:- 分布式缓存失效时,请求先查本地缓存,避免直接打数据库;
- 本地缓存设置较短的过期时间,且不与分布式缓存过期时间同步。
- 方案4:熔断降级兜底
结合Sentinel/Hystrix等组件,当数据库压力达到阈值时,触发熔断机制,返回降级结果(如“服务繁忙,请稍后重试”),保护数据库。
Redis
Redis 是一款 开源的、高性能的键值对(Key-Value)内存数据库,同时支持持久化、集群、多数据结构,核心定位是 缓存中间件,也可用于分布式锁、消息队列、限流等场景。
- 核心优势:基于内存操作,读写性能极高(单机QPS可达10万+);支持丰富的数据结构,适配多种业务场景;支持高可用集群部署,保证服务稳定性。
- 解决的核心问题:缓解数据库压力,提升系统响应速度;实现分布式场景下的锁、限流等功能。
二、核心特性(面试必答)
1. 高性能
- 基于内存存储,避免磁盘IO的性能瓶颈;
- 采用单线程模型(核心读写线程单线程),避免多线程上下文切换开销;(单线程指“核心命令执行线程”单线程,持久化、集群同步等操作是多线程的)
- 采用IO多路复用模型(epoll/kqueue),高效处理大量并发连接。
2. 丰富的数据结构
支持 String、Hash、List、Set、Sorted Set(ZSet)、Bitmap、HyperLogLog、Geo 等多种数据结构,满足不同业务需求(如 Hash 存用户信息、ZSet 做排行榜、Bitmap 做签到统计)。
3. 持久化机制
解决内存数据易丢失的问题,提供两种持久化方式:
- RDB(Redis Database):定时将内存中的数据快照写入磁盘,适合备份、灾难恢复;优点是恢复速度快,缺点是可能丢失最近一次快照后的修改。
- AOF(Append Only File):记录所有写命令到日志文件,重启时重放命令恢复数据;支持三种刷盘策略(
always/everysec/no),优点是数据安全性高,缺点是日志文件体积大,恢复速度慢。
生产环境建议 RDB+AOF 混合使用,兼顾性能和数据安全性。
4. 高可用与分布式
- 主从复制:实现数据备份和读写分离,主节点写、从节点读,提升读性能。
- 哨兵(Sentinel):监控主从节点状态,主节点宕机时自动触发故障转移,提升可用性。
- 集群(Cluster):采用槽位(Slot) 分片机制,将 16384 个槽位分配到不同节点,实现数据分片和水平扩展,支持高并发、大数据量场景。
| 架构方案 | 核心逻辑 | 适用场景 |
|---|---|---|
| 主从复制 | 1主多从,主写从读,从节点复制主节点数据 | 读多写少、需要提升读性能的场景 |
| 哨兵模式 | 基于主从复制,哨兵节点监控主从状态,自动故障转移 | 需要高可用、自动容灾的中小规模场景 |
| 集群模式 | 16384 个槽位分片,多主多从,支持水平扩展 | 高并发、大数据量的大规模分布式场景 |
云厂商 Redis 集群的VIP 仅用于普通业务请求的自动路由,而分布式锁(尤其是高可用的 Redlock 方案)不能依赖 VIP,需要直连所有主节点。
5. 原子性操作与事务
- 单命令天然原子性(如
incr、hset); - 支持简单事务(
multi/exec),但不支持回滚(仅能保证命令队列的原子性,某条命令失败不影响后续命令执行); - 支持 Lua 脚本,实现复杂原子性操作(如分布式锁的解锁逻辑)。
三、底层核心数据结构
Redis 对外暴露的是 String/Hash 等对象类型,底层通过 简单动态字符串(SDS)、双端链表、压缩列表、哈希表、跳表 等数据结构实现,不同对象类型会根据数据量自动选择最优底层结构:
| 对外对象类型 | 底层常用数据结构 | 适用场景 |
|---|---|---|
| String | 简单动态字符串(SDS) | 缓存用户信息、计数器、分布式锁 |
| Hash | 压缩列表(ziplist)→ 哈希表(hashtable) | 存储对象属性(如用户昵称、年龄) |
| List | 压缩列表 → 双端链表 | 消息队列、最新列表 |
| Set | 整数集合(intset)→ 哈希表 | 去重、交集/并集计算(如共同好友) |
| Sorted Set | 压缩列表 → 跳表 | 排行榜、延迟队列(如按分数排序) |
加分点:说明编码转换触发条件(如 Hash 元素超过
hash-max-ziplist-entries则转为哈希表),体现对 Redis 配置的理解。
跳表:跳表其实就是给有序链表加了多层索引,将有序链表的查找 / 插入 / 删除时间复杂度从 O (n) 优化到 O (logn)
压缩链表:是 Redis 专门为省内存设计的一种紧凑数据结构,本质就是一块连续的内存,把多个节点的信息都挤在里面,没有普通链表那种指针的开销。
四、典型应用场景(结合业务,体现实战)
- 缓存
- 核心场景:缓存热点数据(如商品详情、用户信息),减轻数据库压力;
- 关键策略:设置合理的过期时间、避免缓存穿透/击穿/雪崩、保证缓存与数据库一致性(如更新数据库后删除缓存)。
- 分布式锁
- 实现方案:基于
SET NX EX命令,配合 Lua 脚本原子解锁; - 进阶:使用 Redisson 框架,支持看门狗自动续约、可重入锁、公平锁等特性。
- 实现方案:基于
- 计数器/限流器
- 计数器:利用
incr/decr原子命令,实现文章阅读量、点赞数统计; - 限流器:基于
incr+ 过期时间,实现接口限流(如incr key 1 EX 60,限制每分钟请求次数)。
- 计数器:利用
- 消息队列
- 基于 List 的
lpush/brpop实现简单消息队列; - 基于 Pub/Sub 实现发布订阅(如实时通知);
- 基于 Stream 实现持久化消息队列(支持消费组、消息回溯)。
- 基于 List 的
- 排行榜
- 基于 Sorted Set 的
zadd/zrange命令,实现按分数排序的排行榜(如电商销量榜、游戏战力榜)分数高位 + 时间戳逆序值低位。
- 基于 Sorted Set 的
五、性能优化
- 内存优化
- 选择合适的数据结构(如小 Hash 用压缩列表,避免哈希表开销);
- 开启内存淘汰策略(
maxmemory-policy),如volatile-lru(淘汰过期键中最近最少使用的),避免内存溢出; - 避免大 Key(如大 Hash、大 List),大 Key 会导致内存碎片、删除阻塞,可拆分大 Key 为多个小 Key。
- 命令优化
- 避免使用
keys *(遍历所有 Key,阻塞单线程),改用scan命令分批遍历; - 批量操作使用
pipeline(减少网络往返次数),而非循环执行单条命令; - 避免使用耗时命令(如
hgetall、smembers),改用hscan、sscan分批获取。
- 避免使用
- 网络优化
- 使用长连接替代短连接,减少 TCP 握手开销;
- 开启 TCP 延迟确认(
tcp-delayed-ack),提升网络吞吐量; - 部署 Redis 集群时,尽量让客户端与 Redis 节点在同一机房,减少网络延迟。
- 持久化优化
- RDB 适合做备份,AOF 适合保证数据安全性,生产环境建议混合持久化(
aof-use-rdb-preamble yes); - AOF 重写时开启
no-appendfsync-on-rewrite yes,避免刷盘阻塞命令执行。
- RDB 适合做备份,AOF 适合保证数据安全性,生产环境建议混合持久化(
Hash Tag
补充:集群模式下,单个 Key 只能落在一个槽位,因此批量操作(如 mget)的 Key 需在同一槽位(可通过 Hash Tag 实现)。
Hash Tag 是 Redis 集群提供的 “强制槽位一致” 机制,核心规则是:Redis 计算槽位时,仅使用 Key 中{}包裹的部分作为哈希依据(若 Key 无{},则用整个 Key 计算)。通过这一规则,可让不同 Key 的哈希槽位强制相同。例如:{user}:1001、{user}:1002
分布式锁
分布式锁是分布式系统中用于保证多个节点(进程)对共享资源互斥访问的机制,类比单机锁(如synchronized、Lock),但作用范围从单个 JVM 扩展到多节点集群。
Redis(AP):
(1)核心实现思路(基于SET NX EX命令)
// 加锁:key=锁名,value=唯一标识(如UUID+线程ID),NX=不存在则设置,EX=自动过期时间
SET lock_key unique_value NX EX 30
- 加锁:利用
NX保证互斥性(只有一个节点能设置成功),EX设置自动过期时间(避免节点宕机导致锁无法释放);unique_value用于解锁时校验(防止误删其他节点的锁)。 - 解锁:必须用Lua脚本保证原子性(避免“判断+删除”两步操作的竞态):
if redis.call('get', KEYS[1]) == ARGV[1] then return redis.call('del', KEYS[1]) else return 0 end - 续约:若业务执行时间超过锁过期时间,需开启“看门狗”线程(定时延长锁过期时间),否则发生“锁丢失”。
(2)进阶优化:Redis集群版(Redlock算法)
针对单Redis节点的单点故障问题,Redlock算法的核心逻辑:
- 部署多个独立的Redis节点(无主从);
- 客户端依次向所有节点请求加锁,只有超过半数节点加锁成功,且总耗时小于锁过期时间,才算加锁成功;
- 解锁时需向所有节点发送解锁请求。
开源框架:Redisson(支持单机、主从、Redlock,内置看门狗、可重入锁、公平锁等特性)
(3)优缺点
- 优点:性能高(基于内存操作)、支持自动过期、易于扩展;
- 缺点:主从架构下可能出现“锁丢失”(主节点宕机,未同步锁到从节点);Redlock实现复杂,性能略有损耗。
ZooKeeper(CP):
(1)核心实现思路(基于临时有序节点)
- 加锁:客户端在Zookeeper的指定节点下,创建临时有序子节点;判断自己是否是序号最小的子节点,若是则加锁成功;若不是则监听前一个序号的节点。
- 解锁:客户端断开连接(正常解锁/节点宕机),临时节点自动删除,后续节点监听到事件后,重新判断自己是否为最小节点。
(2)优缺点
- 优点:天生支持公平锁、无锁丢失问题(临时节点自动释放)、高可用;
- 缺点:性能低于Redis(基于磁盘的ZAB协议)、创建节点成本高,适合并发量不高的场景。
数据库实现分布式锁:
(1)核心实现思路(两种方式)
- 基于唯一索引:创建锁表,字段为
lock_key(唯一索引),加锁时插入一条记录(唯一索引保证互斥),解锁时删除记录;通过SELECT ... FOR UPDATE实现悲观锁。 - 基于乐观锁:给锁表加
version字段,加锁时通过UPDATE ... SET version=version+1 WHERE lock_key=? AND version=?,更新成功则加锁成功。
(2)优缺点
- 优点:实现简单,无需额外中间件;
- 缺点:性能差(数据库IO瓶颈)、易产生死锁(基于悲观锁)、锁没有自动过期(需定时任务清理),适合并发量极低的场景。
一致性哈希
面试官您好,一致性哈希主要是解决分布式系统中,节点扩容或缩容时,数据大规模迁移的问题。
我先说说传统哈希的痛点:比如我们有N个缓存节点,用数据key的哈希值 % N来分配数据。这时候如果新增一个节点,N变成N+1,所有数据的映射关系都会变,几乎所有数据都要迁移,这会给系统带来巨大压力,甚至宕机。
而一致性哈希就把这个问题解决了,它的核心逻辑就三步:
- 构建哈希环:先把哈希值的范围映射成一个环形空间,比如0到2³²-1,首尾相连形成一个环。
- 节点映射到环上:把每个服务器节点的唯一标识(比如IP+端口)计算哈希值,然后放到这个环上的对应位置。
- 数据映射到节点:同样计算数据key的哈希值,也放到环上,然后从这个key的位置顺时针找,遇到的第一个节点,就是这个key要存储的节点。
然后重点说它的核心优势:
- 节点扩容/缩容时,数据迁移量极小:比如新增一个节点,只会影响这个节点到环上前一个节点之间的数据,其他数据都不用动;删除节点也是同理,只会迁移这个节点上的数据到下一个节点。
- 容错性好:某个节点宕机,它的数据只会被迁移到下一个节点,不会影响整个系统的映射关系。
当然也有个小问题:如果节点太少,容易出现数据分布不均的情况——比如几个节点都集中在环的某一段,导致某几个节点负载特别高。解决办法就是虚拟节点:给每个物理节点分配多个虚拟节点,分散到环的不同位置,这样数据就能均匀分布了。
最后说下应用场景:像分布式缓存(比如Redis Cluster早期方案、Memcached集群)、分布式存储,基本都会用到一致性哈希,就是为了应对节点动态扩缩容的场景,减少数据迁移成本。
Redis 实现消息队列
1. 基础方案:List 实现简单队列(生产 / 消费模型)
利用 List 的 lpush(生产)+ brpop/rpop(消费)特性,实现先进先出(FIFO)的消息队列,是 Redis 最经典的队列实现方式。
优缺点:
✅ 优点:实现简单、天然 FIFO、支持阻塞消费、性能高(百万级 QPS);
❌ 缺点:无消息确认(ACK)机制(消费失败易丢消息)、无重复消费防护、不支持广播 / 多消费者组、无法做延时队列。
2. 进阶方案:Pub/Sub 实现发布订阅(广播模型)
Redis 的 Pub/Sub(发布 / 订阅)特性,支持 1 个生产者向多个消费者广播消息,适用于 “一对多” 的通知场景(如实时通知、日志分发)。
优缺点:
✅ 优点:天然广播能力、轻量级、实时性高;
❌ 缺点:无持久化(Redis 重启 / 消费者离线则消息丢失)、无 ACK、无消息堆积(消费者未在线则消息直接丢弃)、不支持消息回溯。
3. 企业级方案:Stream 实现可靠队列(Redis 5.0+)
Redis 5.0 推出的 Stream 是专为消息队列设计的数据结构,整合了 List 的持久化、Pub/Sub 的广播、以及 Kafka 风格的消费者组(Consumer Group)特性,是 Redis 最完善的队列实现。
核心特性:
- 消息持久化:所有消息落地存储,Redis 重启不丢失;
- 消费者组:支持多个消费组并行消费,每个消费组独立维护消费偏移量(避免重复消费);
- ACK 机制:消息需显式确认(XACK),未确认的消息可重新消费;
- 阻塞读取:XREADGROUP 支持阻塞读取,且可指定起始偏移量(支持消息回溯);
- 消息 ID:每条消息有唯一 ID(格式 时间戳-序列号),可精准定位。
优缺点:
✅ 优点:支持持久化、ACK、消费者组、消息回溯、阻塞消费,接近专业 MQ 能力;
❌ 缺点:语法稍复杂、高并发下性能略低于 List(但满足大部分业务场景)。
4. 特殊场景:Sorted Set 实现延时队列
利用 Sorted Set 的 score 存储 “消息执行时间戳”,实现延时 / 定时队列(如订单超时关闭、定时任务)。
IO多路复用
面试官您好,我这么理解IO多路复用:
首先,它解决的是单线程高效处理大量并发IO连接的问题。传统的阻塞IO,一个线程只能处理一个连接,没数据就一直等着,高并发下要开大量线程,切换开销特别大;而IO多路复用,能让一个线程同时监听很多个IO连接,不用瞎等,提升并发效率。
然后,它的核心逻辑很简单:
- 程序会把需要监听的所有IO连接对应的文件描述符,注册到内核的一个多路复用对象上;
- 接着程序调用阻塞等待函数,线程就歇着了,不占用CPU;
- 只要有任意一个连接的数据就绪——比如有数据可读、或者可以写入数据,内核就会标记这个连接,然后唤醒程序线程;
- 线程醒了之后,只需要处理这些就绪的连接,处理完再回去阻塞等待,循环往复。
这里要强调,它属于同步IO,因为内核只通知程序“数据就绪了”,还是得程序自己去读写数据,不是内核帮你把数据拿到用户空间。
然后说三种常见实现的区别,重点是epoll:
- select:有文件描述符数量上限,默认1024;每次调用都要把所有监听的描述符传给内核,内核还得挨个遍历判断是否就绪,连接多了之后性能下降特别快。
- poll:解决了select的数量上限问题,但还是要把所有描述符传给内核,内核同样要全量遍历,高并发下效率还是不行。
- epoll:这是Linux下最优的实现,没有描述符数量上限;它在内核里维护红黑树管理注册的描述符,还有一个就绪链表;哪个连接数据就绪,内核会通过回调机制把它加入就绪链表,程序线程唤醒后直接取就绪的描述符处理,不用全量遍历,所以连接再多性能也不会明显下降。
另外epoll有两种触发模式:水平触发是只要连接处于就绪状态,就一直通知;边缘触发是只有就绪状态发生变化时,才通知一次,后者性能更高,但编程要求更严格,得配合非阻塞IO用。
最后,它的实际应用特别广,像Redis的单线程高并发、Nginx的百万级连接支撑,还有Java NIO的Selector,底层都是靠IO多路复用,尤其是epoll来实现的。
Java 非阻塞IO
面试官您好,我这么理解Java非阻塞IO,其实它就是Java NIO的核心特性,关键就在于“不阻塞”这三个字:
首先,先对比传统的Java BIO(阻塞IO):BIO里,线程调用read()或者write()的时候,如果数据没就绪——比如读数据时内核缓冲区是空的,写数据时内核缓冲区满了——线程就会一直卡着,直到数据就绪或者超时,这段时间线程啥也干不了,只能等。
而Java非阻塞IO就不一样了:线程调用读写方法的时候,不管数据有没有就绪,都会立刻返回结果。比如调用read(),如果内核缓冲区有数据,就直接读取并返回读取到的字节数;如果没数据,不会阻塞,而是直接返回0,线程可以去干别的事,不用在这死等。
然后,Java非阻塞IO不是单独用的,得配合Selector(选择器) 、Buffer(缓冲区)、Channel(通道)一起工作,也就是IO多路复用的机制,这才是它高效的关键。
具体工作流程就是:
- 我们先把Socket通道(
SocketChannel)设置为非阻塞模式; - 把这个通道注册到Selector上,指定要监听的事件——比如“读就绪”“写就绪”;
- 线程调用Selector的阻塞方法,比如
select(),这时候线程会阻塞,但不是阻塞在某个通道的读写上,而是阻塞在Selector上,等着监听的事件发生; - 一旦有通道的数据就绪了,Selector就会唤醒线程,线程再去遍历这些就绪的通道,调用读写方法处理数据——因为数据已经就绪了,所以这次读写肯定能成功,不会阻塞;
- 处理完之后,线程又回到Selector继续等待,循环往复。
这里要强调两个点:
第一,非阻塞是针对通道的,不是针对Selector的。Selector的select()方法还是会阻塞,但它能同时监听多个通道,比BIO一个线程守一个通道高效太多;
第二,Java NIO的非阻塞,是基于操作系统的非阻塞IO实现的,Linux下底层还是依赖epoll这些多路复用机制。
最后说下它的应用场景:像Netty这种高性能的网络框架,底层就是基于Java NIO的非阻塞+Selector机制,能单线程处理成百上千的连接,特别适合高并发的场景,比如分布式服务、网关这些。
MQ消息队列
面试官您好,我理解的MQ消息队列,本质就是个“消息中转站”——简单说就是,一个系统(生产者)把要传递的信息打包成“消息”扔给MQ,另一个系统(消费者)从MQ里拿消息来处理,这俩系统不用直接对接。
它最核心的作用就是解决分布式系统里的几个痛点问题:
- 解耦:比如电商系统里,用户下单后要扣库存、发优惠券、通知物流。要是不用MQ,下单系统就得直接调用这三个系统的接口,哪个系统改了接口,下单系统都得跟着改。用了MQ的话,下单系统只需要把“下单成功”的消息发给MQ就行,库存、优惠券这些系统自己去MQ拿消息处理,互相不影响。
- 削峰填谷:这个在秒杀场景里最常用。比如秒杀活动瞬时来了10万请求,直接打给数据库肯定扛不住。这时候可以先把请求都扔进MQ,然后让后台系统按照自己能承受的速度慢慢从MQ里取请求处理,避免数据库被冲垮。
- 异步通信:还是下单的例子,要是不用MQ,用户下单后得等扣库存、发优惠券这些操作全做完才能收到“下单成功”的反馈,体验很差。用了MQ,下单系统发完消息就直接告诉用户“下单中”,后面的操作异步处理,用户不用等,体验好很多。
然后MQ还有几个核心特点得提一下:
- 消息可靠性:得保证消息别丢了,比如生产者发完消息,MQ要确认收到;消费者拿到消息处理完了,要告诉MQ“我处理完了”,MQ再把消息删掉,不然消费者挂了,消息就丢了。
- 消息有序性:有些场景下消息得按顺序来,比如用户下单、付款、发货的消息,得按这个顺序处理,不能乱。
- 集群高可用:MQ本身得是集群部署,不然一个MQ节点宕机了,整个系统就歇菜了,所以得保证MQ自己不宕机。
常见的MQ产品也可以提一嘴:比如RabbitMQ,轻量级,功能全,适合中小型系统;Kafka,吞吐量极高,适合日志收集、大数据这种海量消息的场景;RocketMQ,阿里开源的,可靠性高,适合电商这种核心业务场景。
最后还要说下使用MQ的注意点:
- 引入MQ会增加系统复杂度,比如要考虑消息重复消费的问题——消费者可能因为网络问题,重复拿到同一条消息,所以业务逻辑得做幂等性处理,就是重复处理也不会出错。
- 还要考虑消息积压的问题,如果消费者处理速度跟不上生产者发消息的速度,MQ里的消息会越积越多,最后撑爆MQ,所以得监控消息积压情况,及时扩容消费者。
总结一下,MQ就是通过“异步+解耦”的方式,帮分布式系统扛住高并发、提升稳定性的中间件。
RocketMQ
面试官您好,我了解的RocketMQ,是阿里开源的一款分布式消息中间件,后来捐给了Apache基金会,它的设计初衷就是面向高并发、高可靠的电商核心业务场景,像阿里的双十一就用过它来支撑海量消息流转。
首先说它的核心架构,是典型的分布式架构,主要分这么几个角色:
- NameServer:相当于“注册中心”,是个轻量级的服务,主要存着Broker的地址信息,还有Topic和Broker的映射关系。Producer和Consumer启动时,会去NameServer上注册和获取Broker地址,NameServer是无状态的,部署多个就能实现高可用。
- Broker:核心角色,负责消息的存储、转发和持久化。Broker分主从节点,主节点负责写消息,从节点同步主节点的数据,主节点挂了从节点能顶上,保证数据不丢。
- Producer:就是发消息的一端,比如电商下单系统,下单成功后就会作为Producer给RocketMQ发“订单创建”的消息。它支持集群部署,还能通过消息分组避免重复发送。
- Consumer:就是收消息处理的一端,比如库存系统、物流系统,作为Consumer从Broker拉取消息来处理。它也支持集群,还有消费组的概念,同一个消费组里的多个消费者,会分摊消费队列的消息,实现负载均衡。
然后是RocketMQ的核心特点和关键能力,这也是它的优势所在:
- 高吞吐量、高可靠:它的底层存储用了类似磁盘顺序写的机制,写消息的速度特别快,支撑几十万级别的TPS没问题;而且支持同步刷盘、异步刷盘两种持久化方式,同步刷盘能保证消息写入磁盘才返回成功,可靠性极高,适合金融、电商这种核心业务。
- 消息有序性:这个很关键,比如用户下单、付款、发货的消息,必须按顺序处理。RocketMQ里一个Topic可以分成多个消息队列,Producer能指定把消息发到同一个队列里,而同一个队列的消息,Consumer会按顺序消费,这样就保证了局部有序,满足大部分业务的有序需求。
- 丰富的消息类型:除了普通消息,还支持延时消息(比如订单超时未支付自动取消,就可以发个延时消息)、事务消息(解决分布式事务问题,比如下单扣库存,保证本地事务和发消息要么都成功要么都失败)、批量消息(大量小消息可以批量发送,减少网络开销)。
- 重试和死信机制:Consumer处理消息失败的话,会自动重试;如果重试多次还是失败,消息就会被扔进死信队列,后面可以人工排查原因再处理,不会丢消息。
- 事务消息:“半消息机制、事务确认、回查补偿”。半消息是发送到 Broker 但暂不向消费者投递的消息,Broker 会标记其为 “待确认” 状态,只有生产者确认事务成功后,才会转为 “可投递”;若确认失败,则删除半消息。
再说说适用场景和优缺点:
- 适用场景:最适合电商、金融这种核心业务,比如订单处理、库存同步、分布式事务;也适合日志收集、大数据传输这种需要高吞吐量的场景。
- 优点:性能强、可靠性高、功能丰富,尤其是事务消息和延时消息,对业务支持特别友好;而且部署和运维也比较简单,适合企业级使用。
- 缺点:相比RabbitMQ,它的生态和社区插件稍微少一点;轻量级程度不如RabbitMQ,对资源的消耗相对高一些。
最后总结下,RocketMQ就是一款为高并发、高可靠业务而生的企业级消息中间件,在国内互联网公司里用得特别多,尤其是阿里系和一些电商平台。
分布式事务
面试官您好,我理解的分布式事务,简单说就是 一个业务操作,需要跨多个数据库、多个服务来完成,而且要保证这些操作要么全部成功,要么全部失败,不能出现“有的成、有的败”的情况。
比如电商下单场景:扣减库存是一个服务的数据库操作,生成订单是另一个服务的数据库操作,扣减用户积分又是第三个服务的操作——这三个操作必须在一个事务里,要么全成(订单生成、库存扣减、积分扣减都完成),要么全败(都回滚,库存、积分不变),不然就会出现“库存扣了但订单没生成”的烂账。
分布式事务的核心痛点,就是跨服务、跨数据库,单机事务的ACID特性搞不定了——单机事务靠数据库的锁和日志就能保证,但分布式环境下,网络可能断、服务可能宕机,必须靠专门的方案来保证一致性。
接下来我说说主流的几种方案,各有优劣,适合不同场景:
一、 2PC(两阶段提交)——最经典的“强一致性”方案
这个是最早的分布式事务方案,核心是分“准备”和“提交”两步,还有一个“协调者”来统一指挥(比如数据库的事务管理器)。
- 第一阶段:准备阶段 协调者给所有参与的服务(叫“参与者”)发指令:“你们把要做的操作先执行一遍,但别提交,告诉我能不能成功”。 每个参与者执行完操作(比如扣库存、生成订单),记录好日志,然后告诉协调者“我准备好了”或者“我失败了”。
- 第二阶段:提交阶段 如果协调者收到所有参与者都说“准备好了”,就发指令:“所有人一起提交!”; 如果有任何一个参与者说“失败了”,协调者就发指令:“所有人一起回滚!”。
优点:强一致性,能保证所有操作要么全成要么全败;
缺点:太死板了!协调者是单点,挂了整个事务就卡壳了;而且准备阶段所有参与者都要锁资源,直到提交完成,性能特别差,适合并发低的场景,现在很少用了。
二、 TCC(补偿事务)——“手动控制”的高可用方案
TCC是把每个分布式操作,拆成“Try-Confirm-Cancel”三个方法,完全靠业务代码来实现一致性,特别灵活。
还是用下单的例子:
- Try(尝试):做业务检查和资源预留——比如检查库存够不够,预留出要扣的库存(标记为“锁定状态”);检查用户积分够不够,预留积分。这一步只做预留,不真正扣减。
- Confirm(确认):如果所有服务的Try都成功了,就执行Confirm——把预留的库存真正扣减,把预留的积分真正扣减,生成正式订单。
- Cancel(取消/补偿):如果任何一个服务的Try失败了,就执行Cancel——把预留的库存释放,把预留的积分释放,取消订单创建。
优点:性能高,因为Try阶段锁定资源的时间很短;而且不依赖数据库的事务,靠业务代码控制,适合高并发场景(比如秒杀);
缺点:对开发要求高!每个业务都要写Try、Confirm、Cancel三个方法,代码量翻倍,还要考虑幂等性(比如Confirm重复调用会不会重复扣库存)。
三、 SAGA(长事务)——“事后补偿”的柔性方案
SAGA适合业务流程长、步骤多的场景,核心思路是“先执行完所有操作,出问题了再反向补偿”。
比如一个业务有A、B、C三个步骤:
- 正向执行:先执行A→再执行B→再执行C;
- 反向补偿:如果C执行失败了,就反向执行补偿操作——先补偿B(撤销B的影响)→再补偿A(撤销A的影响)。
优点:开发比TCC简单,不用写Try方法,只要写正向操作和反向补偿操作就行;而且没有锁资源的问题,性能好;
缺点:一致性弱一点——可能会有短暂的“中间状态”(比如A、B执行完了,C没执行,补偿还没做),适合对一致性要求不是特别高的场景(比如物流订单、退款流程)。
四、 本地消息表(可靠消息队列)——“异步解耦”的主流方案
这个方案现在用得特别多,核心是靠本地事务+消息队列,把分布式事务拆成多个单机事务,实现最终一致性。
还是下单的例子,步骤很清晰:
- 订单服务执行本地事务:生成订单 + 把“扣减库存”的消息写入本地消息表(这两个操作在同一个单机事务里,要么全成要么全败);
- 有个定时任务,不断扫描本地消息表,把没发送的消息发到MQ里;
- 库存服务从MQ里拿到消息,执行扣减库存的本地事务;如果执行成功,就给MQ发确认;如果失败,MQ会重试;
- 订单服务收到库存服务的确认后,把本地消息表的这条消息标记为“已完成”。
优点:解耦性强,订单服务和库存服务不用直接通信;性能高,异步执行;而且消息表在本地,不怕服务宕机,可靠性高;
缺点:是最终一致性,不是强一致性——可能会有“订单生成了,库存还没扣”的短暂延迟;需要保证消息不重复消费(做幂等)、不丢失。
最后总结选型思路(面试加分)
分布式事务没有银弹,选方案看业务场景:
- 要强一致性、并发低:用2PC(比如银行转账);
- 要高并发、性能优先:用TCC(比如秒杀下单);
- 业务步骤多、流程长:用SAGA(比如物流、退款);
- 要解耦、异步、最终一致:用本地消息表(比如电商常规下单)。
而且实际开发中,我们更倾向于尽量避免分布式事务——比如把能合并的服务合并,或者把跨库操作放到一个服务里,毕竟分布式事务复杂度高、性能损耗大。
分布式ID
一是Redis自增,用Redis的incr命令生成自增数,性能高还轻量,只要处理好Redis的持久化就行;
二是雪花算法(Snowflake),能生成64位的全局唯一ID,里面包含时间戳、机器ID这些信息,每秒能生成海量ID,适合分布式集群;
三是美团Leaf、百度UidGenerator这类优化后的发号器,比如Leaf解决了雪花算法的时钟回拨问题,稳定性更优。
短链生成系统
短链生成系统说白了就是个把超长的 URL 转换成短小链接的工具,主要解决链接太长不好传播、方便统计和追踪的问题。
核心工作流程就两步:生成短链和访问短链跳转
- 短链生成算法:
- 哈希算法,可能会有哈希碰撞、哈希值太长问题
- MySQL 自增 ID + 62进制转换缩短长度
- 分布式ID + 62进制转换缩短长度
- 存储设计:
- 缓存层:Redis缓存短链→长链映射(设置过期时间)。
- 数据库:持久化记录,字段包括短链码、长链、创建时间、访问次数。
- 优化点:
- 分库分表支撑高并发写入
- 加验证码、限流 防篡改和防滥用
- 给短链加过期时间
秒杀系统
秒杀的特点是瞬时流量洪峰(比如1秒10万请求)、读多写少(商品详情页疯狂刷新)、写操作敏感(库存扣减不能超卖)。直接让请求打到数据库,数据库肯定扛不住,所以核心思路是层层拦截,把压力挡在最外层。
1. 前端层:第一道拦截,减少无效请求
- 页面静态化:把秒杀商品的详情页(图片、价格、描述)全做成静态文件,放到CDN上,用户刷新时直接从CDN加载,不用请求后端服务器;
- 按钮防重复点击:用户点击“秒杀”按钮后,直接置灰+倒计时(比如5秒内不能再点),避免用户手抖重复提交请求;
- 本地限流:比如限制用户1秒内最多发1次请求,前端直接拦截,不用把无效请求发到后端。
2. 网关层:第二道拦截,限流+黑白名单
- 限流:用网关(比如Nginx、Spring Cloud Gateway)做限流,比如按IP限流(每个IP 1秒最多2次请求)、按用户ID限流(每个用户只能参与1次秒杀),超过阈值直接返回“请求太频繁”;
- 黑白名单:把恶意刷单的IP/用户ID加入黑名单,直接拒绝请求;白名单可以放内部测试账号,方便验证;
- 路径改写+缓存:把秒杀接口的请求路径映射到缓存层,优先走缓存,减少后端压力。
3. 应用层:核心逻辑,削峰+隔离+异步
- 请求削峰:引入MQ(比如RocketMQ、Kafka),把秒杀请求先扔进MQ,后端应用再按照自己的处理能力,慢慢从MQ里消费请求——这就是“削峰填谷”,避免请求直接冲击数据库;
✅ 注意:不是所有请求都能进MQ,要先做资格校验(比如用户是否登录、是否在秒杀时间内),校验不通过的直接拒绝,不让无效请求进MQ; - 系统隔离:秒杀业务和主业务(比如电商的商品详情、购物车)物理隔离——单独部署秒杀服务集群,用独立的服务器、独立的数据库,避免秒杀的流量把主业务拖垮;
还可以做线程隔离,给秒杀接口分配独立的线程池,不占用主业务的线程资源; - 异步化处理:用户秒杀成功后,不用同步扣减库存、生成订单,而是发一条“秒杀成功”的消息到MQ,后台消费线程异步处理订单创建、库存扣减、短信通知——用户端直接返回“秒杀成功,订单正在生成”,提升体验。
4. 缓存层:扛住读压力,防超卖的第一道防线
- 缓存预热:秒杀开始前,把商品库存(比如100件)加载到Redis里,设置成
stock:1001 → 100这种Key-Value; - 库存预扣减:用户请求秒杀时,先在Redis里用
decr命令扣减库存(decr stock:1001),这个操作是原子性的——如果扣减后库存≥0,说明秒杀成功;如果库存<0,直接返回“已抢完”;
✅ 关键:Redis扣减库存是防超卖的核心,因为Redis是单线程的,decr命令不会有并发问题,比数据库的事务靠谱多了; - 缓存穿透/击穿/雪崩防护:
- 穿透:用布隆过滤器过滤不存在的商品ID,避免恶意请求查不存在的商品;
- 击穿:秒杀商品的库存Key设置为永不过期,秒杀结束后手动删除;
- 雪崩:缓存集群做高可用(主从+哨兵),避免缓存宕机。
5. 数据库层:最终一致性,兜底防超卖
- 库存最终扣减:只有Redis扣减成功的请求,才会异步去数据库扣减库存——这里要做幂等性处理(比如用用户ID+商品ID作为唯一键,避免重复扣减);
- 数据库优化:
- 分库分表:把秒杀商品的库存表单独分库分表,比如按商品ID哈希分表;
- 索引优化:给库存表的商品ID加主键索引,扣减库存的SQL要简单(
UPDATE stock SET count = count -1 WHERE goods_id = 1001 AND count > 0),避免长事务; - 读写分离:读库存走从库,写库存走主库,减轻主库压力;
- 防超卖兜底:扣减库存的SQL必须加
count > 0的条件,就算前面缓存层出问题,数据库也能保证不会把库存扣成负数。
三、 关键细节:保证高可用与一致性
- 库存一致性:Redis库存和数据库库存可能不一致(比如Redis扣减成功,数据库扣减失败),可以用定时任务异步校准——比如每分钟对比Redis和数据库的库存,差异大的话报警并修复;
- MQ的可靠性:要保证消息不丢——生产者发消息要做事务消息或发送确认,消费者要做消费确认(处理完再ack),还要有死信队列,处理消费失败的消息;
- 降级兜底:如果系统压力太大,触发熔断降级——比如返回“系统繁忙,请稍后重试”,或者直接显示“已售罄”,保护核心服务不崩溃;
- 监控告警:实时监控QPS、Redis库存、MQ消息积压量、数据库负载,一旦超过阈值,立即告警,人工介入处理。
四、 避坑点(面试加分,体现实战经验)
- 千万别做“先查库存再扣减”:比如先查Redis库存
get stock:1001,再判断是否>0,再decr——这两步不是原子操作,高并发下会超卖;必须用decr一步到位; - 别让秒杀请求穿透到数据库:所有读请求必须走缓存,写请求必须经过MQ削峰;
- 别忽略用户的重复请求:就算前端防重复点击,也要在后端用用户ID+商品ID做幂等校验,避免恶意刷请求。
限流/熔断/降级
限流:限流是通过限制单位时间内的请求数量,防止系统因流量过大而崩溃。
- 固定窗口:在固定时间窗口内限制请求数量。(每秒最多处理 100 个请求)
- 滑动窗口:在滑动时间窗口内限制请求数量,更精确地控制流量。(过去 1 秒内最多处理 100 个请求)
- 令牌桶:以固定速率生成令牌,请求需要消耗令牌,令牌不足时拒绝请求。(每秒生成 100 个令牌,请求消耗令牌,令牌用完后拒绝请求。)
- 漏桶:以固定速率处理请求,超出速率的请求排队或丢弃。(每秒处理 100 个请求,超出速率的请求排队或丢弃。)
熔断:熔断是一种故障保护机制,当系统检测到某个服务或资源出现故障时,主动断开对该服务的调用,避免故障扩散。
- 依赖服务故障:当某个依赖服务(如数据库、第三方 API)出现故障时,避免雪崩效应。
- 超时保护:当服务响应时间过长时,主动断开调用。
- 故障恢复:在服务恢复后,逐步尝试重新调用。
降级:降级是一种容错机制,当系统检测到某个服务或资源不可用时,提供一种备选方案(如返回默认值、缓存数据等),保证系统的基本功能可用。
- 熔断触发降级:当熔断器打开时,调用降级逻辑,返回备选结果。
CPU飙升排查
定位 CPU 飙升至 100% 的问题代码的步骤如下:
- 使用
top命令找到高 CPU 进程和线程。top -Hp <PID> - 将线程 ID 转换为 16 进制。
- 使用
jstack导出线程栈,查找高 CPU 线程的栈信息,定位执行的代码行。 - 分析问题代码,常见问题包括死循环、正则表达式回溯陷阱和频繁 GC(STW)。
- 使用工具(如
jstat查看 JVM 各区内存和 GC 情况、jmap堆内存快照、jvisualvm图形化工具)进一步分析内存和 GC 情况。
Full GC频繁排查
- 工具链:
jstat -gcutil观察各区内存变化;jmap -dump导出堆快照,MAT分析大对象。 - 常见原因:内存泄漏(如未关闭的数据库连接)、大对象未分片(如一次性加载全表数据)。
线程死锁排查
jstack <pid>查看线程栈,搜索“deadlock”关键词;或Arthas的thread -b命令。- 预防:避免嵌套锁、使用超时锁(如
tryLock)。
设计模式
- 设计模式的七大原则
- 单一职责原则:一个类只负责一项职责,降低复杂度,提高可维护性。
- 开闭原则:对扩展开放,对修改关闭(如通过抽象类或接口扩展功能)。
- 依赖倒置原则:高层模块依赖抽象而非具体实现(如Spring的依赖注入)。
- 里氏替换原则:子类可替换父类且不破坏原有功能(如Java集合框架中的List接口)。
- 接口隔离原则:使用多个专用接口代替臃肿的统一接口(如拆分支付接口和订单接口)。
- 迪米特法则:减少类间依赖,降低耦合(如通过中介者模式解耦对象)。
- 合成复用原则:优先使用组合/聚合而非继承(如装饰器模式)。
- 设计模式的分类
- 创建型模式:解决对象创建问题,如单例、工厂、建造者模式。
- 结构型模式:处理类与对象的组合关系,如代理、适配器、装饰器模式。
- 行为型模式:管理对象间的通信与协作,如观察者、策略、责任链模式。
- 工厂模式
- 简单工厂模式:通过静态方法根据参数创建对象,但违反开闭原则。
- 工厂方法模式:抽象工厂接口,子类决定实例化对象(如Spring的
FactoryBean)。 - 抽象工厂模式:创建相关对象族(如不同数据库的
Connection和Statement)。 - 对比:
- 工厂方法针对单一产品,抽象工厂针对产品族。
- Spring中的应用:
BeanFactory是工厂模式的典型实现。
- 代理模式
- 静态代理:代理类与目标类实现相同接口,手动增强功能(如日志记录)。
- 动态代理:运行时生成代理类(如JDK基于接口、CGLIB基于继承)。
- 应用场景:AOP(如Spring事务管理)、远程调用(RPC)。
- 观察者模式
- 核心思想:对象间一对多依赖,状态变化时自动通知(如事件监听)。
- 实现:
- Subject维护观察者列表,通过
notifyObservers()触发更新。
- Subject维护观察者列表,通过
- Spring中的应用:
ApplicationEvent事件机制。 - 对比发布-订阅模式:观察者是松耦合的一对多,发布-订阅通过消息队列解耦。
- 适配器模式
- 类适配器:通过继承适配者类实现目标接口(如将第三方SDK接口适配到系统)。
- 对象适配器:通过组合适配者对象实现目标接口(更灵活,推荐)。
- 应用场景:整合旧系统接口、兼容不同数据格式。
- 策略模式
- 核心思想:将一组算法封装成独立的策略类,使得它们可以互相替换,且算法的变化不影响客户端。
- 核心角色:
- 策略接口:定义算法的抽象方法(如PaymentStrategy.pay())。
- 具体策略类:实现不同算法(如AliPayStrategy、WeChatPayStrategy)。
- 上下文类(Context):持有策略对象,根据需求切换策略。
- 应用:支付方式、排序算法选择。
- 模板模式
- 核心思想:在抽象类中定义算法的骨架,将某些步骤延迟到子类实现,子类可以不改变算法结构重写特定步骤。
- 核心角色:
- 抽象模板类:定义算法骨架。
- 具体子类:实现抽象方法。
- 应用:如Spring的JdbcTemplate。
- 责任链模式
- 核心思想:将请求的发送者和接收者解耦,多个处理器对象形成链式结构,依次尝试处理请求。
- 核心角色:
- 处理器接口:定义处理方法和下一个处理器的引用。
- 具体处理器:实现处理逻辑。
- 应用:Spring MVC拦截器链、Servlet Filter链。
- 装饰器模式
- 核心思想:动态地为对象添加额外职责,通过组合替代继承,避免类爆炸。
- 核心角色:
- 组件接口:定义核心功能(如InputStream.read())。
- 具体组件:基础实现(如FileInputStream)。
- 装饰器基类:持有组件对象,实现相同接口(如FilterInputStream)。
- 具体装饰器:扩展功能(如BufferedInputStream)。
- 应用:Java IO:如BufferedInputStream(装饰InputStream)、GZIPInputStream。
单例模式 DCL
public class Singleton {
// 1. 私有静态实例变量:
// - volatile关键字必须加!禁止指令重排,避免多线程下获取到未完全初始化的实例
// - 懒汉式:初始化为null,使用时才创建
private static volatile Singleton instance;
// 2. 私有构造方法:禁止外部new创建实例
private Singleton() {}
// 3. 公共静态获取实例方法(核心DCL逻辑)
public static Singleton getInstance() {
// 第一次检查:实例未创建时才进入加锁逻辑(避免每次获取实例都加锁,提升性能)
if (instance == null) {
// 加锁:保证多线程下只有一个线程能进入创建实例的逻辑
synchronized (Singleton.class) {
// 第二次检查:防止多个线程等待锁后,重复创建实例
if (instance == null) {
// 创建实例(volatile避免此处指令重排) 并非原子操作,如果重排为1、3、2,那么会返回未初始化的实例
// 1.分配内存空间;
// 2.初始化实例对象;
// 3.将引用指向分配的内存地址。
instance = new Singleton();
}
}
}
return instance;
}
}
三个线程顺序打印ABC
- 信号量 Semaphore
import java.util.concurrent.Semaphore;
public static void main(String[] args) {
Semaphore semA = new Semaphore(1);
Semaphore semB = new Semaphore(0);
Semaphore semC = new Semaphore(0);
int maxCount = 10; // 打印次数
new Thread(() -> {
try {
for (int i = 0; i < maxCount; i++) {
semA.acquire(); // 获取A的信号量
System.out.print("A");
semB.release(); // 释放B的信号量
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
new Thread(() -> {
try {
for (int i = 0; i < maxCount; i++) {
semB.acquire(); // 获取B的信号量
System.out.print("B");
semC.release(); // 释放C的信号量
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
new Thread(() -> {
try {
for (int i = 0; i < maxCount; i++) {
semC.acquire(); // 获取C的信号量
System.out.print("C");
semA.release(); // 释放A的信号量,形成循环
System.out.println(); // 每打印完一轮换行
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
}
LRU缓存
LRU(最近最少使用)、LFU(最不经常使用)、FIFO(先进先出)
- LinkedHashMap(accessOrder=true + removeEldestEntry)
- 自定义双向链表 + HashMap实现(优化点:分段锁减少竞争、软/弱引用避免OOM、异步刷新过期数据)
public class LRUCache { // 时间复杂度都是 O(1)
class Node {
Node prev, next;
Object key, value;
}
private int size; // 最大容量
private Map<Object, Node> cache; // 存储数据
private Node head, tail; // 虚拟头尾节点
public Object get(Object key) {
Node node = cache.get(key);
if (node != null) {
moveToHead(node);
return node.value;
} else {
return null;
}
}
public void put(Object key, Object value) {
Node node = cache.get(key);
if (node != null) {
node.value = value;
moveToHead(node);
} else {
Node node1 = new Node(key, value);
cache.put(key, node1);
addToHead(node1);
if (cache.size() > size) {
Node tail = removeTail();
cache.remove(tail.key);
}
}
}
private void moveToHead(Node node) {
Queue<TreeNode> queue = new LinkedList<>();
removeNode(node);
addToHead(node);
}
private Node removeTail() {
Node prev = tail.prev;
removeNode(prev);
return prev;
}
// 先改自身指针,再改前后指针
private void addToHead(Node node1) {
node1.prev = head;
node1.next = head.next;
head.next.prev = node1;
head.next = node1;
}
// 改前后指针即可
private void removeNode(Node node) {
node.prev.next = node.next;
node.next.prev = node.prev;
}
}
最小栈
题目:设计一个栈,支持push、pop、top操作,并能在常数时间内检索到最小元素。
考点:辅助栈与空间换时间。
class MinStack {
private Stack<Integer> stack;
private Stack<Integer> minStack;
public MinStack() {
stack = new Stack<>();
minStack = new Stack<>();
}
public void push(int val) {
stack.push(val);
if (minStack.isEmpty() || val <= minStack.peek()) {
minStack.push(val);
}
}
public void pop() {
if (stack.pop().equals(minStack.peek())) {
minStack.pop();
}
}
public int top() {
return stack.peek();
}
public int getMin() {
return minStack.peek();
}
}
最长递增子序列
题目:给定一个整数数组,从数组中挑选若干元素组成子序列(元素在原数组中相对顺序不变,但无需连续),且子序列中的元素严格递增(后一个数 > 前一个数),满足这两个条件的子序列中,长度最大的那个就是 “最长递增子序列”,其长度即为 LIS 的核心求解目标。
动态规划核心思路:
- 状态定义:dp[i] 聚焦 “以第 i 个元素结尾” 的递增子序列,而非 “前 i 个元素的最长子序列”,这是避免遗漏的关键;
- 状态转移:对于每个 i,遍历其之前的所有 j,若nums[i] > nums[j],则dp[i]可由dp[j]+1更新(因为 nums [i] 能接在 nums [j] 的子序列后);
- 全局最大值:遍历过程中持续更新max,最终max即为整个数组的最长递增子序列长度。
复杂度分析:
- 时间复杂度:O (n²)(两层嵌套循环,n 为数组长度);
- 空间复杂度:O (n)(仅需长度为 n 的 dp 数组)。
/**
* 动态规划求解最长递增子序列(LIS)的长度
* 递增子序列定义:子序列元素严格递增,且元素在原数组中相对顺序不变(无需连续)
* @param nums 输入的整数数组(如[10,9,2,5,3,7,101,18])
* @return 最长递增子序列的长度
*/
public int lengthOfLIS(int[] nums) {
// 边界条件:空数组直接返回0
if (nums == null || nums.length == 0) {
return 0;
}
// 1. 定义dp数组:dp[i] 表示以nums[i]为结尾的最长递增子序列的长度
int[] dp = new int[nums.length];
// 2. 初始化dp数组:每个元素自身构成长度为1的递增子序列,因此初始值为1
Arrays.fill(dp, 1);
// 3. 记录全局最长递增子序列的长度(至少为1,因为数组非空)
int max = 1;
// 4. 遍历数组,从第二个元素开始(i=1),逐个计算dp[i]
for (int i = 1; i < nums.length; i++) {
// 5. 遍历i之前的所有元素j,寻找能和nums[i]形成递增的子序列
for (int j = 0; j < i; j++) {
// 6. 核心条件:nums[i] > nums[j],说明nums[i]可接在nums[j]的递增子序列后
if (nums[i] > nums[j]) {
// 7. 状态转移:以nums[i]结尾的最长子序列长度 = max(当前dp[i], dp[j]+1)
// dp[j]+1表示:将nums[i]接在以nums[j]结尾的子序列后,形成更长的子序列
dp[i] = Math.max(dp[i], dp[j] + 1);
}
}
// 8. 更新全局最长长度
max = Math.max(max, dp[i]);
}
// 9. 返回全局最长递增子序列长度
return max;
}
优化解法:最长递增子序列的优化解法(贪心 + 二分,时间复杂度 O (n log n)) 。
快速排序
- 时间复杂度:平均O(nlogn),最坏O(n²)(如已有序数组)。
- 空间复杂度:O(logn)(递归栈)。
public static void quickSort(int[] arr, int left, int right) {
// 递归的边界
if (left < right) {
int partitionIndex = partition(arr, left, right);
quickSort(arr, left, partitionIndex - 1);
quickSort(arr, partitionIndex + 1, right);
}
}
private static int partition(int[] arr, int left, int right) {
// 选择首元素为基准值(pivot)
int pivot = arr[left];
// 创建标记 mark 指针
int markPoint = left;
// 排除基准值后逐个遍历
for (int i = left + 1; i <= right; i++) {
// 如果比基准值小,则 mark++ 后互换
if (arr[i] < pivot) { // 小于:从小到大;大于:从大到小
markPoint++;
swap(arr, i, markPoint);
}
}
// 将首元素基准值与当前 mark 互换, 即基准值的坐边都是比基准值小的
swap(arr, left, markPoint);
return markPoint;
}
private static void swap(int[] arr, int i, int j) {
int temp = arr[i];
arr[i] = arr[j];
arr[j] = temp;
}
public static void main(String[] args) {
int[] arr = {0, 0, 1, 2, 4, 2, 2, 3, 1, 4};
quickSort(arr, 0, arr.length - 1);
System.out.println(Arrays.toString(arr));
}
如何优化最坏情况?快排会退化为 O (n²)
- 随机选择基准(如
pivot = arr[random(low, high)])。
最大的第K个数
思路1:堆排序 O(n log k) 大规模数据
public int findKthLargest(int[] nums, int k) {
PriorityQueue<Integer> heap = new PriorityQueue<>();
for (int num : nums) {
heap.offer(num);
if (heap.size() > k) {
heap.poll();
}
}
return heap.peek();
}
思路2:快速选择(分治减治)平均 O(n),最坏 O(n²)
public int findKthLargest(int[] nums, int k) {
int left = 0, right = nums.length - 1;
while (true) {
int pivotIndex = partition(nums, left, right);
if (pivotIndex == nums.length - k) {
return nums[pivotIndex];
} else if (pivotIndex < nums.length - k) {
left = pivotIndex + 1;
} else {
right = pivotIndex - 1;
}
}
}
private int partition(int[] nums, int low, int high) {
int pivot = nums[high];
int i = low;
for (int j = low; j < high; j++) {
if (nums[j] <= pivot) {
swap(nums, i, j);
i++;
}
}
swap(nums, i, high);
return i;
}
二维数组中的查找
思路:从左下角开始,若当前值小于目标则右移,否则上移,时间复杂度 O(m+n)
public boolean findNumberIn2DArray(int[][] matrix, int target) {
int i = matrix.length - 1, j = 0;
while (i >= 0 && j < matrix[0].length) {
if (matrix[i][j] > target) {
i--;
} else if (matrix[i][j] < target) {
j++;
} else {
return true;
}
}
return false;
}
二维数组顺时针打印
题目:给定一个 m x n 的二维数组(矩阵),按照顺时针螺旋的顺序打印出数组中的所有元素。
“边界收缩法”:
- 定义上(top)、下(bottom)、左(left)、右(right)四个边界,代表当前待遍历的矩阵范围;
- 按 “左→右(上边界)→上→下(右边界)→右→左(下边界)→下→上(左边界)” 的顺序遍历当前层;
- 每遍历完一条边,收缩对应边界(如上边界遍历完后top++);
- 循环直到边界交叉(top > bottom 或 left > right),结束遍历。
时间复杂度为 O (m×n)(m 是行数,n 是列数)空间复杂度:O (1)
/**
* 顺时针打印二维数组(螺旋遍历)
* 核心思路:定义上下左右四个边界,逐层向内遍历,遍历完一层后收缩边界,直到边界交叉
* @param matrix 待遍历的二维数组
* @return 顺时针遍历后的元素列表
*/
public List<Integer> spiralOrder(int[][] matrix) {
List<Integer> result = new ArrayList<>();
// 边界条件:数组为空或行数为0,直接返回空列表
if (matrix == null || matrix.length == 0) {
return result;
}
// 1. 定义四个边界
int top = 0; // 上边界(初始为第0行)
int bottom = matrix.length - 1; // 下边界(初始为最后一行)
int left = 0; // 左边界(初始为第0列)
int right = matrix[0].length - 1; // 右边界(初始为最后一列)
// 2. 循环遍历:直到上下边界交叉、左右边界交叉
while (top <= bottom && left <= right) {
// 第一步:从左到右遍历上边界行(top行)
for (int i = left; i <= right; i++) {
result.add(matrix[top][i]);
}
top++; // 上边界向下收缩(该层遍历完成)
// 第二步:从上到下遍历右边界列(right列)
for (int i = top; i <= bottom; i++) {
result.add(matrix[i][right]);
}
right--; // 右边界向左收缩
// 注意:需判断上下边界是否仍有效(避免单行重复遍历)
if (top <= bottom) {
// 第三步:从右到左遍历下边界行(bottom行)
for (int i = right; i >= left; i--) {
result.add(matrix[bottom][i]);
}
bottom--; // 下边界向上收缩
}
// 注意:需判断左右边界是否仍有效(避免单列重复遍历)
if (left <= right) {
// 第四步:从下到上遍历左边界列(left列)
for (int i = bottom; i >= top; i--) {
result.add(matrix[i][left]);
}
left++; // 左边界向右收缩
}
}
return result;
}
连续子数组的最大和
题目:求整数数组中连续子数组的最大和。
思路:定义 dp[i] 表示以 nums[i] 结尾的子数组最大和,递推公式 dp[i] = max(nums[i], dp[i-1] + nums[i])
时间复杂度:O (n)(n 为数组nums的长度); 空间复杂度:O (1)(仅使用常数级额外空间)
public int maxSubArray(int[] nums) {
int pre = 0; // 前面的累加和
int max = nums[0]; // 记录最大和
for (int num : nums) {
// 核心:参考递推公式
pre = Math.max(pre + num, num);
// 仅记录最大和
max = Math.max(max, pre);
}
return max;
}
数组中两个数的和
题目:给定一个整数数组和一个目标值,找出数组中和为目标值的两个数。
考点:哈希表优化查找时间(O(n))。
变种:三数之和、四数之和。
public int[] twoSum(int[] nums, int target) {
Map<Integer, Integer> map = new HashMap<>(); // k:数值 v: 下标索引
for (int i = 0; i < nums.length; i++) {
int complement = target - nums[i]; // 计算匹配的数值
if (map.containsKey(complement)) { // O1查找
return new int[]{map.get(complement), i};
}
map.put(nums[i], i); // 保存数值和下标
}
throw new IllegalArgumentException("No two sum solution");
}
数字和为目标数
题目:给定一个无重复元素的数组和一个目标数,找出所有可以使数字和为目标数的组合。
考点:回溯与剪枝。
/**
* 找出数组中所有和为目标数的组合(元素可重复选取,组合无重复)
* @param candidates 无重复元素的候选数组(如[2,3,6,7])
* @param target 目标和(如7)
* @return 所有满足条件的组合列表
*/
public List<List<Integer>> combinationSum(int[] candidates, int target) {
// 存储最终所有符合条件的组合
List<List<Integer>> result = new ArrayList<>();
// 调用回溯函数:初始临时组合为空,剩余目标和为target,起始遍历索引为0
backtrack(result, new ArrayList<>(), candidates, target, 0);
return result;
}
/**
* 回溯核心函数:递归构建和为目标数的组合
* @param result 最终结果集
* @param temp 临时组合,存储当前正在构建的数字组合
* @param candidates 候选数字数组
* @param remain 剩余需要凑的和(初始为target,每选一个数就减去该数)
* @param start 遍历的起始索引(核心:控制组合不重复,避免[2,3]和[3,2]这类重复组合)
*/
private void backtrack(List<List<Integer>> result, List<Integer> temp, int[] candidates, int remain, int start) {
// 剪枝1:剩余需要凑的和为负数,说明当前组合超过目标数,直接返回
if (remain < 0) {
return;
}
// 终止条件:剩余需要凑的和为0,说明当前组合和为目标数,加入结果集
if (remain == 0) {
// 必须新建ArrayList(否则temp后续修改会影响结果集中的组合)
result.add(new ArrayList<>(temp));
return;
}
// 遍历候选数组:从start索引开始,而非从0开始(关键去重逻辑)
for (int i = start; i < candidates.length; i++) {
// 选择:将当前数字加入临时组合
temp.add(candidates[i]);
// 递归:剩余和减去当前数字,起始索引仍为i(允许重复选取当前数字)
backtrack(result, temp, candidates, remain - candidates[i], i);
// 回溯:撤销选择,移除临时组合最后一个数字,尝试下一个数字
temp.remove(temp.size() - 1);
}
}
合并重叠区间
题目:给定一组区间,合并所有重叠的区间。
考点:
- 排序:先把所有区间按「起始位置」升序排列,保证后续只需要依次对比相邻区间;
- 遍历合并:
- 用current变量暂存当前正在合并的区间,初始化为第一个区间;
- 遍历每个区间,对比当前区间的起始位置和current的结束位置:
- 若重叠(当前区间起始 ≤ current结束):更新current的结束位置为两者最大值(合并);
- 若不重叠:把current加入结果列表,再将current替换为当前遍历的区间;
- 遍历结束后,把最后一个current加入结果列表(避免遗漏最后一个合并后的区间)。
- 排序 O (n log n),遍历 O (n),空间复杂度 O (n)
public int[][] merge(int[][] intervals) {
// 边界条件:空数组或只有一个区间,直接返回
if (intervals == null || intervals.length == 0) {
return new int[0][];
}
if (intervals.length == 1) {
return intervals;
}
// 第一步:按区间起始位置升序排序
Arrays.sort(intervals, (a, b) -> a[0] - b[0]);
// 第二步:遍历合并区间(核心逻辑和你提供的完全一致)
List<int[]> result = new ArrayList<>();
int[] current = intervals[0]; // 初始化当前合并区间为第一个区间
for (int[] interval : intervals) {
if (interval[0] <= current[1]) {
// 重叠,合并:更新current的结束位置为最大值
current[1] = Math.max(current[1], interval[1]);
} else {
// 不重叠,把当前合并好的区间加入结果,切换current为新的区间
result.add(current);
current = interval;
}
}
// 最后一个current还没加入结果,补充进去
result.add(current);
// 转换为二维数组返回
return result.toArray(new int[result.size()][]);
}
最长无重复子串
题目:给定一个字符串,找出不含有重复字符的最长子串的长度。
考点:滑动窗口(双指针)与哈希集合。
- 时间复杂度:O (n)(最坏情况下为 O (2n),但复杂度量级仍为 O (n));
- 空间复杂度:O(min(m, n))
public int lengthOfLongestSubstring(String s) {
// 1. 用HashSet存储当前滑动窗口内的字符,保证无重复(核心:快速判断字符是否重复)
Set<Character> set = new HashSet<>();
// 2. 滑动窗口左指针(初始在0位置,控制窗口左边界)
int left = 0;
// 3. 记录最长无重复子串的长度(初始为0)
int maxLen = 0;
// 4. 滑动窗口右指针(遍历字符串,扩展窗口右边界)
for (int right = 0; right < s.length(); right++) {
// 5. 核心:如果当前右指针字符已在窗口中(重复),则不断移动左指针,直到移除重复字符
while (set.contains(s.charAt(right))) {
// 移除左指针指向的字符,并将左指针右移(缩小窗口)
set.remove(s.charAt(left++));
}
// 6. 此时窗口内无重复,将当前右指针字符加入窗口
set.add(s.charAt(right));
// 7. 更新最长子串长度:当前窗口长度 = right - left + 1(+1是因为下标从0开始)
maxLen = Math.max(maxLen, right - left + 1);
}
// 8. 返回最长无重复子串长度
return maxLen;
}
回文子串
题目:给定一个字符串 s,找出该字符串中所有的回文子串,返回包含所有回文子串的列表。回文子串指正读和反读都相同的子串。 算法核心思想:中心扩展法(回文子串的特性是 “中心对称”)
- 奇数长度回文:以单个字符为中心(如 “aba” 中心是 b),向左右扩展;
- 偶数长度回文:以两个相邻字符为中心(如 “abba” 中心是两个 b),向左右扩展。
时间复杂度:O(n²); 空间复杂度:O (1)
/**
* 找出字符串中所有的回文子串
* @param s 输入字符串
* @return 包含所有回文子串的列表
*/
List<String> allPalindromeSubstrings(String s) {
List<String> result = new ArrayList<>();
// 边界条件:字符串为空或null,直接返回空列表
if (s == null || s.isEmpty()) {
return result;
}
// 遍历字符串的每个字符,作为回文子串的中心
for (int i = 0; i < s.length(); i++) {
// 情况1:处理奇数长度的回文子串(中心为单个字符,如 "aba",中心是下标1的'b')
expandAroundCenter(s, i, i, result);
// 情况2:处理偶数长度的回文子串(中心为两个相邻字符,如 "abba",中心是下标1和2的'b'/'b')
expandAroundCenter(s, i, i + 1, result);
}
return result;
}
/**
* 从指定的左右中心向两侧扩展,寻找所有以该中心为核心的回文子串
* @param s 输入字符串
* @param left 回文中心左边界
* @param right 回文中心右边界
* @param result 存储所有回文子串的结果列表
*/
void expandAroundCenter(String s, int left, int right, List<String> result) {
// 扩展条件:左边界不越界、右边界不越界、左右指针指向的字符相等(满足回文)
while (left >= 0 && right < s.length() && s.charAt(left) == s.charAt(right)) {
// 截取当前回文子串(substring左闭右开,所以right+1),加入结果列表
result.add(s.substring(left, right + 1));
// 向左侧扩展一位
left--;
// 向右侧扩展一位
right++;
}
}
反转链表I && II
题目:反转一个单链表。
考点:迭代与递归实现,指针操作。
时间复杂度:O(n) ;空间复杂度:O(1)
public ListNode reverseList(ListNode head) {
ListNode prev = null;
ListNode curr = head;
while (curr != null) {
ListNode temp = curr.next; // 保存下一个节点
curr.next = prev; // 反转当前节点的指针
prev = curr; // prev后移
curr = temp; // curr后移
}
return prev;
}
public ListNode reverseBetween(ListNode head, int left, int right) {
// 1. 虚拟头节点:处理left=1(反转区间从第一个节点开始)的边界情况
ListNode dummy = new ListNode(0);
dummy.next = head;
// 2. 定位反转区间的前驱节点pre(最终停在left的前一个节点)
ListNode pre = dummy;
for (int i = 1; i < left; i++) {
pre = pre.next;
}
// 3. 定位反转区间的第一个节点start,和最后一个节点end
ListNode start = pre.next; // 反转区间的头
ListNode end = pre;
for (int i = 0; i < right - left + 1; i++) { // 移动right-left+1次,找到end
end = end.next;
}
// 4. 定位反转区间的后继节点next(end的下一个节点)
ListNode next = end.next;
// 5. 切断反转区间与原链表的联系(拆出子链表)
pre.next = null; // 断开pre和start的连接
end.next = null; // 断开end和next的连接
// 6. 反转子链表(start→end)
reverseLinkedList(start);
// 7. 重新拼接:pre → 反转后的子链表头(原end) → 反转后的子链表尾(原start) → next
pre.next = end; // 反转后end是子链表头
start.next = next; // 反转后start是子链表尾
// 8. 返回虚拟头节点的next(处理left=1的情况)
return dummy.next;
}
链表环检测
题目:判断链表中是否有环,并返回环的入口节点。
考点:快慢指针(Floyd判圈算法)无论有无环,时间复杂度都是 O(n)(n 为链表总节点数)空间复杂度为 O(1)
时间复杂度:O(n); 空间复杂度:O(1)
public ListNode detectCycle(ListNode head) {
ListNode slow = head, fast = head;
while (fast != null && fast.next != null) {
slow = slow.next;
fast = fast.next.next;
if (slow == fast) { // 确定有还=环
slow = head;
while (slow != fast) {
slow = slow.next;
fast = fast.next;
}
return slow;
}
}
return null;
}
合并两个有序链表
题目:将两个升序链表合并为一个新的升序链表。
考点:双指针遍历。
时间复杂度:O(m + n); 空间复杂度:O(1)
public ListNode mergeTwoLists(ListNode l1, ListNode l2) {
// 虚拟头节点,return时剔除
ListNode dummy = new ListNode(0);
ListNode curr = dummy;
while (l1 != null && l2 != null) {
if (l1.val < l2.val) {
// 当前指针指向 l1
curr.next = l1;
l1 = l1.next;
} else {
// 当前指针指向 l2
curr.next = l2;
l2 = l2.next;
}
// 当前指针后移
curr = curr.next;
}
// 注意遗漏
curr.next = l1 != null ? l1 : l2;
return dummy.next;
}
全排列(无重复数字)
题目:给定一个不含重复数字的数组,返回其所有可能的不重复全排列。
思路:通过回溯法 “选数字→递归→撤销选择” 的循环,构建所有排列。遍历数组每个数字,若未被选入当前临时排列则加入,递归到临时排列长度等于数组长度时,记录一个完整排列;递归返回后撤销最后一次选择,继续尝试其他数字,最终覆盖所有可能的排列组合。
时间O(n×n!),空间O(n×n!)
/**
* 生成不含重复数字数组的所有全排列
* @param nums 不含重复数字的一维数组
* @return 所有全排列的列表
*/
public List<List<Integer>> permute(int[] nums) {
// 存储最终所有全排列结果
List<List<Integer>> result = new ArrayList<>();
// 调用回溯函数,初始临时列表为空
backtrack(result, new ArrayList<>(), nums);
return result;
}
/**
* 回溯核心函数:递归构建全排列
* @param result 最终结果集
* @param temp 临时列表,存储当前正在构建的排列
* @param nums 原始数组
*/
private void backtrack(List<List<Integer>> result, List<Integer> temp, int[] nums) {
// 终止条件:临时列表长度等于数组长度,说明已构建完一个完整排列。
// 如果是返回 k 个元素的所有排列,改为 temp.size() == k
if (temp.size() == nums.length) {
// 必须新建ArrayList(否则temp后续修改会影响结果),加入结果集
result.add(new ArrayList<>(temp));
return;
}
// 遍历数组中所有数字,尝试加入临时列表
for (int num : nums) {
// 剪枝:如果当前数字已在临时列表中,跳过(避免重复选)
if (temp.contains(num)) {
continue;
}
// 选择:将当前数字加入临时列表
temp.add(num);
// 递归:继续构建剩余位置的排列
backtrack(result, temp, nums);
// 回溯:撤销选择,移除临时列表最后一个数字,尝试下一个可能
temp.remove(temp.size() - 1);
}
}
全排列(含重复数字)
题目:给定一个包含重复数字的数组,返回其所有可能的不重复全排列。
核心思路:
- 先排序让重复数字相邻;
- 用used数组替代contains(提升效率),标记元素是否被选;
- 关键剪枝:重复数字仅当 “前一个重复元素已被选” 时才选当前元素,避免生成重复排列;
- 其余逻辑和无重复版本一致,通过回溯构建所有不重复的排列。
时间最坏O(n×n!)、实际更低,空间O(n×k) (n!读作n的阶乘)
/**
* 生成含重复数字数组的所有不重复全排列
* @param nums 包含重复数字的一维数组
* @return 所有不重复全排列的列表
*/
public List<List<Integer>> permuteUnique(int[] nums) { // 方法名修正为permuteUnique,区分无重复版本
List<List<Integer>> result = new ArrayList<>();
// 排序:让重复数字相邻,便于后续剪枝去重
Arrays.sort(nums);
// used数组:标记数组元素是否被选入当前排列(替代contains,提升效率)
backtrack(result, new ArrayList<>(), nums, new boolean[nums.length]);
return result;
}
/**
* 回溯核心函数(带去重剪枝)
* @param result 最终结果集
* @param temp 临时列表,存储当前正在构建的排列
* @param nums 排序后的原始数组
* @param used 标记数组,used[i]=true表示nums[i]已被选入当前排列
*/
private void backtrack(List<List<Integer>> result, List<Integer> temp, int[] nums, boolean[] used) {
// 终止条件:临时列表长度等于数组长度,记录完整排列
// 如果是返回 k 个元素的所有排列,终止条件改为 temp.size() == k
if (temp.size() == nums.length) {
result.add(new ArrayList<>(temp));
return;
}
// 遍历数组(按索引遍历,而非直接遍历值,便于判断重复)
for (int i = 0; i < nums.length; i++) {
// 剪枝条件1:当前元素已被选,跳过;
// 剪枝条件2:当前元素和前一个元素重复,且前一个元素未被选(避免重复排列)
if (used[i] || (i > 0 && nums[i] == nums[i - 1] && !used[i - 1])) {
continue;
}
// 选择:标记当前元素为已用,加入临时列表
used[i] = true;
temp.add(nums[i]);
// 递归:继续构建剩余排列
backtrack(result, temp, nums, used);
// 回溯:撤销标记和选择
used[i] = false;
temp.remove(temp.size() - 1);
}
}
数组中出现次数超过一半
题目:找出数组中出现次数超过一半的元素。
思路:摩尔投票法,该算法的核心思想是 “多数元素的净票数无法被抵消”。
时间复杂度:O (n)(仅遍历数组一次); 空间复杂度:O (1)(仅用两个变量,无额外空间)。
/**
* 找出数组中出现次数超过一半的元素(摩尔投票法)
* 题目前提:数组非空且一定存在满足条件的元素(无需额外校验)
* @param nums 输入整数数组(如[1,2,3,2,2,2,5,4,2])
* @return 出现次数超过一半的元素
*/
public int majorityElement(int[] nums) {
// 1. 投票数:记录当前候选元素的“净支持数”
int votes = 0;
// 2. 候选元素x:初始为0,后续会被替换为真实候选
int x = 0;
// 3. 遍历数组,核心逻辑:不同元素相互抵消,最终剩余的候选即为目标
for (int num : nums) {
// 4. 当投票数为0时,更换候选元素为当前num(无候选时,当前元素成为新候选)
if (votes == 0) {
x = num;
}
// 5. 投票计数:
// - 若当前元素等于候选x,投票数+1(支持);
// - 若不等,投票数-1(抵消);
votes += (num == x) ? 1 : -1;
}
// 6. 最终候选x即为出现次数超过一半的元素(题目保证存在)
return x;
}
不重复的三元组
题目:找到一个整数数组中所有不重复的三元组(即三个数的组合),这些三元组的和等于0
- 算法核心逻辑(排序 + 双指针)
- 排序:先对数组排序,一是为了双指针能按大小收敛,二是为了方便跳过重复元素去重;
- 固定首元素:遍历数组,将nums[i]作为三元组第一个元素,剩余两个元素通过双指针在i右侧查找;
- 双指针收敛:
- 左指针left从i+1开始(避免重复选i),右指针right从数组末尾开始;
- 若nums[left]+nums[right] = -nums[i],找到符合条件的三元组;
- 若和偏小,左指针右移(取更大的数);若和偏大,右指针左移(取更小的数);
- 去重:遍历 / 指针移动时跳过重复元素,避免生成如[-1,0,1]和[-1,0,1](重复-1)的重复三元组。
- 复杂度分析
- 时间复杂度:O (n²)(排序 O (n log n) + 遍历 + 双指针 O (n²),n² 主导);
- 空间复杂度:O (log n)~O (n)(排序的系统栈空间,若使用原地排序则为 O (log n))。
/**
* 找出数组中所有和为0的不重复三元组(a+b+c=0)
* 要求:三元组元素不重复(如[-1,0,1]和[0,-1,1]视为同一组,仅保留一个)
* @param nums 输入整数数组(如[-1,0,1,2,-1,-4])
* @return 所有符合条件的不重复三元组列表
*/
public List<List<Integer>> threeSum(int[] nums) {
List<List<Integer>> result = new ArrayList<>();
// 边界条件:数组为空或长度<3,直接返回空列表(无法组成三元组)
if (nums == null || nums.length < 3) {
return result;
}
// 核心步骤1:排序数组(O(n log n))
// 作用1:方便双指针向中间收敛查找;作用2:方便跳过重复元素去重
Arrays.sort(nums);
// 核心步骤2:遍历固定第一个数nums[i],剩余两个数用双指针查找
// 遍历范围:i < nums.length-2(需留至少两个数给left/right)
for (int i = 0; i < nums.length - 2; i++) {
// 剪枝1:跳过重复的第一个数(避免生成重复三元组)
// 条件i>0保证是“非第一个元素”的重复,避免误删如[0,0,0]的情况
if (i > 0 && nums[i] == nums[i - 1]) {
continue;
}
// 双指针初始化:left在i右侧第一个位置,right在数组末尾
int left = i + 1;
int right = nums.length - 1;
// 目标和:剩余两个数的和需等于 -nums[i](才能满足nums[i]+left+right=0)
int target = -nums[i];
// 双指针向中间收敛查找(O(n) per i,总O(n²))
while (left < right) {
// 计算左右指针元素的和
int sum = nums[left] + nums[right];
if (sum == target) {
// 找到符合条件的三元组,加入结果集
result.add(Arrays.asList(nums[i], nums[left], nums[right]));
// 剪枝2:跳过left指针重复元素(避免重复三元组)
while (left < right && nums[left] == nums[left + 1]) {
left++;
}
// 剪枝3:跳过right指针重复元素(避免重复三元组)
while (left < right && nums[right] == nums[right - 1]) {
right--;
}
// 双指针同时向中间移动,继续查找下一组可能的组合
left++;
right--;
} else if (sum < target) {
// 和小于目标值:需要更大的数,左指针右移
left++;
} else {
// 和大于目标值:需要更小的数,右指针左移
right--;
}
}
}
return result;
}
二叉树的中序遍历
时间复杂度:O (n)(n 为二叉树节点总数); 空间复杂度: O (h)(h 为二叉树高度)
递归实现
public void inorderRecur(TreeNode root, List<Integer> res) {
if (root == null) return;
inorderRecur(root.left, res); // 递归左子树
res.add(root.val); // 访问根节点
inorderRecur(root.right, res); // 递归右子树
}
非递归实现
public List<Integer> inorderNonRecur(TreeNode root) {
List<Integer> res = new ArrayList<>();
Stack<TreeNode> stack = new Stack<>();
TreeNode cur = root;
while (cur != null || !stack.isEmpty()) {
while (cur != null) { // 压入所有左子节点
stack.push(cur);
cur = cur.left;
}
cur = stack.pop(); // 弹出并访问
res.add(cur.val);
cur = cur.right; // 处理右子树
}
return res;
}
二叉树的层序遍历
题目:按层遍历二叉树,返回每层的节点值。
考点:队列实现,BFS逐层处理,记录每层节点数。
时间复杂度:O(n); 空间复杂度:O (n)(最坏情况),平均情况 O (h)(h 为树高)
// class TreeNode {int val; TreeNode left, right;TreeNode(int x) { val = x; }}
public List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> result = new ArrayList<>();
if (root == null) {
return result;
}
// 注意:队列按层存放
Queue<TreeNode> queue = new LinkedList<>();
// 首先存放第一层:只有root节点
queue.offer(root);
while (!queue.isEmpty()) {
// 当前层的节点值集合
List<Integer> level = new ArrayList<>();
result.add(level);
// queue存放的当前层的节点数
int size = queue.size();
// 遍历当前层的所有节点
for (int i = 0; i < size; i++) {
// 取出节点
TreeNode node = queue.poll();
level.add(node.val);
// 继续存放下一层节点
if (node.left != null) {
queue.offer(node.left);
}
if (node.right != null) {
queue.offer(node.right);
}
}
}
return result;
}
二叉树的最大深度
题目:给定一个二叉树,找出其最大深度。
考点:递归与DFS。
时间复杂度:O(n); 空间复杂度:O (h)(最坏情况 O (n),平衡树 O (log n))
public int maxDepth(TreeNode root) {
if (root == null) {
return 0;
}
return Math.max(maxDepth(root.left), maxDepth(root.right)) + 1;
}
二叉树的最大路径和
题目:路径可以是任意节点到任意节点,不一定经过根节点。
思路:归求单侧最大贡献 + 全局记录跨节点最大路径。
int maxSum = Integer.MIN_VALUE;
public int maxPathSum(TreeNode root) {
dfs(root);
return maxSum;
}
private int dfs(TreeNode root) {
if (root == null) {
return 0;
}
// 以 root 为起点,向下延伸的单侧最大路径和
int left = Math.max(dfs(root.left), 0); // 舍弃负数路径
int right = Math.max(dfs(root.right), 0);
// 更新全局最大路径和
maxSum = Math.max(maxSum, left + right + root.val);
return Math.max(left, right) + root.val; // 返回单侧最大路径
}
二叉搜索树的第K大节点
题目:给定一棵二叉搜索树,找到其中第K大的节点。
思路:二叉搜索树特性: 反向中序遍历结果是“从大到小”的序列,遍历到第K个节点即为目标。 中序遍历(左→根→右)结果是 “从小到大” 的有序序列。
时间复杂度:O(n); 空间复杂度:O (h)(最坏情况 O (n),平衡树 O (log n))
// 全局计数器:记录当前遍历到的是“第几个”节点(从1开始)
int count = 0;
// 全局结果:存储第K大节点的值
int result = 0;
public int kthLargest(TreeNode root, int k) {
// 调用反向中序遍历(右→根→左),遍历过程中统计第K大节点
reverseInorder(root, k);
return result;
}
private void reverseInorder(TreeNode root, int k) {
if (root == null) {
return;
}
// 第一步:优先遍历右子树(二叉搜索树右子树节点值 > 根节点值,先拿更大的数)
reverseInorder(root.right, k);
// 第二步:处理当前根节点
// 计数器+1(表示遍历到了第count个节点)
if (++count == k) {
// 找到第K大节点,记录值并直接返回(无需继续遍历)
result = root.val;
return;
}
// 第三步:遍历左子树(左子树节点值 < 根节点值,最后拿更小的数)
reverseInorder(root.left, k);
}
零钱兑换(凑硬币)
题目:给定不同面额的硬币和一个总金额,计算可以凑成总金额的最少硬币数。
动态规划:
- 拆解问题:凑金额i的最少硬币数,可由i - coin的最少硬币数 + 1 推导(coin是当前尝试的硬币面额);
- 状态转移方程:dp[i] = min(dp[i], dp[i - coin] + 1)(前提是coin ≤ i)。
时间复杂度:O(amount * n)(n是硬币面额数,遍历 amount 个金额,每个金额遍历 n 种硬币);
空间复杂度:O(amount)(仅需一个长度为amount+1的 dp 数组)
/**
* 计算凑成总金额所需的最少硬币数
* @param coins 不同面额的硬币数组(如[1,2,5])
* @param amount 目标总金额(如11)
* @return 最少硬币数;若无法凑出,返回-1
*/
public int coinChange(int[] coins, int amount) {
// 1. 定义dp数组:dp[i] 表示凑成金额i所需的最少硬币数
int[] dp = new int[amount + 1];
// 2. 初始化dp数组:
// - 填充一个比最大可能值(amount,全用1元硬币)大的数(amount+1),表示“初始不可达”
// - 这样后续取min时,不会误把初始值当成有效解
Arrays.fill(dp, amount + 1);
// 3. 基准条件:凑成金额0需要0个硬币
dp[0] = 0;
// 4. 遍历所有金额(从1到目标金额),逐个计算最少硬币数
for (int i = 1; i <= amount; i++) {
// 5. 遍历每种硬币面额,尝试用该硬币凑当前金额i
for (int coin : coins) {
// 6. 只有当硬币面额≤当前金额时,才有可能用该硬币凑数
if (coin <= i) {
// 7. 状态转移方程:
// - 凑金额i的最少硬币数 = min(当前dp[i], 凑金额(i-coin)的最少硬币数 + 1个当前硬币)
// - 比如凑11元,用5元硬币的话,等价于凑6元的最少硬币数+1个5元
dp[i] = Math.min(dp[i], dp[i - coin] + 1);
}
}
}
// 8. 最终结果判断:
// - 若dp[amount]仍大于amount,说明无法凑出(初始值没被更新),返回-1
// - 否则返回dp[amount](最少硬币数)
return dp[amount] > amount ? -1 : dp[amount];
}
股票的最大利润
题目1:给定一个数组表示股票每日价格,只能进行一次买卖操作,求最大利润。
思路:贪心策略 ——“低买高卖”,遍历过程中始终记录到当前天为止的最低价格,并计算 “当前天卖出能获得的利润”,最终取所有利润的最大值。
时间复杂度 O (n),空间复杂度 O (1)。
/**
* 股票买卖问题1:只能进行一次买入+卖出操作,计算最大利润
* 核心逻辑:遍历过程中记录当前最低买入价,同时计算当前卖出的利润,更新最大利润
* @param prices 每日股票价格数组(如[7,1,5,3,6,4])
* @return 最大利润;若无法盈利(价格持续下跌),返回0
*/
public int maxProfit(int[] prices) {
// 初始化当前遍历到的最低价格(初始为整型最大值,确保第一个价格能更新它)
int minPrice = Integer.MAX_VALUE;
// 初始化最大利润(初始为0,无盈利时直接返回0)
int maxProfit = 0;
// 遍历每日价格
for (int price : prices) {
// 情况1:当前价格比记录的最低价格更低,更新最低买入价
if (price < minPrice) {
minPrice = price;
} else {
// 情况2:当前价格高于最低买入价,计算卖出利润,更新最大利润
// 利润 = 当前价格 - 最低买入价
maxProfit = Math.max(maxProfit, price - minPrice);
}
}
// 返回最大利润(无盈利则为0)
return maxProfit;
}
题目2:允许无限次买卖,求最大利润。
思路:贪心策略 ——“赚所有能赚的差价”,将连续上涨的行情拆分为多笔单次交易(如 1→5→6 的收益 = 5-1 + 6-5 = 6-1),累加所有正差价即为最大利润。时间复杂度 O (n),空间复杂度 O (1)。
/**
* 股票买卖问题2:允许无限次买入+卖出(但不能同时持有多笔,必须卖了才能再买),计算最大利润
* 核心逻辑:只要后一天价格高于前一天,就在前一天买入、后一天卖出,累加所有正收益
* @param prices 每日股票价格数组(如[7,1,5,3,6,4])
* @return 最大利润;若无法盈利,返回0
*/
public int maxProfit(int[] prices) {
// 初始化总利润
int profit = 0;
// 从第二天开始遍历(对比当天和前一天的价格)
for (int i = 1; i < prices.length; i++) {
// 若当天价格 > 前一天价格,累加该段收益(相当于前一天买、当天卖)
if (prices[i] > prices[i - 1]) {
profit += prices[i] - prices[i - 1];
}
}
// 返回总利润(无盈利则为0)
return profit;
}
版权声明:凡未经本网站书面授权,任何媒体、网站及个人不得转载、复制、重制、改动、展示或使用本网站的局部或全部的内容或服务,或在非本网站所属服务器上建立镜像。如果已转载,请自行删除。同时,我们保留进一步追究相关行为主体的法律责任的权利。我们希望与各媒体合作,签订著作权有偿使用许可合同,故转载方须书面/邮件申请,以待商榷。