阶段 0 · 纯 Java · JDK 17 本阶段是后续 11 个 Spring Boot
阶段的「地基」:不涉及任何 Spring
概念,只解决一个问题——把一个有编程基础、但没写过生产级 Java
的人,训练成能写出健壮、可维护、可运行 Java 代码的后端预备选手。
1. 导语
你可能会问:都要学 Spring Boot 了,为什么还要回头补 Java?
因为 Spring Boot 本身就是一个 Java
框架。框架替你做的事越多,你对底层「为什么会这样、坑在哪里」的判断就越重要。真实生产环境的排查,往往不是「报错了找不到方法」这种明面上的问题,而是下面这类隐性问题:
- 一段
HashMap遍历,线上偶发
ConcurrentModificationException,本地却怎么都复现不出来; - 一个实体类只重写了
equals没重写
hashCode,导致Set去重和Map
查找「看运气」失效; - 用
Executors.newFixedThreadPool()
创建线程池,大促流量一上来直接 OOM; ThreadLocal用了不
remove,线程池复用线程后用户 A 读到了用户 B 的
traceId。
这些都不是「语法不会」,而是「没有建立生产级编码的肌肉记忆」。本阶段的目标就是把这块地基打牢。
学完本阶段,你能做到:
- 独立写出可运行、有校验、有异常处理、有日志、有注释的 Java
代码; - 讲清
HashMap的底层原理,能根据场景正确选型集合; - 写出泛型工具类、自定义注解,并用反射看懂框架级代码;
- 用
Stream和Optional
写出简洁且不踩坑的数据处理代码; - 手动配置生产级线程池,理解
volatile/synchronized/CAS
的边界; - 用 Maven
管理一个多模块项目,并串起一个完整的「用户管理」小项目。
本阶段与后续阶段的关系:它不依赖任何前置阶段(有任意编程语言经验即可);它产出的
ErrorCode、BizException 等公共类,会被后续
Spring Boot
阶段逐字复用。所以这一篇请务必逐行敲一遍,而不是只读。
2. 学习目标与前置要求
学完你能…
- 能搭环境:独立安装/切换 JDK 17,配置
JAVA_HOME,用 IDEA 断点调试一个程序。 - 能选集合:讲清
ArrayList/LinkedList/HashMap/HashSet
的底层与选型,正确重写equals/hashCode。 - 能写框架级代码:用泛型
PECS
写工具方法,自定义注解并用反射读取,理解动态代理。 - 能写简洁代码:用
Stream
三阶段完成过滤、分组、转 Map、统计,用Optional
正确表达「可能为空」。 - 能建异常体系:定义
ErrorCode+
BizException,配置 SLF4J + Logback 日志。 - 能配线程池:手写
ThreadPoolExecutor
七参数与拒绝策略,用ThreadLocal传 traceId 且不泄漏。 - 能用
Maven:管理依赖冲突、多模块项目,理解私服的作用。 - 能写规范代码:遵循阿里 Java 开发手册与 Clean
Code,串起「用户管理」小项目。
前置要求
- 依赖阶段:无。有任意一门编程语言(Python / JS / Go
/ C 等)的使用经验即可。 - 建议具备:基本数据类型、循环、函数、面向对象的概念性理解。若完全零基础,建议先看任意一本
Java 入门书(如《Head First Java》)的前 6 章,再回到本篇。 - 工具:一台能联网的电脑,安装 JDK 17、IntelliJ
IDEA(社区版即可)、Maven 3.8+。
3. 环境准备
本篇所有代码统一基于 JDK 17(LTS 版本,Spring Boot
3.x 要求的最低版本)。下文给出 Linux/macOS/Windows
三平台的安装要点,重点讲清 JAVA_HOME
与「版本切换」这两个新手最容易卡住的点。
3.1 安装 JDK 17
推荐使用 OpenJDK 的发行版 Eclipse Temurin(原
AdoptOpenJDK),免费且生产可用。
- Linux(Ubuntu/Debian):
# 安装 Temurin 仓库后执行
sudo apt-get update
sudo apt-get install -y temurin-17-jdk
- macOS(Homebrew):
brew install --cask temurin@17
- Windows:到 Adoptium
官网 下载.msi安装包,一路下一步,安装时勾选「Set
JAVA_HOME variable」。
3.2 配置 JAVA_HOME 与环境变量
JAVA_HOME 是很多工具(Maven、Gradle、IDEA、Tomcat)寻找
JDK 的唯一依据,必须指向 JDK
的安装根目录(不是 bin 目录)。
- Linux/macOS,编辑
~/.bashrc(或
~/.zshrc):
# 替换成你实际的安装路径(可用 `readlink -f $(which java)` 查到)
export JAVA_HOME=/usr/lib/jvm/temurin-17-jdk-amd64
export PATH=$JAVA_HOME/bin:$PATH
source ~/.bashrc # 让配置立即生效
- Windows:系统环境变量中新建
JAVA_HOME,值为
C:Program FilesEclipse Adoptiumjdk-17.0.x.y-hotspot,再把
%JAVA_HOME%bin追加到Path。
3.3 验证与版本切换
java -version
# 期望输出类似:
# openjdk version "17.0.12" 2024-07-16
# OpenJDK Runtime Environment Temurin-17.0.12+7 (build 17.0.12+7)
# OpenJDK 64-Bit Server VM Temurin-17.0.12+7 (build 17.0.12+7)
javac -version # javac 17.0.12
版本切换:如果你机器上装了多个
JDK,切换的本质就是改
JAVA_HOME,而不是「卸载重装」。常用的三种做法:
- 手动改环境变量(最朴素);
- 用 SDKMAN(Linux/macOS):
sdk install java 17.0.12-tem
→sdk use java 17.0.12-tem; - Windows 用
setx JAVA_HOME "..."后重开终端。
关键点:
java -version显示的是PATH
里第一个java的版本,javac -version
同理。判断「我到底在哪个 JDK 下编译」就看JAVA_HOME,别只看
java -version。
3.4 安装 IDEA 与 Maven
- IntelliJ IDEA Community:到 JetBrains
官网下载社区版即可,本篇用到的调试、重构功能社区版全都有。 - Maven:Linux/macOS 用
brew install maven或
apt-get install maven;Windows 下载 zip 解压后配
MAVEN_HOME与Path。验证:
mvn -version
# Apache Maven 3.9.x ...
第 1~6 章的绝大多数示例用 JDK 自带的
javac/java即可运行,不依赖
Maven;只有第 5 章日志、第 7 章及实战项目用到
Maven。这样安排是为了让你先用最小的工具负担把语言本身学扎实。
4. 正文章节
第 1 章 环境搭建与工具链
1.1 第一个程序:HelloWorld
新建文件 HelloWorld.java,内容如下:
// HelloWorld.java —— JDK 11+ 支持直接 `java HelloWorld.java` 单文件运行,无需先 javac
public class HelloWorld {
public static void main(String[] args) {
// println 是 System.out 的静态调用;生产代码禁止用 System.out 打日志,见第 5 章
System.out.println("Hello, Java 17!");
}
}
两种运行方式:
# 方式一:传统两步(编译 + 运行)
javac HelloWorld.java # 生成 HelloWorld.class 字节码
java HelloWorld # 运行字节码,输出 Hello, Java 17!
# 方式二:单文件源码启动(JDK 11+ 特性,适合快速验证)
java HelloWorld.java
javac把.java编译成
.class(字节码),java通过 JVM
解释/即时编译执行字节码。这是
Java「一次编译,到处运行」的基石——.class
文件与操作系统无关,只要有对应版本的 JVM 就能跑。
1.2 生产级习惯:从第一行就做对
虽然只是 HelloWorld,但两个生产级习惯要从现在建立:
- 类名与文件名一致且用
UpperCamelCase(HelloWorld→
HelloWorld.java)。这是 Java 编译器的硬性要求(对
public类而言),也是阿里手册的命名规范。 - 一个
.java文件只有一个public
顶层类。多个public类会直接编译报错。
1.3 IDEA 高效使用
IDEA 是 Java 后端的事实标准
IDE。下面这些快捷键会伴随你整个职业生涯,第 1 天就练成肌肉记忆:
| 快捷键(Windows/Linux) | macOS | 作用 |
|---|---|---|
psvm + Tab |
同左 | 生成 public static void main |
sout + Tab |
同左 | 生成 System.out.println() |
Alt + Enter |
Option + Enter |
万能修复:导包、补异常、抽变量 |
Ctrl + Shift + F10 |
Ctrl + Shift + R |
运行当前类 |
Ctrl + Alt + L |
Option + Cmd + L |
格式化代码 |
F7 / F8 / F9 |
同左 | 调试:单步进入 / 单步跳过 / 跳到下一个断点 |
Ctrl + Shift + A |
Cmd + Shift + A |
搜索任意动作(迷路时用它) |
1.4
断点调试演示(生产排查的起点)
生产环境排查问题的核心能力,是在本地复现 +
断点观察变量。下面用一段有 bug 的代码演示完整调试流程:
// DebugDemo.java —— 一个刻意写错的累加逻辑,用来演示断点
public class DebugDemo {
public static void main(String[] args) {
int[] nums = {1, 2, 3, 4, 5};
int sum = sumEven(nums); // 期望只累加偶数,结果却不对
System.out.println("偶数之和 = " + sum);
}
// 注意:循环里 i++ 与 i+=2 的边界差异,故意留 bug
private static int sumEven(int[] arr) {
int sum = 0;
for (int i = 0; i < arr.length; i++) {
sum += arr[i]; // 这里没判断奇偶,是 bug
}
return sum;
}
}
调试步骤:
- 在
sum += arr[i];
这一行左侧单击,打上断点(红点); - 右键
Debug 'DebugDemo'启动(而非 Run); - 程序停在断点处,观察下方 Variables 面板中
i、arr[i]、sum的实时值; - 按
F8逐行执行,看sum
每次加的是奇数还是偶数——你会立刻发现i
走到奇数下标时也在累加; - 修复:
sum += arr[i];改为
if (arr[i] % 2 == 0) { sum += arr[i]; }; - 再运行,输出
偶数之和 = 6(2 + 4)。
关键点:「会打断点、会看变量、会单步执行」是后端工程师的基本功。遇到「本地不报错、线上报错」,第一反应永远是「在可疑行打断点,看变量值到底变成了什么」,而不是对着代码空想。
1.5 JDK / JRE /
JVM:三个词别再混了
这是面试和日常沟通的高频概念,必须分清:
- JVM(Java Virtual
Machine):执行字节码的虚拟机,负责类加载、内存管理、垃圾回收(GC)、JIT
即时编译。 - JRE(Java Runtime Environment):JVM +
核心类库(基础类),只能运行 Java 程序,不能编译。 - JDK(Java Development Kit):JRE +
开发工具(javac编译器、jdb
调试器、javadoc文档生成器、jconsole
监控等)。
关系:JDK ⊃ JRE ⊃ JVM。开发机装
JDK;生产机器最小化部署往往只装 JRE(容器化时代通常直接装完整
JDK,方便线上 jstack/jmap 排查)。
Java「一次编译、到处运行」的本质:javac 把
.java
编译成平台无关的字节码(.class),不同操作系统上的
JVM
各自解释/即时编译执行这份字节码。所以「到处运行」靠的是各平台都有对应的
JVM,而不是字节码本身能直接跑。
1.6 IDEA 创建项目与目录结构
IDEA 新建 Maven
项目(File → New → Project → Maven)会生成标准目录结构:
demo/
├── pom.xml # Maven 构建配置(第 7 章详解)
└── src/
├── main/
│ ├── java/ # 业务代码(包名对应目录层级)
│ └── resources/ # 配置文件(logback.xml、application.yml 等)
└── test/
└── java/ # 单元测试代码(与 main 同包结构)
关键点:
src/main/java
是代码的家,src/main/resources
是配置的家,src/test/java是测试的家。这是 Maven
的约定优于配置——目录摆对,Maven
自动识别,无需额外配置。
1.7 调试进阶:不止 F8
调试的核心是「控制程序节奏 + 观察状态」。三个进阶技巧:
- 条件断点:在断点上右键,输入条件(如
i == 100),只有条件成立才停下,避免大循环里一步步按到手抽筋; - Evaluate Expression(
Alt + F8/
Option + F8):断点处临时执行一段表达式,查看或修改变量; - Watch:把某个变量加入监视,全程追踪它的变化,不用每次在
Variables 面板里翻找。
1.8 环境排错三招
| 现象 | 原因 | 解决 |
|---|---|---|
java 能用,javac报「不是内部或外部命令」 |
JAVA_HOME 没配,或 Path 没加%JAVA_HOME%bin |
检查两个环境变量是否都配好 |
java -version 与 javac -version版本不一致 |
两个命令在 Path 里找到了不同 JDK |
统一 JAVA_HOME,把 %JAVA_HOME%bin 放Path 最前 |
| 改了环境变量不生效 | 终端缓存了旧环境 | Windows 重开终端;Linux/macOS 执行 source ~/.bashrc或重开 shell |
1.9
classpath:类从哪里来(入门必懂)
java HelloWorld 能跑,是因为 JVM 知道去哪里找
HelloWorld.class。JVM 找类的搜索路径就叫
classpath,默认是当前目录(.)。
当你的类依赖别的类(比如第 5 章的
ErrorCode、BizException),运行时就要让 JVM
能找到它们:
# -d 指定 .class 输出目录;-cp 指定运行时 classpath
javac -d out ErrorCode.java BizException.java BizDemo.java
java -cp out BizDemo
关键点:编译用
javac -d out把.class
输出到out目录,运行时用java -cp out告诉
JVM 去out里找类。第 3 章反射示例、第 6
章线程池示例都走这个套路。Maven(第 7 章)本质上就是在替你管理
classpath——自动下载依赖 jar 并拼进 classpath。
本章小结
JDK 17 安装、JAVA_HOME 配置、IDEA
快捷键与断点调试,是后续所有章节的操作基础;从第一行代码起就遵守命名规范、用调试器替代
print 排查问题。
第 2 章 面向对象与集合框架
后端 90%
的代码在操作集合。本章前半讲面向对象的四大支柱,后半深入集合框架——尤其是
HashMap的原理与equals/hashCode
契约,这是面试必考、生产必踩的点。
2.1
面向对象四支柱(快速过一遍)
// OopDemo.java —— 类 / 继承 / 多态 / 抽象类 / 接口 的最小可用示例
public class OopDemo {
// 抽象类:不能 new,抽取公共状态与模板方法
abstract static class Animal {
protected String name;
Animal(String name) { this.name = name; }
abstract String sound(); // 抽象方法,子类必须实现
String introduce() { // 模板方法:复用不变逻辑
return name + " 叫:" + sound();
}
}
// 接口:定义能力契约,可多实现(Java 类是单继承、接口多实现)
interface Pet {
void play(); // 接口方法默认 public abstract
}
// 子类:继承抽象类 + 实现接口
static class Dog extends Animal implements Pet {
Dog(String name) { super(name); }
@Override String sound() { return "汪汪"; }
@Override public void play() { System.out.println(name + " 在接飞盘"); }
}
static class Cat extends Animal implements Pet {
Cat(String name) { super(name); }
@Override String sound() { return "喵喵"; }
@Override public void play() { System.out.println(name + " 在玩毛线球"); }
}
public static void main(String[] args) {
// 多态:父类引用指向子类对象,运行时才决定调用哪个实现
Animal a1 = new Dog("旺财");
Animal a2 = new Cat("咪咪");
System.out.println(a1.introduce()); // 旺财 叫:汪汪
System.out.println(a2.introduce()); // 咪咪 叫:喵喵
((Pet) a1).play(); // 转型到接口,调用 play
}
}
四个概念一句话定位:
- 封装:用
private
隐藏内部状态,只暴露必要方法,防止外部随意破坏对象状态。 - 继承:
extends复用父类代码;Java
单继承(一个类只能有一个父类),用接口弥补「多继承」需求。 - 多态:父类引用指向子类对象,方法调用在运行时动态绑定(动态分派)。这是
Spring 里「面向接口编程」的语言基础。 - 抽象:
abstract类/方法与
interface
都是「只定契约、不写实现」,把「做什么」和「怎么做」分离。
2.2 集合框架总览与选型
Java 集合分两大接口族:Collection(单列:List / Set /
Queue)和 Map(双列:键值对)。生产选型一句话决策表:
| 集合 | 底层结构 | 特点 | 生产选型场景 |
|---|---|---|---|
ArrayList |
动态数组 | 按下标查快 O(1),中间插入慢 O(n) | 默认 List 选择,读多写少 |
LinkedList |
双向链表 | 头尾插入删除快 O(1),按下标查慢 O(n) | 需要频繁头插/当队列(一般用 ArrayDeque 替代) |
HashMap |
数组+链表+红黑树 | 平均 O(1) 查 | 默认 Map 选择 |
LinkedHashMap |
链表 + 哈希 | 保持插入顺序 | 需要有序遍历 / 做 LRU 缓存 |
TreeMap |
红黑树 | 按 key 排序 | 需要 key 有序(如按字典序遍历) |
HashSet |
内部就是 HashMap | 去重 | 去重、判断存在性 |
ConcurrentHashMap |
CAS + 分段锁(JDK8 后) | 并发安全且高效 | 并发场景替代 HashTable |
一句话:能确定用
ArrayList就用
ArrayList,不确定就用
ArrayList;LinkedList
在生产里几乎只出现在「实现队列」的场合,而队列首选
ArrayDeque。
2.3 HashMap
底层原理(本章重点)
HashMap 是面试必考。你需要能不看笔记讲出下面这条 put
流程:
- 定位桶:对 key 调
hashCode(),再经扰动函数hash(key)(高 16
位与低 16 位异或,减少哈希碰撞),最后(n - 1) & hash
定位到数组下标(n是数组长度)。 - 插入:该桶为空则直接放入;有元素则用
equals逐个比对(先比==,再比
equals),key 相同则覆盖 value,否则以链表尾插。 - 树化:链表长度 ≥ 8 且数组长度 ≥ 64
时,链表转红黑树,把最坏 O(n) 降到 O(log n)。 - 扩容:元素个数超过
容量 × 负载因子(0.75)时,数组扩容为 2
倍,所有元素重新散列(rehash)。
三个高频追问的答案:
- 为什么容量是 2 的幂? 因为
(n - 1) & hash等价于
hash % n,但位运算快得多;且n - 1全为
1,能让 hash 的每一位都参与运算,分布更均匀。 - 什么时候树化? 链表长度 ≥ 8 且
数组长度 ≥ 64
两个条件同时满足(数组太小时会先扩容而不是树化,因为扩容性价比更高)。 - 为什么 JDK8 从「头插」改成「尾插」? 头插在并发
resize 时可能形成环形链表,导致get
死循环;尾插规避了这个问题。但这不代表 HashMap
线程安全——并发写仍要ConcurrentHashMap。
2.4 equals 与 hashCode
契约(生产必踩)
契约只有三条,但每一条都有血泪教训:
equals相等的两个对象,hashCode
必须相等;hashCode相等,equals
不一定相等(哈希碰撞);- 重写
equals必须同时重写
hashCode,反之亦然。
为什么必须同时重写?因为 HashSet/HashMap
先按 hashCode 找桶,再用 equals
比对。如果你只重写了 equals 不重写
hashCode,两个业务上相等的对象会因 hashCode
不同而落到不同桶,Set 去重、Map
查找直接「看运气」失效。
// User.java —— 生产级:正确重写 equals / hashCode(用 Objects 工具类,规避 null 判断)
import java.util.Objects;
public class User {
private Long id; // 数据库主键,业务上唯一标识
private String username;
private Integer age;
public User(Long id, String username, Integer age) {
this.id = id;
this.username = username;
this.age = age;
}
// equals:先比引用,再判类型,最后逐字段比对(关键字段是业务主键 id)
@Override
public boolean equals(Object o) {
if (this == o) return true; // 同一引用,直接相等
if (!(o instanceof User)) return false; // 类型不符,直接不等(instanceof 天然防空)
User user = (User) o;
return Objects.equals(id, user.id); // Objects.equals 内部处理 null,避免 NPE
}
// hashCode:与 equals 用到的字段保持一致(都用 id)
@Override
public int hashCode() {
return Objects.hash(id);
}
public Long getId() { return id; }
public String getUsername() { return username; }
public Integer getAge() { return age; }
@Override
public String toString() {
return "User{id=" + id + ", username='" + username + "', age=" + age + '}';
}
}
关键点提示:
- 用
Objects.equals而不是
a.equals(b):前者内部先判
a == null,避免NullPointerException。 equals里用instanceof而不是
getClass():前者对子类更宽容,也更简洁。若要求「严格同类才算相等」才用
getClass()。equals/hashCode
用到的字段必须一致:上面都用id。若
equals用id、hashCode用
username,会出现「equals 相等但 hashCode
不等」这种违反契约的 bug。
2.5 HashMap
遍历的三种方式对比(生产示例)
// MapTraverseDemo.java —— 三种遍历方式的性能与可读性对比
import java.util.HashMap;
import java.util.Map;
public class MapTraverseDemo {
public static void main(String[] args) {
Map<Long, User> userMap = new HashMap<>();
userMap.put(1L, new User(1L, "张三", 25));
userMap.put(2L, new User(2L, "李四", 30));
userMap.put(3L, new User(3L, "王五", 28));
// 方式一(推荐):entrySet —— 一次拿到 key 和 value,无需二次哈希
for (Map.Entry<Long, User> entry : userMap.entrySet()) {
System.out.println(entry.getKey() + " -> " + entry.getValue());
}
// 方式二(不推荐):keySet 再 get —— 每次 get 都要再哈希定位一次桶,性能差
for (Long id : userMap.keySet()) {
System.out.println(id + " -> " + userMap.get(id));
}
// 方式三(JDK8+,最简洁):forEach + Lambda
userMap.forEach((id, user) -> System.out.println(id + " -> " + user));
}
}
关键点:遍历 Map 一律用
entrySet()。keySet()再
get()是「二次哈希」,数据量大时性能明显变差,也是 Code
Review 里的常见驳回点。
2.6 遍历中删除:fail-fast
与正确姿势
// RemoveDemo.java —— 遍历时删除元素会抛 ConcurrentModificationException
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
public class RemoveDemo {
public static void main(String[] args) {
List<String> names = new ArrayList<>(List.of("张三", "李四", "王五"));
// 错误写法:for-each 里直接 list.remove,会抛 ConcurrentModificationException
// for (String name : names) {
// if ("李四".equals(name)) names.remove(name);
// }
// 正确写法一:removeIf(JDK8+,最推荐,语义清晰)
names.removeIf("李四"::equals);
// 正确写法二:Iterator.remove(需要边遍历边做更多逻辑时)
Iterator<String> it = names.iterator();
while (it.hasNext()) {
if ("王五".equals(it.next())) {
it.remove();
}
}
System.out.println(names); // [张三]
}
}
fail-fast 机制:
ArrayList遍历时内部记录一个
modCount(修改次数),一旦遍历过程中集合被结构性修改,next()
检测到modCount变了就立刻抛
ConcurrentModificationException,宁可快速失败也不返回脏数据。单线程里这是「写错代码」的信号,多线程里这就是
ArrayList非线程安全的证据(并发写要用
CopyOnWriteArrayList)。
2.7 字符串与金额(两个高频坑)
// StringMoneyDemo.java —— String 三兄弟 + BigDecimal 金额
import java.math.BigDecimal;
public class StringMoneyDemo {
public static void main(String[] args) {
// String 不可变;循环拼接会不断创建中间对象,必须用 StringBuilder
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append(i).append(',');
}
String joined = sb.toString();
// 错误:new BigDecimal(double) 会引入二进制浮点误差
BigDecimal wrong = new BigDecimal(0.1);
System.out.println(wrong); // 0.1000000000000000055511151231257827021181583404541015625
// 正确:用字符串构造 或 BigDecimal.valueOf(内部也是字符串构造)
BigDecimal right = BigDecimal.valueOf(0.1);
System.out.println(right); // 0.1
// 金额比较必须用 compareTo,不能用 equals(equals 会比较 scale 小数位数)
BigDecimal a = new BigDecimal("10.0");
BigDecimal b = new BigDecimal("10.00");
System.out.println(a.equals(b)); // false(scale 不同)
System.out.println(a.compareTo(b) == 0); // true(数值相等)
}
}
String / StringBuilder /
StringBuffer 区别:
| 类型 | 可变性 | 线程安全 | 场景 |
|---|---|---|---|
String |
不可变 | —(天然安全) | 常量、少量拼接 |
StringBuilder |
可变 | 否 | 循环/大量拼接首选 |
StringBuffer |
可变 | 是(方法加 synchronized) | 几乎不用,性能差 |
金额运算一律
BigDecimal,构造用字符串或
valueOf,比较用
compareTo。这是支付、订单、库存系统的基本铁律。
2.8 ArrayList 扩容与
LinkedList 的真相
ArrayList 底层是 Object[]
数组,默认初始容量 10,满了扩容为原来的 1.5
倍(oldCapacity + (oldCapacity >> 1)),扩容时用
Arrays.copyOf
复制整个数组——所以频繁扩容是性能杀手,能预估大小就传初始容量:
// 生产技巧:预分配容量,避免频繁扩容复制
List<User> users = new ArrayList<>(1000); // 预估 1000 条,一次到位
LinkedList 底层是双向链表,get(index) 要
O(n)
从头/尾遍历(会判断靠哪头近),所以它几乎只适合「频繁头尾插入」或「实现队列/栈」的场景——而
Java 提供了更好的
ArrayDeque(循环数组实现的队列),所以生产里
LinkedList 用得很少。
2.9 HashSet
的真相:它就是个「只有 key 的 HashMap」
HashSet 内部持有一个
HashMap,add(e) 实际是
map.put(e, PRESENT)(PRESENT 是一个共享的哨兵
Object)。所以:
HashSet去重完全依赖hashCode()+
equals()(回到 2.4 的契约);- 往
HashSet里放可变对象,若对象被修改导致
hashCode
变了,就会「既删不掉也找不到」——这是经典坑。放进 Set/Map 作为
key 的对象,其hashCode/equals
用到的字段应尽量不可变。
2.10 LinkedHashMap 做 LRU
缓存
LinkedHashMap 在 HashMap
基础上维护了一条双向链表来记录访问顺序。设
accessOrder=true 后,每次 get
都会把该条目移到链表尾部,于是链表头部就是「最久未访问」——这正是
LRU(Least Recently Used)缓存。
// LruCacheDemo.java —— 用 LinkedHashMap 实现 LRU 缓存(面试高频手写题)
import java.util.LinkedHashMap;
import java.util.Map;
public class LruCacheDemo {
public static void main(String[] args) {
// accessOrder=true:按访问顺序排序;重写 removeEldestEntry 实现容量上限
Map<String, String> lru = new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, String> eldest) {
return size() > 3; // 超过 3 条就淘汰最久未访问的
}
};
lru.put("a", "1");
lru.put("b", "2");
lru.put("c", "3");
lru.get("a"); // 访问 a,a 变「最新」
lru.put("d", "4"); // 触发淘汰,最久未访问的 b 被移除
System.out.println(lru.keySet()); // [c, a, d](b 被淘汰)
}
}
2.11 排序:Comparable 与
Comparator
Comparable:对象自身实现
compareTo,定义「自然顺序」(一个类只能有一种)。Comparator:外部比较器,定义「临时顺序」(可以有多种,如按年龄、按姓名)。
// SortDemo.java —— Comparator 外部排序(生产最常用)
import java.util.*;
public class SortDemo {
record Person(Long id, String name, Integer age) {}
public static void main(String[] args) {
List<Person> people = new ArrayList<>(List.of(
new Person(2L, "李四", 30),
new Person(1L, "张三", 25),
new Person(3L, "王五", 25)
));
// Comparator:按年龄升序,年龄相同按姓名升序(生产常用)
people.sort(Comparator.comparing(Person::age).thenComparing(Person::name));
System.out.println(people);
// 按年龄降序
people.sort(Comparator.comparing(Person::age).reversed());
System.out.println(people);
}
}
2.12 不可变集合:防止外部误改
JDK 9+ 的 List.of / Set.of /
Map.of 创建的是不可变集合,任何修改都会抛
UnsupportedOperationException,适合定义常量集合或「只读视图」:
// ImmutableDemo.java —— 不可变集合
import java.util.*;
public class ImmutableDemo {
public static void main(String[] args) {
List<String> names = List.of("张三", "李四"); // 不可变,且不允许 null 元素
// names.add("王五"); // 抛 UnsupportedOperationException
// 把可变集合包成只读视图(内部数据变化仍会反映,但不能通过该视图修改)
List<String> mutable = new ArrayList<>(List.of("a", "b"));
List<String> unmodifiable = Collections.unmodifiableList(mutable);
// unmodifiable.add("c"); // 抛异常
System.out.println(unmodifiable);
}
}
2.13 负载因子
0.75:时间与空间的折中
HashMap 默认负载因子 0.75 是经验最优值:
- 太大(如
1.0):桶塞得更满才扩容,空间省了,但哈希碰撞变多,查得慢; - 太小(如 0.5):频繁扩容,空间浪费,但碰撞少。
0.75
是「时间开销」和「空间开销」的平衡点。一般不要改它,除非你对数据分布和性能有明确的实测依据。
本章小结
面向对象是 Java 的语言底色;集合选型与 HashMap
原理、equals/hashCode
契约、fail-fast、BigDecimal
金额,构成了生产代码里每天都要用对的基本盘。
第 3 章 泛型、注解与反射
这三样是「看懂 Spring
源码」的三把钥匙:泛型让代码类型安全又复用,注解让框架能「声明式」地给代码加能力,反射让框架能在运行时「读懂并操作」你的类。学完本章,你就明白
Spring 的依赖注入、AOP、MyBatis 的 Mapper 代理底层是怎么回事了。
3.1 泛型:从语法到 PECS
泛型的作用是把类型检查从运行时提前到编译期,同时避免到处强转。基础语法:
// GenericDemo.java —— 泛型类、泛型方法、通配符的最小可用示例
import java.util.ArrayList;
import java.util.List;
public class GenericDemo {
// 泛型类:类名后声明类型参数,实例化时确定具体类型
static class Box<T> {
private T value;
Box(T value) { this.value = value; }
T get() { return value; }
void set(T value) { this.value = value; }
}
// 泛型方法:返回值前声明 <T>,让方法独立于类的类型参数
static <T> T firstOrNull(List<T> list) {
return list.isEmpty() ? null : list.get(0);
}
// PECS-生产者 extends:只从 list 读元素,不能往里写(因为不知道具体子类型)
static double sum(List<? extends Number> list) {
double total = 0;
for (Number n : list) total += n.doubleValue();
return total;
}
// PECS-消费者 super:只往 list 写元素,读出来只能是 Object(因为不知道具体父类型)
static void addIntegers(List<? super Integer> list) {
list.add(1);
list.add(2);
}
public static void main(String[] args) {
Box<String> box = new Box<>("hello"); // 菱形语法,右侧自动推断
System.out.println(box.get());
System.out.println(firstOrNull(List.of("a", "b"))); // 编译期保证类型安全
System.out.println(sum(List.of(1, 2, 3.5))); // 9.5
List<Number> nums = new ArrayList<>();
addIntegers(nums); // List<? super Integer> 可接 List<Number>
System.out.println(nums); // [1, 2]
}
}
PECS 原则(记住这句口诀:Producer Extends,
Consumer Super):
<? extends T>:这个集合是「生产者」,你只能从里面读(读出来是
T),不能写。用于「我只读不写」的参数。<? super T>:这个集合是「消费者」,你只能往里面写
T(或 T 的子类),读出来只能是 Object。用于「我只写不读」的参数。- 什么时候同时要读要写?那就别用通配符,用具体类型
<T>。
3.2 类型擦除(重要限制)
泛型是编译期的概念,编译后会被「擦除」:List<String>
和 List<Integer> 在运行时都是同一个
List(元素类型变成
Object,并在需要处插入强转)。这带来几个硬限制,必须记住:
// ErasureDemo.java —— 类型擦除带来的三个限制(都编译不过,理解即可,不用跑)
public class ErasureDemo {
// 限制 1:不能 new T()(运行时不知道 T 是什么,无法实例化)
// static <T> T create() { return new T(); }
// 限制 2:不能 instanceof T(运行时 T 已被擦除为 Object)
// static <T> boolean isInstance(Object o) { return o instanceof T; }
// 限制 3:不能重载「仅泛型参数不同」的方法(擦除后签名相同,冲突)
// static void print(List<String> list) {}
// static void print(List<Integer> list) {}
// 限制 4:泛型不能是基本类型(int 要装箱成 Integer)
// List<int> list = new ArrayList<>(); // 错误,要用 List<Integer>
}
类型擦除的「补偿」:因为擦除,运行时才知道具体类型的地方(如反序列化),需要显式传递
Class<T>作为类型令牌。下一节反射的对象↔︎Map
工具就会用到这个技巧。
3.3 注解与元注解
注解本身不做任何事,它只是元数据,需要「反射」或「注解处理器」来读取并赋予行为。元注解用于描述自定义注解:
| 元注解 | 作用 |
|---|---|
@Target |
注解能加在哪:TYPE(类)、METHOD(方法)、FIELD(字段)、PARAMETER(参数)… |
@Retention |
注解保留到何时:SOURCE(源码,编译丢弃)、CLASS(字节码,运行丢弃)、RUNTIME(运行时可反射读取) |
@Documented |
让注解出现在 Javadoc 里 |
@Inherited |
子类继承父类上的注解(只对类注解有效) |
生产级自定义注解——幂等注解(实际项目里配合 AOP
做防重复提交、防重复扣款):
// Idempotent.java —— 幂等注解:标记「这个方法需要保证幂等(重复调用结果不变)」
import java.lang.annotation.*;
@Target(ElementType.METHOD) // 只能加在方法上
@Retention(RetentionPolicy.RUNTIME) // 运行时可反射读取(AOP 依赖这个)
@Documented
public @interface Idempotent {
String key() default ""; // 幂等键的表达式,如 "#orderNo"(SpEL 风格占位)
long expire() default 10; // 幂等记录的过期时间,单位秒
}
// OrderService.java —— 使用幂等注解的最小示例
public class OrderService {
@Idempotent(key = "#orderNo", expire = 60)
public void createOrder(String orderNo) {
// 业务逻辑:下单
System.out.println("创建订单:" + orderNo);
}
}
3.4 反射:运行时的「照妖镜」
反射允许你在运行时获取类的构造器、方法、字段,甚至调用私有成员(setAccessible(true))。Spring
的 @Autowired 注入、AOP 切面、MyBatis 的 Mapper
动态代理,底层都是反射。
// ReflectDemo.java —— 反射读取自定义注解 + 调用私有方法
import java.lang.reflect.Method;
public class ReflectDemo {
public static void main(String[] args) throws Exception {
// 1. 拿到方法对象(这里演示读 OrderService 上的幂等注解)
Method method = OrderService.class.getMethod("createOrder", String.class);
Idempotent anno = method.getAnnotation(Idempotent.class);
System.out.println("幂等键表达式 = " + anno.key() + ",过期秒数 = " + anno.expire());
// 2. 调用私有方法(生产框架里 setAccessible 很常见)
Method secret = SecretHolder.class.getDeclaredMethod("secret");
secret.setAccessible(true); // 突破 private 访问限制
System.out.println(secret.invoke(new SecretHolder()));
}
}
class SecretHolder {
private String secret() { return "反射破开了 private"; }
}
3.5 用反射实现「对象 ↔︎
Map」互转(生产级示例)
这是本章的综合实战:很多场景(把对象塞进缓存、做通用日志、序列化前处理)需要把对象转成
Map,再还原。下面是一个处理嵌套对象、跳过
null、按需还原类型的生产级工具类。
// BeanMapConverter.java —— 反射实现 对象 <-> Map 互转(生产级:异常处理 + 嵌套处理)
import java.lang.reflect.Field;
import java.lang.reflect.Modifier;
import java.util.HashMap;
import java.util.Map;
public final class BeanMapConverter {
// 禁止实例化工具类(私有构造器,阿里手册规范)
private BeanMapConverter() {}
/** 对象转 Map:只取非静态字段,key 为字段名,value 为字段值(null 跳过) */
public static Map<String, Object> toMap(Object obj) {
if (obj == null) {
throw new IllegalArgumentException("入参对象不能为 null");
}
Map<String, Object> map = new HashMap<>();
// getDeclaredFields 拿到所有字段(含 private),getFields 只能拿 public
for (Field field : obj.getClass().getDeclaredFields()) {
// 跳过静态字段和编译器生成的合成字段,避免污染
if (Modifier.isStatic(field.getModifiers()) || field.isSynthetic()) {
continue;
}
try {
field.setAccessible(true); // 允许读取 private 字段
Object value = field.get(obj);
if (value != null) { // 跳过 null,避免 Map 里出现脏 key
map.put(field.getName(), value);
}
} catch (IllegalAccessException e) {
// 理论上 setAccessible 后不会发生,防御式兜底
throw new IllegalStateException("读取字段失败: " + field.getName(), e);
}
}
return map;
}
/** Map 转对象:按字段名回填,只设置 Map 里存在的 key */
public static <T> T fromMap(Map<String, Object> map, Class<T> clazz) {
if (map == null || clazz == null) {
throw new IllegalArgumentException("map 与 clazz 均不能为 null");
}
try {
// 类型擦除的补偿:显式传入 Class<T>,运行时才知道要 new 什么
T instance = clazz.getDeclaredConstructor().newInstance();
for (Field field : clazz.getDeclaredFields()) {
if (Modifier.isStatic(field.getModifiers()) || field.isSynthetic()) {
continue;
}
Object value = map.get(field.getName());
if (value != null) {
field.setAccessible(true);
field.set(instance, value);
}
}
return instance;
} catch (Exception e) {
// 反射的 checked 异常统一包装成运行时异常,保留原始异常链
throw new IllegalStateException("Map 转对象失败: " + clazz.getName(), e);
}
}
}
// ConverterTest.java —— 验证工具类
import java.util.Map;
public class ConverterTest {
public static void main(String[] args) {
User user = new User(1L, "张三", 25);
Map<String, Object> map = BeanMapConverter.toMap(user);
System.out.println(map); // {id=1, username=张三, age=25}
User restored = BeanMapConverter.fromMap(map, User.class);
System.out.println(restored); // User{id=1, username='张三', age=25}
System.out.println(restored.equals(user)); // true(equals 只比 id)
}
}
运行方式(这两个文件依赖第 2 章的
User.java,放同一目录):
javac -d out User.java BeanMapConverter.java ConverterTest.java
java -cp out ConverterTest
关键点:反射的
getDeclaredConstructor().newInstance()
要求类有无参构造器(User
已提供)。反射有性能开销,Spring
会在启动时做一次、后续复用,而不是每次请求都反射——这是「框架用反射、业务少用反射」的原因。
3.6 动态代理入门
动态代理是 AOP
的底层:在运行时生成一个代理类,拦截对目标对象的方法调用,插入横切逻辑(日志、事务、权限)。
// DynamicProxyDemo.java —— JDK 动态代理:无侵入地给方法加日志
import java.lang.reflect.InvocationHandler;
import java.lang.reflect.Method;
import java.lang.reflect.Proxy;
public class DynamicProxyDemo {
interface UserService {
void save(String name);
}
static class UserServiceImpl implements UserService {
@Override public void save(String name) {
System.out.println("保存用户: " + name);
}
}
public static void main(String[] args) {
// 目标对象
UserService target = new UserServiceImpl();
// 动态生成代理对象(JDK 代理要求目标实现接口)
UserService proxy = (UserService) Proxy.newProxyInstance(
DynamicProxyDemo.class.getClassLoader(),
new Class<?>[]{UserService.class},
new InvocationHandler() {
@Override
public Object invoke(Object proxyObj, Method method, Object[] args) throws Throwable {
System.out.println("[代理] 调用前: " + method.getName()); // 横切逻辑:日志
Object result = method.invoke(target, args); // 调用真实方法
System.out.println("[代理] 调用后: " + method.getName());
return result;
}
}
);
proxy.save("张三");
// 输出:
// [代理] 调用前: save
// 保存用户: 张三
// [代理] 调用后: save
}
}
关键点:JDK
动态代理只能代理接口(UserService),代理类会实现同样的接口;若目标类没有接口,需要用
CGLIB 这类字节码增强(继承目标类生成子类)。Spring AOP 就是「有接口用
JDK 代理、无接口用 CGLIB」这套策略的集大成者。
3.7
更多元注解:@Repeatable 与 @Inherited
@Repeatable:让同一个注解在同一处可重复标注(如一个类打多个标签)。@Inherited:父类上的注解,子类(未重新标注时)会自动继承——只对类注解生效。
// RepeatableDemo.java —— 可重复注解
import java.lang.annotation.*;
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Repeatable(Tags.class) // 声明容器注解,才能重复标注
@interface Tag { String value(); }
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@interface Tags { Tag[] value(); } // 容器注解:持有多个 Tag
@Tag("缓存")
@Tag("核心")
class Article {}
public class RepeatableDemo {
public static void main(String[] args) {
for (Tag t : Article.class.getAnnotationsByType(Tag.class)) {
System.out.println(t.value()); // 缓存 / 核心
}
}
}
3.8
类型令牌:泛型擦除的补偿手段
因为类型擦除,运行时拿不到泛型实参。解决办法是显式传递
Class<T>
作为「类型令牌」,让方法知道要处理的具体类型。第 3.5 节的
BeanMapConverter.fromMap(Map, Class<T>)
就是典型用法。更常见的场景是「从 JSON 反序列化到指定类型」:
// TypeTokenDemo.java —— Class<T> 类型令牌
import java.util.HashMap;
import java.util.Map;
public class TypeTokenDemo {
// 调用方通过 Class<T> 告诉方法「我要什么类型」
static <T> T fromMap(Map<String, Object> map, Class<T> clazz) throws Exception {
T obj = clazz.getDeclaredConstructor().newInstance();
// ... 反射回填字段(完整实现见 3.5 节 BeanMapConverter)
return obj;
}
public static void main(String[] args) throws Exception {
Map<String, Object> data = new HashMap<>();
data.put("id", 1L);
User user = fromMap(data, User.class); // 类型令牌 User.class(User 来自第 2 章)
System.out.println(user);
}
}
关键点:这就是很多框架(Jackson、Gson)反序列化 API
的底层套路——readValue(json, User.class)里的
User.class就是在传类型令牌。
本章小结
泛型(含 PECS
与类型擦除)、注解(含元注解与自定义幂等注解)、反射(含对象↔︎Map
互转与动态代理)是框架级代码的三大支柱,理解了它们,Spring
源码的大门就推开了一半。
第 4 章 Lambda、Stream 与
Optional
这是让 Java 代码从「啰嗦」变「简洁」的一章。
Stream
是声明式数据处理:你描述「要什么」,而不是手写「怎么一步步做」。Optional
则把「可能为空」这个事实显式地写进类型签名,逼着调用方处理空值。三者加在一起,能让集合处理代码少写一半、错少出一半。
4.1 函数式接口与 Lambda
函数式接口:只有一个抽象方法的接口,可用
@FunctionalInterface 标注(编译器会强制校验)。Lambda
表达式就是这种接口的一个匿名实现的语法糖。
// LambdaDemo.java —— 函数式接口与 Lambda 的四种写法
public class LambdaDemo {
@FunctionalInterface
interface Calculator {
int calc(int a, int b);
}
public static void main(String[] args) {
// 写法一:完整 Lambda(参数类型可省略)
Calculator add = (int a, int b) -> a + b;
// 写法二:省略参数类型
Calculator sub = (a, b) -> a - b;
// 写法三:方法体有多行时用大括号
Calculator mul = (a, b) -> {
System.out.println("乘法运算");
return a * b;
};
// 写法四:方法引用(已有方法直接引用,最简洁)
Calculator max = Math::max;
System.out.println(add.calc(1, 2)); // 3
System.out.println(sub.calc(5, 2)); // 3
System.out.println(mul.calc(2, 3)); // 6
System.out.println(max.calc(1, 2)); // 2
}
}
JDK 内置的四大函数式接口(配合 Stream 高频使用):
| 接口 | 签名 | 用途 |
|---|---|---|
Function<T, R> |
R apply(T t) |
转换:输入 T 输出 R(map 用) |
Predicate<T> |
boolean test(T t) |
判断:返回布尔(filter 用) |
Consumer<T> |
void accept(T t) |
消费:只进不出(forEach 用) |
Supplier<T> |
T get() |
生产:只出不进(Optional.orElseGet 用) |
4.2 Stream 三阶段与惰性求值
Stream
的完整流程分三阶段,只有终止操作会真正触发计算:
- 创建:
list.stream()(串行)/
list.parallelStream()(并行); - 中间操作:
filter/map/
sorted/distinct/
limit…(惰性,不立即执行,返回新
Stream); - 终止操作:
collect/
forEach/count/
reduce…(触发执行,流关闭)。
// StreamDemo.java —— 三阶段与惰性验证
import java.util.List;
public class StreamDemo {
public static void main(String[] args) {
List<String> names = List.of("张三", "李四", "王五", "赵六");
// 惰性验证:去掉终止操作,中间操作不会执行(下面这行没有任何输出)
names.stream().filter(n -> {
System.out.println("filter 执行了: " + n);
return n.length() == 2;
});
System.out.println("---- 加上终止操作后 ----");
// 加上终止操作 forEach,中间操作才会真正执行
names.stream()
.filter(n -> n.length() == 2) // 中间:过滤
.map(n -> "姓名:" + n) // 中间:转换
.forEach(System.out::println); // 终止:消费
// 输出:
// ---- 加上终止操作后 ----
// 姓名:张三
// 姓名:李四
// 姓名:王五
// 姓名:赵六
}
}
4.3
常见操作:filter / map / collect / groupingBy
// CollectDemo.java —— 常用终止操作:toList / toMap / groupingBy / 统计
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
public class CollectDemo {
static class User {
Long id; String name; Integer age;
User(Long id, String name, Integer age) {
this.id = id; this.name = name; this.age = age;
}
Long getId() { return id; }
String getName() { return name; }
Integer getAge() { return age; }
@Override public String toString() { return "User{id=" + id + ", name='" + name + "', age=" + age + '}'; }
}
public static void main(String[] args) {
List<User> users = List.of(
new User(1L, "张三", 25),
new User(2L, "李四", 30),
new User(3L, "王五", 25),
new User(4L, "赵六", 30)
);
// 1. filter + collect(toList):过滤年龄 > 25
List<User> adults = users.stream()
.filter(u -> u.getAge() > 25)
.collect(Collectors.toList());
System.out.println("年龄>25: " + adults);
// 2. map + collect(toList):只取姓名
List<String> names = users.stream()
.map(User::getName)
.collect(Collectors.toList());
System.out.println("姓名列表: " + names);
// 3. groupingBy:按年龄分组
Map<Integer, List<User>> byAge = users.stream()
.collect(Collectors.groupingBy(User::getAge));
System.out.println("按年龄分组: " + byAge);
// 4. 统计:数量、平均年龄、最大年龄
long count = users.stream().count();
double avgAge = users.stream().mapToInt(User::getAge).average().orElse(0);
int maxAge = users.stream().mapToInt(User::getAge).max().orElse(0);
System.out.println("人数=" + count + ", 平均年龄=" + avgAge + ", 最大年龄=" + maxAge);
}
}
4.4 List 转
Map(防键重复,生产级高频坑)
Collectors.toMap 在 key 重复时会直接抛
IllegalStateException。生产数据几乎不可能保证 key
唯一(比如按年龄做
key),所以必须传第三个参数(合并函数)兜底。
// ToMapDemo.java —— List 转 Map 的键重复问题
import java.util.*;
import java.util.function.Function;
import java.util.stream.Collectors;
public class ToMapDemo {
public static void main(String[] args) {
List<CollectDemo.User> users = List.of(
new CollectDemo.User(1L, "张三", 25),
new CollectDemo.User(2L, "李四", 30),
new CollectDemo.User(3L, "王五", 25) // 年龄 25 出现两次
);
// 错误:按年龄做 key,会抛 IllegalStateException: Duplicate key
// Map<Integer, CollectDemo.User> byAge = users.stream()
// .collect(Collectors.toMap(CollectDemo.User::getAge, Function.identity()));
// 正确:用合并函数 (old, fresh) -> fresh,key 冲突时保留新值
Map<Integer, CollectDemo.User> byAgeKeepNew = users.stream()
.collect(Collectors.toMap(
CollectDemo.User::getAge,
Function.identity(),
(oldValue, newValue) -> newValue // 保留后出现的值
));
System.out.println("保留新值: " + byAgeKeepNew);
// 生产常用:按唯一 id 转 Map(id 唯一,合并函数只是防御性兜底)
Map<Long, CollectDemo.User> byId = users.stream()
.collect(Collectors.toMap(CollectDemo.User::getId, Function.identity(), (a, b) -> a));
System.out.println("按 id 转 Map: " + byId);
// 分组统计:每个年龄有多少人(groupingBy + counting)
Map<Integer, Long> ageCount = users.stream()
.collect(Collectors.groupingBy(CollectDemo.User::getAge, Collectors.counting()));
System.out.println("各年龄人数: " + ageCount);
}
}
关键点:
toMap
的第三个合并函数是「键冲突时的裁决规则」:(a, b) -> a
保留旧值,(a, b) -> b
保留新值,(a, b) -> a + b求和。写
toMap不写第三个参数 = 埋雷。
4.5 Optional 正确用法
Optional
的本质是把「可能为空」写进类型签名:一个方法返回
Optional<User>,就是在告诉调用方「我可能查不到,你看着办」。这比返回
null 安全得多。
// OptionalDemo.java —— Optional 的正确与错误用法
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
public class OptionalDemo {
static class UserService {
// 模拟按 id 查询,可能查不到
private static final Map<Long, String> DB = new HashMap<>();
static { DB.put(1L, "张三"); }
// 正确:返回 Optional<User>,显式表达「可能为空」
Optional<String> findById(Long id) {
return Optional.ofNullable(DB.get(id));
}
}
public static void main(String[] args) {
UserService service = new UserService();
// 正确写法一:orElseThrow —— 查不到就抛业务异常(推荐,链式)
String user = service.findById(1L)
.orElseThrow(() -> new IllegalArgumentException("用户不存在"));
System.out.println(user);
// 正确写法二:orElse / orElseGet —— 给默认值
String fallback = service.findById(999L).orElse("匿名用户");
System.out.println(fallback);
// 正确写法三:ifPresent —— 存在才执行
service.findById(1L).ifPresent(u -> System.out.println("找到: " + u));
// 正确写法四:map + filter 链式转换
String upper = service.findById(1L)
.map(String::toUpperCase)
.filter(s -> s.length() > 1)
.orElse("默认");
System.out.println(upper);
// 错误写法:isPresent + get(等价于判 null 再取值,失去 Optional 意义)
// if (service.findById(1L).isPresent()) {
// String u = service.findById(1L).get();
// }
}
}
三条 Optional 铁律(阿里手册):
- 只作为方法返回值表达「可能为空」,禁止当字段、禁止当方法入参。
- 禁止
isPresent() + get(),用
orElseThrow/orElse/ifPresent
代替——前者和「判 null 再取值」一样啰嗦且易错。 - 慎用
orElse(固定值):orElse
的参数无论是否为空都会先求值,若默认值是昂贵操作(如查库、new
大对象),要用orElseGet(() -> ...)惰性求值。
4.6 parallelStream 的性能陷阱
parallelStream 用 ForkJoin
线程池并行处理,不是万能加速器。下面三种情况用了反而更慢:
- 数据量小:切分任务、线程调度的开销大于并行收益;
- 有 IO 操作:并行 IO 会打满连接、加剧资源竞争;
- 有顺序/依赖:
findFirst、sorted
等强依赖顺序的操作,并行要额外同步。
// ParallelDemo.java —— parallelStream 不是银弹(数据量小时串行更快)
import java.util.List;
import java.util.stream.IntStream;
public class ParallelDemo {
public static void main(String[] args) {
List<Integer> small = IntStream.rangeClosed(1, 1000).boxed().toList();
List<Integer> large = IntStream.rangeClosed(1, 10_000_000).boxed().toList();
// 数据量小:串行通常更快(并行有线程调度开销)
long t1 = System.currentTimeMillis();
long s1 = small.stream().mapToLong(Integer::longValue).sum();
long t2 = System.currentTimeMillis();
long s2 = small.parallelStream().mapToLong(Integer::longValue).sum();
long t3 = System.currentTimeMillis();
System.out.println("小数据 串行=" + (t2 - t1) + "ms, 并行=" + (t3 - t2) + "ms");
// 数据量大:并行才有明显优势
long t4 = System.currentTimeMillis();
long l1 = large.stream().mapToLong(Integer::longValue).sum();
long t5 = System.currentTimeMillis();
long l2 = large.parallelStream().mapToLong(Integer::longValue).sum();
long t6 = System.currentTimeMillis();
System.out.println("大数据 串行=" + (t5 - t4) + "ms, 并行=" + (t6 - t5) + "ms");
}
}
关键点:默认用
stream(),只有在「数据量大、纯 CPU
计算、无顺序依赖」且实测确实变快时,才用
parallelStream()。并行流用的是公共
ForkJoinPool,滥用会拖垮整个 JVM 的其他并行任务。
4.7 flatMap 与 reduce
flatMap
把「流里的每个元素」映射成一个流,再拍平成一个流,常用于「嵌套集合展开」:
// FlatMapDemo.java —— flatMap 拍平嵌套集合
import java.util.List;
import java.util.stream.Collectors;
public class FlatMapDemo {
public static void main(String[] args) {
// 一个用户有多个订单号,需求:取出所有用户的全部订单号(去重)
record UserOrders(String name, List<String> orders) {}
List<UserOrders> list = List.of(
new UserOrders("张三", List.of("O1", "O2")),
new UserOrders("李四", List.of("O2", "O3"))
);
List<String> allOrders = list.stream()
.flatMap(u -> u.orders().stream()) // 每个 UserOrders 展开成订单流,再拍平
.distinct() // 去重 O2
.collect(Collectors.toList());
System.out.println(allOrders); // [O1, O2, O3]
}
}
reduce 把流「归约」成一个值(求和、求积):
// ReduceDemo.java —— reduce 归约
import java.util.List;
public class ReduceDemo {
public static void main(String[] args) {
List<Integer> nums = List.of(1, 2, 3, 4, 5);
int sum = nums.stream().reduce(0, Integer::sum); // 求和 = 15
int product = nums.stream().reduce(1, (a, b) -> a * b); // 求积 = 120
System.out.println("sum=" + sum + ", product=" + product);
}
}
4.8 分区与统计汇总
// PartitionDemo.java —— partitionBy 二分 + summarizingInt 汇总
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
public class PartitionDemo {
record Person(String name, int age) {}
public static void main(String[] args) {
List<Person> people = List.of(
new Person("张三", 25), new Person("李四", 15),
new Person("王五", 30), new Person("赵六", 10)
);
// partitionBy:按谓词二分(成年/未成年)
Map<Boolean, List<Person>> adults = people.stream()
.collect(Collectors.partitioningBy(p -> p.age() >= 18));
System.out.println("成年人: " + adults.get(true));
System.out.println("未成年: " + adults.get(false));
// summarizingInt:一次拿到 count/sum/min/max/average
var summary = people.stream().collect(Collectors.summarizingInt(Person::age));
System.out.println("人数=" + summary.getCount()
+ ", 平均年龄=" + summary.getAverage()
+ ", 最大=" + summary.getMax());
}
}
4.9
Optional.flatMap:链式处理「可能为空的可能为空」
当链上的某一步又返回 Optional(嵌套 Optional)时,用
flatMap 拍平,避免
Optional<Optional<T>>:
// OptionalFlatMapDemo.java —— flatMap 拍平嵌套 Optional
import java.util.Optional;
public class OptionalFlatMapDemo {
public static void main(String[] args) {
Optional<Optional<String>> nested = Optional.of(Optional.of("hello"));
// map 会得到 Optional<Optional<String>>,flatMap 拍平成 Optional<String>
String s = nested.flatMap(o -> o).orElse("空");
System.out.println(s);
}
}
本章小结
函数式接口 + Lambda 让代码变短,Stream
三阶段(创建→中间→终止)让数据处理变声明式,Optional
让空值处理变显式;记住 toMap
必带合并函数、Optional
只做返回值、parallelStream 谨慎用这三条铁律。
第 5 章 异常处理与日志
生产排查问题全靠异常信息和日志。乱 catch
会吞掉线索,不记日志等于摸黑排障。本章建立两套生产级设施:一套「错误码 +
业务异常」体系,一套「SLF4J + Logback」日志体系。其中
ErrorCode、BizException这两个类,会被后续所有
Spring Boot 阶段逐字复用,请照抄并记住。
5.1 受检异常 vs 非受检异常
Java 异常分两大类:
- 受检异常(Checked):继承自
Exception(但不含
RuntimeException),编译器强制你必须
try-catch或throws
声明。典型:IOException、SQLException。设计意图是「这些错误可以恢复,调用方必须处理」。 - 非受检异常(Unchecked):继承自
RuntimeException,编译器不强制处理。典型:NullPointerException、IllegalArgumentException。代表「编程错误或无法恢复的业务错误」。
生产实践:业务异常统一用非受检(RuntimeException
子类),这样不用在每个方法签名上 throws
满天飞,由全局异常处理器统一兜底(Spring 阶段的
GlobalExceptionHandler 就是干这个的)。
5.2
自定义异常体系(生产级核心)
一个可维护的异常体系包含两层:错误码枚举(定义所有可预期的错误)+
业务异常类(携带错误码抛出)。
// ErrorCode.java —— 统一错误码枚举(公共类,后续阶段逐字复用,基础码不得改动)
public enum ErrorCode {
SUCCESS(0, "success"),
PARAM_ERROR(40001, "参数错误"),
UNAUTHORIZED(40101, "未登录或登录已过期"),
FORBIDDEN(40301, "无权限访问"),
USER_NOT_FOUND(40401, "用户不存在"),
USER_EXISTS(40002, "用户名已存在"),
SYSTEM_ERROR(50000, "系统繁忙,请稍后重试"),
;
private final int code;
private final String message;
// 枚举构造器默认 private,无需显式写
ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
public int getCode() { return code; }
public String getMessage() { return message; }
}
// BizException.java —— 统一业务异常(公共类,后续阶段逐字复用)
public class BizException extends RuntimeException {
private final int code;
public BizException(ErrorCode ec) {
super(ec.getMessage()); // 错误码里的 message 作为异常信息
this.code = ec.getCode();
}
public BizException(int code, String message) {
super(message);
this.code = code;
}
public int getCode() { return code; }
}
设计说明:错误码用
int(而非
String),便于前后端对齐、排查时快速检索。4xxxx
是客户端可修复的错误(参数错、未登录、无权限、资源不存在),5xxxx
是服务端错误。这套语义和 HTTP 状态码一致,方便你后续对接 Spring
的响应体。
使用示例:
// BizDemo.java —— 抛出与捕获业务异常
public class BizDemo {
public static void main(String[] args) {
try {
registerUser("张三");
registerUser("张三"); // 第二次触发「用户名已存在」
} catch (BizException e) {
// 打印业务码 + 业务提示,而不是堆栈
System.out.println("业务失败: code=" + e.getCode() + ", msg=" + e.getMessage());
}
}
static void registerUser(String name) {
if ("张三".equals(name)) {
// 抛业务异常:可预期的错误,用错误码表达
throw new BizException(ErrorCode.USER_EXISTS);
}
System.out.println("注册成功: " + name);
}
}
运行:javac ErrorCode.java BizException.java BizDemo.java && java BizDemo
5.3 异常链与禁止事项
异常链:捕获底层异常后,用带 cause
的构造器把原始异常挂上去,保留完整堆栈。生产排查时日志会打出整条链,定位根因就靠它。
为此,BizException 需要额外提供一个带
cause
的重载构造器(在总规范原始定义上新增,不改变既有公共签名,后续阶段同样复用):
// BizException 追加构造器(在原有 (ErrorCode)/(int,String) 两个构造器基础上新增)
public BizException(int code, String message, Throwable cause) {
super(message, cause); // cause 挂到父类,getCause() 能拿到
this.code = code;
}
// ExceptionChainDemo.java —— 异常链:包装底层异常并保留 cause
public class ExceptionChainDemo {
public static void main(String[] args) {
try {
loadConfig();
} catch (BizException e) {
// getCause() 拿到被包装的原始异常,整条链定位根因
System.out.println("业务异常: " + e.getMessage());
System.out.println("根因: " + e.getCause());
}
}
static void loadConfig() {
try {
// 模拟底层 IO 异常
throw new java.io.IOException("配置文件不存在");
} catch (java.io.IOException e) {
// 包装成业务异常,同时用第三个参数保留原始异常链
throw new BizException(ErrorCode.SYSTEM_ERROR.getCode(), "配置加载失败", e);
}
}
}
运行:javac ErrorCode.java BizException.java ExceptionChainDemo.java && java ExceptionChainDemo,输出里
根因: 会打印
java.io.IOException: 配置文件不存在,证明异常链完整保留了。
生产异常的四条禁止(阿里手册):
- 禁止空 catch:
catch (Exception e) {}
吞掉异常,线上问题成「无头案」; - 禁止
e.printStackTrace():直接打到
stderr,不走日志框架,生产会丢失; - 禁止
catch (Exception e) { return null; }:让调用方拿到
null 继续跑,错误被掩盖; - 禁止在循环里
try-catch:性能差且吞异常,应把异常上抛,在循环外统一处理。
5.4
try-with-resources(自动关资源)
凡是实现了 AutoCloseable 的资源(流、连接),都应该用
try-with-resources,JVM 自动在 finally 里帮你
close,避免资源泄漏。
// TryWithResourcesDemo.java —— 自动关闭资源
import java.io.BufferedReader;
import java.io.FileReader;
import java.io.IOException;
public class TryWithResourcesDemo {
public static void main(String[] args) {
// 资源在 try 括号里声明,代码块结束后自动 close(倒序关闭)
try (BufferedReader reader = new BufferedReader(new FileReader("pom.xml"))) {
String line;
while ((line = reader.readLine()) != null) {
System.out.println(line);
}
} catch (IOException e) {
System.err.println("读取失败: " + e.getMessage());
}
}
}
5.5 日志:SLF4J + Logback
为什么用 SLF4J + Logback:SLF4J
是日志门面(接口),Logback
是实现。业务代码只依赖 SLF4J
接口,具体用哪个实现可以在部署时切换(Logback / Log4j2
等),这就是「面向接口编程」在日志上的应用。Java 自带的
java.util.logging(JUL)功能弱、配置麻烦,生产基本不用。
最小可用(零依赖,先建立概念):
// JulDemo.java —— JDK 自带日志,零依赖即可运行(仅用于理解概念)
import java.util.logging.Logger;
public class JulDemo {
private static final Logger log = Logger.getLogger(JulDemo.class.getName());
public static void main(String[] args) {
log.info("这是一条 info 日志");
log.warning("这是一条 warning 日志");
}
}
生产级(SLF4J +
Logback):需要引入依赖,这里先给一个最小 pom.xml
让你能跑通(Maven 的完整讲解在第 7 章,现在照抄即可)。
<!-- pom.xml —— 最小可运行配置,仅引入日志依赖(完整 Maven 讲解见第 7 章) -->
<?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.example</groupId>
<artifactId>logging-demo</artifactId>
<version>1.0.0</version>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
</properties>
<dependencies>
<!-- SLF4J 门面 API -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.13</version>
</dependency>
<!-- Logback 实现(会传递引入 slf4j-api,但显式声明更清晰) -->
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.6</version>
</dependency>
</dependencies>
</project>
<!-- src/main/resources/logback.xml —— Logback 基础配置 -->
<configuration>
<!-- 控制台输出器 -->
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<!-- 日志格式:时间 级别 线程名 类名 - 消息 -->
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<!-- 根日志级别:INFO 及以上才输出(DEBUG 太多,生产默认 INFO) -->
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
<!-- 对指定包调级别:本包 DEBUG,方便排查时临时打开 -->
<logger name="com.example" level="DEBUG"/>
</configuration>
// src/main/java/com/example/LogDemo.java —— 生产级日志写法
package com.example;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
public class LogDemo {
// 每个类一个 Logger,参数传当前类,便于日志定位到具体类
private static final Logger log = LoggerFactory.getLogger(LogDemo.class);
public static void main(String[] args) {
log.info("用户登录成功, userId={}", 10086); // 占位符写法,避免字符串拼接开销
log.debug("这是一条调试日志,生产默认不输出");
try {
int r = 1 / 0;
} catch (Exception e) {
// 异常必须带堆栈一起打(第二个参数传异常对象),否则丢失排查线索
log.error("计算失败, userId={}", 10086, e);
}
}
}
运行(先确保第 7 章的 Maven 概念不熟也没关系,直接执行):
mvn compile exec:java -Dexec.mainClass="com.example.LogDemo"
# 或先 mvn package 再 java -cp target/classes:$HOME/.m2/repository/... 运行
关键点:
- 日志用
{}占位符,不要
"用户" + id
字符串拼接——占位符只在「该级别需要输出」时才真正拼字符串,DEBUG
关闭时零开销;- 异常用
log.error("...", e)
传异常对象,让 Logback 自动打印堆栈;- 生产默认 INFO,DEBUG
只在排查时对指定包临时打开(logback.xml的
<logger>已演示);- 全系列规范:业务代码禁止
System.out,一律用Logger(Spring
阶段会用 Lombok 的@Slf4j省掉这行声明)。
5.6 日志级别与文件滚动
日志级别从低到高:TRACE < DEBUG < INFO < WARN < ERROR。生产默认
INFO,即只输出 INFO 及以上的日志;DEBUG
级别的内容(如方法入参)在排查时才临时打开。
生产日志不能只打控制台,还要滚动写入文件(按大小/日期切割,便于归档与检索):
<!-- logback.xml 追加:滚动文件输出器(与 CONSOLE 并存) -->
<configuration>
<!-- 按大小+日期滚动:单文件 10MB,保留 7 天 -->
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/app.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
<maxFileSize>10MB</maxFileSize>
<maxHistory>7</maxHistory>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
<appender-ref ref="FILE"/>
</root>
</configuration>
关键点:控制台输出方便开发调试,文件滚动方便生产归档与事后排查。生产环境两个
appender
都要配,排查时先看文件日志(控制台日志在容器重启后就丢了)。
本章小结
用 ErrorCode + BizException
建立可预期错误的统一表达,用「异常链」保留根因,用 try-with-resources
自动关资源,用 SLF4J + Logback
记录可检索的日志——这四件事合起来,就是「线上出了问题能快速定位」的底气。
第 6 章 并发编程与线程池
并发是后端的分水岭:单线程写对了只是及格,多线程写对了才算能扛生产流量。本章先把三个核心概念(可见性、有序性、原子性)讲透,再落到生产最常用的两件事上——手动配置线程池和用
ThreadLocal 传 traceId。
6.1 并发三性问题与三个关键词
多线程出错,本质是三个问题:可见性(一个线程改的值,另一个线程看不到)、有序性(指令重排导致执行顺序错乱)、原子性(一段操作被打断)。Java
用三个关键词分别应对:
| 关键词 | 保证什么 | 不保证什么 |
|---|---|---|
volatile |
可见性 + 有序性(内存屏障禁止重排) | 原子性(i++ 仍是三步:读-改-写) |
synchronized |
原子性 + 可见性 + 有序性(互斥 + 内存屏障) | —(性能较差,JDK6+ 有锁升级优化) |
CAS(Atomic* 类) |
原子性(无锁,CPU 指令级比较交换) | 不解决 ABA 问题(需 AtomicStampedReference) |
// VolatileDemo.java —— volatile 保证可见性,但不保证原子性
public class VolatileDemo {
// 用 volatile:主线程改 flag,子线程能立刻看到(可见性)
private static volatile boolean running = true;
// 不用 volatile:counter++ 是「读-改-写」三步,多线程会丢更新
private static int counter = 0;
// 用 AtomicInteger:CAS 保证 ++ 的原子性
private static final java.util.concurrent.atomic.AtomicInteger atomicCounter =
new java.util.concurrent.atomic.AtomicInteger(0);
public static void main(String[] args) throws InterruptedException {
// 演示可见性:子线程循环读 running,主线程 1 秒后改 false
Thread watcher = new Thread(() -> {
while (running) { /* 空转,等待 running 变 false */ }
System.out.println("子线程看到 running=false,退出");
});
watcher.start();
Thread.sleep(1000);
running = false; // 若 running 无 volatile,子线程可能永远看不到
watcher.join();
// 演示原子性:10 个线程各加 1000 次
Thread[] threads = new Thread[10];
for (int i = 0; i < 10; i++) {
threads[i] = new Thread(() -> {
for (int j = 0; j < 1000; j++) {
counter++; // 非原子,结果 < 10000
atomicCounter.incrementAndGet(); // CAS 原子,结果 = 10000
}
});
threads[i].start();
}
for (Thread t : threads) t.join();
System.out.println("普通 int counter = " + counter); // 大概率 < 10000
System.out.println("AtomicInteger counter = " + atomicCounter.get()); // 恒为 10000
}
}
关键点:
volatile
只能保证「读到的值是最新的」,不能保证「读-改-写」三步不被穿插。要修
counter++,要么synchronized,要么用
AtomicInteger(底层 CAS)。
6.2 synchronized 与锁升级
synchronized 是 Java 内置锁。JDK6
之后做了锁升级优化,让它不再是「一上来就重量级」:
无锁 → 偏向锁(只有一个线程反复进入,无竞争,只做标记)→
轻量级锁(少量竞争,CAS 自旋)→
重量级锁(竞争激烈,挂起线程交给操作系统)。
你只需记住结论:竞争不激烈时 synchronized
性能已经很好,不必一上来就排斥它;需要更灵活的「可中断、可超时、公平锁」时,才用
ReentrantLock。
// SyncDemo.java —— synchronized 方法锁与代码块锁(this 锁)
public class SyncDemo {
private int balance = 100;
// 方法锁:锁的是 this 对象
public synchronized void deposit(int amount) {
balance += amount;
}
// 代码块锁:锁 this(等价于上面的方法锁),粒度更可控
public void withdraw(int amount) {
synchronized (this) {
balance -= amount;
}
}
public int getBalance() { return balance; }
}
6.3 线程池:为什么禁用
Executors(重点)
为什么禁用 Executors
工厂方法?因为:
Executors.newFixedThreadPool(n)
用的是无界队列
LinkedBlockingQueue,任务堆积时队列无限增长 →
OOM;Executors.newCachedThreadPool()的最大线程数是
Integer.MAX_VALUE,来多少任务开多少线程 → 线程爆炸
/ OOM。
所以阿里手册强制:线程池一律用
ThreadPoolExecutor 显式构造,明确 7 个参数。
ThreadPoolExecutor 的 7 个参数(面试必背):
new ThreadPoolExecutor(
corePoolSize, // 核心线程数:常驻线程,任务来了先由它们执行
maximumPoolSize, // 最大线程数:队列满了才开新线程,最多开到这么多
keepAliveTime, // 非核心线程空闲多久被回收
TimeUnit.SECONDS, // keepAliveTime 的时间单位
workQueue, // 阻塞队列:核心线程忙时,任务先进队列排队
threadFactory, // 线程工厂:给线程起名(排查时一眼看出是哪个池)
handler // 拒绝策略:队列满 + 线程也满时,新任务怎么处理
);
执行流程(必须能口述):任务来 → 先看核心线程是否满 →
没满开核心线程 → 满了进队列 → 队列也满 → 开非核心线程直到最大线程数 →
还是满 → 走拒绝策略。
四种拒绝策略:
| 策略 | 行为 | 适用场景 |
|---|---|---|
AbortPolicy(默认) |
抛 RejectedExecutionException |
需要感知「我扛不住了」的场景 |
CallerRunsPolicy |
让提交任务的线程自己执行 | 限流:让上游慢下来,生产常用 |
DiscardPolicy |
静默丢弃新任务 | 可丢弃的非关键任务(慎用) |
DiscardOldestPolicy |
丢弃队列最老任务 | 优先保证新任务(慎用) |
6.4 生产级线程池配置类 +
命名工厂
// NamedThreadFactory.java —— 自定义线程工厂:给线程起有业务含义的名字
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.atomic.AtomicInteger;
public class NamedThreadFactory implements ThreadFactory {
private final String namePrefix; // 如 "biz-pool"
private final AtomicInteger seq = new AtomicInteger(0);
public NamedThreadFactory(String namePrefix) {
this.namePrefix = namePrefix;
}
@Override
public Thread newThread(Runnable r) {
// 名字形如 biz-pool-1、biz-pool-2,排查时一眼定位是哪个池的线程
Thread t = new Thread(r, namePrefix + "-" + seq.incrementAndGet());
t.setDaemon(false); // 业务线程池用非守护线程
return t;
}
}
// ThreadPoolDemo.java —— 生产级线程池:显式 7 参数 + 有界队列 + 拒绝策略 + 命名
import java.util.concurrent.*;
public class ThreadPoolDemo {
public static void main(String[] args) throws InterruptedException {
// 生产级线程池:CPU 密集型用「核数+1」,IO 密集型用「核数*2」
int core = Runtime.getRuntime().availableProcessors(); // CPU 核数
ThreadPoolExecutor pool = new ThreadPoolExecutor(
core, // 核心线程数
core * 2, // 最大线程数
60, TimeUnit.SECONDS, // 非核心线程空闲 60s 回收
new ArrayBlockingQueue<>(1000), // 有界队列,防止 OOM(关键!)
new NamedThreadFactory("biz-pool"),// 自定义命名工厂
new ThreadPoolExecutor.CallerRunsPolicy() // 拒绝策略:让调用线程执行,实现限流
);
// 提交任务:业务代码用 execute(无返回值)或 submit(有返回值)
for (int i = 0; i < 5; i++) {
final int taskId = i;
pool.execute(() -> {
String thread = Thread.currentThread().getName();
System.out.println("任务 " + taskId + " 由线程 [" + thread + "] 执行");
});
}
// 打印池监控指标(生产可接入监控系统)
System.out.println("核心线程数=" + pool.getCorePoolSize()
+ ", 活跃线程数=" + pool.getActiveCount()
+ ", 队列大小=" + pool.getQueue().size());
// 优雅关闭:不再接收新任务,等已提交任务执行完(生产停机必须做)
pool.shutdown();
pool.awaitTermination(10, TimeUnit.SECONDS);
}
}
运行:javac NamedThreadFactory.java ThreadPoolDemo.java && java ThreadPoolDemo
关键点:
- 队列必须是有界的(
ArrayBlockingQueue),这是防
OOM 的命门;- 核心/最大线程数公式只是经验值:CPU 密集型 = 核数 +
1,IO 密集型 = 核数 × 2(更精确要压测);- 线程必须命名,线上
jstack
看线程名就知道是哪个业务池卡了;- 禁止在方法里
new Thread(),统一走线程池,否则线程失控。
6.5 ThreadLocal:传递
traceId 与内存泄漏
ThreadLocal
是「线程本地变量」:每个线程持有自己的一份拷贝,互不干扰。生产最典型的用途是在链路里传递
traceId——一次请求从入口到出口,所有日志都打上同一个
traceId,方便串起整条调用链。
// TraceContext.java —— ThreadLocal 工具类:传递 traceId(生产级)
public final class TraceContext {
private static final ThreadLocal<String> TRACE = new ThreadLocal<>();
private TraceContext() {} // 工具类私有构造器
public static void set(String traceId) { TRACE.set(traceId); }
public static String get() { return TRACE.get(); }
public static void clear() { TRACE.remove(); } // 必须调用,否则内存泄漏
}
// ThreadLocalDemo.java —— 传递 traceId + finally 里 remove(防内存泄漏)
import java.util.concurrent.*;
public class ThreadLocalDemo {
private static final ThreadPoolExecutor POOL = new ThreadPoolExecutor(
2, 2, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(10),
new NamedThreadFactory("trace-pool"),
new ThreadPoolExecutor.CallerRunsPolicy()
);
public static void main(String[] args) {
for (int i = 0; i < 3; i++) {
final String traceId = "trace-" + i;
POOL.execute(() -> {
try {
TraceContext.set(traceId); // 进入线程先 set
handle(); // 业务逻辑里 get 到同一个 traceId
} finally {
TraceContext.clear(); // 必须 finally remove,防泄漏
}
});
}
POOL.shutdown();
}
static void handle() {
System.out.println("线程[" + Thread.currentThread().getName()
+ "] 处理请求, traceId=" + TraceContext.get());
}
}
为什么必须
remove():线程池里的线程是复用的。上一个请求
set 的 traceId 若不
remove,会残留在线程里,下一个复用该线程的请求就读到了上一个请求的
traceId——这正是线上「用户 A 日志里出现用户 B 的
traceId」这种诡异 bug 的根因。同时,ThreadLocal 的 key
是弱引用、value 是强引用,不 remove 时 value 无法被
GC,长期累积就是内存泄漏。
关键点:
set和remove必须成对出现,且
remove放在finally里,保证异常时也执行。
6.6
并发工具类:CountDownLatch / Semaphore
JDK 提供了几个高频并发工具,底层都是 AQS:
CountDownLatch:一次性门闩,等待 N
个任务完成后放行(如「等所有子任务做完再继续」)。Semaphore:信号量,控制同时访问某资源的线程数(如限流、连接池)。
演示并发原语时用裸
new Thread()
是为了聚焦原语语义;生产业务代码禁止裸
new Thread(),统一走线程池(见 6.4)。
// LatchSemaphoreDemo.java —— CountDownLatch 与 Semaphore
import java.util.concurrent.*;
public class LatchSemaphoreDemo {
public static void main(String[] args) throws InterruptedException {
// CountDownLatch:3 个子任务完成后主线程才继续
CountDownLatch latch = new CountDownLatch(3);
for (int i = 0; i < 3; i++) {
final int id = i;
new Thread(() -> {
System.out.println("子任务 " + id + " 完成");
latch.countDown(); // 计数 -1
}).start();
}
latch.await(); // 阻塞直到计数归零
System.out.println("所有子任务完成,主线程继续");
// Semaphore:同时只允许 2 个线程访问
Semaphore sem = new Semaphore(2);
for (int i = 0; i < 5; i++) {
final int id = i;
new Thread(() -> {
try {
sem.acquire(); // 获取许可(满了就阻塞)
System.out.println("线程 " + id + " 获得许可");
Thread.sleep(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
sem.release(); // 释放许可(必须 finally)
}
}).start();
}
}
}
6.7
ReentrantLock:可中断、可超时的锁
synchronized
拿到锁后不可中断、不可超时。ReentrantLock
弥补了这些,还支持公平锁:
// LockDemo.java —— ReentrantLock 可超时获取锁
import java.util.concurrent.TimeUnit;
import java.util.concurrent.locks.ReentrantLock;
public class LockDemo {
private static final ReentrantLock lock = new ReentrantLock();
public static void main(String[] args) {
lock.lock(); // 线程1(主线程)先拿到锁
new Thread(() -> {
try {
// 最多等 1 秒,拿不到就放弃(比 synchronized 灵活)
if (lock.tryLock(1, TimeUnit.SECONDS)) {
try {
System.out.println("线程2 拿到锁");
} finally {
lock.unlock();
}
} else {
System.out.println("线程2 等待超时,放弃");
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}).start();
lock.unlock(); // 线程1 释放
}
}
6.8
双重检查锁单例:volatile 的经典应用
「懒加载单例」是面试必考。朴素的双重检查锁必须加
volatile,否则指令重排可能导致别的线程拿到「半初始化」的对象:
// SingletonDemo.java —— 双重检查锁 + volatile(生产级写法)
public class SingletonDemo {
// volatile 防止「分配内存->初始化->赋值引用」被重排成「分配内存->赋值引用->初始化」
private static volatile SingletonDemo instance;
private SingletonDemo() {} // 私有构造器,禁止外部 new
public static SingletonDemo getInstance() {
if (instance == null) { // 第一重检查:避免每次进来都加锁
synchronized (SingletonDemo.class) {
if (instance == null) { // 第二重检查:避免重复创建
instance = new SingletonDemo();
}
}
}
return instance;
}
}
关键点:
instance = new SingletonDemo()
不是原子操作(分配内存、初始化、赋引用三步),不加volatile
时 JIT 可能重排,另一个线程在第一重检查处看到「非 null 但未初始化」的
instance。volatile
的内存屏障禁止了这种重排。
6.9
线程池执行流程(文字版对照)
把 6.3 的执行流程画成文字流程,配合 7 参数记忆:
新任务提交
→ 当前线程数 < corePoolSize ? 是 → 新建核心线程执行
→ 否 → 队列 workQueue 未满 ? 是 → 任务入队排队
→ 否 → 当前线程数 < maximumPoolSize ? 是 → 新建非核心线程执行
→ 否 → 走拒绝策略 handler
对应地理解 keepAliveTime
只对「非核心线程」生效:非核心线程空闲超过 keepAliveTime
就被回收,核心线程默认不回收(除非
allowCoreThreadTimeOut(true))。
6.10 死锁:如何产生与避免
两个线程各自持有对方需要的锁,又等待对方释放,就会死锁——程序卡死,线程永远阻塞。
// DeadlockDemo.java —— 演示死锁(用 jstack 可看到两个线程互相等待)
public class DeadlockDemo {
private static final Object A = new Object();
private static final Object B = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (A) {
sleep(100);
synchronized (B) { System.out.println("线程1 拿到 A 和 B"); }
}
}).start();
new Thread(() -> {
synchronized (B) {
sleep(100);
synchronized (A) { System.out.println("线程2 拿到 B 和 A"); }
}
}).start();
}
static void sleep(long ms) {
try { Thread.sleep(ms); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
}
}
避免死锁的三条原则:
- 固定加锁顺序:所有线程按相同顺序获取多把锁(上例改成都先
A 后 B 就不死锁); - 尽量用一把锁:能一把锁解决就别用两把;
- 用
tryLock(超时):拿不到就放弃,避免无限等待。
排查死锁:
jstack <pid>
打印线程堆栈,底部会直接提示「Found one Java-level deadlock」。
本章小结
volatile 管可见性有序性、synchronized/CAS
管原子性;线程池必须显式 ThreadPoolExecutor
七参数、有界队列、命名线程、拒绝策略;ThreadLocal 传
traceId 必须 finally 里
remove——这三条是并发编程的生产铁律。
第 7 章 构建工具 Maven 实战
前面几章用
javac/java
是为了「零负担学语言」。进入真实项目,依赖管理、编译、打包、多模块,全部交给
Maven。本章讲清坐标、依赖传递与冲突、dependencyManagement、多模块和私服——这些是
Spring Boot 阶段每天都在用的基建。
7.1 坐标:依赖的唯一身份
Maven
用三个值唯一标识一个构件(artifact),称为坐标(GAV):
| 坐标项 | 含义 | 示例 |
|---|---|---|
groupId |
组织/公司域名的反写 | com.example、org.springframework.boot |
artifactId |
项目/模块名 | user-service、spring-boot-starter-web |
version |
版本号 | 3.2.5、1.0.0-SNAPSHOT |
一个标准的 pom.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.example</groupId>
<artifactId>user-service</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging> <!-- jar / war / pom(pom 用于父工程) -->
<properties>
<!-- 统一管理版本号与编码,子模块可继承 -->
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>
7.2 依赖传递与冲突解决
Maven
会自动把「你依赖的构件所依赖的构件」也拉下来,这叫依赖传递。传递多了就会冲突(同一个构件出现多个版本)。解决冲突有三板斧:
- 最短路径优先:Maven
默认选「依赖路径最短」的版本; - 路径相同,先声明优先:谁先写进
pom.xml
用谁; - 显式控制(生产推荐):用
dependencyManagement统一版本 +exclusions
排除传递依赖。
**scope(依赖范围)**也影响传递:compile(默认,会传递)、provided(编译期提供,运行期由容器提供,如
Servlet
API)、runtime(运行期才需要)、test(仅测试用)。
<dependencies>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.14.0</version>
</dependency>
<!-- 排除某个依赖里传递进来的、你不需要的构件(常见:排除旧版 slf4j 实现) -->
<dependency>
<groupId>com.example</groupId>
<artifactId>some-lib</artifactId>
<version>1.0.0</version>
<exclusions>
<exclusion>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-log4j12</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>
dependencyManagement 与
dependencies 的区别(高频面试题):
dependencyManagement:只声明版本、不实际引入。子模块要用时,只写
groupId/artifactId,不写
version,版本自动继承。用于父工程统一锁版本。dependencies:实际引入依赖。
7.3
多模块项目(生产级完整示例)
生产项目通常按「分层」或「业务域」拆成多个 Maven
模块。下面是一个「父工程 + 两个子模块」的完整结构:
user-parent/ # 父工程,packaging=pom
├── pom.xml # 定义 modules + dependencyManagement
├── user-common/ # 公共模块(放 User 实体、工具类)
│ └── pom.xml
└── user-service/ # 服务模块(依赖 user-common)
└── pom.xml
父工程 user-parent/pom.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.example</groupId>
<artifactId>user-parent</artifactId>
<version>1.0.0</version>
<packaging>pom</packaging> <!-- 父工程必须是 pom 打包 -->
<!-- 声明所有子模块,父工程构建时按顺序一起构建 -->
<modules>
<module>user-common</module>
<module>user-service</module>
</modules>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<!-- 统一锁版本:子模块引用时不用写 version -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
<version>2.0.13</version>
</dependency>
<dependency>
<groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId>
<version>1.5.6</version>
</dependency>
</dependencies>
</dependencyManagement>
</project>
子模块 user-common/pom.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/version 可省略,自动继承 -->
<parent>
<groupId>com.example</groupId>
<artifactId>user-parent</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>user-common</artifactId>
<dependencies>
<!-- 版本由父工程 dependencyManagement 锁定,这里不写 version -->
<dependency>
<groupId>org.slf4j</groupId>
<artifactId>slf4j-api</artifactId>
</dependency>
</dependencies>
</project>
子模块 user-service/pom.xml(依赖
user-common):
<?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>
<parent>
<groupId>com.example</groupId>
<artifactId>user-parent</artifactId>
<version>1.0.0</version>
</parent>
<artifactId>user-service</artifactId>
<dependencies>
<!-- 依赖本项目的 user-common 模块 -->
<dependency>
<groupId>com.example</groupId>
<artifactId>user-common</artifactId>
<version>1.0.0</version>
</dependency>
</dependencies>
</project>
在 user-parent 目录下构建:
mvn clean install # clean 清理 + install 安装到本地仓库
# 构建顺序:先 user-common,再 user-service(因为 service 依赖 common)
关键点:多模块的构建顺序由模块间的依赖关系决定(Maven
自动拓扑排序),不依赖<modules>里的书写顺序。
7.4 私服与常用插件
私服(如 Nexus、Artifactory)是公司内部的「Maven
中央仓库代理 + 私有构件仓库」:
- 作用一:缓存中央仓库,团队内网拉包快、稳定,不依赖外网;
- 作用二:托管私有构件,团队内部模块(如
user-common)通过私服共享; - 作用三:统一治理,控制依赖白名单、安全扫描。
上传到私服用 <distributionManagement> 配置:
<distributionManagement>
<repository>
<id>nexus-releases</id>
<url>https://nexus.example.com/repository/maven-releases/</url>
</repository>
<snapshotRepository>
<id>nexus-snapshots</id>
<url>https://nexus.example.com/repository/maven-snapshots/</url>
</snapshotRepository>
</distributionManagement>
注意:
id要和~/.m2/settings.xml里配置的
<server>的id对应,私服的账号密码写在
settings.xml(不进代码库)。
常用插件(生产几乎必配):
| 插件 | 作用 |
|---|---|
maven-compiler-plugin |
编译,指定 Java 版本(JDK 17) |
maven-surefire-plugin |
运行单元测试(mvn test 的底层) |
maven-jar-plugin |
打普通 jar 包 |
maven-shade-plugin |
打「带所有依赖」的 fat jar |
7.5 Maven 生命周期与常用命令
Maven 有三个内置生命周期,最常用的是
default(构建生命周期),它由一串**阶段(phase)**组成,按顺序执行(执行后面的阶段会自动先执行前面的):
validate → compile → test → package → verify → install → deploy
常用命令与对应阶段:
| 命令 | 作用 |
|---|---|
mvn clean |
清理 target/ 目录 |
mvn compile |
编译 src/main/java |
mvn test |
编译 + 跑单元测试(maven-surefire-plugin) |
mvn package |
编译 + 测试 + 打包(jar/war) |
mvn install |
package + 安装到本地仓库( ~/.m2/repository) |
mvn deploy |
install + 发布到私服/中央仓库 |
关键点:
mvn install
是本地开发高频命令——把当前模块装进本地仓库,其他本地模块才能依赖到它。mvn deploy
才是「发布给团队」的动作。
7.6 settings.xml 与镜像加速
Maven 的全局配置在
~/.m2/settings.xml(用户级)或
$MAVEN_HOME/conf/settings.xml(全局)。两件生产必配的事:
- 镜像(mirror):把中央仓库换成更快的内网私服或镜像源(如阿里云),加速依赖下载:
<settings>
<mirrors>
<mirror>
<id>aliyun</id>
<mirrorOf>central</mirrorOf> <!-- 代理 central 中央仓库 -->
<name>aliyun public</name>
<url>https://maven.aliyun.com/repository/public</url>
</mirror>
</mirrors>
</settings>
- 私服账号:
<servers>
里配置私服的用户名密码,与pom.xml里
<distributionManagement>的id
对应:
<settings>
<servers>
<server>
<id>nexus-releases</id>
<username>deploy-user</username>
<password>xxxxxx</password>
</server>
</servers>
</settings>
关键点:账号密码放
settings.xml(不进代码库),pom.xml
里只放id引用——这是「配置与代码分离」的体现。
本章小结
坐标(GAV)是依赖的身份,依赖传递会引发冲突(用
dependencyManagement + exclusions
治理),多模块用「父 POM(packaging=pom)+
<modules> +
继承」组织,私服负责内网缓存与私有构件共享——Maven 是后续所有 Spring Boot
项目的构建底座。
第 8 章 生产级实战与代码规范
最后一章回答一个问题:同样是能跑的代码,什么是「生产级」?
答案是规范。本章先讲阿里 Java 开发手册与 Clean Code
的核心条款,再给出「用户管理」项目的骨架设计,把前 7
章的知识点串起来。完整的可运行项目在第 5 节「生产级实战项目」给出。
8.1 命名规范(阿里手册)
命名是代码可读性的第一道关。阿里手册的命名铁律:
| 元素 | 规则 | 示例(正确 / 错误) |
|---|---|---|
| 类名 | UpperCamelCase(大驼峰) |
UserService / userservice |
| 方法、变量 | lowerCamelCase(小驼峰) |
findById / find_by_id |
| 常量 | UPPER_SNAKE_CASE |
MAX_RETRY_COUNT / maxRetryCount |
| 包名 | 全小写 | com.example.user |
| 布尔变量 | 禁止 is 开头(POJO) |
deleted / isDeleted |
| 数组 | Type[](中缀) |
String[] args / String args[] |
// 命名正误对照
public class NamingDemo {
// 错误:常量应全大写下划线
// public static final int maxRetryCount = 3;
// 正确
public static final int MAX_RETRY_COUNT = 3;
public static void main(String[] args) {
// 错误:魔法值 3 散落代码,语义不明
// 正确:常量名自带语义
int retry = MAX_RETRY_COUNT;
System.out.println(retry);
}
}
8.2
注释规范:解释「为什么」,不解释「是什么」
注释不是给代码「翻译一遍」,而是记录代码本身表达不了的决策、约束、坑。
// 错误注释:复述代码(无信息量)
// 循环遍历用户列表
for (User user : users) { ... }
// 正确注释:解释为什么(有价值)
// 这里用 entrySet 而非 keySet,避免 get 的二次哈希开销(见第 2 章)
for (Map.Entry<Long, User> entry : userMap.entrySet()) { ... }
// 正确注释:标记已知坑
// 注意:toMap 的第三个参数是键冲突时的合并函数,不写会在 key 重复时抛 IllegalStateException
Map<Long, User> byId = users.stream()
.collect(Collectors.toMap(User::getId, Function.identity(), (a, b) -> a));
Clean Code 的核心原则(选最关键的 4 条):
- 函数短小、单一职责:一个方法只做一件事,超过 20
行就考虑拆; - 命名即文档:好的变量名/方法名能替代一半注释;
- DRY(Don’t Repeat
Yourself):重复的代码抽成方法/工具类; - 先写能跑的,再重构到干净:可运行是底线,干净是追求。
8.3 工具类与不可变对象
工具类(全是静态方法的类)必须私有构造器,防止被误实例化:
// 工具类正确写法:private 构造器 + final 类
public final class UserUtils {
private UserUtils() {} // 禁止 new
public static boolean isAdult(User user) {
return user != null && user.getAge() != null && user.getAge() >= 18;
}
}
不可变对象用 JDK 17 的
record(自动生成构造器、equals/hashCode/toString,且字段不可变):
// record:JDK 14+ 预览、17 正式可用。适合做 DTO、值对象
public record UserVO(Long id, String username, Integer age) {
// 可在紧凑构造器里做校验(生产级:入参校验)
public UserVO {
if (id == null || username == null || username.isBlank()) {
throw new IllegalArgumentException("id 和 username 不能为空");
}
}
}
关于 Lombok:生产项目普遍用 Lombok 的
@Data、@Slf4j来省掉
getter/setter/日志声明的样板代码。本阶段纯 Java
教学不引入(先手写理解原理),从 Spring 阶段开始,全系列统一用
Lombok(构造器注入@RequiredArgsConstructor+
final字段、日志
@Slf4j)。这里先留个印象即可。
8.4 用户管理项目骨架设计
下面设计第 5 节完整项目的骨架:一个 User 实体 +
一个组合了 Stream 与线程池的 UserService。设计要点:
User实体:重写
equals/hashCode(第 2 章);UserService:查询用 Stream 过滤/分组(第 4
章),异步统计用线程池(第 6 章);- 异常用
ErrorCode/BizException(第 5
章)。
// User.java —— 用户实体(生产级:字段私有 + 重写 equals/hashCode)
import java.util.Objects;
public class User {
private Long id;
private String username;
private Integer age;
public User() {} // 无参构造器(反射/Mapper 需要)
public User(Long id, String username, Integer age) {
this.id = id;
this.username = username;
this.age = age;
}
// 业务主键是 id,equals/hashCode 只认 id
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
User user = (User) o;
return Objects.equals(id, user.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public Integer getAge() { return age; }
public void setAge(Integer age) { this.age = age; }
@Override
public String toString() {
return "User{id=" + id + ", username='" + username + "', age=" + age + '}';
}
}
// UserService.java —— 组合 Stream + 线程池 + 异常(骨架,完整版见第 5 节)
import java.util.*;
import java.util.concurrent.*;
import java.util.function.Function;
import java.util.stream.Collectors;
public class UserService {
// 内存数据源:生产里这里是数据库/缓存,这里用 List 演示集合 + Stream
private final List<User> users = new ArrayList<>();
// 生产级线程池:有界队列 + 命名 + 拒绝策略(第 6 章)
private final ThreadPoolExecutor pool = new ThreadPoolExecutor(
Runtime.getRuntime().availableProcessors(),
Runtime.getRuntime().availableProcessors() * 2,
60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new NamedThreadFactory("user-pool"),
new ThreadPoolExecutor.CallerRunsPolicy()
);
/** 新增用户:先校验,再判重(用 Set 判重依赖 equals/hashCode) */
public void add(User user) {
if (user == null || user.getUsername() == null || user.getUsername().isBlank()) {
throw new BizException(ErrorCode.PARAM_ERROR);
}
if (users.contains(user)) { // contains 依赖 equals(只比 id)
throw new BizException(ErrorCode.USER_EXISTS);
}
users.add(user);
}
/** 按年龄过滤(Stream filter) */
public List<User> listByAgeGreaterThan(int age) {
return users.stream()
.filter(u -> u.getAge() != null && u.getAge() > age)
.collect(Collectors.toList());
}
/** 按 id 转 Map(防键重复) */
public Map<Long, User> toMap() {
return users.stream()
.collect(Collectors.toMap(User::getId, Function.identity(), (a, b) -> a));
}
/** 按年龄分组(groupingBy) */
public Map<Integer, List<User>> groupByAge() {
return users.stream()
.filter(u -> u.getAge() != null)
.collect(Collectors.groupingBy(User::getAge));
}
/** 异步统计平均年龄:用线程池执行(第 6 章) */
public Future<Double> avgAgeAsync() {
return pool.submit(() -> users.stream()
.mapToInt(u -> u.getAge() == null ? 0 : u.getAge())
.average()
.orElse(0));
}
}
关键点:这个骨架已经把本篇 80% 的知识点串起来了——实体重写
equals/hashCode、Stream 过滤/分组/转
Map、线程池异步、ErrorCode/BizException
异常、命名规范。第 5 节把它补全为「可运行的完整项目」。
8.5 更多阿里手册要点(高频)
除了 8.1 的命名,再补几条 Code Review 里最常见的:
- 判空用
isEmpty():if (list.isEmpty())而非
if (list.size() == 0)——语义更清晰。 - 常量/字面量在前:
"SUCCESS".equals(status)
而非status.equals("SUCCESS")——前者能防status
为 null 时的 NPE。 - POJO 的布尔字段不用
is
开头:private boolean deleted;(isXxx
会被框架当作 getter 名,产生歧义)。 - 参数校验放最前:入口处先校验再干活,
if (param == null) throw ...,避免带着脏数据往下走。 - 避免多层嵌套:
if深度不超过 3
层,超过就抽方法或提前return(卫语句)。
// 卫语句:提前 return,避免深层嵌套
public String getLevel(User user) {
if (user == null) return "未知"; // 卫语句
if (user.getAge() == null) return "未知";
if (user.getAge() < 18) return "未成年";
return "成年"; // 主干逻辑清晰
}
8.6 单元测试基础:JUnit 5
生产级代码必须有测试。JUnit 5 是 Java 事实标准的测试框架(Spring
阶段会大量用到,这里先入门):
// UserServiceTest.java —— JUnit 5 单元测试(测试方法名描述场景)
import com.example.usermgmt.common.BizException;
import com.example.usermgmt.service.UserService;
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.*;
class UserServiceTest {
@Test
void shouldThrowUserExistsWhenRegisterDuplicateId() {
UserService service = new UserService();
service.register(1L, "张三", 25);
// 断言:重复 id 注册应抛 BizException,且错误码是 USER_EXISTS
BizException e = assertThrows(BizException.class,
() -> service.register(1L, "李四", 30));
assertEquals(ErrorCode.USER_EXISTS.getCode(), e.getCode());
}
@Test
void shouldThrowUserNotFoundWhenIdAbsent() {
UserService service = new UserService();
assertThrows(BizException.class, () -> service.findById(999L));
}
}
三个测试铁律:一个测试只测一个行为、测试方法名描述场景(shouldXxxWhenYyy)、先写失败再写实现(TDD
的起点,后续阶段会展开)。
8.7 分层思想:为进入 Spring
做铺垫
第 5 节的项目已经在「分层」:repository(数据访问)→
service(业务)→ App(入口/表现)。后续 Spring
阶段会把这套分层映射为
controller / service / mapper / entity / dto / vo,并加上
ApiResult 统一响应体、GlobalExceptionHandler
全局异常处理。
这里先记住一句话:每层只做自己该做的事,层与层之间通过接口交互。UserService
不关心数据存在哪(内存还是数据库),UserRepository
不关心业务规则——这种「面向接口、职责单一」的思维,是理解 Spring
依赖注入(IoC)的认知前提。
本章小结
生产级代码 = 正确的命名 + 解释「为什么」的注释 + 单一职责的小函数 +
私有构造器的工具类 + 不可变值对象;把这些规范应用到
User/UserService
上,就得到可扩展的项目骨架。
5. 生产级实战项目:用户管理
本节把前 8 章串成一个完整可运行的 Maven
项目。它实现了「用户注册、查询、过滤、分组统计、异步统计、带过期时间的本地缓存」6
个功能,覆盖本篇 80% 以上的知识点:集合与
equals/hashCode、泛型与反射(缓存工具可扩展)、Stream、异常体系、线程池、Maven。
5.1 目录结构
user-mgmt/
├── pom.xml
└── src/main/java/com/example/usermgmt/
├── common/
│ ├── ErrorCode.java
│ └── BizException.java
├── entity/
│ └── User.java
├── repository/
│ └── UserRepository.java
├── service/
│ └── UserService.java
└── App.java
5.2 完整代码
pom.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.example</groupId>
<artifactId>user-mgmt</artifactId>
<version>1.0.0</version>
<packaging>jar</packaging>
<properties>
<maven.compiler.source>17</maven.compiler.source>
<maven.compiler.target>17</maven.compiler.target>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
</project>
common/ErrorCode.java(与第 5
章逐字一致,后续阶段复用):
package com.example.usermgmt.common;
/** 统一错误码枚举:4xxxx 客户端可修复,5xxxx 服务端错误 */
public enum ErrorCode {
SUCCESS(0, "success"),
PARAM_ERROR(40001, "参数错误"),
UNAUTHORIZED(40101, "未登录或登录已过期"),
FORBIDDEN(40301, "无权限访问"),
USER_NOT_FOUND(40401, "用户不存在"),
USER_EXISTS(40002, "用户名已存在"),
SYSTEM_ERROR(50000, "系统繁忙,请稍后重试"),
;
private final int code;
private final String message;
ErrorCode(int code, String message) {
this.code = code;
this.message = message;
}
public int getCode() { return code; }
public String getMessage() { return message; }
}
common/BizException.java:
package com.example.usermgmt.common;
/** 统一业务异常:携带错误码,可保留原始异常链 */
public class BizException extends RuntimeException {
private final int code;
public BizException(ErrorCode ec) {
super(ec.getMessage());
this.code = ec.getCode();
}
public BizException(int code, String message) {
super(message);
this.code = code;
}
public BizException(int code, String message, Throwable cause) {
super(message, cause);
this.code = code;
}
public int getCode() { return code; }
}
entity/User.java:
package com.example.usermgmt.entity;
import java.util.Objects;
/** 用户实体:业务主键 id,equals/hashCode 只认 id */
public class User {
private Long id;
private String username;
private Integer age;
public User() {}
public User(Long id, String username, Integer age) {
this.id = id;
this.username = username;
this.age = age;
}
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof User)) return false;
User user = (User) o;
return Objects.equals(id, user.id);
}
@Override
public int hashCode() {
return Objects.hash(id);
}
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
public String getUsername() { return username; }
public void setUsername(String username) { this.username = username; }
public Integer getAge() { return age; }
public void setAge(Integer age) { this.age = age; }
@Override
public String toString() {
return "User{id=" + id + ", username='" + username + "', age=" + age + '}';
}
}
repository/UserRepository.java(线程安全本地缓存,带过期时间,第
6 章 L3 实战):
package com.example.usermgmt.repository;
import com.example.usermgmt.entity.User;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.TimeUnit;
import java.util.stream.Collectors;
/**
* 用户仓储:用 ConcurrentHashMap 实现线程安全的本地缓存。
* 每个条目记录「数据 + 过期时间戳」,读取时惰性淘汰过期条目。
* 生产里这里是数据库(MyBatis/JPA),本阶段用内存实现演示并发集合。
*/
public class UserRepository {
/** 缓存条目:数据 + 过期时间(毫秒时间戳) */
private static class CacheEntry {
final User user;
final long expireAt; // 过期时刻(System.currentTimeMillis() + ttl)
CacheEntry(User user, long expireAt) {
this.user = user;
this.expireAt = expireAt;
}
}
// ConcurrentHashMap 保证并发读写安全,不需要手动加锁
private final ConcurrentHashMap<Long, CacheEntry> cache = new ConcurrentHashMap<>();
/** 写入:带过期时间(ttl 秒),过期后读取不到 */
public void put(User user, long ttlSeconds) {
long expireAt = System.currentTimeMillis() + TimeUnit.SECONDS.toMillis(ttlSeconds);
cache.put(user.getId(), new CacheEntry(user, expireAt));
}
/** 读取:若已过期则惰性删除并返回 null,实现缓存自动过期 */
public User get(Long id) {
CacheEntry entry = cache.get(id);
if (entry == null) {
return null;
}
if (System.currentTimeMillis() > entry.expireAt) {
cache.remove(id); // 惰性淘汰:过期了顺手删掉
return null;
}
return entry.user;
}
public void remove(Long id) {
cache.remove(id);
}
public int size() {
return cache.size();
}
/** 列出所有未过期用户(Stream 过滤过期条目) */
public List<User> findAll() {
long now = System.currentTimeMillis();
return cache.values().stream()
.filter(e -> now <= e.expireAt) // 只返回未过期条目
.map(e -> e.user)
.collect(Collectors.toList());
}
}
service/UserService.java:
package com.example.usermgmt.service;
import com.example.usermgmt.common.BizException;
import com.example.usermgmt.common.ErrorCode;
import com.example.usermgmt.entity.User;
import com.example.usermgmt.repository.UserRepository;
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicLong;
import java.util.function.Function;
import java.util.stream.Collectors;
/** 用户业务层:校验、去重、查询、分组统计、异步统计 */
public class UserService {
private final UserRepository repository = new UserRepository();
/** 生产级线程池:有界队列 + 命名 + 拒绝策略(第 6 章) */
private final ThreadPoolExecutor pool = new ThreadPoolExecutor(
Runtime.getRuntime().availableProcessors(),
Runtime.getRuntime().availableProcessors() * 2,
60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(100),
new NamedThreadFactory("user-stat-pool"),
new ThreadPoolExecutor.CallerRunsPolicy()
);
/** 注册用户:参数校验 + 判重,缓存 5 分钟 */
public User register(Long id, String username, Integer age) {
if (id == null || username == null || username.isBlank()) {
throw new BizException(ErrorCode.PARAM_ERROR);
}
if (age != null && (age < 0 || age > 150)) {
throw new BizException(ErrorCode.PARAM_ERROR.getCode(), "年龄不合法");
}
if (repository.get(id) != null) {
throw new BizException(ErrorCode.USER_EXISTS);
}
User user = new User(id, username, age);
repository.put(user, 300); // 缓存 5 分钟
return user;
}
/** 按 id 查询:查不到抛业务异常(Optional 风格) */
public User findById(Long id) {
return Optional.ofNullable(repository.get(id))
.orElseThrow(() -> new BizException(ErrorCode.USER_NOT_FOUND));
}
/** 列出所有用户(缓存 size 有限,演示用) */
public List<User> listAll() {
return List.copyOf(repositoryCacheValues());
}
/** 年龄过滤(Stream filter) */
public List<User> listAdults(int minAge) {
return repositoryCacheValues().stream()
.filter(u -> u.getAge() != null && u.getAge() >= minAge)
.collect(Collectors.toList());
}
/** 按年龄分组(Stream groupingBy) */
public Map<Integer, List<User>> groupByAge() {
return repositoryCacheValues().stream()
.filter(u -> u.getAge() != null)
.collect(Collectors.groupingBy(User::getAge));
}
/** 转 Map(toMap 带合并函数防键重复) */
public Map<Long, User> toIdMap() {
return repositoryCacheValues().stream()
.collect(Collectors.toMap(User::getId, Function.identity(), (a, b) -> a));
}
/** 异步统计平均年龄(线程池执行,Future 拿结果) */
public Future<Double> averageAgeAsync() {
return pool.submit(() -> repositoryCacheValues().stream()
.mapToInt(u -> u.getAge() == null ? 0 : u.getAge())
.average()
.orElse(0));
}
/** 仓库当前数据(从缓存里捞出所有未过期 User) */
private List<User> repositoryCacheValues() {
return repository.findAll();
}
/** 关闭线程池(生产停机必须优雅关闭) */
public void shutdown() {
pool.shutdown();
}
/** 复用第 6 章的自定义命名线程工厂 */
static class NamedThreadFactory implements ThreadFactory {
private final String prefix;
private final AtomicLong seq = new AtomicLong(0);
NamedThreadFactory(String prefix) { this.prefix = prefix; }
@Override
public Thread newThread(Runnable r) {
return new Thread(r, prefix + "-" + seq.incrementAndGet());
}
}
}
App.java(程序入口,演示全部功能):
package com.example.usermgmt;
import com.example.usermgmt.common.BizException;
import com.example.usermgmt.entity.User;
import com.example.usermgmt.service.UserService;
import java.util.concurrent.Future;
/** 用户管理程序入口:演示注册、查询、过滤、分组、异步统计、异常处理 */
public class App {
public static void main(String[] args) throws Exception {
UserService service = new UserService();
// 1. 注册用户
service.register(1L, "张三", 25);
service.register(2L, "李四", 30);
service.register(3L, "王五", 25);
service.register(4L, "赵六", 18);
System.out.println("=== 注册完成 ===");
// 2. 判重:重复 id 抛 USER_EXISTS
try {
service.register(1L, "张三重复", 99);
} catch (BizException e) {
System.out.println("判重生效: code=" + e.getCode() + ", msg=" + e.getMessage());
}
// 3. 查询:查不到抛 USER_NOT_FOUND
try {
service.findById(999L);
} catch (BizException e) {
System.out.println("查询生效: code=" + e.getCode() + ", msg=" + e.getMessage());
}
// 4. Stream 过滤 + 分组 + 转 Map
System.out.println("成年人(>=18): " + service.listAdults(18));
System.out.println("按年龄分组: " + service.groupByAge());
System.out.println("按 id 转 Map: " + service.toIdMap());
// 5. 异步统计(线程池)
Future<Double> avgFuture = service.averageAgeAsync();
System.out.println("平均年龄(异步): " + avgFuture.get());
// 6. 优雅关闭
service.shutdown();
}
}
5.3 运行步骤
# 1. 按上面目录结构建好文件后,在 user-mgmt 目录下执行:
cd user-mgmt
mvn clean compile # 编译(若首次运行会自动下载 Maven 插件)
# 2. 运行主类(maven exec 插件,或手动 classpath)
mvn exec:java -Dexec.mainClass="com.example.usermgmt.App"
# 备选:不用 exec 插件,手动运行
# mvn package
# java -cp target/classes com.example.usermgmt.App
期望输出(顺序略有差异属正常):
=== 注册完成 ===
判重生效: code=40002, msg=用户名已存在
查询生效: code=40401, msg=用户不存在
成年人(>=18): [User{id=1, username='张三', age=25}, User{id=2, username='李四', age=30}, User{id=3, username='王五', age=25}, User{id=4, username='赵六', age=18}]
按年龄分组: {18=[User{id=4, username='赵六', age=18}], 25=[User{id=1, username='张三', age=25}, User{id=3, username='王五', age=25}], 30=[User{id=2, username='李四', age=30}]}
按 id 转 Map: {1=User{...}, 2=User{...}, 3=User{...}, 4=User{...}}
平均年龄(异步): 24.5
说明:如果你没有
exec-maven-plugin,运行
mvn exec:java时 Maven
会自动去中央仓库下载该插件(需要联网)。也可以像前面几章一样,直接用
javac编译src/main/java下的所有
.java再运行,二者等价。
6. 常见坑与排错指南
| 坑 / 现象 | 原因 | 解决方案 |
|---|---|---|
ConcurrentModificationException |
for-each 遍历时直接 list.remove(),触发 fail-fast |
用 removeIf 或 Iterator.remove() |
Set/Map 去重或查找「时灵时不灵」 |
实体只重写 equals 没重写 hashCode |
二者必须同时重写,且用相同字段 |
| 遍历 Map 慢、被 Code Review 打回 | 用 keySet() 再 get(),二次哈希 |
用 entrySet() 一次拿 key 和 value |
toMap 抛IllegalStateException: Duplicate key |
key 重复且没传合并函数 | 传第三个参数 (a, b) -> a 或(a, b) -> b |
| 金额计算少几分钱、对不上账 | double/float 二进制误差,或new BigDecimal(0.1) |
一律 BigDecimal,字符串/valueOf构造, compareTo 比较 |
| 循环里字符串拼接内存暴涨 | String 不可变,+ 产生大量中间对象 |
用 StringBuilder |
| 线程池任务堆积 → OOM | Executors.newFixedThreadPool() 用无界队列 |
用 ThreadPoolExecutor + ArrayBlockingQueue有界队列 + 拒绝策略 |
方法里到处 new Thread(),线程失控 |
无统一管理 | 统一走线程池,禁止裸 new Thread() |
| 线上日志串了用户(A 的 traceId 出现在 B 请求) | ThreadLocal 没remove,线程池复用线程导致脏数据 |
finally 里 ThreadLocal.remove() |
| 异常被吞、线上无日志无堆栈 | 空 catch / e.printStackTrace() / catch 后return null |
异常上抛统一处理,log.error("...", e) 带堆栈 |
volatile 计数结果不对 |
volatile 只保证可见性,不保证 i++原子性 |
用 AtomicInteger 或 synchronized |
| Maven 依赖版本冲突、拉错包 | 依赖传递多个版本,最短路径优先命中旧版 | dependencyManagement 锁版本 + exclusions排除 |
反射 newInstance 报NoSuchMethodException |
类没有无参构造器 | 提供无参构造器,或 getDeclaredConstructor(参数)匹配 |
8. 总结与延伸阅读
总结
本阶段你建立的是「生产级 Java」的完整地基:从环境与调试,到集合底层与
equals/hashCode 契约,到泛型/注解/反射,到
Stream/Optional
的简洁写法,到异常与日志体系,到线程池与
ThreadLocal,最后用 Maven
多模块和「用户管理」项目把它们串成可运行的整体。记住三个贯穿全篇的「生产级心智」:凡事显式(错误码显式、空值显式、线程池参数显式)、凡事有界(队列有界、线程有界)、凡事可观测(命名线程、结构化日志、traceId)。这些能力会被后续
11 个 Spring Boot 阶段逐层放大,但地基在这里。
延伸阅读
- 官方文档(优先):《The Java™ Tutorials》—— Collections
与 Concurrency
章节; - 《Effective Java》(第 3 版)—— 生产级 Java
的圣经,重点看「equals/hashCode」「泛型」「Lambda 与
Stream」「并发」几章; - 《Java 并发编程实战》——
系统理解可见性/原子性/有序性与线程池; - 《深入理解 Java 虚拟机》(第 3 版)—— 想追到 JVM
内存、GC、锁升级的底层必读; - 《阿里巴巴 Java 开发手册》——
国内事实标准的编码规范,配套 IDEA 插件可实时检查。
本阶段完 · 下一篇《阶段 1:Spring 基础与
IoC/DI》将用你在本篇打下的 Java 地基,正式进入 Spring 世界。