前端工程师的 Java 后端 30 天快速入门
假设你已经熟悉 TypeScript / JavaScript 与前端工程化,目标是在 30 天内具备独立设计、实现并交付一个中等复杂度后端服务的能力。 每天建议投入 2--3 小时(周末可加练),总计约 90 小时。
| 项目 | 说明 |
|---|---|
| 技术栈 | Java 21 + Spring Boot 3 + MySQL 8 + Redis 7 + RabbitMQ + Docker |
| 学习法 | 每天「目标 → 要点 → 代码 → 动手 → 自测 → 避坑」闭环,必须写代码 |
| 交付物 | 第 29 天完成一个可部署的完整服务,第 30 天复盘 |
| 配套 | 同目录 index.html 是可视化学习面板(含进度打卡与搜索) |
目录
Week 1:Java 语言与工程基础(Day 1-7)
把 TS/JS 的直觉映射到 Java:静态类型、面向对象、集合与 Stream、Maven、单元测试。学完能独立写命令行小工具并给它写测试。
- [Day 1 · 搭环境:JDK、Maven 与第一个 Java 程序](#Day 1 · 搭环境:JDK、Maven 与第一个 Java 程序 "#day-1")
2h - [Day 2 · 类型系统:基本类型、String 与不可变性](#Day 2 · 类型系统:基本类型、String 与不可变性 "#day-2")
2-3h - [Day 3 · 方法、流程控制与异常体系](#Day 3 · 方法、流程控制与异常体系 "#day-3")
2h - [Day 4 · 面向对象:类、接口、record 与枚举](#Day 4 · 面向对象:类、接口、record 与枚举 "#day-4")
2-3h - [Day 5 · 集合框架与 Stream:把 JS 数组思维翻译过来](#Day 5 · 集合框架与 Stream:把 JS 数组思维翻译过来 "#day-5")
3h - [Day 6 · 泛型、Optional 与 java.time](#Day 6 · 泛型、Optional 与 java.time "#day-6")
2-3h - [Day 7 · 工程化:Maven、JUnit5、Mockito 与日志](#Day 7 · 工程化:Maven、JUnit5、Mockito 与日志 "#day-7")
3h
Week 2:Spring Boot 与数据访问(Day 8-14)
IoC/DI、REST API、校验与全局异常、配置管理、MySQL + JPA/MyBatis、索引与数据库迁移。学完能交付一套带数据库的 CRUD 服务。
- [Day 8 · Spring Boot 起步:10 分钟跑起一个后端服务](#Day 8 · Spring Boot 起步:10 分钟跑起一个后端服务 "#day-8")
2-3h - [Day 9 · IoC / DI:Spring 的心脏](#Day 9 · IoC / DI:Spring 的心脏 "#day-9")
3h - [Day 10 · 设计 REST API:Controller、DTO 与统一响应](#Day 10 · 设计 REST API:Controller、DTO 与统一响应 "#day-10")
3h - [Day 11 · 参数校验与全局异常处理](#Day 11 · 参数校验与全局异常处理 "#day-11")
2-3h - [Day 12 · 配置管理:profiles、类型安全配置与密钥](#Day 12 · 配置管理:profiles、类型安全配置与密钥 "#day-12")
2h - [Day 13 · 数据库访问:JPA / MyBatis 与事务](#Day 13 · 数据库访问:JPA / MyBatis 与事务 "#day-13")
3-4h - [Day 14 · SQL 进阶、索引与数据库迁移](#Day 14 · SQL 进阶、索引与数据库迁移 "#day-14")
3h
Week 3:中间件与工程能力(Day 15-21)
Redis 缓存、JWT 认证、消息队列、AOP、并发异步、对象存储、接口文档与联调契约。学完能处理真实业务里的性能、安全与异步问题。
- [Day 15 · Redis 缓存:后端性能的第一剂药](#Day 15 · Redis 缓存:后端性能的第一剂药 "#day-15")
3h - [Day 16 · 认证与授权:Spring Security + JWT](#Day 16 · 认证与授权:Spring Security + JWT "#day-16")
3-4h - [Day 17 · 消息队列:异步、削峰与解耦](#Day 17 · 消息队列:异步、削峰与解耦 "#day-17")
3h - [Day 18 · AOP:把横切逻辑从业务里抽出来](#Day 18 · AOP:把横切逻辑从业务里抽出来 "#day-18")
2-3h - [Day 19 · 并发与异步:线程池、CompletableFuture、ThreadLocal](#Day 19 · 并发与异步:线程池、CompletableFuture、ThreadLocal "#day-19")
3h - [Day 20 · 调用外部世界:HTTP 客户端与对象存储](#Day 20 · 调用外部世界:HTTP 客户端与对象存储 "#day-20")
3h - [Day 21 · 接口文档与前后端联调契约](#Day 21 · 接口文档与前后端联调契约 "#day-21")
2-3h
Week 4:生产化、交付与架构(Day 22-30)
Docker、集成测试、可观测性、JVM 与性能、CI/CD、安全、分层架构、综合实战与复盘。学完能把服务真正部署上线并讲清楚它。
- [Day 22 · Docker:把服务与环境一起打包](#Day 22 · Docker:把服务与环境一起打包 "#day-22")
3h - [Day 23 · 测试进阶:切片测试与 Testcontainers](#Day 23 · 测试进阶:切片测试与 Testcontainers "#day-23")
3h - [Day 24 · 可观测性:日志、指标与链路追踪](#Day 24 · 可观测性:日志、指标与链路追踪 "#day-24")
3h - [Day 25 · JVM 与性能:先测量,再优化](#Day 25 · JVM 与性能:先测量,再优化 "#day-25")
3-4h - [Day 26 · CI/CD:从 push 到上线](#Day 26 · CI/CD:从 push 到上线 "#day-26")
3h - [Day 27 · 安全:别让自己成为新闻里的那个公司](#Day 27 · 安全:别让自己成为新闻里的那个公司 "#day-27")
2-3h - [Day 28 · 分层与架构:从能跑到好维护](#Day 28 · 分层与架构:从能跑到好维护 "#day-28")
3h - [Day 29 · 综合实战:一天交付一个完整服务](#Day 29 · 综合实战:一天交付一个完整服务 "#day-29")
6-8h - [Day 30 · 复盘、面试表达与 90 天进阶路线](#Day 30 · 复盘、面试表达与 90 天进阶路线 "#day-30")
3h
Day 1 · 搭环境:JDK、Maven 与第一个 Java 程序
所属阶段 :Week 1 · Java 语言与工程基础 | 建议时长 :2h | 进度:第 1 / 30 天
今日导语:第一天不追求写代码量,目标是三件事:把工具链一次装对、说清一个 Java 程序从 .java 文件到跑起来经历了什么、建立 Maven 工程的目录直觉。环境问题是新手最容易消耗一整天的坑,今天一次性解决,后面 29 天不再为它分心。
前端视角:直觉映射:JDK ≈ Node 运行时 + TypeScript 编译器 tsc 的合体;Maven ≈ npm/pnpm(pom.xml ≈ package.json,但约定更强);jar ≈ 打包产物。区别在于:Node 直接跑 JS(解释 + JIT),Java 必须先编译成字节码 .class 再由 JVM 执行------所以 Java 有真正的"编译期类型检查"。
🎯 今日目标
- 装好 JDK 21(LTS)+ IntelliJ IDEA,跑通 Hello World
- 讲清 JVM / JRE / JDK 三者关系,以及 Java 的执行模型(编译 → 字节码 → JVM → JIT)
- 认识 Maven 标准目录与 pom.xml 最小结构,能独立建工程、编译、打包、运行
📌 执行模型:为什么要先编译
- javac 把 .java 编译成平台无关的字节码 .class;JVM 负责加载、校验、解释执行,热点代码由 JIT 编译成本地机器码。这就是"一次编译,到处运行"的由来。
- 编译期能挡住类型错误、未实现接口、未处理的受检异常------这是 Java 相对 JS 最大的安全感来源,也是你写业务代码时最快得到反馈的地方。
- JVM 启动比 Node 慢(几十到几百毫秒),但长时间运行后的稳态性能通常更好;这也是后端服务"常驻进程"而非"每次请求启动"的原因。
- Runtime.version()、availableProcessors()、maxMemory() 三个 API 可以让你快速确认运行环境是否如你所想。
📌 版本、发行版与工具链
- 版本:Java 8 / 11 / 17 / 21 是 LTS。2026 年的新项目直接用 21(虚拟线程、record、模式匹配、ZGC 都是成熟特性);维护老项目时再回头学 8。
- 发行版:Eclipse Temurin、Amazon Corretto、Azul Zulu 都是免费可商用的 OpenJDK;Oracle JDK 的商用授权要留意,公司项目别随便装。
- 多版本管理:macOS/Linux 用 SDKMAN(sdk install java 21.0.x-tem),Windows 用 scoop 或手动配 JAVA_HOME;机器上 8 与 21 并存是常态。
- IDE:IntelliJ IDEA 是事实标准。Community 版免费够学语言,Ultimate 版对 Spring / JPA / 数据库工具支持更好。必装插件:Lombok、Maven Helper。
- 构建工具:Maven(XML、约定优于配置、生态最全)与 Gradle(Groovy/Kotlin DSL、构建更快)。先学 Maven------国内 90% 的存量项目是 Maven,面试也默认你能看懂 pom.xml。
📌 Maven 工程约定
- 标准目录:src/main/java(源码)、src/main/resources(配置)、src/test/java(测试)、target/(构建产物,不进 Git)。
- 坐标三要素 GAV:groupId(组织,如 com.demo)、artifactId(项目名)、version(SNAPSHOT 表示开发中版本)。
- 生命周期:validate → compile → test → package → verify → install → deploy。执行后面的阶段会自动先执行前面的。
- 常用命令:mvn clean compile、mvn test、mvn package(产出 jar)、mvn install(装到本地仓库)、mvn dependency:tree(查依赖冲突的神器)。
- 本地仓库 ~/.m2/repository:第一次构建会下载大量依赖,配好国内镜像源能省很多时间。
💻 安装与验证
bash
# macOS(Windows 用 SDKMAN / scoop / 官网安装包)
brew install --cask temurin@21
brew install maven
# Linux / 通用:SDKMAN
curl -s "https://get.sdkman.io" | bash && sdk install java 21.0.5-tem
java -version # 应输出 openjdk version "21.x"
javac -version
mvn -v # Maven 3.9+
echo $JAVA_HOME # 必须指向 21,否则 Maven 会报告 release 版本不支持
💻 HelloWorld.java ------ 看清入口与运行环境
java
public class HelloWorld {
public static void main(String[] args) {
// Java 程序入口:必须是「类 + public static void main(String[])」
// 没有 JS 那种"顶层脚本直接执行"的写法
System.out.println("Hello, backend!");
System.out.println("Java 版本: " + Runtime.version());
System.out.println("可用核心数: " + Runtime.getRuntime().availableProcessors());
System.out.println("JVM 最大内存(MB): " + Runtime.getRuntime().maxMemory() / 1024 / 1024);
}
}
// 编译与运行(先体会一次完整链路):
// javac HelloWorld.java → 生成 HelloWorld.class(字节码)
// java HelloWorld → JVM 加载并执行
// 单文件源码模式(JDK 11+,仅学习用,工程里不用):
// java HelloWorld.java
💻 最小可用的 pom.xml
xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.demo</groupId>
<artifactId>hello</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<!-- 指定语言级别,否则 Maven 编译器插件可能用很老的默认值 -->
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<!-- 依赖写法:groupId + artifactId + version(+scope) -->
</dependencies>
</project>
💻 从零建工程到打包运行
bash
mvn -B archetype:generate \
-DgroupId=com.demo -DartifactId=hello \
-DarchetypeArtifactId=maven-archetype-quickstart
cd hello
mvn clean package # 编译 + 跑测试 + 打 jar
java -cp target/classes com.demo.App # 直接跑 class
# 想用 java -jar 运行,需要 maven-shade-plugin 打 fat jar
# (Day 8 之后 Spring Boot 会自带这个能力)
🛠 动手任务
- 用 IDEA 新建 Maven 项目,写一个 main 输出 Java 版本、CPU 核数、JVM 最大内存,并分别用「IDE 运行」和「java -cp」两种方式跑通。
- 手动执行 javac 编译一次,再用 javap -c HelloWorld 看一眼字节码(不用看懂,感受"编译产物"确实存在)。
- 在 ~/.m2/settings.xml 配置阿里云镜像,然后 mvn clean package 观察下载速度的变化。
✅ 自测题与参考答案(3 题)
Q1. JVM 执行模型与 Node(V8 + 事件循环)相比,最核心的差异有哪些?
参考答案(点击展开)
① Java 先由 javac 编译成字节码再由 JVM 执行,有真正的编译期类型检查;Node 直接执行 JS 源码(解释 + JIT)。② Java 是多线程共享内存的并发模型,Node 是单线程事件循环 + 异步 IO。③ JVM 启动较慢(几十到数百毫秒)但稳态吞吐高,适合长驻服务;Node 启动快、IO 密集场景轻量。④ Java 有成熟的分代 GC 与调优体系,Node 依赖 V8 的 GC。实践含义:后端服务通常是"常驻进程 + 线程池并发",你需要重新建立线程安全与共享状态的意识。
Q2. classpath 是什么?为什么会抛 ClassNotFoundException?
参考答案(点击展开)
classpath 是 JVM 搜索 .class 文件与资源的路径集合(目录或 jar 文件)。运行时需要的类不在这条路径上,就会抛 ClassNotFoundException(编译期对应的错误是"找不到符号"/NoClassDefFoundError)。Maven 会自动把依赖 jar 拼进 classpath;手写 java 命令时必须用 -cp / -classpath 指定,多个路径在 macOS/Linux 用冒号、Windows 用分号分隔。常见触发场景:打出的 jar 没带依赖、依赖 scope 写成 test、IDE 运行配置与命令行不一致。
Q3. pom.xml 里不配置 maven.compiler.release 会怎样?
参考答案(点击展开)
Maven 编译器插件会使用较老的默认值(很多老项目默认 1.6/1.8),导致你写的 var、record、文本块、switch 表达式等新语法直接编译失败,报错信息是"不支持的发行版本 xx"或"-source 8 中不支持 XXX"。解决:显式配置 <maven.compiler.release>21</maven.compiler.release>,或让项目继承 spring-boot-starter-parent(它已经统一管理了 Java 版本与插件配置)。
⚠️ 前端容易踩的坑
- 安装路径含中文或空格,导致 Maven / IDEA 启动异常------这是最浪费时间的一类问题。
- JAVA_HOME 指向了老版本 JDK,mvn 报 "release version not supported"。用 mvn -v 确认它实际用的 JDK。
- 期待"改一行代码刷新一下就生效"。Java 需要重新编译;开发期靠 Spring DevTools 或 IDEA 的 Hot Reload(只能热替换方法体)缓解,别指望前端那种 HMR。
- 把 target/ 目录提交进 Git。新建项目第一件事就是配 .gitignore:忽略 target/、.idea/、.iml、.log。
Day 2 · 类型系统:基本类型、String 与不可变性
所属阶段 :Week 1 · Java 语言与工程基础 | 建议时长 :2-3h | 进度:第 2 / 30 天
今日导语:Java 是强静态类型语言,而 TS 的类型在编译后会被擦除。今天要把"类型"真正建立起来:基本类型与包装类的区别、String 为什么不可变、== 与 equals 到底比什么。这些看起来琐碎,却是线上 NullPointerException 和"偶发 bug"的主要来源。
前端视角:直觉映射:final ≈ const;String 更像 Python 的 str(不可变),任何"修改"都会产生新对象。最大的思维差异是------TS 的类型是"编译后消失的注释",Java 的类型在运行时真实存在(泛型除外,见 Day 6),所以 instanceof、反射、强转都有意义。
🎯 今日目标
- 掌握 8 种基本类型、包装类与自动装箱/拆箱的成本
- 彻底搞懂 String 不可变性、常量池,以及 equals / hashCode 的契约
- 会用 var、final、文本块、switch 表达式等现代 Java 语法写出简洁代码
📌 基本类型与包装类
- 8 种基本类型:byte(1B) / short(2B) / int(4B) / long(8B) / float / double / char / boolean。它们是值,不是对象,没有方法。
- 包装类 Integer、Long、Double... 让基本类型能放进集合(List)。自动装箱 Integer.valueOf(i) 与拆箱 intValue() 有开销,高频循环要留意。
- Integer 缓存 -128~127:该区间内装箱返回同一对象,x == y 为 true;超出区间为 false。数值比较永远用 equals,或先拆箱再用 ==。
- 精度:金额必须用 BigDecimal,且用字符串构造器 new BigDecimal("19.90")(传 double 会把浮点误差带进来);计数用 long 而非 int(订单量、毫秒时间戳很容易溢出 int 的 21 亿)。
📌 String:不可变与常量池
- 不可变:String 内部是 final 的 byte\[\],substring / concat / replace 都返回新对象。好处是天然线程安全、可缓存 hash、适合做 Map 的 key。
- 常量池:字面量 "x" 复用常量池对象;new String("x") 强制在堆上新建。所以 new String("x") == "x" 是 false。
- 拼接:编译器虽会把 + 优化成 StringBuilder,但循环里每次迭代都可能产生新对象,复杂度退化到 O(n²);显式用 StringBuilder、String.join 或文本块。
- 比较:== 比引用,equals 比内容。判断空串用 isBlank()(JDK 11+,忽略空白),推荐 "常量".equals(变量) 的写法避免 NPE。
- equals 与 hashCode 的契约:equals 相等则 hashCode 必须相等;作为 HashMap 的 key,参与 equals 的字段必须不可变,否则 put 之后改字段就再也 get 不到。
📌 现代语法糖(JDK 17/21 常用)
- var:局部变量类型推导(var list = new ArrayList();),只能用于有初始值的局部变量;公开 API 上写清类型更可读。
- 文本块 """:多行字符串,写 JSON / SQL / HTML 模板时告别 \n 拼接,还能自动去掉公共缩进。
- switch 表达式:可以直接返回值(case A -> 1;),不再需要 break,且编译器强制枚举分支穷尽,漏写会报错。
- instanceof 模式匹配:if (obj instanceof String s) { s.length(); },省掉一行强转。
- 日志与调试串推荐用占位符或 String.format,而不是大量 + 拼接。
💻 类型、装箱与字符串比较
java
var name = "Tom"; // 推导为 String(Java 10+)
final int MAX_RETRY = 3; // 类似 const
// 1) == 比较引用,equals 比较内容
String a = new String("x");
String b = "x";
System.out.println(a == b); // false
System.out.println(a.equals(b)); // true
System.out.println("x".equals(a)); // 推荐:常量在前,a 为 null 也不 NPE
// 2) Integer 缓存坑
Integer x = 127, y = 127;
System.out.println(x == y); // true(命中 -128~127 缓存)
Integer m = 128, n = 128;
System.out.println(m == n); // false!数值比较请用 equals
// 3) BigDecimal:金额唯一正确姿势
System.out.println(0.1 + 0.2); // 0.30000000000000004
var p1 = new BigDecimal("19.90");
var p2 = new BigDecimal("0.10");
System.out.println(p1.subtract(p2)); // 19.80,精确
System.out.println(p1.setScale(2, RoundingMode.HALF_UP));
// 4) 文本块 + switch 表达式 + 模式匹配
String json = """
{"name":"Tom","age":18}
""";
String label = switch (status) {
case PAID -> "已支付";
case PENDING -> "待支付";
case CLOSED -> "已关闭";
};
if (obj instanceof String s && !s.isBlank()) {
System.out.println(s.length());
}
💻 equals / hashCode 正确写法
java
public final class UserId {
private final long value;
public UserId(long value) { this.value = value; }
@Override
public boolean equals(Object o) {
if (this == o) return true; // 1. 引用相同
if (!(o instanceof UserId other)) return false; // 2. 类型判断(模式匹配)
return this.value == other.value; // 3. 比较关键字段
}
@Override
public int hashCode() {
return Long.hashCode(value); // 与 equals 用同一组字段
}
}
// 用 record 一行替代上面全部代码(自动生成 equals/hashCode/toString)
public record UserId(long value) {}
🛠 动手任务
- 写一个程序:读取一段 CSV 文本,解析出每行的字段,用 BigDecimal 求和金额列,最后按金额倒序输出------刻意练一遍 split、StringBuilder、BigDecimal、Comparator。
- 做一个"踩坑集":写 10 个断言验证 == / equals / Integer 缓存 / BigDecimal 的行为(如 assertEquals(false, m == n)),跑一遍看哪些与你预期不同。
✅ 自测题与参考答案(3 题)
Q1. 为什么 Integer m = 128, n = 128; m == n 是 false,而 127 时是 true?
参考答案(点击展开)
Integer.valueOf 对 -128127 做了缓存(IntegerCache),该区间的自动装箱返回同一个对象,引用相等;超出区间每次新建对象,== 比较引用自然为 false。结论:包装类的数值比较一律用 equals,或先拆成基本类型(intValue())再用 ==。同类现象还有:Long 缓存 -128127、Byte/Boolean/Character(0~127) 全缓存、Boolean 只有 TRUE/FALSE 两个实例。JVM 参数 -XX:AutoBoxCacheMax 可以扩大上限,但不要依赖它。
Q2. String 为什么设计成不可变?有什么代价?
参考答案(点击展开)
收益:① 天然线程安全,无需同步;② hashCode 可缓存(String 作 HashMap 的 key 极快);③ 字符串常量池复用,节省内存;④ 安全(类名、URL、文件路径作为参数不会被调用方偷偷改掉)。代价:每次"修改"都产生新对象,大量拼接会制造垃圾------所以用 StringBuilder / 文本块 / String.join,不要在循环里用 +。另外 substring 在 JDK 7u6 之后已改为拷贝,不再持有原 char\[\],避免内存泄漏。
Q3. 0.1 + 0.2 为什么不等于 0.3?金额该怎么存?
参考答案(点击展开)
double 是 IEEE 754 二进制浮点,0.1 无法被精确表示,累加后误差暴露。金额一律用 BigDecimal,且必须用字符串构造器 new BigDecimal("19.90")(用 new BigDecimal(19.90) 会把 double 的误差原样带进来);比较大小用 compareTo 而不是 equals(equals 要求 scale 也相同,19.9 与 19.90 不相等);除法必须指定精度与舍入模式,否则遇到无限小数抛 ArithmeticException。数据库对应 DECIMAL(18,2);也可以按"最小单位整数"(分)存储,避免浮点与序列化问题。
⚠️ 前端容易踩的坑
- 用 == 比较字符串或包装类:本地测试可能因为常量池/缓存"刚好通过",上线后偶发失效------最经典的线上事故之一。
- 用 double 做金额计算,对账时出现 0.01 的差额。
- 在循环里用 String += 拼接大文本,性能退化到 O(n²),还可能打满 GC。
- 把可变对象当 HashMap 的 key,put 之后修改字段 → get 不到,且造成隐蔽的内存泄漏。
Day 3 · 方法、流程控制与异常体系
所属阶段 :Week 1 · Java 语言与工程基础 | 建议时长 :2h | 进度:第 3 / 30 天
今日导语:今天补齐两件每天都在用的事:方法的写法(重载、可变参数、值传递),以及 Java 独有的"受检异常"机制。异常体系决定了错误怎么传递、在哪一层处理------这也是前端转后端最需要重建的思维:JS 里 try/catch 是补救,Java 里异常是架构的一部分。
前端视角:直觉映射:Java 的 try/catch 和 JS 几乎一样,但多了一个编译期强制处理的"受检异常"(类似 TS 里被类型系统逼着处理的 Result)。try-with-resources ≈ Go 的 defer / C# 的 using,出作用域自动 close。
🎯 今日目标
- 掌握方法签名、重载与可变参数,理解 Java 只有值传递
- 分清 Checked / Unchecked 异常,知道工程上该如何选择
- 用 try-with-resources 正确释放资源,会写自定义业务异常
📌 方法与参数传递
- 方法签名 = 方法名 + 参数类型列表(不含返回值与异常)。重载靠参数列表区分;Java 不允许仅靠返回值重载。
- 可变参数 int... nums 本质是数组,必须放参数列表最后;调用时可传 0 个。
- Java 只有值传递:基本类型传值副本;引用类型传"引用的副本"。所以方法内 obj.setXxx() 会影响外部对象,但 obj = new Obj() 不会影响外部引用------这点常被误解。
- 参数较多时用 record 封装成命令对象(CreateUserCmd),比长参数列表可读且易扩展(加字段不改签名)。
📌 异常体系
- Throwable → Error(JVM 级,如 OutOfMemoryError,不要捕获) / Exception。Exception 下分:RuntimeException 及其子类(非受检,编译器不强制)+ 其余受检异常(IOException、SQLException 等,必须 try/catch 或 throws)。
- 工程实践:业务开发更推崇自定义 RuntimeException(BizException)。受检异常会"污染"调用链------每层都要声明 throws,最后往往被 catch (Exception e) 一锅端。
- Spring 生态默认围绕 RuntimeException 设计:@Transactional 默认只回滚 RuntimeException/Error,@RestControllerAdvice 也在边界统一处理。
- 异常链:throw new BizException("读取配置失败", e) 保留 cause;丢掉 cause 等于自断线索。
- 异常要带上下文(哪个用户、哪个订单号),但不要把密码、身份证等敏感信息放进异常消息。
📌 try-with-resources 与 finally
- 实现 AutoCloseable 的对象写在 try(...) 里,无论正常结束还是抛异常都会自动 close;close 抛出的异常会被"抑制"并附加到主异常上(getSuppressed)。
- 执行顺序:try 块 → 资源 close → finally → 返回(返回值在 finally 前已计算好;finally 里写 return 会覆盖返回值并吞掉异常,禁止这样写)。
- 需要关闭的资源:文件流、网络连接、JDBC 连接(Spring 托管的不用手动关)、锁。
- JDK 9+ 可以写 try (reader) 直接引用已存在的 final 变量,不必在括号里 new。
💻 异常与资源管理完整示例
java
// 1) 可变参数 + 值传递演示
public static int sum(int... nums) {
int s = 0;
for (int n : nums) s += n;
return s; // sum() == 0,sum(1,2,3) == 6
}
static void reassign(StringBuilder sb) { sb = new StringBuilder("new"); }
static void mutate(StringBuilder sb) { sb.append("!"); }
// 调用 mutate 后外部可见;调用 reassign 后外部不变 ------ Java 只有值传递
// 2) try-with-resources:自动 close,异常也不丢
public String readFirstLine(Path p) {
try (var reader = Files.newBufferedReader(p, StandardCharsets.UTF_8)) {
return reader.readLine();
} catch (IOException e) {
throw new BizException("读取配置文件失败: " + p, e); // 保留 cause
}
}
// 3) 自定义业务异常(带错误码,供全局异常处理器使用)
public class BizException extends RuntimeException {
private final int code;
public BizException(String msg) { this(400, msg); }
public BizException(int code, String msg) { super(msg); this.code = code; }
public BizException(int code, String msg, Throwable cause) { super(msg, cause); this.code = code; }
public int getCode() { return code; }
}
// 4) 兜底:只捕获你能真正处理的异常
try {
return parse(input);
} catch (NumberFormatException e) { // 具体异常优先于宽泛异常
return defaultValue;
} finally {
MDC.remove("traceId"); // 清理线程变量,别在这里 return
}
🛠 动手任务
- 实现一个 FileStore 工具类(save/read/delete),把 IOException 统一包装为 BizException,并打印一次完整堆栈,观察 Caused by 链条。
- 写一个小 demo 验证"值传递":分别调用 reassign 与 mutate,打印调用前后对象的变化,把结论写进注释。
- 故意写一个空 catch,看看 IDEA 与构建检查会给出什么提示,体会静态检查的价值。
✅ 自测题与参考答案(3 题)
Q1. 受检异常和非受检异常各适合什么场景?为什么 Spring 生态偏爱后者?
参考答案(点击展开)
受检异常适合"调用方必须处理且能恢复"的场景(如文件不存在时改走默认配置),它把错误写进方法签名,编译器强制你面对。非受检异常(RuntimeException)适合编程错误与业务规则违背(参数非法、余额不足),通常一路抛到系统边界统一处理。Spring 偏爱后者的原因:① 受检异常会逐层 throws 污染签名,在接口与 Lambda 里尤其痛苦;② @Transactional 默认只对 RuntimeException/Error 回滚,受检异常不回滚更符合"可恢复"的语义;③ 配合 @RestControllerAdvice 统一出口,异常不必在每个方法里处理;④ Spring 自己的异常体系(DataAccessException)全部是非受检的。
Q2. finally 与 try-with-resources 同时存在时,执行顺序是什么?
参考答案(点击展开)
先执行 try 块;无论是否异常,先执行 try-with-resources 的 close()(按声明的逆序关闭),再执行 finally;最后才是方法返回(返回值在 finally 执行前就已计算好)。若 close() 与 try 块都抛异常,try 块的异常作为主异常,close 的异常通过 addSuppressed 挂在它上面。注意两条铁律:① finally 里写 return 会吞掉 try 中的异常并覆盖返回值;② 清理 ThreadLocal / MDC / 锁这类动作放 finally 是对的,但别在里面做可能抛异常的事。
Q3. 为什么不要写 catch (Exception e) {} 空实现?正确做法是什么?
参考答案(点击展开)
空 catch 让故障完全静默:请求失败、数据不一致、用户看到空白页,而日志里什么都没有,排查成本极高。正确做法:① 能恢复就恢复并记 WARN(降级返回缓存/默认值);② 不能处理就包装成业务异常继续上抛(throw new BizException(msg, e)),由全局异常处理器统一转成响应;③ 至少 log.error 带上下文与堆栈;④ 不要捕获 Error 与 Throwable(会连 OOM、StackOverflow 一起吞掉,那时 JVM 已无法保证状态);⑤ 多个 catch 要从具体到宽泛排序,否则编译报错。
⚠️ 前端容易踩的坑
- 前端习惯"用 try/catch 包住一切",后端里这会让事务回滚失效(异常被吞,@Transactional 以为是成功提交)。
- 在循环里捕获异常却不 break,瞬间刷出几万条日志把磁盘打满。
- 把异常当流程控制(大量 try/catch 替代 if 判断),可读性和性能都变差。
- 自定义异常忘了传 cause,线上只能看到最外层的"操作失败",看不到真正的 SQL 超时。
Day 4 · 面向对象:类、接口、record 与枚举
所属阶段 :Week 1 · Java 语言与工程基础 | 建议时长 :2-3h | 进度:第 4 / 30 天
今日导语:Java 是"一切皆类"的语言,没有一等函数与字面量对象。今天要把 OOP 落到具体写法上:什么时候用接口、什么时候用抽象类、record 怎么用、枚举能做多少事。目标是能用最少的类把业务模型表达清楚,而不是搭一棵庞大的继承树。
前端视角:直觉映射:Java 的 interface ≈ TS 的 interface + 抽象基类(可多实现);record ≈ TS 的 type/interface,但自动生成构造器、equals/hashCode/toString;enum 比 TS 的联合类型强得多------可以带字段、方法和行为。
🎯 今日目标
- 掌握封装、继承、多态在 Java 中的具体语法与运行时行为(动态分派)
- 能判断接口 vs 抽象类的选择,会用 default 方法
- 用 record 写 DTO、用 enum 写状态机、用 Builder 构造复杂对象
📌 类与对象基础
- 封装:字段 private + getter/setter;IDEA 可一键生成,也可用 Lombok @Getter/@Setter/@Data/@Builder 消除样板代码(团队要统一约定)。
- 继承:extends 单继承,子类拥有父类非 private 成员;方法重写要加 @Override(不加也能编译,但加了能让编译器帮你检查签名是否正确)。
- 多态:父类型引用指向子类型对象,方法调用在运行时按实际类型分派(虚方法表)。这让"面向接口编程 + 注入不同实现"成为可能------Spring 的依赖注入正是建立在这上面。
- 构造:无参构造器 + 全参构造器 + Builder。字段多且可选参数多时,Builder 比构造器重载清晰得多。
- 组合优于继承:能用字段拼装就别 extends。继承会把父类实现细节绑死到子类,父类一改全链路崩。
📌 接口 vs 抽象类
- 接口表达"能力/契约",可多实现;抽象类表达"是什么 + 复用代码",单继承。
- Java 8 起接口可有 default 方法(提供默认实现,避免破坏已有实现类)与 static 方法;Java 9 起可有 private 方法。
- 选择口诀:需要多继承的能力、只想定义行为 → 接口;需要共享状态与模板方法流程 → 抽象类。
- Spring 里绝大多数是"接口 + 一个实现类",方便 mock 与替换(这也是 Day 9 依赖注入的基础)。
- 不要在接口里放一堆常量("常量接口"反模式),常量用 final class 或 enum。
📌 record 与 enum
- record:不可变数据载体,自动生成全参构造器、equals/hashCode/toString 与访问器(name() 而非 getName())。适合 DTO、VO、命令对象、Map 的 key。
- record 可以用紧凑构造器做校验:public record Money(BigDecimal v) { Money { Objects.requireNonNull(v); } }。
- enum 是单例的类:可带字段、构造器、方法,还能实现接口。适合状态机、错误码、类型字典------比字符串常量类型安全,比 int 常量可读。
- enum 的坑:不要用 ordinal() 存数据库(顺序一变数据全错),用 name() 或自定义 code 字段;JPA 用 @Enumerated(EnumType.STRING)。
- switch 枚举时编译器可检查穷尽性;新增枚举值后所有 switch 处都会提示,非常安全。
💻 record / enum / 接口实战
java
// 1) DTO:一行搞定,不可变、线程安全
public record UserVo(Long id, String name, String email) {
public UserVo { // 紧凑构造器:校验
Objects.requireNonNull(id, "id 不能为空");
}
}
// 2) 枚举状态机:把流转规则内聚在枚举里
public enum OrderStatus {
PENDING("待支付"),
PAID("已支付"),
CLOSED("已关闭");
private final String label;
OrderStatus(String label) { this.label = label; }
public String getLabel() { return label; }
public boolean canTransferTo(OrderStatus target) {
return switch (this) {
case PENDING -> target == PAID || target == CLOSED;
case PAID -> target == CLOSED;
case CLOSED -> false;
};
}
}
// 3) 接口定义能力,default 提供兜底实现
public interface Payable {
BigDecimal amount();
default BigDecimal fee() { return BigDecimal.ZERO; }
default BigDecimal total() { return amount().add(fee()); }
}
// 4) Builder 构造复杂对象
var order = Order.builder()
.userId(1001L)
.status(OrderStatus.PENDING)
.item(new OrderItem(9L, 2, new BigDecimal("99.00")))
.build();
💻 多态:依赖注入的思想雏形
java
public interface Notifier {
void send(String to, String content);
}
public class SmsNotifier implements Notifier {
@Override public void send(String to, String c) { /* 调短信网关 */ }
}
public class MailNotifier implements Notifier {
@Override public void send(String to, String c) { /* 调邮件服务 */ }
}
// 调用方只依赖接口 ------ 这就是 Spring 注入能工作的原理
public class OrderService {
private final Notifier notifier; // 具体实现由容器决定
public OrderService(Notifier notifier) { this.notifier = notifier; }
public void pay(Long orderId) {
// 业务...
notifier.send("user@example.com", "订单已支付"); // 运行时动态分派
}
}
// 单测里替换实现,无需任何框架
var service = new OrderService(new SmsNotifier());
🛠 动手任务
- 用 record 建模 CreateOrderReq / OrderVo,用 enum 建模订单状态并实现 canTransferTo,非法流转抛 BizException。
- 把一个"计算运费"的逻辑写成接口 Payable + 三种实现(免运费 / 按重量 / 按金额),在上层用注入切换实现------体会多态与 Day 9 的 DI 是同一件事。
- 用 Lombok @Builder 改写你昨天写的一个多字段类,对比可读性差异。
✅ 自测题与参考答案(3 题)
Q1. 重载(overload)与重写(override)的区别?
参考答案(点击展开)
重载是同一个类中方法名相同、参数列表不同(类型/个数/顺序),编译期根据静态类型决定调用哪个,与返回值无关,典型场景是提供不同参数组合的便捷方法。重写是子类重新实现父类的实例方法,要求方法名与参数列表完全相同、返回类型兼容、访问权限不能变小、不能抛出更宽的受检异常,运行期按实际对象类型动态分派。加 @Override 能让编译器校验是否真的重写成功(参数写错时会报错,而不是悄悄变成重载)。注意:private/static/final 方法不能被重写;static 方法可以被"隐藏"但不参与多态。
Q2. record 的字段为什么是 final 的?它适合做 JPA 实体吗?
参考答案(点击展开)
record 的设计目标是"不可变的数据载体",所有组件字段都是 private final,只有访问器没有 setter,天然线程安全、适合做 Map 的 key 与跨层传输。它不适合做 JPA 实体:① JPA 要求实体类非 final、有无参构造器、字段可变(需要通过反射设置字段、支持懒加载代理);② 实体通常需要继承与双向关联,record 不支持继承;③ record 隐含 final 类,无法被 Hibernate 增强。实践:record 用于 DTO/VO/命令对象/投影查询的返回值,JPA 用普通 class(@Entity + Lombok)。
Q3. 为什么枚举不要用 ordinal() 持久化?
参考答案(点击展开)
ordinal() 返回枚举声明的序号(从 0 开始)。一旦有人在中间插入新值、删除旧值或调整顺序,已存数据库的数字含义就全变了,造成严重且难以回滚的数据错乱。正确做法:① JPA 用 @Enumerated(EnumType.STRING) 存 name()(可读但占空间、改名要迁移数据);② 或给枚举加一个稳定的业务 code 字段(如 "PAID")并持久化它,兼顾稳定与紧凑;③ MyBatis 同理配置 EnumTypeHandler 用 name 或自定义 code。永远不要依赖声明顺序做业务判断。
⚠️ 前端容易踩的坑
- 滥用继承造出深层类树(BaseEntity → AbstractEntity → BizEntity → Order),改一个父类全链路受影响。
- 在 JPA 实体上用 Lombok @Data:equals/hashCode 包含所有字段,懒加载字段可能触发意外 SQL;双向关联的 toString 还会导致栈溢出(循环引用)。
- 用字符串常量表示状态("PAID"),拼错只能运行时炸,也没有编译期穷尽检查。
- record 里放 List 等可变容器并直接返回,外部仍能改内容------紧凑构造器里应做防御性拷贝(List.copyOf)。
Day 5 · 集合框架与 Stream:把 JS 数组思维翻译过来
所属阶段 :Week 1 · Java 语言与工程基础 | 建议时长 :3h | 进度:第 5 / 30 天
今日导语:集合是 Java 业务代码的骨架,Stream 是你最熟悉的"数组方法"在 Java 里的对应物。今天两个目标:看到数据处理需求能立刻选对集合类型;能把 JS 的 map/filter/reduce 直觉正确翻译成 Stream,并知道它惰性与不可复用的特性。
前端视角:直觉映射:Stream 的 map/filter/reduce/anyMatch/toList ≈ JS 的 map/filter/reduce/some/结果。三个关键区别:① Stream 是惰性的,没有终止操作就不执行;② Stream 只能消费一次(第二次调用抛 IllegalStateException);③ parallelStream 默认共享公共线程池,别乱用。
🎯 今日目标
- 熟用 List / Set / Map 三大族及其实现类,理解各自复杂度
- 理解 HashMap / ArrayList 的底层结构与扩容代价
- 用 Stream + Lambda 写声明式数据处理,会用 Collectors 做分组聚合
📌 三大集合族与选型
- List(有序可重复):ArrayList 随机访问 O(1)、默认首选;LinkedList 实际几乎不用(内存开销大、缓存局部性差);并发读多写少用 CopyOnWriteArrayList。
- Set(去重):HashSet(无序,O(1))、LinkedHashSet(保持插入顺序)、TreeSet(有序,O(log n))。去重依赖 equals/hashCode。
- Map(键值):HashMap(默认)、LinkedHashMap(保持顺序,可做 LRU)、TreeMap(按 key 排序)、ConcurrentHashMap(并发安全,性能远好于 Hashtable 的全局锁)。
- Queue/Deque:ArrayDeque(栈与队列首选,别用古老的 Stack 类)、PriorityQueue(堆,TopK 场景)、BlockingQueue(线程池与生产者消费者)。
- 选型口诀:要顺序用 List,要去重/判存在用 Set,要按 key 查用 Map,要排队用 Queue。
📌 HashMap 与 ArrayList 的底层
- HashMap:数组 + 链表 + 红黑树。hash(key) 定位桶;冲突时链表,链表长度 ≥ 8 且数组 ≥ 64 时树化为红黑树(防哈希碰撞攻击);负载因子 0.75,扩容需 rehash,代价高。
- 建 Map 时预估容量:new HashMap<>(expectedSize * 4 / 3 + 1),避免多次扩容。
- key 必须正确实现 hashCode/equals,且参与计算的字段在放入后不可变(record 自动满足,普通类要小心)。
- ArrayList:Object\[\] 数组,默认容量 10,扩容 1.5 倍并 Arrays.copyOf;已知大小时用 new ArrayList<>(n) 减少拷贝。
- fail-fast:迭代中修改集合会抛 ConcurrentModificationException;删除请用 Iterator.remove() 或 removeIf()。
📌 Stream 实战要点
- 流水线 = 数据源 + 0~n 个中间操作 + 1 个终止操作。中间操作(map/filter/sorted/distinct/peek/skip/limit)惰性,终止操作(collect/toList/count/findFirst/anyMatch/reduce)触发执行。
- 函数式接口:Function(map)、Predicate(filter)、Supplier、Consumer(forEach)。Lambda 引用的外部局部变量必须是 final 或事实不变。
- Collectors 是最常用的终止器:toList / toSet / toMap / joining / groupingBy / partitioningBy / summingInt / reducing / collectingAndThen。
- Optional 常与 Stream 配合:findFirst() 返回 Optional,别直接 get()。
- 调试用 peek 打日志;性能敏感的热路径(百万级)用普通 for 循环更快(Stream 有装箱与调用开销)。
💻 Stream 常见套路
java
// 1) 分组聚合:每个用户的已支付订单总额
Map<Long, BigDecimal> totalByUser = orders.stream()
.filter(o -> o.getStatus() == OrderStatus.PAID)
.collect(Collectors.groupingBy(
Order::getUserId,
Collectors.reducing(BigDecimal.ZERO, Order::getAmount, BigDecimal::add)));
// 2) 提取字段、去重、排序、转不可变 List
List<String> names = users.stream()
.map(User::getName)
.filter(Objects::nonNull)
.distinct()
.sorted(Comparator.comparing(String::length).reversed())
.toList(); // JDK 16+,返回不可变 List
// 3) toMap 必须处理重复 key,否则直接抛异常
Map<Long, User> byId = users.stream()
.collect(Collectors.toMap(User::getId, Function.identity(), (a, b) -> a));
// 4) 分页 + 统计 + 拼接
var page = orders.stream().skip(20L).limit(10).toList();
long paid = orders.stream().filter(o -> o.getStatus() == PAID).count();
String joined = names.stream().collect(Collectors.joining(", ", "[", "]"));
// 5) 多级分组 + 下游收集
Map<String, Map<OrderStatus, Long>> stat = orders.stream()
.collect(Collectors.groupingBy(o -> o.getRegion(),
Collectors.groupingBy(Order::getStatus, Collectors.counting())));
💻 集合使用中的坑与正确写法
java
// 坑 1:Stream 只能消费一次
Stream<String> s = list.stream().filter(x -> x.length() > 2);
s.count(); // OK
s.toList(); // IllegalStateException: stream has already been operated upon
// 坑 2:迭代中删除元素
for (String x : list) { if (x.isBlank()) list.remove(x); } // ConcurrentModificationException
list.removeIf(String::isBlank); // 正确
// 坑 3:Arrays.asList 返回定长 List
List<String> fixed = Arrays.asList("a", "b");
fixed.add("c"); // UnsupportedOperationException
// 需要可变:new ArrayList<>(Arrays.asList("a","b"))
// 坑 4:List.of / Map.of 返回不可变集合且不允许 null
List<String> im = List.of("a", "b");
im.add("c"); // UnsupportedOperationException
// 坑 5:并行流默认共享 ForkJoinPool.commonPool
list.parallelStream().forEach(...); // 可能拖垮整个应用,IO 密集反而更慢
🛠 动手任务
- 挑一段你熟悉的前端数据处理逻辑(按用户聚合、按状态筛选、取 Top N、字段拼接),用 Stream 完整翻译一遍并写单测验证结果一致。
- 造 10 万条数据,对比「for 循环」「Stream」「parallelStream」在过滤+求和场景下的耗时,把结论记下来(不要想当然认为并行更快)。
- 用 PriorityQueue 实现 TopK(取金额最大的 10 笔订单),对比 sort + limit 的写法差异。
✅ 自测题与参考答案(3 题)
Q1. Stream 为什么不能复用?forEach 里修改外部 List 有什么问题?
参考答案(点击展开)
Stream 是一次性管道,终止操作会消费数据源并触发整条流水线,之后再调用会抛 IllegalStateException。需要多次使用就重新创建 Stream(或先 collect 成 List 再多次 stream())。forEach 里修改外部集合属于副作用:① 破坏函数式的可推理性,结果与顺序依赖外部状态;② 在 parallelStream 下有线程安全问题(应改用 Collectors 或线程安全容器);③ 正确做法是用 filter/map/collect 声明式地得到新集合,而不是在遍历中 mutate。另外 forEach 无法提前 break,需要短路请用 anyMatch/findFirst/takeWhile。
Q2. HashMap 的 key 是可变对象,put 之后修改了参与 hashCode 的字段会发生什么?
参考答案(点击展开)
put 时按旧 hash 定位到桶 A 存放;修改字段后 hash 变了,再 get 时会去桶 B 查找,结果找不到------表现为"明明 put 进去了却取不出来",且旧条目无法被删除,直到扩容 rehash 才可能意外出现,属于隐蔽的内存泄漏。remove 同理失效。规则:作为 key 的对象必须不可变(用 record 或只含 final 字段的类),或至少保证参与 equals/hashCode 的字段放入后不再修改。若确实需要可变 key,改字段前先 remove 再 put。
Q3. ArrayList 与 LinkedList 该怎么选?为什么几乎不用 LinkedList?
参考答案(点击展开)
绝大多数场景选 ArrayList:随机访问 O(1)、内存连续、CPU 缓存友好、遍历极快;尾部追加均摊 O(1),中间插入删除 O(n)。LinkedList 理论上头尾增删 O(1),但每个节点都要存前后指针(内存约为 ArrayList 的数倍),且内存分散导致缓存命中率低,实测在绝大多数数据量下都慢于 ArrayList。只有在频繁于队列头尾增删且几乎不随机访问时才考虑,而那种场景通常用 ArrayDeque 更好(ArrayDeque 是循环数组,同时支持栈与队列,内存更省)。
⚠️ 前端容易踩的坑
- 在 forEach / map 里做 IO 或查数据库(N+1 查询),慢且难测------应先批量查出来再在内存里组装。
- parallelStream 默认使用公共 ForkJoinPool,一处慢 IO 会拖垮全应用;它只适合 CPU 密集、数据量大、无共享状态的场景。
- Collectors.toMap 遇到重复 key 直接抛 IllegalStateException,必须提供 merge 函数。
- 把 Stream 当"更长的 for 循环"写超长链式调用,可读性反而变差------超过 5 个操作时拆成方法或改用普通循环。
Day 6 · 泛型、Optional 与 java.time
所属阶段 :Week 1 · Java 语言与工程基础 | 建议时长 :2-3h | 进度:第 6 / 30 天
今日导语:泛型是类型安全的支柱,Optional 是对抗 NPE 的工具,java.time 是唯一正确的时间 API。这三样加起来能让你写的代码从"能跑"进化到"不容易错"。今天还会产出两个后面天天要用的类:统一响应体 R 与分页体 PageVo。
前端视角:直觉映射:Java 泛型 ≈ TS 泛型,但存在"类型擦除"(运行时拿不到 T 的真实类型,所以 new T()、new T\[\]、instanceof List 都不行)。Optional ≈ 一个显式表达"可能没有"的盒子,强迫调用方处理空的情况。
🎯 今日目标
- 能写泛型类/泛型方法,理解 PECS 原则与类型擦除
- 用 Optional 优雅处理 null,知道它的适用边界
- 用 java.time 替代 Date/Calendar,并统一前后端时间格式
📌 泛型
- 泛型类 class R、泛型方法 T pick(T a, T b)、泛型接口 interface Repo<T, ID>。命名习惯:T(类型)、E(元素)、K/V(键值)、R(返回)。
- 通配符:? extends T 表示"T 或 T 的子类型"(生产者,只能读);? super T 表示"T 或 T 的父类型"(消费者,只能写)。口诀 PECS:Producer Extends, Consumer Super。
- 类型擦除:编译后泛型被擦成 Object 或上界,运行时 List 与 List 是同一个类。所以不能 new T()、不能 new T\[\]、不能用 instanceof 判断泛型参数。
- 出现 unchecked cast 警告要认真看------它通常意味着你骗过了编译器,错误被推迟到运行时的强转处。
- 常见用途:统一响应体 R、分页体 PageVo、通用 Repository<T, ID>、工具类 JsonUtil.read(String, Class)。
📌 Optional
- 定位:作为方法返回值表达"可能没有结果"(如 findById),强迫调用方处理。
- 常用方法:ofNullable / of / empty、map、flatMap、filter、ifPresent、orElse、orElseGet、orElseThrow。
- orElse 与 orElseGet 的区别:orElse 的参数总会求值(哪怕有值),orElseGet 只在为空时才调用 Supplier------默认值构造代价高时必须用后者。
- 边界:不要用作字段、参数、集合元素;也不要直接 get()(等于没判空)。
- 链式取值:repo.findById(id).map(User::getProfile).map(Profile::getCity).orElse("未知"),替代三层 if 判空。
📌 java.time(JSR-310)
- 核心类:Instant(时间戳,机器用)、LocalDate / LocalDateTime(本地时间,展示用)、ZonedDateTime(带时区)、Duration(时间段)、Period(日期段)。全部不可变、线程安全。
- 格式化:DateTimeFormatter 不可变可共享;别用 SimpleDateFormat(非线程安全,并发下会输出错乱日期)。
- 前后端约定:统一 ISO-8601 字符串(2026-09-29T10:00:00+08:00)或时间戳,二选一绝不混用;Jackson 配置 WRITE_DATES_AS_TIMESTAMPS=false。
- 数据库:MySQL DATETIME ↔ LocalDateTime,TIMESTAMP ↔ Instant;注意 JDBC 的 serverTimezone 参数。
- 计算:plusDays / plus(Duration)、ChronoUnit.DAYS.between;判断过期用 Instant.now().isAfter(expireAt)。
💻 R 与 PageVo(这两个类你会用满 30 天)
java
public record R<T>(int code, T data, String msg) {
public static <T> R<T> ok(T data) { return new R<>(0, data, "ok"); }
public static <T> R<T> ok() { return new R<>(0, null, "ok"); }
public static <T> R<T> fail(int code, String msg) { return new R<>(code, null, msg); }
public boolean success() { return code == 0; }
}
public record PageVo<T>(List<T> list, long total, int page, int size) {
public static <T> PageVo<T> of(List<T> list, long total, int page, int size) {
return new PageVo<>(list == null ? List.of() : list, total, page, size);
}
public static <T> PageVo<T> empty(int page, int size) {
return new PageVo<>(List.of(), 0, page, size);
}
public int totalPages() { return (int) Math.ceil((double) total / size); }
}
💻 Optional 与时间 API
java
// Optional:优雅替代嵌套判空
String city = userRepo.findById(id)
.map(User::getProfile)
.map(Profile::getCity)
.filter(c -> !c.isBlank())
.orElse("未知");
// orElse vs orElseGet:默认值构造昂贵时必须用 orElseGet
User u1 = cache.get(id).orElse(loadFromDb(id)); // loadFromDb 总会执行!
User u2 = cache.get(id).orElseGet(() -> loadFromDb(id)); // 仅为空时执行
// 抛异常
User u3 = userRepo.findById(id)
.orElseThrow(() -> new BizException(404, "用户不存在"));
// java.time
Instant now = Instant.now(); // 机器时间
LocalDateTime expire = LocalDateTime.now().plusDays(7);
String iso = expire.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
LocalDateTime parsed = LocalDateTime.parse("2026-09-29T10:00:00");
long days = ChronoUnit.DAYS.between(LocalDate.now(), LocalDate.of(2026, 12, 31));
boolean expired = Instant.now().isAfter(claims.getExpiration().toInstant());
// 全局共享的 formatter(线程安全)
private static final DateTimeFormatter FMT =
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss");
🛠 动手任务
- 实现 R 与 PageVo,写一个 demo 返回分页 JSON,确认空列表输出 \[\] 而不是 null。
- 把一段三层 if (x != null) 的嵌套判空用 Optional 重写,并写单测覆盖「中间层为空」的分支。
- 配置 Jackson 让 LocalDateTime 输出为 ISO-8601 字符串(而非数组),Day 10 的接口里就能看到效果。
✅ 自测题与参考答案(3 题)
Q1. 什么是类型擦除?为什么 List 强转成 List 能编译通过、运行时还能 add?
参考答案(点击展开)
Java 泛型只在编译期存在,编译后 T 被擦成 Object(或上界),字节码里没有泛型信息(仅保留在 Signature 属性供反射读取)。因此运行时 List 与 List 是同一个 List 类;强制转换只检查对象本身是 List,不检查元素类型,所以能编译也能通过运行;add(1) 之后再用 String 接收会在隐式强转处抛 ClassCastException------错误被推迟到使用点。这也是为什么不能 new T()、不能 new T\[\]、不能用 instanceof 判断泛型参数、不能重载仅泛型参数不同的方法。要真正约束元素类型,可用不可变包装或在使用处做类型检查。
Q2. Optional 什么时候不该用?
参考答案(点击展开)
① 不要作为实体/DTO 字段:Optional 不可序列化(Jackson 需要额外模块),且"字段可能不存在"应由业务规则表达;② 不要作为方法参数:调用方被迫包一层 ofNullable,反而啰嗦,直接传值在方法内判空即可;③ 不要放进集合(List<Optional>);④ 不要直接 get()(等于放弃判空);⑤ 高频热路径里 Optional 有对象分配成本,普通判空更合适。正确用法只有一个:作为查询方法的返回类型,表达"可能查不到"。另外 Optional 的 equals 语义会让两个 Optional.empty() 相等,别拿它当身份标识。
Q3. 为什么不能用 Date / SimpleDateFormat?前后端时间该怎么约定?
参考答案(点击展开)
Date 可变、API 混乱(年份从 1900 起、月份从 0 起)、不含时区;SimpleDateFormat 非线程安全,多线程共享同一实例会输出错乱日期甚至抛异常。改用 java.time:Instant 存机器时间,LocalDateTime/ZonedDateTime 处理业务时间,DateTimeFormatter 不可变且线程安全,可定义为 static final 共享。前后端约定:统一 ISO-8601 字符串(含时区偏移)或统一毫秒时间戳,二选一并在接口文档写明;Spring 侧配置 spring.jackson.date-format 与 WRITE_DATES_AS_TIMESTAMPS=false,避免 LocalDateTime 被序列化成 2026,9,29,10,0 这类数组。
⚠️ 前端容易踩的坑
- Optional.of(null) 直接 NPE(应该用 ofNullable);Optional.get() 不判空等于另一种 NPE。
- 多处共享 SimpleDateFormat 静态实例,并发下输出 "2026-09-0029" 这类诡异日期。
- 给前端返回 LocalDateTime 数组格式,前端被迫写两套解析逻辑。
- 泛型遇到基本类型时自动装箱(Stream 而非 IntStream),大数据量下有性能与内存成本------数值计算用 IntStream / LongStream。
Day 7 · 工程化:Maven、JUnit5、Mockito 与日志
所属阶段 :Week 1 · Java 语言与工程基础 | 建议时长 :3h | 进度:第 7 / 30 天
今日导语:第一周收官。今天把"写代码"升级为"工程化地写代码":依赖怎么管、测试怎么写、日志怎么打。这三件事决定了你的代码能否被别人维护,也是面试时区分"培训班水平"与"工程水平"的关键。周末建议用今天的内容把前 6 天的练习全部补上测试。
前端视角:直觉映射:Maven 生命周期 ≈ npm scripts 的标准化版本;JUnit ≈ Jest/Vitest;Mockito ≈ vi.fn() / jest.mock();SLF4J ≈ 只依赖 console 接口不关心底层实现(门面模式,底层可换 Logback / Log4j2)。
🎯 今日目标
- 看懂并改写 pom.xml,能定位并解决依赖冲突
- 用 JUnit5 + Mockito 写出真正有用的单元测试(快、稳定、可读)
- 用 SLF4J + Logback 打出规范日志(占位符、级别、上下文)
📌 Maven 依赖管理
- 作用域 scope:compile(默认,打进产物)、provided(容器提供,如 servlet-api、lombok)、runtime(编译不需要运行需要,如 JDBC 驱动)、test(仅测试)、import(引入 BOM 统一版本)。
- 依赖传递与冲突:A→B→C。冲突时 Maven 按「路径最近优先 + 先声明优先」仲裁;用 mvn dependency:tree 定位,用 排除或 锁定版本。
- BOM / 父 POM:spring-boot-dependencies 或 spring-boot-starter-parent 统一管理几百个依赖版本,别自己写版本号。
- 常用插件:maven-surefire-plugin(跑单测)、maven-failsafe-plugin(跑集成测试)、spring-boot-maven-plugin(打 fat jar)、jacoco-maven-plugin(覆盖率)、flyway-maven-plugin。
- 多模块:父工程 packaging=pom + ,子模块独立构建;中大型项目基本都会走到这一步(Day 28 再展开)。
📌 JUnit5 与 Mockito
- 结构:@Test、@BeforeEach/@AfterEach、@BeforeAll/@AfterAll、@DisplayName(中文描述很有用)、@Nested(分组)、assertThrows、assertAll。
- 参数化:@ParameterizedTest + @CsvSource / @ValueSource / @MethodSource,一条用例覆盖多组数据。
- Mockito:@ExtendWith(MockitoExtension.class)、@Mock(造替身)、@InjectMocks(注入被测对象)、when(...).thenReturn(...)、verify/never/times、ArgumentCaptor。
- 测试原则 FIRST:Fast(毫秒级)、Independent(无顺序依赖)、Repeatable(不依赖真实时间与随机数)、Self-validating(必须有断言)、Timely(随代码一起写)。
- 单测不连真实数据库/网络/文件系统;需要真实依赖时用 Testcontainers(Day 23)或 H2(注意方言差异)。
- 命名习惯:should_xxx_when_yyy 或中文 @DisplayName,让失败信息一眼看懂。
📌 日志规范
- 门面 SLF4J + 实现 Logback(Spring Boot 默认)。代码只依赖 LoggerFactory.getLogger(Xxx.class),不依赖具体实现。
- 占位符:log.info("创建订单 orderId={} userId={}", id, userId);不要字符串拼接(占位符在未达级别时不会拼接,性能更好)。
- 级别:ERROR(需人介入)、WARN(可预期业务异常/降级)、INFO(关键状态变化、启动信息)、DEBUG(排查用,线上关闭)、TRACE(几乎不用)。
- 异常日志:log.error("下单失败 orderId={}", id, e) ------ 异常对象放最后一个参数才会打印完整堆栈;拼进消息里只会打印 toString。
- 禁止:System.out.println(不受日志框架管理、无级别、无法采集)、打印密码/身份证/完整请求体。
💻 pom.xml 关键片段
xml
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional> <!-- 不传递给下游 -->
</dependency>
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<!-- 排除冲突依赖示例 -->
<dependency>
<groupId>com.example</groupId>
<artifactId>some-lib</artifactId>
<version>1.0.0</version>
<exclusions>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
💻 单元测试示例(含 Mockito 与参数化)
java
@ExtendWith(MockitoExtension.class)
class OrderServiceTest {
@Mock OrderRepo repo;
@Mock StockClient stockClient;
@InjectMocks OrderService service; // 自动把 mock 注入构造器
@Test
@DisplayName("库存不足时应抛业务异常,且不落库")
void should_throw_when_stock_not_enough() {
when(stockClient.available(9L)).thenReturn(0);
BizException ex = assertThrows(BizException.class,
() -> service.createOrder(9L, 2));
assertEquals(1001, ex.getCode());
verify(repo, never()).save(any()); // 确认没有写库
}
@ParameterizedTest(name = "{0} 件 × {1} 元 = {2}")
@CsvSource({"1,10,10", "2,10,20", "0,10,0"})
void calc_amount(int qty, int price, int expected) {
assertEquals(expected, service.calc(qty, price));
}
@Test
@DisplayName("下单成功后应保存订单,数量正确")
void should_save_order() {
when(stockClient.available(9L)).thenReturn(5);
when(repo.save(any())).thenAnswer(inv -> inv.getArgument(0));
service.createOrder(9L, 2);
var captor = ArgumentCaptor.forClass(Order.class);
verify(repo).save(captor.capture());
assertEquals(2, captor.getValue().getQty());
}
}
💻 日志正确姿势
java
@Slf4j // Lombok 自动生成 log 字段
@Service
public class OrderService {
public Long createOrder(CreateOrderCmd cmd) {
long start = System.currentTimeMillis();
try {
Long id = doCreate(cmd);
log.info("创建订单成功 orderId={} userId={} amount={} cost={}ms",
id, cmd.userId(), cmd.amount(), System.currentTimeMillis() - start);
return id;
} catch (BizException e) {
log.warn("创建订单失败(业务) userId={} code={} msg={}", cmd.userId(), e.getCode(), e.getMessage());
throw e;
} catch (Exception e) {
// 异常对象放最后一个参数,才会打印完整堆栈
log.error("创建订单失败(系统) userId={} cmd={}", cmd.userId(), cmd, e);
throw new BizException(500, "下单失败,请稍后重试");
}
}
}
💻 logback-spring.xml 片段
xml
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/app.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>100MB</maxFileSize>
<maxHistory>14</maxHistory>
<totalSizeCap>2GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{HH:mm:ss.SSS} [%thread] %-5level %logger{36} traceId=%X{traceId} - %msg%n</pattern>
</encoder>
</appender>
<springProfile name="prod">
<root level="WARN">
<appender-ref ref="FILE"/>
</root>
</springProfile>
🛠 动手任务
- 给本周写的所有代码补单元测试,用 mvn test jacoco:report 看覆盖率,核心逻辑覆盖到 70% 以上。
- 故意引入两个版本不同的同一依赖,用 mvn dependency:tree 定位并用 或 解决。
- 配置 logback-spring.xml:控制台输出 + 文件按天滚动 + 不同包不同级别,并确认 ERROR 日志带完整堆栈。
✅ 自测题与参考答案(3 题)
Q1. test / provided / runtime 三种依赖作用域的区别是什么?
参考答案(点击展开)
compile(默认):编译、测试、运行都可见,打进最终产物,如 spring-web。provided:编译与测试可见,运行时由容器/JDK 提供,不打包,典型如 servlet-api(Tomcat 自带)、lombok(仅编译期)。runtime:编译不需要(代码不直接引用其类),运行与测试需要,典型如 MySQL JDBC 驱动(代码面向 DataSource 接口编程)。test:仅测试编译与运行可见,如 JUnit、Mockito、Testcontainers。选错 scope 的后果:lombok 写成 compile 会污染下游依赖;驱动写成 test 会导致线上 ClassNotFound;provided 用错会导致本地能跑、部署后缺类。
Q2. 为什么单元测试里不能连真实 MySQL?那数据访问怎么测?
参考答案(点击展开)
连真实库会让测试变慢(毫秒→分钟)、不稳定(数据被别人改、网络抖动)、不可并行,还要求人人本地装数据库,最终导致大家不跑测试。做法:① Service 层单测用 Mockito mock 掉 Repository,只验证业务规则与调用行为(verify);② 需要验证 SQL 正确性时用 Testcontainers 起一次性真实 MySQL(Day 23),或用 H2 但要注意方言差异(窗口函数、JSON 类型、索引行为不同,容易"本地过、线上挂");③ 把涉及数据库的测试单独分组(*IT 后缀),用 maven-failsafe-plugin 在 verify 阶段跑,与快的单测分开执行。
Q3. log.error("失败 id=" + id, e) 与 log.error("失败 id={}", id, e) 有什么区别?
参考答案(点击展开)
第一种先做字符串拼接:无论日志级别是否开启都会执行拼接,浪费 CPU;第二种用占位符,当 ERROR 级别未开启时不会拼接(惰性求值)。更关键的是:SLF4J 会识别最后一个参数为 Throwable 并打印完整堆栈,但如果把异常拼进字符串(e.getMessage()),堆栈就丢失了,排查时只剩一行"失败:null",根本不知道错在哪。所以规则是:异常必须作为最后一个参数传入,且使用它的方法重载(多个占位符时最后一个参数是异常,不会占用占位符)。
⚠️ 前端容易踩的坑
- 单元测试里 Thread.sleep(3000) 等真实时间 → 用 Clock 注入、Awaitility 或虚拟时间,否则 CI 越来越慢。
- 为了覆盖率写 assertEquals(1, 1) 这种假测试------覆盖率是结果不是目标,应考核"关键分支是否被覆盖"。
- 日志里打印完整请求体(含密码、身份证、银行卡)→ 合规风险,必须脱敏(只打 ID 与关键字段)。
- 在循环里打 INFO 日志(每轮一条),一次请求刷出上万行,磁盘打满、性能骤降。