跳转至

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 时,操作系统为它创建进程,分配虚拟地址空间、文件描述符表和其他内核资源。

flowchart LR A["磁盘上的程序<br/>java + classpath"] --> B["操作系统创建进程"] B --> C["独立虚拟地址空间"] B --> D["文件描述符表"] B --> E["线程与调度信息"] B --> F["权限与资源限制"]

虚拟地址空间让不同进程可以使用相似的地址布局,同时由操作系统和硬件完成隔离。一个进程越界访问另一个进程的普通内存会被阻止,因此进程适合承载故障隔离和安全边界。

进程之间无法直接读取彼此的普通变量。需要协作时,要通过 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

共享堆使线程间传递对象很方便,也引入三类问题:

  1. 原子性count++ 包含读、加、写,多线程交错后可能丢更新。
  2. 可见性:线程 A 写入的值可能暂时停留在缓存中,线程 B 未立即观察到。
  3. 有序性:编译器和 CPU 可以在不破坏单线程语义的条件下重排指令。

锁、原子类、volatile 和并发容器分别约束这些问题。选择同步工具前,应先确认共享状态能否被消除。例如把请求数据限制在线程栈和不可变对象中,通常比给大量共享变量加锁更简单。

三、一次线程切换发生了什么

CPU 核心数量有限,可运行线程可能很多。操作系统暂停线程 A 时,需要保存寄存器和程序计数器,再恢复线程 B 的上下文:

sequenceDiagram participant A as Thread A participant K as OS Scheduler participant B as Thread B A->>K: 时间片耗尽或发生阻塞 K->>K: 保存 A 的寄存器与 PC K->>K: 选择可运行的 B K->>B: 恢复 B 的上下文 B->>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 核心数 × (1 + 等待时间 / 计算时间)

它只能提供起始值。真实阈值必须通过 CPU 利用率、上下文切换、队列等待和尾延迟校准。


16.2 Java 代码怎样变成机器执行

一、编译先生成字节码

javac 将 Java 源码编译为平台无关的 .class 字节码:

flowchart LR A["OrderService.java"] -->|javac| B["OrderService.class"] B --> C["Class Loader"] C --> D["字节码验证"] D --> E["解释器"] E -->|发现热点代码| F["JIT 编译器"] F --> G["当前机器的机器码"]

字节码是 JVM 指令,不是目标 CPU 的原生机器码。跨平台能力来自不同系统上的 JVM 都理解相同的 Class 文件格式。

二、JVM 启动的关键阶段

执行 java Main 后,可以按下面的顺序理解:

  1. 操作系统创建 JVM 进程。
  2. JVM 初始化运行时数据区、类加载器、GC 和核心线程。
  3. 类加载器定位并读取主类。
  4. 验证器检查字节码结构和类型安全。
  5. JVM 为静态字段分配空间并解析符号引用。
  6. 执行类初始化方法。
  7. 调用 main

类的生命周期通常概括为:

Loading
→ Linking
   ├── Verification
   ├── Preparation
   └── Resolution
→ Initialization
→ Using
→ Unloading

Preparation 阶段为静态字段分配内存并设置类型默认值;Initialization 阶段才执行代码中声明的静态赋值和静态代码块。

三、类加载器为什么分层

Java 使用分层类加载器管理不同来源的类:

Bootstrap ClassLoader
Platform ClassLoader
Application ClassLoader
自定义 ClassLoader

常见的父委派过程是:当前加载器先询问父加载器,父加载器无法完成时再自己加载。这样可以避免应用代码伪造 java.lang.String 等核心类,也减少同一个基础类被重复加载。

在 JVM 中,类的身份由“全限定类名 + 定义它的 ClassLoader”共同决定。两个加载器各自加载同名 Class 文件,JVM 仍会把它们视为不同类型。这也是插件系统和应用服务器中出现 ClassCastException 的常见来源。

四、解释执行与 JIT 如何协作

解释器能立即执行字节码,启动快;逐条解释长期运行的热点循环则成本较高。JIT 根据运行时采样,把频繁执行的方法编译为优化后的机器码。

冷代码:解释执行
    ↓ 运行次数增加
热点探测:收集分支、类型和调用频率
JIT:生成优化机器码
    ↓ 假设失效
Deoptimization:退回较通用执行路径

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 的类型、作用域、构造方式、依赖、初始化方法等信息。

flowchart LR A["@Service OrderService"] --> B["扫描与解析"] B --> C["BeanDefinition"] C --> D["BeanFactory"] D --> E["OrderService Bean"]

BeanDefinition 还不是业务对象。容器可以在实例化前修改这些元数据,这为自动配置和框架扩展提供了入口。

三、Spring Boot 启动全链路

可以把 Spring Boot 启动理解为五个阶段:

flowchart TD A["1. 准备环境<br/>配置、Profile、PropertySource"] --> B["2. 建立容器<br/>ApplicationContext"] B --> C["3. 收集对象配方<br/>BeanDefinition 与自动配置"] C --> D["4. 创建 Bean<br/>实例化、注入、代理、初始化"] D --> E["5. 启动应用<br/>Web Server、事件、Runner"]

更细的主线如下:

  1. 创建并配置 ApplicationContext
  2. 读取配置文件、环境变量和启动参数。
  3. 扫描组件并加载满足条件的自动配置。
  4. 注册 BeanDefinition。
  5. 执行 BeanFactoryPostProcessor,允许修改对象配方。
  6. 实例化非懒加载单例。
  7. 解析构造器、字段或 Setter 依赖。
  8. 执行 Aware 接口和初始化前回调。
  9. BeanPostProcessor 可能返回代理对象。
  10. 执行初始化方法。
  11. 刷新容器,启动 Web Server 并发布事件。

实际实现包含更多细节,但这条主线可以解释大多数启动问题。

四、一个 Bean 的生命周期

实例化
→ 属性填充/依赖注入
→ Aware 回调
→ BeanPostProcessor.before
→ @PostConstruct
→ InitializingBean / init-method
→ BeanPostProcessor.after
→ 可供业务使用
→ @PreDestroy / destroy-method

如果 Bean 需要 AOP,postProcessAfterInitialization 阶段可能返回代理对象。其他 Bean 注入到的通常是代理引用,方法调用经过代理后才执行事务、缓存或鉴权逻辑。

五、@Transactional 为什么有时失效

典型声明式事务通过代理拦截方法调用:

sequenceDiagram participant C as Controller participant P as OrderService Proxy participant T as TransactionManager participant S as OrderService Target C->>P: createOrder() P->>T: begin P->>S: createOrder() S-->>P: result / exception alt success P->>T: commit else rollback rule matched P->>T: rollback end P-->>C: result

同一个对象内部使用 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 默认从类型出发:

查找 OrderRepository 类型候选
→ 只有一个:注入
→ 多个:结合 @Qualifier、@Primary、名称等消歧
→ 没有且 required=true:启动失败

@Resource 常见解析顺序是先根据名称匹配,再回退到类型。两者主要差异是候选解析规则。

依赖冲突时不要依靠偶然的 Bean 名称。使用明确的 @Qualifier,或者进一步抽象接口职责,能让配置意图更清楚。

八、反射在 Spring 中做了什么

反射允许运行期读取类、字段、方法、构造器和注解。Spring 可以借此:

  • 识别组件和配置注解。
  • 选择构造器并创建对象。
  • 调用生命周期方法。
  • 读取方法上的事务、缓存和权限元数据。
  • 将配置属性绑定到对象。

反射提供灵活性,但会减少编译期约束。框架通常缓存已解析的构造器、字段和注解元数据,避免每次请求重复扫描。

九、SPI 与 Starter 的关系

Java SPI 解决实现发现:

调用方定义接口
→ 扩展方实现接口
→ META-INF/services 声明实现类
→ ServiceLoader 在运行时加载

Spring Boot Starter 的范围更大:

依赖聚合
→ AutoConfiguration 注册
→ Conditional 条件判断
→ ConfigurationProperties 配置绑定
→ 创建客户端、拦截器和指标 Bean

组件需要被多个服务复用,并包含统一配置、客户端、错误处理、指标或安全规则时,Starter 能降低接入成本。单一项目内部的一小段业务复用,普通 Module 已经足够。


16.4 请求切换线程后,Trace 为什么会断

一、Trace Context 与日志 MDC 是两层

分布式追踪通过 HTTP Header、gRPC Metadata 或 MQ 消息属性跨进程传播,例如 W3C Trace Context:

traceparent: 00-<trace-id>-<span-id>-01

进入 Java 进程后,Tracing SDK 将 Context 与当前执行流关联;日志框架经常把 trace_id 放入 MDC。MDC 基于 ThreadLocal,一旦任务切换到线程池中的另一个线程,原线程的 MDC 不会自动出现。

sequenceDiagram participant R as Request Thread participant Q as Executor Queue participant W as Worker Thread R->>R: MDC.traceId = abc R->>Q: submit(task) Q->>W: worker executes task Note over W: 线程复用,默认没有 abc

二、正确传播过程

提交任务时捕获当前上下文,执行前恢复,结束后清理:

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 统一处理。

三、两种典型故障

链路断裂:

HTTP Span
→ Service Span
→ 提交异步任务
→ 新线程创建了另一条 Trace

表现为同一次请求在 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 完成:

  1. 协商协议版本与密码套件。
  2. 服务端发送证书。
  3. 客户端验证证书链、域名与有效期。
  4. 双方建立会话密钥。
  5. 后续应用数据加密传输。

TLS 既提供机密性,也验证服务端身份。握手失败时,应从证书、SNI、系统时间、密码套件和代理终止位置排查。

四、请求经过哪些基础设施

sequenceDiagram participant B as Browser participant D as DNS participant C as CDN participant L as Load Balancer participant A as Spring Application participant R as Redis participant DB as MySQL B->>D: resolve domain D-->>B: address B->>C: HTTPS request C->>L: cache miss / dynamic request L->>A: select healthy instance A->>R: cache lookup alt cache miss A->>DB: SQL query DB-->>A: row A->>R: cache fill end A-->>B: response

每一层承担不同职责:

组件 主要职责
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 建立直觉:

平均在途请求数 ≈ QPS × 平均响应时间

示例:

QPS = 1000
平均响应时间 = 0.2 秒
平均在途请求数 ≈ 200

如果下游变慢到 1 秒,在 QPS 不变时平均在途请求会升到 1000。系统可能在流量没有增加的情况下被延迟拖垮。

二、线程池的四个核心问题

看线程池参数时,先回答:

  1. 同时允许多少任务执行?
  2. 暂时执行不了的任务放在哪里?
  3. 队列满了怎么办?
  4. 任务最长允许执行多久?
flowchart LR A[Incoming Tasks] --> B{Worker Available?} B -->|Yes| C[Run] B -->|No| D{Queue Has Capacity?} D -->|Yes| E[Bounded Queue] D -->|No| F[Reject / Caller Runs / Shed Load]

无界队列看似减少拒绝,实际上会把故障转化为排队时间和内存增长。任务在队列里等待 30 秒后再执行,即使业务只允许 2 秒,结果也已经没有价值。

三、线程池不能突破下游上限

假设线程池允许 500 个并发任务,数据库连接池只有 50 个连接:

500 个 Worker
→ 50 个获得 DB Connection
→ 450 个等待连接
→ 请求 Deadline 继续消耗
→ 超时后可能触发重试
→ 等待队列进一步增长

扩大线程池不会提高数据库吞吐,反而会增加排队和内存。容量设计需要同时考虑:

  • 应用 Worker/虚拟线程数量。
  • HTTP/gRPC 出站连接数。
  • 数据库连接池。
  • Redis 和 MQ 客户端连接。
  • 下游配额与限流。

四、限流与并发限制的区别

限流控制进入速率,例如每秒 1000 个请求;并发限制控制同时执行的请求数,例如最多 200 个。

机制 控制对象 典型用途
固定窗口 时间窗口内计数 简单粗粒度保护
滑动窗口 最近一段时间的真实请求量 风控、严格配额
令牌桶 平均速率和可接受突发 通用 API
漏桶 稳定输出速率 保护匀速下游
Semaphore 同时在途任务数 数据库、模型和外部 API

五、令牌桶怎样允许突发

令牌以固定速率进入桶,桶有容量上限。请求必须获得令牌才能通过:

补充速率:100 tokens/s
桶容量:200 tokens

空闲 2 秒后桶满
→ 可以短时间通过 200 个突发请求
→ 长期平均速率仍受 100/s 约束

固定窗口在窗口边界可能出现两倍突发:上一窗口末尾和下一窗口开头分别打满配额。滑动窗口或令牌桶能更平滑地控制流量。

六、超时、重试、熔断和降级如何组合

推荐顺序:

入口限流
→ 有界并发
→ 每个下游设置子 Timeout
→ 只对幂等且可重试错误退避重试
→ 持续失败触发熔断
→ 返回降级结果或快速失败

重试会增加真实下游流量。原始请求 1000 QPS,每次最多重试两次,最坏可能制造 3000 次调用。所有重试必须受总 Deadline、最大次数和随机抖动限制。

七、怎样做容量测试

压测应逐级增加并发,并同时观察:

  • 吞吐是否继续增长。
  • P50、P95、P99 是否出现拐点。
  • 线程池队列和拒绝数量。
  • 数据库连接池等待时间。
  • CPU、内存、GC 和上下文切换。
  • 下游错误率与重试放大倍数。

系统饱和的典型信号是吞吐基本不再增长,尾延迟和错误率快速上升。稳定系统应在饱和点前开始限流,并在压力下降后自行恢复。

面试30秒版本:QPS 是到达速率,并发是同时在途的请求数,稳态下满足"平均在途请求数 ≈ QPS × 平均响应时间"(Little's Law),所以下游变慢会在流量不变的情况下把在途请求数顶上去。线程池扩容不能突破下游容量——连接池、第三方配额、CPU 核数才是真实上限,队列只能延后问题,无界队列会把故障变成堆积的内存和过期请求。稳定链路的组合拳是:入口限流控制进入速率,有界并发和有界队列控制同时处理量,每个下游设子 Timeout,只对幂等和瞬时错误重试并带总预算,持续失败就熔断降级。


16.7 用算法训练"状态如何移动"

算法学习的价值不只在记模板。滑动窗口、队列和单调栈分别训练三种状态组织方式:

  • 滑动窗口:维护一段连续区间的不变量。
  • BFS 队列:按层次和先后顺序推进状态。
  • 单调栈:让尚未确定答案的元素等待未来边界。

一、滑动窗口:最长无重复子串

给定字符串:

abcabcbb

目标是找到最长的、不含重复字符的连续子串。

第一步:暴力方法为什么慢

枚举每个起点和终点,再检查区间是否重复,最坏需要三层工作,复杂度可到 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

正确更新:

left = max(left, last[ch] + 1)
第五步:代码
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 层。队列天然保存“先发现、先处理”的顺序。

        3
       / \
      9  20
        /  \
       15   7
队列变化
开始:[3]
处理第 0 层:[3]       → 加入 [9,20]
处理第 1 层:[9,20]    → 加入 [15,7]
处理第 2 层:[15,7]    → 结束

每轮开始时记录 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 1 0 0  →  1 0 1 0 0
1 0 1 1 1  →  2 0 2 1 1
1 1 1 1 1  →  3 1 3 2 2
1 0 0 1 0  →  4 0 0 3 0

当前格为 1,高度延续上一行加一;为 0,高度归零。问题转化为:对每一行求柱状图最大矩形。

为什么需要单调栈

某根柱子的最大矩形宽度,要等左右两侧第一次出现更低柱子时才能确定。单调递增栈保存“右边界还没出现”的柱子下标。

处理高度:

2, 1, 5, 6, 2, 3

遇到高度 2 时,前面的 6 和 5 都比它高:

弹出 6:右边界是当前 2,左边界是栈顶 5
弹出 5:右边界是当前 2,左边界是前一个更低的 1

弹出下标 mid 后:

height = heights[mid]
right = currentIndex
left = stack.peek()
width = right - left - 1
area = height × width
哨兵简化边界

在数组首尾加入高度 0:

0, 2, 1, 5, 6, 2, 3, 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、边界用例

真正掌握一个知识点,需要能够解释它解决什么问题、画出运行过程、指出失败条件,并用工具验证。到这一步,定义会自然成为理解的结果。