全景:Java 的定位与现状
钻进语法之前先回答三个问题:Java 今天长什么样、它跑在哪里、和邻近语言怎么选。顺便把一件事说清楚——这一页讲的是语言和 JDK 本身,不是任何一套公司流程。
先把三个总被混着说的名字分开:Java 是语言,JVM 是虚拟机(跑字节码、管内存、做即时编译),JDK 是你装到机器上的那一整套。你写 Java、编成字节码、由 JVM 执行。
今天的 Java 在哪儿跑
- 服务端是最大主场——但服务端不等于「大公司内网系统」,独立开发者的 API、游戏后端、机器人也跑在 JVM 上;
- Android 整个平台的 API 是 Java 形态;大数据(Kafka、Elasticsearch、Spark)几乎全在 JVM 上。
和邻居怎么选
- vs Kotlin:同一个 JVM、可逐文件混写,不是二选一——先会 Java 再看 Kotlin 几乎没有损失;
- vs Go:Go 启动快、单二进制,Java 胜在生态存量与运行期 JIT 的长跑性能;
- vs C#:定位高度重合,现实里由平台与团队决定。
// 现代 Java 的气质:三行能跑,也能一句话建模
// —— 下面这些语法在 02–08 章逐个展开
sealed interface 事件 permits 升级, 下线 {} // 07 章:把「一共就这几种」写进类型
record 升级(String 玩家, int 等级) implements 事件 {}
record 下线(String 玩家) implements 事件 {}
static String 播报(事件 e) {
return switch (e) { // 08 章:模式匹配 + 解构
case 升级(String p, int lv) when lv >= 100 -> p + " 满级了!";
case 升级(String p, int lv) -> p + " 升到 " + lv + " 级";
case 下线(String p) -> p + " 走了";
}; // 不用写 default:编译器知道就这两种
}
void main() { // 01 章:JDK 25 起 main 可以这么短
IO.println(播报(new 升级("小明", 100))); // 小明 满级了!
}new Date()、Vector/Hashtable、匿名内部类当回调、SimpleDateFormat。Java 有个别的语言没有的负担:它的名声被使用场景绑架了。很多人对它的全部印象来自「公司里那套系统」——满屏配置、层层抽象。那些是组织的产物,不是语言的属性。
讲什么,不讲什么
- 讲:语言本身(02–09 章)、标准库真正常用的那部分、以及现代 Java 与旧印象差别最大的地方;
- 不讲某一套企业框架的配置细节——19、20 章会说清「要写 Web 服务有哪些路子」,但不做某个框架的使用手册:框架三年一换,语言不换。
怎么用这一页
- 一边读一边跑:JDK 25 让验证成本几乎归零,存成
X.java后java X.java就出结果,不用建项目、不用装构建工具(01 章)。
// 这一页里例子的样子:一个人能做完、跑起来有反馈的东西
// ① 命令行小工具(03、19 章)—— 统计一个目录里各种后缀的文件数
Files.walk(Path.of("."))
.filter(Files::isRegularFile)
.collect(Collectors.groupingBy(Java::后缀, Collectors.counting()))
// ② 抓个接口做统计(11、19 章)—— HTTP 客户端在 JDK 里,不用装库
HttpClient.newHttpClient().send(req, BodyHandlers.ofString())
// ③ 文字游戏(05、07、08 章)—— 状态用 record 建模,回合用模式匹配分发
record 房间(String 描述, Map<String, 房间> 出口) {}
// ④ 桌面小程序(20 章)—— Swing 就在 JDK 里,一个文件就能出窗口
new JFrame("我的工具") {{ setVisible(true); }};
// ⑤ 一次抓一百个页面(14 章)—— 虚拟线程让并发写法退回到最好懂的那种
try (var pool = Executors.newVirtualThreadPerTaskExecutor()) {
urls.forEach(u -> pool.submit(() -> 抓(u)));
}var、record、文本块、toList()),但类型系统、对象模型、异常与泛型这些地基一处都没省。上手:三行跑通第一个程序
这一章不讲语言,只做一件事:把 JDK 装上、让程序在你机器上真的动起来、把报错看懂。JDK 25 之后 Java 的入门门槛降了一大截——第一个程序真的只要三行,而且不需要任何构建工具。
Java 的安装只有两个决定:装哪个版本(选 25,最新的 LTS)和装谁家的发行版。后者其实不用纠结——各家都是同一份 OpenJDK 源码编出来的,同一个版本号下通过同一套兼容性测试,日常使用没有区别。
装:拿不准就装 Temurin
Windows winget install EclipseAdoptium.Temurin.25.JDK macOS brew install --cask temurin@25 Linux sudo apt install openjdk-25-jdk # 或用 SDKMAN! 任意平台 sdk install java 25-tem # SDKMAN! 能装多版本并随时切换 手动 从 adoptium.net 下 zip/tar.gz 解压即用 # 不需要管理员权限
- Eclipse Temurin(adoptium.net)社区维护、免费、全平台、授权上没有弯弯绕。其它常见的是 Corretto、Zulu、Microsoft Build of OpenJDK、Oracle JDK——只有 Oracle JDK 的许可条款值得看一眼;
- 解压就能用,不需要「安装」:JDK 是一个自包含目录,把它的
bin加进PATH即可。想留几个版本就装几份、按需切 PATH(SDKMAN! 就是自动做这件事的)。
验:三条命令
$ java -version java version "25.0.1" 2025-10-21 LTS // ① 能跑 Java 程序 $ javac -version javac 25.0.1 // ② 能编译 —— 这条不通就是只装了 JRE $ jshell -version // ③ 有交互式环境(下一卡)
JDK 与 JRE 的区别:JRE 只能跑已编译好的程序,JDK 还能编译并附带全套工具(javac、jshell、jar、jlink…)。装 JDK,别装 JRE——现在的官方下载页基本只提供 JDK 了。
# 装完先确认这三个环境变量的状态(很多教程会让你手动设,其实通常不必)
$ java -version # 这条通了,说明 PATH 已经对了
$ echo $JAVA_HOME # 空着也没关系;只有部分构建工具需要它
# 想同时装多个版本,SDKMAN!(macOS/Linux/WSL)是最省事的
$ curl -s "https://get.sdkman.io" | bash
$ sdk list java # 看有哪些发行版和版本
$ sdk install java 25-tem
$ sdk install java 21-tem
$ sdk use java 21-tem # 只对当前终端生效
$ sdk default java 25-tem
# Windows 上的等价物:直接装两份,用时切 PATH
$ winget install EclipseAdoptium.Temurin.25.JDK
$ winget install EclipseAdoptium.Temurin.21.JDKjava -version 通但 javac -version 不通,就是这个问题。JAVA_HOME:java -version 通了说明 PATH 已经对了,绝大多数场景够用。只有 Maven/Gradle 或某些 IDE 会去读 JAVA_HOME,到那时再设。你在网上看到的那个 public class Hello { public static void main(String[] args) { … } } 已经不是必需的了。JDK 25 起,最短的可运行 Java 程序是三行,而且可以直接运行源文件,不用先编译。
三层「其实可以省」
| 你以为必须写的 | 今天的真相 | 起自 |
|---|---|---|
javac 再 java | 直接 java Hello.java,编译在内存里完成(多文件也会自动一起编译) | 11 / 22 |
public class Hello { … } | 类名可以整个省掉(紧凑源文件) | 25 |
public static void main(String[] args) | void main() 就够,System.out.println 也可写成 IO.println | 25 |
紧凑写法适合脚本、练习、验证想法;一旦要被别人 import、要打成 jar、要分包,就该写成正常的 public class(05 章)。两种写法可以混:入口用紧凑形式、其余文件写成正常的类。
更快的草稿纸:jshell
- JDK 自带的交互式环境(Java 9 起),不用建文件、不用 main、不用分号——想验证一句话的行为,它比建项目快一个数量级;
- 最适合三件事:验证一个断言(「
"👍中".length()到底是几?」答案是 3,04 章讲为什么)、探索陌生的类(打p.按Tab列出所有方法)、当计算器; - 值得记的命令只有几个:
/list看输入过的代码、/vars看当前变量、/exit退出。
// ① 最短形式(JDK 25):Hello.java
void main() {
IO.println("你好,Java");
}
// ② 要读输入、要用命令行参数时(args 可选,写了就有)
void main(String[] args) {
String 名字 = args.length > 0 ? args[0] : IO.readln("你叫什么?");
IO.println("你好," + 名字);
}
// ③ 完整形式:给别人当库用、要打包时写这种
public class Hello {
public static void main(String[] args) {
System.out.println("你好,Java");
}
}
// 跑法(三种都试一遍,心里就有数了)
// $ java Hello.java 直接跑源文件
// $ javac Hello.java 先编译,产生 Hello.class
// $ java Hello 再运行(注意:不带 .class 后缀)
// $ java Main.java 多文件也行,同目录的会一起编译.java 文件不一定能编译:它替你 import 了一堆包,也不强制你处理受检异常(12 章)。把它当草稿纸,不要当编译器。学一门语言,读懂它的抱怨比记住语法更省时间。Java 的错误分三个阶段出现,先判断是哪一阶段,范围立刻缩小十倍。
① 编译期:javac 说的话(最常见,也最好修)
格式永远是「文件:行号: error: 说明」,下面用 ^ 指出位置:
Hello.java:3: error: ';' expected // 少分号:指着上一个 token 的末尾 int x = "文本" ^ E2.java:16: error: incompatible types: String cannot be converted to int int n = l.get(0); // 类型对不上:两边都写出来了 E5.java:4: error: variable y might not have been initialized return y; // 局部变量必须先赋值再用
- 永远从第一条错误开始修——后面的经常是它的连锁反应,少一个大括号能连带报出十几条;
- javac 分阶段工作:只要有「类型对不上」这类错误,「变量没初始化」这类检查根本不会跑。所以修完一批又冒出新的一批是正常的,不是你改坏了。
② 启动期找不到东西 ③ 运行期异常与堆栈
Error: Could not find or load main class Appp // 类名拼错,或 -cp 没指对
Exception in thread "main" java.lang.NullPointerException:
Cannot invoke "String.length()" because "message" is null
at N.main(N.java:4)- 「找不到主类」的三个原因:类名拼错(注意大小写)、
-cp没包含.class所在目录、有包名时没写全名(java com.example.App); - 堆栈从上往下读:最上面是出事现场,往下是谁调用了它。先找到属于你自己代码的那一行,从那里查;
- Java 的空指针异常会告诉你到底哪个东西为空(Java 14 引入,15 起默认开启)——上面那条直接点名了
message。
// 三类错误的最小复现,自己跑一遍会记得更牢
// ① 编译期:类型对不上
int x = "文本"; // incompatible types: String cannot be converted to int
// ② 启动期:类名拼错
// $ java Helo Error: Could not find or load main class Helo
// ③ 运行期:空指针
String message = null;
message.length(); // NPE: Cannot invoke "String.length()" because "message" is null
// 常见运行期异常与它们的意思(12 章展开)
// NullPointerException 用了 null
// ArrayIndexOutOfBoundsException 下标越界,消息里会带「Index 5 out of bounds for length 3」
// ClassCastException 强制转型转错了类型
// NumberFormatException Integer.parseInt("12a") —— 消息里带原始字符串
// ArithmeticException: / by zero 整数除以零(浮点除以零不抛,得 Infinity)
// 想看英文原版报错(方便搜索):
// $ javac -J-Duser.language=en X.java
// $ java -Duser.language=en Xfile.encoding 已固定为 UTF-8,而 stdout.encoding 跟随系统。先确认是终端编码问题再去改代码。javac 会输出中文报错,搜索时把它切回英文更容易找到答案:编译加 -J-Duser.language=en,运行加 -Duser.language=en。语言地基:类型、变量与运算
Java 的一切都从「这是基本类型还是引用类型」开始:赋值时复制什么、相等怎么算、能不能是 null,全由这个二分决定。这一章把它讲透,顺带把数字运算里那些会静默给出错误答案的地方一次挖开。
Java 的类型只分两类,这个二分决定了赋值、传参、比较的全部行为:基本类型(8 个,变量里装的就是值本身)和引用类型(其余全部,变量里装的是「指向对象的引用」)。
八个基本类型,记住范围就够
| 类型 | 大小 | 范围 | 默认值 | 什么时候用 |
|---|---|---|---|---|
int | 32 位 | 约 ±21 亿 | 0 | 整数默认用它 |
long | 64 位 | 约 ±9.2×10¹⁸ | 0L | 时间戳、大计数(字面量要加 L) |
double | 64 位 | 浮点 | 0.0 | 小数默认用它 |
float | 32 位 | 浮点 | 0.0f | 省内存的图形/科学计算(字面量要加 f) |
boolean | 未规定 | true/false | false | 条件(不能和 0/1 互转) |
char | 16 位 | 一个 UTF-16 码元 | '\u0000' | 单个字符(04 章有坑) |
byte | 8 位 | −128 ~ 127 | 0 | 二进制数据(有符号) |
short | 16 位 | 约 ±3.2 万 | 0 | 基本用不上 |
- 日常只需要
int、long、double、boolean、char,另外三个只在特定场合出现。 - 基本类型不能是
null——这既是它们的优点(不会 NPE),也是它们的限制(无法表达「没有值」)。
赋值时到底复制了什么
- 基本类型:复制值。
int b = a;之后改b不影响a。 - 引用类型:复制引用(那个「地址」),对象只有一个。
List<String> b = a;之后b.add(...),a也看得见——因为它们指着同一个对象。 - Java 传参永远是「按值传」:传基本类型时复制值,传引用时复制引用。所以方法里
x = 新对象影响不到外面,但x.改一改()影响得到。「Java 是传引用」这个说法是错的,正确说法是「传的是引用的副本」。
包装类型:把基本类型装进对象
- 每个基本类型都有对应的包装类:
int→Integer、double→Double、boolean→Boolean… - 为什么需要它:泛型和集合只能装对象,
List<int>是非法的,必须写List<Integer>(09、10 章)。 - 自动装箱/拆箱:编译器在需要时替你来回转换,写起来像没区别——但它藏着两个真会出事的地方,见下方 pitfall 和 04 卡。
// 基本类型:变量里就是值
int a = 10;
int b = a; // 复制了一份值
b = 99;
// a 还是 10
// 引用类型:变量里是「指向对象的引用」
var 列表1 = new ArrayList<String>(List.of("a"));
var 列表2 = 列表1; // 复制的是引用,对象只有一个
列表2.add("b");
// 列表1 现在也是 [a, b]
// 传参:传的是「引用的副本」
static void 试试(List<String> l) {
l.add("外面看得见"); // ← 改的是同一个对象,外面看得见
l = new ArrayList<>(); // ← 只是让局部变量指向别处,外面无感
l.add("外面看不见");
}
// 装箱与拆箱
Integer 装箱 = 42; // 自动装箱:Integer.valueOf(42)
int 拆箱 = 装箱; // 自动拆箱:装箱.intValue()
List<Integer> 只能装对象 = List.of(1, 2, 3); // List<int> 不合法
// 每种类型的字面量写法
long 大数 = 10_000_000_000L; // 下划线只是给人看的分隔符
double 小数 = 1.5e3; // 1500.0
int 十六进制 = 0xFF, 二进制 = 0b1010;
char 字符 = '中';null 拆箱就是 NPE,这是最难查的一类空指针,因为出事的那行看起来根本没调用方法。Integer x = null; boolean c = false; int r = c ? 1 : x; 抛 NullPointerException: Cannot invoke "java.lang.Integer.intValue()" because "<local0>" is null——三元表达式的两个分支类型不同(int 和 Integer)时,整个表达式被提升为 int,于是没被选中的那一支也可能被拆箱。规避:让三元表达式的两支类型一致,或者别把可空的包装类型丢进算术表达式。Integer 而不是 int:只有两种情况。① 要放进集合或泛型里(List<Integer>);② 需要表达「这个值可能不存在」(Integer 分数 = null 表示还没打分)。其余一律用 int——它更快、更省内存、不会 NPE。一百万个 Integer 约占 20 MB,一百万个 int 约 4 MB,差 5 倍;五百万次累加用 long 和用 Long 差三倍多(具体倍数看机器,自己跑一下)。Java 是静态类型语言:每个变量都有确定的类型,编译期就定死。var(Java 10 起)只是让编译器替你把类型写出来,不是变成动态类型——变量类型依然固定,只是你不用打两遍。
var 用不用:一条实用规则
- 右边已经把类型说清楚了,就用
var:var 列表 = new ArrayList<String>();(重复写ArrayList<String>毫无信息)、var 扫描器 = new Scanner(System.in);。 - 右边看不出类型,就写出来:
var 结果 = 处理(输入);—— 读代码的人(包括三个月后的你)得跳去看方法签名才知道是什么。 var不能用在:字段、方法参数、返回类型;也不能初始化成null(推不出类型)。它只用于有初始值的局部变量。
作用域:大括号就是边界
- 变量的存活范围是声明它的那对大括号。出了括号就不存在,可以重名声明新的。
- 局部变量必须先赋值再使用,编译器会做「确定赋值分析」:
int y; return y;直接报variable y might not have been initialized(报错原文)。字段则不同——字段有默认值(数字 0、布尔 false、引用 null),不赋值也能用。 - 这个差别很重要:局部变量忘了赋值编译不过(好事),字段忘了赋值静默变成
null,等到用的时候才 NPE。
final 与「实际上的 final」
final表示「这个变量只能赋值一次」。注意它锁的是变量,不是对象:final List<String> l = new ArrayList<>();之后l.add(...)完全合法,只是不能再让l指向别的列表。- lambda 和匿名类只能捕获 final 或「实际上的 final」变量(赋值之后再没被改过)。改了就编译不过:
local variables referenced from a lambda expression must be final or effectively final(报错原文)。11 章会讲怎么绕。 - 要不要到处写
final:写了更安全但也更啰嗦,两派都有。折中做法是只在「改了就是 bug」的地方写,比如常量和构造器里赋值的字段。
// var:右边说清楚了就用
var 名字 = "小明"; // String
var 分数 = new HashMap<String, Integer>(); // 不用把长类型写两遍
var 行 = Files.readAllLines(路径); // List<String>,读得出来
// var 用不了的地方
// var x; 编译错:需要初始值才能推断
// var y = null; 编译错:推不出类型
// var 不能用于字段、参数、返回类型
// 作用域:大括号就是边界
for (int i = 0; i < 3; i++) {
int 临时 = i * 2;
}
// 这里 i 和 临时 都已经不存在了
// final 锁的是变量,不是对象
final List<String> 名单 = new ArrayList<>();
名单.add("小明"); // ✓ 合法:改的是对象内容
// 名单 = new ArrayList<>(); ✗ 编译错:不能重新赋值
// 常量:static final + 全大写(唯一被广泛遵守的命名约定)
static final int 最大重试次数 = 3;
static final String 版本 = "1.0";var 遇上数字字面量会推出你可能不想要的类型。var x = 1; 推出的是 int,var y = 1.0; 是 double——这没问题;但 var n = 0; 后面想放大数就得改成 0L。更隐蔽的是菱形推断:var m = new HashMap<>(); 推出来是 HashMap<Object,Object>,之后 m.get(k) 拿到的全是 Object,得到处强转。用 var 时,右边的泛型参数必须写全。UpperCamelCase、方法和变量 lowerCamelCase、常量 UPPER_SNAKE_CASE、包名全小写。Java 标识符可以用中文(本页的例子就大量在用,读起来更清楚),编译器完全接受;不过真项目里的公共 API 还是写英文,因为别人的编辑器和终端未必配好了中文。数字运算是最容易「不报错但算错」的地方。下面四条全部是本机在 JDK 25.0.1 上的行为,它们不是 bug,是规范规定的——但不知道就会写出静默错误的代码。
① 整数溢出:不报错,直接绕回去
Integer.MAX_VALUE + 1 → -2147483648 // 悄悄变成最小值 Math.abs(Integer.MIN_VALUE) → -2147483648 // 绝对值居然是负的
int是 32 位补码,超出范围就环绕。算钱、算时间毫秒、做累加统计时特别容易撞上——21 亿这个上限比想象中好突破(比如毫秒数只够表示 24 天)。- 两个解法:用
long;或者用Math.addExact/multiplyExact/absExact,溢出时抛ArithmeticException: integer overflow(报错原文),把静默错误变成响亮的失败。
② 整数除法:直接截断,不四舍五入
7 / 2 → 3 7 % 2 → 1 -7 / 2 → -3 -7 % 2 → -1 // 向零截断,余数跟着被除数的符号 1 / 0 → ArithmeticException: / by zero 1.0 / 0 → Infinity // 浮点除零不抛异常! 0.0 / 0.0 → NaN
- 两个
int相除结果还是int。想要小数必须至少有一边是浮点:7 / 2.0或(double) a / b。 a / b * b != a:7/2*2是 6。计算平均值、分页、进度百分比时最常出错。
③ 浮点:0.1 + 0.2 不等于 0.3
0.1 + 0.2 → 0.30000000000000004
(0.1 + 0.2) == 0.3 → false
Double.NaN == Double.NaN → false // NaN 不等于自己
new BigDecimal(0.1) → 0.1000000000000000055511151231257827021181583404541015625- 二进制浮点存不下 0.1 这类十进制小数,这是 IEEE 754 的性质,所有语言都一样。
- 比较浮点不要用
==,用Math.abs(a - b) < 1e-9。 - 算钱一律不要用
double:要么用BigDecimal(且必须用字符串构造,见上面第四行的—传double进去误差已经带进来了),要么用long存「分」。
④ 类型提升:小类型相加会变成 int
byte + byte的结果类型是int,所以byte c = a + b;编译不过,得强转。char + int也是int:'a' + 1打印出来是98而不是'b'。- 混合运算的提升顺序:
byte/short/char→int→long→float→double,往「更大」的那边靠。
// 溢出:静默 vs 响亮
int 静默 = Integer.MAX_VALUE + 1; // -2147483648,不报错
int 响亮 = Math.addExact(Integer.MAX_VALUE, 1); // ArithmeticException: integer overflow
// 时间戳用 long,不是 int
long 毫秒 = System.currentTimeMillis(); // int 只够表示 24 天
// 整数除法:想要小数得先转
int 完成 = 7, 总数 = 9;
int 错的 = 完成 / 总数 * 100; // 0 —— 先算 7/9 得 0
double 对的 = 100.0 * 完成 / 总数; // 77.77…
// 浮点比较
double x = 0.1 + 0.2;
boolean 别这么写 = x == 0.3; // false
boolean 这么写 = Math.abs(x - 0.3) < 1e-9; // true
// 钱:两种正确做法
var 单价 = new BigDecimal("19.99"); // ✓ 字符串构造
var 总价 = 单价.multiply(BigDecimal.valueOf(3));
// var 错的 = new BigDecimal(19.99); ✗ 误差已经带进来了
long 分 = 1999; // ✓ 或者干脆用整数存最小单位
// 位运算(做标志位、哈希、图形时会用到)
int 与 = 0b1100 & 0b1010; // 0b1000
int 左移 = 1 << 10; // 1024
int 无符号右移 = -8 >>> 1; // 2147483644(>> 保符号,>>> 补 0)float 基本没有使用理由,别学老教程用它。它只有 7 位左右的十进制有效数字,float f = 0.1f + 0.2f; 的误差比 double 大得多,而在今天的机器上并不更快;省内存的收益只在存几百万个数时才显现(那时该考虑的是 float[] 数组而不是单个变量)。小数一律用 double,钱用 BigDecimal 或整数分。10/5)。一个便宜的习惯:把百分比的 100 写成 100.0 放在算式最前面(100.0 * a / b),从第一步就进入浮点运算。这是 Java 新手最集中的一个坑:== 比较的是「是不是同一个对象」,equals 比较的是「内容是不是一样」。基本类型没有这个问题(== 就是比值),引用类型必须分清。
几个看起来矛盾的结果
| 表达式 | 结果 | 为什么 |
|---|---|---|
Integer a=127,b=127; a==b | true | −128~127 有缓存,是同一个对象 |
Integer c=128,d=128; c==d | false | 超出缓存范围,各造一个 |
c.equals(d) | true | 比的是值 |
"hi" == "hi" | true | 字面量在常量池里共享 |
"hi" == new String("hi") | false | new 强制造新对象 |
"hi" == ("h" + "i") | true | 编译期就拼好了,还是常量 |
String p="h"; "hi" == (p+"i") | false | 运行期拼接,新对象 |
「Integer 用 == 比较,小数字对、大数字错」就是这么来的——它是所有静默 bug 里最恶劣的一种:你的测试数据用了小数字,一切正常;上线后数字变大,逻辑突然失效。
规则很简单
- 基本类型(
int、double、char…):用==。 - 其它一切(
String、Integer、你自己的类):用equals。 - 唯二的例外:枚举用
==比(07 章,每个枚举常量全局唯一,==更快也更安全);判断「是不是同一个对象」时才故意用==。 - 比较可能为 null 的东西:用
Objects.equals(a, b),两边都为 null 算相等,不会 NPE。或者把常量写在前面:"admin".equals(输入)。
自己的类要能比较,就得覆写 equals
- 不覆写
equals时,默认行为是==——即使两个对象内容完全一样也不相等。 - 覆写
equals就必须同时覆写hashCode,否则放进HashSet/HashMap会出鬼。一个只覆写了equals的类,两个「相等」的对象放进HashSet,size()是 2;而同样内容的 record 只有 1。 - 最省事的办法:用 record(07 章)——它自动生成正确的
equals、hashCode、toString,一行都不用写。
// 规则:基本类型 ==,其余 equals
int a = 1000, b = 1000;
System.out.println(a == b); // true —— 基本类型比值
Integer x = 1000, y = 1000;
System.out.println(x == y); // false!比的是对象身份
System.out.println(x.equals(y)); // true
System.out.println(x.intValue() == y); // true —— 有一边是 int 就会拆箱比值
// 字符串永远用 equals
Scanner 输入 = new Scanner(System.in);
String 命令 = 输入.nextLine();
if (命令 == "退出") { } // ✗ 永远是 false(运行期读进来的是新对象)
if ("退出".equals(命令)) { } // ✓ 而且命令为 null 也不会炸
if (Objects.equals(命令, 目标)) { } // ✓ 两边都可能为 null 时
// 自己的类:用 record 就自动有正确的 equals/hashCode
record 坐标(int x, int y) {}
var 集合 = new HashSet<坐标>();
集合.add(new 坐标(1, 2));
集合.add(new 坐标(1, 2));
System.out.println(集合.size()); // 1(换成普通 class 且没覆写 hashCode 就是 2)Integer 缓存范围(−128~127)是可以被 JVM 参数改大的,所以「128 以上就 false」不是可以依赖的规律,而是「根本不该依赖 ==」的证据。更严重的是这个 bug 的表现形式:它只在数值变大后才出现。写登录逻辑时用户 ID 是 1、2、3,测试全过;上线后 ID 到了几千,「同一个用户」突然被判成不同的人。凡是包装类型,一律用 equals 或先拆箱成基本类型再比。== 比身份、equals 比内容」这句话背下来,能省掉将来几个小时的调试。顺带一个实用细节:IDE 里对 String 用 == 通常会给出警告,别关掉它。如果你确实想比身份(极少见),显式写 a == b 并加一行注释说明意图。Java 的转换分两类:不丢信息的自动发生(小类型 → 大类型),可能丢信息的必须你自己写(大 → 小、父类 → 子类)。「必须显式写」是有意为之的设计——它逼你在每一处可能丢数据的地方停一秒。
基本类型之间
int → long → float → double // 自动,不用写 double → int // 必须强转,小数部分直接砍掉(不是四舍五入) (int) 3.99 → 3 // 截断 (int) -3.99 → -3 // 向零截断 (byte) 200 → -56 // 超出范围就只留低 8 位 Math.round(3.5) → 4 // 要四舍五入用 Math.round
(byte) 200得到 −56 是因为只保留低 8 位再按补码解释——不报错,也不警告。处理二进制数据时要特别小心byte是有符号的(b & 0xFF是把它当无符号看的标准写法)。
引用类型之间
- 向上转型(子类 → 父类)自动发生:
Object o = "字符串";。 - 向下转型(父类 → 子类)必须写,而且可能在运行期失败:
(Integer) o如果o其实是 String,抛ClassCastException: class java.lang.String cannot be cast to class java.lang.Integer(报错原文)。 - 安全的做法是先用
instanceof检查,而且 Java 16 起可以在检查的同时绑定变量(模式匹配,08 章):if (o instanceof String s)—— 不用再写一遍强转。 null instanceof 任何类型一律是 false,所以instanceof天然带了空检查。
字符串与数字互转
- 数字 → 字符串:
String.valueOf(n)、Integer.toString(n),或者最省事的"" + n。 - 字符串 → 数字:
Integer.parseInt(s)、Double.parseDouble(s)。失败会抛NumberFormatException: For input string: "12a"(报错原文)——处理用户输入时必须try/catch。 parseInt和valueOf的区别:前者返回int,后者返回Integer。要拿去算就用parseInt。
// 基本类型:自动 vs 强转
int i = 42;
long l = i; // 自动:不会丢
double d = l; // 自动
int 回去 = (int) d; // 必须强转:可能丢小数
double 价格 = 3.99;
System.out.println((int) 价格); // 3 —— 截断,不是四舍五入
System.out.println(Math.round(价格)); // 4
// byte 是有符号的:处理二进制数据的标准写法
byte b = (byte) 200; // -56
int 当无符号看 = b & 0xFF; // 200
// 引用类型:先检查再转(Java 16 起可以一步到位)
Object o = 读一个东西();
if (o instanceof String s && s.length() > 3) { // s 直接可用,不用再强转
System.out.println(s.toUpperCase());
}
// 老写法(还会在旧代码里见到)
if (o instanceof String) {
String s = (String) o; // 同一个类型写了三遍
}
// 字符串 ↔ 数字
int n = Integer.parseInt("42");
String s2 = String.valueOf(n);
try {
int 用户输入 = Integer.parseInt(行);
} catch (NumberFormatException e) {
System.out.println("这不是个数字:" + e.getMessage());
}long,是个非常隐蔽的坑:long 毫秒 = 24 * 60 * 60 * 1000 * 30; —— 右边整个是 int 运算,先溢出成负数,再转成 long。写成 24L * 60 * 60 * 1000 * 30(第一个数就是 long)才对。规律:类型转换发生在整个表达式算完之后,不是算之前。同理 double 平均 = 总分 / 人数;(两个 int)也是先整数除法再转 double。List<Object> 再逐个 instanceof 分发——那是泛型(09 章)和密封类型(07 章)要解决的问题。强转应该是稀有的;如果它在你的代码里到处都是,先回头看类型能不能定得更准。控制流、方法与数组
分支、循环、方法、数组——这一章是所有语言的公共部分,Java 这里没什么惊喜。快速过一遍语法,重点放在几个真会踩的地方,最后用它们拼出一个能用的小工具。
语法和 C 家族一脉相承,没有意外。这一卡只把今天该用哪种写法和几个容易写错的点标出来。
四种循环,怎么选
| 写法 | 什么时候用 |
|---|---|
for (var x : 集合) | 默认选它——遍历数组或集合、不需要下标时 |
for (int i = 0; i < n; i++) | 需要下标、或要跳着走(i += 2) |
while (条件) | 次数不确定:读到文件结束、等用户输入 |
do { } while (条件) | 至少要执行一次(菜单循环)。用得很少 |
集合.forEach(...) / Stream | 11 章。做转换和过滤时比循环清楚 |
几个真会踩的点
if (x = 5)在 Java 里编译不过(C 里是经典 bug)——因为if只接受boolean,赋值表达式的值是int。这是 Java 「boolean不能和数字互转」带来的好处之一。&&和||是短路的:左边能定胜负就不算右边。所以if (数组.length > 0 && 数组[0] == 1)是安全的(空数组时不会越界)。单个的&|不短路,别写错。- 跳出多层循环用标签,不用设一堆
boolean标志位:给外层循环起个名字,break 名字;直接跳出去。 - 循环变量的作用域:
for (int i...)里的i出了循环就不存在。想在循环后用它,得在循环外声明。
switch:今天该用箭头写法
- 新写法(Java 14 起):
case A -> ...;—— 不会贯穿、可以当表达式返回值、多个值写一行。 - 老写法:
case A: ... break;—— 忘写break就会「贯穿」到下一个分支,这是 C 家族最著名的 bug 源之一。 - 新写法基本上全面更好,08 章会讲它更强的那一面(模式匹配、穷尽性检查)。
// 遍历:默认用增强 for
String[] 名单 = {"小明", "小红", "小刚"};
for (var 名字 : 名单) {
System.out.println(名字);
}
// 需要下标时
for (int i = 0; i < 名单.length; i++) {
System.out.printf("%d. %s%n", i + 1, 名单[i]);
}
// 跳出双层循环:用标签,不要用标志位
外层:
for (int i = 0; i < 9; i++) {
for (int j = 0; j < 9; j++) {
if (棋盘[i][j] == 目标) {
找到了 = true;
break 外层; // 一次跳出两层
}
}
}
// switch:箭头写法(不会贯穿,还能当表达式)
String 提示 = switch (等级) {
case 1, 2, 3 -> "新手";
case 4, 5 -> "熟练";
default -> "高手";
};
// 需要多行时用大括号 + yield
int 奖励 = switch (等级) {
case 1 -> { 记录("新手奖励"); yield 100; }
default -> 0;
};
// 短路求值:左边为假就不算右边
if (列表 != null && !列表.isEmpty()) { } // 安全List<String> l = new ArrayList<>(List.of("a","b","c")); 遍历中删掉 "b"(倒数第二个),不抛异常,静默少走一轮结束;换成删 "a" 就抛 ConcurrentModificationException。「有时报错有时不报」比「一定报错」难查得多——正确做法是用 list.removeIf(条件),或者用显式的 Iterator 并调它的 remove()。for 与 stream 只差个位数毫秒),而是「哪个把意图写在了明面上」。纯遍历做副作用(打印、写文件、改状态)用普通 for;数据从一种形态变成另一种形态用 Stream。方法是 Java 里代码复用的最小单位。语法没什么可讲的,值得说的是重载的挑选规则和可变参数的几个坑。
方法签名与重载
- 一个方法由名字 + 参数类型列表唯一确定(返回类型不算——只改返回类型是编译错误)。
- 重载:同名不同参。编译器按编译期的参数类型挑,规则是「优先精确匹配 → 再考虑自动提升 → 再考虑装箱 → 最后才考虑可变参数」。
- 这个规则导致的经典坑(10 章还会再遇到):
List<Integer>上调list.remove(1)删的是下标 1(精确匹配remove(int)),要删「值等于 1 的元素」必须写list.remove(Integer.valueOf(1))。[10,20,30]上remove(1)得到[10,30],remove(Integer.valueOf(10))得到[20,30]。
可变参数(varargs)
int 求和(int... 数字)里,数字就是个数组。调用方可以传任意多个,也可以直接传一个数组。- 只能有一个可变参数,且必须是最后一个参数。
- 三个验证过的坑:
f((Object) null)得到长度 1 的数组;f((Object[]) null)得到null本身,一读length就 NPE;传一个String[]给Object...时,javac 会警告non-varargs call of varargs method,因为它不确定你想传「一个数组」还是「两个元素」。
递归与栈
- 递归在 Java 里没有特殊待遇:没有尾调用优化,每一层都真的占一个栈帧。深度太大就
StackOverflowError。 - 能递归多深取决于栈大小和每帧的大小——判据是「栈帧大小 × 深度 < 栈上限」,不是某个固定数字。默认栈大小通常是几百 KB 到 1 MB 量级,
-Xss4m可以调大。想知道自己机器上的极限,写个只递增计数的方法二分一下就有答案。 - 树、图、目录遍历用递归很自然;对着一个几十万元素的链表递归就该换成循环。
// 一个方法的完整形态
static int 最大值(int a, int b) { // 修饰符 返回类型 名字(参数)
return a > b ? a : b;
}
// 重载:同名不同参
static int 最大值(int a, int b, int c) { return 最大值(最大值(a, b), c); }
static double 最大值(double a, double b) { return a > b ? a : b; }
// 可变参数:内部就是个数组
static int 求和(int... 数字) {
int 和 = 0;
for (int n : 数字) 和 += n;
return 和;
}
求和(); // 0
求和(1, 2, 3); // 6
求和(new int[]{1, 2}); // 3 —— 直接传数组也行
// 递归:树形结构上最自然
static long 目录大小(File 目录) {
if (目录.isFile()) return 目录.length();
long 合计 = 0;
for (File 子 : 目录.listFiles()) 合计 += 目录大小(子);
return 合计;
}
// 重载挑选的经典坑
List<Integer> 列表 = new ArrayList<>(List.of(10, 20, 30));
列表.remove(1); // 删下标 1 → [10, 30]
列表.remove(Integer.valueOf(10)); // 删值 10 → [30]void 清空(List<String> l) { l = new ArrayList<>(); } 什么也没做;void 清空(List<String> l) { l.clear(); } 真的清空了调用方的列表。写方法时想清楚你要的是哪一种;如果不希望调用方的对象被改,就在方法里先复制一份。处理数据并保存然后发通知,那它就该是三个方法。好名字是最便宜的注释:是不是闰年(年) 比 check(y) 加三行注释有用得多。数组是 Java 里唯一「长度固定、下标访问、语言内置」的容器。日常写业务代码基本用 List(10 章),但数组在性能敏感处、二维网格、以及和底层 API 打交道时仍然不可替代。
基本用法
- 创建即定长,之后长度不能变;
length是字段不是方法(arr.length,没有括号——String的是length()方法,这个不一致是历史遗留)。 - 创建时就有默认值:数字 0、布尔 false、引用
null。所以new String[3]里是三个null,直接用会 NPE。 - 越界抛
ArrayIndexOutOfBoundsException,消息里带Index 5 out of bounds for length 3,很好定位。
三个必须知道的行为
- 直接打印数组是一串乱码:
System.out.println(arr)输出[I@5fd9b663(类型标记 + 哈希)。要看内容用Arrays.toString(arr),多维用Arrays.deepToString。 - 比较数组不能用
equals(那是比身份)——用Arrays.equals(a, b)。 - 数组是协变的,这是个已知的设计缺陷:
Object[] o = new String[1];编译通过,但o[0] = 42;运行时抛ArrayStoreException。泛型集合没有这个问题(09 章会讲为什么)。
多维数组其实是「数组的数组」
new int[3][4]创建的是 3 个长度为 4 的数组。因此每行可以不一样长(锯齿数组),也因此二维遍历时按行遍历比按列快得多(内存局部性)。- 做棋盘、迷宫、图像时二维数组最直接:
int[][] 棋盘 = new int[9][9];。
Arrays 工具类,常用的就这几个
Arrays.toString/deepToString—— 打印Arrays.sort—— 排序(可传Comparator)Arrays.fill—— 全部填成某个值Arrays.copyOf/copyOfRange—— 扩容或截取Arrays.stream(arr)—— 转成 Stream(11 章)Arrays.binarySearch—— 二分查找(前提是已排序,没排序会给出无意义的结果而不是报错)
// 三种创建方式
int[] a = new int[5]; // 五个 0
int[] b = {3, 1, 4, 1, 5}; // 直接给内容
int[] c = new int[]{3, 1, 4}; // 需要当参数传时用这种
System.out.println(b.length); // 5 —— 字段,没有括号
System.out.println(b); // [I@5fd9b663 ← 没用
System.out.println(Arrays.toString(b)); // [3, 1, 4, 1, 5] ← 要这个
// 常用操作
Arrays.sort(b); // 原地排序
int[] 大一点 = Arrays.copyOf(b, 10); // 扩容(多出来的补 0)
int[] 一段 = Arrays.copyOfRange(b, 1, 3); // 下标 [1,3)
Arrays.fill(a, -1); // 全部填 -1
int 和 = Arrays.stream(b).sum(); // 转 Stream(11 章)
// 二维:棋盘、迷宫、图像
char[][] 棋盘 = new char[3][3];
for (char[] 行 : 棋盘) Arrays.fill(行, '.');
棋盘[1][1] = 'X';
for (char[] 行 : 棋盘) System.out.println(new String(行));
// 数组 ↔ 集合
List<String> 列表 = List.of(名单); // 数组 → 不可变 List
List<String> 可改 = new ArrayList<>(List.of(名单));
String[] 回去 = 列表.toArray(new String[0]); // List → 数组
// 注意:基本类型数组转 List 有坑
int[] 原始 = {1, 2, 3};
// List.of(原始).size() 是 1!整个数组被当成了一个元素
List<Integer> 对的 = Arrays.stream(原始).boxed().toList();Arrays.asList(数组) 返回的不是普通 ArrayList,它是个「定长视图」:在它上面 add 抛 UnsupportedOperationException,但 set(0, x) 是允许的而且会改到原数组。这个「能改元素不能改长度」的半吊子行为坑过无数人。要一个真能增删的列表,写 new ArrayList<>(Arrays.asList(数组));要只读的写 List.of(数组)(它连 set 都不让)。List:① 二维网格(棋盘、迷宫、像素)——List<List<X>> 又长又慢;② byte[]/char[] 处理二进制或文本缓冲;③ 元素是基本类型且量很大时(int[] 比 List<Integer> 省 5 倍内存,02 章有);④ 和需要数组的 API 对接。其余场合默认用 List,它能增删、有丰富的方法、打印起来还正常。学到这里,语言地基已经够写一个真能用的东西了。这一卡把前面的零件拼成一个命令行小工具的骨架——它可以直接改成你自己想要的东西。
一个命令行工具的四件事
- 拿到输入:命令行参数(
args)、标准输入(IO.readln()或Scanner)、或者文件路径。 - 处理:这部分是你的逻辑。
- 输出结果:正常结果打到
System.out,错误信息打到System.err——这样用户用管道接结果时不会被错误信息污染。 - 给出退出码:成功 0,失败非 0(
System.exit(1))。脚本靠它判断成败。
三种读输入的方式,怎么选
| 方式 | 适合 |
|---|---|
args | 参数固定、要被别的脚本调用 |
IO.readln("提示") | 交互式问答(JDK 25 起,最省事) |
new Scanner(System.in) | 要按类型读(nextInt 等),或需要兼容旧 JDK |
Files.readAllLines(路径) | 处理文件(19 章展开) |
「现在你来」:三个改造方向
- 改成词频统计:把行拆成词(
line.split("\\s+")),用Map计数(10 章),按次数排序输出前十。 - 改成文件整理器:遍历下载目录(19 章的
Files.walk),按后缀把文件移到对应子目录。 - 改成猜数字游戏:
Math.random()生成答案,循环读输入并提示大了小了,记录次数。
// 一个命令行工具的完整骨架(JDK 25 紧凑写法,直接 java 这个文件.java 就能跑)
import java.nio.file.*;
import java.util.*;
void main(String[] args) throws Exception {
// ① 参数检查:不合法就说清用法并给非 0 退出码
if (args.length == 0) {
System.err.println("用法:java 统计.java <文件>");
System.exit(2);
}
var 路径 = Path.of(args[0]);
// ② 可能失败的操作要处理失败(12 章)
if (!Files.exists(路径)) {
System.err.println("找不到文件:" + 路径.toAbsolutePath());
System.exit(1);
}
// ③ 干活
var 行 = Files.readAllLines(路径);
int 字符数 = 0, 非空行 = 0;
for (var l : 行) {
字符数 += l.length();
if (!l.isBlank()) 非空行++;
}
// ④ 结果打到 stdout,格式化对齐
System.out.printf("%-10s %d%n", "总行数", 行.size());
System.out.printf("%-10s %d%n", "非空行", 非空行);
System.out.printf("%-10s %d%n", "字符数", 字符数);
}
// 交互式版本(JDK 25 的 IO 类)
void 猜数字() {
int 答案 = (int) (Math.random() * 100) + 1;
for (int 次数 = 1; ; 次数++) {
int 猜 = Integer.parseInt(IO.readln("猜一个 1-100 的数:"));
if (猜 == 答案) { IO.println("对了!用了 " + 次数 + " 次"); return; }
IO.println(猜 < 答案 ? "小了" : "大了");
}
}System.exit() 会立刻终止 JVM,不会执行后面的代码,也不会跑 finally 块。在小工具的 main 里用它没问题(那正是它的用途),但别在库代码或方法内部随手用——调用方会毫无预兆地整个进程消失,还没法捕获。方法里表达失败应该抛异常(12 章),让调用方决定怎么办;只有最外层的 main 才有资格决定退出码。.java 文件本身发过去——对方装了 JDK 就能 java 你的文件.java,不需要任何构建步骤。这是 JDK 11 之后 Java 才有的能力,很多人还不知道。字符串与文本
字符串是出现频率最高的类型,也是坑最密的地方之一:它不可变、它有常量池、它按 UTF-16 存储所以一个 emoji 长度是 2。这一章把这些讲透,顺便把拼接性能这件被误传了十几年的事一遍。
Java 的 String 一旦创建就不能修改——所有看起来「改字符串」的方法(toUpperCase、replace、trim…)都是返回一个新字符串,原来那个纹丝不动。这一条解释了字符串的几乎所有行为。
最常见的新手 bug
String s = "hello"; s.toUpperCase(); // 返回值被丢掉了,s 还是 "hello" s = s.toUpperCase(); // ✓ 必须接住返回值
为什么要设计成不可变
- 可以安全共享:字符串字面量可以放进「常量池」被所有地方共用,不怕谁改坏了。
- 线程安全:多个线程读同一个字符串不需要加锁(13 章)。
- 哈希可以缓存:
hashCode算一次就存在字段里。一百万字符的字符串,第一次算hashCode和第二次差了一个数量级——第二次直接读缓存。这让String当HashMap的键特别高效。 - 可以放心当参数传:不用担心被调用的方法把你的字符串改了。
常量池与 == 的那些怪事
- 源码里的字符串字面量会被放进常量池,内容相同的字面量是同一个对象——这就是
"hi" == "hi"为 true 的原因。 new String("hi")强制在堆上另造一个,所以==是 false。没有理由这么写,除非你在做实验。- 验证过的分界线:
"h" + "i"编译期就拼好了、仍是常量(==为 true);变量参与的拼接是运行期行为,产生新对象(==为 false)。 - 结论还是那句:比字符串内容永远用
equals(02 章)。常量池是实现细节,不是可以依赖的行为。
// 不可变:所有"修改"都返回新对象
String 原文 = " Hello Java ";
String 结果 = 原文.strip() // "Hello Java"
.toLowerCase() // "hello java"
.replace(' ', '-'); // "hello-java"
// 原文 还是 " Hello Java "
// 常量池:这些结果验证过
String a = "hi", b = "hi";
a == b // true —— 同一个常量
a == new String("hi") // false —— new 强制造新的
a == new String("hi").intern() // true —— intern 把它换成池里那个
a == ("h" + "i") // true —— 编译期就拼好了
String p = "h";
a == (p + "i") // false —— 运行期拼接,新对象
// 所以:内容比较一律 equals
a.equals(b) // true,永远可靠
a.equalsIgnoreCase("HI") // true
// 空与空白的四种判断,别搞混
"".isEmpty() // true —— 长度为 0
" ".isEmpty() // false —— 有三个空格
" ".isBlank() // true —— 只有空白字符(Java 11 起)
// 变量可能为 null 时:s == null || s.isBlank()final 区别;后者更隐蔽:String 确实完全不可变,但很多人由此推出「把 String 放进 record 就万无一失」,忘了 record 里放 List 就完全不是这回事(07 章有构造完之后外部还能通过原引用改内容)。不可变是逐层的,不会自动传染给成员。strip() 比 trim() 好,用它。trim() 是上古方法,只删掉 ASCII 码 32 以下的字符;strip()(Java 11 起)按 Unicode 定义删空白,能正确处理全角空格、不间断空格等。同族还有 stripLeading()、stripTrailing()。老代码里的 trim() 是资料过时的一个可靠信号(00 章讲过怎么识别)。这一卡是速查:把日常 90% 会用到的字符串操作放在一起,配上返回值和边界行为。不用背,用到时回来扫一眼。
查找与判断
| 方法 | 作用 | 注意 |
|---|---|---|
length() | 长度 | 是 UTF-16 码元数,不是「字符数」(见下一卡) |
isEmpty() / isBlank() | 空 / 只有空白 | 都不能对 null 调用 |
contains(s) | 是否包含 | 参数是 CharSequence |
startsWith / endsWith | 前后缀 | 判断文件后缀常用 |
indexOf(s) | 位置 | 找不到返回 −1,不是抛异常 |
charAt(i) | 取一个码元 | 越界抛 StringIndexOutOfBoundsException |
compareTo(s) | 字典序比较 | 按码元值比,中文按 Unicode 码位不是拼音 |
变形与切分
| 方法 | 作用 | 注意 |
|---|---|---|
substring(a, b) | 截取 [a, b) | 右边开区间;越界抛异常 |
split(正则) | 切分成数组 | 参数是正则,切点号要写 "\\." |
strip() | 去两端空白 | 比 trim() 正确 |
replace(a, b) | 全部替换 | 字面替换;replaceAll 才是正则 |
repeat(n) | 重复 | Java 11 起。画分隔线很方便 |
lines() | 按行切成 Stream | Java 11 起,处理多行文本首选 |
toUpperCase() | 转大写 | 受区域影响(土耳其语的 i 是著名例子) |
String.join(分隔符, 列表) | 拼接 | 比循环拼接清楚得多 |
chars() | 码元流 | 统计字符频率时好用 |
格式化:三种写法
String.format("%s 今年 %d 岁", 名字, 年龄)—— 最常用。"%s 今年 %d 岁".formatted(名字, 年龄)—— Java 15 起,链式写法更顺。System.out.printf(...)—— 直接打印,注意换行要写%n(跨平台)而不是\n。- 常用占位符:
%s任意对象、%d整数、%.2f两位小数、%,d千分位、%-10s左对齐占 10 格、%05d补零。
// 查找与判断
String 文件名 = "报告.2026.pdf";
文件名.endsWith(".pdf") // true
文件名.indexOf(".") // 2(找不到会返回 -1)
文件名.lastIndexOf(".") // 7 —— 取后缀要用这个
文件名.substring(文件名.lastIndexOf(".") + 1) // "pdf"
// split 的参数是正则,这是最常见的误用
"a.b.c".split(".") // 空数组!. 在正则里是"任意字符"
"a.b.c".split("\\.") // [a, b, c] ✓
"a, b,c".split(",\\s*") // [a, b, c] —— 顺手处理空格
// 拼接:用 join,别用循环
String.join(", ", List.of("小明", "小红")) // "小明, 小红"
String.join(System.lineSeparator(), 行列表) // 按平台换行拼
// 格式化
String.format("%-10s %6.2f %,d", "苹果", 3.5, 1234567);
// "苹果 3.50 1,234,567"
"你好,%s!".formatted(名字); // Java 15 起
System.out.printf("%d 项%n", n); // %n 而不是 \n
// 画个分隔线
System.out.println("─".repeat(40));
// 按行处理多行文本
文本.lines()
.filter(l -> !l.isBlank())
.map(String::strip)
.forEach(System.out::println);split、replaceAll、matches 的参数都是正则表达式,replace 不是。这四个方法名字看着像一家,行为分两派,是标准库里最容易误用的一组。"1+1".replaceAll("+", "-") 直接抛 PatternSyntaxException(+ 是正则量词),而 "1+1".replace("+", "-") 正常工作。只做字面替换就用 replace;确实要用正则时,记得 . | + * ? ( [ { ^ $ 都要转义(或者用 Pattern.quote)。split 的第二个参数值得知道:split(",", -1) 会保留末尾的空串。默认行为会把它们丢掉——"a,b,,".split(",") 得到 3 个元素而不是 4 个,解析 CSV 时这会让列错位。处理有固定列数的分隔数据时,一律加 -1。「Java 里字符串拼接要用 StringBuilder,因为 + 很慢」——这句话一半对一半错,而且错的那一半让很多人写出了没必要的丑代码。这一卡用把它说清。
真相:编译器早就在优化了,但优化不了循环
- 一句里的多个
+,编译器会合成一次操作。用javap -c看字节码,"x" + a + b编译成一条invokedynamic makeConcatWithConstants指令——运行时由 JDK 生成最优的拼接代码(Java 9 起是这个机制,不再是老教程说的StringBuilder)。所以单句拼接放心写+。 - 但循环里每转一圈都是独立的一次拼接。
s += "x"每次都产生一个新字符串并复制全部旧内容——整体是 O(n²)。
循环拼接的代价随规模平方增长
| 循环次数 | s += "x" | StringBuilder |
|---|---|---|
| 1 万 | 约 9 ms | 约 0.4 ms |
| 2 万 | 约 30 ms | 约 0.4 ms |
| 4 万 | 约 62 ms | 约 0.8 ms |
关键不是具体毫秒数(你的机器会不同),而是形状:规模翻倍、耗时翻四倍——这是 O(n²) 的确定性特征,谁跑都一样。StringBuilder 那一列基本不随规模增长。到十万次时前者已经是秒级了。
所以规则很简单
- 不在循环里 → 随便用
+,可读性优先。"用户 " + 名字 + " 得分 " + 分数完全没问题。 - 在循环里累积 → 用
StringBuilder(或者更好:用 Stream 的Collectors.joining(),11 章)。 - 拼接固定的多行文本 → 用文本块(下一卡),比什么都清楚。
// ✓ 单句拼接:放心用 +,编译器会合成一次操作
String 消息 = "用户 " + 名字 + " 得分 " + 分数 + " 分";
// ✗ 循环里累积:O(n²)
String 结果 = "";
for (var 行 : 一万行) {
结果 += 行 + "\n"; // 每次都复制一遍已有内容
}
// ✓ 用 StringBuilder
var sb = new StringBuilder();
for (var 行 : 一万行) {
sb.append(行).append('\n'); // append 返回自己,可以链式
}
String 结果2 = sb.toString();
// ✓✓ 更好:交给 Stream(11 章)
String 结果3 = 一万行.stream().collect(Collectors.joining("\n"));
String 结果4 = String.join("\n", 一万行); // 更直接
// StringBuilder 的其它常用方法
sb.insert(0, "开头");
sb.reverse();
sb.setLength(0); // 清空复用
sb.deleteCharAt(sb.length() - 1); // 去掉最后一个分隔符
// 已知大概长度时预分配,能少几次扩容
var sb2 = new StringBuilder(1024);PreparedStatement 的 ? 占位符、JSON 用序列化库、HTML 用模板引擎并开启转义。「先拼字符串再想办法转义」这条路走不通,因为你永远想不全所有需要转义的情况。StringBuffer 是 StringBuilder 的线程安全版本,但你几乎永远不需要它。它每个方法都加了锁,而字符串拼接这种局部操作本来就不会跨线程共享。看到老代码用 StringBuffer,八成只是从更老的代码里抄来的(它比 StringBuilder 早出现)。日常一律用 StringBuilder。Java 15 起有了文本块:用三个双引号包住的多行字符串,里面的引号和换行都不用转义。写 SQL、JSON、HTML、多行说明文字时它是唯一正确的选择。
缩进怎么算:以最左边的非空白行为准
文本块会自动去掉公共缩进——具体规则是:找出所有非空行以及结束的三引号那一行里,缩进最少的那一个,把这个量从每一行前面删掉。
- 结束的三引号位置很重要:把它单独放一行且顶格,公共缩进就是 0(所有代码缩进都会被保留)。放在和内容同一层,缩进就被干净地剥掉。
- 内容里相对更深的缩进会被保留——这正是你想要的(比如 JSON 的嵌套层次)。
三个验证过的细节
- 每行末尾的空格会被自动删掉(防止不可见的尾随空格混进来)。真的需要保留就用
\s转义。 - 结束的三引号单独一行时,最后会有一个换行符;不想要就把三引号接在最后一行内容后面。
"""\nabc\n"""的值是"abc\n"。 - 行尾写一个反斜杠可以「续行」:这一行的换行符不生效,内容和下一行连起来。适合写很长的单行文本又想在源码里折行。
配合 formatted 用
文本块里可以放 %s 占位,然后调 .formatted(...) 填值——这是写模板最顺手的组合。
// 多行 JSON:不用转义引号,不用 \n
String 请求体 = """
{
"名字": "小明",
"技能": ["Java", "画画"],
"等级": 3
}
""";
// 公共缩进(4 空格)被自动剥掉,内部的 2 空格缩进保留
// 多行 SQL
String 查询 = """
select 名字, count(*) as 次数
from 记录
where 日期 >= ?
group by 名字
order by 次数 desc
""";
// 游戏里的多行提示
IO.println("""
┌──────────────┐
│ 猜数字游戏 │
└──────────────┘
输入 1-100 的数字,q 退出"""); // 三引号接在内容后 = 末尾没有换行
// 配合 formatted 当模板用
String 邮件 = """
你好,%s:
你的分数是 %d 分。
""".formatted(名字, 分数);
// 行尾反斜杠:续行(换行符不生效)
String 长句 = """
这是很长的一句话,\
在源码里折了行,实际上是一行。""";
// 要保留行尾空格:用 \s
String 对齐 = """
左边 \s
右边""";\n 还是换行、写 \\ 才得到一个反斜杠。写正则或 Windows 路径时这一点会咬人:"""C:\新建""" 里的 \n 会被当成换行符(而且 \新 这种非法转义会直接编译错)。路径统一用正斜杠(Java 在 Windows 上也接受),正则里的反斜杠照样要双写。\n,现在直接一个文本块贴进去,改起来所见即所得。写单元测试时尤其省事:期望的输出直接照抄进文本块,比拼字符串直观十倍。Java 的 String 内部按 UTF-16 存储,char 是一个 16 位的码元,不是「一个字符」。绝大多数常用汉字正好占一个码元,所以这件事平时不显眼——直到你的程序遇到 emoji 或生僻字。
"👍中"
| 表达式 | 结果 | 含义 |
|---|---|---|
"👍中".length() | 3 | UTF-16 码元数(👍 占 2 个) |
codePointCount(0, 3) | 2 | 真正的字符数(码位数) |
charAt(0) | 55357 | 👍 的前半个码元,单独没有意义 |
substring(0, 1) | 半个 emoji | 切在了码元中间,得到无效字符 |
getBytes(UTF_8).length | 7 | 👍 占 4 字节 + 中占 3 字节 |
三个数字全都不一样:长度 3、字符数 2、字节数 7。搞混它们会导致截断出乱码、进度条算错、数据库字段溢出。
怎么正确处理
- 按真正的字符遍历:用
s.codePoints()而不是s.chars()或charAt循环。 - 按字符数截断:先算码位再定位,或者更稳妥地用
BreakIterator(它还能处理「字母 + 组合重音」「emoji + 肤色修饰符」这类多码位的「用户感知字符」)。 - 算存储长度用字节数:
s.getBytes(StandardCharsets.UTF_8).length。
编码:JDK 18 之后好多了
- Java 18 起,文件读写和源文件编码的默认值统一是 UTF-8(不再跟随系统区域)——这消灭了「在我机器上是好的」类问题的一大来源。中文 Windows 上
file.encoding就是UTF-8。 - 但控制台输出编码仍跟随系统:同一台机器上
stdout.encoding是GBK。这就是 01 章那条乱码 pitfall 的来源。 - 凡是涉及编码的 API 都显式传
StandardCharsets.UTF_8,别依赖默认值——这是一个几乎零成本的好习惯。
// 三个"长度"是三回事
String s = "👍中";
s.length() // 3 —— UTF-16 码元数
s.codePointCount(0, s.length()) // 2 —— 字符数
s.getBytes(StandardCharsets.UTF_8).length // 7 —— 字节数
// ✗ 按 char 遍历:emoji 会被拆成两半
for (int i = 0; i < s.length(); i++) {
System.out.println(s.charAt(i)); // 前两个都是乱码
}
// ✓ 按码位遍历
s.codePoints().forEach(cp ->
System.out.println(new String(Character.toChars(cp))));
// ✓ 更彻底:按"用户感知的一个字符"切(能正确处理肤色修饰符等)
var 切分器 = BreakIterator.getCharacterInstance();
切分器.setText(s);
// 编码转换:一律显式指定
byte[] 字节 = s.getBytes(StandardCharsets.UTF_8);
String 回来 = new String(字节, StandardCharsets.UTF_8);
Files.writeString(路径, s, StandardCharsets.UTF_8);
// 中文排序:不要用默认的 compareTo(那是按码位)
var 按拼音 = Collator.getInstance(Locale.CHINA);
名单.sort(按拼音); // "赵钱孙李" 排出来才是拼音序
// 看清楚系统给你的默认值(排查乱码时第一步)
System.out.println(System.getProperty("file.encoding")); // UTF-8(Java 18+)
System.out.println(System.getProperty("stdout.encoding")); // 跟随系统compareTo 排序」得到的是 Unicode 码位顺序,不是拼音顺序。这个错误特别容易蒙混过关,因为码位顺序看起来也像有序的。要按拼音排必须用 Collator.getInstance(Locale.CHINA)。同理,toUpperCase()/toLowerCase() 不传 Locale 时会使用系统默认区域——在土耳其语环境下 "i".toUpperCase() 得到的是带点的 İ 而不是 I,这个著名的坑让不少程序在土耳其用户那里挂掉。做大小写归一化时传 Locale.ROOT。类与对象
类是把「数据 + 操作数据的方法」捆在一起的单位。这一章讲清楚一个对象从 new 到能用中间发生了什么、static 到底是什么意思,以及怎么写出不会被别人改坏的对象。
一个类就是一张「模板」,new 按模板造出一个个对象。字段是每个对象各自的数据,方法是能对这些数据做的事,构造器负责让新对象一出生就处于可用状态。
new 玩家("小明") 这一句里发生了什么
- ① 在堆上分配一块内存,所有字段先设成默认值(数字 0、布尔 false、引用 null);
- ② 按顺序执行字段初始化表达式和实例初始化块;
- ③ 执行构造器体;
- ④ 把这块内存的引用交给你。
(有父类时步骤更多,下一卡专门讲顺序。)
构造器的三条规则
- 名字必须和类名相同,没有返回类型(写了
void它就变成了一个普通方法,这是个很难发现的错误)。 - 不写构造器时,编译器送你一个无参的空构造器;只要你写了任何一个构造器,这份赠品就没了。
- 可以重载,并且可以用
this(...)调用同类的另一个构造器(必须是第一句)——把公共逻辑集中在一个「主构造器」里。
四个访问修饰符
| 修饰符 | 谁能访问 | 日常怎么用 |
|---|---|---|
private | 只有本类 | 字段默认写这个 |
| (不写) | 同一个包 | 包内协作的工具类 |
protected | 同包 + 子类 | 给继承留的口子(06 章) |
public | 所有人 | 你想让别人用的方法 |
默认策略:字段一律 private,方法按需要开放。理由不是「规范要求」,而是你以后想改内部实现时,只有 private 的东西可以随便改。
this 的两个用途
- 区分同名的字段和参数:
this.名字 = 名字;—— 这是构造器里最常见的写法。 - 调用本类的另一个构造器:
this(名字, 0)。
// 一个完整的类
public class 玩家 {
// 字段:每个对象各有一份,一律 private
private final String 名字; // final = 造出来就不能再变
private int 血量;
private final List<String> 背包 = new ArrayList<>();
// 主构造器:所有初始化逻辑集中在这里
public 玩家(String 名字, int 血量) {
if (名字 == null || 名字.isBlank())
throw new IllegalArgumentException("名字不能为空"); // 12 章
this.名字 = 名字; // this 区分字段与参数
this.血量 = 血量;
}
// 便捷构造器:委托给主构造器(必须是第一句)
public 玩家(String 名字) {
this(名字, 100);
}
// 方法:对内部数据的操作
public void 受伤(int 点数) {
血量 = Math.max(0, 血量 - 点数); // 内部保证不变量:血量不为负
}
public boolean 活着() { return 血量 > 0; }
// 只读访问:返回副本,别把内部列表交出去(见本章第 4 卡)
public List<String> 背包() { return List.copyOf(背包); }
// toString:调试时会救你的命
@Override public String toString() {
return "玩家[" + 名字 + " 血量=" + 血量 + "]";
}
}
// 用它
var p = new 玩家("小明");
p.受伤(30);
System.out.println(p); // 玩家[小明 血量=70]getX()/setX(),理由是「封装」——但一个字段配一对直通存取器,等于没有封装,只是把 p.血量 = -50 写成了 p.set血量(-50)。真正的封装是暴露有意义的操作(受伤(30)、治疗(20)),让类自己保证「血量不为负」这类不变量。如果一个类真的只是数据袋,用 record 一行搞定;如果它有规则要守,就暴露规则而不是字段。toString()。不写的话,打印出来是 玩家@5fd9b663 这种东西,调试时毫无帮助。更省事的办法是用 record(07 章)——它自动生成 toString,输出 玩家[名字=小明, 血量=70]。事实上,如果你的类只是一包数据,那它就该是个 record,而不是这一卡里的写法。static 的意思只有一个:这个东西属于类本身,不属于任何一个对象。全世界只有一份,不用 new 就能用。
三种正当用法
- 常量:
static final double 圆周率 = 3.14159;—— 每个对象都存一份完全没必要。 - 工具方法:不依赖任何对象状态的纯计算,
Math.max、Integer.parseInt、List.of全是这一类。判据:方法体里没用到任何实例字段,它就该是 static。 - 工厂方法:
static 玩家 从存档读(String 路径)—— 比构造器灵活(可以有名字、可以返回子类、可以返回缓存的对象)。
静态方法里不能用 this,也不能直接用实例字段
因为它跑的时候可能一个对象都还没有。这就是 main 必须是 static 的原因——JVM 要在没有任何对象的情况下调用它。(不过 JDK 25 的紧凑源文件里 void main() 可以不写 static,编译器会替你 new 一个实例出来,01 章。)
什么时候不该用 static
- 可变的静态字段几乎总是坏主意:它是全局变量,多线程下要加锁(13 章)、测试时相互污染、看不出谁改了它。
static final的不可变常量没问题;static int 计数器就是隐患。 - 「把所有方法都写成 static」的工具类堆积:如果一堆 static 方法总是操作同一批数据,那批数据加上这些方法本来就该是个类。
- 静态方法不能被覆写(只能被「隐藏」,06 章有)——它没有多态,所以不适合放需要被替换的行为。
// ✓ 常量
public class 配置 {
public static final int 最大玩家数 = 8;
public static final Path 存档目录 = Path.of("saves");
}
配置.最大玩家数 // 不用 new,直接用类名访问
// ✓ 工具方法:不依赖任何对象状态
public class 距离 {
public static double 欧氏(double x1, double y1, double x2, double y2) {
return Math.hypot(x2 - x1, y2 - y1);
}
private 距离() {} // 私有构造器:明确表示"这个类不该被 new"
}
// ✓ 工厂方法:比构造器灵活
public class 玩家 {
public static 玩家 新手(String 名字) { return new 玩家(名字, 100); }
public static 玩家 从存档(String 行) { /* 解析 */ }
// 两个都返回玩家,但名字说清了区别(构造器做不到这一点)
}
// ✗ 可变的静态字段 = 全局变量
public class 计分 {
public static int 总分 = 0; // 谁都能改、多线程会错、测试之间会串
}
// 静态初始化块:类第一次被用到时跑一次(15 章讲触发时机)
public class 词典 {
private static final Map<String, String> 表;
static {
表 = new HashMap<>();
表.put("hello", "你好"); // 需要多行逻辑才能初始化时用它
}
}null 或 0——不报错,只是值不对。更隐蔽的是:静态代码在类被首次主动使用时才执行(15 章),所以「什么时候跑」取决于运行路径,同一份代码在不同入口下表现可能不同。规避办法很简单:静态字段只放不可变常量,需要复杂初始化的东西改成方法里按需创建。一个对象诞生时,代码执行的顺序是固定的,但它和源码从上到下的顺序不一样。这一卡用一段输出把顺序钉死。
父类 Base + 子类 Sub,new Sub() 的输出
[1] Base 静态块 // 类第一次被用到时,父类静态先跑 [2] Sub 静态块 // 然后子类静态 [3] Base 实例块 // 从这里开始每次 new 都跑 [4] Base 构造器 [5'] Sub.init 被父类构造器调用,此时 name=null // ← 危险区! [6] Sub 实例块,name=小明 [7] Sub 构造器,name=小明
再 new 一个:只有 [3] 到 [7] 会重跑,静态块([1][2])一辈子只跑一次。
规则总结
- 静态部分:类第一次被主动使用时执行一次,父类先于子类,同一个类内按源码顺序。
- 实例部分:每次
new都执行,顺序是「父类的字段初始化 + 实例块 → 父类构造器 → 子类的字段初始化 + 实例块 → 子类构造器」。 - 关键点:父类构造器跑完之前,子类的字段还全是默认值。
由此而来的经典陷阱:构造器里调用可被覆写的方法
- 上面输出里的 [5'] 就是现场:
Base的构造器调用了init(),而Sub覆写了它。此时子类的name字段还没赋值,是null——虽然源码里明明写着private String name = "小明";。 - 这类 bug 极难查:它表现为「明明赋了值却是 null」,而且只在被继承时出现。
- 规避:构造器里只调用
private、static或final方法——这三类不可能被覆写。要做的初始化如果确实需要子类参与,改成显式的两步(构造 + 一个初始化()方法)。
// 上面那段输出对应的代码
class Base {
static { System.out.println("[1] Base 静态块"); }
{ System.out.println("[3] Base 实例块"); }
Base() {
System.out.println("[4] Base 构造器");
init(); // ← 危险:调用了可被覆写的方法
}
void init() { System.out.println("[5] Base.init"); }
}
class Sub extends Base {
static { System.out.println("[2] Sub 静态块"); }
private String name = "小明";
{ System.out.println("[6] Sub 实例块,name=" + name); }
Sub() {
super(); // 不写也会自动调用(且必须是第一句)
System.out.println("[7] Sub 构造器,name=" + name);
}
@Override void init() {
System.out.println("[5'] name=" + name); // null!
}
}
// ✓ 安全的写法:构造器里只调不可覆写的方法
class 安全的 {
private final String 名字;
安全的(String 名字) {
this.名字 = 校验(名字); // private,子类改不了
}
private static String 校验(String s) {
return Objects.requireNonNull(s, "名字不能为空");
}
}this(...) 或 super(...) 必须是构造器的第一句(Java 25 起放宽了:允许在 super(...) 之前写不访问 this 的语句,比如参数校验和计算——不加 --enable-preview 就能编译)。所以你不能「先算一下参数再决定调哪个父构造器」——需要复杂逻辑就抽成一个 private static 方法,在 super(算一下(x)) 里调用它。这是初学者最常撞的编译错误之一:call to super must be first statement in constructor。new Sub(); new Sub(); 跑一次,你会亲眼看到静态块只出现一次、看到 name 在 [5'] 处是 null。亲眼看过一次,以后遇到「字段莫名其妙是 null」时你会第一时间想到它。不可变对象是造出来之后状态就再也不会变的对象(String、LocalDate、record 都是)。它是 Java 里性价比最高的一个设计习惯:写的时候多花一分钟,省掉后面一整类 bug。
为什么值得
- 可以随便传,不怕被改:不需要在每个方法边界上想「他会不会改我的对象」。
- 天生线程安全:没有写操作就没有竞争,13 章一整章的问题绕过去了一大半。
- 可以当
Map的键、放进Set:可变对象当键是个著名的雷(改了字段之后就再也找不到它了)。 - 更好推理:一个变量指向的对象内容永远不变,读代码时不用追踪「谁在什么时候改了它」。
怎么写一个真正不可变的类
- ① 所有字段
private final;② 不提供任何 setter;③ 类本身final(防止子类加可变状态);④ 关键:可变成员必须拷贝进来、拷贝出去。 - 第 ④ 条是最容易漏的。一个
record Box(List<String> items),构造时传进去一个ArrayList,构造完之后外部通过原来那个引用add一个元素——box.items()里真的多了一个。record 只保证「字段引用不变」,不保证「引用指向的对象不变」。 - 修法:在紧凑构造器里
items = List.copyOf(items);。同一个实验,加了这一行之后外部的修改就影响不到了。
需要「改一改」的时候怎么办
返回一个新对象,而不是修改自己——String 和 LocalDate 都是这么做的:日期.plusDays(1) 返回新日期。自己的类可以提供 withXxx 风格的方法。
// 用 record 写不可变类(07 章展开),加一个紧凑构造器做防御性拷贝
record 队伍(String 名称, List<String> 成员) {
队伍 { // 紧凑构造器:校验 + 规范化
Objects.requireNonNull(名称);
成员 = List.copyOf(成员); // ← 关键:拷进来,切断外部引用
}
// 需要"改"就返回新对象
队伍 加人(String 人) {
var 新成员 = new ArrayList<>(成员);
新成员.add(人);
return new 队伍(名称, 新成员);
}
}
// 没有防御性拷贝会发生什么
var 原始 = new ArrayList<>(List.of("小明"));
var 队 = new 队伍("一队", 原始);
原始.add("偷偷加的");
// 有 List.copyOf:队.成员() 是 [小明] ✓
// 没有的话: 队.成员() 是 [小明, 偷偷加的] ✗
// 传统 class 的写法(record 之前都这么写)
public final class 坐标 {
private final int x, y;
public 坐标(int x, int y) { this.x = x; this.y = y; }
public int x() { return x; }
public 坐标 右移(int d) { return new 坐标(x + d, y); } // 返回新的
}
// 输出时也要小心:别把内部集合直接交出去
class 背包 {
private final List<String> 物品 = new ArrayList<>();
public List<String> 物品() { return 物品; } // ✗ 谁都能改
public List<String> 物品副本() { return List.copyOf(物品); } // ✓
}List.of()、Map.of() 返回的不可变集合,调用 add 会抛 UnsupportedOperationException,而且异常消息是 null——什么提示都没有。这个空消息让人一头雾水,尤其是在深层调用链里冒出来时。看到「UnsupportedOperationException 而且没有消息」,第一反应就该是「我在往一个不可变集合里写」。常见触发点:List.of(...)、Arrays.asList(...)(03 章)、Collectors.toUnmodifiableList()、以及 Java 16 起 stream().toList() 的返回值(老写法 collect(Collectors.toList()) 返回的才是可变的,这个差别验证过)。Date(可变,麻烦)被 LocalDate(不可变)取代,List.of() 返回的也是不可变列表。Java 允许类里面套类,一共四种形态。今天你主要会用到其中两种,另外两种在 lambda 出现后基本退场了——但读老代码时还会遇到。
四种嵌套类
| 形态 | 写法 | 今天用不用 |
|---|---|---|
| 静态嵌套类 | static class 内部 {} | 常用。只是「放在里面的普通类」,表达从属关系 |
| 内部类(非静态) | class 内部 {} | 少用。它持有外部对象的引用,能访问外部字段 |
| 局部类 | 方法体里 class X {} | 罕见 |
| 匿名类 | new 接口() { ... } | lambda 能做的都被取代了,剩下的场合仍需要它 |
静态 vs 非静态:一个字之差,差别很大
- 非静态内部类每个实例都偷偷持有外部实例的引用。这意味着:① 必须先有外部对象才能创建它(
外部对象.new 内部());② 它会让外部对象无法被回收——如果内部类实例被长期持有(比如放进一个静态集合或注册成监听器),整个外部对象跟着泄漏。 - 默认加
static。除非你真的需要访问外部实例的字段,否则嵌套类一律写static。
匿名类还剩什么用
- lambda 只能实现「只有一个抽象方法」的接口(函数式接口,11 章)。需要实现多个方法时,还得用匿名类。
- 需要
this指向自己时:——匿名类里的this指匿名类实例自己,而 lambda 里的this指外围类的实例。这个区别偶尔关键。 - 需要有状态(自己的字段)时。
// ✓ 静态嵌套类:表达"这个类属于那个类"
public class 迷宫 {
public static record 格子(int 行, int 列) {} // 迷宫.格子
private final 格子[][] 地图;
}
// 非静态内部类:能访问外部字段,但也拖着外部对象
public class 链表 {
private int 长度;
class 迭代器 { // 没有 static
boolean 还有() { return 位置 < 长度; } // 直接用外部的 长度
}
}
var 表 = new 链表();
var it = 表.new 迭代器(); // 创建语法都不一样
// 匿名类 → lambda 的演进(同一件事的三代写法)
// ① 匿名类(Java 7 及以前)
按钮.addActionListener(new ActionListener() {
@Override public void actionPerformed(ActionEvent e) {
System.out.println("点了");
}
});
// ② lambda(Java 8 起)
按钮.addActionListener(e -> System.out.println("点了"));
// ③ 方法引用(更短的情况下)
按钮.addActionListener(this::处理点击);
// 匿名类仍然需要的场合:要实现多个方法
文件.addWatcher(new Watcher() {
@Override public void onCreate(Path p) { }
@Override public void onDelete(Path p) { } // 两个方法,lambda 做不到
});static 嵌套类 + 显式弱引用,或者在对象销毁时记得反注册。格子、节点、结果),它适合嵌套;如果名字本身就足够清楚(玩家、存档),就独立成文件。接口、继承与多态
「同一个调用,落到不同实现上」是面向对象最核心的能力。这一章讲清接口和继承各自解决什么问题、为什么今天的建议是「优先用接口和组合」,以及 Object 那几个方法为什么必须成对覆写。
继承让子类拿到父类的全部字段和方法,并可以覆写(override)其中的实例方法。「多态」指的就是:调用哪个版本,看的是对象的运行期类型,不是变量的声明类型。
三种成员的行为完全不同
| 成员 | 看哪个类型 | |
|---|---|---|
| 实例方法 | 运行期类型(真多态) | P p = new Q(); p.name() → "Q" |
| 静态方法 | 编译期类型(叫「隐藏」不是覆写) | P.who() → "P.static" |
| 字段 | 编译期类型(也是隐藏) | 子类同名字段不覆盖父类的,两份都在 |
结论:只有实例方法值得用来做多态。静态方法和字段的「覆盖」是假的,看着像多态、行为不是——所以不要给子类的字段起和父类一样的名字,那只会制造困惑。
覆写的规则
- 一定加
@Override注解。它不改变行为,但会让编译器帮你检查——名字拼错、参数不匹配时直接报错,而不是静默地变成一个新方法。这是成本最低、收益最高的一个习惯。 - 能不能覆写:
final方法不能、private方法不能(子类里同名的是另一个方法)、static方法不是覆写。 - 访问权限只能放宽不能收紧;返回类型可以是更具体的子类型(协变返回)。
super.方法()调用父类版本——覆写时想「在父类行为基础上加点东西」就用它。
什么时候该用继承
- 判据是「is-a」而且行为要被替换:
正方形 is-a 图形、缓存文件流 is-a 输入流。 - 不是为了复用代码。「这个类有几个方法我也想要」不是继承的理由——那应该用组合(本章第 4 卡)。
- 继承是最强的耦合:父类改一行可能悄悄改变所有子类的行为,而子类作者根本看不见。所以现代建议是「不为继承设计的类就写成
final」。
// 多态:同一个调用落到不同实现上
abstract class 图形 {
abstract double 面积(); // 没有实现,子类必须给
String 描述() { return getClass().getSimpleName() + " 面积 " + 面积(); }
}
class 圆 extends 图形 {
private final double r;
圆(double r) { this.r = r; }
@Override double 面积() { return Math.PI * r * r; }
}
class 矩形 extends 图形 {
private final double w, h;
矩形(double w, double h) { this.w = w; this.h = h; }
@Override double 面积() { return w * h; }
}
List<图形> 一堆 = List.of(new 圆(1), new 矩形(2, 3));
for (var g : 一堆) System.out.println(g.描述()); // 各调各的 面积()
// super:在父类行为上加东西
class 带日志的圆 extends 圆 {
@Override double 面积() {
double a = super.面积();
System.out.println("算了一次面积:" + a);
return a;
}
}
// @Override 的价值:拼错时编译器会拦住你
class 错的 extends 图形 {
// @Override double 面机() { ... } ← 加了注解会报错
double 面机() { return 0; } // 不加注解:静默变成一个新方法
@Override double 面积() { return 0; }
}
// 不打算被继承的类,写 final
public final class 工具 { }equals:写成 public boolean equals(玩家 其它)(参数是具体类型)而不是 equals(Object o)。它能编译、能运行、你自己直接调用时行为也对,但集合内部调的是 equals(Object),走的还是父类版本——于是 HashSet 里出现两个「相同」的对象。@Override 能当场抓住这个错误,这就是为什么它值得每次都写。new,就写 abstract。new 图形() 没有意义(没有哪个具体形状叫「图形」),那就把它标成抽象的,编译器会拦住任何试图实例化它的代码,同时强制子类实现 面积()。这比在文档里写「请不要直接使用这个类」可靠得多。接口描述「能做什么」,不关心「是什么」。一个类可以实现任意多个接口(但只能继承一个类),这是接口比继承灵活的根本原因。
接口里能放什么(今天的接口比你想的能装)
- 抽象方法:默认就是
public abstract,不用写。 - 默认方法(
default,Java 8 起):带实现的方法,实现类可以不管它。它的存在是为了「给已经发布的接口加方法而不破坏所有实现类」——List.forEach、Comparator.reversed都是这么加进来的。 - 静态方法:接口自己的工具方法,如
Comparator.comparing(...)、List.of(...)。 - 私有方法(Java 9 起):给默认方法共享实现用。
- 常量:字段自动是
public static final。不过接口里放常量是个老做法,今天用枚举或普通类的静态常量更好。
两个接口有同名默认方法怎么办
编译器会强制你自己决定。一个类同时实现两个都有 hi() 默认方法的接口,必须覆写它,并可以用 接口名.super.hi() 显式选一个(或者两个都调)。这就是 Java 用「有限制的多继承」避开菱形问题的方式——它不猜,它让你写清楚。
接口的三种典型用法
- 能力标记:
Comparable(能排序)、AutoCloseable(能自动关闭)、Iterable(能被 for-each 遍历)。实现了它就获得一整套语言/库的支持。 - 抽象依赖:方法参数写
List而不是ArrayList,调用方就能传任何实现。「接口用于声明,实现类只在 new 的那一行出现」是个好习惯。 - 回调:只有一个抽象方法的接口叫「函数式接口」,可以用 lambda 实现(11 章)。
// 定义能力
interface 可存档 {
String 存成文本(); // 抽象方法:实现类必须给
default void 存到文件(Path p) throws IOException { // 默认方法
Files.writeString(p, 存成文本());
}
static 可存档 空的() { return () -> ""; } // 静态工厂
}
record 存档(String 玩家, int 关卡) implements 可存档 {
@Override public String 存成文本() { return 玩家 + "," + 关卡; }
// 存到文件 直接白拿
}
// 一个类可以实现多个接口
class 关卡 implements 可存档, Comparable<关卡>, AutoCloseable {
@Override public int compareTo(关卡 其它) { return Integer.compare(编号, 其它.编号); }
@Override public void close() { 释放资源(); } // 12 章的 try-with-resources
@Override public String 存成文本() { return ""; }
}
// 两个接口有同名默认方法:必须自己决定
interface A { default String hi() { return "A"; } }
interface B { default String hi() { return "B"; } }
class AB implements A, B {
@Override public String hi() { return A.super.hi() + "+" + B.super.hi(); }
}
// new AB().hi() → "A+B"
// 好习惯:声明用接口,实现只出现在 new 的那一行
List<String> 名单 = new ArrayList<>(); // 左边接口,右边实现
Map<String, Integer> 分数 = new HashMap<>();public static final,所以「接口常量」这个老做法看起来很方便——但它是反模式。让一个类 implements 常量接口 来「白拿常量」,会把这些常量泄漏进类的公开 API,子类还会继续继承它们,改起来牵一发动全身。常量该放在枚举里(07 章)或一个不可实例化的类里,用 配置.最大玩家数 这种带前缀的方式访问——多打几个字,换来的是「这个常量从哪来的」一目了然。Comparable 和 Comparator 的分工值得一次记牢:Comparable 是「这个类天生的顺序」(写在类里面,只能有一个);Comparator 是「这次排序我想要的顺序」(写在外面,可以有无数个)。list.sort(Comparator.comparing(玩家::分数).reversed()) 这种链式写法能拼出任意排序规则,而且完全不用改被排序的类。这两个东西的能力在 Java 8 之后大幅重叠(接口也能有实现了),所以选择标准也变了。今天的默认答案是:先考虑接口,只在需要共享状态时才用抽象类。
能力对比
| 接口 | 抽象类 | |
|---|---|---|
| 能有抽象方法 | 能 | 能 |
| 能有带实现的方法 | 能(default) | 能 |
| 能有静态方法 | 能 | 能 |
| 能有实例字段(状态) | 不能 | 能 |
| 能有构造器 | 不能 | 能 |
| 一个类能有几个 | 任意多个 | 只有一个 |
| 能限制访问权限 | 方法都是 public | 能用 protected |
三条实用判据
- 需要保存状态(字段)→ 抽象类。这是接口做不到的唯一一件事,也是抽象类今天的主要理由。
- 只是定义契约 → 接口。而且接口不占用那个宝贵的「唯一继承名额」。
- 拿不准就用接口:以后想加实现可以用默认方法,想加状态可以再引入一个抽象基类去实现这个接口——反过来(从抽象类退回接口)就是破坏性改动了。
标准库自己的做法
List(接口)+ AbstractList(抽象类,提供大部分默认实现)+ ArrayList(具体类)——这个「接口 + 骨架实现」的三层结构是标准库反复用的套路。它兼得两者:调用方只依赖接口,实现者可以选择继承骨架省事,也可以完全自己实现。
还有第三个选项:密封接口
如果你的场景是「一共就那么几种,我全都知道」——比如四种棋子、三种消息、若干种表达式节点——那既不是接口也不是抽象类,而是 sealed interface + record(07、08 章)。这是现代 Java 处理「封闭的一组类型」的标准答案,而且能让 switch 做穷尽检查。
// 场景一:只定义契约 → 接口
interface 存储 {
void 保存(String 键, String 值);
Optional<String> 读取(String 键);
}
class 内存存储 implements 存储 { }
class 文件存储 implements 存储 { }
// 调用方只认接口,随时能换实现(也方便测试时塞个假的)
// 场景二:子类要共享状态 → 抽象类
abstract class 怪物 {
protected int 血量; // 字段:接口给不了
protected final String 名字;
protected 怪物(String 名字, int 血量) { // 构造器:接口也给不了
this.名字 = 名字; this.血量 = 血量;
}
public void 受伤(int d) { 血量 -= d; } // 所有怪物共享这段逻辑
public abstract int 攻击力(); // 各自不同
}
class 史莱姆 extends 怪物 {
史莱姆() { super("史莱姆", 20); }
@Override public int 攻击力() { return 3; }
}
// 场景三:一共就这几种 → 密封接口(07、08 章)
sealed interface 棋子 permits 兵, 車, 馬 {}
record 兵(boolean 过河) implements 棋子 {}
// 标准库的三层套路:接口 + 骨架 + 实现
// interface List<E> → abstract class AbstractList<E> → class ArrayList<E>Runnable、Comparator、AutoCloseable…),抽象类只作为「可选的省事骨架」出现。new 那一行。好处不是抽象本身,而是你以后换实现时只改一处——把 new ArrayList<>() 改成 new LinkedList<>(),其余代码一个字不动。但也别过度:给一个只有一种实现、也不打算有第二种的类硬造一个接口,那是纯粹的样板。这是面向对象里最实用的一条建议。「组合」是把另一个对象作为字段持有并调用它;「继承」是把自己变成它的一种。大多数时候你想要的是前者,却因为继承写起来更短而选了后者。
继承的三个真实代价
- 耦合最强:子类依赖父类的实现细节而不只是接口。父类某个方法内部改成调用另一个方法,子类的覆写就可能被跳过或被重复调用——而子类作者完全看不见这个变化。
- 继承会把父类的全部 API 暴露出去:
class 计数集合 extends ArrayList之后,使用者可以调clear()绕过你的计数逻辑。你继承的不只是能力,还有责任。 - 名额只有一个(上一卡)。
什么时候继承是对的
- 父类就是为被继承而设计的(有文档说明哪些方法可以覆写、覆写时的契约是什么),并且关系确实是「is-a」。
- 典型正例:实现一个抽象类要求的方法、扩展框架提供的基类。
- 反例信号:你继承只是因为「那个类有几个方法我也需要」。
怎么把继承改写成组合
把「是一个」改成「有一个」,然后把需要的方法转发过去(这叫委托)。多写几行转发代码,换来的是:只暴露你想暴露的方法、父类怎么改都不影响你、还能随时换成另一个实现。
// ✗ 继承:拿到了不想要的全部 API
class 计数列表<E> extends ArrayList<E> {
private int 添加次数;
@Override public boolean add(E e) { 添加次数++; return super.add(e); }
@Override public boolean addAll(Collection<? extends E> c) {
添加次数 += c.size(); return super.addAll(c); // 如果 addAll 内部调了 add,就数了两遍!
}
}
// 这个 bug 取决于 ArrayList 的内部实现,而那是随时可能变的
// ✓ 组合 + 委托:只暴露你想要的
class 计数集合<E> {
private final List<E> 内部 = new ArrayList<>(); // 有一个,而不是是一个
private int 添加次数;
public void 添加(E e) { 内部.add(e); 添加次数++; }
public int 大小() { return 内部.size(); } // 需要什么转发什么
public int 添加次数() { return 添加次数; }
public List<E> 快照() { return List.copyOf(内部); }
// clear()、remove() 这些没转发 = 使用者根本调不到,计数不会被绕过
}
// 组合还让"换实现"变成一行的事
class 游戏 {
private final 存储 存档; // 依赖接口
游戏(存储 存档) { this.存档 = 存档; } // 谁用谁决定用哪个实现
}
new 游戏(new 文件存储()); // 正式跑
new 游戏(new 内存存储()); // 测试时(这就是"依赖注入"的全部内核)
// 装饰:一层套一层地加能力,标准库到处在用
var 流 = new BufferedReader(new InputStreamReader(System.in));ArrayList 意味着你的类多了三十多个你没写过、也没测过、还可能绕过你逻辑的方法。如果转发代码写起来很烦,那通常说明你需要的接口面太宽了——这本身就是个值得重新想想设计的信号。new。不需要任何库就能用,而且立刻带来两个好处:测试时可以传个假的进去,换实现时不用改这个类。框架只是在你有几百个对象要装配时才开始有价值;写自己的项目时手动传就够了。所有类都隐式继承 Object,它带来几个方法。其中 equals 和 hashCode 的关系是 Java 里最重要的一条「不知道就会错」的契约。
四个你会用到的
toString():默认返回类名@哈希,没有信息量。调试时会救命,几乎每个类都该覆写(record 自动有)。equals(Object):默认是==(比身份)。要按内容比就得覆写。hashCode():默认基于对象地址。覆写equals就必须一起覆写它。getClass():拿到运行期的真实类型,调试和反射时用。
契约:相等的对象必须有相同的哈希
HashMap/HashSet的查找是两步:先用hashCode定位桶,再用equals在桶里比。哈希不一样就根本不会走到第二步。- 一个只覆写了
equals的类,两个「相等」的对象放进HashSet,size()是 2——它们被分到了不同的桶,谁也没见到谁。而同样内容的 record 是 1。 - 反过来不要求:哈希相同的对象不必相等(那叫哈希冲突,正常现象)。
怎么正确地写
- 最好的办法:用 record(07 章)。它按所有分量自动生成一致的
equals/hashCode/toString,永远不会写错。 - 必须手写时:
Objects.equals(a, b)逐字段比较、Objects.hash(字段1, 字段2, ...)生成哈希——两个方法必须基于同一组字段。IDE 能一键生成,别手写。 - 可变字段不要参与
hashCode:对象进了HashSet之后再改这个字段,它就「丢了」——用contains找不到,遍历却能看到。
// ✓ 最省事:record 自动全都对
record 坐标(int x, int y) {}
new 坐标(1,2).equals(new 坐标(1,2)) // true
new 坐标(1,2).toString() // 坐标[x=1, y=2]
// 手写版本(必须成对,且基于同一组字段)
public final class 坐标2 {
private final int x, y;
@Override public boolean equals(Object o) { // 参数必须是 Object!
if (this == o) return true; // 快速路径
return o instanceof 坐标2 其它 // 08 章的模式匹配,顺带处理了 null
&& x == 其它.x && y == 其它.y;
}
@Override public int hashCode() {
return Objects.hash(x, y); // 和 equals 用同一组字段
}
@Override public String toString() {
return "坐标2[" + x + ", " + y + "]";
}
}
// ✗ 只覆写 equals 的后果
class 坏例子 {
final int v;
@Override public boolean equals(Object o) { return o instanceof 坏例子 b && b.v == v; }
// 没有 hashCode
}
var 集 = new HashSet<坏例子>();
集.add(new 坏例子(1));
集.add(new 坏例子(1));
集.size() // 2 —— equals 说相等,但它们进了不同的桶
// 可变字段参与 hashCode 的后果
var 集2 = new HashSet<可变的>();
var 对象 = new 可变的("旧名字");
集2.add(对象);
对象.改名("新名字");
集2.contains(对象) // false!它还在集合里,但按新哈希找不到了equals 会遇到一个无解的问题:对称性。如果 子类.equals(父类实例) 要求「类型完全相同」,那 父类.equals(子类实例) 可能返回 true,两边结果不一致,集合行为就会诡异。这个问题在理论上没有既保持对称又允许子类扩展字段的解法(instanceof 检查破坏对称性,getClass() 检查破坏里氏替换)。实际结论就一条:需要值语义的类写成 final(或 record),别让它被继承。这也是 record 直接被设计成隐式 final 的原因。equals/hashCode 只要两秒(IntelliJ 里 Alt+Insert),但更好的选择是先问一句「这个类能不能是 record」。如果它只是一包不变的数据——绝大多数值类型都是——那 record 不仅省掉这两个方法,还顺带给了 toString、解构、以及模式匹配的支持(07、08 章)。手写 equals 应该是例外,不是常态。record、enum 与 sealed:现代数据建模
这一章是现代 Java 和你印象里那个 Java 差别最大的地方。三个关键字合起来,让「一包数据」「一组固定的值」「一共就这几种情况」这三件最常见的事各自缩短到一行。
record(Java 16 起正式)是「不可变的数据载体」的语言级支持。一行声明,编译器替你生成构造器、访问器、equals、hashCode、toString——而且保证它们互相一致(06 章那个契约不用你操心了)。
record 坐标(int x, int y) {} 自动得到什么
- 规范构造器
坐标(int x, int y); - 访问器
x()、y()(注意没有get前缀); equals/hashCode:按所有分量逐个比较,new 坐标(1,2).equals(new 坐标(1,2))为true,放进HashSet只算一个;toString:输出坐标[x=1, y=2];- 隐式
final,字段也都是final——不能继承、不能改。 - 解构支持:可以在
switch和instanceof里拆开(08 章)。
加逻辑:紧凑构造器
需要校验或规范化时,写一个没有参数列表的构造器(叫「紧凑构造器」),在里面直接给参数赋值,编译器会在最后自动把它们赋给字段。这是放校验和防御性拷贝的标准位置。
record 能做和不能做
| 能 | 不能 |
|---|---|
| 实现接口 | 继承类(隐式 final 且已继承 Record) |
| 加自己的方法 | 加实例字段(只能有分量) |
| 加静态字段和方法 | 改分量的值 |
| 加多个构造器 | 省掉某个分量的访问器 |
| 嵌套在类里、写成局部 record | 让它可变 |
什么时候用 record,什么时候用 class
- 用 record:坐标、配置、一条记录、API 的请求/响应、方法要返回多个值、
Map的复合键、事件——凡是「一包数据 + 相等性按内容算」的都是。 - 用 class:有可变状态(游戏角色的血量)、有需要保护的不变量和行为、需要继承体系。
- 一个信号:你正在写一个只有字段和 getter 的类——那就是 record。
// 一行顶过去二十行
record 坐标(int x, int y) {}
// 加校验:紧凑构造器(没有参数列表)
record 分数(String 科目, int 分) {
分数 { // ← 注意没有 (String 科目, int 分)
Objects.requireNonNull(科目);
if (分 < 0 || 分 > 100)
throw new IllegalArgumentException("分数要在 0-100 之间:" + 分);
科目 = 科目.strip(); // 规范化:赋给参数即可,编译器会写进字段
}
}
// 加方法、加静态工厂、实现接口
record 矩形(double 宽, double 高) implements Comparable<矩形> {
static 矩形 正方形(double 边) { return new 矩形(边, 边); }
double 面积() { return 宽 * 高; }
boolean 是正方形() { return 宽 == 高; }
@Override public int compareTo(矩形 o) { return Double.compare(面积(), o.面积()); }
}
// 方法要返回两个值?以前得造个类或用数组,现在一行
record 统计(int 最小, int 最大, double 平均) {}
static 统计 分析(int[] 数据) { return new 统计(..., ..., ...); }
// 局部 record:只在一个方法里用的临时结构
void 排行() {
record 条目(String 名字, int 分) {} // 就在方法里定义
名单.stream()
.map(p -> new 条目(p.名字(), p.算分()))
.sorted(Comparator.comparingInt(条目::分).reversed())
.forEach(System.out::println);
}
// 访问器没有 get 前缀
var p = new 坐标(3, 4);
p.x() // ✓ 3
// p.getX() ✗ 不存在record Box(List<String> items) {},传一个 ArrayList 进去,构造完之后从外部往原列表 add——box.items() 里真的多了一个元素。修法是在紧凑构造器里 items = List.copyOf(items);(同一实验加上这行后外部修改就无效了)。凡是分量里有集合、数组、或其它可变对象,都要在紧凑构造器里拷一份;数组尤其要注意,record 数据(int[] 值) 的 equals 比的还是数组的身份,几乎肯定不是你想要的。Object[](丢类型)、用 Map(丢类型还要记键名)、或者用输出参数(丑)。现在直接在方法上面写一行 record,甚至可以写成局部 record——类型安全、有名字、自动带 toString。枚举表示「取值就这么几个,不会再多」的类型:星期、状态、方向、难度。Java 的 enum 比大多数语言的强得多——它是真正的类,可以有字段、构造器、方法,每个常量还能有自己的实现。
为什么用枚举而不是常量字符串或 int
- 编译期检查:拼错
"MONDY"编译器不管,写错星期.MONDY当场报错。 switch能穷尽检查:漏了一个分支编译器会提醒(配合箭头 switch)。- 自带方法:
values()拿到全部、ordinal()拿序号、name()拿名字、valueOf("X")反查。 - 可以安全地用
==比较——每个常量全局唯一,这是 02 章那条「引用类型用 equals」的例外。 - 天生单例、天生线程安全。
带字段:给每个常量附加数据
枚举可以有构造器,每个常量在声明时传参。这让「难度 → 倍率」「行星 → 质量」这类映射直接长在类型上,不需要额外的 Map。
带行为:每个常量自己的实现
可用:给枚举声明一个抽象方法,每个常量用大括号提供自己的实现(ADD { int apply(a,b){return a+b;} })。这是替代「一堆 if-else 分发」的最优雅方案之一——加一个新常量时编译器会强制你实现方法,不可能漏。
配套的两个集合
EnumMap:键是枚举时用它,内部就是个数组,比HashMap快且有序(按声明顺序)。EnumSet:用位运算实现,做「一组标志」时极省内存。
// 最简单的枚举
enum 方向 { 上, 下, 左, 右 }
// 带字段:每个常量附带数据
enum 难度 {
简单(0.5, "新手村"),
普通(1.0, "标准体验"),
地狱(2.5, "自找苦吃"); // 注意这里是分号
private final double 倍率;
private final String 说明;
难度(double 倍率, String 说明) { // 构造器自动是 private
this.倍率 = 倍率; this.说明 = 说明;
}
public double 倍率() { return 倍率; }
public int 算伤害(int 基础) { return (int) (基础 * 倍率); }
}
难度.地狱.算伤害(100) // 250
// 带行为:每个常量有自己的实现
enum 运算 {
加("+") { public int 算(int a, int b) { return a + b; } },
乘("*") { public int 算(int a, int b) { return a * b; } };
final String 符号;
运算(String s) { 符号 = s; }
public abstract int 算(int a, int b); // 加新常量时编译器强制你实现
}
for (var op : 运算.values())
System.out.printf("3 %s 4 = %d%n", op.符号, op.算(3, 4));
// 常用 API
方向.values() // [上, 下, 左, 右]
方向.上.ordinal() // 0
方向.上.name() // "上"
方向.valueOf("上") // 方向.上(名字不存在会抛 IllegalArgumentException)
方向.上 == 方向.valueOf("上") // true —— 枚举可以放心用 ==
// 专用集合
var 计数 = new EnumMap<方向, Integer>(方向.class);
var 允许 = EnumSet.of(方向.上, 方向.下);
// switch 上枚举:不用写枚举类名前缀
String 箭头 = switch (d) {
case 上 -> "↑"; case 下 -> "↓";
case 左 -> "←"; case 右 -> "→";
}; // 四个都写了,不需要 defaultordinal() 和枚举常量的顺序不要持久化。把 ordinal() 存进数据库或文件、或者按序号做数组下标,看起来很省——直到有人在枚举中间插入了一个新常量,所有旧数据的含义集体错位,而且不会报任何错。要持久化就存 name()(读回来用 valueOf),或者给枚举加一个显式的、永不改变的 code 字段。同理,values() 每次调用都返回一个新数组(防止你改它),在热点循环里反复调会白白产生垃圾,需要时存成 private static final。switch 不写 default,是个很有价值的习惯。如果你穷举了所有常量、不写 default,那么将来给枚举加一个新常量时,编译器会立刻报错指出所有需要更新的 switch。写了 default 就失去了这个保护——新常量会静默走进兜底分支。这是「让编译器帮你记住待办事项」的经典手法,08 章的密封类型把它推广到了普通类上。sealed(Java 17 起正式)让你声明:「这个接口/类只允许这几个类型实现或继承,别的都不行。」它和 record + 模式匹配(08 章)组成了现代 Java 最有力的一套组合。
它解决什么问题
- 接口是开放的:谁都能实现,所以编译器永远无法知道一共有几种实现,
switch上必须写default。 - 密封之后编译器就知道了:
sealed interface 形状 permits 圆, 矩形, 三角 {}——switch 覆盖了这三个就是完整的,不用default;漏一个直接编译错(报错原文:the switch expression does not cover all possible input values)。 - 这就是「加一种类型时,编译器逐个指出所有要改的地方」——上一卡枚举那个好处,现在推广到了任意复杂的类型上。
语法规则
sealed后面必须跟permits列出允许的子类型(如果子类型和它在同一个文件里,permits可以省略)。- 每个被许可的子类型必须选一个身份:
final(到此为止,record 天然是)、sealed(继续密封下一层)、或non-sealed(在这里重新开放)。 - 子类型必须和密封类型在同一个模块或同一个包里——密封的边界是「一个作者能控制的范围」。
典型场景:这类问题以前都很难写好
- 操作结果:成功(带值)/ 失败(带原因)—— 比返回
null或抛异常都清楚。 - 解析出来的语法树:数字、加法、乘法…(表达式求值器的标准写法)。
- 游戏事件、状态机的状态、协议消息:一共就那几种,每种带的数据还不一样。
- 这在函数式语言里叫「代数数据类型」,Java 现在也有了,只是写法不同。
// 密封接口 + record:一共就这三种形状
sealed interface 形状 permits 圆, 矩形, 三角 {}
record 圆(double r) implements 形状 {}
record 矩形(double 宽, double 高) implements 形状 {}
record 三角(double a, double b, double c) implements 形状 {}
// switch 不需要 default —— 编译器知道就这三种(08 章展开)
static double 面积(形状 s) {
return switch (s) {
case 圆 c -> Math.PI * c.r() * c.r();
case 矩形 r -> r.宽() * r.高();
case 三角 t -> 海伦公式(t);
};
}
// 现在往 permits 里加一个"梯形":上面这个 switch 立刻编译错,
// 报 the switch expression does not cover all possible input values(报错原文)
// 场景:操作结果(比 null 和异常都清楚)
sealed interface 结果<T> permits 成功, 失败 {}
record 成功<T>(T 值) implements 结果<T> {}
record 失败<T>(String 原因, int 代码) implements 结果<T> {}
结果<String> r = 读配置();
String 提示 = switch (r) {
case 成功<String>(String v) -> "读到了:" + v;
case 失败<String>(String 因, int 码) -> "失败(" + 码 + "):" + 因;
};
// 场景:表达式求值器(教科书例子,写起来终于不难看了)
sealed interface 表达式 {}
record 数(double 值) implements 表达式 {}
record 加(表达式 左, 表达式 右) implements 表达式 {}
record 乘(表达式 左, 表达式 右) implements 表达式 {}
// 同一个文件里时 permits 可以省略
static double 求值(表达式 e) {
return switch (e) {
case 数(double v) -> v;
case 加(var l, var r) -> 求值(l) + 求值(r);
case 乘(var l, var r) -> 求值(l) * 求值(r);
};
}
求值(new 加(new 数(2), new 乘(new 数(3), new 数(4)))) // 14.0non-sealed 开一个口子,但那样穷尽检查就没了)。判据是「这组类型会不会由我之外的人扩展」:协议消息、AST 节点、状态机状态——不会,用密封;插件接口、策略接口——会,用普通接口。选错了不是灾难,但改起来是破坏性的 API 变更。instanceof)解决的问题,变成了编译器能检查的形式。判断适用场景只要问一句:这些情况是不是「一共就这几种,而且是我说了算」?是就用密封;如果希望别人也能扩展(比如插件),那就该用普通接口。前三卡是零件,这一卡把它们拼起来。用一个具体问题演示:怎么把「一堆字符串和 if-else」重写成「类型 + 模式匹配」。
问题:一个文字冒险游戏的指令系统
玩家输入 go north、take 钥匙、look、quit。朴素写法是一串 if (输入.startsWith("go")),指令一多就变成几百行的分发泥潭,而且每处都要重新解析参数。
三步重写
- ① 把「一共有哪些指令」写成密封接口:每种指令一个 record,各自带自己需要的数据(
移动带方向,拿取带物品名,看和退出什么都不带)。 - ② 解析只做一件事:字符串 → 指令对象。解析失败也是一种结果(用密封的
结果类型,或者返回Optional)。 - ③ 执行用
switch分发:每个分支直接拿到解构出来的数据,不用再解析一遍。加新指令时,编译器会指着这个 switch 说你漏了。
收益在哪
- 解析和执行彻底分开:可以单独测试解析(给字符串断言得到什么指令),也可以单独测试执行(直接构造指令对象)。
- 数据只解析一次:
移动(方向.北)里的方向已经是枚举了,后面所有代码都不用再碰字符串。 - 穷尽检查:新增
使用(String 物品)时,所有需要处理它的 switch 都会编译报错。
// ① 建模:一共就这几种指令
enum 方向 { 北, 南, 东, 西 }
sealed interface 指令 {}
record 移动(方向 往) implements 指令 {}
record 拿取(String 物品) implements 指令 {}
record 看() implements 指令 {}
record 退出() implements 指令 {}
// ② 解析:字符串 → 指令(只在这里碰字符串)
static Optional<指令> 解析(String 行) {
String[] 词 = 行.strip().toLowerCase(Locale.ROOT).split("\\s+");
return switch (词[0]) {
case "go", "走" -> 方向解析(词).map(移动::new);
case "take", "拿" -> 词.length > 1
? Optional.of(new 拿取(词[1]))
: Optional.empty();
case "look", "看" -> Optional.of(new 看());
case "quit", "退出" -> Optional.of(new 退出());
default -> Optional.empty();
};
}
// ③ 执行:直接拿解构出来的数据,不碰字符串
boolean 执行(指令 指令, 游戏 游戏) {
switch (指令) {
case 移动(方向 往) -> 游戏.走(往);
case 拿取(String 物) -> 游戏.拿(物);
case 看 ignored -> IO.println(游戏.当前房间().描述());
case 退出 ignored -> { return false; }
} // 没有 default:加新指令时这里会编译错
return true;
}
// 主循环
void main() {
var 游戏 = new 游戏();
while (true) {
var 行 = IO.readln("> ");
var 指令 = 解析(行);
if (指令.isEmpty()) { IO.println("听不懂。试试 go/take/look/quit"); continue; }
if (!执行(指令.get(), 游戏)) break;
}
}移动北/移动南/移动东/移动西 四个 record 是错的建模,移动(方向) 才对。判据:如果两个类型的 switch 分支写出来一模一样,它们就不该是两个类型。使用(String 物品, String 目标) 指令,看看编译器怎么指出所有要改的地方;② 把 房间 也建模成 record(record 房间(String 描述, Map<方向, 房间> 出口)),主循环就能真的走起来;③ 把游戏状态存成文本(06 章的 可存档 接口),下次接着玩。这个骨架不到一百行,但它是一个完整的游戏引擎。你在网上和旧代码里会大量遇到前一代写法。这一卡把三组对照放在一起,既是为了读懂旧代码,也是为了知道该怎么改。
① 数据类:从 60 行到 1 行
| 旧写法(Java 8 时代) | 今天 |
|---|---|
| 私有字段 + 全套 getter/setter | record 坐标(int x, int y) {} |
手写或 IDE 生成 equals/hashCode | 自动生成,永远一致 |
手写 toString | 自动生成 |
用 Lombok 的 @Data 注解省样板 | 不需要额外依赖了 |
什么时候不能换成 record:需要可变(有 setter 且真的会被调用)、需要继承、框架要求无参构造器(部分老框架)。
② 一组常量:从 int 到 enum
| 旧写法 | 问题 | 今天 |
|---|---|---|
static final int 简单 = 0; | 任何 int 都能传进去 | enum 难度 { 简单, 普通, 地狱 } |
String 状态 = "PENDING"; | 拼错不报错 | 枚举,拼错编译不过 |
用 Map<Integer, String> 存附加信息 | 两处要同步维护 | 枚举带字段,长在一起 |
③ 多态分发:从继承树到密封 + 模式匹配
- 旧写法:抽象类
图形+ 每个子类覆写面积()。优点是加新形状不用改老代码;缺点是加新操作(周长、绘制、序列化)要动所有子类,而且逻辑散落在各处。 - 新写法:
sealed interface+ record +switch。优点是加新操作只要写个新方法(所有分支集中在一处,好读好改);缺点是加新类型要改所有 switch——但编译器会逐个指出来,不会漏。 - 怎么选:类型会不断增加(插件、扩展点)→ 继承;类型基本固定但操作会不断增加(AST、协议、状态机)→ 密封 + 模式匹配。后者在实际项目里更常见。
// ① 数据类:旧写法(网上教程的典型样子)
public class 坐标Old {
private int x;
private int y;
public 坐标Old() {}
public 坐标Old(int x, int y) { this.x = x; this.y = y; }
public int getX() { return x; }
public void setX(int x) { this.x = x; }
public int getY() { return y; }
public void setY(int y) { this.y = y; }
@Override public boolean equals(Object o) { /* 十行 */ }
@Override public int hashCode() { return Objects.hash(x, y); }
@Override public String toString() { /* 三行 */ }
}
// 今天:
record 坐标(int x, int y) {}
// ② 常量:旧写法
public static final int 难度_简单 = 0;
public static final int 难度_普通 = 1;
void 开始(int 难度) { } // 传 999 也能编译
// 今天:
void 开始(难度 难度) { } // 只能传那三个值之一
// ③ 分发:旧写法(instanceof 链,最糟的一种)
static double 面积Old(Object s) {
if (s instanceof 圆) {
圆 c = (圆) s; // 类型写三遍
return Math.PI * c.r() * c.r();
} else if (s instanceof 矩形) {
矩形 r = (矩形) s;
return r.宽() * r.高();
}
throw new IllegalArgumentException("不认识的形状"); // 运行期才发现漏了
}
// 今天:漏一种编译就报错
static double 面积(形状 s) {
return switch (s) {
case 圆(double r) -> Math.PI * r * r;
case 矩形(var w, var h) -> w * h;
case 三角 t -> 海伦公式(t);
};
}@Data/@Builder 等注解就是为了消除本卡第一张表里的样板。今天 record 已经覆盖了其中最常用的部分,而且是语言原生的(不需要 IDE 插件、不会和新版 JDK 打架)。新项目里 record 能解决的就别引 Lombok;老项目里见到它,知道它在干什么就行。模式匹配与新 switch
模式匹配是「检查类型 + 取出数据 + 分支」三件事的合并写法。它和 record、sealed 一起,把一类原本要写几十行 if-else 的代码压缩成一个表达式,而且让编译器帮你检查有没有漏。
从 Java 16 起,instanceof 可以在检查类型的同时把值绑定到一个新变量上。这一个小改动消灭了 Java 里最啰嗦的一种代码模式。
三行变一行
// 旧写法:类型写三遍 if (o instanceof String) { String s = (String) o; if (s.length() > 3) { ... } } // 新写法 if (o instanceof String s && s.length() > 3) { ... }
绑定变量的作用域很聪明
- 编译器按「在这里能不能确定它一定是这个类型」来决定
s在哪些地方可用。 - 所以
if (o instanceof String s && s.isEmpty())合法(&&右边只有在左边为真时才求值);if (o instanceof String s || s.isEmpty())不合法。 - 取反也管用:
if (!(o instanceof String s)) return;—— 之后的整个方法里s都可用(因为不是 String 就已经返回了)。这个「提前返回」写法很常用。
它最典型的用途:写 equals
06 章那个 equals 的标准写法就是它:return o instanceof 坐标 其它 && x == 其它.x; ——一行同时完成了 null 检查、类型检查、强转、比较(null instanceof X 恒为 false,)。
record 模式:还能顺手拆开
Java 21 起,如果类型是 record,可以直接在模式里解构:if (o instanceof 坐标(int x, int y)) ——连访问器都不用调了。嵌套的 record 可以层层解构。
// 基本形式
Object o = 拿一个东西();
if (o instanceof String s) {
System.out.println(s.toUpperCase()); // s 直接可用
}
// 带条件
if (o instanceof String s && s.length() > 3) { }
// 提前返回(很常用的写法)
void 处理(Object o) {
if (!(o instanceof String s)) return;
// 这里往下 s 都能用
System.out.println(s.strip());
}
// 写 equals 的标准姿势(一行同时做四件事)
@Override public boolean equals(Object o) {
return o instanceof 坐标 其它 && x == 其它.x && y == 其它.y;
// null 检查 ✓ 类型检查 ✓ 强转 ✓ 比较 ✓
}
// record 模式:直接解构(Java 21 起)
if (o instanceof 坐标(int x, int y)) {
System.out.println(x + "," + y); // 连访问器都不用调
}
// 嵌套解构
record 线段(坐标 起, 坐标 终) {}
if (o instanceof 线段(坐标(var x1, var y1), 坐标(var x2, var y2))) {
System.out.println(Math.hypot(x2 - x1, y2 - y1));
}
// null 的行为
Object 空 = null;
空 instanceof String // false —— 永远不会 NPE|| 右边用它(左边为假时 s 没被赋值),以及在 if 块外面用它(if (o instanceof String s) { } System.out.println(s); 编译错)。这不是限制,是保护——它保证你拿到 s 时它一定有值。如果发现自己在跟作用域较劲,通常说明该换成「提前返回」的写法了。var:case 矩形(var 宽, var 高)。分量类型明显时(数字、字符串)用 var 更清爽;类型本身是信息时(比如区分 int 和 double)就写出来。另外解构时可以给分量起新名字——坐标(int 横, int 纵) 完全合法,名字由你定,不必和 record 声明时一致。Java 21 起,switch 的 case 里可以写类型模式而不只是常量。配合密封类型(07 章),它成了「按类型分发」这件事的标准写法。
四个能力,全部可用
- 按类型分支:
case 圆 c -> ...; - 解构 record:
case 圆(double r) -> ...直接拿到分量; when守卫:case 圆 c when c.r() > 10 -> "大圆"—— 顺序很重要,带守卫的要写在前面(小圆走到了没有守卫的那一支,大圆走到了带守卫的那一支);- 穷尽性检查:类型是密封的且覆盖了全部子类型时,不用写
default;漏了会编译错。
null 的处理变了,这是最容易踩的一处
- 老式 switch 遇到
null抛 NPE(switch(某个为 null 的 String)抛Cannot invoke "String.hashCode()")。 - 模式 switch 也一样默认抛 NPE(
switch(null)落在类型模式上时抛 NPE)。 - 但现在可以写
case null(有效),把它当成一个正常分支处理。这是模式 switch 唯一能优雅处理 null 的方式,比在外面套一层if (x == null)清楚。 - 也可以写
case null, default -> ...把 null 和兜底合并。
什么时候用它,什么时候用多态
- 用 switch 模式匹配:类型集合固定(密封)、操作会不断增加、逻辑集中在一处更好读。
- 用多态(虚方法):类型会不断增加、每种类型的行为天然属于它自己。
- 两者不冲突:同一套密封类型上,可以既有接口方法(共性行为)又有外部 switch(特定场景的处理)。
// 完整形态:类型 + 守卫 + 解构 + 穷尽(这段跑过)
sealed interface 形状 permits 圆, 矩形, 三角 {}
record 圆(double r) implements 形状 {}
record 矩形(double 宽, double 高) implements 形状 {}
record 三角(double a, double b, double c) implements 形状 {}
String 描述(形状 s) {
return switch (s) {
case 圆 c when c.r() > 10 -> "大圆 " + c.r(); // 守卫要写在前面
case 圆(double r) -> "圆 " + r; // 解构
case 矩形(var w, var h) when w == h -> "正方形 " + w;
case 矩形 r -> "矩形 " + r.宽() + "x" + r.高();
case 三角 t -> "三角形";
}; // 没有 default:密封类型已穷尽
}
// null 必须显式处理(不写 case null 会抛 NPE)
String 安全描述(形状 s) {
return switch (s) {
case null -> "没有形状";
case 圆 c -> "圆";
case 矩形 r -> "矩形";
case 三角 t -> "三角";
};
}
// 混合常量与类型(处理来自外部的 Object)
String 格式化(Object o) {
return switch (o) {
case null -> "(空)";
case Integer i when i < 0 -> "负数 " + i;
case Integer i -> "整数 " + i;
case Double d -> "%.2f".formatted(d);
case String s when s.isBlank() -> "(空白字符串)";
case String s -> "\"" + s + "\"";
case int[] a -> Arrays.toString(a);
default -> o.toString(); // Object 不密封,必须兜底
};
}
// 当语句用(不返回值)时也可以
switch (事件) {
case 点击(var x, var y) -> 处理点击(x, y);
case 按键(var 码) -> 处理按键(码);
}switch (null) 在只有类型模式的 switch 上抛 NullPointerException,即使有 default 分支也一样——default 不接 null。这一点和大多数人的直觉相反。凡是值可能为 null 的 switch,要么写 case null,要么写 case null, default。这也是为什么把 Optional(12 章)或密封的「结果」类型作为返回值比返回可空对象好——根本没有 null 要处理。when 守卫的分支顺序是从上往下匹配的,所以「更具体的写前面」。编译器会检查明显的「前面已经全覆盖了,后面永远走不到」的情况并报错(this case label is dominated by a preceding case label),但带 when 的守卫它检查不了——把 case 圆 c 写在 case 圆 c when ... 前面,后者永远不会执行,而这个错误编译器能抓到;但两个都带守卫时就靠你自己了。Java 14 起 switch 有了两种形态:老的冒号 + break 和新的箭头。它们不只是写法差异——箭头形态修掉了老形态三个真实的坑。
毛病一:贯穿(fall-through)
- 老写法里,一个
case执行完会继续执行下一个case,除非你写break。忘写break是 C 家族最著名的 bug 源之一,而且编译器默认不警告。 - 箭头写法不会贯穿,写不写
break都一样(其实不能写)。 - 偶尔确实需要贯穿(多个值走同一分支),箭头写法用逗号:
case 一, 二, 三 -> ...,更清楚。
毛病二:不能当表达式,得先声明变量
- 老写法只能是语句,所以要「按条件算一个值」时得先
String 结果;,在每个分支里赋值,还得记得处理所有情况——漏一个分支就是「变量可能未初始化」编译错(好),或者忘了default导致值不对(坏)。 - 箭头写法可以直接当表达式返回值,并且编译器要求它必须覆盖所有情况(否则报错),从根上杜绝了漏分支。
毛病三:所有分支共享一个作用域
- 老写法里所有
case在同一个块里,在一个 case 里声明的变量,在别的 case 里也「可见」(但可能没被赋值),经常导致奇怪的编译错误或需要额外加大括号。 - 箭头写法每个分支是独立的块。
需要多行逻辑时用 yield
箭头后面跟大括号时,用 yield 值; 返回这个分支的结果(不是 return——return 会从整个方法返回)。
// ✗ 老写法的三个毛病都在这
String 结果; // 毛病二:得先声明
switch (等级) {
case 1:
String 说明 = "入门"; // 毛病三:这个变量在下面也"可见"
结果 = "新手";
// 毛病一:这里忘了 break,会掉进 case 2
case 2:
结果 = "熟练";
break;
default:
结果 = "未知";
}
// ✓ 新写法
String 结果2 = switch (等级) {
case 1 -> "新手";
case 2, 3 -> "熟练"; // 多个值用逗号,意图明确
default -> "未知";
};
// 多行逻辑:大括号 + yield
int 奖励 = switch (等级) {
case 1 -> {
记录("发放新手奖励");
yield 100; // 不是 return!
}
case 2 -> 50;
default -> 0;
};
// 枚举 + 箭头 + 不写 default = 加新常量时编译器提醒你(07 章)
enum 状态 { 待办, 进行中, 完成 }
String 图标 = switch (状态) {
case 待办 -> "○";
case 进行中 -> "◐";
case 完成 -> "●";
};
// 老代码里确实需要贯穿的场景,新写法这样表达
int 天数 = switch (月) {
case 1, 3, 5, 7, 8, 10, 12 -> 31;
case 4, 6, 9, 11 -> 30;
case 2 -> 闰年(年) ? 29 : 28;
default -> throw new IllegalArgumentException("月份不对:" + 月);
};switch 的贯穿行为在极少数情况下是故意的,改写时要看仔细。把一段老代码从冒号形态改成箭头形态时,如果原来某个 case 后面没有 break 而且不是笔误(比如「大写字母和小写字母都要做同样的预处理,然后小写字母还要多做一步」),机械地加逗号会改变语义。改写前先确认每一处缺失的 break 到底是 bug 还是设计——绝大多数是 bug,但那少数几个会让你调试很久。default 分支可以直接 throw。default -> throw new IllegalArgumentException(...) 是合法的(throw 不产生值,但它也不会正常结束),这比返回一个假值(-1、null、"")好得多——错误在发生的地方就暴露,而不是在三层调用之外变成一个莫名其妙的空指针。前三卡是语法,这一卡是它真正改变代码形态的地方。三个例子,都是模式匹配出现之前写起来很难看的场景。
① 处理嵌套的动态结构
解析 JSON、YAML、配置文件时,拿到的是嵌套的 Map/List/字符串/数字。老写法要一层层强转 + 判空 + 检查类型;模式匹配可以一次匹配整条路径,任何一层类型不对就整体不匹配。
② 状态机
状态用密封接口(每种状态带自己的数据),转移函数是 switch (当前状态, 事件)。好处:非法的状态-事件组合在编译期就能被发现(漏写分支就报错),而且每个状态携带的数据是类型安全的——「等待支付」状态才有订单号,「已完成」状态才有完成时间,不用在一个大类里塞一堆可能为 null 的字段。
③ 访问者模式的替代
- 「对一棵树做不同的操作」这个需求,以前的标准答案是访问者模式:定义
Visitor接口、每个节点实现accept、每种操作写一个 Visitor 实现。样板极多,而且加一种节点要改所有 Visitor。 - 现在:密封接口 + switch。加一种操作 = 写一个新方法;加一种节点 = 编译器指出所有需要更新的 switch。样板归零。
// ① 匹配嵌套结构:一次匹配整条路径
Object 配置 = 解析(文本); // 得到嵌套的 Map/List/String/Integer
// 老写法:五层判断
if (配置 instanceof Map) {
Object 服务器 = ((Map<?, ?>) 配置).get("服务器");
if (服务器 instanceof Map) {
Object 端口 = ((Map<?, ?>) 服务器).get("端口");
if (端口 instanceof Integer p && p > 0) { 用(p); }
}
}
// 用 record 建模之后:一行
sealed interface 节点 {}
record 对象(Map<String, 节点> 项) implements 节点 {}
record 数组(List<节点> 元素) implements 节点 {}
record 文本(String 值) implements 节点 {}
record 数字(double 值) implements 节点 {}
String 渲染(节点 n) {
return switch (n) {
case 文本(String v) -> "\"" + v + "\"";
case 数字(double v) -> String.valueOf(v);
case 数组(List<节点> 元素) ->
元素.stream().map(this::渲染).collect(Collectors.joining(",", "[", "]"));
case 对象(Map<String, 节点> 项) ->
项.entrySet().stream()
.map(e -> "\"" + e.getKey() + "\":" + 渲染(e.getValue()))
.collect(Collectors.joining(",", "{", "}"));
};
}
// ② 状态机:每个状态带自己的数据
sealed interface 下载状态 {}
record 待开始(String 地址) implements 下载状态 {}
record 进行中(String 地址, long 已下载, long 总量) implements 下载状态 {}
record 已完成(Path 文件, Duration 耗时) implements 下载状态 {}
record 已失败(String 原因, int 重试次数) implements 下载状态 {}
String 显示(下载状态 s) {
return switch (s) {
case 待开始(var 地址) -> "等待中 " + 地址;
case 进行中(var a, var 已, var 总) when 总 > 0
-> "%.1f%%".formatted(100.0 * 已 / 总);
case 进行中 p -> "已下载 " + p.已下载() + " 字节";
case 已完成(var 文件, var 耗时) -> "完成 " + 文件.getFileName();
case 已失败(var 因, var n) when n >= 3 -> "放弃:" + 因;
case 已失败(var 因, var n) -> "失败(第 " + n + " 次):" + 因;
};
}
// 注意:进行中 才有进度,已完成 才有文件路径 —— 不需要一堆可能为 null 的字段case List<String> l 编译不过(09 章会讲为什么——运行期根本不知道元素类型),只能写 case List<?> l。所以「按元素类型分发」这件事模式匹配帮不了你,得靠别的手段(比如把类型信息显式建模成 record 的一个分量,或者用密封接口给每种列表一个专门的类型)。这是 Java 泛型擦除留下的疤,模式匹配也没能绕过去。地址、进度、文件、错误原因),然后靠一个 状态 枚举字段决定哪些字段有效——于是每次访问字段都要先想「现在这个字段有意义吗」,还要处理 null。密封接口 + record 把这个约束交给了类型系统:拿到 已完成 就一定有文件路径,编译器保证。泛型与类型擦除
泛型让同一段代码安全地作用于多种类型。Java 的泛型有个独特之处:它只活在编译期,运行时会被擦掉——这个决定带来了兼容性,也带来了一串「为什么不能这么写」的限制。
泛型是 Java 5 加进来的。在它之前,集合里装的都是 Object——什么都能放进去,取出来必须强转,转错了要到运行期才知道。泛型把这个检查提前到了编译期。
没有泛型的世界
List 名单 = new ArrayList();
名单.add("小明");
名单.add(42); // 编译器不管
String s = (String) 名单.get(1); // 运行期 ClassCastException有了泛型:List<String> 名单 之后,add(42) 当场编译错,get 直接返回 String 不用强转。同一个错误,从「上线后崩溃」变成了「写的时候红线」。
三个地方会用到泛型
- 用别人的泛型类型(99% 的时间):
List<String>、Map<String, List<Integer>>、Optional<User>。 - 写自己的泛型方法:一个方法对多种类型都成立时(下一卡)。
- 写自己的泛型类:容器、结果包装、缓存(下一卡)。
菱形推断:右边不用重复写
Map<String, List<Integer>> m = new HashMap<>();—— 右边的<>里什么都不用写,编译器从左边推。- 配合
var时反过来:var时右边必须写全(var m = new HashMap<String, Integer>();),否则推出来是HashMap<Object, Object>(02 章的 pitfall)。
命名约定
T(Type)、E(Element)、K/V(Key/Value)、R(Result)、N(Number)。这是约定不是规则,写 <元素> 也完全合法——但单字母大写是整个生态的共识,读代码时一眼能认出「这是个类型参数不是个类」。
// 日常用法:把类型写清楚
List<String> 名单 = new ArrayList<>();
名单.add("小明");
// 名单.add(42); ✗ 编译错:incompatible types
String 第一个 = 名单.get(0); // 不用强转
// 嵌套泛型
Map<String, List<Integer>> 分数表 = new HashMap<>();
分数表.computeIfAbsent("小明", k -> new ArrayList<>()).add(95);
// 增强 for 也受益:不用强转
for (String 名 : 名单) { }
// 泛型方法:调用时通常不用显式写类型(编译器推断)
List<String> 空的 = Collections.emptyList();
Optional<Integer> 也许有 = Optional.of(42);
// var 与泛型:右边必须写全
var 对的 = new HashMap<String, Integer>(); // HashMap<String, Integer>
var 坑 = new HashMap<>(); // HashMap<Object, Object>!
// 基本类型不能当类型参数,得用包装类型(02 章)
// List<int> ✗
List<Integer> 数字 = List.of(1, 2, 3); // ✓
int[] 更省内存 = {1, 2, 3}; // 大量数字时用数组List<Integer> 里的每个数字都是一个堆上对象,比 int[] 多占约 5 倍内存,遍历时还要拆箱。数据量大且是纯数字时,用数组或 IntStream;Map<Integer, X> 这种更要注意(每个键都是对象)。这个限制正是「值类型 / Valhalla 项目」在解决的问题,但它还没进正式版本,今天仍要自己权衡。List(不带尖括号)出现在新写的代码里,那是个 bug 信号。这叫「原始类型」(raw type),是为了兼容 Java 5 之前的代码保留的。用了它,泛型的所有检查都会失效,编译器只会给一个 unchecked 警告(报错原文:unchecked call to add(E) as a member of the raw type List)。那个警告值得当成错误对待——下一卡有它造成的事故。自己写泛型的场合比想象中少(大部分时间是在用),但一旦需要,语法很简单:在类名或返回类型前面声明类型参数,然后就能像普通类型一样用它。
泛型类
class 盒子<T> { private T 内容; } —— T 在整个类里可用。典型场景是容器、包装器、缓存。
泛型方法:类型参数写在返回类型前面
static <T> T 第一个或默认(List<T> l, T 默认值)—— 注意<T>的位置。- 调用时通常不用写类型,编译器从参数推断。需要显式指定时写成
工具.<String>方法(...)(很少见)。 - 静态方法必须自己声明类型参数:类上的
<T>属于实例,静态方法用不了它。
有界类型参数:限制 T 能是什么
<T extends Comparable<T>>—— T 必须可比较,于是方法里能调compareTo。不加界限的话,T只能当Object用(因为编译器只知道它是「某个类型」)。extends在这里同时表示「继承」和「实现接口」,可以用&连接多个:<T extends Comparable<T> & Serializable>。
什么时候该写泛型
- 该写:这段逻辑对多种类型都一模一样,而且调用方需要保住类型信息(返回值的类型跟着参数走)。
- 不该写:只有一两种类型会用到——直接写具体类型更好读。「以后可能会有别的类型」不是理由,泛型可以以后再加,而过早的泛型会污染整个 API。
// 泛型类:一个简单的结果包装
public record 结果<T>(T 值, String 错误) {
public static <T> 结果<T> 成功(T 值) { return new 结果<>(值, null); }
public static <T> 结果<T> 失败(String 错) { return new 结果<>(null, 错); }
public boolean 成功了() { return 错误 == null; }
// 静态方法必须自己声明 <T>:类上那个属于实例
}
结果<Integer> r = 结果.成功(42); // 类型自动推断
// 泛型方法:<T> 写在返回类型前
static <T> T 第一个或默认(List<T> 列表, T 默认值) {
return 列表.isEmpty() ? 默认值 : 列表.get(0);
}
String s = 第一个或默认(名单, "无"); // 返回类型跟着参数走
// 有界类型:加了界限才能调它的方法
static <T extends Comparable<T>> T 最大(List<T> 列表) {
T 最大值 = 列表.get(0);
for (T x : 列表)
if (x.compareTo(最大值) > 0) 最大值 = x; // 没有界限时这行编译不过
return 最大值;
}
// 多个类型参数
static <K, V> Map<V, K> 反转(Map<K, V> 原) {
var 新的 = new HashMap<V, K>();
原.forEach((k, v) -> 新的.put(v, k));
return 新的;
}
// 一个真会用到的泛型类:定长缓存
class 最近使用<K, V> extends LinkedHashMap<K, V> {
private final int 上限;
最近使用(int 上限) { super(16, 0.75f, true); this.上限 = 上限; }
@Override protected boolean removeEldestEntry(Map.Entry<K, V> 最老) {
return size() > 上限; // 超了自动淘汰最久未用的
}
}class 盒子<T> { static T 默认值; } 编译错——T 是「每个实例各自的类型」,而静态成员属于整个类,没有实例可言。静态方法要用泛型,必须自己声明一份(上面 结果 类里的 static <T> 就是这么写的),而且那个 T 和类上的 T 是两回事(重名只是习惯)。这条规则每个初学泛型的人都会撞一次。LinkedHashMap 的第三个构造参数传 true 表示「按访问顺序排」,再覆写 removeEldestEntry 就得到一个自动淘汰的缓存,总共五行。写小工具时需要个缓存,不用引任何库。Java 的泛型是擦除式的:编译器检查完类型之后,把类型参数擦掉,生成的字节码里 List<String> 和 List<Integer> 是同一个东西。这个决定是为了兼容 Java 5 之前的代码,代价是一串限制。
擦除的三个直接证据
| 实验 | 结果 |
|---|---|
new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass() | true——运行期是同一个类 |
new ArrayList<String>().getClass().getTypeName() | java.util.ArrayList(没有尖括号) |
反射读字段 List<String> names 的泛型类型 | java.util.List<java.lang.String>——字段和方法签名上的泛型信息保留在 class 文件里 |
最后一行很重要:擦除的是「运行期对象里的类型」,不是「所有泛型信息」。声明处的泛型写在 class 文件的签名里,所以反射和序列化库能读到它——这就是 Jackson 之类的库能正确反序列化 List<User> 的原理(要靠 TypeReference 这种「匿名子类保住签名」的技巧)。
事故:原始类型能往 List<String> 里塞整数
List<String> l = new ArrayList<>(); List raw = l; // 原始类型,编译器只给一个 unchecked 警告 raw.add(42); // 塞进去了! l.size() → 1 // 列表里真的有一个元素 String s = l.get(0); // ClassCastException: Integer cannot be cast to String
关键点:出错的地方不是塞进去那一行,而是取出来那一行——错误现场离根因十万八千里。这叫「堆污染」,也是那个 unchecked 警告存在的全部理由。永远不要忽略它。
由擦除导出的限制清单
- 不能
new T():运行期不知道 T 是什么。绕法是传一个Supplier<T>或Class<T>进来。 - 不能
new T[10]:同上。绕法是(T[]) new Object[10](会有警告)或者干脆用List。 - 不能
x instanceof List<String>:只能写instanceof List<?>(08 章那条 pitfall)。 - 不能有两个「擦除后一样」的重载:
f(List<String>)和f(List<Integer>)冲突。 - 不能
catch (MyException<T> e):异常类不能是泛型的。 - 不能用基本类型当类型参数(上一卡)。
// 擦除的证据
new ArrayList<String>().getClass() == new ArrayList<Integer>().getClass()
// true —— 运行期只有一个 ArrayList
// 堆污染:错误出现在离根因很远的地方
List<String> l = new ArrayList<>();
((List) l).add(42); // 警告:unchecked call to add(E)
String s = l.get(0); // ← 这里才炸:ClassCastException
// 绕过"不能 new T()":传工厂进来
static <T> List<T> 造几个(int n, Supplier<T> 工厂) {
var 结果 = new ArrayList<T>();
for (int i = 0; i < n; i++) 结果.add(工厂.get());
return 结果;
}
造几个(3, ArrayList::new);
// 绕过"不能 instanceof List<String>"
if (o instanceof List<?> 列表) { // ✓ 只能问"是不是 List"
// 元素类型只能自己逐个检查
if (!列表.isEmpty() && 列表.get(0) instanceof String) { }
}
// 需要运行期类型信息时:显式传 Class
static <T> T 读配置(Path p, Class<T> 类型) {
// 库都是这么做的:Jackson 的 readValue(json, User.class)
return 类型.cast(解析(p));
}
// 泛型可变参数:会有警告,确认安全后才加注解
@SafeVarargs
static <T> List<T> 列表(T... 元素) { // T... 内部是 T[],而 T[] 是"泛型数组"
return List.of(元素);
}Object[] o = new String[1]; o[0] = 42; 编译通过,运行期抛 ArrayStoreException(03 章);而 List<Object> l = new ArrayList<String>(); 直接编译错。后者更好:错误提前到了编译期。数组那个行为是 Java 1.0 的历史包袱(当时没有泛型,需要写 sort(Object[]) 这类通用方法)。知道这一点有助于理解下一卡的通配符——它就是在补回泛型「不能协变」丢掉的灵活性。List<String> 和裸 List 生成的字节码几乎一样(只多了编译器插入的强转指令),不会因为「泛型很多」而变慢或变大。C# 那种「真泛型」在值类型上有性能优势,但代价是运行时要为每种类型生成代码。Java 选了兼容性,这是个有意识的取舍,不是疏忽。List<Number> 和 List<Integer> 没有任何关系——即使 Integer 是 Number 的子类。这个「不变性」是类型安全的必然结果,但它让「写一个能处理各种数字列表的方法」变得不可能。通配符就是来补这个口子的。
为什么不能协变(一分钟理解)
假设 List<Integer> 可以赋给 List<Number>,那么:List<Number> n = 某个整数列表; n.add(3.14); —— 一个 Double 就进了整数列表。所以编译器必须禁止这种赋值。
PECS:Producer Extends, Consumer Super
| 写法 | 含义 | 能做什么 | 什么时候用 |
|---|---|---|---|
List<? extends 数字> | 「某种数字的列表」 | 能读(读出来是 数字),不能写 | 参数只用来读取数据(生产者) |
List<? super 整数> | 「整数或其父类的列表」 | 能写整数,读出来只是 Object | 参数只用来接收数据(消费者) |
List<?> | 「某种未知类型的列表」 | 只能读成 Object、只能 add(null) | 只关心大小、遍历打印 |
sum(List<? extends Number>) 能同时接受 List.of(1, 2.5, 3L)(混着 Integer/Double/Long),算出 6.5;fill(List<? super Integer>) 能往 List<Number> 里塞整数。
怎么记
- 你从它那儿拿东西 →
extends(它是生产者)。 - 你往它里面放东西 →
super(它是消费者)。 - 既拿又放 → 别用通配符,直接用具体类型参数
<T>。 - 标准库到处是例子:
Collections.copy(List<? super T> 目标, List<? extends T> 源)——一个参数放、一个参数拿,两边刚好相反。
实用建议
写自己的 API 时,如果参数是「一个集合,我只读它」,就写 ? extends——这一个字的成本,换来调用方可以传各种子类型的集合。但别过度:内部实现里的局部变量不需要通配符,只有公开方法的参数才值得。
// 问题:这个方法只能接受 List<Number>,传 List<Integer> 编译错
static double 求和差(List<Number> 列表) { }
// 求和差(List.of(1, 2, 3)); ✗ List<Integer> 不是 List<Number>
// ✓ 生产者用 extends:我只从它那儿读
static double 求和(List<? extends Number> 列表) {
double 和 = 0;
for (Number n : 列表) 和 += n.doubleValue(); // 读:没问题
// 列表.add(1); ✗ 不能写:编译器不知道它到底是哪种数字的列表
return 和;
}
求和(List.of(1, 2.5, 3L)); // 6.5—— 混着几种数字都行
// ✓ 消费者用 super:我只往里面写
static void 填数(List<? super Integer> 目标) {
目标.add(1);
目标.add(2); // 写:没问题
Object o = 目标.get(0); // 读出来只能当 Object
}
List<Number> n = new ArrayList<>();
填数(n); // ✓ 得到 [1, 2]
// 标准库的经典例子:一读一写,两边正好相反
// Collections.copy(List<? super T> dest, List<? extends T> src)
// Stream.map(Function<? super T, ? extends R> mapper)
// Comparator 也是典型:它消费元素
static <T> void 排序(List<T> 列表, Comparator<? super T> 比较器) { }
// 这样一个 Comparator<Object> 也能拿来排 List<String>
// 只关心结构不关心类型:无界通配符
static void 打印大小(Collection<?> c) {
System.out.println(c.size());
}
// 既读又写:不用通配符,用类型参数
static <T> void 交换(List<T> 列表, int i, int j) {
T 临时 = 列表.get(i);
列表.set(i, 列表.get(j));
列表.set(j, 临时);
}List<? extends Number> 取列表() 会把通配符传染给所有调用方——他们拿到的东西什么都写不进去,还得自己再想办法处理。通配符属于「参数」的灵活性,返回类型应该给出确定的类型。同样的道理:如果一个方法的类型参数只出现一次,那它通常该是通配符而不是类型参数(void 打印(List<?> l) 比 <T> void 打印(List<T> l) 更简单)。Stream、Collections、Comparator 的签名是 PECS 的最佳教材,照着抄基本不会错。前四卡讲的是机制。这一卡讲实践:你自己写代码时,泛型该出现在哪儿、不该出现在哪儿,以及怎么读懂库里那些冗长的签名。
读懂复杂签名的三步法
拿 Stream 的 map 举例:<R> Stream<R> map(Function<? super T, ? extends R> mapper)
- ① 先找类型参数:
<R>是这个方法新引入的,T来自Stream<T>这个类。 - ② 再看返回值:返回
Stream<R>—— 元素类型变成了 R。 - ③ 最后看参数:一个「吃 T(或它的父类)、吐 R(或它的子类)」的函数。
? super T和? extends R都是为了让调用方能传更宽泛的函数——忽略通配符读一遍:Function<T, R>,意思就清楚了。
技巧:第一次读复杂签名时,把所有 ? super / ? extends 划掉。剩下的骨架就是它真正在说的事。
你自己该在哪儿写泛型
| 场景 | 建议 |
|---|---|
| 写一个只处理一种类型的方法 | 别用泛型,直接写具体类型 |
| 写一个容器/包装/缓存 | 用泛型类 |
| 写一个「输入什么类型就返回什么类型」的方法 | 用泛型方法 |
| 公开方法的集合参数,只读 | 加 ? extends |
| 局部变量、私有方法 | 怎么简单怎么来,不用讲究 |
三个信号说明泛型用过头了
- 类型参数超过两个,而且不是
K,V这种约定俗成的组合; - 到处是
@SuppressWarnings("unchecked")——这说明你在跟擦除较劲,多半有更简单的设计; - 方法签名比方法体还长。
// 读懂签名的练习:把通配符划掉再读
<R> Stream<R> map(Function<? super T, ? extends R> f)
// 划掉后: Stream<R> map(Function<T, R> f) —— "拿一个 T->R 的函数,得到 Stream<R>"
<T> Optional<T> reduce(BinaryOperator<T> 累加器)
// "拿两个 T 合成一个 T 的函数,可能得到一个 T"(空流就没有)
<K> Collector<T, ?, Map<K, List<T>>> groupingBy(Function<? super T, ? extends K> 分类)
// 中间那个 ? 是内部累加类型,用的人不用管 —— 划掉读:
// "拿一个 T->K 的分类函数,得到 Map<K, List<T>>"
// ✓ 该用泛型:容器
class 环形缓冲<T> {
private final Object[] 数据; // 内部用 Object[],这是标准做法
private int 头, 尾;
环形缓冲(int 容量) { 数据 = new Object[容量]; }
void 放(T x) { 数据[尾++ % 数据.length] = x; }
@SuppressWarnings("unchecked") // 这个抑制是有理由的:只有放进去的才取得出
T 取() { return (T) 数据[头++ % 数据.length]; }
}
// ✗ 不该用泛型:只有一种类型会用
static <T extends CharSequence> int 数一数(T 文本) { return 文本.length(); }
static int 数一数(String 文本) { return 文本.length(); } // ✓ 这样就够了
// 公开 API 的参数:加 ? extends 让调用方更自由
public void 添加全部(Collection<? extends 玩家> 一批) { }
// 调用方可以传 List<VIP玩家>、Set<新手玩家>…@SuppressWarnings("unchecked") 不是关掉警告的开关,是一份「我确认这里安全」的签名。加它之前必须能回答「为什么这里的转型一定成功」——上面那个环形缓冲能加,是因为数组里的东西只可能通过 放(T) 进去。加注解时把范围缩到最小(加在具体那一行的局部变量上,而不是整个方法或类上),并写一行注释说明理由。范围一大,就会顺手屏蔽掉后来新增的、真正有问题的警告。var),编译器会告诉你它推出来的到底是什么类型。集合框架
标准库里用得最多的一块。这一章讲清 List/Set/Map/Queue 各自解决什么问题、同一族里怎么选实现,以及那些「用错了不报错、只是慢一千倍或答案不对」的地方。
集合框架的结构比看起来简单:四个接口族 + 每族两三个常用实现。先按「你要解决什么问题」选族,再按性能特点选实现。
按问题选族
| 你要 | 用 | 常用实现 |
|---|---|---|
| 有序的一串、允许重复、按下标访问 | List | ArrayList(默认) |
| 去重、只关心「在不在里面」 | Set | HashSet(默认)、LinkedHashSet(保插入序)、TreeSet(排序) |
| 键 → 值的映射 | Map | HashMap(默认)、LinkedHashMap、TreeMap |
| 两头进出(队列 / 栈) | Deque | ArrayDeque |
| 按优先级取出最小的 | Queue | PriorityQueue(堆) |
90% 的情况下答案是 ArrayList、HashMap、HashSet。先用它们,有明确理由再换。
三个「验证过的」选型判据
- 查找用 Set/Map,别用 List。20 万元素里做 2000 次
contains,HashSet约 0.15 ms,ArrayList约 7.4 ms(差约 50 倍,而且这个差距随规模线性拉大)。 - 随机访问用
ArrayList,两头频繁增删用ArrayDeque。20 万元素每 100 个取一次,ArrayList约 0.1 ms、LinkedList约 131 ms(一千倍);反过来在头部插两万次,ArrayList约 185 ms、LinkedList约 6 ms。 - 需要排序用
TreeMap/TreeSet,不需要就别用: 10 万次查找HashMap约 5 ms、TreeMap约 10 ms(约 2 倍,因为一个是常数时间一个是对数时间)。
命名与遗留
- 看到
Vector、Hashtable、Stack、Enumeration就知道资料很老了:它们是 Java 1.0 的产物,每个方法都加了锁(性能差),今天已被ArrayList/HashMap/ArrayDeque取代。 - 需要线程安全的集合,也不是用它们——用
ConcurrentHashMap等(13 章)。
// 默认三件套
List<String> 顺序表 = new ArrayList<>();
Set<String> 去重表 = new HashSet<>();
Map<String, Integer> 映射 = new HashMap<>();
// 队列与栈:都用 ArrayDeque
Deque<String> 队列 = new ArrayDeque<>();
队列.addLast("先来的"); // 入队
队列.pollFirst(); // 出队(空时返回 null,不抛异常)
Deque<String> 栈 = new ArrayDeque<>();
栈.push("后进"); // 压栈(等于 addFirst)
栈.pop(); // 弹栈(空时抛 NoSuchElementException)
// 优先队列:每次取出最小的(做 Dijkstra、任务调度、Top-K)
var 堆 = new PriorityQueue<任务>(Comparator.comparingInt(任务::优先级));
堆.add(new 任务("洗碗", 3));
堆.poll(); // 优先级最小的那个
// 需要保持插入顺序的 Set / Map
Set<String> 有序去重 = new LinkedHashSet<>(); // 去重且记得先来后到
Map<String, Integer> 有序映射 = new LinkedHashMap<>();
// 需要按键排序
TreeMap<String, Integer> 排序映射 = new TreeMap<>();
排序映射.firstKey(); // TreeMap 独有:最小键
排序映射.headMap("m"); // 范围查询
// 快速构造(Java 9 起)——注意得到的是不可变集合
var 三个 = List.of("a", "b", "c");
var 表 = Map.of("一", 1, "二", 2);
var 可改的 = new ArrayList<>(List.of("a", "b")); // 要能改就再包一层LinkedList 几乎在所有场景下都不是最优解,包括那些教科书说它该赢的场景。它唯一真正的优势是「已经拿着某个位置的迭代器时,在那里插入是 O(1)」——但只要你需要先 get(i) 找到那个位置,就已经是 O(n) 了(LinkedList.get 比 ArrayList 慢一千倍)。头部插入用 ArrayDeque,中间插入用 ArrayList(数组复制在现代 CPU 上极快)。标准库作者自己说过:LinkedList 的存在更多是历史原因。ArrayDeque 同时是最好的队列和最好的栈。老代码里的 Stack 类不要用(它继承自 Vector,带锁,而且迭代顺序是反的——从底到顶,几乎肯定不是你想要的);LinkedList 虽然也实现了 Deque,但每个元素都是一个独立对象,缓存不友好,慢很多。需要队列或栈,一律 ArrayDeque。Map 是日常写代码用得最多的集合。它在 Java 8 之后新增的几个方法能省掉大量样板,但很多人还在写 Java 7 的写法。
五个应该进入肌肉记忆的方法
| 方法 | 做什么 | 取代了什么 |
|---|---|---|
getOrDefault(k, 默认) | 取不到就给默认值 | m.containsKey(k) ? m.get(k) : 默认 |
computeIfAbsent(k, k -> 新建) | 没有就建一个并存进去 | 「先查再建再放」三行 |
merge(k, 1, Integer::sum) | 没有就放 1,有就合并 | 计数的标准写法 |
putIfAbsent(k, v) | 只在键不存在时放 | 先 containsKey 再 put |
forEach((k, v) -> ...) | 遍历 | entrySet 循环 |
注意 getOrDefault 不会写回(调用后 map 仍然是空的),它只是「读的时候给个兜底」。要写回用 computeIfAbsent。
三种实现的区别
HashMap:迭代顺序不保证(按 banana/apple/cherry/date 顺序放进去,遍历出来是 banana/date/apple/cherry)。最快,默认选它。LinkedHashMap:保持插入顺序。需要「输出顺序稳定」时用它(比如生成配置文件、给人看的报表)。TreeMap:按键排序,还能做范围查询(headMap/tailMap/firstKey)。代价是查找从常数时间变成对数时间。
遍历的三种写法
map.forEach((k, v) -> ...)—— 最简洁,推荐。for (var e : map.entrySet())—— 需要break/continue时用。for (var k : map.keySet())再map.get(k)—— 别这么写,每次都要重新查一遍。
// 计数:merge 是最短的正确写法
var 词频 = new HashMap<String, Integer>();
for (var 词 : 所有词) {
词频.merge(词, 1, Integer::sum); // 没有就放 1,有就加 1
}
// 老写法:
// 词频.put(词, 词频.getOrDefault(词, 0) + 1); 也对,但 merge 更直接
// 一对多:computeIfAbsent
var 分组 = new HashMap<Character, List<String>>();
for (var 名 : 名单) {
分组.computeIfAbsent(名.charAt(0), c -> new ArrayList<>()).add(名);
}
// 老写法要三行:if (!分组.containsKey(k)) 分组.put(k, new ArrayList<>()); 分组.get(k).add(名);
// 读取:三种兜底方式
int 分数 = 表.getOrDefault("小明", 0); // 不写回(调用后表还是空的)
表.putIfAbsent("小明", 0); // 写回,但已有值时不动
int 分数2 = 表.computeIfAbsent("小明", k -> 0); // 写回并返回
// 遍历
表.forEach((名, 分) -> System.out.printf("%s: %d%n", 名, 分));
for (var 项 : 表.entrySet()) { // 需要 break 时用这个
if (项.getValue() > 90) { System.out.println(项.getKey()); break; }
}
// 按值排序输出前十(很常见的需求)
词频.entrySet().stream()
.sorted(Map.Entry.<String, Integer>comparingByValue().reversed())
.limit(10)
.forEach(e -> System.out.printf("%-15s %d%n", e.getKey(), e.getValue()));
// HashMap 允许 null 键和 null 值,但 Map.of 不允许
表.put(null, 1); // ✓ HashMap 可以
// Map.of(null, 1); ✗ NullPointerExceptionHashMap 的迭代顺序不保证,但它在一次运行内是稳定的——这会让人误以为可以依赖它。同样的四个键,HashMap 遍历出来的顺序和插入顺序不同,但每次运行都一样;而 Map.of(...) 的迭代顺序每次运行都可能不同(它故意加了随机化,就是为了防止有人依赖顺序)。后果:用 HashMap 生成的输出在你机器上一直是那个顺序,换台机器、换个 JDK 版本、或者多插入一个元素就变了。要顺序稳定就用 LinkedHashMap,别赌。equals/hashCode(06 章),而且不能可变。最省事的做法是用 record 当复合键:record 位置(int 行, int 列) {} 然后 Map<位置, 格子>——比拼字符串键("3,4")类型安全,比嵌套 Map<Integer, Map<Integer, 格子>> 好写一百倍。在 for-each 循环里增删集合元素,是新手最常撞的运行期错误之一。但更麻烦的是:它不总是报错。
两种结果
| 操作 | 结果 |
|---|---|
[a,b,c] 遍历中删 "a"(第一个) | 抛 ConcurrentModificationException |
[a,b,c] 遍历中删 "b"(倒数第二个) | 不抛异常,静默结束,结果是 [a,c] |
为什么:for-each 底层是迭代器,每次 next() 前会检查「集合被改过没有」。而删掉倒数第二个元素后,hasNext() 判断「当前位置 == 新的 size」直接返回 false,循环正常结束,那个检查根本没机会跑。
这比「一定报错」危险得多:你的测试数据恰好删中间元素时一切正常,上线后删了别的位置就崩。
四种正确写法
list.removeIf(条件)——最简洁,Java 8 起。删除的首选。- 显式迭代器 +
it.remove():需要在删除时做别的事情(记日志、统计)时用。 - 倒序 for 循环(按下标):从后往前删,下标不会错位。
- 收集到新集合:
list.stream().filter(...).toList()—— 不改原集合,最安全。
Map 上同理
map.entrySet().removeIf(e -> ...),或者用 map.values().removeIf(...)。直接在 forEach 里 put/remove 同样会出问题(除了 ConcurrentHashMap,13 章)。
// ✗ 危险:结果取决于删的是哪个位置
var 列表 = new ArrayList<>(List.of("a", "b", "c"));
for (String x : 列表) {
if (x.equals("b")) 列表.remove(x); // 删 b:静默成功;删 a:抛 CME
}
// ✓ 首选:removeIf
列表.removeIf(x -> x.equals("b"));
列表.removeIf(String::isBlank);
列表.removeIf(p -> p.血量() <= 0); // 游戏循环里清理死亡单位
// ✓ 需要在删除时做别的事:显式迭代器
var it = 列表.iterator();
while (it.hasNext()) {
var x = it.next();
if (该删(x)) {
记录("删除了 " + x);
it.remove(); // 用迭代器自己的 remove
}
}
// ✓ 边遍历边加:不能改原集合,收集到新的
var 新增 = new ArrayList<String>();
for (var x : 列表) {
if (要分裂(x)) 新增.add(x + "-副本");
}
列表.addAll(新增); // 循环结束后一起加
// ✓ 最安全:不改原集合,产生新的(11 章)
var 活着的 = 列表.stream().filter(p -> p.血量() > 0).toList();
// Map 上的删除
表.entrySet().removeIf(e -> e.getValue() < 60);
表.values().removeIf(Objects::isNull);ConcurrentModificationException 这个名字有误导性:它不是多线程专属。单线程里自己边遍历边改照样抛。反过来,多线程下改集合的后果比这个异常严重得多——四个线程同时往一个 HashMap 里写 20 万条,最后 size() 只有 8 万多(数据直接丢了,不抛任何异常);换成 ConcurrentHashMap 是准确的 20 万。所以这个异常其实是好事:它是快速失败机制,把「可能已经出错」在第一时间喊出来。真正可怕的是那些不抛异常的场景。ConcurrentModificationException(集合结构没变),但如果这个字段参与了 hashCode,而元素在 HashSet 或作为 Map 的键,那这个元素就「丢了」(06 章有)。集合里的元素,凡是参与哈希的字段就当成只读的。排序在 Java 里是「给一个比较规则」的事。Comparator 的链式工厂方法(Java 8 起)让复杂排序规则变得非常好写,值得一次掌握。
三种排序入口
list.sort(比较器)—— 原地排序,改的是这个列表。Collections.sort(list)—— 老写法,等价于上面。stream().sorted(比较器).toList()—— 产生新列表,原列表不动(11 章)。
List.of(...) 得到的不可变列表不能原地排序(抛 UnsupportedOperationException),只能用 Stream 那条路。
Comparator 的链式构造
| 写法 | 意思 |
|---|---|
comparing(玩家::分数) | 按分数升序 |
.reversed() | 反过来(降序) |
.thenComparing(玩家::名字) | 分数相同时按名字 |
comparingInt(...) | 基本类型专用,避免装箱 |
nullsFirst(comparator) | null 排前面(否则 NPE) |
naturalOrder() / reverseOrder() | 用元素自己的 compareTo |
两个规范保证
- Java 的对象排序是稳定的:值相等的元素保持原有相对顺序。这是规范要求,可以依赖(先按名字排、再按分数排,分数相同的就还是按名字有序的)。
- 比较器必须自洽:如果
a > b且b > c,那必须a > c。不自洽会抛IllegalArgumentException: Comparison method violates its general contract!——最常见的原因是用减法比较整数时溢出了(用Integer.compare就没这问题)。
// 链式构造比较器:读起来像自然语言
名单.sort(Comparator
.comparingInt(玩家::分数).reversed() // 分数从高到低
.thenComparing(玩家::名字)); // 分数相同按名字
// 按多个键,其中一个可能为 null
名单.sort(Comparator.comparing(玩家::公会,
Comparator.nullsLast(Comparator.naturalOrder())));
// 自然顺序:元素自己实现 Comparable
record 版本(int 主, int 次) implements Comparable<版本> {
@Override public int compareTo(版本 o) {
return 主 != o.主 ? Integer.compare(主, o.主)
: Integer.compare(次, o.次);
// 用 Integer.compare 而不是 主 - o.主 —— 后者会溢出
}
}
Collections.sort(版本列表); // 用自然顺序
// 不改原列表:走 Stream
var 排好的 = 名单.stream()
.sorted(Comparator.comparing(玩家::名字))
.toList();
// 排序 + 取前 N(Top-K 的标准写法)
var 前十 = 名单.stream()
.sorted(Comparator.comparingInt(玩家::分数).reversed())
.limit(10)
.toList();
// 中文按拼音排(04 章)
名单.sort(Comparator.comparing(玩家::名字, Collator.getInstance(Locale.CHINA)));
// 数组排序
int[] 数字 = {3, 1, 2};
Arrays.sort(数字); // 基本类型:只能自然顺序
Arrays.sort(对象数组, Comparator.comparing(...)); // 对象数组:可以给比较器(a, b) -> a.分数 - b.分数)是个流传很广的坏习惯。分数一大就会整数溢出,比较结果符号反转,排序结果错乱——而且只在特定数据上出现。更糟的是,如果溢出导致比较器不自洽,sort 会直接抛 IllegalArgumentException: Comparison method violates its general contract!,这个异常在生产环境里非常难复现(要凑到特定的数据分布)。一律用 Integer.compare(a, b)、Double.compare,或者更好——直接用 Comparator.comparingInt。comparingInt/comparingLong/comparingDouble 而不是 comparing,当键是基本类型时。后者会把每个键装箱成 Integer,排一次序产生 n 个临时对象。这是个免费的优化:改一个方法名的事,语义完全一样。Java 里「不可变集合」有好几种,它们的语义不一样,用错了会得到一个「以为不会变、其实会变」的对象。这一卡把它们分清。
三种「不可变」
| 写法 | 能改吗 | 底层数据被别人改了会怎样 |
|---|---|---|
List.of(a, b) | 不能(抛 UOE) | 没有底层,真不可变 |
List.copyOf(原) | 不能 | 拷贝了一份,不受影响 |
Collections.unmodifiableList(原) | 不能通过它改 | 是个视图!原列表改了它跟着变 |
第三行是最容易误用的:unmodifiableList 只是「包了一层,挡住写方法」,原来那个引用还能改。要真正切断联系必须用 List.copyOf。
List.of 家族的几个约束
- 不允许
null元素:List.of(1, null)抛 NPE。这是有意为之——它逼你面对「这里为什么会有 null」。 - 改任何东西都抛
UnsupportedOperationException,而且异常消息是null(什么提示都没有),第一次遇到会很困惑。 Map.of最多 10 对,更多要用Map.ofEntries(Map.entry(k, v), ...)。Map.of的迭代顺序每次运行都不同(故意随机化,防止你依赖它)。
什么时候用哪个
- 常量集合:
static final List<String> 方向 = List.of("上","下"); - 方法返回值:
return List.copyOf(内部列表);—— 调用方拿到快照,改不了你的内部状态(05 章)。 - 方法参数:接受
List<String>就好,别要求不可变——那是调用方的事。 - 需要能改:
new ArrayList<>(List.of(...))。
// 三者的区别
var 原始 = new ArrayList<>(List.of("a"));
var 拷贝 = List.copyOf(原始); // 拷了一份
var 视图 = Collections.unmodifiableList(原始); // 只是包了一层
原始.add("b");
// 拷贝 → [a] ✓ 不受影响
// 视图 → [a, b] ✗ 跟着变了!
// 不可变集合的写操作
List.of(1, 2).add(3); // UnsupportedOperationException(消息是 null)
List.of(1, null); // NullPointerException —— 不接受 null
// Stream 的两种 toList 也不一样
Stream.of(1).toList() // 不可变(Java 16 起)
Stream.of(1).collect(Collectors.toList()) // 可变的 ArrayList
// 常量集合的标准写法
private static final Set<String> 支持的后缀 = Set.of("png", "jpg", "gif");
private static final Map<String, Integer> 权重 = Map.of(
"低", 1, "中", 5, "高", 10);
// 超过 10 对时
private static final Map<String, String> 表 = Map.ofEntries(
Map.entry("a", "1"),
Map.entry("b", "2"));
// 返回内部集合的正确姿势(05 章)
public List<String> 物品() {
return List.copyOf(内部物品); // 快照,调用方改不了也看不到后续变化
}List.of(某个可变对象) 里那个对象照样能被改——集合只是不让你增删和替换元素。要真正的不可变,元素本身也得是不可变的(record + 防御性拷贝,05、07 章)。这条在多线程下尤其重要:一个 List.of(可变对象) 在多个线程间共享,仍然是有竞争的(13 章)。List.of() 对 1 个和 2 个元素有专门的实现类(没有数组、没有多余字段),Set.of 和 Map.of 也做了类似优化。所以「常量集合用 List.of」不只是语义更准,性能上也是免费的。Lambda 与 Stream
Java 8 加进来的这套东西,把「遍历集合做点什么」从循环变成了描述。它不会让代码更快,但会让「这段代码到底在干嘛」变得一眼可见——前提是知道它的惰性、复用限制和几个真会咬人的地方。
lambda 是「把一段行为当成值传来传去」的语法。Java 的 lambda 本质上是「实现了某个只有一个抽象方法的接口的对象」——理解这一点,所有规则就都顺了。
函数式接口:一个抽象方法就够
- 任何只有一个抽象方法的接口都能用 lambda 实现(可以有任意多个
default/static方法)。 @FunctionalInterface注解不是必需的,但写上编译器会帮你检查(加了第二个抽象方法时当场报错)。- lambda 的参数类型和返回类型由目标接口决定,所以同一段
x -> x * 2可以是Function<Integer,Integer>,也可以是IntUnaryOperator——看你把它赋给谁。
标准库里最常用的六个
| 接口 | 签名 | 典型用途 |
|---|---|---|
Function<T,R> | T → R | map |
Predicate<T> | T → boolean | filter、removeIf |
Consumer<T> | T → void | forEach |
Supplier<T> | () → T | 延迟求值、orElseGet |
BiFunction<T,U,R> | (T,U) → R | merge |
UnaryOperator<T> | T → T | replaceAll |
另外还有一整套基本类型特化版本(IntPredicate、ToIntFunction、IntSupplier…),它们的存在是为了避免装箱——泛型不能用基本类型(09 章)留下的疤。
捕获规则:只能捕获「实际上的 final」
- lambda 里用到的局部变量,必须是
final或赋值后再没被改过。改了就编译错:local variables referenced from a lambda expression must be final or effectively final(报错原文)。 - 为什么:lambda 可能在方法返回之后才执行(放进集合、传给另一个线程),那时局部变量早就没了——所以它捕获的是值的副本,允许修改会让人误以为改的是外面那个。
- 绕法:用数组
int[] 计数 = {0};、用AtomicInteger、或者更好——换个写法(用Stream的count()/reduce代替手动累加)。 - 字段没有这个限制:lambda 里改
this.计数完全合法。
// lambda 的四种写法(越往下越短)
Comparator<String> c1 = (String a, String b) -> { return a.length() - b.length(); };
Comparator<String> c2 = (a, b) -> a.length() - b.length(); // 类型可推断
Predicate<String> 空的 = s -> s.isBlank(); // 单参数可省括号
Supplier<List<String>> 工厂 = ArrayList::new; // 方法引用(下一卡)
// 自定义函数式接口
@FunctionalInterface
interface 伤害计算 {
int 算(int 攻击, int 防御); // 唯一的抽象方法
default 伤害计算 加暴击(double 倍) { // default 方法不算
return (a, d) -> (int) (算(a, d) * 倍);
}
}
伤害计算 普通 = (攻, 防) -> Math.max(1, 攻 - 防);
伤害计算 暴击 = 普通.加暴击(2.0); // 组合出新行为
// 把行为当参数:这就是 lambda 的全部意义
static void 重试(int 次数, Runnable 动作) {
for (int i = 0; i < 次数; i++) {
try { 动作.run(); return; }
catch (Exception e) { System.out.println("第 " + (i+1) + " 次失败"); }
}
}
重试(3, () -> 下载(地址));
// 捕获规则:改了就编译不过
int 计数 = 0;
// 列表.forEach(x -> 计数++); ✗ must be final or effectively final
// 绕法一:用数组(能用但难看)
int[] 计数2 = {0};
列表.forEach(x -> 计数2[0]++);
// 绕法二(更好):换个写法
long 计数3 = 列表.stream().filter(条件).count();
// 基本类型特化:避免装箱
IntPredicate 是偶数 = n -> n % 2 == 0; // 不装箱
Predicate<Integer> 也行但会装箱 = n -> n % 2 == 0;列表.forEach(p -> Files.writeString(p, "x")); 编译不过——Consumer.accept 没有声明 throws IOException(12 章)。三条出路:① 在 lambda 内部 try/catch 并包成运行期异常(最常见);② 改用普通的 for 循环(往往是最清楚的选择);③ 自己定义一个允许抛异常的函数式接口再做转换(样板多,除非重复出现很多次否则不值)。不要因为想用 Stream 就把异常吞掉。this 指外围类的实例,匿名类里的 this 指匿名对象自己(05 章验证过)。这个差别在写事件回调时偶尔关键——lambda 的行为通常才是你想要的(能直接访问外围类的字段和方法),这也是它比匿名类更顺手的原因之一。当 lambda 的全部内容就是「调用一个已有的方法」时,可以用 :: 写得更短。它不是新功能,只是语法糖,但用对了确实更好读。
四种形式
| 形式 | 写法 | 等价的 lambda |
|---|---|---|
| 静态方法 | Integer::parseInt | s -> Integer.parseInt(s) |
| 特定对象的方法 | System.out::println | x -> System.out.println(x) |
| 任意对象的方法 | String::toUpperCase | s -> s.toUpperCase() |
| 构造器 | ArrayList::new | () -> new ArrayList<>() |
第三种最容易困惑:String::toUpperCase 看起来没有接收者,实际上第一个参数就是接收者。玩家::名字(record 的访问器)也是这一类,在 Comparator.comparing(玩家::名字) 里到处出现。
什么时候用它
- 用:lambda 体只有一次方法调用,且参数原样传递。
.map(String::strip)比.map(s -> s.strip())少一层噪音。 - 别用:需要调整参数顺序、需要多做一点事、或者方法名本身表达不清意图时——
.map(x -> 处理(x, 配置))就该保持 lambda。 - 可读性优先:
::不总是更清楚。.filter(Objects::nonNull)很好读;某些嵌套的类::方法就不如显式写出参数。
// 四种形式
List<String> 文本 = List.of(" 1 ", "2", " 3");
文本.stream().map(String::strip) // 任意对象的实例方法
.map(Integer::parseInt) // 静态方法
.forEach(System.out::println); // 特定对象的方法
Supplier<ArrayList<String>> 工厂 = ArrayList::new; // 构造器
// record 的访问器:排序和分组时到处用
名单.sort(Comparator.comparing(玩家::名字));
名单.stream().collect(Collectors.groupingBy(玩家::公会));
// 常见的几个
.filter(Objects::nonNull)
.filter(String::isBlank)
.map(Object::toString)
.flatMap(List::stream)
.reduce(0, Integer::sum)
.collect(Collectors.toMap(玩家::编号, Function.identity()))
// 构造器引用配合 map:字符串 → 对象
record 标签(String 文本) {}
var 标签们 = 文本.stream().map(标签::new).toList();
// ✗ 别硬套:这些保持 lambda 更清楚
.map(x -> 处理(x, 配置)) // 有额外参数
.map(x -> x.名字().toUpperCase()) // 两步操作
.sorted((a, b) -> b.分数() - a.分数()) // 顺序反过来(而且这里该用 Integer.compare)类名::实例方法 和 对象::实例方法 长得一样,含义完全不同,编译器靠上下文区分。这偶尔会产生令人困惑的报错:当一个类同时有静态方法 f(X) 和实例方法 f() 时,类::f 有歧义,编译器会报 reference to f is ambiguous。遇到这种报错,退回显式 lambda 就好——方法引用是可选的糖,不值得为它跟编译器较劲。Function.identity() 就是 x -> x,在 Collectors.toMap 里很常见(「键取某个字段,值就是对象本身」)。写成 x -> x 也完全一样,甚至更好读——identity() 唯一的实际优势是它返回同一个共享实例,在极高频调用时少产生一点垃圾。日常两种都行。一条 Stream 流水线永远是三段:从某个源创建 → 若干个中间操作 → 一个终端操作。理解「中间操作只是在记账,终端操作才真正跑」是用好它的关键。
惰性到什么程度
| 代码 | peek 实际执行了几次 |
|---|---|
Stream.of(1,2,3).peek(收集).map(x->x*2)(没有终端操作) | 0 次——整条链一行没跑 |
同上,末尾加 .toList() | 3 次 |
Stream.of(1,2,3,4).peek(收集).filter(x->x>1).findFirst() | 只看了 [1, 2] 就停了(短路) |
Stream.of(1,2,3).peek(收集).count() | 0 次——JDK 知道元素个数,直接跳过整条链 |
最后一行值得注意:count() 在能确定元素数量时会跳过所有不改变数量的中间操作。所以千万别在 peek 里做有副作用的事然后指望它一定执行。
三段各有什么
- 创建:
集合.stream()、Arrays.stream(数组)、Stream.of(a,b,c)、IntStream.range(0,n)、Files.lines(路径)、字符串.lines()、Stream.iterate(种子, 下一个)。 - 中间(返回 Stream,惰性):
filter、map、flatMap、sorted、distinct、limit、skip、takeWhile、dropWhile、peek。 - 终端(触发执行):
toList、collect、forEach、count、reduce、anyMatch、findFirst、min/max、sum。
一条流只能消费一次
同一个 Stream 变量调两次终端操作,第二次抛 IllegalStateException: stream has already been operated upon or closed。需要多次遍历就先 toList() 存下来,或者从源头重新创建一条流。
// 三段式
var 结果 = 名单.stream() // ① 创建
.filter(p -> p.分数() >= 60) // ② 中间(惰性,只是记账)
.map(玩家::名字)
.sorted()
.toList(); // ③ 终端(这一刻才真的跑)
// 惰性 + 短路:不会遍历完整个集合
var 找到 = 一百万个.stream()
.filter(p -> p.名字().equals("小明"))
.findFirst(); // 找到就停
// 各种创建方式
IntStream.rangeClosed(1, 100).sum() // 5050
Arrays.stream(数组)
Files.lines(路径) // 惰性读文件,记得 close(12 章)
文本.lines() // 按行切
Stream.iterate(1, x -> x * 2).limit(10) // 无限流 + limit
Stream.generate(Math::random).limit(5)
// flatMap:拍平嵌套结构
List<List<String>> 嵌套 = ...;
嵌套.stream().flatMap(List::stream).toList(); // 变成一层
// 把所有文件里的所有词拉平成一条流
Files.walk(目录)
.filter(Files::isRegularFile)
.flatMap(p -> { try { return Files.lines(p); } catch (IOException e) { return Stream.of(); } })
.flatMap(行 -> Arrays.stream(行.split("\\s+")))
.filter(w -> !w.isBlank())
.collect(Collectors.groupingBy(w -> w, Collectors.counting()));
// takeWhile / dropWhile(Java 9 起):按条件截断,比 filter 更适合有序数据
日志.stream().dropWhile(l -> !l.startsWith("ERROR")).toList();
// ✗ 流只能用一次
var s = Stream.of(1, 2);
s.toList();
// s.toList(); IllegalStateException: stream has already been operated upon or closedpeek 是用来调试的,别拿它做正事。它的文档明确说「主要用于调试」,而证明了原因:count() 会直接跳过它(0 次执行),某些优化路径下 peek 也可能被跳过或执行次数与预期不符。想在流水线中间做副作用(写日志、更新计数),用 map 里显式处理,或者干脆改用 forEach。另外 Files.lines() 返回的流持有文件句柄,必须 close(放进 try-with-resources,12 章),这是个很容易漏的资源泄漏。IntStream/LongStream/DoubleStream 是「不装箱」的专用流,有 sum()、average()、max() 这些 Stream<T> 没有的方法。处理数字时优先用它们:list.stream().mapToInt(玩家::分数).average()。回到对象流用 boxed(),反过来用 mapToObj()。collect 是最强的终端操作,配合 Collectors 里的工厂方法能做出各种汇总。其中 groupingBy 是最值钱的一个——它把「按某个键分组统计」这件事从十行循环变成一行。
最常用的七个
| 写法 | 得到 |
|---|---|
.toList() | 不可变 List(Java 16 起,最常用) |
.collect(Collectors.toList()) | 可变 ArrayList |
.collect(Collectors.toSet()) | Set(去重) |
.collect(joining(", ", "[", "]")) | 拼成字符串(带分隔符和首尾) |
.collect(groupingBy(键函数)) | Map<键, List<元素>> |
.collect(groupingBy(键, counting())) | Map<键, Long>(计数) |
.collect(toMap(键函数, 值函数)) | Map(键冲突会抛异常) |
分组可以套娃
groupingBy 的第二个参数还是一个收集器,所以可以嵌套:按公会分组 → 每组再按等级分组 → 最里层求平均分。三层嵌套仍然是一个表达式,写循环要二十行。
toMap 的两个陷阱
- 键冲突直接抛异常:
IllegalStateException: Duplicate key a (attempted merging values aa and ab)(报错原文)。必须提供第三个参数(合并函数)才能处理重复。 - 值为 null 会抛 NPE——即使
HashMap本身允许 null 值。这是toMap内部用merge实现的副作用。值可能为 null 时改用groupingBy或手动收集。
// 基本收集
var 名字们 = 名单.stream().map(玩家::名字).toList();
var 一行 = 名单.stream().map(玩家::名字)
.collect(Collectors.joining(", ", "[", "]")); // [小明, 小红]
// 分组:这是 Collectors 里最值钱的一个
Map<String, List<玩家>> 按公会 =
名单.stream().collect(Collectors.groupingBy(玩家::公会));
// 分组 + 统计({a=2, b=1} 这种结果)
Map<String, Long> 每个公会几人 =
名单.stream().collect(Collectors.groupingBy(玩家::公会, Collectors.counting()));
Map<String, Double> 每个公会平均分 =
名单.stream().collect(Collectors.groupingBy(玩家::公会,
Collectors.averagingInt(玩家::分数)));
// 套娃:公会 → 等级 → 名字列表
Map<String, Map<Integer, List<String>>> 两层 =
名单.stream().collect(Collectors.groupingBy(玩家::公会,
Collectors.groupingBy(玩家::等级,
Collectors.mapping(玩家::名字, Collectors.toList()))));
// 二分:partitioningBy(键只有 true/false,比 groupingBy 高效)
Map<Boolean, List<玩家>> 及格与否 =
名单.stream().collect(Collectors.partitioningBy(p -> p.分数() >= 60));
// toMap:必须处理键冲突(不处理会抛 IllegalStateException)
var 编号到玩家 = 名单.stream()
.collect(Collectors.toMap(玩家::编号, p -> p)); // 编号唯一才安全
var 首字母到名字 = 名单.stream()
.collect(Collectors.toMap(p -> p.名字().charAt(0), 玩家::名字,
(旧, 新) -> 旧 + "|" + 新)); // 第三个参数:合并
// 一次算完所有统计量
IntSummaryStatistics 统计 = 名单.stream()
.mapToInt(玩家::分数).summaryStatistics();
统计.getMax(); 统计.getAverage(); 统计.getCount();
// 收集到指定的集合类型
.collect(Collectors.toCollection(TreeSet::new)) // 要排序的 Set
.collect(Collectors.toCollection(LinkedHashSet::new))stream().toList() 返回的是不可变列表,而 collect(Collectors.toList()) 返回可变的 ArrayList——这两个长得几乎一样的写法,行为不同。把老代码里的 collect(toList()) 机械替换成 toList() 时,如果后面有人往结果里 add,就会在运行期抛 UnsupportedOperationException(而且消息是 null,10 章讲过)。默认用 toList()(不可变更安全),确实需要继续修改时才用 collect(Collectors.toList()) 或 new ArrayList<>(结果)。groupingBy,别写循环。词频统计、按扩展名统计文件数、按小时统计日志量、按公会算平均分——这些全是一行。而且分组的结果类型是编译器推出来的,写完看一眼 IDE 提示的类型就知道对不对。Stream 好用,但它有几个「写得出来、跑起来错」的地方。这一卡把验证过的坑集中起来,并回答那个最常被问的问题:parallel() 到底该不该加。
坑一:在 Stream 里做副作用
IntStream.range(0, 10000).parallel().forEach(结果::add)(往普通 ArrayList 里加)——期望 10000 个元素,实际得到 7999,而且每次运行结果都不同,有时还会直接抛异常。
- 原因:
ArrayList不是线程安全的(13 章),多个线程同时add会互相覆盖。 - 正确写法:用
collect/toList()让框架负责合并——同一个实验换成.boxed().toList()得到准确的 10000。 - 这条规则对串行流也成立:往外部集合里
add能跑通,但它违背了 Stream 的设计意图,也让代码没法安全地并行化。
坑二:parallel() 常常更慢
一千万个 int 求和,普通 for 约 10 ms、stream 约 11 ms、parallel 约 17 ms——并行反而最慢(拆分、调度、合并的开销盖过了收益)。
- 并行流用的是公共 ForkJoinPool(全 JVM 共享),线程数默认是 CPU 核数减一。一个慢任务会拖累整个进程里所有用并行流的地方。
- 什么时候真的划算:元素多(至少上万)、每个元素的处理成本高(不是简单加法)、数据源易于拆分(数组、
ArrayList好拆,LinkedList和Files.lines难拆)、且不涉及 IO 阻塞。 - 判断办法只有一个:量一下。不量就加
parallel()是纯粹的迷信。
什么时候别用 Stream
- 只是遍历做副作用:普通 for 循环更直接(
for (var x : 列表) 打印(x);比列表.forEach(this::打印)也没差,但涉及break/continue/受检异常时循环完胜)。 - 需要提前跳出并做复杂控制流:Stream 只有
findFirst/anyMatch这类有限的短路。 - 要改的是原集合:
removeIf比过滤再赋值清楚。 - 一行能写完的简单循环:为了「显得现代」而把三行循环写成五行流水线,是负收益。
// ✗ 副作用:并行时直接丢数据(10000 变 7999)
var 结果 = new ArrayList<Integer>();
IntStream.range(0, 10000).parallel().forEach(结果::add);
// ✓ 让框架负责收集(准确 10000)
var 结果2 = IntStream.range(0, 10000).parallel().boxed().toList();
// ✗ 修改流的数据源:行为未定义
var 列表 = new ArrayList<>(List.of("a", "b"));
列表.stream().forEach(x -> 列表.add(x + "!")); // CME 或死循环
// ✗ 排序无限流:永远不会结束
// Stream.iterate(1, x -> x + 1).sorted().limit(10);
// ✓ 什么时候 parallel 可能划算:元素多 + 每个都很贵
var 缩略图 = 一万张图片.parallelStream()
.map(这个操作::很耗 CPU)
.toList();
// 但仍然要量一遍:System.nanoTime() 前后一夹就知道
// ✗ 别用 parallel 做 IO(14 章的虚拟线程才是答案)
// 网址.parallelStream().map(这个会阻塞::下载) ← 会占满公共线程池
// 什么时候普通循环更好
for (var 行 : 所有行) { // 需要 break + 受检异常
if (行.startsWith("#")) continue;
if (行.equals("END")) break;
Files.writeString(输出, 行); // 受检异常,lambda 里写起来很难看
}
// 量一下再决定(16 章有更靠谱的量法)
long t = System.nanoTime();
var r = 数据.stream().map(this::处理).toList();
System.out.printf("%.1f ms%n", (System.nanoTime() - t) / 1e6);parallel() 用的是全 JVM 共享的公共 ForkJoinPool,这是它最危险的一点。你在一个地方提交了一个跑得很慢(或者阻塞在 IO 上)的并行流,整个进程里所有其它并行流都会跟着变慢——包括某些库内部悄悄用了并行流的地方。如果一定要并行 CPU 密集任务,把它提交到自己的 ForkJoinPool 里跑(myPool.submit(() -> 流.parallel()...).get());如果任务是 IO 阻塞的,根本不该用并行流,用虚拟线程(14 章)。过滤 → 变形 → 汇总 用 Stream;逐个处理并产生副作用 用循环。两者混用完全正常,不必强求一个项目里只有一种风格。Stream 的中间操作长期是一个封闭的集合——想要「每 N 个一批」「滑动窗口」「带状态地去重」,只能跳出 Stream 手写。Stream.gather()(JDK 24 起正式)补上了这个口子:它之于中间操作,就像 Collector 之于终端操作。
四个内置的 Gatherer(输出)
| 写法 | 输入 [1,2,3,4,5] 的输出 |
|---|---|
Gatherers.windowFixed(2) | [[1,2],[3,4],[5]]——定长分批 |
Gatherers.windowSliding(2) | [[1,2],[2,3],[3,4],[4,5]]——滑动窗口 |
Gatherers.scan(()->0, Integer::sum) | [1,3,6,10,15]——累积前缀和 |
Gatherers.fold(()->0, Integer::sum) | [15]——折叠成一个 |
它解决的真实问题
- 批量处理:一次插一千条数据库、一次发一百条消息——
windowFixed(1000)直接给你分好批,而且是惰性的(不用先把全部数据读进内存)。 - 相邻元素比较:算日增长率、找连续上升段、检测重复行——
windowSliding(2)。 - 带状态的过滤:「同一个用户只保留第一条」这类需求,以前得在 lambda 外面放一个
Set(副作用,不能并行),现在可以写成自定义 Gatherer。
自己写一个
Gatherer 由三部分组成:初始状态(可选)、integrator(收到一个元素怎么办,返回 false 表示提前结束)、finisher(流结束时还有什么要发出去)。比写 Collector 简单,因为不需要考虑并行合并(除非你想支持)。
// 内置的四个(输出见上表)
import static java.util.stream.Gatherers.*;
Stream.of(1,2,3,4,5).gather(windowFixed(2)).toList();
// [[1, 2], [3, 4], [5]]
Stream.of(1,2,3,4).gather(windowSliding(2)).toList();
// [[1, 2], [2, 3], [3, 4]]
// 实用场景一:分批处理,惰性且不占内存
Files.lines(大文件)
.gather(windowFixed(1000))
.forEach(一批 -> 批量写入(一批)); // 每一千行写一次
// 实用场景二:相邻比较,算日增长
每日人数.stream()
.gather(windowSliding(2))
.map(两天 -> 两天.get(1) - 两天.get(0))
.toList();
// 实用场景三:累积(进度、余额、running total)
支出.stream().gather(scan(() -> 0, Integer::sum)).toList();
// 自己写一个:按某个键只保留第一次出现的(去重但保序)
static <T, K> Gatherer<T, ?, T> 按键去重(Function<T, K> 键) {
return Gatherer.ofSequential(
HashSet::<K>new, // 初始状态
(见过, 元素, 下游) -> { // 每个元素怎么办
if (见过.add(键.apply(元素))) 下游.push(元素);
return true; // false 表示提前结束整条流
});
}
玩家们.stream().gather(按键去重(玩家::公会)).toList(); // 每个公会留一个代表
// 提前结束的例子:取到第一个负数为止
Gatherer.<Integer, Void, Integer>of((无状态, n, 下游) -> n >= 0 && 下游.push(n));IntStream.range(0, (n+999)/1000).mapToObj(i -> list.subList(...)) ——能用但要求数据已经全在内存里。windowFixed 的优势是惰性:配合 Files.lines 可以流式地分批处理一个几个 GB 的文件,内存里始终只有一批。这是 Gatherers 最实用的一个用途。异常、资源与 Optional
程序总会遇到「不该发生的事发生了」。Java 用异常表达它,用 try-with-resources 保证清理,用 Optional 表达「可能没有值」。这一章讲清三者各自的位置,以及那条最有争议的规则——受检异常。
Java 的异常分三支,区别不在「有多严重」,而在「编译器管不管你」。
三支
| 类型 | 编译器强制处理吗 | 典型 | 你该怎么办 |
|---|---|---|---|
Error | 不 | StackOverflowError、OutOfMemoryError | 不要捕获——JVM 已经不正常了 |
| 受检异常 ( Exception 的非运行时子类) | 是 | IOException、SQLException、InterruptedException | 必须 catch 或在签名上 throws |
| 非受检异常 ( RuntimeException 及其子类) | 不 | NullPointerException、IllegalArgumentException、IllegalStateException | 通常是 bug,修代码而不是 catch |
受检异常:Java 独有,也是争议最大的设计
- 意图:让「这个操作可能失败」出现在类型签名里,调用方不可能忽略。不处理直接编译错——
unreported exception IOException; must be caught or declared to be thrown。 - 实践中的问题:它会沿着调用链传染(一路
throws),而且和 lambda 完全不兼容(11 章)——于是大量代码用catch (Exception e) {}把它吞掉,反而比没有更糟。 - 后来的语言(C#、Kotlin、Go)都没有采用它,Java 自己的新 API 也在减少使用。
- 今天的实用立场:你自己定义的异常,除非调用方真的能做点什么来恢复,否则一律用非受检(继承
RuntimeException)。
三个最常用的标准异常,别自己造
IllegalArgumentException—— 参数不合法(调用方传错了)。IllegalStateException—— 对象当前状态不允许这个操作(调用时机不对)。NullPointerException—— 用Objects.requireNonNull(x, "说明")主动抛,比等它在三层之后自然发生好得多。
// 受检异常:编译器强制处理(不处理直接编译错)
void 读文件() {
// String s = Files.readString(p); ✗ unreported exception IOException
try {
String s = Files.readString(p);
} catch (IOException e) {
// 要么在这里处理
}
}
void 读文件2() throws IOException { // 要么往上抛
String s = Files.readString(p);
}
// 主动检查参数:在错误发生的地方抛,而不是等它蔓延
public 玩家(String 名字, int 血量) {
this.名字 = Objects.requireNonNull(名字, "名字不能为空");
if (血量 <= 0)
throw new IllegalArgumentException("血量必须为正:" + 血量);
// 消息里带上实际值 —— 这是最有用的调试信息
}
// 状态不对用 IllegalStateException
void 开始游戏() {
if (玩家数 < 2)
throw new IllegalStateException("至少要 2 个玩家,当前 " + 玩家数);
}
// 自定义异常:继承 RuntimeException(除非调用方真能恢复)
public class 存档损坏 extends RuntimeException {
private final Path 文件;
public 存档损坏(Path 文件, Throwable 原因) {
super("存档损坏:" + 文件, 原因); // 一定要传 cause!
this.文件 = 文件;
}
public Path 文件() { return 文件; } // 带上结构化信息,方便调用方处理
}
// 捕获多个类型:一个 catch 搞定
try {
干活();
} catch (IOException | InterruptedException e) {
System.err.println("失败:" + e.getMessage());
}catch (Exception e) { }(空的 catch 块)是 Java 代码里最有害的一行。它把问题彻底藏起来:程序继续跑,但状态已经不对,真正的崩溃会发生在几百行之外,堆栈里完全看不到根因。最低限度也要打印出来:e.printStackTrace()(调试时)或记进日志。还有一个几乎同样糟的变体:catch (Exception e) { throw new RuntimeException("出错了"); }——丢掉了 cause,堆栈里只剩一句没用的「出错了」。包装异常时永远把原异常作为第二个参数传进去。throw new IllegalArgumentException("血量不合法") 和 "血量必须为正:-5" 的调试成本差十倍。标准库在这方面做得很好,可以照着学:NumberFormatException: For input string: "12a"、Index 5 out of bounds for length 3、Duplicate key a (attempted merging values aa and ab)——每一条都能让你直接定位问题。凡是「用完要关」的东西——文件、网络连接、数据库连接——都该用 try-with-resources。它不只是省几行,它还修掉了手写 finally 的一个真实缺陷。
两个资源 + 主体抛异常 + close 也抛异常
open A open B close B // ← 关闭顺序和创建顺序相反 close A catch: 主体炸了 // ← 主异常保留 suppressed=[close 炸了 B, close 炸了 A] // ← 关闭时的异常挂在它下面
- 关闭顺序与创建顺序相反(后创建的先关),这符合依赖关系。
- 主体的异常被保留,关闭时的异常挂进
getSuppressed()——两个信息都不丢。 - 手写 finally 做不到这一点:
finally { r.close(); }里如果close抛异常,它会覆盖掉主体那个真正重要的异常,你只能看到一句「关闭失败」,完全不知道原本出了什么事。
用法要点
- 资源类型必须实现
AutoCloseable(标准库里所有 IO 类、Stream、JDBC(Java 连数据库的标准接口)连接都实现了)。 - 多个资源用分号隔开,都会被关(前面的失败也不影响后面的关闭)。
- Java 9 起可以用已有的 final 变量:
try (已有的资源),不用非得在括号里新建。 Stream也是资源:Files.lines()、Files.walk()返回的流持有文件句柄,必须关——这是最容易漏的一个(普通集合的stream()不用关)。
finally 还剩什么用
清理不是「资源」的东西(恢复某个标志位、解锁、打日志)。但注意 finally 里的 return 会吞掉一切——try { return 1; } finally { return 2; } 返回 2,而且如果 try 里有异常也会被丢掉。永远不要在 finally 里写 return。
// 基本用法
try (var 读 = Files.newBufferedReader(路径)) {
String 行;
while ((行 = 读.readLine()) != null) 处理(行);
} // 自动 close,即使中间抛异常
// 多个资源:分号隔开,倒序关闭
try (var 输入 = Files.newInputStream(源);
var 输出 = Files.newOutputStream(目标)) {
输入.transferTo(输出);
}
// Stream 也是资源!这是最容易漏的一个
try (var 行 = Files.lines(路径)) {
return 行.filter(l -> l.contains(关键词)).count();
}
// 注意:普通集合的 .stream() 不需要关,只有 IO 相关的流才要
// 自己的类也能用:实现 AutoCloseable
class 计时 implements AutoCloseable {
private final long 起 = System.nanoTime();
private final String 名;
计时(String 名) { this.名 = 名; }
@Override public void close() {
System.out.printf("%s 用了 %.1f ms%n", 名, (System.nanoTime() - 起) / 1e6);
}
}
try (var t = new 计时("加载存档")) {
加载();
} // 出了块自动打印耗时
// 拿到被压制的异常(排查"关闭也失败"时)
catch (Exception e) {
System.err.println("主要问题:" + e.getMessage());
for (var 被压制 : e.getSuppressed())
System.err.println(" 关闭时还出了:" + 被压制);
}
// ✗ 永远不要在 finally 里 return(会吞掉一切)
static int 坏例子() {
try { return 1; } finally { return 2; } // 返回 2,异常也会被吞
}Files.lines() / Files.walk() 返回的 Stream 不关会泄漏文件句柄,而且症状极其隐蔽。短程序里看不出来,长期运行的程序会慢慢耗尽系统的文件描述符,最后报一个和真正原因毫无关系的 Too many open files。判据很简单:只要这个 Stream 是从「文件、网络、数据库」创建的,就必须放进 try-with-resources;从内存集合创建的(list.stream())不用。IDE 一般会警告,别忽略。AutoCloseable 做计时器」是个很好用的小技巧(上面那个例子):try (var t = new 计时("加载")) 一行就给一段代码加上耗时统计,而且无论怎么退出(正常、异常、return)都会打印。同样的模式还能用来做:临时切换配置、加锁解锁、开启/关闭调试输出。Java 没有 defer,try-with-resources 就是最接近的东西。语法很简单,难的是判断:这个异常该在哪一层处理?一条实用原则——「早抛,晚捕」:在最早发现问题的地方抛出,在最早能真正做点什么的地方捕获。
什么叫「能真正做点什么」
- 能重试(网络超时 → 退避后再来一次);
- 能降级(读缓存失败 → 去读数据库;读配置失败 → 用默认值);
- 能给用户一个有意义的提示(「文件不存在」比堆栈好);
- 是程序的最外层(
main、请求处理入口)——必须兜住,否则整个程序挂掉。
如果这一层什么都做不了,就别 catch,让它继续往上走。「就地 catch 然后打个日志再重新抛」是常见的噪音制造机——同一个错误会在日志里出现五遍。
跨越抽象边界时才包装
- 底层的
SQLException不该泄漏到界面层——在数据访问层把它包成你自己的异常(存档损坏),但一定要把原异常作为cause传进去。 - 包装的价值:上层不需要认识底层的异常类型;代价:堆栈变长。所以只在真的跨了抽象层时才包,同一层内部直接往上抛。
最外层怎么写
小工具的 main 里,把预期内的失败变成友好提示 + 非零退出码,把预期外的直接打印堆栈(03 章那个骨架就是这个结构)。不要让用户看到一个光秃秃的堆栈,也不要把堆栈完全藏起来——最好的做法是「给用户一句人话,把堆栈写进日志或加 --verbose 才显示」。
// ✓ 底层:发现问题就抛,别自作主张
static 存档 解析(String 行) {
String[] 段 = 行.split(",");
if (段.length != 3)
throw new IllegalArgumentException("存档格式不对,应有 3 段:" + 行);
return new 存档(段[0], Integer.parseInt(段[1]), 段[2]);
}
// ✓ 中间层:跨抽象边界时包装,一定带 cause
static 存档 读存档(Path 文件) {
try {
return 解析(Files.readString(文件));
} catch (IOException | IllegalArgumentException e) {
throw new 存档损坏(文件, e); // ← 原异常作为 cause
}
}
// ✓ 能降级的地方才 catch
static 存档 读或新建(Path 文件) {
try {
return 读存档(文件);
} catch (存档损坏 e) {
System.err.println("存档读不了,开新档:" + e.getMessage());
return 存档.新的(); // 真的做了点什么
}
}
// ✓ 能重试的地方
static String 带重试下载(String 地址) throws InterruptedException {
RuntimeException 最后一次 = null;
for (int i = 0; i < 3; i++) {
try { return 下载(地址); }
catch (RuntimeException e) {
最后一次 = e;
Thread.sleep(Duration.ofMillis(200L << i)); // 指数退避
}
}
throw 最后一次; // 三次都失败,还是要让它挂
}
// ✓ 最外层:人话 + 退出码
void main(String[] args) {
try {
干活(args);
} catch (存档损坏 e) {
System.err.println("错误:" + e.getMessage());
System.exit(1);
} catch (Exception e) {
System.err.println("意料之外的错误,请把下面的信息报给作者:");
e.printStackTrace(); // 意料之外的才打堆栈
System.exit(2);
}
}
// ✗ 噪音制造机:日志 + 重抛,同一个错误会打印五遍
catch (IOException e) {
log.error("出错了", e);
throw e;
}try { Integer.parseInt(s) } catch { 不是数字 } 判断字符串是不是数字」在数据量小时无所谓,但异常的构造要抓取整个堆栈,在循环里跑几十万次会非常慢。能用条件判断就别用异常(正则、matches("\\d+")、或者干脆接受 parseInt 但只在解析用户单次输入时)。异常是给「异常情况」的,一个 for 循环里每次都抛的东西,按定义就不是异常情况。InterruptedException 时有个特殊规矩:要么继续往上抛,要么恢复中断标志(Thread.currentThread().interrupt();)。直接吞掉它会让上层代码永远不知道「有人请求过取消」,导致本该停止的循环继续跑下去。这是 13 章会详细讲的取消机制的一部分,但因为它是受检异常,很多人第一次遇到就随手吞了。Optional<T> 是一个「装着 0 个或 1 个值的盒子」。它的价值不在于消灭 null,而在于把「可能没有」写进方法签名里——调用方看到返回类型就知道要处理这种情况。
正确用法:方法的返回值
- 用它:
Optional<玩家> 按名字查找(String 名)—— 调用方一眼就知道可能查不到。 - 别用它当参数:
void f(Optional<String> s)比重载两个方法或允许 null 更啰嗦。 - 别用它当字段:它不可序列化,还多一层包装的开销。
- 别用它装集合:空集合本身就表达了「没有」,
Optional<List>让调用方要判两次。
orElse 和 orElseGet 的关键差别
Optional.of("有值").orElse(算个贵的默认值()) // 贵的那个函数被调用了!
Optional.of("有值").orElseGet(() -> 算个贵的()) // 没有被调用orElse(x)的参数是个值,所以x在调用前就必须算出来——即使 Optional 里有值也一样。orElseGet(供应者)是惰性的,只在真的为空时才调。- 规则:默认值是常量用
orElse,需要计算(查数据库、读文件、new 对象)一律用orElseGet。用错了不会报错,只是白干活——或者更糟,产生了不该有的副作用。
常用方法
| 方法 | 作用 |
|---|---|
ofNullable(x) | x 可能为 null 时用它(of(null) 会 NPE,) |
map / flatMap | 有值时变形(链式处理的核心) |
filter | 不满足条件就变成空 |
orElse / orElseGet | 取值或默认 |
orElseThrow() | 空就抛 NoSuchElementException: No value present |
ifPresent / ifPresentOrElse | 有值就做点什么 |
stream() | 转成 0 或 1 个元素的流(Java 9 起,配合 flatMap 很好用) |
// ✓ 典型用法:查找类方法的返回值
Optional<玩家> 按名字查(String 名) {
return 名单.stream().filter(p -> p.名字().equals(名)).findFirst();
}
// 链式处理:不用一层层判空
String 公会名 = 按名字查("小明")
.map(玩家::公会)
.filter(g -> !g.isBlank())
.orElse("无公会");
// orElse vs orElseGet(差别是"默认值会不会被计算")
var 配置 = 读到的.orElse(默认配置); // 常量:orElse 就行
var 数据 = 缓存.orElseGet(() -> 从数据库读()); // 昂贵:必须 orElseGet
// 空就抛,带自己的异常
var 玩家 = 按名字查(名).orElseThrow(
() -> new IllegalArgumentException("没有这个玩家:" + 名));
// 有值才做
按名字查(名).ifPresentOrElse(
p -> System.out.println("找到了 " + p),
() -> System.out.println("没找到"));
// 配合 Stream:过滤掉空的并取值(Java 9 起)
var 找到的 = 名字列表.stream()
.map(this::按名字查)
.flatMap(Optional::stream) // 空的自动消失
.toList();
// ✗ 反面写法:这跟直接判 null 没区别,还多包一层
if (结果.isPresent()) {
用(结果.get());
}
// ✗ 别当参数、别当字段、别装集合
// void f(Optional<String> s) → 重载或允许 null
// private Optional<String> 名字; → 直接用可空字段
// Optional<List<String>> 查列表() → 返回空列表就够了
// Optional.of(null) 会 NPE—— 可能为 null 时用 ofNullable
Optional.ofNullable(可能为空的);Optional 本身可以是 null,这是它最讽刺的一点。Optional<String> x = null; 完全合法,然后 x.isPresent() 照样 NPE。所以「返回 Optional 的方法永远不要返回 null」——要么 Optional.empty(),要么有值。另一个常见误用是把 Optional 当成「更安全的 null」到处传:它只在方法返回值这一个位置上创造价值(让签名自我说明),传得越深,样板越多,收益越少。Optional 的方法值得记几个:stream().findFirst()、stream().max()/min()、stream().reduce(合并函数)(无初始值版本)、Map 没有直接返回 Optional 的方法(用 Optional.ofNullable(map.get(k)))。看到 Optional 返回值就意味着「这里确实可能没有」,是 API 作者在提醒你。这一卡是排查手册:拿到一个异常之后,按什么顺序看什么。
读堆栈的三步
- ① 看第一行:异常类型 + 消息。Java 的异常消息通常已经把答案写出来了(
Index 5 out of bounds for length 3、Cannot invoke "String.length()" because "message" is null)。 - ② 从上往下找第一个属于你自己的类(包名是你写的那些)——那通常就是问题所在。上面那些是标准库或框架的内部帧。
- ③ 找
Caused by::包装过的异常会有一串Caused by,最后一个才是根因。
常见异常速查
| 异常 | 通常意味着 |
|---|---|
NullPointerException | 消息里点名了哪个东西是 null,直接看它 |
ArrayIndexOutOfBounds | 循环边界写错(<= 写成了 < 的反面) |
ClassCastException | 强转错了,或者泛型被绕过(09 章) |
ConcurrentModificationException | 遍历时改了集合(10 章) |
UnsupportedOperationException(消息为空) | 往不可变集合里写(10 章) |
NumberFormatException | 消息里带着原始字符串,看它长什么样 |
StackOverflowError | 递归没有终止条件(03 章) |
OutOfMemoryError: Java heap space | 真的不够用、或者内存泄漏(16 章) |
NoClassDefFoundError | 编译时有、运行时缺——类路径不对 |
打印、日志与断言
- 写小工具:
System.out.println完全够用。别为了「专业」而给一个五十行的脚本配日志框架。 - 程序长期跑:用日志(JDK 自带
System.getLogger(),或第三方的 SLF4J + Logback)。日志的价值在于可以分级、可以留档、可以在不改代码的情况下调整详细程度。 assert关键字默认是关闭的——必须加-ea才生效。所以它只适合开发期的自检,绝对不能用它做参数校验或安全检查(生产环境里那行代码等于不存在)。
// 读一个真实的堆栈
// Exception in thread "main" java.lang.NullPointerException:
// Cannot invoke "String.length()" because "message" is null ← ① 消息已说明
// at 我的包.处理器.处理(处理器.java:42) ← ② 我自己的代码,从这看
// at 我的包.主程序.main(主程序.java:15)
// Caused by: java.io.FileNotFoundException: config.json ← ③ 根因在最下面
// 让 NPE 报出变量名:编译时带调试信息(差别)
// javac X.java → because "<local1>" is null
// javac -g X.java → because "message" is null
// (IDE 和构建工具默认就带 -g,所以平时看到的是真名)
// 主动检查,早点炸
Objects.requireNonNull(配置, "配置未加载");
if (下标 < 0 || 下标 >= 大小)
throw new IndexOutOfBoundsException("下标 " + 下标 + ",大小 " + 大小);
// 日志:JDK 自带的,不用引第三方
static final System.Logger 日志 = System.getLogger("我的程序");
日志.log(System.Logger.Level.INFO, "开始处理 {0} 个文件", 数量);
日志.log(System.Logger.Level.ERROR, "处理失败", 异常);
// 断言:默认关闭,只用于开发期自检
assert 余额 >= 0 : "余额不该为负:" + 余额;
// $ java -ea 我的程序 ← 不加 -ea 这行完全不执行
// 临时排查:把中间结果打出来(最朴素也最有效)
System.out.println("[调试] 读到 " + 行数 + " 行,前三行=" + 行.subList(0, Math.min(3, 行.size())));
// 想看某个对象的全部内容:先确保它有 toString(05 章)
System.out.println(玩家); // record 自动就有;普通类不覆写就是 玩家@5fd9b663e.printStackTrace() 打到的是标准错误流,在很多环境里会和正常输出混在一起或者干脆看不到。它适合本地调试,但不该出现在最终的程序里——长期运行的程序要么用日志,要么至少统一走一个错误处理函数。另一个更隐蔽的问题:printStackTrace() 会打印但不会终止,如果它出现在 catch 块里而后面的代码继续执行,程序就会带着已经损坏的状态跑下去。打印之后要么恢复、要么退出,别装作没事发生。println 强得多,值得花二十分钟学会。三个操作就够用:断点(点行号左边)、单步(F8 跳过 / F7 进入)、看变量(下方面板)。还有一个救命功能是「条件断点」——右键断点填一个条件(比如 i == 9999),循环跑到那次才停,排查「只有某个特定数据出问题」时无可替代。并发地基:线程与共享状态
并发的难点从来不是「怎么开线程」,而是「两个线程碰同一个东西会发生什么」。这一章用把数据竞争、可见性、死锁这三类问题真的复现出来,再给出今天该用的工具。
并发 bug 的可怕之处在于它不报错,只是给出错误的答案,而且时有时无。这一卡先把它复现出来。
四个线程各加十万次
static int 计数 = 0; // 四个线程,每个 for (int j = 0; j < 100_000; j++) 计数++; // 期望 400000,得到 147405(每次运行都不一样)
- 原因:
计数++不是一条指令,它是「读出来 → 加一 → 写回去」三步。两个线程同时读到 100,各自加到 101,各自写回——两次自增只生效了一次。 - 换成
AtomicInteger的同一实验,结果准确是 400000。
更严重的:集合被写坏
四个线程往同一个 HashMap 里写 20 万条不重复的键——最后 size() 只有 85961。没有抛任何异常,数据就是丢了。换成 ConcurrentHashMap 做同样的事,结果准确是 200000。
(历史上更糟的版本还会让 HashMap 内部结构成环,导致 CPU 100% 且永远不返回。)
什么叫「线程安全」
- 不是「用了 synchronized」,而是「多个线程同时使用它,结果仍然正确」。
- 三条最有效的做法,按优先级排:① 不共享(每个线程用自己的数据,最后汇总);② 共享不可变对象(05 章——没有写操作就没有竞争);③ 共享可变对象但加保护(本章后面几卡)。
- 前两条能解决绝大多数问题,第三条才是不得已。
怎么开一个线程
今天的答案基本是「别直接开」——用线程池(本章第 5 卡)或虚拟线程(14 章)。但知道它长什么样还是有用的:Thread.ofPlatform().start(() -> ...) 或老写法 new Thread(runnable).start()。
// 复现数据竞争(自己跑一遍,每次结果都不同)
static int 计数 = 0;
void main() throws InterruptedException {
Thread[] 线程 = new Thread[4];
for (int i = 0; i < 4; i++) {
线程[i] = new Thread(() -> {
for (int j = 0; j < 100_000; j++) 计数++; // ← 三步操作,不原子
});
线程[i].start();
}
for (var t : 线程) t.join(); // 等它们都结束
IO.println("期望 400000,实得 " + 计数); // 147405
}
// 修法一:原子类(本章第 4 卡)
static final AtomicInteger 计数2 = new AtomicInteger();
计数2.incrementAndGet(); // 准确 400000
// 修法二:加锁(第 3 卡)
static final Object 锁 = new Object();
synchronized (锁) { 计数++; }
// 修法三(最好):根本不共享,各算各的最后汇总
long 总和 = IntStream.range(0, 4)
.parallel()
.mapToLong(i -> { long 局部 = 0; for (int j = 0; j < 100_000; j++) 局部++; return 局部; })
.sum(); // 每个线程有自己的局部变量,没有竞争
// 集合被写坏(20 万条只剩 8 万多)
Map<Integer, Integer> 坏的 = new HashMap<>(); // ✗ 多线程写
Map<Integer, Integer> 好的 = new ConcurrentHashMap<>(); // ✓ 准确
// 开线程的两种写法(今天一般不直接这么写,见第 5 卡和 14 章)
new Thread(() -> 干活()).start();
Thread.ofPlatform().name("下载器").start(() -> 干活());除了「同时改」这种竞争,还有一类更反直觉的问题:一个线程写了变量,另一个线程永远读不到新值。
一个卡死的循环
boolean 停 = false; // 没有 volatile // 线程 A:while (!停) { n++; } // 主线程:Thread.sleep(300); 停 = true; // 主线程改了 300 毫秒后,线程 A 仍然在跑,join(2000) 超时后它还活着
- 为什么:JIT 编译器看到循环体里没人改
停,就把它提到循环外面只读一次(等价于if (!停) while(true) n++;)。这个优化在单线程下完全正确。 - 另外还有 CPU 缓存层面的原因:每个核有自己的缓存,写入不一定立刻对其它核可见。
- 加上
volatile之后,循环立刻能退出。
volatile 做什么、不做什么
- 做:保证「写了之后别的线程立刻能读到」(可见性),并禁止相关的重排序。
- 不做:不保证原子性。
volatile int 计数; 计数++;仍然会丢数据——因为自增还是三步。 - 所以
volatile的正确用途很窄:一个线程写、其它线程读的状态标志(停止标志、初始化完成标志、配置版本号)。 - 需要「读-改-写」就用
AtomicInteger或锁。
happens-before:一句话版本
Java 内存模型规定了几对「A 之后 B 一定能看到 A 的写入」的关系。实用上只需记住四条:① 同一个线程内前面的语句对后面可见;② synchronized 解锁 → 之后的加锁;③ volatile 写 → 之后的读;④ Thread.start() 之前的写对新线程可见,新线程结束前的写对 join() 之后可见。只要用了标准的同步工具,这些都自动成立——需要手动推理 happens-before 的场景,通常说明设计太复杂了。
// 复现可见性问题(不加 volatile 时线程停不下来)
static boolean 停 = false; // ✗
static volatile boolean 停2 = false; // ✓
void main() throws InterruptedException {
var 工作 = new Thread(() -> {
long n = 0;
while (!停) n++; // 不加 volatile 时可能被优化成死循环
IO.println("退出了,循环了 " + n + " 次");
});
工作.start();
Thread.sleep(300);
停 = true;
工作.join(2000);
IO.println("它还活着吗:" + 工作.isAlive()); // true
}
// volatile 的正确用途:状态标志
private volatile boolean 要停了 = false;
public void 请求停止() { 要停了 = true; }
public void 循环() {
while (!要停了) { 干一轮(); }
}
// ✗ volatile 不保证原子性:这样写照样丢数据
static volatile int 计数 = 0;
计数++; // 仍然是读-改-写三步
// ✓ 需要读-改-写就用原子类
static final AtomicInteger 计数2 = new AtomicInteger();
计数2.incrementAndGet();
// 更好的停止方式:中断(能打断阻塞中的 sleep/wait/IO)
var 线程 = new Thread(() -> {
while (!Thread.currentThread().isInterrupted()) {
try { Thread.sleep(1000); }
catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 恢复标志(12 章)
break;
}
}
});
线程.interrupt(); // 请求它停止(不是强制杀死)volatile;判据是「有没有跨线程读写同一个变量」,不是「测出来会不会错」。sleep、wait、take() 或阻塞式 IO 上,标志位改了也没用,得等它自己醒过来。interrupt() 会让这些阻塞方法立刻抛 InterruptedException。Java 没有安全的「强杀线程」——Thread.stop() 早就被移除了,因为它会让对象停在不一致的中间状态。synchronized 是 Java 内置的锁:同一时刻只有一个线程能进入被同一把锁保护的代码块,进出时还自带内存可见性保证(上一卡的 happens-before)。
锁的是对象,不是代码
synchronized (某个对象) { ... }—— 锁的是那个对象。两段用同一个对象加锁的代码互斥;用不同对象的互不影响。synchronized修饰实例方法 = 锁this;修饰静态方法 = 锁那个类的Class对象。- 用
this当锁是个隐患:外部代码也能synchronized (你的对象),可能造成你没法控制的阻塞甚至死锁。推荐用一个私有的final Object 锁 = new Object();。
锁的粒度
- 锁住的代码越少越好:只保护真正共享的那几行。把整个方法(包括 IO、日志、耗时计算)都锁住,会让并发退化成串行。
- 但也不能太碎:如果两个操作必须原子地一起完成(先查再改),就必须在同一个锁块里,否则中间会被别人插入。
ReentrantLock:需要更多控制时
| 能力 | synchronized | ReentrantLock |
|---|---|---|
| 基本互斥 | ✓ | ✓ |
| 自动释放 | ✓(出块就放) | 需要 try/finally |
| 尝试获取(拿不到就走) | ✗ | tryLock() |
| 带超时 | ✗ | tryLock(1, SECONDS) |
| 可中断等待 | ✗ | lockInterruptibly() |
| 公平锁 | ✗ | 构造时可选 |
没有上面这些需求就用 synchronized——它更简洁、不会忘记释放,而且性能上早已没有劣势。
// ✓ 私有锁对象:外部碰不到
public class 计分板 {
private final Object 锁 = new Object();
private final Map<String, Integer> 分数 = new HashMap<>();
public void 加分(String 名, int 分) {
synchronized (锁) { // 只锁真正需要的几行
分数.merge(名, 分, Integer::sum);
}
}
public Map<String, Integer> 快照() {
synchronized (锁) { return Map.copyOf(分数); }
}
}
// ✗ 锁粒度太大:把 IO 也锁进去了
public synchronized void 处理(String 数据) {
var 结果 = 很慢的网络请求(数据); // 别的线程全在这儿排队
分数.put(数据, 结果);
}
// ✓ 只锁共享状态那一步
public void 处理2(String 数据) {
var 结果 = 很慢的网络请求(数据); // 并发进行
synchronized (锁) { 分数.put(数据, 结果); }
}
// ✗ 检查和修改分开 = 中间会被插入
if (!分数.containsKey(名)) { // 线程 A 检查完
分数.put(名, 0); // 线程 B 也检查完并 put 了,A 再 put 覆盖掉
}
// ✓ 要么放进同一个锁块,要么用原子操作
分数.putIfAbsent(名, 0); // ConcurrentHashMap 上这是原子的
// ReentrantLock:需要 tryLock / 超时 / 可中断时
private final ReentrantLock 锁2 = new ReentrantLock();
if (锁2.tryLock(1, TimeUnit.SECONDS)) {
try { 干活(); }
finally { 锁2.unlock(); } // 必须在 finally 里!
} else {
IO.println("忙不过来,稍后再试");
}
// 读多写少:读写锁
var 读写锁 = new ReentrantReadWriteLock();
读写锁.readLock().lock(); // 多个读者可以同时进
读写锁.writeLock().lock(); // 写者独占synchronized (列表) { 列表 = new ArrayList<>(); } 之后,别的线程锁的是新对象,两边互不排斥。所以锁对象必须是 final 的。同类的错误还有:锁一个包装类型或字符串(synchronized ("锁") ——字符串常量是全局共享的,别的完全无关的代码可能锁着同一个对象),锁一个每次调用都新建的对象(等于没锁)。一律用 private final Object 锁 = new Object();,最省心。synchronized 是可重入的:同一个线程可以重复获得同一把锁。这让「一个 synchronized 方法调用另一个 synchronized 方法」不会自己把自己锁死。但要小心它掩盖的设计问题——如果你发现锁的嵌套层次很深,通常说明职责划分得不清楚。另外可重入也意味着:你在持锁时调用的任何方法(包括覆写过的、回调的)都还在锁里面,那些方法如果很慢或者去拿别的锁,问题就来了(下一卡的死锁)。大多数情况下你不需要自己写锁——标准库已经提供了线程安全的计数器、集合和队列,它们内部用的是比锁更高效的手段(CAS,比较并交换)。
原子类
AtomicInteger/AtomicLong/AtomicBoolean/AtomicReference。- 核心方法:
incrementAndGet()、getAndIncrement()、addAndGet(n)、compareAndSet(旧, 新)、updateAndGet(函数)。 - 高并发计数用
LongAdder更快:它把计数拆成多个槽分散写,读的时候求和——写多读少时明显优于AtomicLong。
并发集合
| 要什么 | 用 |
|---|---|
| 线程安全的 Map | ConcurrentHashMap |
| 线程安全的 List(读远多于写) | CopyOnWriteArrayList |
| 线程间传数据的队列 | LinkedBlockingQueue / ArrayBlockingQueue |
| 无锁高吞吐队列 | ConcurrentLinkedQueue |
| 线程安全的 Set | ConcurrentHashMap.newKeySet() |
Collections.synchronizedMap(...) 是老办法:它给每个方法加同一把锁,并发度很差,而且复合操作仍然不安全(「先查再放」中间还是会被插入)。用 ConcurrentHashMap。
ConcurrentHashMap 的原子复合操作
它的价值不只是「每个方法线程安全」,更在于提供了原子的复合操作:putIfAbsent、computeIfAbsent、merge、compute——这些在一次调用里完成「查 + 改」,中间不会被插入。这正是普通 HashMap 加锁也很难做对的部分。
阻塞队列:生产者-消费者的标准答案
BlockingQueue 的 put 在满时阻塞、take 在空时阻塞——这一条就把「生产者-消费者」模型的所有同步问题解决了,不用自己 wait/notify(那套 API 极易写错,今天基本不该直接用)。
// 原子计数
static final AtomicInteger 已处理 = new AtomicInteger();
已处理.incrementAndGet();
已处理.addAndGet(10);
已处理.updateAndGet(n -> Math.min(n + 1, 100)); // 自定义的原子更新
// 高并发计数:LongAdder 比 AtomicLong 快
static final LongAdder 请求数 = new LongAdder();
请求数.increment();
long 总数 = 请求数.sum();
// CAS:无锁地更新一个引用
var 当前配置 = new AtomicReference<配置>(初始);
当前配置.compareAndSet(旧的, 新的); // 只在还是旧的时才换
// ConcurrentHashMap 的原子复合操作(关键价值)
var 计数 = new ConcurrentHashMap<String, Integer>();
计数.merge(键, 1, Integer::sum); // 原子的"没有就放 1,有就加 1"
计数.computeIfAbsent(键, k -> 造一个()); // 原子的"没有就建"(只会建一次)
// 线程安全的 Set
Set<String> 已访问 = ConcurrentHashMap.newKeySet();
if (已访问.add(地址)) { 抓(地址); } // add 返回 true 表示之前没有
// 生产者-消费者:用阻塞队列即可
BlockingQueue<String> 队列 = new LinkedBlockingQueue<>(100);
// 生产者
队列.put(任务); // 满了就等(自动限流)
// 消费者
while (!Thread.currentThread().isInterrupted()) {
String 任务 = 队列.take(); // 空了就等
处理(任务);
}
// 读多写极少的列表
List<监听器> 监听器们 = new CopyOnWriteArrayList<>();
// 每次写都复制整个数组 —— 写很贵,读完全无锁
// 等所有任务完成
var 闩 = new CountDownLatch(任务数);
// 每个任务结束时 闩.countDown();
闩.await(); // 等它归零ConcurrentHashMap 上写 if (!map.containsKey(k)) map.put(k, v); 仍然有竞争——两次调用之间是没有保护的。这类「复合操作」必须用它提供的原子方法(putIfAbsent/computeIfAbsent/merge)。同理,遍历 ConcurrentHashMap 时看到的是「某个时刻的弱一致视图」:遍历过程中别人的修改可能看到也可能看不到,size() 也只是个近似值。它保证不出错、不保证你看到一致的快照。ConcurrentHashMap.computeIfAbsent 保证同一个键的构造函数只会执行一次,这让它成为「并发安全的懒加载缓存」的最简写法:缓存.computeIfAbsent(键, k -> 很贵的计算(k))。但注意别在里面做会去修改同一个 map 的操作(比如在构造函数里又往这个 map 里 put),那会死锁或抛 IllegalStateException: Recursive update。直接 new Thread 的问题是:创建线程很贵、数量不受控、没法复用、没法拿返回值。线程池解决全部四个。(不过 14 章会说明:对 IO 型任务,今天有更好的答案。)
基本用法
Executors.newFixedThreadPool(n)—— 固定 n 个线程,最常用。submit(任务)返回Future,可以get()拿结果(会阻塞直到完成)。ExecutorService实现了AutoCloseable(Java 19 起),可以放进 try-with-resources——出块时自动等所有任务完成并关闭,比手写shutdown()+awaitTermination()省事得多。
怎么定线程数
- CPU 密集型(计算、压缩、图像处理):约等于核数。再多只会增加上下文切换。
- IO 密集型(网络请求、读文件、查数据库):远大于核数——线程大部分时间在等。但这正是平台线程的痛点:一个线程约占 1 MB 栈,开几千个就很吃力。
- 开一万个平台线程(每个 sleep 2 秒)能起来,没有崩——但这是极限量级,而且创建它们本身就花了可观的时间。要处理十万个并发 IO 请求,平台线程这条路走不通。这就是虚拟线程存在的理由(14 章)。
拿结果与异常
Future.get()阻塞等待;任务里抛的异常会被包成ExecutionException,用getCause()拿真正的异常。submit提交的任务抛异常时,异常会被吞掉直到你调get()——如果从不调get(),错误就静默消失了。这是线程池最容易踩的一个坑。(execute提交的则会打到默认的未捕获异常处理器。)CompletableFuture用于组合多个异步操作。它的join()抛的是CompletionException,真正的异常在getCause()里。
// 推荐写法:try-with-resources(Java 19 起)
try (var 池 = Executors.newFixedThreadPool(4)) {
for (var 文件 : 一堆文件) {
池.submit(() -> 处理(文件));
}
} // 出块时自动等所有任务完成并关闭
// 拿结果
try (var 池 = Executors.newFixedThreadPool(4)) {
List<Future<Integer>> 结果 = new ArrayList<>();
for (var 文件 : 一堆文件)
结果.add(池.submit(() -> 数行数(文件)));
int 总数 = 0;
for (var f : 结果) {
try { 总数 += f.get(); }
catch (ExecutionException e) {
IO.println("有个任务失败了:" + e.getCause()); // 真正的异常在 cause 里
}
}
}
// invokeAll:一次提交一批,全部完成后返回
var 任务 = 文件们.stream()
.<Callable<Integer>>map(f -> () -> 数行数(f))
.toList();
var 全部结果 = 池.invokeAll(任务);
// 定时任务
var 定时池 = Executors.newScheduledThreadPool(1);
定时池.scheduleAtFixedRate(() -> 存档(), 0, 5, TimeUnit.MINUTES);
// CompletableFuture:组合异步操作(join 抛 CompletionException)
var 结果 = CompletableFuture
.supplyAsync(() -> 下载(地址))
.thenApply(this::解析)
.thenAccept(IO::println)
.exceptionally(e -> { IO.println("失败:" + e.getCause()); return null; });
// 等多个都完成
CompletableFuture.allOf(f1, f2, f3).join();
// 老写法(还会大量见到):手动关闭
var 池 = Executors.newFixedThreadPool(4);
try { /* 提交任务 */ }
finally {
池.shutdown(); // 不再接新任务
池.awaitTermination(1, TimeUnit.MINUTES); // 等已有的做完
}submit 会把任务里抛出的异常吞掉,直到你调 Future.get()——如果你从不调,错误就完全消失了。这是线程池最隐蔽的一个坑:任务静默失败,程序继续跑,你什么都不知道。三个应对:① 一定调 get()(哪怕只是为了看异常);② 或者用 execute() 提交(异常会走默认的未捕获异常处理器,至少会打印);③ 或者在任务内部就 try/catch 兜住并记录——这是最可靠的做法。shutdown()。(想让线程不阻止退出,可以用 Thread.ofPlatform().daemon(),但那意味着它们会被无预警地终止。)死锁是两个(或更多)线程各自持有对方需要的锁,谁也不放手。程序不会崩溃,不会报错——它就那么停住了。
复现一个死锁
线程1:synchronized(A) { sleep(100); synchronized(B) { } }
线程2:synchronized(B) { sleep(100); synchronized(A) { } }
// 600 毫秒后检查:
ThreadMXBean.findDeadlockedThreads() → [1010107, 1010108] // 精确报出两个线程
两个线程的状态 → BLOCKED / BLOCKED关键点:JVM 知道发生了死锁,但它不会自动做任何事——只是让那两个线程永远停在那里。
三个诊断办法
jcmd <pid> Thread.print(或jstack <pid>):打印所有线程栈,输出末尾会直接写出「Found one Java-level deadlock」并列出环。这是首选办法,不用改代码。ThreadMXBean.findDeadlockedThreads():在程序内部检测(上面那个实验用的就是它),可以做成一个自检的定时任务。- JFR(JDK 自带的飞行记录器)/ VisualVM / JConsole:图形界面查看(16 章)。
四个必要条件与三个规避手段
- 死锁需要同时满足:互斥、持有并等待、不可抢占、循环等待。破坏任意一个就不会死锁。
- 手段一:统一加锁顺序(最有效)。如果所有代码都按同一个顺序获取锁(比如按对象的 ID 从小到大),循环等待就不可能形成。
- 手段二:用
tryLock带超时——拿不到就放弃已有的锁重来,破坏「持有并等待」。 - 手段三(最好):一次只持有一把锁。需要两把锁的场景通常可以重构掉:把数据合并到一个对象里、或者在锁外面先算好再进锁写。
还有一类「活着但卡住」
不是所有的卡死都是死锁:还可能是等一个永远不来的通知、无限重试、或者一个线程在锁里做了极慢的操作(IO、网络)导致所有人排队。看线程状态能区分:死锁是 BLOCKED,等通知是 WAITING,忙等是 RUNNABLE 而 CPU 打满。
// 复现死锁(findDeadlockedThreads 精确报出两个线程)
static final Object A = new Object(), B = new Object();
new Thread(() -> {
synchronized (A) { 睡(100); synchronized (B) { } }
}, "死锁-1").start();
new Thread(() -> {
synchronized (B) { 睡(100); synchronized (A) { } } // ← 顺序反了
}, "死锁-2").start();
// 程序内检测
var mx = ManagementFactory.getThreadMXBean();
long[] ids = mx.findDeadlockedThreads();
if (ids != null) {
for (var 信息 : mx.getThreadInfo(ids, true, true))
IO.println("死锁线程:" + 信息.getThreadName() + " 等着 " + 信息.getLockName());
}
# 命令行诊断(不用改代码,首选)
$ jcmd -l # 列出所有 Java 进程和 pid
$ jcmd <pid> Thread.print # 打印全部线程栈,末尾直接报 deadlock
$ jstack <pid> # 同上,老命令
// ✓ 规避一:统一加锁顺序
void 转账(账户 从, 账户 到, int 金额) {
账户 先 = 从.编号() < 到.编号() ? 从 : 到; // 永远按编号从小到大锁
账户 后 = 先 == 从 ? 到 : 从;
synchronized (先) {
synchronized (后) {
从.扣(金额); 到.加(金额);
}
}
}
// ✓ 规避二:tryLock 带超时,拿不到就整体重来
while (true) {
if (锁A.tryLock(100, TimeUnit.MILLISECONDS)) {
try {
if (锁B.tryLock(100, TimeUnit.MILLISECONDS)) {
try { 干活(); return; } finally { 锁B.unlock(); }
}
} finally { 锁A.unlock(); }
}
Thread.sleep(ThreadLocalRandom.current().nextInt(50)); // 随机退避
}
// ✓ 规避三(最好):重构成只需要一把锁
synchronized (银行) { 从.扣(金额); 到.加(金额); } // 粗但正确synchronized (锁) { 监听器们.forEach(l -> l.收到(事件)); } 看起来无害,但某个监听器里可能又去拿了别的锁,而另一个线程正以相反的顺序持有它们。规避:在锁里只做最小的状态更新,把回调放到锁外面执行(先在锁里复制一份要通知的列表,出锁后再遍历)。jcmd <pid> Thread.print。它不需要提前配置、不需要改代码、对运行中的程序几乎没有影响,而且输出里包含每个线程此刻正在执行哪一行。这是排查所有「卡住」类问题的万能第一步——死锁、无限循环、等一个永远不来的响应,三种情况在输出里长得完全不同。虚拟线程与结构化并发
Java 21 之后并发写法变了:一个任务一个线程这种最好懂的模型重新变得可行,一百万个线程也不再是问题。这一章讲清虚拟线程是什么、它改变了什么、以及网上那些「要避开 synchronized」的建议为什么已经过时。
虚拟线程(Java 21 正式)是由 JVM 而不是操作系统管理的线程。它的行为和普通线程一模一样(同样的 Thread 类、同样的 sleep、同样的 try/catch),但代价小了好几个数量级。
一百万个虚拟线程的代价
一百万个虚拟线程,每个 Thread.sleep(100 毫秒): → 一百万个全部完成,总耗时 2.11 秒
- 换成平台线程,一百万根本开不出来(每个约占 1 MB 栈,光内存就是 1 TB)。开一万个平台线程还能撑住,但那已经接近实用上限。
- 虚拟线程的栈在堆上,按需增长,一个空闲的虚拟线程只有几百字节量级。
它是怎么做到的
- 虚拟线程运行时需要「骑」在一个真实的载体线程上(默认是一个 ForkJoinPool,并行度等于 CPU 核数)。
- 关键在于:虚拟线程一旦阻塞(sleep、网络读、加锁),JVM 就把它从载体上卸下来,载体去跑别的虚拟线程。等阻塞结束再挂回去。
- 所以「阻塞」变得极其便宜——它只是一次 JVM 内部的调度,不再是操作系统级的线程切换。
什么时候用,什么时候没用
| 任务类型 | 虚拟线程有用吗 |
|---|---|
| 网络请求、读写文件、查数据库 | 非常有用——这正是它的目标 |
| 等待、休眠、锁竞争 | 有用 |
| 纯 CPU 计算(压缩、加密、图像) | 没用——CPU 就那么多核,用平台线程池 |
判据:任务里有没有「等待」。有等待,虚拟线程能让你把并发度提高几个数量级;没有等待(纯算),线程再多也不会更快。
// 创建虚拟线程:三种写法
Thread.startVirtualThread(() -> 干活()); // 最短
Thread.ofVirtual().name("下载-1").start(() -> 干活()); // 带名字
var 池 = Executors.newVirtualThreadPerTaskExecutor(); // 最常用
// 一百万个虚拟线程各 sleep 100ms,总共 2.11 秒
try (var 池2 = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 0; i < 1_000_000; i++) {
池2.submit(() -> { Thread.sleep(Duration.ofMillis(100)); return null; });
}
} // try-with-resources 出块时等全部完成
// 真实场景:并发抓一百个网页(每个任务写成最朴素的同步代码)
try (var 池3 = Executors.newVirtualThreadPerTaskExecutor()) {
var 结果 = 地址们.stream()
.map(u -> 池3.submit(() -> 抓一个(u))) // 每个地址一个虚拟线程
.toList();
for (var f : 结果) IO.println(f.get());
}
String 抓一个(String 地址) throws Exception {
// 注意这里是最普通的阻塞式代码 —— 不需要回调、不需要 async/await
var 客户端 = HttpClient.newHttpClient();
var 响应 = 客户端.send(HttpRequest.newBuilder(URI.create(地址)).build(),
HttpResponse.BodyHandlers.ofString());
return 地址 + " → " + 响应.statusCode();
}
// 判断当前是不是虚拟线程
Thread.currentThread().isVirtual()
// 虚拟线程的 toString:
// VirtualThread[#1010096,我的虚拟线程]/runnable@ForkJoinPool-1-worker-15
// ↑ 编号 ↑ 名字 ↑ 状态 ↑ 当前骑在哪个载体线程上
// ✗ 虚拟线程不该用于纯 CPU 计算
// 一百万个虚拟线程做矩阵乘法,不会比 CPU 核数个平台线程快Executors.newFixedThreadPool 那套「限制线程数」的思路对虚拟线程完全不适用——它们本来就便宜,每个任务开一个新的就好(newVirtualThreadPerTaskExecutor 的名字就是这个意思)。如果你需要限制并发数(比如「最多同时 10 个请求打到某个服务」),用 Semaphore 而不是限制线程数——把「限流」这个语义和「线程管理」分开,两者本来就是两回事。CompletableFuture 链或响应式框架——代码难写、难读、堆栈信息面目全非。现在可以回到「一个任务一个线程,从头到尾顺着写」,出错时堆栈干净、可以用调试器单步、try/catch 正常工作。这才是它真正改变的东西。虚拟线程刚出来时(Java 21),有一个真实的限制叫「钉住」(pinning):虚拟线程在 synchronized 块里阻塞时,无法从载体线程上卸下来,会把整个载体占住。于是当时的建议是「把 synchronized 换成 ReentrantLock」。
这个问题在 JDK 25 上已经不存在
启动参数:-Djdk.virtualThreadScheduler.parallelism=1
-Djdk.virtualThreadScheduler.maxPoolSize=1
(也就是说:只有 一个 载体线程)
A:4 个虚拟线程,各自在 synchronized 块里 sleep(300ms)(各持不同的锁)
→ 309 ms ← 完全并发,没有被钉住
B:4 个虚拟线程,裸 sleep(300ms) 作对照
→ 302 ms
C:4 个虚拟线程,抢同一把锁各 sleep(300ms)
→ 1205 ms ← 这次串行是锁本身造成的,和钉住无关结论:JDK 24 起(JEP 491)synchronized 内部阻塞不再钉住载体线程。A 组如果被钉住,只有一个载体的情况下必然要 1200 毫秒——309 毫秒,说明四个虚拟线程真的并发跑了。
那还有什么会钉住
- 本地方法(JNI)内部的阻塞——JVM 管不到本地栈帧。
- 类初始化(static 块)里的阻塞。
- 这两种在日常代码里都很罕见。
今天该怎么做
- 不用为了虚拟线程去把
synchronized改成ReentrantLock。如果你看到这条建议,先看它是哪一年写的。 - 真正要注意的仍然是「别在锁里做很慢的事」——这跟虚拟线程无关,13 章讲过。
- 老的诊断开关
-Djdk.tracePinnedThreads已经移除了(加上它 JVM 静默忽略,不报错也不输出)。今天用 JFR 的jdk.VirtualThreadPinned事件看(16 章)。
// 这段代码在 JDK 21 上会钉住载体线程,在 24+ 上不会
final Object 锁 = new Object();
Thread.ofVirtual().start(() -> {
synchronized (锁) {
Thread.sleep(300); // 在锁里阻塞
}
});
// 自己验证的办法:把载体线程限制成 1 个再跑
// $ java -Djdk.virtualThreadScheduler.parallelism=1 \
// -Djdk.virtualThreadScheduler.maxPoolSize=1 你的程序.java
// 如果 4 个各 sleep 300ms 的虚拟线程总共花 ~300ms,说明没被钉住;
// 花 ~1200ms 就是被钉住了(串行执行)
// 真正要注意的(和虚拟线程无关,13 章讲过):别在锁里做慢操作
// ✗
synchronized (锁) {
var 数据 = 网络请求(); // 所有人排队等网络
缓存.put(键, 数据);
}
// ✓
var 数据 = 网络请求();
synchronized (锁) { 缓存.put(键, 数据); }
// 限制并发数:用信号量,不是限制线程数
var 限流 = new Semaphore(10); // 最多 10 个同时打到目标服务
try (var 池 = Executors.newVirtualThreadPerTaskExecutor()) {
for (var 地址 : 一万个地址) {
池.submit(() -> {
限流.acquire();
try { return 抓(地址); }
finally { 限流.release(); }
});
}
}
// 看虚拟线程被钉住的诊断(老开关已移除,用 JFR)
// $ java -XX:StartFlightRecording=filename=r.jfr 你的程序
// $ jfr summary r.jfr | grep PinnedScopedValue 而不是回到线程池)、「阻塞是罪恶的、要用响应式」(虚拟线程下阻塞很便宜)。反过来,13 章那些关于共享状态的规则一条都没变——虚拟线程解决的是「线程太贵」,不是「并发很难」。开了几个并发任务之后,谁来保证它们都结束了?谁来在一个失败时取消其余的?结构化并发把「并发任务的生命周期」限制在一个代码块内——就像 try-with-resources 之于资源。
(JDK 25,需要 --enable-preview)
| 场景 | 结果 |
|---|---|
| 三个任务并行(300/500/400 ms),全部要结果 | 507 ms——等最慢的那个 |
| 两个任务,一个 200 ms 就失败、另一个要 2000 ms | 203 ms 就返回了——慢的那个被自动取消 |
两个镜像,谁快用谁(anySuccessfulResultOrThrow) | 154 ms——快的一返回就走 |
| 带超时 400 ms,任务要 3000 ms | 403 ms 抛 TimeoutException |
它解决了什么
- 没有泄漏的任务:出了作用域,所有 fork 出去的任务要么完成要么被取消。用裸
ExecutorService时很容易漏掉这一点(一个任务失败了,其余的还在后台白跑)。 - 错误自动传播:一个子任务抛异常,
join()就抛,并且其余子任务被中断。 - 堆栈是连贯的:子任务的堆栈能看到父任务的调用链,调试时不再是一堆孤立的线程栈。
- 取消是自动的:不需要自己传
CancellationToken或维护一堆Future去cancel。
它还是预览特性
- JDK 25 上需要
--enable-preview(编译和运行都要加)。不加会直接编译错:StructuredTaskScope is a preview API and is disabled by default(报错原文)。 - API 在 21→25 之间改过好几次(从
new StructuredTaskScope<>()改成了StructuredTaskScope.open(),ShutdownOnFailure改成了Joiner体系)——网上的例子大概率编译不过,以你手上 JDK 的文档为准。 - 不要用在需要长期稳定的代码里;用
ExecutorService+ 手动管理仍然是稳妥的选择。
// 需要 --enable-preview(JDK 25)
// $ java --enable-preview 你的程序.java
// ① 全部成功才算成功(300/500/400ms 三个任务,总共 507ms)
try (var scope = StructuredTaskScope.open()) {
var 北京 = scope.fork(() -> 查天气("北京"));
var 上海 = scope.fork(() -> 查天气("上海"));
var 广州 = scope.fork(() -> 查天气("广州"));
scope.join(); // 等全部(任一失败则整体失败)
IO.println(北京.get() + " " + 上海.get() + " " + 广州.get());
} // 出块保证:所有子任务都已结束或被取消
// ② 一个失败,其余自动取消(203ms 就返回,2000ms 那个被取消)
try (var scope = StructuredTaskScope.open()) {
scope.fork(() -> 很慢的服务()); // 2000ms
scope.fork(() -> 会失败的服务()); // 200ms 后抛异常
scope.join();
} catch (StructuredTaskScope.FailedException e) {
IO.println("失败:" + e.getCause());
}
// ③ 谁快用谁(多个镜像源、多个备用接口)
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.<String>anySuccessfulResultOrThrow())) {
scope.fork(() -> 从镜像A下());
scope.fork(() -> 从镜像B下());
String 结果 = scope.join(); // 第一个成功的(154ms)
}
// ④ 带超时
try (var scope = StructuredTaskScope.open(
StructuredTaskScope.Joiner.awaitAllSuccessfulOrThrow(),
cf -> cf.withTimeout(Duration.ofMillis(400)))) {
scope.fork(() -> 要三秒的服务());
scope.join(); // 403ms 抛 TimeoutException
}
// 不用预览特性时的等价写法(稳妥版)
try (var 池 = Executors.newVirtualThreadPerTaskExecutor()) {
var f1 = 池.submit(() -> 查天气("北京"));
var f2 = 池.submit(() -> 查天气("上海"));
try { IO.println(f1.get() + f2.get()); }
catch (ExecutionException e) { f1.cancel(true); f2.cancel(true); throw e; }
// 取消要自己写,而且容易漏
}StructuredTaskScope 从 Java 21 到 25 换过构造方式、换过取消策略的表达方式。网上搜到的代码(包括 21 时期的官方文档示例)在 25 上大概率编译不过。更重要的是:用 --enable-preview 编出来的 class 文件只能在同一个版本的 JDK 上运行——升级 JDK 就必须重新编译。所以它适合自己的实验项目,不适合要给别人用的东西。Future 并在异常时逐个 cancel,还得处理「取消时机」和「已经完成的怎么办」;结构化并发一个 join() 就全包了。ThreadLocal 是「给每个线程一份自己的数据」的老办法(当前用户、请求 ID、数据库事务)。虚拟线程一多,它的两个缺陷就放大了:一百万个线程各存一份数据是真的占内存,而且它可变、生命周期不清晰。
ScopedValue(JDK 25 正式)
- 不可变:绑定之后在作用域内不能改,只能读。
- 作用域明确:
ScopedValue.where(键, 值).run(...)—— 出了这个块就自动解绑。调用之后isBound()立刻是false。 - 共享而不是复制:子任务(结构化并发 fork 出去的)能继承父作用域的绑定,但不需要各存一份。
- 不需要
remove()——ThreadLocal 忘记 remove 是线程池场景下的经典内存泄漏源。
典型用途
「沿着调用链往下传,但又不想在每个方法签名上加参数」的东西:当前登录用户、请求追踪 ID、当前语言/时区、事务上下文。注意这类东西本质上是隐式参数,用多了会让代码难以理解——能显式传参就显式传,ScopedValue 是给那些「传遍所有方法太啰嗦」的横切关注点准备的。
ThreadLocal 还能用吗
- 能——虚拟线程里
ThreadLocal正常工作。它没有被废弃。 - 但在「一个请求一个虚拟线程」的模型下,
ScopedValue更合适:语义更准(这个值只在这段调用期间有效)、不会泄漏、内存开销小。 - 老代码不用急着改;写新代码时优先考虑
ScopedValue。
// ScopedValue(JDK 25 正式,不需要 --enable-preview)
static final ScopedValue<String> 当前用户 = ScopedValue.newInstance();
// 绑定 + 执行(出了作用域 isBound() 就是 false)
String 结果 = ScopedValue.where(当前用户, "小明")
.call(() -> 处理请求()); // 有返回值用 call
ScopedValue.where(当前用户, "小明")
.run(() -> 处理请求无返回()); // 无返回值用 run
// 深处的方法直接读,不用一路传参
String 处理请求() {
return 查数据();
}
String 查数据() {
return "给 " + 当前用户.get() + " 的数据"; // 五层深也能读到
}
// 没绑定就读会抛异常,先判断
if (当前用户.isBound()) { IO.println(当前用户.get()); }
String 谁 = 当前用户.orElse("游客");
// 嵌套绑定:内层覆盖外层,出来后恢复
ScopedValue.where(当前用户, "小明").run(() -> {
IO.println(当前用户.get()); // 小明
ScopedValue.where(当前用户, "管理员").run(() -> {
IO.println(当前用户.get()); // 管理员
});
IO.println(当前用户.get()); // 小明(自动恢复)
});
// 对比 ThreadLocal 的老写法
static final ThreadLocal<String> 老的 = new ThreadLocal<>();
老的.set("小明");
try {
处理请求();
} finally {
老的.remove(); // ← 忘了这一行就是线程池下的内存泄漏
}
// 每个请求一个虚拟线程 + ScopedValue:今天的标准组合
try (var 池 = Executors.newVirtualThreadPerTaskExecutor()) {
for (var 请求 : 进来的请求) {
池.submit(() -> ScopedValue.where(当前用户, 请求.用户())
.call(() -> 处理(请求)));
}
}ThreadLocal 还是 ScopedValue)用多了会让代码非常难懂。一个方法的行为取决于「调用它之前有没有人绑定了某个值」,而这在签名上完全看不出来——读代码的人要在整个调用链上搜索才知道值从哪来。判据:如果一个值只在两三层调用内传递,老老实实加参数;只有真正的横切关注点(追踪 ID、当前用户、语言环境)才值得用隐式上下文。「省得写参数」不是理由。ScopedValue 的不可变性是特性不是限制。ThreadLocal 允许任何一层代码 set 一个新值,于是「这个值现在是什么」变得难以推理;ScopedValue 只能在进入一个新作用域时重新绑定,值的变化点在代码结构上是可见的。这和 05 章「默认写不可变」是同一个思路。13、14 两章讲了很多工具。这一卡把它们收拢成一张「遇到什么问题用什么」的表。
先分类:你的任务是哪种
| 任务 | 特征 | 用什么 |
|---|---|---|
| IO 密集(网络、文件、数据库) | 大部分时间在等 | 虚拟线程,一个任务一个 |
| CPU 密集(计算、压缩、图像) | 一直在算 | 固定大小的平台线程池(≈ 核数),或并行流 |
| 定时/周期任务 | 每隔一段时间跑 | ScheduledExecutorService |
| 生产者-消费者 | 一边产一边消 | BlockingQueue + 若干消费者线程 |
| 只是想让某件事在后台做 | 一次性 | Thread.startVirtualThread(...) |
再看共享状态怎么办
| 情况 | 做法 |
|---|---|
| 各线程算各的,最后汇总 | 首选——根本没有共享,什么都不用做 |
| 共享只读数据 | 用不可变对象(record / List.of),不用同步 |
| 共享一个计数器 | AtomicInteger / LongAdder |
| 共享一个 Map | ConcurrentHashMap(用它的原子方法) |
| 共享一个状态标志 | volatile boolean |
| 几个字段必须一起改 | synchronized 保护它们,锁对象私有且 final |
| 需要超时 / 尝试获取 | ReentrantLock |
| 沿调用链传上下文 | ScopedValue |
三条不会过时的原则
- 能不共享就不共享——这条能消灭九成问题。
- 共享就共享不可变的——第二好的选择。
- 必须共享可变状态时,把它锁在一个类里面,从外面只能通过方法访问(05 章的封装在这里有了硬性理由)。让「哪些代码会碰这个状态」是有限且可枚举的。
// 场景一:并发抓一百个页面(IO 密集)
try (var 池 = Executors.newVirtualThreadPerTaskExecutor()) {
var 结果 = 地址们.stream().map(u -> 池.submit(() -> 抓(u))).toList();
for (var f : 结果) 处理(f.get());
}
// 场景二:批量压缩一千张图(CPU 密集)
int 核数 = Runtime.getRuntime().availableProcessors();
try (var 池 = Executors.newFixedThreadPool(核数)) {
图片们.forEach(图 -> 池.submit(() -> 压缩(图)));
}
// 或者更简单:图片们.parallelStream().forEach(this::压缩);
// 场景三:一边下载一边处理(生产者-消费者)
BlockingQueue<byte[]> 队列 = new LinkedBlockingQueue<>(50); // 有界 = 自动限流
Thread.startVirtualThread(() -> { // 生产者:IO 型
for (var 地址 : 地址们) 队列.put(下载(地址));
队列.put(结束标记);
});
while (true) { // 消费者
var 数据 = 队列.take();
if (数据 == 结束标记) break;
处理(数据);
}
// 场景四:共享状态封装在一个类里(原则三)
public final class 进度 {
private final AtomicInteger 完成 = new AtomicInteger();
private final int 总数;
public 进度(int 总数) { this.总数 = 总数; }
public void 完成一个() { // 外面只能调这个
int n = 完成.incrementAndGet();
if (n % 100 == 0)
IO.println("%.1f%%".formatted(100.0 * n / 总数));
}
}
// 所有线程共享一个 进度 实例,但只能通过 完成一个() 碰它
// 场景五:定时存档
var 定时 = Executors.newScheduledThreadPool(1);
定时.scheduleWithFixedDelay(() -> 存档(), 5, 5, TimeUnit.MINUTES);深水区:JVM 怎么跑你的代码
这一章回答「我写的这行代码,从保存到执行,中间到底发生了什么」。类加载、即时编译、内存布局、垃圾回收——不知道也能写代码,但知道了,很多奇怪现象就有了解释。
Java 是「两段式」的:先编译成与平台无关的字节码,再由 JVM 边跑边优化成机器码。这个设计解释了它的几乎所有特点——跨平台、启动慢、长跑快。
五个阶段
- ① 编译(
javac):源码 →.class。这一步做类型检查、语法糖展开(增强 for、字符串拼接、lambda、record 的方法生成),但几乎不做优化。 - ② 加载:JVM 在第一次用到某个类时把它读进来、验证、准备静态字段、解析引用(下一卡)。
- ③ 解释执行:一开始逐条解释字节码,启动快但慢。
- ④ 即时编译(JIT):跑得多的方法被编译成机器码,还会根据实际运行数据做激进优化(第 3 卡)。
- ⑤ 垃圾回收:全程在后台回收不再用的对象(第 5 卡)。
用 javap 看编译器到底生成了什么
javap -c 你的类.class 能反汇编出字节码。本机上的一个例子:"x" + a + b 编译成一条 invokedynamic makeConcatWithConstants 指令——不是老教程说的 StringBuilder(04 章)。类似地你能看到:增强 for 展开成迭代器循环、switch 编译成 tableswitch/lookupswitch、lambda 也是 invokedynamic。
为什么是字节码而不是直接编译成机器码
- 跨平台:同一个
.class在任何有 JVM 的地方都能跑。 - 更好的优化时机:JIT 能看到真实的运行数据——哪个分支常走、哪个方法的参数实际是什么类型——这些信息编译期拿不到。所以长时间运行的 Java 程序可以比预先编译的版本更快。
- 代价:启动要预热、要额外的内存放 JIT 编译的代码和运行数据。
# 看编译产物:javap 是最好的学习工具
$ javac Concat.java
$ javap -c -p Concat.class
# 输出片段:字符串拼接编成了 invokedynamic
static java.lang.String f(java.lang.String, int);
Code:
0: aload_0
1: iload_1
2: invokedynamic #7, 0 // makeConcatWithConstants
7: areturn
# 更多有用的 javap 选项
$ javap -c -p -v X.class # -v 还显示常量池、字段、行号表
$ javap -s X.class # 显示方法签名(写 JNI/反射时用)
$ javap java.util.List # 不带 -c 就是看公开 API(不用查文档)
# 看类加载的顺序(排查"这个类从哪来的")
$ java -verbose:class X | head -20
# 看 JIT 编译了哪些方法
$ java -XX:+PrintCompilation X 2>&1 | head
# 看 GC 在干什么
$ java -Xlog:gc X
# 看所有 JVM 参数的最终值(排查"这个参数生效了吗")
$ java -XX:+PrintFlagsFinal -version | grep MaxHeapSize.class 文件有版本号,新版本编的不能在老 JVM 上跑。用 JDK 25 编译出来的类,拿到 JDK 21 上运行会抛 UnsupportedClassVersionError: ... has been compiled by a more recent version of the Java Runtime。反过来完全没问题(向后兼容)。要给老版本用,编译时加 --release 21(它同时限制语法和 API,比老的 -source/-target 组合可靠——那个组合会让你用到新 API 却在老 JVM 上找不到)。javap 是理解 Java 语法糖最直接的工具。不确定「这个新语法编译成了什么」时,写个五行的类编译一下 javap -c 看看——record 生成了哪些方法、增强 for 展开成什么、模式匹配 switch 用了什么指令,全都一目了然。它比读十篇解释文章快,而且不会被过时的说法误导。JVM 不会一次性加载所有类,而是在第一次「主动使用」时才加载。这解释了很多「为什么静态代码块在这个时候才跑」的现象。
什么算「主动使用」
new一个实例、访问静态字段或方法、反射、初始化子类时先初始化父类、启动类(main所在的类)。- 不算的:声明一个该类型的变量、访问
static final的编译期常量(那个值早就被内联进调用方了)、通过子类访问父类的静态字段。 - 这就是 05 章那条 pitfall 的机制:静态初始化「什么时候跑」取决于运行路径。
三个阶段
- 加载:找到
.class字节,创建Class对象。 - 链接:验证字节码合法(这是 JVM 安全的基础)、给静态字段分配空间并设成默认值、解析符号引用。
- 初始化:执行静态字段的赋值和
static块——JVM 保证这一步是线程安全且只跑一次的(这是「静态初始化实现单例」能成立的原因)。
类路径:类从哪里来
-cp(或--class-path)指定去哪找类:目录、jar 文件、通配符libs/*。分隔符 Windows 是;、macOS/Linux 是:。- 顺序有意义:同名类以先找到的为准。这就是「依赖冲突」时表现诡异的原因。
- 模块路径(
--module-path)是 Java 9 引入的另一套机制,日常小项目用不到,但jlink(18 章)依赖它。
两个长得像的异常,含义完全不同
| 异常 | 意思 | 常见原因 |
|---|---|---|
ClassNotFoundException | 按名字找类时没找到 | 反射/Class.forName 时名字写错,或 jar 没在类路径上 |
NoClassDefFoundError | 编译时有,运行时没有 | 类路径缺 jar;或者这个类的静态初始化曾经抛过异常 |
第二行括号里那条很值得记:一个类初始化失败后,后续每次使用它都会抛 NoClassDefFoundError,而真正的原因(第一次的那个异常)早就被刷屏冲掉了。看到 NoClassDefFoundError 却确信 jar 在,就去日志最开头找第一个 ExceptionInInitializerError。
// 懒加载:静态块什么时候跑
class 重的 {
static { IO.println("重的 被初始化了"); }
static final String 常量 = "hi"; // 编译期常量
static final String 非常量 = 造一个(); // 运行期才知道
}
重的 x; // 不触发初始化(只是声明)
IO.println(重的.常量); // 不触发!编译期常量已被内联
IO.println(重的.非常量); // 触发 → 打印"重的 被初始化了"
new 重的(); // 触发
// 类初始化是线程安全且只跑一次的 —— 单例最简写法
public class 配置 {
private static class 持有 {
static final 配置 实例 = 加载(); // 第一次访问 持有.实例 时才加载
}
public static 配置 获取() { return 持有.实例; }
// 不需要 synchronized、不需要 volatile、不需要双重检查锁
}
# 类路径:分隔符各平台不同
$ java -cp "libs/*:." Main # macOS / Linux
$ java -cp "libs/*;." Main # Windows
# 排查"到底加载了哪个版本的类"
$ java -verbose:class Main | grep 某个类名
# 输出会显示每个类是从哪个 jar 或目录加载的
// 程序内查同一个问题
IO.println(某个类.class.getProtectionDomain().getCodeSource().getLocation());
// 反射加载(框架的基础)
Class<?> c = Class.forName("我的包.插件"); // 找不到抛 ClassNotFoundException
Object 实例 = c.getDeclaredConstructor().newInstance();NoClassDefFoundError 最容易误导的一种情况:它可能不是类路径问题。如果一个类的静态初始化抛了异常(比如静态块里读配置文件失败),JVM 会把这个类标记为「不可用」,之后每次访问它都抛 NoClassDefFoundError: Could not initialize class X——而真正的原因是第一次那个 ExceptionInInitializerError,它可能在几千行日志之前。排查顺序:先确认 jar 在不在类路径上(-verbose:class),确认在,就回头去日志最开头找第一个异常。JVM 有三档执行方式,同一个方法可能先解释、再被 C1 编译、最后被 C2 重新编译。这就是「预热」的含义。
三档的差距
| 执行方式 | 同一段计算的耗时 |
|---|---|
| 默认(分层编译,最终到 C2) | 约 9 ms |
-XX:TieredStopAtLevel=1(只用 C1) | 约 24 ms(约 2.7 倍) |
-Xint(纯解释,不编译) | 约 32 ms(约 3.5 倍) |
倍数在你的机器上会不同,但顺序和量级是稳定的。(这段计算里 Math.sqrt 是 JVM 内建函数,差距不算大;纯 Java 循环的差距通常更明显。)
JIT 能做而提前编译做不到的事
- 激进内联:把小方法直接展开进调用处。这是 JIT 最大的收益来源——它顺带让「写很多小方法」这种好习惯不再有性能代价。
- 基于实际类型的去虚拟化:接口方法调用时,如果实际运行中只出现过一种实现,JIT 直接编译成对那个实现的直接调用(甚至内联进去)。提前编译只能保守地留一个虚调用。
- 基于分支频率的布局优化、循环展开、逃逸分析后的栈上分配(对象根本不进堆)。
去优化:赌错了就退回去
(-XX:+PrintCompilation 的真实输出片段):
1630 % 4 W::main @ 18 (96 bytes) 1630 % 4 W::main @ 18 (96 bytes) made not entrant: uncommon trap
%表示这是 OSR(栈上替换——正在跑的循环中途被换成编译版本);3/4是编译层级(C1 带profile / C2)。uncommon trap就是「去优化」:JIT 假设某个分支不会走(或某个类型不会出现),结果它出现了,于是丢弃编译结果退回解释执行,重新收集数据再编译。- 这就是「预热」的真正含义:不只是「编译需要时间」,还包括「让 JIT 见识到足够多样的实际数据,做出正确的假设」。
对你写代码的影响
- 不要为了性能牺牲可读性去做手工优化(手动内联、避免小方法、把循环展开)——JIT 做得比你好,而且你的手工优化可能妨碍它(方法太大就不会被内联)。
- 测性能必须预热(16 章)——不预热测出来的是解释器的速度。
- 短命的程序享受不到 JIT:跑 200 毫秒就退出的 CLI 工具,全程基本在解释执行。这正是 AOT 缓存和 NativeAOT 想解决的(本章第 6 卡、18 章)。
# 三档对比:自己跑一遍(用你的循环密集代码)
$ java 你的程序.java # 默认:分层编译
$ java -XX:TieredStopAtLevel=1 你的程序.java # 只到 C1
$ java -Xint 你的程序.java # 纯解释
# 看 JIT 都编译了什么(输出格式)
$ java -XX:+PrintCompilation 你的程序.java 2>&1 | head -20
# 时间 序号 标记 层级 方法名 大小
# 484 1628 % 3 W::main @ 18 (96 bytes)
# 494 1630 % 4 W::main @ 18 ... made not entrant: uncommon trap
# ↑ % = OSR ↑ 3=C1带profile 4=C2 ↑ 去优化了
# 看某个方法为什么没被内联(调优时才用得上)
$ java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining 你的程序.java
// 预热:测性能前必须做(16 章会展开)
static void 预热() {
for (int i = 0; i < 10; i++) 要测的方法(样本数据);
}
// JIT 会内联小方法,所以这样写没有性能负担
private boolean 能走(int 行, int 列) { // 会被内联进调用处
return 在界内(行, 列) && !是墙(行, 列);
}
// 反而是"为了性能"把方法写得很大,会让它超过内联阈值
// -XX:MaxInlineSize(默认 35 字节码)/ -XX:FreqInlineSize(默认 325)for(i<N) acc+=i 会被编译器用求和公式一步算完)。这三个坑我在写这一页的实验时全踩过——所以本页所有性能数字都用了 volatile 汇聚点和无闭式解的运算。JVM 的内存分成几块,知道「什么东西放在哪」能解释很多现象:为什么递归会 StackOverflowError 而不是 OutOfMemoryError、为什么一百万个 Integer 那么占地方。
四块主要区域
| 区域 | 放什么 | 谁的 | 满了会 |
|---|---|---|---|
| 堆 | 所有对象和数组 | 全局共享 | OutOfMemoryError: Java heap space |
| 栈 | 局部变量、方法调用帧 | 每个线程一份 | StackOverflowError |
| 元空间 | 类的元数据(不是对象) | 全局 | OutOfMemoryError: Metaspace |
| 代码缓存 | JIT 编译出来的机器码 | 全局 | 退回解释执行(会明显变慢) |
默认大小(你的机器上会不同)
- 最大堆 = 物理内存的 1/4(32 GB 内存的机器上是 7120 MB),初始堆 = 1/64。
- 每个线程的栈:通常几百 KB 到 1 MB 量级,
-Xss可调。这就是平台线程「贵」的主要原因(14 章)。 - 调堆大小:
-Xmx2g(最大)、-Xms2g(初始)。容器里跑要特别注意——现代 JVM 能识别容器内存限制,但老版本会看到宿主机的内存而设得过大。
一个对象有多大
- 对象头:12~16 字节(标记字 + 类指针),每个对象都有。
- 字段:按类型算,还要按 8 字节对齐。
- 一百万个
Integer约 20 MB(每个约 16 字节对象 + 4 字节引用),一百万个int约 4 MB——差 5 倍。这就是 02、09 章说「大量数字用数组」的量化依据。 - 压缩指针(
UseCompressedOops,堆小于 32 GB 时默认开启)把 64 位引用压成 32 位,能省下可观的内存。所以把堆从 30 GB 调到 33 GB 反而可能变慢——这是个有名的反直觉现象。
逃逸分析:有些对象根本不进堆
如果 JIT 能证明一个对象没有逃出方法(没被返回、没被存进字段、没传给别的线程),它可以把对象拆成几个局部变量放在栈上甚至寄存器里——完全不分配、不需要回收。两千万次「new 一个小 record 再读它的两个字段」约 25 ms,直接算是 15 ms——只差不到一倍,说明大部分分配确实被消除了(真在堆上分配两千万个对象要慢得多)。
# 看默认值(MaxHeapSize 约为物理内存 1/4)
$ java -XX:+PrintFlagsFinal -version | grep -E "MaxHeapSize|InitialHeapSize"
size_t InitialHeapSize = 469762048 {ergonomic}
size_t MaxHeapSize = 7465861120 {ergonomic}
# 调整
$ java -Xmx2g -Xms512m 你的程序 # 最大 2G,初始 512M
$ java -Xss2m 你的程序 # 每个线程栈 2M(深递归时用)
// 程序内看内存
var rt = Runtime.getRuntime();
IO.println("最大堆 " + rt.maxMemory() / 1048576 + " MB");
IO.println("已用 " + (rt.totalMemory() - rt.freeMemory()) / 1048576 + " MB");
// 装箱的内存代价(一百万个)
Integer[] 装箱的 = new Integer[1_000_000]; // 约 20 MB
int[] 原始的 = new int[1_000_000]; // 约 4 MB
// 栈 vs 堆:谁放哪
void 方法() {
int a = 1; // 栈上(基本类型局部变量)
int[] b = new int[10]; // b 这个引用在栈上,数组本体在堆上
var c = new 坐标(1, 2); // 可能被逃逸分析优化掉,根本不分配
}
// 逃逸分析的边界:一旦"逃出去"就必须真的分配
static List<坐标> 全局 = new ArrayList<>();
void 逃了() { 全局.add(new 坐标(1, 2)); } // 存进了字段 → 必须在堆上
# 关掉逃逸分析对比一下(学习用,别在生产用)
$ java -XX:-DoEscapeAnalysis 你的程序
# 看堆里到底有什么(16 章展开)
$ jcmd <pid> GC.class_histogram | head -20StackOverflowError 和 OutOfMemoryError 是两回事,别混。前者是某个线程的栈用完了(递归太深、或者每帧局部变量太大),-Xss 调栈大小;后者是堆用完了,-Xmx 调堆大小。而且两者都可能是「调大了也没用」:无限递归调多大都会炸,内存泄漏也是——调参数前先确认是不是真的需要这么多。堆内存不够时,先用 jcmd <pid> GC.class_histogram 看看到底是什么占满了(16 章)。jcmd <pid> GC.heap_info),再设一个有余量的值。GC 是 Java 最省心的部分——99% 的情况下你什么都不用做。这一卡讲的是那 1%:程序停顿明显、内存持续增长、或者你只是好奇它在干什么。
核心假设:绝大多数对象很快就死
- 这是「分代假设」,几十年的实践都支持它:一个方法里 new 出来的临时对象,出了方法就没人用了。
- 于是 GC 把堆分成新生代和老年代:新对象放新生代,那里回收非常快(只需要复制少数还活着的对象出去);活得久的才晋升到老年代。
- 推论:产生大量短命的小对象并不可怕——这正是 GC 最擅长的场景。真正麻烦的是「活得久的大对象」。
GC 日志长什么样
$ java -Xlog:gc 你的程序
[0.009s][info][gc] Using G1 ← 默认收集器
[0.460s][info][gc] GC(0) Pause Young (Normal)
(G1 Evacuation Pause) 49M->4M(452M) 2.625ms怎么读这一行:49M->4M 表示回收前用了 49 MB、回收后只剩 4 MB(说明 45 MB 全是垃圾,分代假设成立),(452M) 是当前堆总大小,2.625ms 是停顿时间。
选哪个收集器
| 收集器 | 特点 | 什么时候用 |
|---|---|---|
| G1(默认) | 停顿可控、吞吐不错 | 不知道就用它 |
ZGC(-XX:+UseZGC) | 停顿极低(亚毫秒),堆再大也一样 | 堆很大且对延迟敏感 |
Parallel(-XX:+UseParallelGC) | 吞吐最高,停顿较长 | 批处理,不在乎卡顿 |
Serial(-XX:+UseSerialGC) | 单线程,开销最小 | 小容器、小堆、CLI 工具 |
同一段分配密集的代码,G1 打印一次 young GC,ZGC 一次都没打印(它的日志更细,默认级别下不显示),Serial 打印了两次且停顿更长。换收集器之前先确认你真的有 GC 问题——绝大多数程序不需要动它。
你能做的事(比调参数有用得多)
- 少产生长命的垃圾:缓存要有上限(09 章那个 LRU)、监听器要反注册、集合别只增不减。
- 别调
System.gc():它只是「建议」,而且通常触发一次代价高昂的 Full GC。 - 内存持续增长时,先找泄漏而不是加内存(下一章的工具)。
# 看 GC 在干什么
$ java -Xlog:gc 你的程序 # 基本日志
$ java -Xlog:gc* 你的程序 # 详细(很吵)
$ java -Xlog:gc:file=gc.log 你的程序 # 写到文件
# 换收集器
$ java -XX:+UseZGC 你的程序 # 低延迟
$ java -XX:+UseSerialGC 你的程序 # 小工具/小容器
$ java -XX:+UseParallelGC 你的程序 # 批处理
# 设定停顿目标(G1 会尽力,不保证)
$ java -XX:MaxGCPauseMillis=50 你的程序
// 分代假设的体现:这样写完全没问题
for (var 行 : 一百万行) {
var 字段 = 行.split(","); // 每次都 new 一个数组
var 记录 = new 记录(字段[0], 字段[1]); // 每次都 new 一个对象
处理(记录);
} // 这些对象全是短命的,young GC 处理它们几乎免费
// ✗ 真正会出事的:无上限地积累长命对象
static final Map<String, byte[]> 缓存 = new HashMap<>();
缓存.put(键, 大数据); // 从不清理 = 内存泄漏
// ✓ 有上限的缓存(09 章那个 LRU)
static final Map<String, byte[]> 缓存2 = new 最近使用<>(1000);
// ✗ 别这么写
System.gc(); // 只是建议,而且通常触发昂贵的 Full GC
# 看堆的实时状态
$ jcmd <pid> GC.heap_info
$ jstat -gc <pid> 1000 # 每秒打印一次各代使用情况-Xlog:gc 看停顿时长和频率);② 确认是不是代码问题(是不是在制造不必要的长命对象);③ 只在前两步都排除后才动参数,而且一次只动一个并量化效果。ThreadLocal 忘了 remove),那些对象在 GC 眼里是活的,永远不会被回收。Java 的内存泄漏就是「不该活着的东西还被引用着」,找它的办法是看堆里什么最多(下一章)。Java 启动慢的原因是每次启动都要重做一遍相同的工作:加载几百个类、验证字节码、解析常量池、解释执行到 JIT 热起来。缓存这些工作的结果,就是 CDS 和 AOT 缓存在做的事。
三代方案
- CDS(类数据共享):把类的元数据预先处理好存成一个归档文件,启动时直接映射进内存。JDK 自带的核心类库归档默认就是开启的——
java -version输出里那个sharing就是它。 - AppCDS:把你自己应用的类也一起归档。
- AOT 缓存(JDK 24/25):更进一步,还缓存已链接的类和部分预热信息,而且命令行简化成了一步(JEP 514)。
AOT 缓存
$ java -XX:AOTCacheOutput=app.aot -cp classes App
... AOTCache creation is complete: app.aot 10158080 bytes // 约 10 MB
启动耗时(同一个小程序,各跑 5 次):
不用缓存:168 / 166 / 167 / 162 / 158 ms
用缓存: 145 / 142 / 146 / 155 / 148 ms- 这个例子只快了约 12%——因为程序本身很小,加载的类不多。类越多、框架越重,收益越大(真实应用常见的是几十个百分点)。
- 代价是一个 10 MB 的缓存文件,而且它和 JDK 版本、类路径绑定——换 JDK 或改依赖就要重新生成。
什么时候值得做
- 值得:命令行工具(每次调用都是一次冷启动)、容器里频繁重启的服务、Serverless、CI 里跑几百次的测试。
- 不值得:长期运行的服务(启动省的 100 毫秒相对于跑几个月毫无意义)、还在频繁改代码的开发阶段。
- 更彻底的方案是 NativeAOT(GraalVM,18 章):直接编译成原生可执行文件,启动是毫秒级,代价是放弃反射和动态加载的灵活性、构建时间长。
# AOT 缓存:一条命令生成(JDK 25 的 JEP 514)
$ java -XX:AOTCacheOutput=app.aot -cp classes App
# 它会启动一个子进程跑一遍你的程序,记录用到了哪些类,然后生成缓存
# 之后用缓存启动
$ java -XX:AOTCache=app.aot -cp classes App
# 老一些的 AppCDS 两步法(JDK 13+,21 上也能用)
$ java -XX:ArchiveClassesAtExit=app.jsa -cp app.jar App
$ java -XX:SharedArchiveFile=app.jsa -cp app.jar App
# 确认核心类库的 CDS 是否开启(默认就开)
$ java -version
# ... mixed mode, sharing ← 这个 sharing 就是 CDS
# 量一下你自己的启动时间(最朴素但有效)
$ for i in 1 2 3 4 5; do
s=$(date +%s%N); java -cp classes App >/dev/null; e=$(date +%s%N)
echo $(( (e-s)/1000000 )) ms
done
// 程序内看自己启动了多久(JVM 启动到现在)
long 已运行 = ManagementFactory.getRuntimeMXBean().getUptime();
IO.println("启动用了 " + 已运行 + " ms");
# 另一个便宜的启动优化:小程序用 Serial GC + 关掉分层编译的高层
$ java -XX:+UseSerialGC -XX:TieredStopAtLevel=1 -cp classes App
# 对跑几百毫秒就退出的工具,不做 C2 编译反而更快(省下编译时间)-Xlog:cds 看它有没有真的用上。另外这个缓存文件不要提交进版本库(它是构建产物,而且和构建环境绑定)。-XX:TieredStopAtLevel=1(别做 C2 编译,反正来不及受益);② -XX:+UseSerialGC(省下 G1 的初始化开销);③ AOT 缓存。三个加起来经常能砍掉三分之一的启动时间,而且都不需要改一行代码。可以把它们写进一个包装脚本里(18 章的 jpackage 会自动生成这样的启动器)。性能与内存:量出来再说
这一章只讲一件事:怎么知道慢在哪。Java 的性能问题几乎从来不在你猜的那个地方,而测量本身有一堆能让结论完全反过来的陷阱——本页所有性能数字都是踩着这些坑测出来的。
在 JVM 上做微基准测试,不小心的话测出来的数字会完全没有意义甚至方向相反。这一卡是写这一页时真实踩过的三个坑。
陷阱一:不预热
前几次调用还在解释执行(15 章),测出来是解释器的速度,可能比稳定状态慢好几倍。修法:先空跑几轮再计时。
陷阱二:结果没人用 → 整段代码被删
// 第一版:想测"两千万次自增" long t = System.nanoTime(); long acc = 0; for (int i = 0; i < 20_000_000; i++) acc += i; System.out.println((System.nanoTime()-t)/1e6 + " ms"); // 打印出 0.0 ms
- JIT 发现
acc后面没人用,把整个循环删了。 - 修法:把结果赋给一个
static volatile字段(汇聚点),或者打印出来。
陷阱三:循环有闭式解 → 编译器一步算完
- 就算加了汇聚点,
for(i<N) acc += i仍然可能测出 0 毫秒——因为等差数列求和有公式,编译器直接算出结果,循环根本没跑。 - 修法:换成没有闭式解的运算(
acc += Math.sqrt(i) / i),或者让累加器本身volatile(强制每次真的读写内存,虽然这也引入了额外开销)。
所以怎么测才算对
- 认真的基准测试用 JMH(
org.openjdk.jmh,OpenJDK 官方出的)——它自动处理预热、死代码消除(Blackhole)、分叉进程、统计误差。要发布性能结论就用它。 - 快速对比用手写的也行,但必须:预热 + 汇聚点 + 无闭式解 + 跑多轮取中位数 + 规模翻倍看耗时怎么变(这个比绝对值可靠得多)。
- 最可靠的结论形式是「形状」而不是「数字」:04 章那个字符串拼接的—规模翻倍耗时翻四倍——这个 O(n²) 的形状在任何机器上都成立,而具体毫秒数只属于我的机器。
// ✗ 三个坑都踩了的版本
long t = System.nanoTime();
long acc = 0;
for (int i = 0; i < 20_000_000; i++) acc += i; // 有闭式解 + 结果没人用
IO.println((System.nanoTime() - t) / 1e6); // 0.0 ms(没预热也没关系,反正没跑)
// ✓ 手写基准的最低要求
static volatile Object 汇聚点; // ① 防止结果被优化掉
static double 测一次(Runnable 要测的) {
long t = System.nanoTime();
要测的.run();
return (System.nanoTime() - t) / 1e6;
}
void main() {
for (int i = 0; i < 5; i++) 测一次(this::任务); // ② 预热
var 结果 = new ArrayList<Double>();
for (int i = 0; i < 10; i++) 结果.add(测一次(this::任务)); // ③ 多轮
结果.sort(null);
IO.println("中位数 " + 结果.get(5) + " ms"); // ④ 取中位数不取平均
}
void 任务() {
double acc = 0;
for (int i = 1; i < 5_000_000; i++) acc += Math.sqrt(i) / i; // ⑤ 无闭式解
汇聚点 = acc; // ⑥ 用掉结果
}
// ✓✓ 最可靠:看"规模翻倍时耗时怎么变"(这个结论跨机器成立)
for (int n : new int[]{10_000, 20_000, 40_000}) {
IO.printf("n=%d %.1f ms%n", n, 测一次(() -> 跑(n)));
}
// 翻倍→翻倍 = O(n);翻倍→翻四倍 = O(n²)(04 章字符串拼接就是这么测出来的)
// 认真做基准测试就用 JMH(需要引依赖)
// @Benchmark public void 测这个(Blackhole bh) { bh.consume(算一下()); }System.currentTimeMillis() 测短时间——它的精度可能只有十几毫秒,测 5 毫秒的东西全是噪音(用 nanoTime());③ 机器上还开着别的程序。结论:任何让你惊讶的性能数字,先怀疑测法。在动手优化之前,先看一眼这份清单——真实项目里的性能问题,绝大多数是这几类中的一种,而且都不需要什么高深技巧就能修。
① 算法复杂度(收益最大)
- 在列表里查找: 20 万元素做 2000 次
contains,ArrayList约 7.4 ms、HashSet约 0.15 ms(约 50 倍,且随规模拉大)。 - 循环里拼字符串: O(n²),1 万次 9 ms、4 万次 62 ms(04 章)。
- 嵌套循环做匹配:两个一万元素的列表两两比较是一亿次——先把一个建成
Map就降到线性。 - 这一类的判据不是「跑一遍多久」,而是「数据翻十倍会怎样」。
② 集合与数据结构选错
LinkedList.get(i)比ArrayList慢约一千倍(20 万元素);TreeMap查找约为HashMap的 2 倍耗时。- 没有预设容量:
new ArrayList<>()装十万个元素要扩容十几次(每次复制整个数组)。已知大小就写new ArrayList<>(100_000)。
③ 装箱与不必要的对象
- 五百万次累加,
long约 3.8 ms、Long约 12.6 ms(三倍多);一百万个Integer占 20 MB vsint[]4 MB。 - 热点循环里的
Map<Integer, X>和List<Double>是常见的隐形成本。
④ IO 才是真正的大头
- 一次网络往返(毫秒级)抵得上几百万次内存操作。优化了半天的循环,可能还不如少发一次请求。
- 没有缓冲的 IO:逐字节读文件比带缓冲慢几个数量级(用
Files.readAllLines/BufferedReader,别用裸FileInputStream.read())。 - N+1 查询:循环里每次查一次数据库——这是 Web 应用最经典的性能问题。
不在清单上的:那些「优化」通常没用
final 不会让代码变快、手动内联小方法反而妨碍 JIT、StringBuilder 预分配的收益很小、i++ 和 ++i 完全一样、位运算代替乘除法在现代 JIT 下没有意义。这些「优化」的共同点是牺牲可读性换取零收益。
// ① 复杂度:嵌套循环 → 用 Map 降到线性
// ✗ 一亿次比较
for (var a : 一万个订单)
for (var b : 一万个用户)
if (a.用户号() == b.编号()) 配对(a, b);
// ✓ 两万次
var 用户表 = 一万个用户.stream()
.collect(Collectors.toMap(用户::编号, u -> u));
for (var a : 一万个订单) 配对(a, 用户表.get(a.用户号()));
// ② 查找:List 换 Set(约 50 倍)
if (黑名单列表.contains(名)) { } // ✗ 每次线性扫
var 黑名单 = Set.copyOf(黑名单列表);
if (黑名单.contains(名)) { } // ✓
// ③ 预分配容量(已知大小时)
var 列表 = new ArrayList<String>(100_000);
var 表 = new HashMap<String, Integer>(200_000);
var sb = new StringBuilder(1024);
// ④ 装箱:热点循环里用基本类型流(三倍差距)
long 和 = 数字们.stream().mapToInt(Integer::intValue).sum(); // 比 reduce 少装箱
int[] 大量数字 = new int[1_000_000]; // 比 List<Integer> 省 5 倍内存
// ⑤ IO:一次读完 vs 逐行 vs 逐字节
Files.readString(路径) // 小文件:最简单
try (var 行 = Files.lines(路径)) { } // 大文件:流式,不占内存
new BufferedReader(new FileReader(f)) // 一定要有 Buffered
// ⑥ 少发一次请求,胜过优化一万行代码
// ✗ N+1
for (var 编号 : 编号们) 结果.add(查一个(编号)); // N 次往返
// ✓ 批量
var 结果 = 批量查(编号们); // 1 次往返
// 不值得做的"优化"(零收益,负可读性)
// x = y << 3 代替 x = y * 8 —— JIT 自己会做
// ++i 代替 i++ —— 完全一样
// 把三个小方法手动合成一个大方法 —— 反而可能超过内联阈值HashSet 而不是 ArrayList 做查找、用 StringBuilder 而不是 += 在循环里拼字符串——这些是默认就该这么写的,不需要先测。真正需要「先测再改」的是那些牺牲可读性的微观调整。内存问题有两种:「一次性用太多」(该用流式处理却全读进内存)和「持续增长」(泄漏)。两者的排查手段一样,判断依据不同。
第一步:看堆里什么最多
jcmd <pid> GC.class_histogram 直接列出每种类有多少个实例、共占多少字节,按大小排序。这是排查内存问题最快的一步,不用改代码、不用重启、几秒钟出结果。
- 看到大量
byte[]/char[]:通常是字符串或缓冲区没释放。 - 看到某个业务类有几百万个实例:直接就是嫌疑人。
- 看到大量
HashMap$Node:某个 Map 在无限增长。
第二步:确认是不是泄漏
- 隔一段时间再看一次直方图:数量持续增长且 GC 之后也不下降 → 泄漏。
- 或者看 GC 日志:每次 Full GC 之后的存活量在阶梯式上升 → 泄漏。
第三步:找到是谁引用着它
jcmd <pid> GC.heap_dump 文件.hprof 导出堆快照,用 Eclipse MAT 或 VisualVM 打开,看「支配树」和「到 GC Root 的最短路径」——它会直接告诉你「这个对象因为被 XXX 引用着所以没被回收」。
Java 里内存泄漏的五种典型模式
- 静态集合只增不减(最常见):
static Map 缓存从来不清理。 - 监听器/回调没反注册:注册进一个长命对象,界面关了也回收不掉(05 章)。
ThreadLocal忘了remove:线程池里线程复用,值一直挂着(14 章的ScopedValue没这问题)。- 非静态内部类隐式持有外部实例(05 章)。
- 大对象的小片段:
substring在很老的 Java 里会共享底层数组(现在不会了),但类似模式在自定义类里仍然存在。
# 第一步:看堆里什么最多(最快、最有用)
$ jcmd -l # 找到 pid
$ jcmd <pid> GC.class_histogram | head -20
# num #instances #bytes class name
# 1: 2,341,882 93,675,280 [B ← byte[],大头
# 2: 2,341,881 56,205,144 java.lang.String
# 3: 890,102 28,483,264 我的包.缓存项 ← 嫌疑人
# 第二步:看堆整体状况
$ jcmd <pid> GC.heap_info
$ jstat -gc <pid> 2000 # 每 2 秒一次,看趋势
# 第三步:导出堆快照,用 MAT / VisualVM 分析
$ jcmd <pid> GC.heap_dump /tmp/heap.hprof
# 或者让它在 OOM 时自动导出(生产环境值得默认开)
$ java -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp 你的程序
// 五种泄漏模式的修法
// ① 静态集合 → 加上限(09 章的 LRU)
static final Map<String, 数据> 缓存 = new 最近使用<>(1000);
// ② 监听器 → 记得反注册
try {
源.添加监听(我的监听器);
干活();
} finally {
源.移除监听(我的监听器);
}
// ③ ThreadLocal → 用完 remove,或者改用 ScopedValue(14 章)
try { 上下文.set(值); 干活(); }
finally { 上下文.remove(); }
// ④ 一次性读太多 → 改成流式
// ✗ 十 GB 的文件
List<String> 全部 = Files.readAllLines(大文件);
// ✓ 流式,内存里只有一行
try (var 行 = Files.lines(大文件)) {
行.filter(条件).forEach(this::处理);
}
// ⑤ 分批处理(11 章的 Gatherers)
try (var 行 = Files.lines(大文件)) {
行.gather(Gatherers.windowFixed(1000)).forEach(this::批量处理);
}OutOfMemoryError: Java heap space 的第一反应不该是调大 -Xmx。如果是真泄漏,调大只是把崩溃推迟几小时;如果是一次性读太多,改成流式处理才是正解。调大堆的合理场景只有一个:程序确实需要这么多内存,而你之前设小了。判断办法:看 GC 日志里 Full GC 之后的存活量——如果它稳定在某个值,那就是程序的真实需求(设成它的 1.5~2 倍);如果它一路上涨,调多大都没用。jcmd <pid> GC.class_histogram 是排查内存问题性价比最高的一条命令。它不需要提前配置任何东西、对运行中的程序影响很小、输出直接按占用大小排序。很多内存问题看一眼直方图就明白了——某个类有几百万个实例,而你知道它本该只有几千个。只有在直方图看不出来时才需要动堆转储和 MAT(那个流程要重得多)。JDK 自带一整套诊断工具,不需要装任何东西。这一卡是速查表:遇到什么问题用哪条命令。
按症状查工具
| 症状 | 命令 |
|---|---|
| 程序卡住不动 | jcmd <pid> Thread.print(死锁会直接报出来) |
| 内存持续增长 | jcmd <pid> GC.class_histogram |
| CPU 打满 | 先 Thread.print 看哪些线程 RUNNABLE,再用 JFR 采样 |
| 想知道哪个方法最耗时 | JFR 录一段,jfr summary / 用 JMC 打开 |
| 不知道进程 pid | jcmd -l 或 jps -l |
| 想看 JVM 参数 | jcmd <pid> VM.flags |
| 想看 GC 情况 | jstat -gc <pid> 1000 |
| 想看某个类从哪个 jar 加载的 | java -verbose:class |
JFR:生产环境可用的性能记录器
- JDK 自带、开销极低(通常 1% 量级),可以在生产环境常开。
- 用
settings=profile录 2 秒,得到 558 KB 的文件,里面有 GC 各阶段、对象分配采样、执行采样、异常抛出、去优化事件、线程状态……一次录制包含了几乎所有你想知道的东西。 - 命令行看:
jfr summary r.jfr(事件统计)、jfr print --events jdk.ExecutionSample r.jfr(具体采样)。图形界面用 JDK Mission Control(JMC),它能直接给出火焰图和「热点方法」列表。
图形工具
- VisualVM(需单独下载):连上一个跑着的 JVM,实时看堆、线程、CPU 采样。适合本地开发。
- JMC(JDK Mission Control):分析 JFR 录制文件的官方工具。
- Eclipse MAT:分析堆转储,找内存泄漏的引用链。
# 找进程
$ jcmd -l
$ jps -l
# 卡住了 —— 第一条该敲的命令
$ jcmd <pid> Thread.print
# 输出末尾会直接写 "Found one Java-level deadlock:" 并列出环
# 内存 —— 第二常用
$ jcmd <pid> GC.class_histogram | head -20
$ jcmd <pid> GC.heap_info
$ jcmd <pid> GC.heap_dump /tmp/heap.hprof
# 看这个 JVM 的配置
$ jcmd <pid> VM.flags # 生效的参数
$ jcmd <pid> VM.system_properties # 系统属性
$ jcmd <pid> VM.uptime # 跑了多久
$ jcmd <pid> help # 还能做什么(很长一串)
# JFR:录一段(profile 设置 2 秒约 558 KB)
$ java -XX:StartFlightRecording=duration=30s,filename=r.jfr,settings=profile 你的程序
# 或者对已经在跑的进程
$ jcmd <pid> JFR.start duration=30s filename=r.jfr settings=profile
$ jcmd <pid> JFR.dump filename=r.jfr
# 看录制内容(输出片段)
$ jfr summary r.jfr
# Event Type Count Size (bytes)
# jdk.GCPhaseParallel 2295 52548
# jdk.ObjectAllocationSample 289 4026 ← 谁在分配
# jdk.ExecutionSample 104 1031 ← CPU 采样
# jdk.Deoptimization 119 2691 ← JIT 去优化
$ jfr print --events jdk.ExecutionSample r.jfr | head -40
$ jfr print --events jdk.VirtualThreadPinned r.jfr # 虚拟线程被钉住(14 章)
# GC 实时监控
$ jstat -gc <pid> 1000 # 每秒一行
# 老命令(jcmd 都能替代,但你会在老文档里看到)
$ jstack <pid> # = jcmd Thread.print
$ jmap -histo <pid> # = jcmd GC.class_histogram
$ jinfo <pid> # = jcmd VM.flagssudo 跑 jcmd 反而连不上(因为它按用户匹配临时文件);② 容器里的进程在宿主机上看不到(要 docker exec 进去执行,而且容器镜像里得有 JDK 不能只有 JRE);③ GC.heap_dump 会触发一次 Full GC 并写出整个堆——几个 GB 的堆会让程序停顿好几秒,生产环境上要有心理准备。相比之下 JFR 和 Thread.print 的开销小得多,优先用它们。jcmd <pid> help。它会列出这个 JVM 支持的所有诊断命令(几十条),连每条命令的参数说明都有(jcmd <pid> help GC.heap_dump)。jcmd 是这些老工具(jstack/jmap/jinfo)的统一入口,功能是超集。不用背命令,会查就行。这一卡是刹车。性能优化是最容易上瘾也最容易做无用功的活动,尤其是在 Java 上——JVM 已经替你做了大量优化,很多「手工调优」只是在制造难读的代码。
动手之前问三个问题
- ① 它现在慢吗?不是「感觉慢」,是有具体数字:一次请求 800 毫秒、启动 3 秒、内存 2 GB。没有基线数字就不要开始——你无法证明自己改好了。
- ② 慢在哪?用工具找出来(上一卡),不要猜。实际瓶颈几乎总是出乎意料:你以为是算法,实际是每次都重新编译正则;你以为是数据库,实际是日志里在做字符串拼接。
- ③ 值得吗?把 200 毫秒优化到 100 毫秒,用户感知不到,但代码可能变得没人敢改。优化的收益要和「未来所有维护成本」比。
不需要测就该做的事(默认写法)
这些不算「优化」,算「别写错」:查找用 Set/Map、循环里拼字符串用 StringBuilder、已知大小时预分配容量、大文件用流式读、批量操作代替 N+1、热点数据结构避开装箱。这些都不损害可读性,直接默认这么写。
需要测了才做的事
缓存、并行化、对象池、手动内存布局、避开虚方法、用 sun.misc.Unsafe 之类的黑魔法。这些每一个都会增加复杂度,必须有数字支撑。
何时收手
- 达到目标就停:目标是「一次请求 200 毫秒内」,到了就别再优化到 150。
- 收益开始递减时停:前两处改动砍掉了 70% 的时间,第三处只砍 3%——那说明主要瓶颈已经解决了。
- 开始需要牺牲正确性时停:「去掉这个检查会快一点」——不行。
// 优化的正确流程
// ① 建立基线(可重复的测量)
// $ time java -jar app.jar 测试数据.txt
// real 3.412s ← 这是基线,写下来
// ② 找瓶颈(不要猜)
// $ java -XX:StartFlightRecording=filename=r.jfr,settings=profile -jar app.jar ...
// $ jfr print --events jdk.ExecutionSample r.jfr | grep 我的包 | sort | uniq -c | sort -rn
// → 发现 60% 的采样都在 正则匹配() 里
// ③ 看一眼那段代码 —— 往往一眼就能看出问题
boolean 是有效编号(String s) {
return s.matches("[A-Z]{2}\\d{6}"); // ✗ 每次调用都重新编译正则!
}
// ④ 改(改动很小,可读性没变差)
private static final Pattern 编号格式 = Pattern.compile("[A-Z]{2}\\d{6}");
boolean 是有效编号2(String s) {
return 编号格式.matcher(s).matches(); // ✓ 编译一次,复用
}
// ⑤ 重新量,确认真的改好了
// $ time java -jar app.jar 测试数据.txt
// real 1.203s ← 3.4 → 1.2 秒,够了,收手
// ✗ 不该做的"优化"(可读性大幅下降,收益接近零)
for (int i = 0, n = 列表.size(); i < n; i++) { } // 缓存 size():JIT 自己会做
if ((x & 1) == 0) { } // 代替 x % 2 == 0:一样快
final String s = ...; // 以为 final 更快:不会
// ✓ 该做的"不算优化的优化"(默认就这么写)
var 黑名单 = Set.copyOf(列表); // 查找用 Set
var sb = new StringBuilder(); // 循环里拼字符串
var 结果 = new ArrayList<String>(预计大小); // 预分配new 一个 ObjectMapper/DateTimeFormatter/SimpleDateFormat、每次都重新读配置文件、每次都重新建立数据库连接。共同特征是「本该只做一次的初始化被放进了热路径」——它们改起来极简单(提成 static final),收益却常常是数量级的。找瓶颈时先看有没有这类。标准库速查
写代码时最常需要「那个方法叫什么来着」。这一章按用途分组,把日常 90% 会用到的 API 排在一起——不用背,用到时扫一眼。
按用途分组的字符串 API 速查。细节和陷阱在 04 章,这里只列「叫什么、返回什么」。
判断
isEmpty()长度为 0 ·isBlank()只有空白 ·contains(s)·startsWith/endsWith·equalsIgnoreCase·matches(正则)
查找与截取
indexOf(s)找不到返回 −1 ·lastIndexOf·charAt(i)·substring(a)/substring(a, b)(右开区间)
变形
strip()(不是 trim)·toUpperCase(Locale.ROOT)·replace(a,b)(字面)·replaceAll(正则, 替换)·repeat(n)·split(正则)/split(正则, -1)(保留末尾空串)·String.join(分隔符, 集合)
转换
Integer.parseInt(s)/Double.parseDouble(s)·String.valueOf(x)·s.getBytes(StandardCharsets.UTF_8)·new String(字节, UTF_8)·s.chars()/s.codePoints()/s.lines()
格式化占位符
| 占位符 | 效果 |
|---|---|
%s | 任意对象(调 toString) |
%d / %,d | 整数 / 带千分位 |
%f / %.2f | 小数 / 两位小数 |
%-10s / %10s | 左对齐 / 右对齐占 10 格 |
%05d | 补零到 5 位 |
%x / %o / %e | 十六进制 / 八进制 / 科学计数 |
%n | 换行(跨平台,别用 \n) |
%% | 一个百分号 |
// 常用组合
文件名.substring(文件名.lastIndexOf('.') + 1) // 取后缀
String.join(", ", 名单) // 拼接
"%-12s %6.2f".formatted(名字, 金额) // 对齐输出
"─".repeat(40) // 分隔线
文本.lines().filter(l -> !l.isBlank()).toList() // 按行处理
行.split(",\\s*", -1) // CSV(保留末尾空列)
// 正则:Pattern 要复用(16 章的性能陷阱)
static final Pattern 编号 = Pattern.compile("([A-Z]{2})-(\\d+)");
var m = 编号.matcher(输入);
if (m.matches()) { // 整串匹配
IO.println(m.group(1) + " / " + m.group(2));
}
while (m.find()) { } // 找所有出现
编号.matcher(文本).replaceAll("$1$2") // 替换($1 引用分组)
编号.splitAsStream(文本) // 按正则切成流
// 命名分组
Pattern.compile("(?<年>\\d{4})-(?<月>\\d{2})").matcher(s).group("年");
// StringBuilder
var sb = new StringBuilder(256);
sb.append(x).append(',').insert(0, "[").reverse();
sb.setLength(0); // 清空复用
// 文本块(04 章)
String 模板 = """
{"名字": "%s", "分数": %d}
""".formatted(名字, 分数);split、replaceAll、matches 吃正则,replace 不吃——所以 "a.b".split(".") 返回空数组、"1+1".replaceAll("+", "-") 直接抛异常。只做字面替换就用 replace;完整的转义清单与 Pattern.quote 在 04 章。Pattern 一定要提成 static final。s.matches(正则)、s.replaceAll(正则, x)、s.split(正则) 这三个方法每次调用都会重新编译正则——在循环里就是实打实的浪费(16 章有案例)。只调用一两次无所谓,进了热路径就该改成预编译的 Pattern。集合 API 速查。选型和陷阱在 10 章。
创建
- 可变:
new ArrayList<>()/new HashMap<>()/new HashSet<>()/new ArrayDeque<>() - 不可变:
List.of(...)/Set.of(...)/Map.of(k,v,...)/Map.ofEntries(Map.entry(k,v)) - 拷贝:
new ArrayList<>(原)(可变)·List.copyOf(原)(不可变快照) - 从数组:
Arrays.asList(数组)(定长视图)·Arrays.stream(数组).boxed().toList()
List 常用
add/add(i, x)/get(i)/set(i, x)/remove(i)(下标)/remove(Object)/size()/isEmpty()/contains/indexOfremoveIf(条件)·replaceAll(函数)·sort(比较器)·subList(a,b)(是视图不是拷贝)·forEach·reversed()(Java 21 起)
Map 常用
put/get/getOrDefault(k, 默认)/containsKey/remove/size- 值钱的四个:
computeIfAbsent(k, k->新建)·merge(k, v, 合并)·putIfAbsent·forEach((k,v)->...) keySet()/values()/entrySet()—— 都是视图,改它们会影响原 Map
Deque(队列 + 栈)
| 操作 | 队列语义 | 栈语义 |
|---|---|---|
| 放入 | addLast / offerLast | push(= addFirst) |
| 取出 | pollFirst(空返 null) | pop(空抛异常) |
| 查看 | peekFirst | peek |
// 计数(最常用的模式之一)
var 词频 = new HashMap<String, Integer>();
词频.merge(词, 1, Integer::sum);
// 一对多
var 分组 = new HashMap<String, List<String>>();
分组.computeIfAbsent(键, k -> new ArrayList<>()).add(值);
// 排序(10 章)
名单.sort(Comparator.comparingInt(玩家::分数).reversed()
.thenComparing(玩家::名字));
// 按值排序取前十
词频.entrySet().stream()
.sorted(Map.Entry.<String,Integer>comparingByValue().reversed())
.limit(10).toList();
// 集合运算
var 交集 = new HashSet<>(a); 交集.retainAll(b);
var 并集 = new HashSet<>(a); 并集.addAll(b);
var 差集 = new HashSet<>(a); 差集.removeAll(b);
// 队列 / 栈
var 队 = new ArrayDeque<String>();
队.addLast(x); 队.pollFirst(); // 队列
队.push(x); 队.pop(); // 栈
// 优先队列(Top-K、调度、Dijkstra)
var 堆 = new PriorityQueue<任务>(Comparator.comparingInt(任务::优先级));
// TreeMap 独有的范围操作
var 有序 = new TreeMap<String, Integer>();
有序.firstKey(); 有序.lastEntry();
有序.headMap("m"); 有序.subMap("a", "m");
有序.floorKey("k"); // 小于等于 k 的最大键
// Java 21 起:SequencedCollection
列表.getFirst(); 列表.getLast(); 列表.reversed();
有序映射.firstEntry(); 有序映射.reversed();subList、keySet、values、entrySet 返回的都是视图不是拷贝。在视图上删元素会真的删原集合(map.values().removeIf(...) 正是利用这一点),而在原集合上改结构会让视图失效(后续操作抛 ConcurrentModificationException)。需要独立的一份就显式拷贝:new ArrayList<>(list.subList(a,b))、Set.copyOf(map.keySet())。SequencedCollection 接口,统一了「有顺序的集合」的首尾操作。以前取第一个元素要写 list.get(0)、deque.peekFirst()、set.iterator().next() 三种写法,现在统一是 getFirst()/getLast()/reversed(),LinkedHashMap 也有了 firstEntry()。这是个小改进但用起来很顺手。Stream API 速查。惰性、复用限制和坑在 11 章。
创建
集合.stream()·Arrays.stream(数组)·Stream.of(a,b,c)·IntStream.range(0,n)/rangeClosed·Files.lines(路径)(要 close)·字符串.lines()/.chars()·Stream.iterate(种子, 下一个)·Stream.generate(供应者)·Optional.stream()
中间操作(惰性)
filter·map/mapToInt/mapToObj·flatMap·distinct·sorted(比较器)·limit(n)/skip(n)·takeWhile/dropWhile·peek(只用于调试)·gather(Gatherer)(JDK 24+)·boxed()
终端操作
- 取集合:
toList()(不可变)·collect(Collectors.xxx)·toArray() - 取一个:
findFirst()/findAny()·min/max·reduce - 判断:
anyMatch/allMatch/noneMatch(都短路) - 数字:
sum()/average()/count()/summaryStatistics() - 副作用:
forEach/forEachOrdered
常用收集器
toList()/toSet()/toCollection(TreeSet::new)joining(分隔符, 前缀, 后缀)groupingBy(键)/groupingBy(键, 下游收集器)partitioningBy(条件)·toMap(键, 值, 合并)counting()/summingInt/averagingInt/mapping(f, 下游)
// 典型流水线
var 结果 = 名单.stream()
.filter(p -> p.分数() >= 60)
.sorted(Comparator.comparingInt(玩家::分数).reversed())
.limit(10)
.map(玩家::名字)
.toList();
// 分组统计(最值钱的一个)
Map<String, Long> 按公会计数 = 名单.stream()
.collect(Collectors.groupingBy(玩家::公会, Collectors.counting()));
// 数字统计一次算完
var 统计 = 名单.stream().mapToInt(玩家::分数).summaryStatistics();
统计.getMin(); 统计.getMax(); 统计.getAverage(); 统计.getSum();
// 拍平嵌套
嵌套列表.stream().flatMap(List::stream).toList();
// 词频(把前面几样拼起来)
try (var 行 = Files.lines(路径)) {
var 词频 = 行.flatMap(l -> Arrays.stream(l.split("\\W+")))
.filter(w -> !w.isBlank())
.map(w -> w.toLowerCase(Locale.ROOT))
.collect(Collectors.groupingBy(w -> w, Collectors.counting()));
}
// 分批(JDK 24+)
流.gather(Gatherers.windowFixed(1000)).forEach(this::批量处理);
// Optional
Optional.ofNullable(可能为空)
.map(String::strip)
.filter(s -> !s.isEmpty())
.orElseGet(() -> 算个默认值()); // 昂贵的默认值用 orElseGet
可选值.ifPresentOrElse(x -> 用(x), () -> 没有时());
可选值.orElseThrow(() -> new IllegalStateException("没找到"));
流.map(this::可能为空的查找).flatMap(Optional::stream).toList();Files.lines()/Files.walk() 要放进 try-with-resources,集合的 .stream() 不用。判据是「这个流是从文件、网络、数据库来的吗」;漏关的症状是很久之后冒出一个和原因毫无关系的 Too many open files(12 章)。IntStream/LongStream/DoubleStream)有对象流没有的方法:sum()、average()、summaryStatistics()、rangeClosed()。处理数字时先 mapToInt 转过去,既避免装箱又能用这些方法;需要回到对象流用 boxed()。现代 Java 的文件操作用 java.nio.file(Path + Files),不要用老的 java.io.File——后者错误处理很差(很多方法失败时只返回 false,不告诉你为什么)。
路径
Path.of("a", "b", "c.txt")(自动用平台分隔符)·路径.resolve("子路径")·路径.getParent()/getFileName()·toAbsolutePath()·normalize()(消掉..)- 跨平台建议:字符串里一律写正斜杠(
"a/b"),Java 在 Windows 上也接受。
读写(小文件一行搞定)
Files.readString(路径)/Files.writeString(路径, 内容)Files.readAllLines(路径)/Files.write(路径, 行列表)Files.readAllBytes/Files.write(路径, 字节)- 大文件用流式:
Files.lines(路径)(try-with-resources)·Files.newBufferedReader/Writer - 追加:
Files.writeString(路径, 内容, StandardOpenOption.APPEND)
查询与操作
Files.exists/isRegularFile/isDirectory/size/getLastModifiedTimeFiles.createDirectories(会建多级,已存在也不报错)·createFile·delete/deleteIfExists·copy/moveFiles.list(目录)(一层,要 close)·Files.walk(目录)(递归,要 close)·Files.find(目录, 深度, 条件)Files.createTempFile/createTempDirectory
编码
Java 18 起默认就是 UTF-8(04 章),但涉及编码的地方仍建议显式传 StandardCharsets.UTF_8——它零成本,而且让代码在任何 JDK 版本上行为一致。
// 小文件:一行读写
String 内容 = Files.readString(路径);
Files.writeString(路径, 内容, StandardCharsets.UTF_8);
List<String> 行 = Files.readAllLines(路径);
// 追加
Files.writeString(日志, 一行 + "\n",
StandardOpenOption.CREATE, StandardOpenOption.APPEND);
// 大文件:流式(一定要 try-with-resources)
try (var 行流 = Files.lines(大文件)) {
long n = 行流.filter(l -> l.contains("ERROR")).count();
}
// 遍历目录(也要 close)
try (var 文件流 = Files.walk(目录)) {
var 按后缀分组 = 文件流
.filter(Files::isRegularFile)
.collect(Collectors.groupingBy(this::后缀, Collectors.counting()));
}
// 路径操作
var p = Path.of("data", "2026", "log.txt"); // data/2026/log.txt
p.getFileName(); // log.txt
p.getParent(); // data/2026
p.resolve("../x").normalize();
p.toAbsolutePath();
Path.of(System.getProperty("user.home"), ".我的工具"); // 用户目录下的配置
// 目录与删除
Files.createDirectories(p.getParent()); // 多级、已存在不报错
Files.deleteIfExists(p);
Files.copy(源, 目标, StandardCopyOption.REPLACE_EXISTING);
Files.move(源, 目标, StandardCopyOption.ATOMIC_MOVE);
// 临时文件
var 临时 = Files.createTempFile("导出-", ".csv");
// 二进制
byte[] 数据 = Files.readAllBytes(图片);
try (var 入 = Files.newInputStream(源);
var 出 = Files.newOutputStream(目标)) {
入.transferTo(出); // 一行完成复制
}
// 压缩包(标准库自带)
try (var zip = new ZipFile(压缩包.toFile())) {
zip.stream().forEach(条目 -> IO.println(条目.getName()));
}java.io.File。它的问题是失败时不说原因:file.delete() 返回 false——是文件不存在?没权限?被占用?你不知道。file.mkdirs()、renameTo() 同样如此。Files.delete(路径) 则会抛出带具体原因的异常(NoSuchFileException、AccessDeniedException、DirectoryNotEmptyException)。老 API 唯一还需要它的场合是对接只接受 File 的旧库,用 路径.toFile() 转过去。Files.writeString(临时, 内容); Files.move(临时, 目标, ATOMIC_MOVE); ——这样即使写到一半程序崩了,目标文件要么是完整的旧版本、要么是完整的新版本,绝不会是半截的。存档、配置、数据文件都值得这么写。日期时间用 java.time(Java 8 起),不要用 Date/Calendar/SimpleDateFormat——那套 API 可变、不线程安全、月份从 0 开始,是标准库里公认最糟的设计之一。
选哪个类型
| 类型 | 表示 | 什么时候用 |
|---|---|---|
LocalDate | 年月日 | 生日、日期 |
LocalTime | 时分秒 | 营业时间 |
LocalDateTime | 日期 + 时间(无时区) | 本地事件 |
Instant | 时间戳(UTC) | 记录「什么时候发生的」,存储首选 |
ZonedDateTime | 带时区的时间 | 需要展示给特定时区的用户 |
Duration | 时间长度(秒/纳秒) | 耗时、超时 |
Period | 日期长度(年/月/日) | 年龄、账期 |
都是不可变的(05 章),所有「修改」方法都返回新对象。
数字
Math.max/min/abs/pow/sqrt/round/floor/ceil/hypot- 防溢出:
Math.addExact/multiplyExact/absExact(02 章) Integer.parseInt/toBinaryString/toHexString/compare/MAX_VALUE- 钱和精确小数:
BigDecimal(用字符串构造)· 大整数:BigInteger
随机
Math.random()—— 简单场景,返回 [0,1)ThreadLocalRandom.current().nextInt(1, 101)—— 并发下用它RandomGenerator.getDefault()—— Java 17 起的新接口new Random(种子)—— 要可复现时给固定种子(游戏地图生成、测试)SecureRandom—— 密码、令牌必须用它
// 日期时间
var 今天 = LocalDate.now();
var 生日 = LocalDate.of(2000, 3, 15); // 月份从 1 开始(老 API 从 0)
int 年龄 = Period.between(生日, 今天).getYears();
今天.plusDays(100); 今天.minusMonths(1); // 返回新对象
今天.getDayOfWeek(); 今天.isLeapYear();
今天.withDayOfMonth(1); // 本月第一天
今天.with(TemporalAdjusters.lastDayOfMonth()); // 本月最后一天
// 月末的边界行为:1 月 31 日加一个月是 2 月 28 日,不会溢出到 3 月
LocalDate.of(2026, 1, 31).plusMonths(1) // 2026-02-28
// 时间戳与时区
var 现在 = Instant.now(); // 存储用这个(UTC)
现在.atZone(ZoneId.of("Asia/Shanghai")); // 展示时才转时区
ZoneId.systemDefault();
// 耗时
var 用时 = Duration.between(开始, Instant.now());
用时.toMillis(); 用时.toSeconds();
Duration.ofMinutes(90).toHours(); // 1
// 格式化 / 解析
var 格式 = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
时间.format(格式);
LocalDate.parse("2026-07-29"); // ISO 格式不用给 formatter
// 数字
Math.addExact(a, b); // 溢出时抛异常(02 章)
Integer.toBinaryString(200); // "11001000"
new BigDecimal("19.99").multiply(BigDecimal.valueOf(3))
.setScale(2, RoundingMode.HALF_UP);
// 随机
ThreadLocalRandom.current().nextInt(1, 101); // [1, 100]
new Random(42).nextInt(100); // 固定种子 = 可复现
new SecureRandom().nextBytes(令牌); // 安全相关必须用它
UUID.randomUUID().toString(); // 36 个字符
// Base64 与哈希(都在标准库里)
Base64.getEncoder().encodeToString(字节);
MessageDigest.getInstance("SHA-256").digest(数据);DateTimeFormatter 里大写 YYYY 是「基于周的年份」,不是普通年份。2025 年 12 月 29 日用 YYYY-MM-dd 格式化得到 2026-12-29,用 yyyy-MM-dd 才是 2025-12-29。这个 bug 每年跨年前后都会大规模爆发一次(因为那几天恰好属于下一年的第一周),平时完全测不出来。同族的还有:DD(一年中的第几天)vs dd(月中的第几日)、mm(分钟)vs MM(月份)。格式串里的大小写全都有意义。Instant(UTC 时间戳),只在展示给人看的时候才转成本地时区。这一条能避开时间处理里绝大多数坑:夏令时切换、用户跨时区、服务器时区和用户时区不同。数据库里存 UTC,界面上按用户时区渲染——这是唯一不会出错的做法。JDK 的 bin 目录里有几十个命令。这一卡列出真正会用到的那些,以及最常用的参数。
日常四个
| 命令 | 做什么 |
|---|---|
java | 运行(class、jar、或直接跑源文件) |
javac | 编译 |
jar | 打包 / 解包 |
jshell | 交互式试代码(01 章) |
诊断四个(16 章)
jcmd—— 万能入口(线程栈、堆直方图、堆转储、JFR、看参数)jfr—— 分析飞行记录文件jps—— 列出 Java 进程jstat—— GC 实时统计
其它偶尔用到
javap—— 看字节码 / 看类的 API(15 章)jlink/jpackage—— 裁剪运行时 / 打成安装包(18 章)jdeps—— 分析依赖了哪些模块jwebserver—— 起一个静态文件服务器(01 章)javadoc—— 从注释生成 API 文档keytool—— 管理证书和密钥库
# === 运行 ===
$ java Hello.java # 直接跑源文件(Java 11+)
$ java Main.java # 多文件也行(Java 22+)
$ java -cp classes 我的包.Main # 跑已编译的类
$ java -jar app.jar # 跑 jar(需要 Main-Class)
$ java --enable-preview X.java # 启用预览特性(14 章)
$ java -Dstdout.encoding=UTF-8 X # 中文乱码时(01 章)
$ java -Duser.language=en X # 英文报错,方便搜
$ java -Xmx2g -Xss2m X # 堆 2G,线程栈 2M(15 章)
$ java -ea X # 启用 assert(12 章)
# === 编译 ===
$ javac X.java # 编译到当前目录
$ javac -d out X.java # 输出到 out/
$ javac -g X.java # 带调试信息(NPE 会显示变量名,01 章)
$ javac --release 21 X.java # 编译成能在 JDK 21 上跑的(15 章)
$ javac -Xlint:all X.java # 打开全部警告(值得默认加)
$ javac @文件列表.txt # 文件太多时从文件读参数
# === 打包 ===
$ jar --create --file app.jar --main-class Main -C out .
$ jar --list --file app.jar # 看里面有什么
$ jar --extract --file app.jar # 解开
$ jar --update --file app.jar -C res . # 追加文件
# === 交互与探索 ===
$ jshell # 交互式
$ jshell -q # 少点废话
$ echo 'System.out.println(1+1);' | jshell -q -
$ javap -c -p X.class # 看字节码
$ javap java.util.Map # 看一个类有哪些方法(比查文档快)
# === 诊断(16 章)===
$ jcmd -l # 列出 Java 进程
$ jcmd <pid> help # 这个 JVM 支持哪些命令
$ jcmd <pid> Thread.print # 卡住时第一条
$ jcmd <pid> GC.class_histogram # 内存问题第一条
$ jcmd <pid> GC.heap_dump /tmp/h.hprof
$ jcmd <pid> JFR.start duration=30s filename=r.jfr settings=profile
$ jfr summary r.jfr
# === 打包分发(18 章)===
$ jdeps --print-module-deps app.jar # 依赖哪些模块
$ jlink --add-modules java.base --output rt --strip-debug
$ jpackage --type app-image --name 我的工具 --input in --main-jar app.jar
# === 其它 ===
$ jwebserver -p 8000 -d . # 临时静态服务器(01 章)
$ javadoc -d docs src/*.java # 生成 API 文档-cp 一旦显式指定,当前目录就不再自动包含在类路径里。不指定 -cp 时默认是当前目录;一旦写了 -cp libs/*,java Main 就找不到 Main.class 了——必须写成 -cp "libs/*:."(把当前目录也加上)。这是「明明文件就在这儿却说找不到主类」最常见的原因。还要注意分隔符是平台相关的:Windows 用 ;,其它平台用 :。javap 类名(不带 -c)是查 API 最快的办法。javap java.util.Map 直接列出所有方法签名,比打开浏览器搜文档快得多,而且版本一定和你手上的 JDK 一致(不会看到某个新版本才有的方法)。想看私有成员加 -p。工具链:从 javac 到把作品发出去
写完了怎么给别人用?这一章讲打包的四个层次:jar、可执行 jar、裁剪过的运行时、双击就能跑的安装包——再说清构建工具什么时候才真的需要、依赖是怎么解析的、缺什么去哪找库,以及测试怎么写。
jar 就是一个 zip 文件,里面装着 .class 和资源,外加一个 META-INF/MANIFEST.MF 描述元信息。它是 Java 世界里分发代码的基本单位。
三步做一个能直接跑的 jar
$ javac -d out src/*.java # ① 编译到 out/ $ jar --create --file app.jar --main-class Main -C out . # ② 打包并指定入口 $ java -jar app.jar # ③ 跑
--main-class是关键:不写的话运行时会报no main manifest attribute, in app.jar(报错原文)。- 一个只有几个类的 jar,大小是 1429 字节——jar 本身几乎没有开销。
有依赖怎么办
- 方案一:把依赖 jar 放旁边,运行时
java -cp "app.jar:libs/*" Main。简单,但分发时要带一个目录。 - 方案二:
Class-Path清单项——在 MANIFEST 里写明依赖的相对路径,然后java -jar就能自动找到。 - 方案三:胖 jar(fat jar / uber jar)——把所有依赖解开重新打进一个 jar。这是分发命令行工具最省事的方式,但需要构建工具帮忙(Maven 的 shade 插件、Gradle 的 shadow 插件)。
资源文件
图片、配置、词典这类文件可以打进 jar,用 getResourceAsStream("/文件名") 读——注意不能用 Files 读,因为它在 jar 里面不是一个真实的文件。
# 完整流程
$ javac -d out $(find src -name "*.java")
$ jar --create --file app.jar --main-class 我的包.Main -C out .
$ java -jar app.jar
# 看 jar 里有什么
$ jar --list --file app.jar
$ unzip -p app.jar META-INF/MANIFEST.MF # 看清单
# 带依赖:把 jar 放旁边
$ java -cp "app.jar:libs/*" 我的包.Main # macOS/Linux
$ java -cp "app.jar;libs/*" 我的包.Main # Windows
# 带资源文件一起打包
$ jar --create --file app.jar --main-class Main -C out . -C resources .
// 读打进 jar 的资源(不能用 Files!)
try (var 输入 = Main.class.getResourceAsStream("/词典.txt")) {
var 内容 = new String(输入.readAllBytes(), StandardCharsets.UTF_8);
}
// 路径以 / 开头 = 从 jar 根目录算起;不以 / 开头 = 相对于这个类的包
// 读配置的常见组合:先看外部文件,没有就用打包进去的默认值
static String 读配置() throws IOException {
var 外部 = Path.of("config.json");
if (Files.exists(外部)) return Files.readString(外部);
try (var 内置 = Main.class.getResourceAsStream("/默认配置.json")) {
return new String(内置.readAllBytes(), StandardCharsets.UTF_8);
}
}
# 给 jar 签名(发布到公共仓库时可能需要)
$ jarsigner -keystore 我的密钥库 app.jar 别名Files/File 读。Files.readString(Path.of("词典.txt")) 在 IDE 里跑得好好的(那时它是磁盘上的真文件),打成 jar 之后就 NoSuchFileException——因为它现在是 zip 里的一个条目,不是文件系统上的路径。必须用 getResourceAsStream。这是「本地能跑、打包后跑不了」的头号原因。jar --list --file app.jar | grep 词典 一秒钟就有答案。反过来,也可以直接用 zip 工具往 jar 里塞文件(虽然用 jar --update 更规范)。「用户得先装 JDK」是 Java 分发的老大难。JDK 14 起自带的 jpackage 能把你的程序 + 一个裁剪过的运行时打成一个原生安装包——用户双击就能用,完全不知道底下是 Java。
本机上的体积对比
| 产物 | 大小 |
|---|---|
| 完整 JDK 25 | 363 MB |
jlink --add-modules java.base 的运行时 | 44 MB |
加上 --compress zip-6 | 31 MB |
jpackage 默认(不给运行时,它自己造一个全量的) | 121 MB |
jpackage --runtime-image 用上面那个瘦运行时 | 32 MB |
关键是给 jpackage 一个 jlink 过的运行时——默认情况下它打包的运行时含有全部模块,白白多出 90 MB。
三步走
- ①
jdeps --print-module-deps app.jar—— 问出你到底需要哪些模块(一个纯计算的程序通常只要java.base)。 - ②
jlink --add-modules 那些模块 --output rt—— 造一个只含这些模块的运行时。加--strip-debug --no-header-files --no-man-pages --compress zip-6进一步瘦身。 - ③
jpackage --runtime-image rt ...—— 打成应用。--type app-image得到一个可直接运行的目录;不加--type则生成平台安装包(Windows 上是 msi/exe,需要额外装 WiX;macOS 是 dmg/pkg;Linux 是 deb/rpm)。
还有一条路:GraalVM Native Image
- 把 Java 程序提前编译成真正的原生可执行文件:启动是毫秒级、内存占用低、单个文件。
- 代价:构建慢(分钟级)、反射和动态类加载需要额外配置(很多框架有现成的配置,但自己写的反射要手动声明)、不能用 JIT 的运行期优化所以峰值吞吐可能略低。
- 适合:命令行工具、Serverless、容器里追求快速启动的服务。
# ① 问出需要哪些模块(纯计算的程序只要 java.base)
$ jdeps --print-module-deps app.jar
java.base
# ② 造一个瘦运行时(44 MB,加压缩 31 MB)
$ jlink --add-modules $(jdeps --print-module-deps app.jar) \
--output rt \
--strip-debug --no-header-files --no-man-pages --compress zip-6
$ ./rt/bin/java -version # 它是个完整可用的 java
$ ./rt/bin/java -cp app.jar Main # 可以直接这么跑
# ③ 打成应用(用瘦运行时 32 MB,不用则 121 MB)
$ mkdir in && cp app.jar in/
$ jpackage --type app-image \
--name 我的工具 \
--input in --main-jar app.jar \
--runtime-image rt \
--dest pkg
$ ./pkg/我的工具/我的工具.exe # Windows;macOS/Linux 是可执行文件
# 生成平台安装包(去掉 --type app-image)
$ jpackage --name 我的工具 --input in --main-jar app.jar --runtime-image rt \
--app-version 1.0 --vendor "我" --icon icon.ico
# Windows 需要先装 WiX;macOS 生成 dmg;Linux 生成 deb/rpm
# 给启动器加 JVM 参数(15 章那三个启动优化)
$ jpackage ... --java-options "-Xmx512m" \
--java-options "-XX:+UseSerialGC" \
--java-options "-XX:TieredStopAtLevel=1"
# GraalVM Native Image(需要单独装 GraalVM)
$ native-image -jar app.jar 我的工具
$ ./我的工具 # 毫秒级启动,单个可执行文件jpackage --input 会把那个目录里的所有东西都打进应用。踩过一次:--input .(当前目录)把 jlink 生成的运行时、之前的打包产物、AOT 缓存全打了进去,产物从 32 MB 膨胀到 441 MB。正确做法是专门建一个干净的输入目录,只放 jar 和真正需要随程序分发的文件。jdeps --print-module-deps 的输出可以直接喂给 jlink,这两个命令是配套设计的。如果程序用了反射动态加载类,jdeps 静态分析可能漏掉一些模块——症状是打包后运行报 ClassNotFoundException,手动往 --add-modules 里补上就行(常见的漏网之鱼是 java.logging、java.sql、jdk.crypto.ec)。很多 Java 教程第二课就让你装 Maven、建 src/main/java 目录树、写 pom.xml。那是给要发布库、要管几十个依赖的项目准备的,学语言的阶段完全不需要。这一卡说清楚:什么时候真的需要工具,在那之前怎么走。
三个阶段,按需升级
| 阶段 | 你需要的 | 什么时候升级 |
|---|---|---|
| 学语法、验证想法 | jshell 或单个 .java 文件 + java X.java | 一直用到你写出几百行为止 |
| 一个小工具、小游戏 | 几个 .java 文件放一个目录,java Main.java 一起编译 | 需要第三方库、或想发给别人时 |
| 真项目 | Maven 或 Gradle(下一卡) | 需要管依赖、跑测试、打包发布 |
标准库自带的东西,比你想的多
「要装什么库才能…」——先看一眼是不是已经有了。(用 Class.forName 逐个探):
- 有:HTTP 客户端(
java.net.http,支持 HTTP/2)、HTTP 服务器(com.sun.net.httpserver,能起真服务)、正则、压缩解压(zip/gzip)、加密与哈希、Base64、日期时间、文件与目录遍历、进程启动、XML 解析、日志、随机数、图形界面(Swing)、图片读写。 - 没有:JSON(这是标准库最著名的缺口,要用 Jackson 或 Gson)、YAML、数据库驱动、HTTP 服务框架。
- 结论:写个抓接口做统计的小工具,除了 JSON 解析,一个第三方库都不用装。
要引一个库时,最省事的两条路
- 手动加 jar:把 jar 下载下来,
java -cp "libs/*;." Main.java(Windows 用;,macOS/Linux 用:)。适合只引一两个库。 - jbang(第三方工具,很轻):能在源文件顶部用注释声明依赖,然后一条命令跑起来,依赖自动下载。写「带依赖的单文件脚本」时非常顺手。
- 再往上就是 Maven / Gradle,下一卡讲。
# 一个目录、几个文件,直接跑(不需要任何配置文件)
$ ls
Main.java Greeter.java Board.java
$ java Main.java # 用到的文件自动一起编译(JDK 22+)
# 引一个 jar:手动指定类路径
$ java -cp "libs/*:." Main.java # macOS / Linux
$ java -cp "libs/*;." Main.java # Windows
# jbang:在源文件里声明依赖(第三方工具,需要单独装)
///usr/bin/env jbang "$0" "$@" ; exit $?
//DEPS com.fasterxml.jackson.core:jackson-databind:2.22.1
void main() throws Exception {
var 树 = new ObjectMapper().readTree("{\"名字\": \"小明\"}");
IO.println(树.get("名字").asText());
}
# $ jbang 上面这个文件.javajwebserver(Java 18 起)。jwebserver -p 8000 -d . 就在当前目录起一个静态文件服务器,用来临时分享文件或调试前端页面非常方便。默认只绑定本机回环地址,要让局域网里其它设备访问得加 -b 0.0.0.0——它自己在启动时就会提示这一点。上一卡说了不用构建工具能走多远。这一卡说清什么时候真的需要,以及最小配置长什么样。
出现下面任何一条,就该上构建工具了
- 需要第三方库,而且不止一两个(手动下 jar 还要下它们的依赖的依赖);
- 要跑自动化测试;
- 要发布(打胖 jar、发到仓库);
- 要和别人协作(对方需要能一条命令构建出同样的结果);
- 要在 CI 上构建。
Maven vs Gradle
| Maven | Gradle | |
|---|---|---|
| 配置文件 | pom.xml(XML,声明式) | build.gradle(.kts)(代码,命令式) |
| 学习曲线 | 结构固定,好懂 | 灵活,但要学它的模型 |
| 构建速度(同一个项目) | 什么都没改也要 5.0 秒 改一行 5.5 秒 | 什么都没改 1.7 秒 改一行 1.8 秒 |
| 冲突时选哪个版本 | 最近的那个 | 最高的那个 |
| 典型用户 | 大量存量项目、库 | Android(官方)、新项目 |
两个都能用,别在这上面纠结。拿不准就用 Maven——它的结构固定,出问题时搜到的答案更容易套用。
上面那组速度是(Maven 3.9.16 / Gradle 9.6.1,同一个两依赖的小项目)。差距来自机制不是优化:Maven 每次都是一个全新 JVM 从头跑一遍所有插件,没有「已经是最新的」这个概念;Gradle 有常驻守护进程和任务级增量,所以第二次之后才快——清空缓存后的第一次要 38.3 秒,紧接着第二次就变成 1.8 秒。项目小的时候这点差别无所谓,代码多起来才会变成每天几十次的体感差异。
标准目录结构(两个工具都用这套)
项目/
├── pom.xml (或 build.gradle)
└── src/
├── main/java/ ← 源码
├── main/resources/ ← 打进 jar 的资源
├── test/java/ ← 测试代码
└── test/resources/这套约定的价值在于:任何人拿到一个 Java 项目都知道东西在哪,工具也不用配置就能找到它们。
包装器(wrapper):值得知道的一个细节
项目里的 mvnw/gradlew 脚本会自动下载指定版本的构建工具——意味着别人克隆你的项目后不用先装 Maven/Gradle,直接 ./mvnw package 就行,而且用的版本和你完全一致。建包装器是标准做法,看到就用它而不是全局的 mvn。
<!-- 最小的 pom.xml:这就够跑起来了 -->
<project xmlns="http://maven.apache.org/POM/4.0.0">
<modelVersion>4.0.0</modelVersion>
<groupId>我的组</groupId>
<artifactId>我的工具</artifactId>
<version>1.0</version>
<properties>
<maven.compiler.release>25</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency> <!-- JSON:标准库没有的那个 -->
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.22.1</version>
</dependency>
<dependency> <!-- 测试 -->
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.1.2</version>
<scope>test</scope>
</dependency>
</dependencies>
</project>
# 常用命令
$ mvn compile # 编译
$ mvn test # 跑测试
$ mvn package # 打成 jar(会先跑测试)
$ mvn package -DskipTests
$ mvn clean package # 先清理再打包
$ mvn dependency:tree # 看依赖树(排查冲突时必用)
// Gradle 的等价配置(build.gradle.kts)
plugins { application }
java { toolchain { languageVersion = JavaLanguageVersion.of(25) } }
dependencies {
implementation("com.fasterxml.jackson.core:jackson-databind:2.22.1")
testImplementation("org.junit.jupiter:junit-jupiter:6.1.2")
}
application { mainClass = "我的包.Main" }
# Gradle 命令
$ ./gradlew build
$ ./gradlew run
$ ./gradlew test
$ ./gradlew dependenciesjavac 手动编一下你的源文件。能编过,问题就在构建配置里;编不过,才是代码问题。这个隔离习惯能省掉大量白费的调试。Could not start Gradle Test Executor / Failed to load JUnit Platform,要自己补一行 testRuntimeOnly("org.junit.platform:junit-platform-launcher")(Maven 的 surefire 自带这个,所以同一份依赖在 Maven 上没事);② 离线模式 -o 因为一个没下过的插件而失败——构建工具的插件本身也是依赖,第一次用 clean 也要联网下。依赖冲突那一类问题单独占了下一卡。你写一行依赖,进来的常常是十几个 jar。构建工具在背后干的最容易出事的一件事,是把一堆互相矛盾的版本要求解析成一份确定的 classpath。这一卡把这个过程拆开——本卡全部数据于 JDK 25.0.1 + Maven 3.9.16 + Gradle 9.6.1。
一行依赖,进来一棵树
pom 里只写了两条依赖(jackson-databind、junit-jupiter),mvn dependency:tree 的输出:
demo:tool:jar:1.0
+- com.fasterxml.jackson.core:jackson-databind:jar:2.22.1:compile
| +- com.fasterxml.jackson.core:jackson-annotations:jar:2.22:compile
| \- com.fasterxml.jackson.core:jackson-core:jar:2.22.1:compile
\- org.junit.jupiter:junit-jupiter:jar:6.1.2:test
+- org.junit.jupiter:junit-jupiter-api:jar:6.1.2:test
| +- org.opentest4j:opentest4j:jar:1.3.0:test
| +- org.junit.platform:junit-platform-commons:jar:6.1.2:test
| +- org.apiguardian:apiguardian-api:jar:1.1.2:test
| \- org.jspecify:jspecify:jar:1.0.0:test
+- org.junit.jupiter:junit-jupiter-params:jar:6.1.2:test
\- org.junit.jupiter:junit-jupiter-engine:jar:6.1.2:test
\- org.junit.platform:junit-platform-engine:jar:6.1.2:test2 条声明 → 12 个 jar。这就是「传递依赖」:你的依赖的依赖也会进来。看不懂依赖树,就没法排查后面那些问题。
scope:这个依赖在哪一步存在
| scope | 编译主代码 | 跑测试 | 打进发布物 | 典型用途 |
|---|---|---|---|---|
compile(默认) | ✓ | ✓ | ✓ | 绝大多数库 |
test | ✗ | ✓ | ✗ | JUnit、AssertJ、Mockito |
runtime | ✗ | ✓ | ✓ | 数据库驱动、日志实现 |
provided | ✓ | ✓ | ✗ | 运行环境已经提供的 |
验证:dependency:copy-dependencies -DincludeScope=runtime 只拷出了 3 个 jackson 的 jar,junit 那一支 9 个 jar 一个都没进去。把测试依赖标成 test 不是洁癖——不标的话它们会跟着进你的发布物。
冲突:Maven 选最近的,Gradle 选最高的
classpath 上一个类只能有一个版本。两条路径要求不同版本时,两个工具的默认策略不一样,而这个差别会真的决定程序崩不崩。
构造:直接依赖写 jackson-core 2.13.0,同时 databind 2.22.1 需要 core 2.22.1。mvn dependency:tree -Dverbose:
+- com.fasterxml.jackson.core:jackson-core:jar:2.13.0:compile
+- com.fasterxml.jackson.core:jackson-databind:jar:2.22.1:compile
| \- (com.fasterxml.jackson.core:jackson-core:jar:2.22.1:compile
- omitted for conflict with 2.13.0)Maven 选了 2.13.0——「最近者胜」,离你的 pom 近的那个赢,和版本高低无关。编译完全通过,运行时才炸:
Exception in thread "main" java.lang.NoSuchMethodError:
'void com.fasterxml.jackson.core.util.BufferRecycler.releaseToPool()'
at com.fasterxml.jackson.databind.ObjectMapper.writeValueAsString(ObjectMapper.java:4153)
at demo.App.toJson(App.java:6)同样的依赖声明搬到 Gradle 上,dependencyInsight 给出的是:
com.fasterxml.jackson.core:jackson-core:2.22.1
Selection reasons:
- By conflict resolution: between versions 2.22.1 and 2.13.0Gradle 选了 2.22.1,程序正常跑出结果。——同一份依赖声明,一个崩一个不崩,差别只在两个工具的默认策略。记住这条:「在他机器上是好的」有时候真的不是玄学。
修法:BOM,以及一个反直觉的
BOM(Bill of Materials)是一份「这一族库互相兼容的版本表」,import 进 dependencyManagement 之后,这族依赖都不用再写版本号。
但踩到一件事:先只加了 jackson-bom 2.22.1,树里 jackson-core 仍然是 2.13.0,照样抛 NoSuchMethodError;把直接依赖上那行 <version>2.13.0</version> 删掉之后才变成 2.22.1、跑通。
规则:dependencyManagement / BOM 只对没写版本号的依赖生效。两处都写时,写在依赖声明上的那个赢。所以用了 BOM 就把版本号从依赖里删干净——留着的那个会安静地覆盖掉你以为在生效的表。
胖 jar:把依赖打进一个文件
普通 jar 只装你自己的 class,别人拿到跑不起来(缺依赖)。胖 jar(fat jar / uber jar)把依赖全塞进同一个文件,java -jar 直接能跑。
同一个项目:普通 jar 3054 字节,maven-shade-plugin 打完 2 372 048 字节、1112 个 class——jackson 整个进去了。Gradle 那边对应 shadow 插件,Spring Boot 有自己的 repackage(本章后面那卡)。
下载的东西堆在哪
Maven 放 ~/.m2/repository,Gradle 放 ~/.gradle/caches。这个只有两条依赖的小项目,仓库里最后躺了 202 个 jar、96 MB——绝大部分是构建工具自己的插件,不是你的依赖。磁盘紧张时整个删掉没关系,下次构建会重新下(代价是那一次慢)。
<!-- scope:标清楚这个依赖在哪一步需要 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>6.1.2</version>
<scope>test</scope> <!-- 不会进发布物 -->
</dependency>
<!-- BOM:一族库的版本表 -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson</groupId>
<artifactId>jackson-bom</artifactId>
<version>2.22.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency> <!-- 不写 version:交给 BOM。写了就盖掉 BOM -->
</dependencies>
<!-- 排除某个传递依赖(谨慎,见 pitfall) -->
<dependency>
...
<exclusions>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
# 排查冲突:-Dverbose 才会显示被淘汰的那些
$ mvn dependency:tree -Dverbose
$ gradle dependencyInsight --dependency jackson-core --configuration runtimeClasspath
# 看某一步实际用到哪些 jar(runtime 只有 3 个)
$ mvn dependency:copy-dependencies -DoutputDirectory=d -DincludeScope=runtime
<!-- 胖 jar:shade 插件(Maven) -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-shade-plugin</artifactId>
<version>3.6.0</version>
<executions><execution>
<phase>package</phase><goals><goal>shade</goal></goals>
<configuration><transformers>
<transformer implementation="org.apache.maven.plugins.shade.resource.ManifestResourceTransformer">
<mainClass>我的包.Main</mainClass>
</transformer>
</transformers></configuration>
</execution></executions>
</plugin><exclusion> 当默认解法。排除一个传递依赖意味着运行时它真的不在了——如果引它的那个库其实要用,你只是把 NoSuchMethodError 换成了 NoClassDefFoundError,而且报错位置更深更难查。优先用 BOM 或 dependencyManagement 统一版本;排除只适用于「这个库我确实一行都用不到」的情况(典型是排掉某个日志实现,换成自己那套)。-Dverbose 的 dependency:tree 只给你最终结果,看不出冲突发生过。对比:普通模式下 jackson-core 就安静地显示成 2.13.0,像是你本来就要这个版本;加上 -Dverbose 才会多出 omitted for conflict with 2.13.0 这一行,告诉你有另一个版本被淘汰了。排查 NoSuchMethodError/NoClassDefFoundError 时,第一条命令就该是它。标准库缺的东西不多(19 章列过),但缺的那几样必须从外面拿。这一卡是一张按「缺口」组织的清单——只列一个人做东西真会用到的,并且给出每一条的代价。
缺口 → 常用选择
| 你缺的 | 常用选择 | 怎么挑 |
|---|---|---|
| JSON | Jackson / Gson | Jackson 功能全、生态大;Gson 小而简单,脚本类程序够用 |
| HTTP 客户端 | JDK 自带 HttpClient / OkHttp | 先用自带的(19 章);要连接池、拦截器、重试才上 OkHttp |
| 日志 | SLF4J + Logback | SLF4J 是接口、Logback 是实现,配一次用很久 |
| 断言更好读 | AssertJ | assertThat(x).isEqualTo(y),链式、报错信息更清楚 |
| 替身对象 | Mockito | 只在真难构造依赖时用——能用真对象就用真的 |
| 测试要真数据库 | Testcontainers | 测试里起一个真容器,比装一个本地数据库干净 |
| 数据库 | JDBC / HikariCP / JDBI / Hibernate | 小项目直接 JDBC 写 SQL;连接池用 HikariCP;ORM 是最后才需要的那层 |
| 数据库版本管理 | Flyway | 把建表语句变成有序的迁移脚本,本地和线上不会分叉 |
| 命令行参数 | picocli | 一个注解就有子命令、帮助、补全,写工具很省事 |
| Web 框架 | Javalin / Spring Boot | 见 19 章和本章后面那卡 |
| 零散工具函数 | Guava / Apache Commons | 先查标准库——今天大部分需求已经内置了 |
代价:一行依赖到底拖进来多少
| 库 | 版本 | 连带 jar 数 | 磁盘 |
|---|---|---|---|
| picocli | 4.7.7 | 1 | 408 KB |
| Gson | 2.13.2 | 2 | 304 KB |
| SLF4J + Logback | 1.5.34 | 3 | 992 KB |
| OkHttp | 5.4.0 | 5 | 2.1 MB |
| AssertJ | 3.27.6 | 2 | 9.9 MB |
| spring-boot-starter-web | 4.1.0 | 34 | 见后面那卡 |
先看清数量级再决定。为了一个字符串工具方法引 Guava、为了一次 HTTP 请求引一整套客户端,都是不划算的交易。
怎么查一个库的当前版本
- 网页:
central.sonatype.com搜坐标,能看到发布日期和用量; - 命令行:
curl https://repo1.maven.org/maven2/<组名按斜杠拆开>/<库名>/maven-metadata.xml; - IDE:在 pom 里写坐标会自动补全版本。
提醒:<release> 字段里的未必是稳定版。同一天查到的 AssertJ 是 4.0.0-M1、SLF4J 是 2.1.0-alpha1——都是里程碑/预览版。看到 -M、-RC、-alpha、-beta 后缀就往下翻一个。
选库的三个问题
- 最近一年有更新吗?Maven Central 上看最后发布日期。停更两年以上的,除非它是彻底稳定的小工具,否则换一个。
- 它自己拖进来几个?上面那张表的量级。依赖越多,冲突概率越高(前一卡)。
- 出问题时你能读它的源码吗?小库能,大框架基本不能。
一个人做东西的时候,「能读懂」比「功能全」更重要——因为没有人可以问,最后兜底的只有源码。
<!-- Maven:加依赖就是加这么一段 -->
<dependency>
<groupId>info.picocli</groupId>
<artifactId>picocli</artifactId>
<version>4.7.7</version>
</dependency>
// Gradle:一行
dependencies {
implementation("info.picocli:picocli:4.7.7")
testImplementation("org.assertj:assertj-core:3.27.6")
}
# 查最新版本
$ curl -s https://repo1.maven.org/maven2/info/picocli/picocli/maven-metadata.xml \
| grep -o '<release>[^<]*'
# 想知道某个库到底会拖进来什么:先建个空项目试一下
$ mvn dependency:copy-dependencies -DoutputDirectory=d -DincludeScope=runtime
$ ls d
// picocli:写命令行工具的样子(20 章会用到)
@Command(name = "整理", mixinStandardHelpOptions = true)
class 整理 implements Runnable {
@Parameters(index = "0", description = "要整理的目录")
Path 目录;
@Option(names = {"-n", "--dry-run"}, description = "只看不动")
boolean 演习;
public void run() { /* 干活 */ }
public static void main(String[] a) {
System.exit(new CommandLine(new 整理()).execute(a));
}
}
// --help 是自动生成的,不用自己写@RunWith、还在教 log4j 1.x、还在用 SimpleDateFormat 而不是 java.time。判断办法只有一个:去 Maven Central 看它最后一次发布是什么时候,再看它的官方文档首页写的是哪个大版本。java.time 极完整)、Base64(java.util.Base64)、压缩(java.util.zip)、哈希(MessageDigest)、随机数(RandomGenerator)、格式化(String.format / NumberFormat)、简易日志(System.getLogger())。17 章那六张速查表就是拿来干这个的。测试的价值不在「证明代码对」,而在「改动之后还敢按保存」。这一卡讲最小可用的测试实践——不追求覆盖率,只求关键路径有保障。
JUnit 5 的最小集合
@Test标一个测试方法;assertEquals(期望, 实际)/assertTrue/assertNull/assertThrows(异常类, 代码块);@BeforeEach/@AfterEach每个测试前后跑;@DisplayName("中文描述")让报告可读;@ParameterizedTest+@ValueSource一组数据跑同一个测试。
值得测的四类
- 纯函数:给定输入 → 期望输出。最容易测、收益最高(解析、计算、格式化、校验)。
- 边界:空输入、单个元素、超长、null、负数、最大值——bug 集中在这里。
- 修过的 bug:修一个 bug 就补一个测试,保证它不会回来。这是投入产出比最高的测试。
- 复杂的分支逻辑:状态机、规则引擎、模式匹配的分发(08 章)。
不值得测的
- getter/setter、record 的自动生成方法——测的是编译器不是你的代码。
- 标准库和第三方库——假定它们是对的。
- 只是把参数转发一下的胶水方法。
- 为了凑覆盖率而写的测试——它们只会在重构时制造阻力。
让代码可测的一个原则
把「计算」和「副作用」分开:解析、判断、计算写成纯函数(好测),读写文件/网络/数据库集中在边界上(不用测或用假的替代)。06 章的「依赖注入」在这里有了第二个理由:能把真实的依赖换成测试用的假实现。
// src/test/java/存档解析测试.java
import org.junit.jupiter.api.*;
import static org.junit.jupiter.api.Assertions.*;
class 存档解析测试 {
@Test
@DisplayName("正常的一行能解析出三个字段")
void 正常情况() {
var 存档 = 存档.解析("小明,5,森林");
assertEquals("小明", 存档.玩家());
assertEquals(5, 存档.关卡());
}
@Test
@DisplayName("字段数不对要抛异常,并且消息里带原文")
void 字段数不对() {
var e = assertThrows(IllegalArgumentException.class,
() -> 存档.解析("小明,5"));
assertTrue(e.getMessage().contains("小明,5")); // 12 章:消息要带实际值
}
@ParameterizedTest
@ValueSource(strings = {"", " ", ",", "a,b,c,d"})
@DisplayName("各种畸形输入都不能让程序崩在别处")
void 边界输入(String 输入) {
assertThrows(IllegalArgumentException.class, () -> 存档.解析(输入));
}
}
// 让代码可测:把计算和副作用分开
// ✗ 难测:读文件和解析混在一起
static 存档 读并解析(Path p) throws IOException {
String[] 段 = Files.readString(p).split(",");
return new 存档(段[0], Integer.parseInt(段[1]), 段[2]);
}
// ✓ 好测:解析是纯函数,读文件在外面
static 存档 解析(String 行) { /* 纯计算,直接测 */ }
static 存档 读(Path p) throws IOException { return 解析(Files.readString(p)); }
// 需要文件时用临时目录(JUnit 自动清理)
@Test
void 读文件(@TempDir Path 临时目录) throws Exception {
var 文件 = 临时目录.resolve("存档.txt");
Files.writeString(文件, "小明,5,森林");
assertEquals("小明", 存档.读(文件).玩家());
}
# 跑测试
$ mvn test
$ ./gradlew test
# 报告在 target/surefire-reports 或 build/reports/testsLocalDate.now() 而逻辑跨了天)。修法:每个测试自己准备数据(@BeforeEach)、用 @TempDir 而不是固定路径、把时间作为参数传进去而不是在方法里取(Clock 就是为此设计的)。上一卡的 JUnit 够用了。这一卡解决的是「测试挂了但我看不出哪里不对」——以及需要把依赖换掉时,什么时候手写一个假的就够、什么时候才值得上工具。本卡于 JUnit 6.1.2 + AssertJ 3.27.6 + Mockito 5.23.0。
AssertJ 买到的是「报错信息」
同一个失败,两种断言给出的原文(抓的 surefire 报告)。JUnit 原生:
expected: <[红, 绿, 蓝, 黄]> but was: <[红, 绿, 紫, 黄]>
你得自己拿眼睛对。AssertJ:
Expecting actual: ["红", "绿", "紫", "黄"] to contain exactly (and in same order): ["红", "绿", "蓝", "黄"] but some elements were not found: ["蓝"] and others were not expected: ["紫"]
对象比较差得更远。JUnit 原生只会把两个 record 的 toString 摆出来;AssertJ 的 usingRecursiveComparison() 直接点名:
field/property 'age' differ: - actual value : 21 - expected value: 20
字段一多,这个差别就是「五秒钟定位」和「瞪十分钟」的差别。而且它只是一个 test 作用域的依赖,不进你的发布物(本章讲依赖解析那一卡的 scope)。
替身:先手写,别默认上 Mockito
要把「取汇率」这种外部依赖换掉时,如果那个依赖是个接口且方法不多,一个 lambda 就是替身:
汇率源 假的 = 币种 -> 7.2;
new 换算器(假的).换(100, "USD"); // 720.0零依赖、零学习成本、出错时堆栈里没有魔法。Mockito 真正划算的地方只有一个:你要验证的不是返回值,而是「它被怎么调用了」——调了几次、有没有用某个参数调过、有没有不该调却调了。这类断言手写替身做起来很啰嗦。
而且它的失败信息也是有备而来的:
TooFewActualInvocations:
汇率源.取("USD");
Wanted 2 times:
-> at MockFail.次数不对(MockFail.java:10)
But was 1 time:
-> at MockFail.次数不对(MockFail.java:9)代价是 4 个 jar、9.9 MB,而且 mock 用多了测试会变成「验证实现细节」——改个内部写法就红一片。能用真对象就用真的,能手写就手写,剩下的才交给它。
参数化:一组数据跑同一段逻辑
边界值最适合这么写——四行数据四个用例,失败时报告里直接显示是哪一行挂的(@ParameterizedTest(name = "{0} 元 → {1}"))。比写四个几乎一样的 @Test 方法好维护得多,加一个边界只要加一行。
有数据库的代码怎么测:用真的,不是假的
把数据库 mock 掉,你测的就只是「我以为 SQL 会返回什么」,SQL 本身写错了照样绿。更好的办法是让它连一个真的、但一次性的库——19 章那个 SQLite 用 jdbc:sqlite::memory: 就是进程内的、跑完就消失的真数据库。
建表、插三行、跑一句 GROUP BY 聚合、断言结果,整个测试 零外部依赖、零清理代码,而且 SQL 写错会真的挂。需要 PostgreSQL 那种特有语法时,再上 Testcontainers(用 Docker 起一个真容器)。
// 依赖(都是 test 作用域,不进发布物)
<dependency><groupId>org.assertj</groupId><artifactId>assertj-core</artifactId>
<version>3.27.6</version><scope>test</scope></dependency>
<dependency><groupId>org.mockito</groupId><artifactId>mockito-core</artifactId>
<version>5.23.0</version><scope>test</scope></dependency>
import static org.assertj.core.api.Assertions.*;
import static org.mockito.Mockito.*;
// ① AssertJ:链式,报错信息带上下文
assertThat(列表).hasSize(3).contains(2).doesNotContain(9).isNotEmpty();
assertThat(映射).containsKey("a").containsValue(1);
assertThat(文本).startsWith("hell").contains("wor");
assertThat(Optional.of("值")).isPresent().contains("值");
assertThatThrownBy(() -> Integer.parseInt("x"))
.isInstanceOf(NumberFormatException.class)
.hasMessageContaining("\"x\"");
assertThat(实际对象).usingRecursiveComparison().isEqualTo(期望对象); // 逐字段报差异
// ② 替身:接口 + lambda,零依赖
interface 汇率源 { double 取(String 币种); }
@Test void 手写替身() {
汇率源 假的 = 币 -> 7.2;
assertThat(new 换算器(假的).换(100, "USD")).isEqualTo(720.0);
}
// ③ Mockito:只在要验证「被怎么调用」时才值得
@Test void 验证调用() {
汇率源 假的 = mock(汇率源.class);
when(假的.取("USD")).thenReturn(7.2);
var c = new 换算器(假的);
c.换(100, "USD");
c.换(50, "USD");
verify(假的, times(2)).取("USD"); // 手写替身不方便做的事
verify(假的, never()).取("EUR");
}
// ④ 参数化:一组数据,四个用例
@ParameterizedTest(name = "{0} 元 → {1}")
@CsvSource({"100, 720.0", "0, 0.0", "1.5, 10.8", "-20, -144.0"})
void 参数化(double 金额, double 期望) {
assertThat(new 换算器(币 -> 7.2).换(金额, "USD")).isEqualTo(期望);
}
// ⑤ 有数据库的代码:连内存库跑真 SQL(19 章)
@Test void 内存数据库() throws Exception {
try (var c = DriverManager.getConnection("jdbc:sqlite::memory:")) {
// 建表、插数据、跑聚合 —— 全是真的 SQL
assertThat(按标签统计(c))
.containsExactly(entry("工作", 2), entry("生活", 1));
} // 连接一关,库就没了,不用清理
}
# Gradle 上跑 JUnit 6 还要补一行(本章构建工具那卡)
testRuntimeOnly("org.junit.platform:junit-platform-launcher")verify 了一遍,于是只要重构内部写法——哪怕行为完全没变、所有功能都正常——测试就红一片。这种测试的作用是反的:它阻止重构,而测试本来是为了让你敢重构而写的。只验证跨出边界的调用(真的发出去的请求、真的写下去的数据),内部怎么算的交给结果断言去管。判断标准:如果一个测试在「行为正确但实现改了」时会挂,它多半测错了东西。Clock 和 RandomGenerator 作为参数传进去就行)。这条线画对了,你的测试里 mock 会少一大半。Java 的 IDE 是它生态里少有的、确实比命令行强很多的地方——类型信息完整,让自动补全、重构、跳转定义都极其精确。
选哪个
- IntelliJ IDEA 社区版:免费,Java 支持是业界标杆。不知道选什么就用它。(Ultimate 版加的主要是 Web 框架和数据库工具。)
- VS Code + Extension Pack for Java:轻量,如果你已经在用 VS Code 写别的语言,这个更顺手。
- Eclipse / NetBeans:仍然活跃,各有拥趸。
真正值得学的操作(其余用到再说)
| 做什么 | IntelliJ | VS Code |
|---|---|---|
| 跳到定义 | Ctrl+B | F12 |
| 找所有用到的地方 | Alt+F7 | Shift+F12 |
| 全局搜任何东西 | Shift Shift | Ctrl+P |
| 重命名(安全重构) | Shift+F6 | F2 |
| 抽取方法 | Ctrl+Alt+M | 右键 Refactor |
| 快速修复 / 生成代码 | Alt+Enter | Ctrl+. |
| 格式化 | Ctrl+Alt+L | Shift+Alt+F |
其中「重命名」和「抽取方法」是 IDE 相对于文本编辑器最大的优势——它们理解代码结构,改的是符号不是文本,不会误伤同名的字符串或注释。
把警告当回事
- IDE 的黄色波浪线大多是对的:未使用的变量、可能的空指针、用
==比字符串、可以简化的表达式、忘了关的资源。 - 编译时加
-Xlint:all能让javac也报出这些(构建工具里配一下)。 - 静态分析工具:SpotBugs、Error Prone、SonarLint——它们能找到 IDE 找不到的问题(比如
equals/hashCode不配套、格式化字符串参数不匹配)。不是必需品,但装一个 SonarLint 插件基本没有成本。
// IDE 帮你抓到的典型问题(这些都是真实的 bug)
if (名字 == "admin") { } // ⚠ 字符串用 == 比较(02 章)
List<String> l = 获取();
if (l.size() > 0) { } // ⚠ 可以简化成 !l.isEmpty()
var 流 = Files.lines(路径); // ⚠ 资源没关(12 章)
@Override public boolean equals(玩家 o) { } // ⚠ 参数该是 Object(06 章)
System.out.printf("%d 个", 名字); // ⚠ %d 配了个字符串
// Alt+Enter(IntelliJ)能一键修的东西:
// - 自动 import
// - 用 var 替换显式类型(反之亦然)
// - 循环 ↔ Stream 互转
// - if-else 链 → switch 表达式
// - 匿名类 → lambda → 方法引用
// - 生成 equals/hashCode/toString/构造器
// - 把类转成 record(如果它符合条件)
# 命令行也能开警告
$ javac -Xlint:all X.java
# 常见警告:unchecked(泛型,09 章)、deprecation(用了过时 API)、
# rawtypes、fallthrough(switch 贯穿)、try(资源未使用)
<!-- Maven 里默认打开 -->
<properties>
<maven.compiler.showWarnings>true</maven.compiler.showWarnings>
</properties>remove(int) vs remove(Object),03 章)。与外面的世界打交道
程序总要和外界交换点什么:抓个网页、起个服务、读写 JSON、调用别的程序、甚至调 C 函数。这一章的重点是——标准库自带的东西比大多数人以为的多得多,写小工具常常一个第三方库都不用装。
Java 11 起标准库自带了现代 HTTP 客户端(java.net.http),支持 HTTP/2、异步、WebSocket。别再用老的 HttpURLConnection(API 极其难用),也不用为了发个请求引第三方库。
默认行为
客户端默认协议版本:HTTP_2 请求 https://example.com → 状态 200,响应版本 HTTP_1_1
- 客户端默认尝试 HTTP/2,服务端不支持就自动降回 1.1——所以
response.version()和client.version()可能不一样,这是正常的。
三种响应处理方式
BodyHandlers.ofString()—— 拿字符串(建议同时传StandardCharsets.UTF_8)。BodyHandlers.ofFile(路径)—— 直接存成文件,不经过内存,下载大文件用它。BodyHandlers.ofLines()—— 拿一个行的 Stream,流式处理。
同步 vs 异步
send(...)阻塞等待——配合虚拟线程(14 章),这是今天最推荐的写法:写起来最简单,并发能力也够。sendAsync(...)返回CompletableFuture——需要组合多个请求的复杂流程时用。
几个实用配置
connectTimeout(连接超时,在 client 上设)和timeout(整个请求超时,在 request 上设)—— 两个都要设,否则可能永远挂着。followRedirects(NORMAL)—— 默认是不跟随重定向的,很多人在这里出错(拿到 301 却以为请求失败了)。- 一个
HttpClient实例应该复用(它内部有连接池和线程),别每次请求都新建。
// 复用一个 client(它是线程安全的)
static final HttpClient 客户端 = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(10))
.followRedirects(HttpClient.Redirect.NORMAL) // 默认不跟随!
.build();
// GET
var 请求 = HttpRequest.newBuilder(URI.create("https://example.com"))
.header("User-Agent", "我的工具/1.0")
.timeout(Duration.ofSeconds(30))
.GET()
.build();
var 响应 = 客户端.send(请求, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
IO.println(响应.statusCode()); // 200
IO.println(响应.version()); // HTTP_1_1(对方不支持 h2 就降级)
IO.println(响应.headers().firstValue("content-type").orElse("?"));
// POST JSON
var 提交 = HttpRequest.newBuilder(URI.create(接口))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(json, StandardCharsets.UTF_8))
.build();
// 下载大文件:直接落盘,不经过内存
客户端.send(请求, HttpResponse.BodyHandlers.ofFile(Path.of("下载.zip")));
// 流式处理响应
var 行响应 = 客户端.send(请求, HttpResponse.BodyHandlers.ofLines());
try (var 行 = 行响应.body()) {
行.filter(l -> l.contains(关键词)).forEach(IO::println);
}
// 并发抓一批:虚拟线程 + 同步 API(14 章)
try (var 池 = Executors.newVirtualThreadPerTaskExecutor()) {
var 任务 = 地址们.stream()
.map(u -> 池.submit(() -> 客户端.send(
HttpRequest.newBuilder(URI.create(u)).build(),
HttpResponse.BodyHandlers.ofString())))
.toList();
for (var f : 任务) IO.println(f.get().statusCode());
}
// 异步(需要组合多个请求时)
客户端.sendAsync(请求, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body)
.thenAccept(IO::println);
// 查询参数要编码
String 地址 = "https://例子.com/搜?q="
+ URLEncoder.encode(关键词, StandardCharsets.UTF_8);HttpClient 默认不跟随重定向。请求一个会 301/302 的地址时,你拿到的是那个重定向响应本身(状态码 301、body 是空的),而不是最终页面——很多人以为是「请求失败」,其实只是没配 followRedirects。另一个常见坑:只设了 connectTimeout 没设请求的 timeout,对方接了连接但一直不返回数据,你的线程就永远挂着。两个超时都要设。HttpClient + 虚拟线程就完全够了,不需要 OkHttp 之类的库。唯一常缺的一块是 JSON 解析(下一卡)。如果只是要从 HTML 里抠几个字段,正则或者 indexOf + substring 通常也够——真要解析结构复杂的 HTML 才值得引 jsoup。JDK 里自带一个能用的 HTTP 服务器(com.sun.net.httpserver,模块名 jdk.httpserver)。十几行代码就能起一个真服务并被 curl 访问到。
它能做什么、不能做什么
- 能:处理路径、读请求体、设响应头、返回任意内容、多线程处理。做小工具的本地界面、内部接口、Webhook 接收端、给自己用的 API 完全够。
- 不能:路由参数(
/user/{id}要自己解析)、JSON 自动转换、模板渲染、认证、中间件——这些是框架的活。
什么时候该上框架
| 需求 | 建议 |
|---|---|
| 几个接口,自己用 | JDK 自带的就行 |
| 要静态文件服务 | jwebserver(命令行,一条命令) |
| 十几个接口,要路由和 JSON | 轻量框架(Javalin、Helidon SE、Vert.x) |
| 完整的应用,要数据库、认证、配置 | Spring Boot、Quarkus、Micronaut |
轻量框架的价值是「加一点点就够用」:Javalin 之类的核心 API 一屏就能学完,启动毫秒级,适合个人项目;大框架的价值是「团队协作和长期维护」——它们提供的那套结构在项目变大后才回本,写自己的小服务时反而是负担。
静态文件:一条命令
jwebserver -p 8000 -d .(Java 18 起自带)。默认只绑定本机回环地址,要让局域网访问得加 -b 0.0.0.0——它启动时自己会提示这一点。调试前端页面、临时给同事传文件,这条命令很实用。
// 一个真能跑的 HTTP 服务(本机通过)
import com.sun.net.httpserver.HttpServer;
void main() throws Exception {
var 服务 = HttpServer.create(new InetSocketAddress(8080), 0);
服务.createContext("/hi", 交换 -> {
byte[] 内容 = "你好".getBytes(StandardCharsets.UTF_8);
交换.getResponseHeaders().add("Content-Type", "text/plain; charset=utf-8");
交换.sendResponseHeaders(200, 内容.length);
try (var 输出 = 交换.getResponseBody()) { 输出.write(内容); }
});
// 读请求体、看方法和查询串
服务.createContext("/api/记录", 交换 -> {
if (!"POST".equals(交换.getRequestMethod())) {
交换.sendResponseHeaders(405, -1); // -1 表示没有响应体
return;
}
String 请求体 = new String(交换.getRequestBody().readAllBytes(),
StandardCharsets.UTF_8);
String 查询 = 交换.getRequestURI().getQuery();
保存(请求体);
交换.sendResponseHeaders(204, -1);
});
// 用虚拟线程处理请求(14 章)
服务.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
服务.start();
IO.println("跑在 http://localhost:8080/hi");
}
# 静态文件服务器:一条命令(Java 18+)
$ jwebserver -p 8000 -d ./网页
# 默认只绑本机;局域网要访问加 -b 0.0.0.0(它会自己提示)
// 轻量框架长什么样(以 Javalin 为例,需要引依赖)
var 应用 = Javalin.create().start(8080);
应用.get("/玩家/{名字}", ctx -> ctx.json(查(ctx.pathParam("名字"))));
应用.post("/玩家", ctx -> { 保存(ctx.bodyAsClass(玩家.class)); ctx.status(201); });
// 路由参数、JSON 自动转换 —— 这就是框架相对于裸 HttpServer 省掉的部分HttpServer 没有任何安全防护,只适合本机或可信内网。它不做路径规范化(自己拼文件路径时要小心 ../ 穿越)、没有请求大小限制(readAllBytes 读一个巨大的请求体会直接吃光内存)、没有速率限制。把它暴露到公网之前,要么换成正经框架,要么前面放一层反向代理。另外 sendResponseHeaders(状态, 长度) 的第二个参数:写 0 表示「长度未知,用分块传输」,写 −1 表示「没有响应体」,写错会让客户端一直等。jdk.httpserver 最好的用法。比如一个整理文件的工具,用它起个本地服务,浏览器打开就能看到进度和结果——比在终端里打印好用得多,而且不需要任何依赖。配合 20 章的桌面方案,你有了两条给程序加界面的路。上一卡的裸 HttpServer 和 20 章那个大框架之间,还有一档东西:核心 API 一屏能学完、启动毫秒级、加一点点就够用。Javalin 是这一档里最典型的一个。本卡于 Javalin 7.2.2 + JDK 25.0.1。
它替你做的四件事
拿上一卡的裸 HttpServer 逐条对照,你就知道这一层到底买到了什么:
| 事情 | 裸 HttpServer | Javalin |
|---|---|---|
| 路径参数 | 自己 substring 切 URI | /note/{key} + ctx.pathParam("key") |
| 方法分发 | 自己 switch 请求方法 | .get(...) / .put(...) 分开注册 |
| JSON | 自己调 Jackson 再写字节 | ctx.json(对象),头也帮你设 |
| 异常 | 每个 handler 自己兜 | .exception(某异常.class, ...) 统一映射 |
这四条全部通过:PUT /note/a 返回 201,GET /note/a 返回 {"text":"hello","at":1785331914445}(record 直接变 JSON),GET /num/abc 被异常处理器接住返回 400 「要一个数字」,?q=你好 的中文参数原样拿到。
代价:三档摆在一起看
| 裸 HttpServer | Javalin 7.2.2 | Spring Boot 4.1.0 | |
|---|---|---|---|
| 依赖 jar | 0 | 41 | 34 |
| 打出来的 jar | 1974 字节 | 10.4 MB | 19.9 MB |
| 启动 | 基本瞬时 | 444 ms | 1672 ms |
| 要学的概念 | 只有 HTTP | Context 一个类 | 容器 + 自动配置 + starter |
注意 Javalin 的 jar 数比 Spring Boot 还多(它带一整套 Jetty 12,本身又是 Kotlin 写的,会拖进 kotlin-stdlib)——数量不等于重量,真正的差别在体积、启动时间,以及你需要理解多少东西才能改动它。
虚拟线程:一行
cfg.concurrency.useVirtualThreads = true。处理请求的线程变成 VirtualThread[#89,JettyServerThreadPool-Virtual-25]/runnable@ForkJoinPool-1-worker-3——和 14 章那套模型直接接上,代码不用改。「一个请求一个线程」这种最好懂的写法,在这一档框架上今天是完全成立的。
什么时候从裸 HttpServer 换过来
- 路由超过五六条,
substring切路径开始出错; - 要来回转 JSON,手写序列化开始占篇幅;
- 要统一处理错误、日志、跨域这类横切的事;
- 但还不需要数据库连接池、认证、配置中心、监控——那些才是 20 章那一档的理由。
// 一个真跑通的 Javalin 服务(Javalin 7.2.2)
import io.javalin.Javalin;
public record Note(String text, long at) {}
static final ConcurrentHashMap<String, Note> store = new ConcurrentHashMap<>();
Javalin.create(cfg -> {
cfg.concurrency.useVirtualThreads = true; // 14 章
cfg.startup.showJavalinBanner = false;
cfg.routes
.get("/note/{key}", ctx -> {
var n = store.get(ctx.pathParam("key"));
if (n == null) ctx.status(404).result("没有这条");
else ctx.json(n); // record → JSON,头也设好
})
.put("/note/{key}", ctx -> {
store.put(ctx.pathParam("key"),
new Note(ctx.body(), System.currentTimeMillis()));
ctx.status(201);
})
.get("/q", ctx -> ctx.result("q=" + ctx.queryParam("q")))
.get("/num/{n}", ctx ->
ctx.result("翻倍 " + Integer.parseInt(ctx.pathParam("n")) * 2))
.exception(NumberFormatException.class,
(e, ctx) -> ctx.status(400).result("要一个数字"));
}).start(8094);
<!-- 依赖:Javalin 自己 + JSON + 日志实现 -->
<dependency><groupId>io.javalin</groupId>
<artifactId>javalin</artifactId><version>7.2.2</version></dependency>
<dependency><groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId><version>2.22.1</version></dependency>
<dependency><groupId>ch.qos.logback</groupId>
<artifactId>logback-classic</artifactId><version>1.5.34</version></dependency>
#
$ curl -X PUT -d "hello" localhost:8094/note/a # 201
$ curl localhost:8094/note/a
{"text":"hello","at":1785331914445}
$ curl -o /dev/null -w "%{http_code}" localhost:8094/note/zz
404
$ curl localhost:8094/num/abc # 要一个数字(400)app.get("/路径", ctx -> ...),在 Javalin 7.2.2 上直接编译不过——7 把路由从 Javalin 实例挪进了配置里(cfg.routes.get(...)),虚拟线程开关也从 cfg.useVirtualThreads 挪到了 cfg.concurrency.useVirtualThreads。判断办法:看官网文档页顶上标的版本号是不是你引的那个;拿不准就用 javap -cp 那个.jar 类名 把方法列出来看(这也是我最后确认 API 的办法)——字节码不会过期,博客会。logback.xml,你会被 Jetty 的 DEBUG 日志淹掉。同一次启动:没有配置文件时输出 124 行(全是 Jetty 内部组件的 DEBUG),放一个只有六行的 src/main/resources/logback.xml 把根级别设成 INFO 之后是 11 行。加日志实现的同时就把配置文件建好,否则你的程序自己那句「启动完成」会淹没在别人的调试信息里。JDK 里有 XML 解析器,却没有 JSON——这是标准库最著名的一个缺口(逐个探过:javax.json、java.json 都不存在,javax.xml.parsers 倒是有)。原因是历史:JSON 流行起来时 Java 已经很成熟了,而加进标准库意味着永远不能改。
两个主流选择
| Jackson | Gson | |
|---|---|---|
| 体量 | 较大,功能全 | 小,够用 |
| 速度 | 更快 | 够快 |
| record 支持 | 原生支持 | 较新版本支持 |
| 生态 | 事实标准(Spring 默认用它) | Android 常见 |
拿不准就用 Jackson(com.fasterxml.jackson.core:jackson-databind)。
和 record 是绝配
07 章的 record 加上 Jackson,「JSON ↔ 对象」变成一行:定义一个 record 描述结构,readValue(json, 我的Record.class) 直接得到类型安全的对象。这比手动一层层 get("字段") 强太多——字段名写错是编译错误而不是运行期返回 null。
不引依赖的临时办法
- 只要取一两个字段:正则或
indexOf+substring。丑但能用,写完就扔的脚本可以接受。 - 只是生成 JSON:用文本块 +
formatted拼(04 章)——但必须自己处理转义,输入不可控时不要这么做。 - 正经处理就引库,别硬扛。
// Jackson + record:类型安全,一行搞定
record 仓库(String name, int stars, Owner owner) {}
record Owner(String login) {}
static final ObjectMapper 映射器 = new ObjectMapper(); // 复用!16 章
// 解析
仓库 r = 映射器.readValue(json, 仓库.class);
IO.println(r.name() + " " + r.owner().login());
// 解析成列表(泛型要用 TypeReference,09 章的擦除问题)
List<仓库> 列表 = 映射器.readValue(json, new TypeReference<List<仓库>>() {});
// 生成
String 输出 = 映射器.writeValueAsString(r);
String 好看的 = 映射器.writerWithDefaultPrettyPrinter().writeValueAsString(r);
// 只想取一两个字段:树模型
var 树 = 映射器.readTree(json);
IO.println(树.get("items").get(0).get("name").asText());
IO.println(树.at("/items/0/name").asText()); // JSON Pointer,更简洁
// 常用配置
映射器.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
// ↑ 接口返回了你没定义的字段时不报错(对接外部 API 时基本必开)
// 字段名不一样时
record 用户(@JsonProperty("user_name") String 用户名) {}
// 和 HttpClient 组合:抓接口 → 对象
var 响应 = 客户端.send(请求, HttpResponse.BodyHandlers.ofString());
List<仓库> 仓库们 = 映射器.readValue(响应.body(), new TypeReference<>() {});
仓库们.stream()
.filter(x -> x.stars() > 1000)
.sorted(Comparator.comparingInt(仓库::stars).reversed())
.limit(10)
.forEach(x -> IO.println("%-30s %,d".formatted(x.name(), x.stars())));
// 不引依赖的临时办法(只适合写完就扔的脚本)
var m = Pattern.compile("\"name\"\\s*:\\s*\"([^\"]+)\"").matcher(json);
while (m.find()) IO.println(m.group(1));FAIL_ON_UNKNOWN_PROPERTIES = false。默认情况下,只要 JSON 里出现了你的 record 没定义的字段,Jackson 就直接抛异常——而对方随时可能加字段。你的程序会在某天毫无预兆地全线失败,原因只是别人加了个无关的字段。另一个方向的坑:JSON 里的 null 会变成对象的 null 字段,而 record 的紧凑构造器里如果有 Objects.requireNonNull 就会抛异常——反序列化的数据要当成不可信输入来校验。ObjectMapper 必须复用(提成 static final)——它是线程安全的,但构造很贵(要初始化一堆内部缓存)。每次调用都 new 一个是 16 章那类「本该只做一次的初始化跑进了热路径」的典型案例,而且非常常见。同族的还有 DateTimeFormatter、Pattern。上一卡够写完 90% 的 JSON 代码。剩下 10% 集中在三个地方,而且每一个都会让你卡上半小时:时间类型、结构不固定、文件比内存大。本卡于 Jackson 2.22.1 + JDK 25.0.1。
坑一:java.time 默认根本不支持
一个裸 ObjectMapper 序列化带 LocalDate 的对象,直接抛:
InvalidDefinitionException: Java 8 date/time type `java.time.LocalDate` not supported by default: add Module "com.fasterxml.jackson.datatype:jackson-datatype-jsr310" to enable handling (through reference chain: 事件["日期"])
报错已经把答案写全了——但加上模块还没完。三种状态:
| 配置 | 输出 |
|---|---|
| 裸 ObjectMapper | 抛异常 |
只注册 JavaTimeModule | "日期":[2026,7,29]、"时刻":1785331914.000000000 |
再 disable(WRITE_DATES_AS_TIMESTAMPS) | "日期":"2026-07-29"、"时刻":"2026-07-29T13:31:54Z" |
只注册模块的那一档最坑:不报错,但日期变成了一个三元素数组、时刻变成了带九位小数的秒——对方的解析器多半读不懂,而你自己读回来又完全正常,很难发现。两行一起写,别只写第一行。
坑二:结构不固定
对方的 JSON 里有你没定义的字段时,报错原文:
UnrecognizedPropertyException: Unrecognized field "多出来的" (class 事件), not marked as ignorable (3 known properties: "日期", "名", "时刻")
解法就是上一卡的 FAIL_ON_UNKNOWN_PROPERTIES = false。而当结构真的没法用 record 描述时(字段名是动态的、层数不定),用树模型 JsonNode:
树.get("字段")取不到时返回null——接着.asText()就是 NPE;树.at("/a/b/c")(JSON Pointer)取不到时返回 MissingNode,.asText()给空串,整条路径缺任何一层都不会炸;树.get("x").getNodeType()能问出它到底是 STRING 还是 NUMBER(两种都正确识别)。
处理不可信结构时一律用 at(),别用 get() 链。
坑三:文件比内存大
readValue(文件, new TypeReference<List<行>>(){}) 会把整个数组建成对象再返回。一个 72 MB / 120 万行的 JSON 数组:
| 读法 | -Xmx64m | 不限堆 | 峰值堆 |
|---|---|---|---|
一次性 readValue | OutOfMemoryError | 575 ms | 229 MB |
流式 readValues | 524 ms,正常出结果 | — | 31 MB |
72 MB 的文件全读进来要 200 MB 以上的堆(对象比文本占地方多,还要留出解析时的临时对象);流式读一次只在内存里放一行,峰值 31 MB 且和文件大小无关。速度反而差不多——所以只要你不需要同时持有全部数据(统计、筛选、转换、逐行入库都不需要),流式没有任何劣势。
// ① java.time:两行一起写
static final ObjectMapper M = new ObjectMapper()
.registerModule(new JavaTimeModule())
.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS)
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
// 依赖:com.fasterxml.jackson.datatype:jackson-datatype-jsr310
// 版本要和 jackson-databind 一致(用 jackson-bom,18 章)
record 事件(String 名, LocalDate 日期, Instant 时刻) {}
M.writeValueAsString(e);
// {"名":"发布","日期":"2026-07-29","时刻":"2026-07-29T13:31:54Z"}
// ② 结构不固定:树模型
JsonNode 树 = M.readTree(json);
树.get("不存在"); // null —— 再 .asText() 就 NPE
树.at("/a/b/c").asText(); // "" —— 缺任何一层都不炸 ✓
树.at("/日期").asText(); // "2026-07-29"
树.get("分").getNodeType(); // NUMBER —— 问它到底是什么类型
for (var it = 树.fields(); it.hasNext(); ) { // 字段名是动态的
var 项 = it.next();
IO.println(项.getKey() + " = " + 项.getValue().asText());
}
// ③ 大文件:流式读,内存和文件大小无关
record 行(int id, String 名, int 分, String 城市) {}
long 和 = 0;
var 按城市 = new HashMap<String, Long>();
try (MappingIterator<行> it = M.readerFor(行.class).readValues(文件)) {
while (it.hasNext()) {
var r = it.next(); // 一次只有一行在内存里
和 += r.分();
按城市.merge(r.城市(), 1L, Long::sum);
}
}
// 72MB/120万行:峰值堆 31MB,524ms
// 同一份数据 readValue 成 List:-Xmx64m 直接 OutOfMemoryError
// ④ 写大文件也一样:生成器,别先攒成 List
try (var g = M.createGenerator(Files.newBufferedWriter(输出))) {
g.writeStartArray();
for (int i = 0; i < 1_200_000; i++) {
g.writeStartObject();
g.writeNumberField("id", i);
g.writeStringField("名", "用户" + i);
g.writeEndObject();
}
g.writeEndArray();
}jackson-databind 完全一致,这是 18 章那个依赖冲突问题在这里的具体形态:jackson-datatype-jsr310 和 jackson-databind 版本对不上时,症状不是编译错误,而是运行期的 NoSuchMethodError 或者更隐蔽的「注册了模块但不生效」。用 jackson-bom 统一管(18 章那一卡),别一个个手写版本号。另外 readTree 出来的 JsonNode 上,asText() 对数字节点也返回字符串、对 null 节点返回 "null" 这个字面串——要判断真的有没有值,用 isMissingNode() / isNull(),别拿 asText() 的结果去比。List 再 writeValue,和读的时候一样会 OOM——用 createGenerator 边算边写。数据存哪儿?写到文件里迟早会遇到「查一条要读全部」「写一半崩了」这两个问题——那就是该上数据库的信号。Java 这边的接口叫 JDBC,是标准库的一部分(java.sql),你只需要额外拿一个驱动。本卡于 sqlite-jdbc 3.53.2.1 + SQLite 3.53.2 + JDK 25.0.1。
先用 SQLite:一个 jar,零安装
个人项目不需要装 MySQL/PostgreSQL 那一套。SQLite 就是一个文件,驱动只有 1 个 jar、无任何传递依赖(11.4 MB,因为它把各平台的原生库都打包在里面了)。连接串就是文件路径:jdbc:sqlite:notes.db,文件不存在会自动建。
换数据库时改的只有连接串和建表语句——JDBC 这层 API 是标准的,代码基本不动。这也是「先 JDBC 再考虑 ORM」的底气。
最该知道的一件事:事务,500 倍
插 2000 条:
| 写法 | 耗时 |
|---|---|
| 默认(每条自动提交) | 6500 ms |
setAutoCommit(false) + 最后 commit() | 13 ms |
再加 addBatch / executeBatch | 17 ms |
500 倍。原因是 JDBC 默认每条语句都是一个独立事务,每次都要等磁盘落盘。批量写数据时把整批包进一个事务,是这一卡里最值钱的一行代码。
注意第三行:addBatch 在这里并没有更快——真正的收益来自事务,不是批。(网络数据库上 batch 能省往返,会明显一些;本地 SQLite 上两者都已经快到看不出差别。)别把两件事混为一谈。
参数化不是风格问题
用户名输入框里填 小明' OR '1'='1,拼字符串拼出来的 SQL 是
SELECT name, secret FROM user WHERE name = '小明' OR '1'='1'
返回 2 行,包括那条本来查不到的「管理员 / 机密口令」。同一个输入交给 PreparedStatement 的 ?,返回 0 行——它把整串当成一个名字去查,参数永远不会被当成 SQL 解析。
规则简单到没有例外:任何来自外部的值都走 ?。顺带一个已确认的限制:? 只能替换「值」,不能替换表名/列名(SELECT * FROM ? 直接报 syntax error)——需要动态表名时只能用白名单校验,不能拼用户输入。
读结果的两个坑
getInt读到 NULL 返回0,不是 null(getInt得 0、getString得null)。要区分「值是 0」和「没有值」,只能紧接着调wasNull()——它说的是上一次 get 的那一列。ResultSet、Statement、Connection三样都要关,而且顺序有讲究——全部交给 try-with-resources(12 章),别手写finally。
再往上一层,按需要加
| 东西 | 解决什么 | 什么时候加 |
|---|---|---|
| HikariCP(2 jar,244 KB) | 连接池:连接建立很贵,复用它 | 服务端多请求并发时;单用户小工具不需要 |
| Flyway(4 jar,3.4 MB) | 把建表/改表写成有序的迁移脚本 | 表结构会变、而且有真实数据要保 |
| JDBI | 薄封装:省掉 ResultSet 样板 | 觉得手写映射烦了 |
| Hibernate / JPA | 对象关系映射 | 模型复杂、关联多;不是默认起点 |
// 依赖只要一个(无传递依赖)
<dependency><groupId>org.xerial</groupId>
<artifactId>sqlite-jdbc</artifactId><version>3.53.2.1</version></dependency>
import java.sql.*;
record Note(int id, String text, String tag) {}
String url = "jdbc:sqlite:notes.db"; // 文件不存在会自动建
// 建表
try (var c = DriverManager.getConnection(url); var s = c.createStatement()) {
s.execute("""
CREATE TABLE IF NOT EXISTS note(
id INTEGER PRIMARY KEY AUTOINCREMENT,
text TEXT NOT NULL,
tag TEXT)"""); // 文本块,04 章
}
// 批量写:一个事务(6500ms → 13ms)
try (var c = DriverManager.getConnection(url)) {
c.setAutoCommit(false); // ← 这一行
try (var p = c.prepareStatement(
"INSERT INTO note(text, tag) VALUES(?, ?)")) {
for (var n : 待写入) {
p.setString(1, n.text());
p.setString(2, n.tag());
p.executeUpdate();
}
c.commit();
} catch (SQLException e) {
c.rollback(); // 出错整批回退
throw e;
}
}
// 查询:参数一律走 ?
try (var c = DriverManager.getConnection(url);
var p = c.prepareStatement(
"SELECT id, text, tag FROM note WHERE tag = ? ORDER BY id LIMIT 20")) {
p.setString(1, 用户输入的标签); // 拼字符串就是注入漏洞
var 结果 = new ArrayList<Note>();
try (var rs = p.executeQuery()) {
while (rs.next())
结果.add(new Note(rs.getInt("id"),
rs.getString("text"),
rs.getString("tag")));
}
}
// 拿自增主键
try (var p = c.prepareStatement("INSERT INTO note(text) VALUES(?)",
Statement.RETURN_GENERATED_KEYS)) {
p.setString(1, "新的一条");
p.executeUpdate();
try (var k = p.getGeneratedKeys()) { if (k.next()) 新id = k.getInt(1); }
}
// NULL:getInt 给 0,只能靠 wasNull() 区分
int 分 = rs.getInt("score");
if (rs.wasNull()) { /* 真的没填,不是 0 分 */ }
// 测试用内存库:进程内、跑完就没(18 章)
DriverManager.getConnection("jdbc:sqlite::memory:");SQLITE_BUSY;单进程内多线程写也要小心(默认连接不是线程安全地共享的——一个线程一个连接,或者加连接池)。个人工具、单机服务、桌面程序完全够用;一旦是多个实例同时写同一个库,就该换成 PostgreSQL 那类真正的服务端数据库了。另一个反方向的提醒:别因为「以后可能要换数据库」就一上来套一层 ORM——JDBC 本身就是那层抽象,换库时要改的主要是 SQL 方言,而 ORM 并不能真的帮你免掉这件事。CREATE TABLE IF NOT EXISTS 在启动时跑一遍。这样程序第一次运行就能自己把库建好,不需要你先手动执行一份 SQL 文件——发给别人的时候尤其省事(对方双击就能用,18 章那条路才走得通)。等到表结构开始需要改、而且库里已经有不能丢的数据时,再上 Flyway 那种迁移工具。顺带:SQLite 的文件就是数据,复制走就是备份,删掉就是重来。写工具时经常需要调用别的命令(git、ffmpeg、python)。ProcessBuilder 是标准做法,比老的 Runtime.exec 好用得多。
三个必须处理的点
- ① 参数要分开传,不要拼成一个字符串。
new ProcessBuilder("git", "commit", "-m", 消息)——这样消息里有空格、引号都不会出问题,而且天然免疫命令注入。 - ② 输出流必须读掉。子进程的输出缓冲区满了它就会卡住等你读——「调用外部命令偶尔卡死」几乎都是这个原因。最省事的做法是
redirectErrorStream(true)合并两个流然后读完。 - ③ 一定要
waitFor()并检查退出码。0 是成功,非 0 是失败。
环境变量与系统属性
| 环境变量 | 系统属性 | |
|---|---|---|
| 读 | System.getenv("名字") | System.getProperty("名字") |
| 设 | 启动进程时设(程序内不能改自己的) | -D名字=值 或 System.setProperty |
| 典型用途 | 密钥、配置、CI 变量 | JVM 行为、程序参数 |
常用的系统属性
user.home(用户目录,放配置的地方)·user.dir(当前工作目录)·java.version·os.name·file.separator·line.separator- 的编码相关属性:
file.encoding(UTF-8)、native.encoding和stdout.encoding(中文 Windows 上是 GBK,01 章那条乱码 pitfall 的来源)。
// 调用外部命令的标准写法
static String 跑一个命令(String... 命令) throws Exception {
var 构造 = new ProcessBuilder(命令)
.redirectErrorStream(true) // 合并 stderr,省得读两个流
.directory(new File(".")); // 工作目录
构造.environment().put("LANG", "C"); // 给子进程加环境变量
Process 进程 = 构造.start();
String 输出 = new String(进程.getInputStream().readAllBytes(),
StandardCharsets.UTF_8); // ← 必须读掉!
int 退出码 = 进程.waitFor();
if (退出码 != 0)
throw new IllegalStateException(
String.join(" ", 命令) + " 失败(退出码 " + 退出码 + "):\n" + 输出);
return 输出;
}
跑一个命令("git", "log", "--oneline", "-5");
跑一个命令("git", "commit", "-m", 用户输入的消息); // 有空格引号都没事
// 带超时(别让它永远挂着)
if (!进程.waitFor(30, TimeUnit.SECONDS)) {
进程.destroyForcibly();
throw new IllegalStateException("超时");
}
// 输出很大时:边跑边读,别 readAllBytes
try (var 读 = 进程.inputReader(StandardCharsets.UTF_8)) {
读.lines().forEach(IO::println);
}
// 环境变量与系统属性
String 密钥 = System.getenv("API_KEY"); // 密钥放环境变量,别写进代码
Path 配置 = Path.of(System.getProperty("user.home"), ".我的工具", "config.json");
// 排查乱码时先看这三个(中文 Windows 上后两个是 GBK)
IO.println(System.getProperty("file.encoding")); // UTF-8(Java 18+)
IO.println(System.getProperty("native.encoding"));
IO.println(System.getProperty("stdout.encoding"));
// 跨平台判断
boolean 是 Windows = System.getProperty("os.name").toLowerCase(Locale.ROOT).contains("win");
// 关于自己进程的信息
ProcessHandle.current().pid();
Runtime.getRuntime().availableProcessors();
// 退出时做点事(保存进度、清理临时文件)
Runtime.getRuntime().addShutdownHook(new Thread(() -> 存档()));waitFor() 等它结束——双方永远等下去。症状是「调用某些命令时偶尔卡住」(输出少的时候不会,输出多了才会)。解法:redirectErrorStream(true) + 读完输出再 waitFor(),或者用 redirectOutput(文件) 让它直接写文件。System.getenv("API_KEY") 加上一句「没设就报错并提示怎么设」,是最省事也最安全的做法。Java 22 起有了 外部函数与内存 API(FFM):纯 Java 代码就能调用 C 库、读写堆外内存,不用再写 JNI 那套(要写 C 代码、要编译成动态库、要处理平台差异)。
一次完整的下行调用
调用 C 标准库的 strlen("hello ffm") → 9
分配 4 个 int 的堆外内存 → byteSize=16,读回第 0 个 = 42
Arena 关闭之后再读 → IllegalStateException: Already closed最后一行是 FFM 相对 JNI 最大的改进:内存的生命周期由 Arena 管理,用完即关,之后再访问是清晰的 Java 异常而不是进程崩溃。JNI 时代这种错误直接就是段错误,没有堆栈、没有日志。
三个概念
Arena:一块堆外内存的作用域。Arena.ofConfined()(单线程,try-with-resources 自动关)、Arena.ofShared()(多线程)、Arena.global()(永不释放)。MemorySegment:一段有边界检查的内存。越界访问抛异常而不是破坏内存。Linker+FunctionDescriptor:描述 C 函数的签名,拿到一个可调用的MethodHandle。
受限方法警告
本机上的原文:
WARNING: A restricted method in java.lang.foreign.Linker has been called WARNING: java.lang.foreign.Linker::downcallHandle has been called by E in an unnamed module WARNING: Use --enable-native-access=ALL-UNNAMED to avoid a warning WARNING: Restricted methods will be blocked in a future release unless native access is enabled
意思是:调用原生代码能绕过 Java 的所有安全保证,所以需要显式声明。加 --enable-native-access=ALL-UNNAMED 消除警告,而且将来的版本会从「警告」变成「直接拒绝」——现在就加上。
什么时候用它
- 用:调用系统 API、复用现成的 C 库(图像编解码、加密、科学计算)、和硬件打交道。
- 不用:标准库已经覆盖的事(文件、网络、加密都有纯 Java 实现,本章前面几卡)。每一处 FFM 调用都是可移植性和安全性的缺口,能不用就不用。
- 有个工具叫
jextract(GraalVM 项目下),能从 C 头文件自动生成 Java 绑定,手写这些描述符很快就烦了。
// 调 C 标准库的 strlen(本机通过)
import java.lang.foreign.*;
import java.lang.invoke.MethodHandle;
void main() throws Throwable {
Linker 链接器 = Linker.nativeLinker();
SymbolLookup 查找 = 链接器.defaultLookup();
// 描述 C 函数签名:size_t strlen(const char*)
MethodHandle strlen = 链接器.downcallHandle(
查找.find("strlen").orElseThrow(),
FunctionDescriptor.of(ValueLayout.JAVA_LONG, ValueLayout.ADDRESS));
try (Arena 区 = Arena.ofConfined()) { // 出块自动释放
MemorySegment 字符串 = 区.allocateFrom("hello ffm");
long 长度 = (long) strlen.invoke(字符串);
IO.println(长度); // 9
}
}
# 运行时加上这个,否则会有一串警告(报错原文见上)
$ java --enable-native-access=ALL-UNNAMED X.java
// 堆外内存的读写
try (var 区 = Arena.ofConfined()) {
var 缓冲 = 区.allocate(ValueLayout.JAVA_INT, 4); // 4 个 int = 16 字节
缓冲.setAtIndex(ValueLayout.JAVA_INT, 0, 42);
IO.println(缓冲.getAtIndex(ValueLayout.JAVA_INT, 0)); // 42
// 越界访问会抛 IndexOutOfBoundsException,而不是破坏内存
}
// 生命周期由 Arena 管(关掉之后再读抛 IllegalStateException: Already closed)
MemorySegment 泄漏的;
try (var 区 = Arena.ofConfined()) { 泄漏的 = 区.allocate(8); }
// 泄漏的.get(...) → IllegalStateException: Already closed
// 对比 JNI/C:这里会是 use-after-free,进程直接崩溃或读到垃圾
// 描述 C 结构体
var 时间结构 = MemoryLayout.structLayout(
ValueLayout.JAVA_LONG.withName("tv_sec"),
ValueLayout.JAVA_LONG.withName("tv_nsec"));
// 加载自己的动态库
var 我的库 = SymbolLookup.libraryLookup("mylib", Arena.global());long 在 Windows 上是 32 位、在 Linux/macOS 上是 64 位;size_t、指针的宽度也随平台变。FFM 里要用 ValueLayout.JAVA_LONG 还是 JAVA_INT,取决于目标平台的实际 ABI——写错不会报错,只会读到垃圾数据或者崩溃。规避:用 jextract 从头文件自动生成绑定(它知道平台差异),或者至少在每个目标平台上都真跑一遍验证。MemorySegment 知道自己有多大,越界访问抛 Java 异常;Arena 知道内存什么时候该释放,用完再访问也抛异常。这让「和 C 打交道」从「一错就是段错误」变成了「一错就是可调试的异常」——对写这类代码的人来说,这个差别是决定性的。做点自己的东西
前面十九章讲的是能力,这一章讲用它做什么。桌面窗口、小游戏、Minecraft 模组、自动化脚本、自己的服务——每条路都给了起手代码和第一个可以做出来的东西,并且把绕不开的 Spring Boot 单独讲明白。
Swing 是 JDK 自带的图形界面库(javax.swing.JFrame 在,而 javafx 不在——JavaFX 需要单独下载)。不装任何东西,一个文件就能做出一个有窗口的程序。
三个概念就够开始
- 容器:
JFrame(窗口)、JPanel(面板,用来分区和自定义绘制)。 - 组件:
JButton、JLabel、JTextField、JTextArea、JList、JTable、JFileChooser。 - 布局:
BorderLayout(上下左右中,最常用)、FlowLayout(顺着排)、GridLayout(网格)、BoxLayout(一行或一列)。
一条必须遵守的规则:EDT
- 所有对界面的操作都必须在事件分发线程(EDT)上做:用
SwingUtilities.invokeLater(...)包起来。 - 反过来,耗时的活不能在 EDT 上做——否则界面会卡住不响应。用
SwingWorker,或者更现代的:虚拟线程干活 +invokeLater更新界面(14 章)。 - 这两条是所有「界面卡死」问题的根源,记住就能避开九成的麻烦。
它的现状
- 优点:零依赖、跨平台、极其稳定(二十多年没有破坏性变更)、配合
jpackage(18 章)能做成双击就跑的应用。 - 缺点:默认外观老气(换
FlatLaf这个第三方外观库能立刻变现代)、不适合做复杂动效。 - 别的选择:JavaFX(更现代,但要单独引依赖)、Compose Multiplatform(Kotlin 生态,声明式)、或者干脆用 19 章的
HttpServer+ 浏览器当界面。
// 一个能跑的窗口程序(一个文件,java 它就行)
import javax.swing.*;
import java.awt.*;
void main() {
SwingUtilities.invokeLater(() -> { // 界面操作必须在 EDT 上
var 窗口 = new JFrame("字数统计");
窗口.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
窗口.setSize(480, 320);
窗口.setLocationRelativeTo(null); // 居中
var 输入框 = new JTextArea();
var 结果 = new JLabel("还没有内容", SwingConstants.CENTER);
var 按钮 = new JButton("统计");
按钮.addActionListener(e -> { // lambda 当回调(11 章)
var 文本 = 输入框.getText();
long 字数 = 文本.codePoints().filter(Character::isLetterOrDigit).count();
结果.setText("%d 个字符,%d 行".formatted(字数, 文本.lines().count()));
});
窗口.setLayout(new BorderLayout(8, 8));
窗口.add(new JScrollPane(输入框), BorderLayout.CENTER);
窗口.add(按钮, BorderLayout.SOUTH);
窗口.add(结果, BorderLayout.NORTH);
窗口.setVisible(true);
});
}
// 耗时的活不能在 EDT 上做(否则界面卡死)
按钮.addActionListener(e -> {
按钮.setEnabled(false);
Thread.startVirtualThread(() -> { // 后台干活(14 章)
var 结果值 = 很慢的处理();
SwingUtilities.invokeLater(() -> { // 回到 EDT 更新界面
结果.setText(结果值);
按钮.setEnabled(true);
});
});
});
// 选文件(做工具时最常用的一个组件)
var 选择器 = new JFileChooser();
if (选择器.showOpenDialog(窗口) == JFileChooser.APPROVE_OPTION) {
var 文件 = 选择器.getSelectedFile().toPath();
}
// 自定义绘制(画图、做游戏画面的基础)
var 画布 = new JPanel() {
@Override protected void paintComponent(Graphics g) {
super.paintComponent(g);
var g2 = (Graphics2D) g;
g2.setRenderingHint(RenderingHints.KEY_ANTIALIASING,
RenderingHints.VALUE_ANTIALIAS_ON);
g2.setColor(new Color(0x89b4fa));
g2.fillOval(50, 50, 100, 100);
g2.drawString("你好", 80, 180);
}
};
// 想重画就调 画布.repaint();setText/add/setVisible/repaint 之外的界面操作都要包在 SwingUtilities.invokeLater 里。反向的错误同样常见:在按钮的回调里直接做耗时操作——那个回调本身就跑在 EDT 上,界面会整个冻住直到它结束。main 里读参数的部分换成 JFileChooser,把 System.out.printf 换成 结果.setText(...),十分钟就有一个能双击运行的桌面工具(配合 18 章的 jpackage 还能发给别人)。这是「把计算和界面分开」(18 章测试那卡)最直接的回报。Java 在游戏领域有一块特殊的地盘:Minecraft 的 Java 版本身就是 Java 写的,它的模组生态是全世界最大的之一。此外还有成熟的 2D/3D 游戏框架。
从零开始:一个游戏循环
不用任何框架,Swing 的自定义绘制(上一卡)加一个定时器就能做出 2D 游戏:更新状态 → 重绘 → 等一帧。贪吃蛇、俄罗斯方块、扫雷、打砖块,都能在两三百行内做完。这是最好的练手项目——用到 07 章的建模、08 章的状态分发、10 章的集合。
往上走的几个框架
| 框架 | 做什么 | 特点 |
|---|---|---|
| libGDX | 2D/3D 游戏,跨平台 | 成熟、文档好、能发布到桌面和 Android |
| LWJGL | OpenGL / Vulkan / OpenAL 的绑定 | 底层,Minecraft 和 libGDX 都建在它上面 |
| Processing | 创意编码、可视化 | 语法是简化的 Java,几行就能出画面 |
| JavaFX | 带 Canvas 和动画时间线 | 比 Swing 现代,适合工具型图形应用 |
Minecraft 模组:一条很实际的路
- 为什么它是个好入口:你有一个现成的、复杂的、有趣的世界,加一个方块或一个物品就能立刻在游戏里看到效果——反馈极其直接。
- 两大加载器:Fabric(轻量、更新快、适合新手)和 NeoForge/Forge(功能全、大型模组多)。两者都有官方的项目模板。
- 会用到本页的什么:类与继承(05、06 章,注册方块要继承基类)、集合(10 章)、事件回调(11 章的 lambda)、JSON 配置(19 章)、构建工具(18 章,模组用 Gradle)。
- 注意版本:Minecraft 的每个版本对应特定的加载器版本和 Java 版本,教程一定要看清是哪个版本的。
// 一个最小的游戏循环(Swing + 定时器,不用任何框架)
import javax.swing.*;
import java.awt.*;
import java.awt.event.*;
import java.util.*;
// 用 record 建模游戏状态(07 章)
record 格子(int 行, int 列) {}
class 贪吃蛇 extends JPanel implements ActionListener, KeyListener {
static final int 格宽 = 20, 列数 = 20, 行数 = 20;
Deque<格子> 蛇 = new ArrayDeque<>(List.of(new 格子(10, 10))); // 10 章
格子 食物 = new 格子(5, 5);
int 行速 = 0, 列速 = 1;
贪吃蛇() {
setPreferredSize(new Dimension(列数 * 格宽, 行数 * 格宽));
setBackground(Color.BLACK);
setFocusable(true);
addKeyListener(this);
new Timer(120, this).start(); // 每 120ms 一帧(在 EDT 上跑,安全)
}
@Override public void actionPerformed(ActionEvent e) { // 每一帧:更新 + 重绘
var 头 = 蛇.peekFirst();
var 新头 = new 格子(头.行() + 行速, 头.列() + 列速);
if (新头.行() < 0 || 新头.行() >= 行数 || 蛇.contains(新头)) { 重开(); return; }
蛇.addFirst(新头);
if (新头.equals(食物)) 食物 = 随机空格(); // record 的 equals 白拿(07 章)
else 蛇.removeLast();
repaint();
}
@Override protected void paintComponent(Graphics g) {
super.paintComponent(g);
g.setColor(Color.RED);
g.fillRect(食物.列() * 格宽, 食物.行() * 格宽, 格宽, 格宽);
g.setColor(Color.GREEN);
for (var 段 : 蛇)
g.fillRect(段.列() * 格宽, 段.行() * 格宽, 格宽 - 1, 格宽 - 1);
}
@Override public void keyPressed(KeyEvent e) {
switch (e.getKeyCode()) { // 箭头 switch(03 章)
case KeyEvent.VK_UP -> { 行速 = -1; 列速 = 0; }
case KeyEvent.VK_DOWN -> { 行速 = 1; 列速 = 0; }
case KeyEvent.VK_LEFT -> { 行速 = 0; 列速 = -1; }
case KeyEvent.VK_RIGHT -> { 行速 = 0; 列速 = 1; }
default -> { }
}
}
@Override public void keyReleased(KeyEvent e) {}
@Override public void keyTyped(KeyEvent e) {}
}
void main() {
SwingUtilities.invokeLater(() -> {
var 窗口 = new JFrame("贪吃蛇");
窗口.add(new 贪吃蛇());
窗口.pack();
窗口.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE);
窗口.setVisible(true);
});
}javax.swing.Timer 做游戏循环时,回调跑在 EDT 上——这既是优点也是限制。优点是可以直接改界面不用 invokeLater;限制是每一帧的计算必须很快,超过一帧的时间界面就开始卡。做复杂一点的游戏就该换成「独立线程算逻辑 + 定时通知界面重绘」,或者直接上 libGDX 那类框架(它们有自己的渲染循环)。另外别用 java.util.Timer——它和 Swing 的那个同名但不在 EDT 上,直接改界面会出上一卡那类问题。ArrayDeque 当蛇身(两头操作正好)、record 的 equals 判断吃到食物、箭头 switch 处理按键、自定义绘制。「做个小游戏」之所以是最好的练手项目,就是因为它天然会用到这些东西,而且每一步都看得见结果。这是最容易做出成果的一类:解决一个你自己每天都在手动做的事。而且 Java 现在写这类东西的门槛已经很低——单文件、不用构建工具、标准库自带需要的一切(01 章)。
五个可以马上做的
- 整理下载文件夹:按后缀分类移动到子目录,按日期归档(19 章的
Files.walk+Files.move)。 - 批量重命名:照片按拍摄日期、文件按序号补零(04 章的
String.format("%03d"))。 - 日志分析:统计错误类型、按小时分布、找出最慢的请求(11 章的
groupingBy)。 - 抓接口做统计:抓一个你关心的公开接口,存成 CSV 或画个简单的图(19 章的
HttpClient+ JSON)。 - 定时备份:把某个目录打包成 zip 加上日期(标准库自带
java.util.zip)。
让它好用的几个细节
- 先做「预演模式」:加一个
--dry-run参数,只打印要做什么而不真做。处理文件的工具,这一条能救你的数据。 - 输出到 stdout,错误到 stderr,失败给非 0 退出码(03 章)——这样它能被别的脚本调用。
- 配置放用户目录:
Path.of(System.getProperty("user.home"), ".我的工具")(19 章)。 - 密钥从环境变量读,别写死(19 章)。
怎么让它随手可用
- 最简单:写个 shell 脚本或 bat 包一层
java 你的文件.java "$@",放进 PATH。 - 正式一点:打成可执行 jar(18 章),脚本里
java -jar。 - 要发给别人:
jpackage打成安装包(18 章),对方不用装 Java。 - 定时跑:交给系统的定时器(Linux/macOS 的 cron、Windows 的任务计划程序),比在程序里写个死循环好得多——进程挂了系统会重新拉起。
// 整理下载文件夹:按后缀分类(一个文件,直接 java 它)
import java.nio.file.*;
import java.util.*;
void main(String[] args) throws Exception {
boolean 预演 = List.of(args).contains("--dry-run"); // ← 先做这个
var 目录 = Path.of(System.getProperty("user.home"), "Downloads");
var 归类 = Map.of(
"图片", Set.of("png", "jpg", "jpeg", "gif", "webp"),
"文档", Set.of("pdf", "docx", "xlsx", "md", "txt"),
"压缩包", Set.of("zip", "7z", "tar", "gz"),
"安装包", Set.of("exe", "msi", "dmg", "deb"));
int 移动了 = 0;
try (var 文件 = Files.list(目录)) { // 记得 close(12 章)
for (var p : 文件.filter(Files::isRegularFile).toList()) {
String 名 = p.getFileName().toString();
int 点 = 名.lastIndexOf('.');
if (点 < 0) continue;
String 后缀 = 名.substring(点 + 1).toLowerCase(Locale.ROOT);
String 分类 = 归类.entrySet().stream()
.filter(e -> e.getValue().contains(后缀))
.map(Map.Entry::getKey).findFirst().orElse("其它");
var 目标 = 目录.resolve(分类).resolve(名);
IO.println((预演 ? "[预演] " : "") + 名 + " → " + 分类 + "/");
if (!预演) {
Files.createDirectories(目标.getParent());
Files.move(p, 目标, StandardCopyOption.REPLACE_EXISTING);
}
移动了++;
}
}
IO.println("共 " + 移动了 + " 个文件");
}
# 包一层脚本,放进 PATH,随手可用
# ~/bin/整理 (chmod +x)
#!/bin/sh
exec java ~/工具/整理下载.java "$@"
# 定时跑交给系统,别在程序里写死循环
# crontab -e: 0 * * * * ~/bin/整理
// 日志分析:一个流水线搞定(11 章)
try (var 行 = Files.lines(日志)) {
行.filter(l -> l.contains("ERROR"))
.map(l -> l.substring(11, 13)) // 取小时
.collect(Collectors.groupingBy(h -> h, TreeMap::new, Collectors.counting()))
.forEach((时, 数) -> IO.println(时 + "点 " + "█".repeat((int)(数 / 10)) + " " + 数));
}Files.list() 返回的是惰性流,如果你在遍历过程中往同一个目录里移动文件,行为是未定义的——可能重复处理、可能漏掉、可能抛异常。解法是先 .toList() 把清单固化下来再操作(上面的代码就是这么写的)。这和 10 章「遍历时修改集合」是同一类问题,只不过对象换成了文件系统。--dry-run 写进去。这不是「以后再加的功能」——它是你验证逻辑对不对的唯一安全手段。移动、重命名、删除这类操作错一次就要手动收拾半天,而加这个参数只要三行代码。同理,第一次真跑之前先在一个复制出来的测试目录上试。上一卡那个整理脚本能跑了,但它只认一个写死的 --dry-run。让它真正好用的那一步,是把参数解析交出去——你写声明,它负责解析、校验、生成帮助、处理拼错。本卡于 picocli 4.7.7 + JDK 25.0.1。
为什么不自己 args[0]
手写参数解析在第二个选项之后就开始变得繁琐:短选项和长选项、-n 还是 --dry-run、带值的选项、可重复的选项、位置参数、默认值、类型转换、以及那个永远要手动维护的帮助文本。picocli 用注解把这些一次说清,而且只有 1 个 jar、408 KB、无传递依赖——这是本页所有第三方库里性价比最高的一个。
它替你生成的帮助(报错原文)
Usage: zhengli [-hnV] [--min-size=<最小>] [-t=<后缀>[,<后缀>...]]... <目录>
按后缀把目录里的文件分类归档。
<目录> 要整理的目录
-h, --help Show this help message and exit.
--min-size=<最小> 只处理大于这个字节数的文件
-n, --dry-run 只打印要做什么,不真动文件
-t, --type=<后缀>[,<后缀>...]
只处理这些后缀(逗号分隔)
-V, --version Print version information and exit.这份东西一行都不用自己写,全部来自注解里的 description。终端支持时它还会自动上色(输出里带 ANSI 序列,重定向到文件时自动关掉)。
拼错了会提示你
$ zhengli --dry-runn Unknown option: '--dry-runn' Possible solutions: --dry-run
它会算编辑距离给出建议,并以退出码 2 结束。这种细节手写要花不少功夫,而它是白送的。
退出码:让它能被别的脚本调用
实现 Callable<Integer>,返回值就是退出码,System.exit(new CommandLine(...).execute(args)) 把它交出去。三种情形:正常结束 0、参数写错 2(picocli 给的)、我自己判断「目录不存在」返回 2。
这就是 03 章那条「失败给非 0 退出码」在这里的落点——有了它,你的工具才能出现在 && 链条和 CI 脚本里。
三个注解就够用
| 注解 | 管什么 | 例子 |
|---|---|---|
@Command | 命令本身:名字、版本、描述 | mixinStandardHelpOptions 一开就白送 -h/-V |
@Parameters | 位置参数(不带横杠那些) | index = "0"、defaultValue = "." |
@Option | 带横杠的选项 | names = {"-n", "--dry-run"}、split = "," |
类型转换是自动的:字段声明成 Path 它就给你 Path,long 就给 long,String[] + split="," 就按逗号拆开(-t java,md 得到两个元素)。转不动时它自己报错并退出,不用你写 try/catch。
// 依赖:1 个 jar,408 KB,无传递依赖
<dependency><groupId>info.picocli</groupId>
<artifactId>picocli</artifactId><version>4.7.7</version></dependency>
import picocli.CommandLine;
import picocli.CommandLine.*;
import java.nio.file.*;
import java.util.concurrent.Callable;
@Command(name = "zhengli", version = "整理 1.0",
mixinStandardHelpOptions = true, // 白送 -h 和 -V
description = "按后缀把目录里的文件分类归档。")
public class 整理 implements Callable<Integer> {
@Parameters(index = "0", description = "要整理的目录", defaultValue = ".")
Path 目录; // 类型转换自动完成
@Option(names = {"-n", "--dry-run"}, description = "只打印要做什么,不真动文件")
boolean 演习;
@Option(names = {"-t", "--type"}, split = ",", description = "只处理这些后缀")
String[] 后缀; // -t java,md → ["java","md"]
@Option(names = "--min-size", defaultValue = "0",
description = "只处理大于这个字节数的文件")
long 最小;
@Override
public Integer call() throws Exception {
if (!Files.isDirectory(目录)) {
System.err.println("不是一个目录: " + 目录); // 错误走 stderr
return 2; // 非 0 退出码
}
// …上一卡那段整理逻辑…
return 0;
}
public static void main(String[] args) {
System.exit(new CommandLine(new 整理()).execute(args));
}
}
#
$ zhengli --help # 上面那份帮助,一行没写
$ zhengli . -n -t java,md --min-size 100
$ zhengli --dry-runn # Possible solutions: --dry-run(退出码 2)
$ zhengli --version # 整理 1.0
// 子命令:一个工具装几个功能
@Command(name = "我的工具", subcommands = {整理.class, 备份.class, 统计.class})
class 主命令 {}
// $ 我的工具 备份 --目标 /d/bak
# 包一层脚本放进 PATH(上一卡)
#!/bin/sh
exec java -jar ~/工具/整理.jar "$@"defaultValue。写成 long 最小 = 100; 看着一样,但 picocli 生成的帮助里不会显示这个默认值,用户无从得知;写成 @Option(defaultValue = "100") 才会在帮助里带出来(配合 showDefaultValues = true 或 ${DEFAULT-VALUE} 占位符)。更一般的教训:命令行工具的帮助文本就是它的全部文档,凡是用户需要知道的信息,都要让它有办法出现在 --help 里——写在代码注释里等于没写。mixinStandardHelpOptions = true 是第一个该开的开关——它一次给你 -h/--help 和 -V/--version 两组标准选项,还保证 --help 的退出码是 0(工具被脚本调用时这点很重要,帮助不该被当成失败)。配合 @Command(version = ...),你的工具从第一天起就有版本号可查——三个月后自己都会忘记装在 PATH 里的那个 jar 是哪一版。服务端是 Java 用得最多的领域。但「写个自己的服务」和「在公司里做企业系统」完全是两回事——前者可以很轻,19 章那个十几行的 HttpServer 就已经是一个能用的服务了。
三个层次,按需选择
| 层次 | 用什么 | 适合 |
|---|---|---|
| 够用就行 | com.sun.net.httpserver(19 章) | 几个接口、Webhook、给自己的工具做界面 |
| 轻量框架 | Javalin / Helidon SE / Vert.x | 个人项目、小服务、要路由和 JSON |
| 全功能框架 | Spring Boot / Quarkus / Micronaut | 要数据库、认证、配置、监控的完整应用 |
不必从最重的那层开始。很多人学 Java 服务端的第一步就是啃 Spring 的注解和配置,结果学到的是那个框架而不是 Java——先用轻量的把「一个 HTTP 请求进来、处理、返回」这条链路走通,再去看框架帮你省掉了什么,理解会牢固得多。
一个真服务需要考虑什么
- 并发:虚拟线程(14 章)让「一个请求一个线程」重新可行,这是今天最简单也够用的模型。
- 数据:JDBC 是标准接口,上面有 Dapper 风格的轻量封装(JDBI)和全功能 ORM(Hibernate/JPA)。小项目直接写 SQL 完全没问题。
- 配置与密钥:环境变量(19 章),别写进代码。
- 错误处理:给客户端返回有意义的状态码和消息,给日志留下堆栈(12 章)。
- 可观测:至少有日志;再进一步是指标和 JFR(16 章)。
部署
- 最简单:打成可执行 jar(18 章),扔到服务器上
java -jar,用 systemd 之类的守护它。 - 容器:官方镜像(
eclipse-temurin:25-jre)+ 你的 jar。注意容器里要限制堆大小——现代 JVM 能识别容器内存限制,但显式设-XX:MaxRAMPercentage=75更保险(15 章)。 - 要快速启动:AOT 缓存(15 章)或 GraalVM Native Image(18 章)。
// 一个完整的小服务(只用 JDK,能跑)
import com.sun.net.httpserver.*;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.*;
record 记录(String 内容, long 时间) {} // 07 章
static final ConcurrentHashMap<String, 记录> 存储 = new ConcurrentHashMap<>(); // 13 章
void main() throws Exception {
var 服务 = HttpServer.create(new InetSocketAddress(8080), 0);
服务.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); // 14 章
服务.createContext("/记录/", 交换 -> {
String 键 = 交换.getRequestURI().getPath().substring("/记录/".length());
try {
switch (交换.getRequestMethod()) { // 03 章
case "GET" -> {
var 值 = 存储.get(键);
if (值 == null) 回(交换, 404, "没有这条");
else 回(交换, 200, 值.内容());
}
case "PUT" -> {
String 体 = new String(交换.getRequestBody().readAllBytes(),
StandardCharsets.UTF_8);
存储.put(键, new 记录(体, System.currentTimeMillis()));
回(交换, 201, "已保存");
}
case "DELETE" -> 回(交换, 存储.remove(键) != null ? 204 : 404, "");
default -> 回(交换, 405, "不支持这个方法");
}
} catch (Exception e) { // 12 章:最外层兜住
e.printStackTrace();
回(交换, 500, "服务器内部错误");
}
});
服务.createContext("/健康", 交换 -> 回(交换, 200, "ok"));
服务.start();
IO.println("http://localhost:8080/记录/试试");
}
static void 回(HttpExchange 交换, int 状态, String 内容) {
try (交换) {
byte[] b = 内容.getBytes(StandardCharsets.UTF_8);
交换.getResponseHeaders().add("Content-Type", "text/plain; charset=utf-8");
交换.sendResponseHeaders(状态, b.length == 0 ? -1 : b.length);
if (b.length > 0) 交换.getResponseBody().write(b);
} catch (Exception e) { e.printStackTrace(); }
}
# 试一下
$ curl -X PUT -d "你好" http://localhost:8080/记录/a
$ curl http://localhost:8080/记录/a
# 容器化(18 章打好 jar 之后)
# FROM eclipse-temurin:25-jre
# COPY app.jar /app.jar
# ENV JAVA_TOOL_OPTIONS="-XX:MaxRAMPercentage=75"
# ENTRYPOINT ["java", "-jar", "/app.jar"]HttpServer 或轻量框架把 HTTP、并发、数据、错误处理弄明白,然后你会自己发现「这些样板确实该有人帮我管」——那时再学框架,每一个特性都对应一个你亲身遇到过的痛点。前一卡说「别从 Spring 开始」——那是学习顺序上的建议,不是让你躲着它。现实是你迟早会撞上:绝大多数 Java 教程、示例和开源项目都是 Spring 写的,看不懂就没法读别人的代码。这一卡不教配置,只讲清它的三个核心机制,以及一个真跑通的最小版本。本卡数字于 Spring Boot 4.1.0 + JDK 25.0.1。
最小版本:一个 pom + 一个文件
继承 spring-boot-starter-parent、写一条 spring-boot-starter-web 依赖,加一个三十行的 java 文件,mvn package 之后 java -jar 就是一个能用的 REST 服务。启动 1.7 秒,curl 直接能打通(右边就是那份真代码)。
机制一:依赖注入容器
你不 new 对象,你在构造器参数里声明「我需要一个 Store」,容器把它塞进来。只有一个构造器时连注解都不用写。
ctx.getBeanDefinitionCount() = 146。你自己写了 2 个类,容器里有 146 个对象,其余全是它替你装的。同一个类型取两次是同一个实例(默认单例,== 为 true)。
价值:换实现不用改调用方(测试时换成假的,18 章)。代价:「这个对象从哪来的」不再显然——这正是 06 章讲依赖注入时说的那笔交易,只是规模放大了七十倍。
机制二:自动配置
它看你的 classpath 上有什么,推断你想要什么,然后替你配好。「一条依赖就有了内嵌 Tomcat + JSON 序列化 + 错误页 + 日志」就是这么来的。
(加 --debug 看条件评估报告):52 项自动配置生效,39 项因条件不满足被跳过。生效的里面有 DispatcherServletAutoConfiguration、ErrorMvcAutoConfiguration、HttpEncodingAutoConfiguration 这些名字——每一个都对应一件它默默替你做了的事。
这也是它最让人迷路的地方:你的代码里没有一行说明 Tomcat 是谁起的。记住 --debug 这个开关——它会逐条打印每项配置生效或没生效的原因,是搞清楚「它到底干了什么」的唯一入口。
机制三:起步依赖(starter)
一行 spring-boot-starter-web,拖进 34 个 jar(Spring Framework 7.0.8、内嵌 Tomcat 11、Jackson、Logback…),打出来的可执行 jar 19.9 MB。
对照:把上面这个服务(PUT/GET 一条笔记、虚拟线程处理请求)用 19 章那套裸 HttpServer 重写一遍,一个依赖都不用,jar 打出来 1974 字节——同一件事,19.9 MB 对 1.9 KB,一万倍。
这个对比不是在说 Spring Boot 臃肿,而是在标出它的适用区间:那 19.9 MB 里装的是数据库、认证、配置、监控、指标的接线,你用得到它们的时候这笔账立刻划算,用不到的时候它就只是启动时间和一堆你没读过的代码。starter 真正的价值也不是省那几行依赖,而是「这一族库的版本由 parent 统一管」——你不写版本号,也就不会撞上前一章那种冲突。
两个值得知道的现代开关
- 虚拟线程:配置里加
spring.threads.virtual.enabled=true。处理请求的线程从Thread[#60,http-nio-8097-exec-4,5,main]变成VirtualThread[#65,tomcat-handler-1]/runnable@ForkJoinPool-1-worker-1——一行配置接上 14 章那整套模型,代码一个字都不用改。 - CDS 加速启动:先
-Djarmode=tools -jar app.jar extract把 jar 摊开,训练跑一次生成归档,之后带-XX:SharedArchiveFile启动。1690 ms → 1145 ms(各三次,波动小于 30 ms),归档文件 31 MB。原理见 15 章。
什么时候它值得
值得:要数据库 + 认证 + 配置 + 监控这一整套;要长期维护;要用它那个确实庞大好用的生态。
不值得:几个接口的小服务、一次性工具、以及你正想搞清楚底下发生了什么的时候。
判断标准不是项目大小,是「你需要的东西里,它替你做的占多少」。只用到 5%,那 34 个 jar 和 52 项自动配置就是纯负担;用到 60%,它省下的时间没有别的东西能替代。
<!-- pom.xml 的全部内容(真跑通的版本) -->
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.0</version>
</parent>
<properties><java.version>25</java.version></properties>
<dependencies>
<dependency> <!-- 就这一条,拖进 34 个 jar -->
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency> <!-- 不写版本号:parent 管着 -->
</dependencies>
<build><plugins><plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin></plugins></build>
@Service // 「这个类交给容器管」
class Store {
private final ConcurrentHashMap<String, String> map = new ConcurrentHashMap<>();
void put(String k, String v) { map.put(k, v); }
String get(String k) { return map.get(k); }
}
@SpringBootApplication // 开启自动配置 + 扫描本包
@RestController // 方法的返回值直接当响应体
public class Svc {
private final Store store;
Svc(Store store) { this.store = store; } // 构造器注入:没有注解
@GetMapping("/note/{key}")
String get(@PathVariable String key) { return store.get(key); }
@PutMapping("/note/{key}")
String put(@PathVariable String key, @RequestBody String body) {
store.put(key, body);
return body;
}
public static void main(String[] args) {
var ctx = SpringApplication.run(Svc.class, args);
System.out.println(ctx.getBeanDefinitionCount()); // 146
}
}
# 跑起来、看它做了什么
$ mvn package && java -jar target/svc-1.0.jar
$ curl -X PUT -d "hello" http://localhost:8080/note/a
$ curl http://localhost:8080/note/a # hello
$ java -jar app.jar --debug # 52 项生效 / 39 项跳过
$ java -jar app.jar --server.port=8099 # 换端口
$ java -jar app.jar --spring.threads.virtual.enabled=true
# CDS:启动 1690ms → 1145ms
$ java -Djarmode=tools -jar app.jar extract
$ cd app && java -XX:ArchiveClassesAtExit=app.jsa \
-Dspring.context.exit=onRefresh -jar app.jar
$ java -XX:SharedArchiveFile=app.jsa -jar app.jar@GetMapping 的方法返回 null 时,Spring 给客户端的是 200 加一个空 body,不是 404。上面那个 /note/{key} 查一个不存在的键,curl -i 拿到的是 HTTP/1.1 200 + Content-Length: 0——调用方会理解成「查到了,内容为空」,而不是「没有这条」。要 404 就得显式返回 ResponseEntity.notFound().build(),或者抛一个带 @ResponseStatus(NOT_FOUND) 的异常。对照:同一个接口用裸 HttpServer 写,查不到就是 404——因为那行 sendResponseHeaders(404, -1) 是你自己写的,你不可能忘了它存在。框架替你做默认决定的每一个地方,都是你需要额外确认一遍的地方——这句话适用于它全部 52 项自动配置。@Component/@Service/@Repository/@Configuration——「这个类交给容器管」,四个的区别只是语义标签;② 构造器参数(或老代码里的 @Autowired)——「这里要一个别人管着的对象」;③ @GetMapping/@PostMapping/@PathVariable/@RequestBody——「这个方法对应哪个 URL、参数从请求的哪部分来」。剩下的注解绝大多数是配置细节,跳过不影响你读懂主流程。学会 Java 之后,有几个方向是「顺路」的——它们共享同一个运行时或同一套类库,你的知识可以直接迁移。
Android
- 整个 Android 平台的 API 是 Java 形态的,即使今天官方首选 Kotlin,底下那套类库、生命周期、四大组件都是你已经熟悉的东西。
- 差异:用的不是标准 JVM(是 ART,字节码会被转成 dex)、类库是 Android 自己的子集、有严格的主线程规则(和 Swing 的 EDT 很像,本章第 1 卡)。
- 建议:先会 Java,学 Android 时重点在平台本身(生命周期、UI、权限、后台限制),语言部分基本免费。
Kotlin
- 同一个 JVM,可以逐文件混写,Kotlin 能直接调用所有 Java 库,反之亦然。
- 它省掉的:空安全进类型系统、data class(≈ record)、扩展函数、协程(≈ 虚拟线程的另一种形态)、更少的样板。
- 建议:先会 Java 再学 Kotlin 几乎没有浪费——你会更清楚每个 Kotlin 特性在解决什么问题。反过来(先 Kotlin 后 Java)也可以,只是会觉得 Java 啰嗦。
其它 JVM 语言
- Scala:函数式 + 面向对象,表达力极强,学习曲线陡。大数据领域(Spark)用得多。
- Clojure:Lisp 方言,不可变数据结构,思路和 Java 差别很大——学它主要是为了换个脑子。
- Groovy:动态类型的 JVM 语言,Gradle 的构建脚本就是它。
还有一条:读标准库源码
JDK 的源码就在你机器上(lib/src.zip),IDE 里对任何标准库方法按跳转就能看到实现。ArrayList、HashMap、String 的源码是极好的学习材料——它们写得干净、注释详尽,而且你每天都在用它们,读起来有真实的语境。
// Java 和 Kotlin 的同一段代码
// Java(07 章)
record 玩家(String 名字, int 分数) {}
List<玩家> 高分 = 名单.stream()
.filter(p -> p.分数() > 90)
.sorted(Comparator.comparingInt(玩家::分数).reversed())
.toList();
// Kotlin 的等价写法
// data class 玩家(val 名字: String, val 分数: Int)
// val 高分 = 名单.filter { it.分数 > 90 }.sortedByDescending { it.分数 }
// 读标准库源码:IDE 里对任何方法按跳转
// 推荐从这几个开始(写得好,而且你天天在用):
// java.util.ArrayList —— 扩容策略、快速失败迭代器
// java.util.HashMap —— 哈希扰动、链表转红黑树的阈值
// java.lang.String —— 紧凑字符串、hashCode 缓存
// java.util.Optional —— 一共两百行,五分钟读完
// java.util.concurrent.atomic.AtomicInteger —— CAS 怎么用
# 源码就在 JDK 目录里
$ ls $JAVA_HOME/lib/src.zip
// 一个具体的建议:读完 ArrayList 的 grow() 方法,
// 你就知道为什么 16 章说"已知大小时要预分配容量"Optional 开始——它总共两百来行,逻辑简单,但把「一个好的 API 该怎么设计」演示得很清楚(为什么没有 isEmpty 直到 Java 11、为什么 get() 后来被建议改成 orElseThrow())。然后读 ArrayList:它会让你对「数组扩容」「快速失败迭代器」「为什么 subList 是视图」这些本页反复提到的概念有第一手的理解。Java 的界面:Swing、JavaFX 与 TUI
给自己写的小工具配个窗口——批量改名、记账、题库抽卡,这些东西有个界面就顺手多了。Swing 就在 JDK 里,装都不用装;这一章讲它够用到哪、什么时候该换、以及怎么把成品交到朋友手里。
Java 的桌面界面有个别处少见的优势:标准库自带。不用 Gradle、不用加依赖,一个 .java 文件就能弹出窗口——想给自己的小工具加个界面时,这个起步成本几乎为零。
真的什么都不用装
- JDK 25.0.1 上
java Demo.java直接跑起右侧那段代码,无-cp、无构建工具:javax.swing与java.awt都在java.desktop模块里,随 JDK 分发。 - 控件的计算结果与尺寸都能直接读出来:
f.getPreferredSize()得java.awt.Dimension[width=119,height=65]——不弹窗口也能验证界面搭对没有,这一点下一卡还会用到。
它「一眼就土」是有具体原因的
UIManager.getLookAndFeel().getName()默认得 Metal——Swing 默认用的是跨平台外观,不是你系统的原生外观。绝大多数「Swing 好丑」的印象来自这个默认值。- 一行就能换成原生:
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName()),必须写在建任何窗口之前。 - 想要真正现代的观感,用 FlatLaf(当前 3.7.2,单个 jar 无传递依赖):一样是一行
setLookAndFeel,扁平化、暗色主题、HiDPI 都有。这是今天写 Swing 的实际默认选择。
JavaFX 不在 JDK 里
- JavaFX 从 Java 11 起就移出了 JDK。在 JDK 25 上
import javafx.application.Application;直接编译失败:error: package javafx.application does not exist。 - 要用得单独引 OpenJFX(当前稳定 26.0.2),而且它带一套原生库——这会直接影响后面「怎么发给别人」那一卡。
- 所以取舍很实际:只是想给自己的工具加个窗口 → Swing(零依赖、jlink 能裁);要动画、图表、CSS 样式、更现代的控件模型 → JavaFX,并接受多一层依赖。
import javax.swing.*;
import java.awt.*;
public class Demo {
public static void main(String[] args) throws Exception {
// 换原生外观,必须在建窗口之前
UIManager.setLookAndFeel(UIManager.getSystemLookAndFeelClassName());
JFrame f = new JFrame("温度换算");
JSpinner spin = new JSpinner(new SpinnerNumberModel(25, -40, 100, 1));
JLabel out = new JLabel();
Runnable conv = () -> out.setText(String.format("%.1f °F",
((Integer) spin.getValue()) * 9 / 5.0 + 32));
spin.addChangeListener(e -> conv.run());
conv.run(); // 25 → 77.0 °F
f.setLayout(new FlowLayout());
f.add(spin);
f.add(out);
f.pack();
f.setDefaultCloseOperation(JFrame.EXIT_ON_CLOSE); // ← 别漏
f.setVisible(true);
}
}
// $ java Demo.java —— 就这样,没有第二步System.exit(0)(或给窗口设 EXIT_ON_CLOSE)去掉后,main 明明打印完「main 结束」,进程仍然挂着不退,只能被外部杀掉。命令行工具顺手加了个窗口之后发现「程序跑完不返回」,十有八九是这条。jshell 里可以直接 import javax.swing.* 然后一句一句搭窗口,改一行看一眼。搭好了再誊回文件。Swing 本身不难,但有三件事不知道就会一路踩:界面只能在一个特定线程上碰、布局不要手写坐标、以及一个很少被提起的能力——它能不开窗口就把界面画出来。
一、所有控件操作都在 EDT 上
- Swing 不是线程安全的。控件的创建与修改都必须在事件分发线程(EDT)上进行——从别的线程改界面,症状是随机的错乱和偶发异常,不会当场报错。
- 从别处切进去用
SwingUtilities.invokeLater(() -> ...)。 - 反过来,耗时的活儿绝不能写在 EDT 上:按钮回调里直接下载文件、扫全盘,界面会整个冻住(连重绘都停)。用
SwingWorker:doInBackground()在后台线程跑,done()和process()自动回到 EDT。 - 这条规矩不是 Swing 独有——GUI 工具包几乎都是「单线程渲染 + 后台干活」的模型(Python 的 tkinter、C++ 的 Qt 都一样)。
二、用布局管理器,别用绝对坐标
setLayout(null)加手写setBounds一开始最快,然后就会在换字号、换系统、HiDPI 屏上全线错位。- 够用的三个:
BorderLayout(上下左右中,窗口主结构)、BoxLayout(一行或一列)、GridBagLayout(表格式,最灵活也最啰嗦)。嵌套 JPanel 比硬用一个复杂布局清楚得多。 pack()让窗口按内容的首选尺寸收紧,比自己猜一个setSize靠谱。
三、界面能离屏渲染成图片
- 把面板画进
BufferedImage再写出 PNG,全程不弹任何窗口:得到 93×26 的 846 字节图片。做法就是右侧那几行——panel.setSize(panel.getPreferredSize())→doLayout()→panel.paint(g)。 - 用处比看起来大:改了布局想确认效果,不必反复开关窗口;界面能进自动化检查(渲染成图存档、和上一版比对);在没有图形界面的机器上也能验证排版。
// ① 耗时活儿放 SwingWorker,别堵住 EDT
new SwingWorker<String, Void>() {
protected String doInBackground() throws Exception {
return 抓一个很慢的接口(); // 后台线程
}
protected void done() throws Exception {
label.setText(get()); // 自动回到 EDT
}
}.execute();
// ② 离屏渲染:不开窗口,直接把界面画成 PNG(93x26)
JPanel p = new JPanel(new FlowLayout());
p.add(new JLabel("25 °C ="));
p.add(new JLabel("77.0 °F"));
p.setSize(p.getPreferredSize());
p.doLayout(); // 没这一句子控件全在 (0,0)
BufferedImage img = new BufferedImage(
p.getWidth(), p.getHeight(), BufferedImage.TYPE_INT_ARGB);
Graphics2D g = img.createGraphics();
p.paint(g);
g.dispose();
ImageIO.write(img, "png", new File("panel.png"));doLayout() 是最常见的易错点:布局管理器没跑过,所有子控件的位置和尺寸都还是 0,画出来是一张空白图或者所有东西叠在左上角。先 setSize 再 doLayout 最后 paint,顺序不能换。SwingWorker 的 done() 里一定要调 get()——后台线程抛的异常被它包在里面,不调就彻底静默了,你只会看到「点了没反应」。不是所有工具都需要窗口。命令行程序想要补全、历史、进度条,甚至一个全屏的面板,Java 这边也有现成的东西——而且不用带图形运行时,一个 jar 走天下。
JLine:把命令行变得像样(当前 4.3.1)
- 行编辑、Tab 补全、历史记录、语法上色、多行输入——
System.console()和Scanner给不了的那些交互,都是它的活。 - 一个有说服力的例子:JDK 自带的
jshell本身就用 JLine。你在 jshell 里按 Tab 补全、按上下键翻历史,走的就是这套。 - 典型用法是给自己的小工具做一个交互式 shell:读一行、执行、打印,循环。
Lanterna:终端里的窗口系统(当前稳定 3.1.5)
- 它比 JLine 更进一步:把终端当成可寻址的画布,提供窗口、按钮、输入框、对话框、菜单——在终端里做出接近 GUI 的布局。
- 纯 Java 实现,不依赖 ncurses 之类的原生库,这是它相对其它语言 TUI 方案的一个实际优势:打包就是普通 jar,没有平台差异。
- 两层 API:底层
Terminal(自己控制光标与字符)和上层TextGUI(组件与事件)。做工具面板用上层,做游戏或可视化用底层。
诚实的现状
- Lanterna 更新节奏很慢——查到的最新版本 3.2.0-alpha1 仍是预览,稳定版停在 3.1.5。功能够用,但别期待活跃迭代。
- 横向看,Java 的 TUI 生态明显不如 Rust 的 ratatui 或 Python 的 Textual 热闹:控件更少、示例更少、遇到问题能搜到的答案也更少。
- 但对「给自己写个带界面的小工具」这个目标,它够用——而且比 Swing 更容易分发:不需要图形环境,SSH 连过去也能跑。
// JLine:三行拿到带补全和历史的输入
Terminal terminal = TerminalBuilder.builder().build();
LineReader reader = LineReaderBuilder.builder()
.terminal(terminal)
.completer(new StringsCompleter("添加", "删除", "列表", "退出"))
.build();
while (true) {
String line = reader.readLine("记账> "); // Tab 补全、↑↓ 翻历史
if (line.equals("退出")) break;
执行(line);
}
// Lanterna:终端里开一个真窗口
Screen screen = new DefaultTerminalFactory().createScreen();
screen.startScreen();
WindowBasedTextGUI gui = new MultiWindowTextGUI(screen);
BasicWindow win = new BasicWindow("温度换算");
Panel panel = new Panel(new GridLayout(2));
panel.addComponent(new Label("摄氏度"));
panel.addComponent(new TextBox("25"));
win.setComponent(panel);
gui.addWindowAndWait(win); // 阻塞,直到窗口关闭
screen.stopScreen(); // ← 忘了这句终端会留在乱掉的状态reset 才好)。startScreen() 之后一定要用 try/finally 保证 stopScreen() 执行——这条对所有语言的 TUI 库都成立。写完了想发给朋友,第一个问题就来了:对方电脑上没装 Java。这是 Java 桌面程序真正的成本所在,也是选 Swing 而不是 JavaFX 的一个很实际的理由。
两个工具,JDK 自带
jlink:裁出一个只含你用到的模块的精简运行时。Swing 在java.desktop模块里,能被它裁进来。jpackage:把「你的 jar + 一个运行时」打成平台原生的安装包或免安装目录(app-image)——对方双击就能用,完全不需要知道 Java 是什么。
体积到底是多少
| 做法 | 体积 | 说明 |
|---|---|---|
| 整个 JDK | 363 MB | 让对方自己装——基本不现实 |
jlink --add-modules java.base | 44 MB | 最小运行时 |
再加 --compress zip-6 | 31 MB | 压缩后 |
jpackage --type app-image | 121 MB | 默认行为:塞了完整运行时 |
| 喂给它 jlink 过的运行时 | 32 MB | --runtime-image,差了近四倍 |
结论很直接:jpackage 不加 --runtime-image 就是在白白多发一百兆。先 jlink 再 jpackage,两条命令的事。
动手前先知道的几件事
- 不能交叉打包:Windows 上只能产出 Windows 包,macOS 包得在 Mac 上打。想三个平台都发,就得有三台机器(或者 CI)。
- macOS 上分发给别人还要处理签名与公证,否则对方会看到「无法打开,来自身份不明的开发者」。
- Windows 上未签名的可执行文件可能被杀毒软件误报——这不是你的代码有问题,是打包器生成的启动程序的通病。
# ① 先把代码打成普通 jar(注意 --main-class 别漏)
javac -d classes Demo.java
jar --create --file demo.jar --main-class Demo -C classes .
# ② 裁一个只含 Swing 所需模块的运行时
jlink --add-modules java.base,java.desktop \
--compress zip-6 --no-header-files --no-man-pages \
--output rt
# ③ 用这个运行时打包(121 MB → 32 MB)
jpackage --type app-image \
--name 温度换算 \
--input pkg \
--main-jar demo.jar \
--runtime-image rt
# 不确定要哪些模块?让 jdeps 替你算:
jdeps --print-module-deps --ignore-missing-deps demo.jar--input 指的是「这个目录里的所有东西都打进去」,不是「入口 jar 所在的目录」。直接写 --input . 打出了 441 MB——因为把裁好的运行时、源码、构建产物全部装了进去。建一个干净目录,只放该发的 jar 和资源,再指向它。jdeps --print-module-deps 直接给出这个 jar 真正需要的模块名,把结果贴进 jlink --add-modules 即可。漏了模块的症状是运行期 NoClassDefFoundError,不是打包时报错。从这里到熟练:路线图
本页覆盖了 Java 语言、JDK 标准库、并发、JVM 与工程化的主干。这一章给出学习顺序、每个阶段「做出来就说明学会了」的产物,以及往下走的方向。
不要按章号从头顺读。二十二章里真正需要先啃透的只有前半部分,剩下的是「用到时回来查」。下面每个阶段都有一个做出来就说明学会了的产物。
阶段一:把语言写顺(01–08 章)
- 目标:能独立写一个有输入输出、有错误处理的命令行工具。
- 重点:
==与equals的区别(02 章)、字符串不可变(04 章)、类与接口的分工(05、06 章)、record 与密封类型(07 章)、模式匹配(08 章)——后两章是现代 Java 与老 Java 差别最大的地方。 - 里程碑:写一个能整理下载文件夹的工具(20 章有起手代码),带
--dry-run,能处理「目录不存在」「文件被占用」两类失败。
阶段二:标准库进直觉(09–12、17 章)
- 目标:处理数据时不再需要边写边查。
- 重点:集合选型(10 章)、Stream 与
groupingBy(11 章)、异常该在哪接(12 章)、泛型读懂签名就够(09 章)。 - 里程碑:写一个日志分析工具——按小时统计错误、找出最频繁的十种错误、输出一个文本柱状图。全程用 Stream,不写一个
for。
阶段三:并发与真实世界(13、14、19 章)
- 目标:能写并发正确的程序,能和外部系统打交道。
- 重点:数据竞争与可见性(13 章,亲手复现一次)、虚拟线程(14 章)、HTTP 客户端与服务器(19 章)。
- 里程碑:并发抓一百个网页、限流、可取消、正确汇总每个任务的成败。然后把结果通过一个本地 HTTP 服务展示出来。
阶段四:交付与深水区(15、16、18、20 章)
- 目标:能把作品交给别人,能诊断真实问题。
- 重点:打包分发(18 章)、JVM 怎么跑代码(15 章)、怎么正确地量(16 章)。
- 里程碑:把阶段一那个工具用
jpackage打成一个双击就能跑的应用(18 章有完整命令和体积),发给一个没装 Java 的朋友。
往下走的方向
- 做产品:桌面程序(20 章)、小游戏、Minecraft 模组、自己的服务。
- 服务端深入:数据库与 SQL、分布式系统、可观测性。先把 HTTP 那条链路弄明白再上框架(20 章那条 pitfall)。
- 性能工程:JMH 基准测试、JFR 分析、读 JDK 源码(20 章)。
- Android / Kotlin(20 章)。
- 语言本身:跟一遍每半年一版的变更(下一卡)。
// 阶段一的里程碑:一个完整的小工具(20 章有完整版)
void main(String[] args) throws Exception {
if (args.length == 0) {
System.err.println("用法:整理 <目录> [--dry-run]"); // 03 章
System.exit(2);
}
// ... 19 章的 Files、11 章的 Stream、12 章的异常处理
}
// 阶段二的里程碑:全程 Stream,不写一个 for
try (var 行 = Files.lines(日志)) {
行.filter(l -> l.contains("ERROR"))
.collect(Collectors.groupingBy(this::错误类型, Collectors.counting()))
.entrySet().stream()
.sorted(Map.Entry.<String,Long>comparingByValue().reversed())
.limit(10)
.forEach(e -> IO.println("%-40s %s %d".formatted(
e.getKey(), "█".repeat(Math.min(40, e.getValue().intValue())), e.getValue())));
}
// 阶段三的里程碑:并发 + 限流 + 汇总成败
var 限流 = new Semaphore(10);
try (var 池 = Executors.newVirtualThreadPerTaskExecutor()) { // 14 章
var 任务 = 地址们.stream().map(u -> 池.submit(() -> {
限流.acquire();
try { return 抓(u); } finally { 限流.release(); }
})).toList();
int 成功 = 0, 失败 = 0;
for (var f : 任务) {
try { f.get(); 成功++; }
catch (ExecutionException e) { 失败++; 记录(e.getCause()); } // 13 章
}
IO.println("成功 " + 成功 + ",失败 " + 失败);
}
# 阶段四的里程碑:打成能发给别人的东西(18 章)
$ jlink --add-modules $(jdeps --print-module-deps app.jar) --output rt \
--strip-debug --no-header-files --no-man-pages --compress zip-6
$ jpackage --name 我的工具 --input in --main-jar app.jar --runtime-image rtJava 的更新节奏是每半年一个版本,每两年一个 LTS。这个节奏意味着:既不用担心学的东西很快作废(向后兼容极好),也不能觉得「学一次就够了」。
怎么跟上
- 只需要看每个版本的 JEP 列表(openjdk.org/jeps)——每版通常只有十几条,其中和日常写代码相关的往往只有两三条,十分钟能扫完。
- LTS 之间的差别才是重点:如果你的项目在 21,那么 21→25 之间累积的变化(
StructuredTaskScope、Gatherers、紧凑源文件、synchronized不再钉住虚拟线程、AOT 缓存)就是你该关注的清单。 - 预览特性要留意:标了「预览」的东西需要
--enable-preview,API 还会变(14 章有教训)。玩可以,别写进要长期维护的代码。
三个可靠信息源
- 官方文档与 JEP:每个语言特性都有一份 JEP,里面写清了动机、设计取舍、和替代方案。这是最权威也最少见到二手误传的地方。
- JDK 源码:就在你机器上(
lib/src.zip),IDE 里一键跳转。遇到「它到底怎么实现的」直接看,比任何博客准确。 - 你自己的 jshell:本页反复强调的那条——任何断言,粘进
jshell跑一遍就有答案。这是最快也最可靠的信息源。
本页的版本基准与它的保质期
- 本页以 JDK 25 LTS 为基准,正文里的行为与报错原文都在
25.0.1上跑过。 - 绝大多数内容在 21 上同样成立;确实是新版本才有的(紧凑源文件、Gatherers、结构化并发、AOT 缓存、
ScopedValue)在卡里都点了名。 - 会最快过时的是:预览特性的 API 形态、具体的版本号、以及性能数字。不会过时的是:类型系统、对象模型、集合语义、并发的正确性规则——这些从 Java 5 到今天基本没变过,再过十年多半也还是这样。
// 验证任何断言的最快方式(01 章)
$ jshell -q
jshell> "👍中".length()
$1 ==> 3
// 或者一行命令
$ echo 'System.out.println(Integer.MAX_VALUE + 1);' | jshell -q -
// 查一个类有哪些方法,不用开浏览器(17 章)
$ javap java.util.stream.Gatherers
// 确认某个 API 在你的 JDK 上到底有没有
$ jshell -q
jshell> Class.forName("java.util.stream.Gatherers")
$1 ==> class java.util.stream.Gatherers
// 看这个 JDK 是哪个版本、有哪些模块
$ java --list-modules | head
$ java -version
// 本页的验证方法:写个最小复现,跑一遍
// 比如验证 14 章那条"synchronized 不再钉住虚拟线程":
$ java -Djdk.virtualThreadScheduler.parallelism=1 \
-Djdk.virtualThreadScheduler.maxPoolSize=1 验证.java
// 4 个虚拟线程各在 synchronized 里 sleep 300ms:
// 总共 ~300ms → 没被钉住
// 总共 ~1200ms → 被钉住了(JDK 21 上的行为).java 文件,java 它.java 就出结果——不用建项目、不用装构建工具(01 章)。看到任何断言心里犯嘀咕,就当场跑一遍。这比多读三遍解释有用得多,也是本页所有结论的产出方式。