Java 基础体系 · 第 1/100 篇。示例统一以 Java 25 LTS 为语言和 JVM 基线;框架示例使用与其兼容的现代稳定版本。
Java 完整学习路线:从 Java 25 语言与 JVM 到 Spring、微服务和生产交付
Java 后端学习不应从“背 API”开始,而应沿着一条可验证的因果链推进:
语言语义
→ 编译器与字节码
→ JVM 执行、内存与并发
→ 对象模型与程序设计
→ 标准库与工程构建
→ Spring 应用生命周期
→ 数据库、事务与接口
→ 微服务通信与故障处理
→ 容器化、观测、容量、灰度和回滚
这条路线的目标不是掌握最多框架,而是能够解释一个程序从源代码到生产请求的完整过程:代码如何被编译,类如何被加载,线程如何看到共享数据,Spring 如何创建对象,服务如何访问数据库,网络故障如何传播,以及发布失败时如何恢复。
本文以 Java 25 LTS 为语言和 JDK 基线。具体框架版本仍需以项目的兼容性矩阵为准:JDK 支持、Spring Boot 支持、构建工具版本、数据库驱动版本和容器基础镜像必须作为一个整体验证。
一、先建立 Java 25 的版本边界
1. Java 25、JDK、JRE 和 JVM 不是同一个概念
- Java 语言定义语法、类型系统、继承、异常、泛型等规则。
- JVM定义类文件格式、指令执行、运行时数据区、线程、内存模型等。
- JDK包含 JVM、标准类库、编译器和诊断工具,例如
java、javac、jar、javadoc、jlink、jcmd。 - JRE是历史上的运行时概念,现代 JDK 发行版通常直接提供完整 JDK,而不再单独发布传统意义上的 JRE。
因此:
javac 负责:源代码 → .class 字节码
java 负责:启动 JVM → 加载类 → 执行字节码
JDK 工具负责:构建、打包、诊断和裁剪运行时
“安装了 Java”并不能说明使用的是哪个 JDK。必须检查:
java -version
javac -version
which java
which javac
Windows 可使用:
where.exe java
where.exe javac
如果 java -version 是 25,而 javac -version 是 21,构建过程可能产生难以解释的行为。工程中应尽量让编译器、测试运行器和生产运行时来自明确的 JDK 版本。
2. Java 25 的学习重点
Java 25 LTS 应优先掌握已经稳定的能力:
- 类、接口、枚举、注解、泛型和异常;
record;sealed class与sealed interface;instanceof模式匹配;switch模式匹配;- record pattern;
- Lambda、方法引用和 Stream;
- 文本块;
Optional、集合、并发工具和 I/O;- 模块系统;
- 虚拟线程;
try-with-resources;CompletableFuture;- JVM 内存模型和垃圾回收。
Java 25 也可能包含新的正式特性、预览特性和孵化特性。三者不能混同:
- 正式特性可以在不启用特殊开关的情况下使用,规范和 API 承诺更稳定。
- 预览特性必须使用
--enable-preview编译和运行,并且可能在后续版本修改或移除。 - 孵化特性通常用于验证 API 或实现方向,也不应直接作为生产基础。
例如,预览语法通常需要:
javac --release 25 --enable-preview Demo.java
java --enable-preview Demo
不能只在编译阶段启用预览而在运行阶段忘记启用。生产项目应明确记录是否依赖预览特性,因为升级 JDK 时需要重新验证源码、字节码和构建插件。
二、从一个 Java 程序追踪到 JVM
1. 一个最小但完整的程序
public class Main {
public static void main(String[] args) {
int total = add(2, 3);
System.out.println(total);
}
static int add(int a, int b) {
return a + b;
}
}
编译并运行:
javac --release 25 Main.java
java Main
预期输出:
5
--release 25 不只是把目标版本写成 25。它会同时约束:
- 生成的类文件版本;
- 可使用的 Java SE API;
- 编译器对目标平台 API 的检查。
例如当前 JDK 可能包含比 Java 25 更新的 API,但使用 --release 25 时,编译器不应允许源码依赖那些超出 Java 25 平台边界的 API。这样可以避免“本机能编译,目标环境运行失败”。
2. 源代码、字节码和类加载
javac 先将源码解析为语法树,再进行名称解析、类型检查、泛型检查和字节码生成。生成的 Main.class 不是机器码,而是 JVM 规范定义的类文件。
运行 java Main 时,大致经历:
启动 JVM
→ 创建主线程
→ 加载 Main 类
→ 验证、准备、解析类
→ 初始化静态状态
→ 查找并调用 main
→ 执行字节码
→ 退出并返回进程状态码
java Main 中不写 .class,因为 JVM 根据类名和类路径查找 Main.class。如果当前目录不在类路径中,可以显式指定:
java -cp out Main
其中 -cp out 表示从 out 目录查找类。
一个常见失败:
Error: Could not find or load main class Main
这通常不是 main 方法内部异常,而是以下路径问题之一:
- 类文件不在类路径中;
- 包名与目录不匹配;
- 启动时写了
Main,实际类名是com.example.Main; - 编译输出目录和运行时类路径不一致。
如果源码是:
package com.example;
public class Main {
public static void main(String[] args) {
System.out.println("ok");
}
}
应当这样编译和运行:
javac --release 25 -d out Main.java
java -cp out com.example.Main
-d out 会根据包名生成:
out/com/example/Main.class
3. 类初始化和静态状态
类被加载不等于类已经初始化。类初始化通常在首次主动使用时发生,例如:
class Config {
static final String VALUE = load();
static String load() {
System.out.println("load");
return "v1";
}
}
public class Main {
public static void main(String[] args) {
System.out.println("before");
System.out.println(Config.VALUE);
System.out.println(Config.VALUE);
}
}
输出中的 load 只应出现一次。静态初始化由 JVM 保证按类初始化规则执行,并且同一个类的初始化不会被多个线程并发重复执行。
但这不意味着所有静态可变字段都自动线程安全:
class Counter {
static int value;
}
类初始化安全只保证初始建立过程,不保证之后的 value++ 是原子的。
三、Java 语言核心:类型、对象和控制流
1. 基本类型和引用类型
Java 类型分为基本类型和引用类型。
基本类型包括:
byte short int long
float double
char
boolean
变量保存基本值;引用类型变量保存对对象的引用。下面的赋值复制的是引用,不是对象:
class Box {
int value;
}
Box a = new Box();
a.value = 10;
Box b = a;
b.value = 20;
System.out.println(a.value); // 20
a 和 b 指向同一个对象。若希望得到独立对象,必须明确复制规则:
Box c = new Box();
c.value = a.value;
这一区别是理解集合、缓存、ORM 实体和并发共享状态的基础。
2. == 与 equals
对于基本类型,== 比较值;对于引用类型,== 比较是否为同一个对象。
String x = new String("java");
String y = new String("java");
System.out.println(x == y); // false
System.out.println(x.equals(y)); // true
值对象通常应重写 equals 和 hashCode,否则放入 HashSet 或作为 HashMap 键时,逻辑相等的对象可能被当作不同键。
record 自动生成基于组件的 equals、hashCode 和 toString:
public record UserId(long value) {
public UserId {
if (value <= 0) {
throw new IllegalArgumentException("value must be positive");
}
}
}
这里的紧凑构造器会在隐式字段赋值前执行校验。UserId 的不变量是:
value > 0
只要构造成功,这个条件就成立。若把校验放到业务代码中而不是构造边界,非法对象就可能在系统内部传播。
3. 不可变性和设计边界
不可变对象是指对象创建后,其可观察状态不再改变。不可变性通常需要同时满足:
- 字段不能被重新赋值;
- 构造时完成合法性检查;
- 不暴露可修改的内部对象;
- 不把可变输入直接保存为内部状态;
- 类不能被子类通过新方法破坏约束,或通过设计限制继承。
错误示例:
public final class Tags {
private final List<String> values;
public Tags(List<String> values) {
this.values = values; // 错误:保存了外部可变引用
}
public List<String> values() {
return values; // 错误:暴露内部可变列表
}
}
修正:
public final class Tags {
private final List<String> values;
public Tags(List<String> values) {
this.values = List.copyOf(values);
}
public List<String> values() {
return values;
}
}
List.copyOf 会建立不可修改的列表视图或副本语义;调用者不能通过原始列表后续修改 Tags 的状态,也不能通过返回值修改内部列表。
注意:不可变性只沿对象图传播一层。若列表元素本身可变,列表不可修改并不代表元素不可变:
List<Box> boxes = List.copyOf(input);
boxes.get(0).value = 99; // 如果 Box 可变,元素仍可变
4. 继承、接口与组合
继承表达“是一个”关系,组合表达“拥有一个”关系。
interface Pricer {
int price(int base);
}
final class DiscountPricer implements Pricer {
@Override
public int price(int base) {
return base * 90 / 100;
}
}
final class OrderService {
private final Pricer pricer;
OrderService(Pricer pricer) {
this.pricer = pricer;
}
int total(int base) {
return pricer.price(base);
}
}
OrderService 不继承 DiscountPricer,而是依赖 Pricer。这样可以替换实现、测试隔离,也避免把父类的状态和生命周期强行带入子类。
接口并不自动意味着良好抽象。若接口暴露了大量与调用者无关的方法,就形成胖接口;若接口只是为了模拟而拆成极细的类型,却没有稳定的业务边界,也会增加复杂度。
5. sealed 类型和穷尽性
当领域类型只有有限种类时,可以使用密封类型:
sealed interface PaymentResult
permits Paid, Rejected, Pending {
}
record Paid(String transactionId) implements PaymentResult {}
record Rejected(String reason) implements PaymentResult {}
record Pending(String requestId) implements PaymentResult {}
结合模式匹配:
static String describe(PaymentResult result) {
return switch (result) {
case Paid p -> "paid: " + p.transactionId();
case Rejected r -> "rejected: " + r.reason();
case Pending p -> "pending: " + p.requestId();
};
}
编译器可以根据 sealed 的许可子类型检查 switch 是否覆盖完整。若新增 Cancelled 却未更新 switch,编译阶段就会发现遗漏。
这比使用整数状态码更安全,因为状态集合从“约定”变成了类型系统的一部分。但密封类型适合状态集合相对稳定的领域;如果外部插件需要无限扩展,实现者就不应封死扩展点。
四、泛型、异常、集合和并发
1. 泛型主要提供编译期类型安全
List<String> names = new ArrayList<>();
names.add("Ada");
// names.add(1); // 编译错误
Java 泛型采用类型擦除的主要模型:运行时通常不能直接区分 List<String> 和 List<Integer>。因此以下代码不能成立:
// if (value instanceof List<String>) { } // 非法
通配符表达的是类型边界:
static double sum(List<? extends Number> values) {
double result = 0;
for (Number value : values) {
result += value.doubleValue();
}
return result;
}
static void addDefaults(List<? super Integer> values) {
values.add(0);
}
? extends Number 适合读取为 Number;? super Integer 适合写入 Integer。原因是前者的真实元素类型可能是 Integer、Double 等,安全读取共同父类型 Number;后者至少能接收 Integer,因为其元素类型是 Integer 的父类型。
2. 异常表达失败路径
受检异常和非受检异常表达不同的责任边界。无论采用哪一种,都不应捕获后静默忽略:
try {
Files.readString(path);
} catch (IOException e) {
throw new ConfigLoadException("cannot load " + path, e);
}
保留 cause 很重要。否则日志只能看到“配置加载失败”,看不到底层是权限、文件不存在还是编码错误。
try-with-resources 依赖 AutoCloseable:
try (BufferedReader reader = Files.newBufferedReader(path)) {
return reader.readLine();
}
关闭动作由语言结构保证执行;若读取和关闭都失败,关闭异常会作为 suppressed exception 保存,而不是简单覆盖主异常。
3. Stream 不是自动并行化
List<String> result = users.stream()
.filter(User::enabled)
.map(User::name)
.toList();
Stream 通常是惰性的:filter 和 map 在终端操作 toList 执行时才真正处理元素。Stream 管道不是集合本身,也不应在多个线程之间任意共享并修改外部变量。
错误示例:
List<Integer> output = new ArrayList<>();
numbers.parallelStream().forEach(output::add); // 竞态风险
并行流使用公共 ForkJoinPool,可能与应用其他任务争用线程。只有在数据规模、操作成本、无共享副作用和线程池隔离都明确时,才有理由使用。
4. Java 内存模型:可见性、原子性和有序性
线程安全不能只看“代码执行速度”。Java 内存模型重点约束三个问题:
- 原子性:操作是否不可分割;
- 可见性:一个线程的写入何时能被另一个线程看到;
- 有序性:编译器、CPU 和 JVM 是否允许重排。
count++ 不是原子操作,可以拆成:
读取 count
计算 count + 1
写回 count
两个线程都读取 0,分别计算 1,再分别写回 1,最终结果可能是 1 而不是 2。
AtomicInteger count = new AtomicInteger();
IntStream.range(0, 10_000)
.parallel()
.forEach(i -> count.incrementAndGet());
System.out.println(count.get()); // 10000
AtomicInteger 通过原子读改写机制解决了该计数场景,但它不适合替代所有锁。涉及多个字段必须保持一致时,需要锁、事务或其他更高层同步机制。
volatile 保证特定字段读写的可见性和一定的有序性,但不把复合操作变成原子操作:
volatile boolean stopped;
它适合停止标志:
while (!stopped) {
work();
}
但不适合:
volatile int count;
count++; // 仍然不是原子操作
五、JVM 核心:内存、执行和垃圾回收
1. JVM 运行时数据区
可以把一个 Java 进程抽象为:
进程
├── 堆:对象和数组的主要存储区域
├── 每线程栈:栈帧、局部变量、操作数栈
├── 程序计数器:当前线程执行位置
├── 类元数据区域:类结构、方法等运行时信息
└── 直接内存:部分 NIO、网络库和框架使用的堆外内存
堆通常由 GC 管理;线程栈随线程创建,栈帧随方法调用进入和退出。递归过深可能触发 StackOverflowError,对象过多或堆上限不足可能触发 OutOfMemoryError: Java heap space。
直接内存不等于堆。即使堆使用率不高,网络缓冲区、内存映射和本地库也可能导致进程被操作系统杀死或触发本地内存不足。因此生产诊断不能只看堆。
2. 可达性而非“变量作用域”决定回收
垃圾回收器判断对象是否仍从 GC Roots 可达。典型 Roots 包括活动线程栈中的引用、静态字段和 JNI 引用。
Object value = new Object();
value = null;
这只说明当前局部变量不再引用对象。如果其他对象、静态字段或线程仍然引用它,对象依然可达。
内存泄漏在 Java 中通常不是“忘记 free”,而是无意中长期保留引用,例如:
- 静态集合不断增长;
- 缓存没有淘汰;
- 监听器注册后没有注销;
- ThreadLocal 在线程池中残留大对象;
- 队列生产速度长期高于消费速度。
3. GC 的取舍
垃圾回收器的基本目标是回收不可达对象,但不同收集器在吞吐量、暂停时间、CPU 和内存开销之间取舍不同。不能仅凭“某收集器更先进”选择方案。
启动时可先记录实际配置:
java -Xlog:gc*:file=gc.log:time,uptime,level,tags \
-Xms512m -Xmx512m \
-jar app.jar
固定 -Xms 和 -Xmx 有助于减少堆动态扩缩带来的变量,但会提高进程启动后的内存保留。容器中还要考虑 JVM 对容器内存限制的识别,以及非堆和本地内存所需空间。
诊断时应同时观察:
jcmd <pid> VM.flags
jcmd <pid> GC.heap_info
jcmd <pid> Thread.print
jcmd <pid> VM.native_memory summary
VM.native_memory 需要在启动 JVM 时启用相应的 Native Memory Tracking,且会产生额外开销。命令结果应与进程 RSS、堆使用、线程数、直接内存和 GC 日志互相验证,而不能只根据单个数字下结论。
4. JIT 和“启动慢”的原因
JVM 初始执行解释器字节码,运行热点方法后可能由 JIT 编译为本地机器码。于是:
- 首次请求可能包含类加载、初始化、JIT 和缓存建立;
- 长时间运行后的吞吐可能不同于刚启动;
- 压测必须区分预热阶段和稳定阶段;
- AOT、类数据共享和应用预热会改变启动与内存权衡,但不应在没有测量时宣称收益。
JIT 优化必须保持 Java 语义。例如没有同步关系的普通字段仍可能存在可见性问题,不能因为“JIT 很聪明”而省略正确同步。
六、Java 25 工具链和可重复构建
1. 手工编译适合学习,不适合完整工程
一个常见目录:
demo/
├── pom.xml
├── src/main/java/com/example/App.java
├── src/test/java/com/example/AppTest.java
└── src/main/resources/application.properties
Maven 示例:
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
构建:
mvn clean verify
verify 不只是编译,通常还会执行测试以及绑定在生命周期中的校验插件。clean 删除前次构建输出,减少旧类文件混入结果的可能。
Gradle 应使用工具链声明,而不是假定执行 Gradle 的机器默认 JDK:
java {
toolchain {
languageVersion = JavaLanguageVersion.of(25)
}
}
执行:
./gradlew clean test
./gradlew build
工具链只能表达“需要 Java 25”,不自动保证所有插件、测试框架和三方库都兼容 Java 25。应在 CI 中实际执行完整构建。
2. jar 和可执行 JAR
假设编译结果在 out:
jar --create --file app.jar \
--main-class com.example.Main \
-C out .
运行:
java -jar app.jar
--main-class 会在清单中写入入口类。若 JAR 内含包名路径错误,或依赖没有被放入正确位置,启动仍会失败。
Spring Boot 的可执行 JAR 通常还包含依赖和启动加载器,其布局不等同于简单的 jar -c。因此应使用 Spring Boot Maven/Gradle 插件生成,而不是手工把所有依赖随意压入 JAR。
3. 可重复构建的含义
可重复构建不是“源码相同就一定得到二进制相同”,而是控制会影响输出的变量,使相同输入在相同工具链下得到可验证的一致结果。需要固定:
- JDK 发行版和版本;
- Maven/Gradle 版本;
- 插件版本;
- 依赖版本和仓库来源;
- 文件编码、时区和区域设置;
- 构建时间、文件顺序和归档元数据;
- 操作系统相关行为。
依赖应使用锁定或依赖收敛机制,CI 使用干净环境,并保存构建清单和制品哈希:
sha256sum app.jar
一个常见错误是只固定直接依赖,未控制传递依赖。升级一个库后,间接依赖可能变化,导致运行时行为或漏洞扫描结果改变。
七、Spring:从容器到 HTTP 请求的完整路径
1. IoC、DI 和 Bean
IoC 是控制反转:对象的创建和装配不再由业务对象自己决定,而交给容器管理。DI 是依赖注入:容器把对象需要的依赖传入构造器、字段或方法。
推荐构造器注入:
@Service
public class OrderService {
private final OrderRepository repository;
public OrderService(OrderRepository repository) {
this.repository = repository;
}
}
好处是依赖是显式的、对象创建后状态完整、容易进行单元测试。字段注入隐藏了依赖,也使脱离容器创建对象变得困难。
Spring Bean 生命周期可以概括为:
扫描或注册 BeanDefinition
→ 实例化
→ 注入依赖
→ 前置处理器
→ 初始化回调
→ 可用
→ 销毁回调
@PostConstruct 适合初始化已注入的状态,但不应在其中执行长时间阻塞任务,否则会拖慢应用启动。Bean 默认是单例,因此服务类应尽量设计为无可变共享状态。
2. 一个端到端 HTTP 示例
@SpringBootApplication
public class Application {
public static void main(String[] args) {
SpringApplication.run(Application.class, args);
}
}
public record CreateOrderRequest(long userId, List<String> items) {}
public record OrderResponse(long id, String status) {}
@RestController
@RequestMapping("/orders")
public class OrderController {
private final OrderService service;
public OrderController(OrderService service) {
this.service = service;
}
@PostMapping
public ResponseEntity<OrderResponse> create(
@RequestBody CreateOrderRequest request) {
if (request.userId() <= 0 || request.items() == null
|| request.items().isEmpty()) {
throw new IllegalArgumentException("invalid order");
}
OrderResponse response = service.create(request);
return ResponseEntity.status(HttpStatus.CREATED).body(response);
}
}
@Service
public class OrderService {
private final AtomicLong ids = new AtomicLong();
public OrderResponse create(CreateOrderRequest request) {
long id = ids.incrementAndGet();
return new OrderResponse(id, "CREATED");
}
}
请求路径是:
HTTP 请求
→ Servlet 容器或响应式运行时
→ Spring MVC HandlerMapping
→ Controller 参数绑定
→ Service 业务处理
→ HTTP 响应序列化
这个例子只适合说明生命周期,不能作为真实订单持久化实现:进程重启后 ID 丢失,多实例之间也不一致。真实系统需要数据库唯一键、事务和幂等设计。
3. 错误处理必须进入协议边界
不要让每个 Controller 都重复拼错误响应:
@RestControllerAdvice
class ErrorHandler {
@ExceptionHandler(IllegalArgumentException.class)
ResponseEntity<Map<String, Object>> badRequest(
IllegalArgumentException e) {
return ResponseEntity.badRequest().body(Map.of(
"code", "INVALID_REQUEST",
"message", e.getMessage()
));
}
}
生产接口还应处理:
- 参数校验失败;
- 认证失败和权限不足;
- 资源不存在;
- 数据库唯一键冲突;
- 下游超时;
- 业务状态冲突;
- 未知异常。
错误码应稳定,错误消息可以更改;日志中记录内部堆栈,但不要把 SQL、密钥、令牌和内部拓扑直接返回客户端。
4. 事务不是“自动重试”
@Transactional 通常通过代理拦截方法调用,在事务边界内创建、提交或回滚事务。一个关键边界是自调用:
class Service {
public void outer() {
inner(); // 可能绕过代理
}
@Transactional
public void inner() {}
}
如果 outer() 和 inner() 在同一个对象中直接调用,调用可能没有经过 Spring 代理,@Transactional 的拦截行为就不会按预期发生。
数据库事务保证的是特定资源上的原子性、一致性、隔离性和持久性,不会自动覆盖远程 HTTP 调用。下面的流程不是一个原子事务:
数据库扣款成功
→ 调用库存服务
→ 网络超时
此时扣款是否成功、库存是否扣减不能靠数据库回滚远程动作解决。需要幂等键、状态机、消息、补偿或 Saga 等机制。
八、数据库、事务和接口设计
1. 连接池是有限资源
应用访问数据库通常经过连接池:
请求线程
→ 从池中借连接
→ 执行 SQL
→ 提交或回滚
→ 归还连接
如果连接池大小为 N,同时需要数据库连接的请求超过 N,多余请求会等待。等待时间最终表现为接口延迟,而不一定表现为数据库 CPU 很高。
连接池过大也有风险:数据库并发连接、锁竞争、上下文切换和内存压力增加。因此容量分析必须结合:
- 请求并发;
- 单次 SQL 持有连接时间;
- 数据库最大连接数;
- 慢查询和锁等待;
- 应用实例数。
若每个实例配置 50 个连接,部署 20 个实例,理论上可能产生 1000 个数据库连接;数据库只看到总和,不会因为连接来自不同容器而自动降低压力。
2. 事务隔离和并发异常
事务隔离级别约束并发事务互相观察数据的方式。常见问题包括:
- 脏读:读到其他事务尚未提交的数据;
- 不可重复读:同一事务两次读取同一行结果不同;
- 幻读:按条件读取的行集合发生变化;
- 丢失更新:后提交的写覆盖了先提交的写。
更新库存的典型错误:
SELECT stock FROM product WHERE id = 1;
-- 应用计算 stock - 1
UPDATE product SET stock = ? WHERE id = 1;
两个事务都读到 10,分别写回 9,实际扣减两次却只减少 1。
可以使用条件更新:
UPDATE product
SET stock = stock - 1
WHERE id = 1 AND stock > 0;
然后检查影响行数:
影响 1 行:扣减成功
影响 0 行:库存不足或记录不存在
数据库在单条条件更新中完成比较和写入,避免了应用层“先读后写”的竞态。若业务还涉及订单状态、支付记录等多个表,仍需在明确事务边界内处理。
九、从单体到微服务:拆分的是故障边界
1. 微服务不是“拆成多个项目”
微服务通常具有:
- 独立部署;
- 明确的业务边界;
- 独立进程和故障边界;
- 通过网络协议通信;
- 独立扩缩容或演进能力。
拆分后获得独立发布和扩容能力,也引入网络延迟、超时、序列化、版本兼容、分布式事务和运维复杂度。
单体调用:
方法调用 → 返回值
微服务调用:
客户端
→ DNS/服务发现
→ 连接池
→ 网络传输
→ 对端线程池
→ 对端数据库
→ 响应或超时
任一环节都可能失败。
2. 超时、重试和幂等
超时是资源保护机制,不是故障修复机制。下游请求应至少区分:
- 连接超时;
- TLS 握手超时;
- 响应超时;
- 连接池等待超时;
- 整体请求截止时间。
重试必须满足两个前提:
- 操作具有幂等语义,或带有幂等键;
- 重试预算受控,且有退避和随机抖动。
查询通常较容易幂等;支付、下单和扣库存若没有幂等键,重试可能造成重复扣款。
幂等键示意:
客户端生成 requestId = abc-123
→ 服务端以 requestId 建唯一约束
→ 第一次成功:保存结果
→ 重复请求:返回已保存结果
超时后的状态是“不确定”,不是“必然失败”。客户端超时可能发生在服务端已经提交数据库之后,因此恢复逻辑必须查询状态或依赖异步结果,而不能盲目重新执行。
3. 服务间协议和数据所有权
每个服务应明确数据所有权。若订单服务直接修改库存服务数据库,表面上减少了一次网络调用,却破坏了边界,使库存服务无法独立演进,也让事务和权限关系变得隐含。
更清晰的方式是:
订单服务 → 库存服务 API
库存服务 → 自己的数据库
同步调用适合需要立即得到结果的流程;消息适合异步解耦和削峰,但会引入重复投递、乱序、积压和最终一致性。消费者必须设计:
- 消息唯一 ID;
- 去重记录;
- 重试次数;
- 死信或人工处理;
- 消费成功后的确认时机。
4. 可观测性
生产服务至少应关联三类信号:
- 日志:记录事件和上下文;
- 指标:记录数量、比例和时延;
- 链路追踪:记录跨服务调用路径。
一个请求应携带 trace ID,并在日志中输出:
traceId、requestId、服务名、接口、状态码、耗时、异常类型
不要把用户密码、访问令牌和完整支付信息写入日志。指标中应关注错误率、延迟分位数、连接池等待、线程池队列、GC 暂停和消息积压,而不是只看平均延迟。
十、Java 服务的容器化和生产交付
1. 容器不是虚拟机
容器共享宿主机内核,通过隔离机制限制进程的文件系统、网络、进程和资源。JVM 在容器中运行时,内存上限来自容器资源限制,但 Java 进程使用的内存不仅是堆:
进程内存
= Java 堆
+ 类元数据
+ 线程栈
+ 代码缓存
+ 直接内存
+ GC/JIT/native library 内存
因此不能把容器内存上限全部配置给 -Xmx。否则堆看似正常,进程仍可能因为 RSS 超限被 OOM Kill。
基础镜像应包含与构建一致的 Java 主版本,并通过启动日志确认:
java -version
镜像中的 JDK/JVM、应用制品和启动参数必须可追踪。不要依赖 latest 这类可变标签。
2. JVM 参数必须经过验证
一个示意启动命令:
java \
-Xms512m \
-Xmx512m \
-XX:MaxMetaspaceSize=256m \
-Xlog:gc*:stdout:time,level,tags \
-jar app.jar
这不是通用配置。每个参数都有代价:
-Xmx限制 Java 堆,不限制整个进程;-Xms影响初始堆和内存占用;MaxMetaspaceSize过小可能导致类元数据耗尽;- GC 日志输出到标准输出便于容器采集,但高日志量可能增加 I/O;
- 固定堆大小可能提高稳定性,也可能减少容器内存弹性。
生产参数应通过压测、故障演练和真实监控验证,而不是复制其他服务的 JVM 参数。
3. 探针和状态模型
健康检查至少区分:
- 启动探针:应用是否还在启动;
- 存活探针:进程是否陷入无法恢复的状态;
- 就绪探针:实例是否可以接收流量。
一个服务可能“活着但未就绪”:进程正常运行,却仍在迁移数据库、建立连接池或加载必要配置。此时若立即接收流量,会把启动过程暴露成业务错误。
Spring Boot Actuator 常用于暴露健康端点,但应限制管理端点暴露范围,并配置认证、网络访问控制和敏感信息脱敏。健康检查也不能无条件把所有下游依赖都纳入存活探针,否则数据库短暂故障可能导致编排系统反复重启所有实例,形成故障放大。
4. 发布状态和故障路径
一次滚动发布可以抽象为:
stateDiagram-v2
[*] --> OldVersion
OldVersion --> Starting: 创建新实例
Starting --> Unready: 应用启动
Unready --> Ready: 启动探针成功且就绪检查通过
Ready --> ReceivingTraffic: 加入负载均衡
ReceivingTraffic --> Draining: 旧实例下线或缩容
Draining --> Stopped: 连接排空
Ready --> Failed: 启动失败或错误率异常
Failed --> Rollback: 停止放量并恢复旧版本
Rollback --> OldVersion
关键点不是“新容器启动成功”,而是:
- 新版本进程成功启动;
- 就绪检查通过;
- 小流量请求成功;
- 错误率、延迟、资源使用和业务指标正常;
- 才继续扩大流量。
下线时要先停止接收新请求,再等待正在处理的请求结束。若立即杀进程,可能造成连接重置、消息重复和事务中断。
5. 灰度发布
灰度是将新版本暴露给一部分流量,并根据指标决定继续、暂停或回滚。流量可以按:
- 实例比例;
- 用户哈希;
- 租户;
- 请求头;
- 地域;
- 功能开关。
按用户稳定哈希时,同一用户通常持续命中同一版本;随机比例则可能使同一用户在新旧版本间切换,影响体验和排障。
灰度必须观察业务指标。例如订单服务不能只看 HTTP 200,还应看:
下单成功率
支付回调成功率
重复订单数量
库存扣减失败率
P95/P99 延迟
数据库锁等待
消息积压
新版本如果能启动但写入了旧版本不能识别的数据格式,回滚应用代码也无法自动修复数据。因此数据库变更通常需要采用兼容阶段:
先扩展:新增可选字段/表/索引
→ 新旧代码都能运行
→ 发布新代码并逐步迁移
→ 确认无旧版本后再收缩
十一、容量、限流和回滚
1. 用 Little 定律建立容量直觉
排队系统中常用关系:
其中:
- :系统中平均在途请求数;
- :平均吞吐率,单位时间请求数;
- :请求平均停留时间。
例如服务稳定处理每秒 100 个请求,平均响应时间为 0.2 秒,则平均在途请求数约为:
这不是线程池大小的直接答案,因为请求可能等待连接池、锁、下游响应或队列。但它能帮助判断:在延迟上升时,在途请求会增加;若线程池和队列无界,最终会积压并耗尽内存。
2. 限流和背压
限流限制进入系统的速率;背压让上游感知下游处理能力,避免无限制堆积。
没有限流时,故障路径可能是:
下游变慢
→ 上游请求等待
→ 线程/协程堆积
→ 连接池耗尽
→ 超时重试增加
→ 流量进一步放大
→ 全链路雪崩
限流、超时、舱壁隔离和有界队列应共同控制资源。队列满时应明确返回拒绝、降级或进入消息系统,而不是无限等待。
3. 回滚的必要条件
可回滚发布至少需要:
- 上一个可运行版本;
- 可定位的制品版本;
- 数据库变更具有向后兼容性,或有明确恢复脚本;
- 配置和密钥版本可追踪;
- 灰度指标和触发阈值;
- 回滚后对残留数据、消息和缓存的一致性检查。
回滚应用版本不等于回滚所有状态。已发送的消息、已写入的数据、已刷新缓存和已执行的外部支付不会因为容器换回旧镜像而自动消失。
十二、建议的完整学习顺序
阶段一:语言和标准库
先完成:
- 基本类型、引用、运算、控制流;
- 类、接口、继承、组合和访问控制;
equals、hashCode、record和不可变对象;- 泛型、集合、异常和 I/O;
- Lambda、Stream 和 Optional;
- 时间 API、JSON 基础和单元测试。
验收标准是:能从零写一个带校验、异常处理和测试的命令行程序,而不是只能调用框架生成代码。
阶段二:JVM 和并发
继续掌握:
- 类加载、字节码、JIT;
- 堆、栈、类元数据、直接内存;
- GC 和 OOM 诊断;
- Java 内存模型;
synchronized、Lock、原子类和并发集合;- Executor、CompletableFuture 和虚拟线程;
jcmd、线程转储、GC 日志和 JFR。
验收标准是:遇到高 CPU、线程阻塞、堆增长、频繁 GC 或请求超时,能先收集证据,再提出假设。
阶段三:工具链和工程结构
掌握:
javac的--release;jar、类路径和模块路径;- Maven 或 Gradle 的生命周期;
- 依赖冲突和版本收敛;
- 测试、静态检查和制品哈希;
- CI 中固定 JDK、插件和依赖;
- 可重复构建和供应链审计。
验收标准是:在干净环境中从源码构建出可运行制品,并解释制品中包含什么。
阶段四:Spring 和数据访问
掌握:
- IoC、DI、Bean 生命周期;
- 配置绑定和环境隔离;
- Controller、Service、Repository 边界;
- 参数校验和统一错误处理;
- 数据库连接池;
- SQL、索引、事务和隔离;
- 缓存、消息和幂等;
- 单元测试、切片测试和集成测试。
验收标准是:能实现一个真实的 CRUD 业务,同时解释事务边界、并发更新和失败响应。
阶段五:微服务
掌握:
- 服务边界和数据所有权;
- HTTP/RPC、序列化和版本兼容;
- 超时、重试、熔断和限流;
- 幂等、补偿和最终一致性;
- 消息重复、乱序和积压;
- 日志、指标、追踪和告警;
- 契约测试与端到端测试。
验收标准是:能画出请求链路和故障链路,并说明每个边界发生超时时系统如何处理。
阶段六:生产交付
最后学习:
- JAR、容器镜像和启动命令;
- JVM 与容器资源;
- 启动、存活和就绪探针;
- 优雅停机;
- 滚动发布和灰度;
- 容量估算与压测;
- 数据库兼容迁移;
- 回滚和故障演练。
验收标准是:能发布一个服务,观察它,制造一次受控故障,并证明系统能够限流、告警和恢复。
十三、最终应形成的能力
完整的 Java 25 后端能力不是“会写 Spring Controller”,而是能把以下问题连起来:
- 这个类型约束是否能在编译期捕获错误?
- 这个对象是否拥有清晰的不变量和生命周期?
- 这段并发代码是否具备可见性和原子性保证?
- 这个请求占用了哪些线程、连接和内存?
- 这个事务覆盖了哪些资源,哪些资源不在事务内?
- 这个重试是否可能造成重复副作用?
- 这个服务失败时,上游是否会继续放大流量?
- 这个新版本能否被旧版本兼容?
- 回滚代码后,数据、消息和缓存是否仍然可处理?
- 监控中的指标能否区分应用错误、资源耗尽和下游故障?
当这些问题可以通过语言规范、JVM 行为、框架生命周期、数据库约束和生产观测数据逐层回答时,Java 学习才从 API 记忆进入了工程推理。
完整学习目录
一、语言与标准库
- Java 25 工具链:JDK、javac、jar、Maven、Gradle 与可重复构建
- Java 25 类型与控制流:基本类型、引用、Record、sealed 和模式匹配
- Java 对象模型:类、接口、继承、组合、不可变性和设计边界
- Java 集合框架:List、Set、Map、Queue、迭代器和复杂度
- Java 泛型完整基础:类型擦除、通配符、PECS、边界和反射
- Java 异常处理:受检异常、错误链、资源关闭和 API 契约
- Java I/O 与 NIO:Stream、Channel、Buffer、文件和网络边界
- Java 日期时间:Instant、LocalDateTime、ZoneId、Duration 和序列化
- Java 反射与注解:Class、MethodHandle、元注解、处理器和边界
- Java Stream API:惰性、Collector、并行流、性能和使用边界
- Java 网络与 HTTP Client:连接、TLS、超时、流和错误恢复
二、并发与 JVM
- Java 线程与 Executor:生命周期、线程池、队列、Future 和取消
- Java 25 虚拟线程与结构化并发:调度、Pinning、取消和容量
- Java 内存模型与同步:happens-before、锁、volatile、Atomic 和 VarHandle
- JVM 类加载与字节码:生命周期、双亲委派、验证和动态代理
- JVM 内存与垃圾回收:堆、栈、元空间、G1、ZGC 和调优
- JVM 诊断与性能:JFR、JMC、jcmd、线程转储、堆转储和证据链
三、工程与数据访问
- Java 测试体系:JUnit 5、AssertJ、Mockito、Testcontainers 和并发测试
- Java 日志与配置:SLF4J、Logback、结构化字段、Secret 和动态治理
- JDBC 与事务:连接池、PreparedStatement、隔离、批处理和泄漏
- MyBatis 完整基础:Mapper、动态 SQL、缓存、事务和性能边界
- JPA 与 Hibernate:实体状态、关联、查询、事务和 N+1 诊断
四、Spring 与服务开发
- Spring Framework 核心:IoC、Bean 生命周期、AOP、事件和事务代理
- Spring Boot 工程基础:自动配置、Starter、配置绑定、Actuator 和启动
- Spring Web MVC 与 WebFlux:请求链、校验、异常、流式和选择边界
- Spring Security 完整指南:Filter Chain、认证、授权、JWT 和 CSRF
- Spring Data 数据访问:Repository、事务、分页、审计和缓存边界
- Spring Boot 测试:Slice、上下文缓存、MockMvc、容器和契约测试
五、生产架构
- Java 微服务治理:HTTP、gRPC、配置、发现、限流、熔断和幂等
- Java 消息队列工程:Kafka、RabbitMQ、确认、幂等、重试和 Outbox
- Java Redis 与 Elasticsearch:客户端、缓存一致性、检索和故障降级
- Java 可观测性:Micrometer、OpenTelemetry、日志、指标、Trace 和 SLO
- Java 服务安全:反序列化、表达式注入、SSRF、TLS 和供应链
- Java 生产交付:容器、JVM 参数、探针、灰度、容量和回滚
一、语言与标准库
- Java 25 词法、表达式与运算符:求值顺序、提升、溢出和短路
- Java 25 字符串:不可变、String Pool、文本块、格式化和性能
- Java 25 方法与重载:参数传递、可变参数、解析规则和 API 设计
- Java 25 对象初始化:字段、初始化块、构造器、继承与发布安全
- Java 25 可见性与包:访问控制、封装、模块边界和依赖方向
- Java 25 Record 完整指南:值对象、紧凑构造器、验证和序列化
- Java 25 sealed 类型与模式匹配:封闭层次、switch 穷尽和建模
- Java 25 Enum 深入:状态、策略、EnumSet、EnumMap 和持久化边界
- Java 25 嵌套类、内部类、局部类与匿名类:捕获和生命周期
- Java 25 不可变对象:防御复制、深浅不可变、线程安全和 Builder
- Java equals、hashCode 与比较:等价关系、排序和集合正确性
- Java Optional:创建、组合、空值边界、性能与错误用法
- Java 正则表达式:Pattern、Matcher、分组、回溯、性能和 ReDoS
- Java 国际化:Locale、ResourceBundle、日期数字和消息格式
- Java 序列化边界:原生序列化风险、JSON、Schema 与版本演进
- Java 密码学 API:随机数、哈希、MAC、对称加密、签名和密钥
- Java Process API:启动子进程、流、超时、退出和命令注入防护
- Java 25 Foreign Function & Memory API:Arena、MemorySegment 和本地调用
- Java SPI 与 ServiceLoader:发现、模块化、隔离和插件架构
- Java 注解处理器:编译期模型、代码生成、增量构建和调试
- Java List 深入:ArrayList、LinkedList、迭代器、复杂度和选型
- Java HashMap 深入:哈希、桶、树化、扩容、迭代和键约束
- Java 有序集合:TreeMap、TreeSet、Comparator 和一致性
- Java Queue 与 Deque:优先队列、双端队列、阻塞语义和选型
- Java 并发集合:ConcurrentHashMap、CopyOnWrite、BlockingQueue 和边界
- Java Collector 深入:归约、分组、下游收集器、并行和自定义
二、并发与 JVM
- Java 线程生命周期:创建、状态、Interrupt、守护线程和协作退出
- Java synchronized 与 Monitor:锁升级、等待通知、可见性和死锁
- Java Lock、ReadWriteLock、StampedLock 与 Condition:语义和陷阱
- Java Atomic 与 VarHandle:CAS、ABA、内存顺序和无锁边界
- Java CompletableFuture:组合、线程池、超时、取消和异常传播
- Java ForkJoinPool 与并行流:工作窃取、阻塞和性能边界
- Java 25 结构化并发:任务作用域、失败传播、取消与预览边界
- Java 25 Scoped Value:上下文传播、不可变绑定与虚拟线程
- Java Flow 与响应式流:Publisher、Subscriber、背压和协议正确性
- JVM 字节码工具:javap、ASM、Byte Buddy、Instrumentation 和验证
- JVM JIT 编译:解释执行、分层编译、内联、去优化和证据
- JVM 逃逸分析:标量替换、锁消除、分配观测和误区
- JVM G1 GC 深入:Region、Remembered Set、暂停目标和调优
- JVM ZGC 深入:并发回收、染色指针、分代模式和容量规划
- JVM Native Memory:堆外、线程栈、Metaspace、NMT 和泄漏
- JVM ClassLoader 泄漏:线程、缓存、Driver、热部署和诊断
三、工程与数据访问
- Maven 完整基础:生命周期、依赖、插件、BOM、仓库和可重复构建
- Gradle 完整基础:Task、配置缓存、依赖、插件和多模块构建
- Java 25 模块系统 JPMS:requires、exports、opens、服务和迁移
- Java 代码质量:Checkstyle、SpotBugs、Error Prone、覆盖率和门禁
- Java JMH 基准测试:预热、黑洞、分叉、死代码和结果解释
- Java 属性测试与模糊测试:生成器、不变量、缩减和回归
- Java 数据库连接池:HikariCP、容量、超时、泄漏和监控
- Java 数据库迁移:Flyway、Liquibase、版本、回滚和零停机
- Spring 事务深入:传播、隔离、代理、自调用和事件边界
- Java Redis 工程:Lettuce、连接、序列化、缓存、锁和 Streams
- Java Elasticsearch 工程:客户端、Mapping、查询、Bulk 和重试
- Java MongoDB 工程:文档建模、驱动、事务、索引和变更流
四、Spring 与服务开发
- Spring 参数校验:Bean Validation、分组、嵌套、错误模型和国际化
- Spring 异常处理:Problem Detail、全局处理、错误码和日志边界
- Spring Cache:抽象、Key、TTL、穿透、一致性和多级缓存
- Spring 调度任务:线程池、Cron、时区、防重入和分布式锁
- Spring Batch:Job、Step、Chunk、Checkpoint、重启和幂等
- Spring Integration:Channel、Adapter、Gateway、流程和错误处理
五、生产架构
- Java gRPC:Protobuf、Unary、Stream、拦截器、Deadline 和治理
- Java Kafka:Producer、Consumer、分区、Offset、事务和再均衡
- Java RabbitMQ:Exchange、确认、重试、死信、顺序和幂等
- Java 服务韧性:超时、重试、限流、熔断、隔离和降级
- Java 服务上 Kubernetes:资源、探针、JVM 容器感知和优雅关闭
系列导航与关联阅读
- 下一篇:Java 25 工具链:JDK、javac、jar、Maven、Gradle 与可重复构建
- 延伸:Java 对象模型:类、接口、继承、组合、不可变性和设计边界
- 延伸:Java 生产交付:容器、JVM 参数、探针、灰度、容量和回滚
官方资料
本文依据 Java、Spring 与相关项目官方文档重新梳理;正文、示例与生产清单由 WR BLOG 编写。

评论
0 条讨论