16. 后端基础补充:JVM、Spring、网络与算法
十六、后端基础:从一次请求理解 JVM、Spring、网络与并发¶
后端基础容易学成一组互不相干的名词:进程、线程、JVM、Bean、反射、HTTP、线程池、限流。真实程序运行时,这些概念都位于同一条请求链上。
假设浏览器发起一个创建订单请求:
浏览器解析域名并建立连接
→ 请求经过负载均衡进入 Java 进程
→ Web Server 分配执行线程
→ Spring 找到 Controller 和 Service
→ Service 在线程池中并发查询库存与优惠
→ Repository 访问数据库
→ 结果沿原链路返回浏览器
沿着这条链可以提出一组具体问题:
- Java 服务为什么以进程存在,线程又在其中承担什么工作?
.java文件怎样变成 CPU 能执行的机器指令?- Spring 为什么能自动创建对象、注入依赖和生成事务代理?
- 请求切换线程后,Trace ID 为什么容易丢失?
- HTTP/2 已经支持多路复用,为什么服务仍可能被慢请求拖垮?
- QPS、并发数、线程池和数据库连接池之间有什么数量关系?
本章围绕这些问题逐层展开。阅读时先理解运行过程,再记术语。
16.1 程序如何获得运行空间¶
一、从可执行程序到进程¶
磁盘上的程序只是一组静态指令和数据。执行 java Main 时,操作系统为它创建进程,分配虚拟地址空间、文件描述符表和其他内核资源。
虚拟地址空间让不同进程可以使用相似的地址布局,同时由操作系统和硬件完成隔离。一个进程越界访问另一个进程的普通内存会被阻止,因此进程适合承载故障隔离和安全边界。
进程之间无法直接读取彼此的普通变量。需要协作时,要通过 Socket、管道、共享内存或消息队列等 IPC 机制交换数据。
二、为什么进程内还需要线程¶
如果整个 Java 服务只有一条执行流,一个请求等待数据库时,CPU 即使空闲也无法处理其他请求。线程让同一进程拥有多条可被调度的执行流:
Java Process
├── 共享:Heap、Metaspace、打开的 Socket、类信息
├── Thread A:Stack、Registers、Program Counter
├── Thread B:Stack、Registers、Program Counter
└── Thread C:Stack、Registers、Program Counter
共享堆使线程间传递对象很方便,也引入三类问题:
- 原子性:
count++包含读、加、写,多线程交错后可能丢更新。 - 可见性:线程 A 写入的值可能暂时停留在缓存中,线程 B 未立即观察到。
- 有序性:编译器和 CPU 可以在不破坏单线程语义的条件下重排指令。
锁、原子类、volatile 和并发容器分别约束这些问题。选择同步工具前,应先确认共享状态能否被消除。例如把请求数据限制在线程栈和不可变对象中,通常比给大量共享变量加锁更简单。
三、一次线程切换发生了什么¶
CPU 核心数量有限,可运行线程可能很多。操作系统暂停线程 A 时,需要保存寄存器和程序计数器,再恢复线程 B 的上下文:
线程数量持续增加会带来更多栈内存、调度和缓存失效成本。线程池的意义之一,就是控制线程数量并复用已有线程。
四、Java 平台线程与虚拟线程¶
传统 HotSpot 平台线程通常一一映射到 Linux 内核线程,阻塞 I/O 会让对应内核线程等待。Java 虚拟线程由 JVM 调度到少量载体线程上:
Virtual Thread 1 ─┐
Virtual Thread 2 ─┼→ Carrier Thread A → OS Thread
Virtual Thread 3 ─┘
Virtual Thread 4 ───→ Carrier Thread B → OS Thread
虚拟线程适合大量彼此独立、主要等待 I/O 的任务。它降低了“每个阻塞请求占一个重量级线程”的成本,但没有消除下游容量限制:
- 数据库仍只有有限连接。
- 第三方接口仍有 QPS 配额。
- CPU 密集代码仍要占用载体线程。
synchronized、本地方法和部分阻塞操作可能造成 Pinning。JDK 24 之前,虚拟线程在synchronized块或方法内阻塞时会被固定(Pinning)到载体线程,无法让出;JEP 491 在 JDK 24 中移除了这一限制,synchronized内部的阻塞也可以正常卸载载体线程。排查旧版本上的 Pinning,仍应先确认线上 JDK 版本,再决定要不要把synchronized替换成ReentrantLock。
因此,引入虚拟线程后仍需要限流、连接池和超时。
五、CPU 密集与 I/O 密集¶
CPU 密集任务持续做计算,例如压缩、加密、图像处理。线程数通常接近 CPU 核心数,再增加线程只会制造调度开销。
I/O 密集任务大部分时间等待网络或磁盘,可以容纳更多并发任务。可用一个直觉公式估算线程数:
它只能提供起始值。真实阈值必须通过 CPU 利用率、上下文切换、队列等待和尾延迟校准。
16.2 Java 代码怎样变成机器执行¶
一、编译先生成字节码¶
javac 将 Java 源码编译为平台无关的 .class 字节码:
字节码是 JVM 指令,不是目标 CPU 的原生机器码。跨平台能力来自不同系统上的 JVM 都理解相同的 Class 文件格式。
二、JVM 启动的关键阶段¶
执行 java Main 后,可以按下面的顺序理解:
- 操作系统创建 JVM 进程。
- JVM 初始化运行时数据区、类加载器、GC 和核心线程。
- 类加载器定位并读取主类。
- 验证器检查字节码结构和类型安全。
- JVM 为静态字段分配空间并解析符号引用。
- 执行类初始化方法。
- 调用
main。
类的生命周期通常概括为:
Loading
→ Linking
├── Verification
├── Preparation
└── Resolution
→ Initialization
→ Using
→ Unloading
Preparation 阶段为静态字段分配内存并设置类型默认值;Initialization 阶段才执行代码中声明的静态赋值和静态代码块。
三、类加载器为什么分层¶
Java 使用分层类加载器管理不同来源的类:
常见的父委派过程是:当前加载器先询问父加载器,父加载器无法完成时再自己加载。这样可以避免应用代码伪造 java.lang.String 等核心类,也减少同一个基础类被重复加载。
在 JVM 中,类的身份由“全限定类名 + 定义它的 ClassLoader”共同决定。两个加载器各自加载同名 Class 文件,JVM 仍会把它们视为不同类型。这也是插件系统和应用服务器中出现 ClassCastException 的常见来源。
四、解释执行与 JIT 如何协作¶
解释器能立即执行字节码,启动快;逐条解释长期运行的热点循环则成本较高。JIT 根据运行时采样,把频繁执行的方法编译为优化后的机器码。
JIT 可以根据真实运行类型执行内联、消除部分边界检查和分配。但它的优化依赖运行时假设,类加载或类型分布变化时可能触发去优化。
Java 因而是混合执行模型:源码先编译为字节码,运行时由解释器与 JIT 共同完成执行。
五、运行时内存区域¶
理解 JVM 内存时,先区分线程共享与线程私有:
| 区域 | 是否共享 | 主要内容 |
|---|---|---|
| Heap | 共享 | 普通对象和数组 |
| Metaspace | 共享 | 类元数据等 |
| Java Stack | 线程私有 | 栈帧、局部变量、操作数栈 |
| PC Register | 线程私有 | 当前执行指令位置 |
| Native Method Stack | 线程私有 | 本地方法调用状态 |
一次方法调用会创建栈帧。对象通常位于堆中,局部变量可能保存对象引用。方法返回后栈帧弹出,但被其他引用持有的堆对象仍然存活。
排查内存问题时要先分类:
StackOverflowError:常见于递归过深。- Java Heap OOM:对象持续积累或堆容量不足。
- Metaspace OOM:动态生成或加载大量类,类加载器无法卸载。
- Direct Memory OOM:NIO、Netty 等直接内存使用失控。
面试30秒版本:javac 把源码编译成平台无关的字节码,JVM 启动后完成加载、验证、准备、解析、初始化,热点方法由解释器逐步移交给 JIT 编译为机器码,运行时假设失效会触发去优化。内存先分线程共享(Heap 放对象、Metaspace 放类元数据)和线程私有(Java Stack、PC Register、Native Method Stack);排查 OOM 先按现象分类——递归过深查 StackOverflowError,对象堆积查 Heap OOM,动态类过多查 Metaspace OOM,NIO/Netty 相关查 Direct Memory OOM——再用 Heap Dump、GC Log 或 JFR 定位具体对象和引用链。
16.3 Spring 为什么能管理业务对象¶
一、先理解容器解决的问题¶
没有容器时,Controller 需要自己创建 Service,Service 再创建 Repository 和客户端:
class OrderController {
private final OrderService service =
new OrderService(
new MySQLOrderRepository(),
new InventoryHttpClient()
);
}
这样会产生几个问题:
- 业务类绑定具体实现,测试时难以替换。
- 数据库、连接池和客户端可能被重复创建。
- 事务、日志、指标等横切逻辑散落在业务代码中。
- 配置变更需要修改对象创建过程。
Spring 容器把“对象如何创建、依赖谁、是否需要代理”集中管理。业务类只声明自己需要什么。
二、BeanDefinition 是对象的配方¶
组件扫描发现 @Component、@Service 等类型后,Spring 先注册 BeanDefinition。它描述 Bean 的类型、作用域、构造方式、依赖、初始化方法等信息。
BeanDefinition 还不是业务对象。容器可以在实例化前修改这些元数据,这为自动配置和框架扩展提供了入口。
三、Spring Boot 启动全链路¶
可以把 Spring Boot 启动理解为五个阶段:
更细的主线如下:
- 创建并配置
ApplicationContext。 - 读取配置文件、环境变量和启动参数。
- 扫描组件并加载满足条件的自动配置。
- 注册 BeanDefinition。
- 执行
BeanFactoryPostProcessor,允许修改对象配方。 - 实例化非懒加载单例。
- 解析构造器、字段或 Setter 依赖。
- 执行 Aware 接口和初始化前回调。
BeanPostProcessor可能返回代理对象。- 执行初始化方法。
- 刷新容器,启动 Web Server 并发布事件。
实际实现包含更多细节,但这条主线可以解释大多数启动问题。
四、一个 Bean 的生命周期¶
实例化
→ 属性填充/依赖注入
→ Aware 回调
→ BeanPostProcessor.before
→ @PostConstruct
→ InitializingBean / init-method
→ BeanPostProcessor.after
→ 可供业务使用
→ @PreDestroy / destroy-method
如果 Bean 需要 AOP,postProcessAfterInitialization 阶段可能返回代理对象。其他 Bean 注入到的通常是代理引用,方法调用经过代理后才执行事务、缓存或鉴权逻辑。
五、@Transactional 为什么有时失效¶
典型声明式事务通过代理拦截方法调用:
同一个对象内部使用 this.otherMethod() 调用事务方法时,调用没有经过代理,事务拦截器就没有机会介入。这类问题的本质是调用路径绕过代理。
其他常见原因包括:
- 方法可见性或代理方式不满足框架要求。
- 异常被业务代码捕获后没有继续抛出。
- 抛出的异常不符合当前回滚规则。
- 使用了错误的事务管理器或数据源。
- 异步线程脱离原事务上下文。
面试30秒版本:Spring AOP 靠代理拦截方法调用,其他 Bean 拿到的是代理对象,方法调用先经过代理再到目标;声明式事务、缓存、鉴权都是这套机制的具体应用。@Transactional 失效的根因几乎都是"没走代理"或"回滚条件不满足":this.xxx() 自调用绕过代理、方法非 public、异常被吞掉或类型不在回滚规则内、事务管理器/数据源配错、异步线程脱离了原事务。排查顺序是先确认调用是否经过代理,再核对回滚规则。
六、构造器注入为什么更稳¶
@Service
public class OrderService {
private final OrderRepository repository;
private final InventoryClient inventoryClient;
public OrderService(
OrderRepository repository,
InventoryClient inventoryClient
) {
this.repository = repository;
this.inventoryClient = inventoryClient;
}
}
构造器把必要依赖变成对象创建条件:
- 缺依赖时对象无法构造。
- 字段可以声明为
final。 - 单元测试可直接传入 Fake 或 Mock。
- 不需要容器也能理解类的依赖关系。
七、@Autowired 和 @Resource 如何查找依赖¶
@Autowired 默认从类型出发:
@Resource 常见解析顺序是先根据名称匹配,再回退到类型。两者主要差异是候选解析规则。
依赖冲突时不要依靠偶然的 Bean 名称。使用明确的 @Qualifier,或者进一步抽象接口职责,能让配置意图更清楚。
八、反射在 Spring 中做了什么¶
反射允许运行期读取类、字段、方法、构造器和注解。Spring 可以借此:
- 识别组件和配置注解。
- 选择构造器并创建对象。
- 调用生命周期方法。
- 读取方法上的事务、缓存和权限元数据。
- 将配置属性绑定到对象。
反射提供灵活性,但会减少编译期约束。框架通常缓存已解析的构造器、字段和注解元数据,避免每次请求重复扫描。
九、SPI 与 Starter 的关系¶
Java SPI 解决实现发现:
Spring Boot Starter 的范围更大:
组件需要被多个服务复用,并包含统一配置、客户端、错误处理、指标或安全规则时,Starter 能降低接入成本。单一项目内部的一小段业务复用,普通 Module 已经足够。
16.4 请求切换线程后,Trace 为什么会断¶
一、Trace Context 与日志 MDC 是两层¶
分布式追踪通过 HTTP Header、gRPC Metadata 或 MQ 消息属性跨进程传播,例如 W3C Trace Context:
进入 Java 进程后,Tracing SDK 将 Context 与当前执行流关联;日志框架经常把 trace_id 放入 MDC。MDC 基于 ThreadLocal,一旦任务切换到线程池中的另一个线程,原线程的 MDC 不会自动出现。
二、正确传播过程¶
提交任务时捕获当前上下文,执行前恢复,结束后清理:
Map<String, String> captured = MDC.getCopyOfContextMap();
executor.submit(() -> {
Map<String, String> previous = MDC.getCopyOfContextMap();
try {
if (captured != null) {
MDC.setContextMap(captured);
} else {
MDC.clear();
}
runTask();
} finally {
if (previous != null) {
MDC.setContextMap(previous);
} else {
MDC.clear();
}
}
});
保存并恢复 Worker 原来的上下文,比执行后只调用 clear() 更完整。生产项目应封装 TaskDecorator、上下文感知 Executor,或使用 OpenTelemetry Instrumentation 统一处理。
三、两种典型故障¶
链路断裂:
表现为同一次请求在 Trace UI 中断成两段。
上下文污染:
Worker 执行请求 A 后没有清理 MDC,随后执行请求 B,B 的日志仍携带 A 的 Trace ID。线程池线程长期复用,所以这种污染可能间歇出现,排查非常困难。
四、MQ 场景如何传播¶
生产者把 Trace Context 写入消息 Header,消费者提取后创建 Consumer Span。业务提交成功后再确认消息;重试消费应创建新的 Span,并通过 Link 或父子关系关联原始生产链路。
Trace ID 用于关联一次调用,业务幂等键用于识别一次业务动作。两者不能互换:同一个业务请求发生重试时,可能产生多个 Span,但仍应共享同一个幂等键。
16.5 浏览器请求如何到达 Java 服务¶
前几节讲的是请求进入 Java 进程之后发生的事情:线程怎样调度、字节码怎样执行、Spring 怎样管理对象、Trace 怎样跨线程传播。这一节往前退一步,看请求在到达 Java 进程之前经过了哪些网络环节——DNS、TCP、TLS、HTTP 版本演进——它们和 JVM 内部机制关系不大,但共同决定了本章开头那次“创建订单请求”从浏览器出发的第一段旅程,也是排查“请求根本没到应用”这类问题时最先要看的地方。
一、第一步:先寻找已有结果¶
浏览器不会立刻访问网络。它会先检查:
- 内存缓存和磁盘缓存。
- Service Worker。
- HSTS 与已有连接。
- DNS 缓存。
缓存命中后,部分步骤可以被跳过。排查前端“为什么没有请求到后端”时,缓存和 Service Worker 是常见原因。
二、DNS 把域名变成地址¶
浏览器需要把域名解析成 IP。查询可能经过浏览器缓存、操作系统缓存、本地网络递归解析器,最终访问权威 DNS。
www.example.com
→ 浏览器/OS 缓存
→ Recursive Resolver
→ Root / TLD / Authoritative DNS
→ A / AAAA / CNAME 结果
DNS 返回多个地址时,客户端或上游流量系统可以选择就近节点。DNS TTL 决定缓存多久;TTL 太长会让故障地址退出缓慢,太短会增加查询压力。
三、TCP 与 TLS 分别解决什么¶
HTTP/1.1 和 HTTP/2 通常运行在 TCP 上。TCP 负责可靠、有序字节流。HTTPS 随后通过 TLS 完成:
- 协商协议版本与密码套件。
- 服务端发送证书。
- 客户端验证证书链、域名与有效期。
- 双方建立会话密钥。
- 后续应用数据加密传输。
TLS 既提供机密性,也验证服务端身份。握手失败时,应从证书、SNI、系统时间、密码套件和代理终止位置排查。
四、请求经过哪些基础设施¶
每一层承担不同职责:
| 组件 | 主要职责 |
|---|---|
| CDN | 静态内容缓存、就近访问、基础防护 |
| 反向代理 | TLS 终止、路由、Header 处理 |
| 负载均衡 | 在健康实例间分配请求 |
| 服务发现 | 提供动态实例地址 |
| 应用服务 | 业务校验与流程编排 |
| Redis / DB | 缓存副本与权威数据 |
五、正向代理和反向代理¶
正向代理站在客户端一侧,代表客户端访问外部资源;目标服务看到的连接来源可能是代理。
反向代理站在服务端一侧,代表一组后端实例接收外部请求;客户端通常不知道请求最终落在哪个应用实例。
负载均衡是一种流量选择能力,可以位于反向代理、四层网络设备或客户端内部。服务发现只负责告诉调用方“有哪些可用地址”,并不自动等于负载均衡。
六、HTTP 三个版本的演进¶
HTTP/1.1¶
HTTP/1.1 支持持久连接,但同一连接上的请求与响应调度能力有限。浏览器通常建立多个 TCP 连接增加并发,连接数量和握手成本随之上升。
HTTP/2¶
HTTP/2 把数据拆成二进制 Frame,并在一条连接上承载多个 Stream:
TCP Connection
├── Stream 1: HEADERS + DATA
├── Stream 3: HEADERS + DATA
└── Stream 5: HEADERS + DATA
它还使用 HPACK 压缩重复头部。多个 Stream 仍共享 TCP 字节流;TCP 丢包后,后续数据需要等待重传,因此传输层队头阻塞仍可能影响多个 Stream。
HTTP/3¶
HTTP/3 基于 QUIC。不同 Stream 的丢包恢复相对独立,并支持连接迁移,移动设备切换网络时更友好。代价是代理、监控和网络环境需要支持 UDP/QUIC。
七、浏览器收到响应之后¶
浏览器根据状态码和响应头处理缓存、重定向、Cookie 与内容类型;随后解析 HTML,构建 DOM 和 CSSOM,加载依赖资源,执行 JavaScript,完成布局与绘制。
后端性能分析通常关注 TTFB 之前的链路;完整用户体验还受到静态资源、前端执行和渲染影响。
16.6 QPS、并发、线程池和下游容量¶
一、QPS 与并发是两个维度¶
QPS 描述单位时间到达多少请求,并发描述同一时刻有多少请求正在系统中。稳定状态下可用 Little's Law 建立直觉:
示例:
如果下游变慢到 1 秒,在 QPS 不变时平均在途请求会升到 1000。系统可能在流量没有增加的情况下被延迟拖垮。
二、线程池的四个核心问题¶
看线程池参数时,先回答:
- 同时允许多少任务执行?
- 暂时执行不了的任务放在哪里?
- 队列满了怎么办?
- 任务最长允许执行多久?
无界队列看似减少拒绝,实际上会把故障转化为排队时间和内存增长。任务在队列里等待 30 秒后再执行,即使业务只允许 2 秒,结果也已经没有价值。
三、线程池不能突破下游上限¶
假设线程池允许 500 个并发任务,数据库连接池只有 50 个连接:
扩大线程池不会提高数据库吞吐,反而会增加排队和内存。容量设计需要同时考虑:
- 应用 Worker/虚拟线程数量。
- HTTP/gRPC 出站连接数。
- 数据库连接池。
- Redis 和 MQ 客户端连接。
- 下游配额与限流。
四、限流与并发限制的区别¶
限流控制进入速率,例如每秒 1000 个请求;并发限制控制同时执行的请求数,例如最多 200 个。
| 机制 | 控制对象 | 典型用途 |
|---|---|---|
| 固定窗口 | 时间窗口内计数 | 简单粗粒度保护 |
| 滑动窗口 | 最近一段时间的真实请求量 | 风控、严格配额 |
| 令牌桶 | 平均速率和可接受突发 | 通用 API |
| 漏桶 | 稳定输出速率 | 保护匀速下游 |
| Semaphore | 同时在途任务数 | 数据库、模型和外部 API |
五、令牌桶怎样允许突发¶
令牌以固定速率进入桶,桶有容量上限。请求必须获得令牌才能通过:
固定窗口在窗口边界可能出现两倍突发:上一窗口末尾和下一窗口开头分别打满配额。滑动窗口或令牌桶能更平滑地控制流量。
六、超时、重试、熔断和降级如何组合¶
推荐顺序:
重试会增加真实下游流量。原始请求 1000 QPS,每次最多重试两次,最坏可能制造 3000 次调用。所有重试必须受总 Deadline、最大次数和随机抖动限制。
七、怎样做容量测试¶
压测应逐级增加并发,并同时观察:
- 吞吐是否继续增长。
- P50、P95、P99 是否出现拐点。
- 线程池队列和拒绝数量。
- 数据库连接池等待时间。
- CPU、内存、GC 和上下文切换。
- 下游错误率与重试放大倍数。
系统饱和的典型信号是吞吐基本不再增长,尾延迟和错误率快速上升。稳定系统应在饱和点前开始限流,并在压力下降后自行恢复。
面试30秒版本:QPS 是到达速率,并发是同时在途的请求数,稳态下满足"平均在途请求数 ≈ QPS × 平均响应时间"(Little's Law),所以下游变慢会在流量不变的情况下把在途请求数顶上去。线程池扩容不能突破下游容量——连接池、第三方配额、CPU 核数才是真实上限,队列只能延后问题,无界队列会把故障变成堆积的内存和过期请求。稳定链路的组合拳是:入口限流控制进入速率,有界并发和有界队列控制同时处理量,每个下游设子 Timeout,只对幂等和瞬时错误重试并带总预算,持续失败就熔断降级。
16.7 用算法训练"状态如何移动"¶
算法学习的价值不只在记模板。滑动窗口、队列和单调栈分别训练三种状态组织方式:
- 滑动窗口:维护一段连续区间的不变量。
- BFS 队列:按层次和先后顺序推进状态。
- 单调栈:让尚未确定答案的元素等待未来边界。
一、滑动窗口:最长无重复子串¶
给定字符串:
目标是找到最长的、不含重复字符的连续子串。
第一步:暴力方法为什么慢¶
枚举每个起点和终点,再检查区间是否重复,最坏需要三层工作,复杂度可到 O(n³)。重复检查浪费了前一个窗口已经获得的信息。
第二步:维护窗口不变量¶
窗口 [left, right] 始终满足“内部没有重复字符”。右指针加入新字符时:
- 新字符没在当前窗口出现:窗口继续扩大。
- 新字符在窗口内出现:左指针跳到上次位置后一格。
第三步:手工运行¶
| right | 字符 | 上次位置 | left 更新 | 当前窗口 | 最大长度 |
|---|---|---|---|---|---|
| 0 | a | 无 | 0 | a |
1 |
| 1 | b | 无 | 0 | ab |
2 |
| 2 | c | 无 | 0 | abc |
3 |
| 3 | a | 0 | 1 | bca |
3 |
| 4 | b | 1 | 2 | cab |
3 |
| 5 | c | 2 | 3 | abc |
3 |
| 6 | b | 4 | 5 | cb |
3 |
| 7 | b | 6 | 7 | b |
3 |
第四步:为什么 left 不能后退¶
对于字符串 abba,处理最后一个 a 时,它的上次位置是 0,但当前 left 已经在 2。如果直接写 left = last[a] + 1,左指针会退回 1,窗口重新包含重复的 b。
正确更新:
第五步:代码¶
int longestUniqueSubstring(String s) {
Map<Character, Integer> last = new HashMap<>();
int left = 0;
int answer = 0;
for (int right = 0; right < s.length(); right++) {
char ch = s.charAt(right);
Integer previous = last.get(ch);
if (previous != null && previous >= left) {
left = previous + 1;
}
last.put(ch, right);
answer = Math.max(answer, right - left + 1);
}
return answer;
}
左右指针都只向前移动,总复杂度为 O(n)。
二、BFS:二叉树层序遍历¶
层序遍历要求先访问第 0 层,再访问第 1 层。队列天然保存“先发现、先处理”的顺序。
队列变化¶
每轮开始时记录 size = queue.size()。这一刻队列中的节点恰好属于当前层;处理过程中加入的孩子留给下一轮。
List<List<Integer>> levelOrder(TreeNode root) {
List<List<Integer>> result = new ArrayList<>();
if (root == null) {
return result;
}
Deque<TreeNode> queue = new ArrayDeque<>();
queue.offer(root);
while (!queue.isEmpty()) {
int size = queue.size();
List<Integer> level = new ArrayList<>(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);
}
result.add(level);
}
return result;
}
每个节点入队、出队各一次,时间复杂度 O(n);空间复杂度取决于最大层宽。
常见错误是在 for 循环中动态读取 queue.size(),导致新加入的下一层节点也被当前轮处理。
三、单调栈:0/1 矩阵最大矩形¶
矩阵问题先转化为每一行的柱状图:
当前格为 1,高度延续上一行加一;为 0,高度归零。问题转化为:对每一行求柱状图最大矩形。
为什么需要单调栈¶
某根柱子的最大矩形宽度,要等左右两侧第一次出现更低柱子时才能确定。单调递增栈保存“右边界还没出现”的柱子下标。
处理高度:
遇到高度 2 时,前面的 6 和 5 都比它高:
弹出下标 mid 后:
height = heights[mid]
right = currentIndex
left = stack.peek()
width = right - left - 1
area = height × width
哨兵简化边界¶
在数组首尾加入高度 0:
开头哨兵让栈始终有左边界,末尾哨兵强制所有剩余柱子出栈。
int largestRectangleArea(int[] heights) {
int[] h = new int[heights.length + 2];
System.arraycopy(heights, 0, h, 1, heights.length);
Deque<Integer> stack = new ArrayDeque<>();
stack.push(0);
int answer = 0;
for (int right = 1; right < h.length; right++) {
while (h[right] < h[stack.peek()]) {
int mid = stack.pop();
int left = stack.peek();
int width = right - left - 1;
answer = Math.max(answer, h[mid] * width);
}
stack.push(right);
}
return answer;
}
每根柱子最多入栈和出栈一次,单行复杂度为 O(n);处理 m × n 矩阵的总复杂度为 O(mn)。
16.8 把知识从"知道"变成"会用"¶
每个机制可以用同一个学习框架检查:
一、画出对象和边界¶
- 线程共享哪些内存,私有哪些状态?
- Spring 代理位于调用方和目标对象之间的哪里?
- HTTP 请求经过哪些网络节点?
- 线程池、连接池和下游配额分别限制什么?
二、写出最小可运行例子¶
- 使用两个线程制造一次丢更新。
- 写一个 BeanPostProcessor 观察生命周期。
- 用
curl -v查看 DNS、TLS 和 HTTP 响应头。 - 逐级增加线程池并发,观察数据库连接等待。
三、主动制造失败¶
- 删除
MDC.clear(),观察线程复用后的 Trace 污染。 - 在事务方法中使用
this自调用,观察事务是否生效。 - 把线程池队列改成无界,模拟下游延迟升高。
- 给滑动窗口使用错误的
left更新,在abba上验证结果。
四、使用证据验证结论¶
| 问题 | 验证工具 |
|---|---|
| JVM 线程与阻塞 | jstack、JFR、async-profiler |
| 内存与 GC | GC Log、Heap Dump、JFR |
| Spring Bean 与代理 | Actuator、启动日志、断点、BeanFactory |
| 网络链路 | curl -v、浏览器 Network、tcpdump |
| 线程池和连接池 | Micrometer、Actuator Metrics |
| SQL 性能 | Slow Log、EXPLAIN ANALYZE |
| 算法复杂度 | 大输入、Profiler、边界用例 |
真正掌握一个知识点,需要能够解释它解决什么问题、画出运行过程、指出失败条件,并用工具验证。到这一步,定义会自然成为理解的结果。