Java 语言与 JDK 完整知识体系交互讲解

全景: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)));       // 小明 满级了!
}
搜出来的 Java 资料,先看年份。这门语言最大的坑不是难,是十几个年份的答案同时排在前面、看起来一样正确。看到这些信号基本可判定资料已老:new Date()Vector/Hashtable、匿名内部类当回调、SimpleDateFormat
Java 从 9 起每半年一版,每两年 9 月那个是 LTS:17、21、25(2025-10)。本页以 JDK 25 为基准。02–06 章铺地基,然后 07、08、11、14 这四章是现代 Java 与你印象里那个 Java 差别最大的地方。

Java 有个别的语言没有的负担:它的名声被使用场景绑架了。很多人对它的全部印象来自「公司里那套系统」——满屏配置、层层抽象。那些是组织的产物,不是语言的属性

讲什么,不讲什么

  • :语言本身(02–09 章)、标准库真正常用的那部分、以及现代 Java 与旧印象差别最大的地方;
  • 不讲某一套企业框架的配置细节——19、20 章会说清「要写 Web 服务有哪些路子」,但不做某个框架的使用手册:框架三年一换,语言不换

怎么用这一页

  • 一边读一边跑:JDK 25 让验证成本几乎归零,存成 X.javajava 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)));
}
别把「Java 很啰嗦」照单全收,也别以为新语法能免掉地基。今天的样板确实少了一大截(var、record、文本块、toList()),但类型系统、对象模型、异常与泛型这些地基一处都没省
如果你是被「学 Java 是为了找工作」推过来的,换个入口会顺畅得多:先挑一个你自己想要的小东西(整理下载文件夹的脚本、查天气的命令行、一个 Minecraft 模组),拿这一页当工具书。

上手:三行跑通第一个程序

这一章不讲语言,只做一件事:把 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 还能编译并附带全套工具(javacjshelljarjlink…)。装 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.JDK
「装了 Java 却编译不了」几乎总是同一个原因:装的是 JRE,或者 PATH 指到了另一份 Java。判据很直接——java -version 通但 javac -version 不通,就是这个问题。
装完不用手动设 JAVA_HOMEjava -version 通了说明 PATH 已经对了,绝大多数场景够用。只有 Maven/Gradle 或某些 IDE 会去读 JAVA_HOME,到那时再设。

你在网上看到的那个 public class Hello { public static void main(String[] args) { … } } 已经不是必需的了。JDK 25 起,最短的可运行 Java 程序是三行,而且可以直接运行源文件,不用先编译。

三层「其实可以省」

你以为必须写的今天的真相起自
javacjava直接 java Hello.java,编译在内存里完成(多文件也会自动一起编译)11 / 22
public class Hello { … }类名可以整个省掉(紧凑源文件)25
public static void main(String[] args)void main() 就够,System.out.println 也可写成 IO.println25

紧凑写法适合脚本、练习、验证想法;一旦要被别人 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               多文件也行,同目录的会一起编译
jshell 里跑得通的代码,直接粘进 .java 文件不一定能编译:它替你 import 了一堆包,也不强制你处理受检异常(12 章)。把它当草稿纸,不要当编译器。
本页每一条你觉得可疑的断言都可以粘进 jshell 当场验。被自己亲手跑出来的结果推翻一次,比读十遍解释记得牢。

学一门语言,读懂它的抱怨比记住语法更省时间。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  X
Windows 中文环境下控制台输出中文可能是乱码,但这跟你的代码无关:JDK 25 里 file.encoding 已固定为 UTF-8,而 stdout.encoding 跟随系统。先确认是终端编码问题再去改代码。
中文系统上的 javac 会输出中文报错,搜索时把它切回英文更容易找到答案:编译加 -J-Duser.language=en,运行加 -Duser.language=en

语言地基:类型、变量与运算

Java 的一切都从「这是基本类型还是引用类型」开始:赋值时复制什么、相等怎么算、能不能是 null,全由这个二分决定。这一章把它讲透,顺带把数字运算里那些会静默给出错误答案的地方一次挖开。

Java 的类型只分两类,这个二分决定了赋值、传参、比较的全部行为基本类型(8 个,变量里装的就是值本身)和引用类型(其余全部,变量里装的是「指向对象的引用」)。

八个基本类型,记住范围就够

类型大小范围默认值什么时候用
int32 位约 ±21 亿0整数默认用它
long64 位约 ±9.2×10¹⁸0L时间戳、大计数(字面量要加 L
double64 位浮点0.0小数默认用它
float32 位浮点0.0f省内存的图形/科学计算(字面量要加 f
boolean未规定true/falsefalse条件(不能和 0/1 互转
char16 位一个 UTF-16 码元'\u0000'单个字符(04 章有坑)
byte8 位−128 ~ 1270二进制数据(有符号
short16 位约 ±3.2 万0基本用不上
  • 日常只需要 intlongdoublebooleanchar,另外三个只在特定场合出现。
  • 基本类型不能是 null——这既是它们的优点(不会 NPE),也是它们的限制(无法表达「没有值」)。

赋值时到底复制了什么

  • 基本类型:复制值。int b = a; 之后改 b 不影响 a
  • 引用类型:复制引用(那个「地址」),对象只有一个。List<String> b = a; 之后 b.add(...)a 也看得见——因为它们指着同一个对象。
  • Java 传参永远是「按值传」:传基本类型时复制值,传引用时复制引用。所以方法里 x = 新对象 影响不到外面,但 x.改一改() 影响得到。「Java 是传引用」这个说法是错的,正确说法是「传的是引用的副本」。

包装类型:把基本类型装进对象

  • 每个基本类型都有对应的包装类:intIntegerdoubleDoublebooleanBoolean
  • 为什么需要它:泛型和集合只能装对象,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——三元表达式的两个分支类型不同(intInteger)时,整个表达式被提升为 int,于是没被选中的那一支也可能被拆箱规避:让三元表达式的两支类型一致,或者别把可空的包装类型丢进算术表达式。
什么时候用 Integer 而不是 int:只有两种情况。① 要放进集合或泛型里(List<Integer>);② 需要表达「这个值可能不存在」(Integer 分数 = null 表示还没打分)。其余一律用 int——它更快、更省内存、不会 NPE。一百万个 Integer 约占 20 MB,一百万个 int 约 4 MB,差 5 倍;五百万次累加用 long 和用 Long 差三倍多(具体倍数看机器,自己跑一下)。

Java 是静态类型语言:每个变量都有确定的类型,编译期就定死。var(Java 10 起)只是让编译器替你把类型写出来,不是变成动态类型——变量类型依然固定,只是你不用打两遍。

var 用不用:一条实用规则

  • 右边已经把类型说清楚了,就用 varvar 列表 = 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; 推出的是 intvar 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 != a7/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/charintlongfloatdouble,往「更大」的那边靠。
// 溢出:静默 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==btrue−128~127 有缓存,是同一个对象
Integer c=128,d=128; c==dfalse超出缓存范围,各造一个
c.equals(d)true比的是值
"hi" == "hi"true字面量在常量池里共享
"hi" == new String("hi")falsenew 强制造新对象
"hi" == ("h" + "i")true编译期就拼好了,还是常量
String p="h"; "hi" == (p+"i")false运行期拼接,新对象

Integer== 比较,小数字对、大数字错」就是这么来的——它是所有静默 bug 里最恶劣的一种:你的测试数据用了小数字,一切正常;上线后数字变大,逻辑突然失效。

规则很简单

  • 基本类型(intdoublechar…):用 ==
  • 其它一切(StringInteger、你自己的类):用 equals
  • 唯二的例外:枚举用 == 比(07 章,每个枚举常量全局唯一,== 更快也更安全);判断「是不是同一个对象」时才故意用 ==
  • 比较可能为 null 的东西:用 Objects.equals(a, b),两边都为 null 算相等,不会 NPE。或者把常量写在前面:"admin".equals(输入)

自己的类要能比较,就得覆写 equals

  • 不覆写 equals 时,默认行为是 ==——即使两个对象内容完全一样也不相等。
  • 覆写 equals必须同时覆写 hashCode,否则放进 HashSet/HashMap 会出鬼。一个只覆写了 equals 的类,两个「相等」的对象放进 HashSetsize()2;而同样内容的 record 只有 1
  • 最省事的办法:用 record(07 章)——它自动生成正确的 equalshashCodetoString,一行都不用写。
// 规则:基本类型 ==,其余 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
  • parseIntvalueOf 的区别:前者返回 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(...) / Stream11 章。做转换和过滤时比循环清楚

几个真会踩的点

  • 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()) { }   // 安全
在增强 for 循环里改集合,行为分两种,而且更危险的是不报错的那种。List<String> l = new ArrayList<>(List.of("a","b","c")); 遍历中删掉 "b"(倒数第二个),不抛异常,静默少走一轮结束;换成删 "a" 就抛 ConcurrentModificationException「有时报错有时不报」比「一定报错」难查得多——正确做法是用 list.removeIf(条件),或者用显式的 Iterator 并调它的 remove()
循环里做「筛选 + 转换 + 汇总」时,Stream(11 章)通常更清楚。判据不是「哪个快」(两者速度接近,一千万元素求和 forstream 只差个位数毫秒),而是「哪个把意图写在了明面上」。纯遍历做副作用(打印、写文件、改状态)用普通 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]
方法里给参数重新赋值,改不到调用方——但改参数指向的对象内容能改到。这是 02 章「传的是引用的副本」的直接后果,也是 Java 新手最常见的误解之一。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,它是个「定长视图」:在它上面 addUnsupportedOperationException,但 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 才有资格决定退出码。
打包分发见 18 章(双击即可运行的形态)。在那之前,最简单的分享方式是把 .java 文件本身发过去——对方装了 JDK 就能 java 你的文件.java,不需要任何构建步骤。这是 JDK 11 之后 Java 才有的能力,很多人还不知道。

字符串与文本

字符串是出现频率最高的类型,也是坑最密的地方之一:它不可变、它有常量池、它按 UTF-16 存储所以一个 emoji 长度是 2。这一章把这些讲透,顺便把拼接性能这件被误传了十几年的事一遍。

Java 的 String 一旦创建就不能修改——所有看起来「改字符串」的方法(toUpperCasereplacetrim…)都是返回一个新字符串,原来那个纹丝不动。这一条解释了字符串的几乎所有行为。

最常见的新手 bug

String s = "hello";
s.toUpperCase();              // 返回值被丢掉了,s 还是 "hello"
s = s.toUpperCase();          // ✓ 必须接住返回值

为什么要设计成不可变

  • 可以安全共享:字符串字面量可以放进「常量池」被所有地方共用,不怕谁改坏了。
  • 线程安全:多个线程读同一个字符串不需要加锁(13 章)。
  • 哈希可以缓存hashCode 算一次就存在字段里。一百万字符的字符串,第一次算 hashCode 和第二次差了一个数量级——第二次直接读缓存。这让 StringHashMap 的键特别高效。
  • 可以放心当参数传:不用担心被调用的方法把你的字符串改了。

常量池与 == 的那些怪事

  • 源码里的字符串字面量会被放进常量池,内容相同的字面量是同一个对象——这就是 "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()
不可变不等于「引用它的变量不能变」,也不等于「里面的东西不会变」。前者是 02 章讲过的 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()按行切成 StreamJava 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);
splitreplaceAllmatches 的参数都是正则表达式,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);
循环里拼 SQL、拼 JSON、拼 HTML,除了慢,还有更严重的问题:注入。把用户输入直接拼进查询语句或页面,是安全漏洞的第一大来源。正确做法是用参数化的接口——数据库用 PreparedStatement? 占位符、JSON 用序列化库、HTML 用模板引擎并开启转义。「先拼字符串再想办法转义」这条路走不通,因为你永远想不全所有需要转义的情况。
StringBufferStringBuilder 的线程安全版本,但你几乎永远不需要它。它每个方法都加了锁,而字符串拼接这种局部操作本来就不会跨线程共享。看到老代码用 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
    右边""";
文本块里的内容仍然会处理转义序列——它不是「原样字符串」(Java 没有 raw string)。所以里面写 \n 还是换行、写 \\ 才得到一个反斜杠。写正则或 Windows 路径时这一点会咬人"""C:\新建""" 里的 \n 会被当成换行符(而且 \新 这种非法转义会直接编译错)。路径统一用正斜杠(Java 在 Windows 上也接受),正则里的反斜杠照样要双写。
文本块最被低估的用途是「把示例数据写进代码」。写小工具时经常需要一份测试输入——以前得单独建个文件或者写一长串 \n,现在直接一个文本块贴进去,改起来所见即所得。写单元测试时尤其省事:期望的输出直接照抄进文本块,比拼字符串直观十倍。

Java 的 String 内部按 UTF-16 存储,char一个 16 位的码元,不是「一个字符」。绝大多数常用汉字正好占一个码元,所以这件事平时不显眼——直到你的程序遇到 emoji 或生僻字。

"👍中"

表达式结果含义
"👍中".length()3UTF-16 码元数(👍 占 2 个)
codePointCount(0, 3)2真正的字符数(码位数)
charAt(0)55357👍 的前半个码元,单独没有意义
substring(0, 1)半个 emoji切在了码元中间,得到无效字符
getBytes(UTF_8).length7👍 占 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.encodingGBK。这就是 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
这一卡的内容平时不会咬你,但一旦咬就很痛。典型场景:给昵称做长度限制(用户填了 emoji,数据库报「数据太长」)、给文本做省略号截断(切出半个字符显示成方块)、按字符数做进度条(emoji 让百分比超过 100)。只要你的程序会接受用户输入的文本,就值得把这一卡的三个「长度」记牢。

类与对象

类是把「数据 + 操作数据的方法」捆在一起的单位。这一章讲清楚一个对象从 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]
别写「什么都不做的 getter/setter」。老教程会教你给每个私有字段配一对 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.maxInteger.parseIntList.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", "你好");   // 需要多行逻辑才能初始化时用它
    }
}
静态字段的初始化顺序会咬人:它按源码里的先后顺序执行。如果 A 的静态初始化里用到了 B,而 B 的静态初始化又用到 A,就会读到还没赋值的 null0——不报错,只是值不对。更隐蔽的是:静态代码在类被首次主动使用时才执行(15 章),所以「什么时候跑」取决于运行路径,同一份代码在不同入口下表现可能不同。规避办法很简单:静态字段只放不可变常量,需要复杂初始化的东西改成方法里按需创建。
判断一个方法该不该是 static,只看一件事:方法体里有没有用到实例字段。没用到就应该是 static——这样调用方一眼就知道它不依赖对象状态、可以放心复用、也更好测试。IDE 通常会提示「这个方法可以是 static」,那个提示是对的。

一个对象诞生时,代码执行的顺序是固定的,但它和源码从上到下的顺序不一样。这一卡用一段输出把顺序钉死。

父类 Base + 子类 Subnew 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」,而且只在被继承时出现。
  • 规避:构造器里只调用 privatestaticfinal 方法——这三类不可能被覆写。要做的初始化如果确实需要子类参与,改成显式的两步(构造 + 一个 初始化() 方法)。
// 上面那段输出对应的代码
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」时你会第一时间想到它。

不可变对象是造出来之后状态就再也不会变的对象(StringLocalDate、record 都是)。它是 Java 里性价比最高的一个设计习惯:写的时候多花一分钟,省掉后面一整类 bug。

为什么值得

  • 可以随便传,不怕被改:不需要在每个方法边界上想「他会不会改我的对象」。
  • 天生线程安全:没有写操作就没有竞争,13 章一整章的问题绕过去了一大半。
  • 可以当 Map 的键、放进 Set:可变对象当键是个著名的雷(改了字段之后就再也找不到它了)。
  • 更好推理:一个变量指向的对象内容永远不变,读代码时不用追踪「谁在什么时候改了它」。

怎么写一个真正不可变的类

  • 所有字段 private final 不提供任何 setter; 类本身 final(防止子类加可变状态);④ 关键:可变成员必须拷贝进来、拷贝出去
  • 第 ④ 条是最容易漏的。一个 record Box(List<String> items),构造时传进去一个 ArrayList,构造完之后外部通过原来那个引用 add 一个元素——box.items() 里真的多了一个。record 只保证「字段引用不变」,不保证「引用指向的对象不变」。
  • 修法:在紧凑构造器里 items = List.copyOf(items);。同一个实验,加了这一行之后外部的修改就影响不到了。

需要「改一改」的时候怎么办

返回一个新对象,而不是修改自己——StringLocalDate 都是这么做的:日期.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()) 返回的才是可变的,这个差别验证过)。
默认写不可变,需要可变时再说。这条习惯在你写多线程代码(13、14 章)时会以十倍的形式回报你——大部分并发 bug 的根源都是「多个线程看同一个可变对象」,而不可变对象根本没有这个问题。标准库自己就是这么演进的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 做不到
});
非静态内部类和匿名类造成的内存泄漏,是 Android 和 Swing 里的经典事故。机制是:一个匿名的监听器/回调被注册到某个长命的对象上(事件总线、静态注册表、后台线程),而它隐式持有创建它的那个界面对象,于是界面关掉了也回收不掉,越积越多。两个信号:① 在长期存活的地方注册回调;② 那个回调是匿名类或非静态内部类。解法:用 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 能当场抓住这个错误,这就是为什么它值得每次都写。
抽象类 vs 普通父类的选择很简单:如果「父类本身」不该被 new,就写 abstractnew 图形() 没有意义(没有哪个具体形状叫「图形」),那就把它标成抽象的,编译器会拦住任何试图实例化它的代码,同时强制子类实现 面积()这比在文档里写「请不要直接使用这个类」可靠得多。

接口描述「能做什么」,不关心「是什么」。一个类可以实现任意多个接口(但只能继承一个类),这是接口比继承灵活的根本原因。

接口里能放什么(今天的接口比你想的能装)

  • 抽象方法:默认就是 public abstract,不用写。
  • 默认方法default,Java 8 起):带实现的方法,实现类可以不管它。它的存在是为了「给已经发布的接口加方法而不破坏所有实现类」——List.forEachComparator.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 章)或一个不可实例化的类里,用 配置.最大玩家数 这种带前缀的方式访问——多打几个字,换来的是「这个常量从哪来的」一目了然。
ComparableComparator 的分工值得一次记牢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>
抽象类的「唯一继承名额」是真的会用完的。一旦某个类继承了你的抽象基类,它就不能再继承任何别的类了——而这个限制会一路传染给所有子类。库的作者尤其要小心:把公开的扩展点做成抽象类,等于要求所有使用者放弃他们的继承名额。这就是标准库里几乎所有扩展点都是接口的原因RunnableComparatorAutoCloseable…),抽象类只作为「可选的省事骨架」出现。
「面向接口编程」这句话的实际操作只有一条:变量、参数、返回类型写接口,具体类只出现在 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));
组合的代价是要写转发代码,于是很多人退回继承。但真正的判断标准不是「少写几行」,而是「我需不需要父类的全部公开方法都出现在我的 API 里」。继承 ArrayList 意味着你的类多了三十多个你没写过、也没测过、还可能绕过你逻辑的方法。如果转发代码写起来很烦,那通常说明你需要的接口面太宽了——这本身就是个值得重新想想设计的信号。
「依赖注入」听起来像个框架概念,其实内核就是上面那三行——把依赖作为构造器参数传进来,而不是在类内部 new不需要任何库就能用,而且立刻带来两个好处:测试时可以传个假的进去,换实现时不用改这个类。框架只是在你有几百个对象要装配时才开始有价值;写自己的项目时手动传就够了。

所有类都隐式继承 Object,它带来几个方法。其中 equalshashCode 的关系是 Java 里最重要的一条「不知道就会错」的契约。

四个你会用到的

  • toString():默认返回 类名@哈希,没有信息量。调试时会救命,几乎每个类都该覆写(record 自动有)。
  • equals(Object):默认是 ==(比身份)。要按内容比就得覆写。
  • hashCode():默认基于对象地址。覆写 equals 就必须一起覆写它。
  • getClass():拿到运行期的真实类型,调试和反射时用。

契约:相等的对象必须有相同的哈希

  • HashMap/HashSet 的查找是两步:先用 hashCode 定位桶,再用 equals 在桶里比。哈希不一样就根本不会走到第二步。
  • 一个只覆写了 equals 的类,两个「相等」的对象放进 HashSetsize()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 的原因。
IDE 生成 equals/hashCode 只要两秒(IntelliJ 里 Alt+Insert),但更好的选择是先问一句「这个类能不能是 record」。如果它只是一包不变的数据——绝大多数值类型都是——那 record 不仅省掉这两个方法,还顺带给了 toString、解构、以及模式匹配的支持(07、08 章)。手写 equals 应该是例外,不是常态。

record、enum 与 sealed:现代数据建模

这一章是现代 Java 和你印象里那个 Java 差别最大的地方。三个关键字合起来,让「一包数据」「一组固定的值」「一共就这几种情况」这三件最常见的事各自缩短到一行。

record(Java 16 起正式)是「不可变的数据载体」的语言级支持。一行声明,编译器替你生成构造器、访问器、equalshashCodetoString——而且保证它们互相一致(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——不能继承、不能改。
  • 解构支持:可以在 switchinstanceof 里拆开(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 的不可变是「浅」的:它保证字段引用不变,不保证引用指向的对象不变。record Box(List<String> items) {},传一个 ArrayList 进去,构造完之后从外部往原列表 add——box.items() 里真的多了一个元素。修法是在紧凑构造器里 items = List.copyOf(items);(同一实验加上这行后外部修改就无效了)。凡是分量里有集合、数组、或其它可变对象,都要在紧凑构造器里拷一份;数组尤其要注意,record 数据(int[] 值)equals 比的还是数组的身份,几乎肯定不是你想要的。
record 让「多返回值」这个老问题彻底不是问题了。以前要返回两个值,得选:造一个只用一次的类(啰嗦)、用 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 右 -> "→";
};                                    // 四个都写了,不需要 default
ordinal() 和枚举常量的顺序不要持久化。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.0
密封类型和「开放扩展」是互斥的,选之前想清楚。密封的收益(编译期穷尽检查)来自「我知道全部子类型」这个前提;一旦你希望第三方也能加一种实现,密封就必须放弃(或者用 non-sealed 开一个口子,但那样穷尽检查就没了)。判据是「这组类型会不会由我之外的人扩展」:协议消息、AST 节点、状态机状态——不会,用密封;插件接口、策略接口——会,用普通接口。选错了不是灾难,但改起来是破坏性的 API 变更。
「密封接口 + record + switch」这三件套是现代 Java 最值得学的组合,没有之一。它把一类以前只能靠继承 + 虚方法(或者一长串 instanceof)解决的问题,变成了编译器能检查的形式。判断适用场景只要问一句:这些情况是不是「一共就这几种,而且是我说了算」?是就用密封;如果希望别人也能扩展(比如插件),那就该用普通接口。

前三卡是零件,这一卡把它们拼起来。用一个具体问题演示:怎么把「一堆字符串和 if-else」重写成「类型 + 模式匹配」。

问题:一个文字冒险游戏的指令系统

玩家输入 go northtake 钥匙lookquit朴素写法是一串 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 里的一个枚举字段,不是三个 record。举例:移动北/移动南/移动东/移动西 四个 record 是错的建模,移动(方向) 才对。判据:如果两个类型的 switch 分支写出来一模一样,它们就不该是两个类型。
「现在你来」:把这个骨架改成你自己的东西。① 加一个 使用(String 物品, String 目标) 指令,看看编译器怎么指出所有要改的地方;② 把 房间 也建模成 record(record 房间(String 描述, Map<方向, 房间> 出口)),主循环就能真的走起来;③ 把游戏状态存成文本(06 章的 可存档 接口),下次接着玩。这个骨架不到一百行,但它是一个完整的游戏引擎。

你在网上和旧代码里会大量遇到前一代写法。这一卡把三组对照放在一起,既是为了读懂旧代码,也是为了知道该怎么改。

① 数据类:从 60 行到 1 行

旧写法(Java 8 时代)今天
私有字段 + 全套 getter/setterrecord 坐标(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);
    };
}
改造旧代码时不要一口气全换,尤其别动「看起来像数据类但其实有人在改它」的类。把一个有 setter 的类换成 record,编译器会揪出所有调用 setter 的地方——但那些地方可能真的依赖可变性(比如框架反序列化时先无参构造再逐个 set)。安全的迁移顺序:① 先把新写的代码用新写法;② 改造那些确实从不修改的类;③ 涉及框架反序列化的最后动,并且先确认框架支持 record(现代的 Jackson 等已经支持,老版本不一定)。
Lombok 是个很流行的注解库,它的 @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 时它一定有值。如果发现自己在跟作用域较劲,通常说明该换成「提前返回」的写法了。
模式里的类型可以写成 varcase 矩形(var 宽, var 高)分量类型明显时(数字、字符串)用 var 更清爽;类型本身是信息时(比如区分 intdouble)就写出来。另外解构时可以给分量起新名字——坐标(int 横, int 纵) 完全合法,名字由你定,不必和 record 声明时一致。

Java 21 起,switchcase 里可以写类型模式而不只是常量。配合密封类型(07 章),它成了「按类型分发」这件事的标准写法。

四个能力,全部可用

  • 按类型分支case 圆 c -> ...
  • 解构 recordcase 圆(double r) -> ... 直接拿到分量;
  • when 守卫case 圆 c when c.r() > 10 -> "大圆" —— 顺序很重要,带守卫的要写在前面(小圆走到了没有守卫的那一支,大圆走到了带守卫的那一支);
  • 穷尽性检查:类型是密封的且覆盖了全部子类型时,不用写 default;漏了会编译错。

null 的处理变了,这是最容易踩的一处

  • 老式 switch 遇到 null 抛 NPEswitch(某个为 null 的 String)Cannot invoke "String.hashCode()")。
  • 模式 switch 也一样默认抛 NPEswitch(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 一致(都抛 NPE),但很多人以为「新语法更安全」。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,但那少数几个会让你调试很久。
箭头 switch 的 default 分支可以直接 throwdefault -> throw new IllegalArgumentException(...) 是合法的(throw 不产生值,但它也不会正常结束),这比返回一个假值(-1null"")好得多——错误在发生的地方就暴露,而不是在三层调用之外变成一个莫名其妙的空指针。

前三卡是语法,这一卡是它真正改变代码形态的地方。三个例子,都是模式匹配出现之前写起来很难看的场景。

① 处理嵌套的动态结构

解析 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 的字段
泛型类型在 switch 模式里有限制:不能匹配被擦除掉的类型参数。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};                    // 大量数字时用数组
泛型不支持基本类型,这是 Java 相比 C# 的一个真实短板。List<Integer> 里的每个数字都是一个堆上对象,int[] 多占约 5 倍内存,遍历时还要拆箱。数据量大且是纯数字时,用数组或 IntStreamMap<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 是两回事(重名只是习惯)。这条规则每个初学泛型的人都会撞一次。
上面那个 LRU 缓存是标准库里一个很实用的冷知识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> 没有任何关系——即使 IntegerNumber 的子类。这个「不变性」是类型安全的必然结果,但它让「写一个能处理各种数字列表的方法」变得不可能。通配符就是来补这个口子的。

为什么不能协变(一分钟理解)

假设 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) 更简单)。
不确定用哪个时,先按「能不能通过编译」试——通配符的规则设计得很自洽,编译器的报错通常直接指向正确答案。另一个实用捷径:看看标准库里同类的方法怎么声明的StreamCollectionsComparator 的签名是 PECS 的最佳教材,照着抄基本不会错。

前四卡讲的是机制。这一卡讲实践:你自己写代码时,泛型该出现在哪儿、不该出现在哪儿,以及怎么读懂库里那些冗长的签名。

读懂复杂签名的三步法

Streammap 举例:<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) 进去加注解时把范围缩到最小(加在具体那一行的局部变量上,而不是整个方法或类上),并写一行注释说明理由。范围一大,就会顺手屏蔽掉后来新增的、真正有问题的警告。
IDE 是学泛型最好的老师。写一个泛型调用,把鼠标停在方法上看推断出来的实际类型;写错时看编译器给的信息——Java 的泛型报错虽然长,但通常把「期望什么、实际是什么」都写出来了。真卡住时,一个可靠的调试手段是把中间结果显式声明成变量(不用 var),编译器会告诉你它推出来的到底是什么类型。

集合框架

标准库里用得最多的一块。这一章讲清 List/Set/Map/Queue 各自解决什么问题、同一族里怎么选实现,以及那些「用错了不报错、只是慢一千倍或答案不对」的地方。

集合框架的结构比看起来简单:四个接口族 + 每族两三个常用实现。先按「你要解决什么问题」选族,再按性能特点选实现。

按问题选族

你要常用实现
有序的一串、允许重复、按下标访问ListArrayList(默认)
去重、只关心「在不在里面」SetHashSet(默认)、LinkedHashSet(保插入序)、TreeSet(排序)
键 → 值的映射MapHashMap(默认)、LinkedHashMapTreeMap
两头进出(队列 / 栈)DequeArrayDeque
按优先级取出最小的QueuePriorityQueue(堆)

90% 的情况下答案是 ArrayListHashMapHashSet先用它们,有明确理由再换。

三个「验证过的」选型判据

  • 查找用 Set/Map,别用 List。20 万元素里做 2000 次 containsHashSet 约 0.15 ms,ArrayList 约 7.4 ms(差约 50 倍,而且这个差距随规模线性拉大)。
  • 随机访问用 ArrayList,两头频繁增删用 ArrayDeque20 万元素每 100 个取一次,ArrayList 约 0.1 ms、LinkedList 约 131 ms(一千倍);反过来在头部插两万次,ArrayList 约 185 ms、LinkedList 约 6 ms。
  • 需要排序用 TreeMap/TreeSet,不需要就别用: 10 万次查找 HashMap 约 5 ms、TreeMap 约 10 ms(约 2 倍,因为一个是常数时间一个是对数时间)。

命名与遗留

  • 看到 VectorHashtableStackEnumeration 就知道资料很老了:它们是 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.getArrayList 慢一千倍)。头部插入用 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);                     ✗ NullPointerException
HashMap 的迭代顺序不保证,但它在一次运行内是稳定的——这会让人误以为可以依赖它。同样的四个键,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(...)直接在 forEachput/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 > bb > 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.ofMap.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 → Rmap
Predicate<T>T → booleanfilterremoveIf
Consumer<T>T → voidforEach
Supplier<T>() → T延迟求值、orElseGet
BiFunction<T,U,R>(T,U) → Rmerge
UnaryOperator<T>T → TreplaceAll

另外还有一整套基本类型特化版本IntPredicateToIntFunctionIntSupplier…),它们的存在是为了避免装箱——泛型不能用基本类型(09 章)留下的疤。

捕获规则:只能捕获「实际上的 final」

  • lambda 里用到的局部变量,必须是 final 或赋值后再没被改过。改了就编译错:local variables referenced from a lambda expression must be final or effectively final报错原文)。
  • 为什么:lambda 可能在方法返回之后才执行(放进集合、传给另一个线程),那时局部变量早就没了——所以它捕获的是值的副本,允许修改会让人误以为改的是外面那个。
  • 绕法:用数组 int[] 计数 = {0};、用 AtomicInteger、或者更好——换个写法(用 Streamcount()/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;
lambda 里不能抛受检异常,这是它最繁琐的限制。列表.forEach(p -> Files.writeString(p, "x")); 编译不过——Consumer.accept 没有声明 throws IOException(12 章)。三条出路:① 在 lambda 内部 try/catch 并包成运行期异常(最常见);② 改用普通的 for 循环(往往是最清楚的选择);③ 自己定义一个允许抛异常的函数式接口再做转换(样板多,除非重复出现很多次否则不值)。不要因为想用 Stream 就把异常吞掉。
lambda 里的 this 指外围类的实例,匿名类里的 this 指匿名对象自己(05 章验证过)。这个差别在写事件回调时偶尔关键——lambda 的行为通常才是你想要的(能直接访问外围类的字段和方法),这也是它比匿名类更顺手的原因之一。

当 lambda 的全部内容就是「调用一个已有的方法」时,可以用 :: 写得更短。它不是新功能,只是语法糖,但用对了确实更好读。

四种形式

形式写法等价的 lambda
静态方法Integer::parseInts -> Integer.parseInt(s)
特定对象的方法System.out::printlnx -> System.out.println(x)
任意对象的方法String::toUpperCases -> 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,惰性):filtermapflatMapsorteddistinctlimitskiptakeWhiledropWhilepeek
  • 终端(触发执行):toListcollectforEachcountreduceanyMatchfindFirstmin/maxsum

一条流只能消费一次

同一个 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 closed
peek 是用来调试的,别拿它做正事。它的文档明确说「主要用于调试」,而证明了原因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 好拆,LinkedListFiles.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;逐个处理并产生副作用 用循环。两者混用完全正常,不必强求一个项目里只有一种风格。

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));
Gatherers 是 JDK 24 才正式的(22、23 上是预览特性),写进要在旧 JDK 上跑的代码之前先确认版本。在 JDK 21 LTS 上没有这个 API——如果你的目标是 21,「分批」仍然得手写(或者用第三方库)。本页以 25 为基准,但这一卡的内容是全页少数几个「21 上没有」的地方之一,00 章那条「绝大多数内容在 21 上同样成立」的说明在这里不适用。
在 Gatherers 出现之前,「每 N 个一批」的标准做法是 IntStream.range(0, (n+999)/1000).mapToObj(i -> list.subList(...)) ——能用但要求数据已经全在内存里。windowFixed 的优势是惰性:配合 Files.lines 可以流式地分批处理一个几个 GB 的文件,内存里始终只有一批。这是 Gatherers 最实用的一个用途。

异常、资源与 Optional

程序总会遇到「不该发生的事发生了」。Java 用异常表达它,用 try-with-resources 保证清理,用 Optional 表达「可能没有值」。这一章讲清三者各自的位置,以及那条最有争议的规则——受检异常。

Java 的异常分三支,区别不在「有多严重」,而在「编译器管不管你」

三支

类型编译器强制处理吗典型你该怎么办
ErrorStackOverflowErrorOutOfMemoryError不要捕获——JVM 已经不正常了
受检异常
Exception 的非运行时子类)
IOExceptionSQLExceptionInterruptedException必须 catch 或在签名上 throws
非受检异常
RuntimeException 及其子类)
NullPointerExceptionIllegalArgumentExceptionIllegalStateException通常是 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 3Duplicate 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> 让调用方要判两次。

orElseorElseGet 的关键差别

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 3Cannot 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 自动就有;普通类不覆写就是 玩家@5fd9b663
e.printStackTrace() 打到的是标准错误流,在很多环境里会和正常输出混在一起或者干脆看不到。它适合本地调试,但不该出现在最终的程序里——长期运行的程序要么用日志,要么至少统一走一个错误处理函数。另一个更隐蔽的问题printStackTrace() 会打印但不会终止,如果它出现在 catch 块里而后面的代码继续执行,程序就会带着已经损坏的状态跑下去。打印之后要么恢复、要么退出,别装作没事发生。
IDE 的调试器比 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(() -> 干活());
并发 bug 在小规模测试下几乎总是「通过」的。上面那个计数实验,如果每个线程只加 100 次,很可能次次都得到正确的 400——竞争窗口太小,恰好没撞上。这就是为什么并发问题总是「在我机器上好好的,一上线就出错」:真实负载下线程更多、跑得更久、CPU 核更多,撞上的概率接近 1。结论:并发正确性不能靠测试来保证,只能靠设计(不共享 / 不可变 / 正确同步)。
写并发代码时,最先该问的不是「加什么锁」,而是「这个东西真的需要共享吗」。大多数并发 bug 的根源是不必要的共享:一个本可以是局部变量的东西被写成了字段、一个本可以各算各的任务被写成了往同一个集合里塞。「每个线程算自己的、最后合并」这个模式能消灭掉九成的并发问题,而且通常还更快(没有锁竞争)。

除了「同时改」这种竞争,还有一类更反直觉的问题:一个线程写了变量,另一个线程永远读不到新值。

一个卡死的循环

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 就死循环」的实验,在某些机器和 JDK 版本上可能复现——JIT 是否做这个优化取决于很多因素。这恰恰是最危险的地方:你的代码可能在开发机上一直正常,换个环境(更多核、不同 JIT 决策、加了负载)突然就卡死。不要因为「我试了没问题」就认为不需要 volatile;判据是「有没有跨线程读写同一个变量」,不是「测出来会不会错」。
停止一个线程要用「中断」而不是标志位,因为中断能唤醒阻塞中的线程。标志位只在线程正在跑时才被检查到;如果它卡在 sleepwaittake() 或阻塞式 IO 上,标志位改了也没用,得等它自己醒过来。interrupt() 会让这些阻塞方法立刻抛 InterruptedExceptionJava 没有安全的「强杀线程」——Thread.stop() 早就被移除了,因为它会让对象停在不一致的中间状态。

synchronized 是 Java 内置的锁:同一时刻只有一个线程能进入被同一把锁保护的代码块,进出时还自带内存可见性保证(上一卡的 happens-before)。

锁的是对象,不是代码

  • synchronized (某个对象) { ... } —— 锁的是那个对象。两段用同一个对象加锁的代码互斥;用不同对象的互不影响。
  • synchronized 修饰实例方法 = 锁 this;修饰静态方法 = 锁那个类的 Class 对象。
  • this 当锁是个隐患:外部代码也能 synchronized (你的对象),可能造成你没法控制的阻塞甚至死锁。推荐用一个私有的 final Object 锁 = new Object();

锁的粒度

  • 锁住的代码越少越好:只保护真正共享的那几行。把整个方法(包括 IO、日志、耗时计算)都锁住,会让并发退化成串行。
  • 但也不能太碎:如果两个操作必须原子地一起完成(先查再改),就必须在同一个锁块里,否则中间会被别人插入。

ReentrantLock:需要更多控制时

能力synchronizedReentrantLock
基本互斥
自动释放✓(出块就放)需要 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

并发集合

要什么
线程安全的 MapConcurrentHashMap
线程安全的 List(读远多于写)CopyOnWriteArrayList
线程间传数据的队列LinkedBlockingQueue / ArrayBlockingQueue
无锁高吞吐队列ConcurrentLinkedQueue
线程安全的 SetConcurrentHashMap.newKeySet()

Collections.synchronizedMap(...) 是老办法:它给每个方法加同一把锁,并发度很差,而且复合操作仍然不安全(「先查再放」中间还是会被插入)。ConcurrentHashMap

ConcurrentHashMap 的原子复合操作

它的价值不只是「每个方法线程安全」,更在于提供了原子的复合操作putIfAbsentcomputeIfAbsentmergecompute——这些在一次调用里完成「查 + 改」,中间不会被插入。这正是普通 HashMap 加锁也很难做对的部分。

阻塞队列:生产者-消费者的标准答案

BlockingQueueput 在满时阻塞、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() 阻塞等待;任务里抛的异常会被包成 ExecutionExceptiongetCause() 拿真正的异常
  • 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 兜住并记录——这是最可靠的做法。
线程池不关闭会让程序不退出。默认的线程池线程是非守护线程,只要它们还活着 JVM 就不会结束——症状是「程序跑完了但命令行不返回」。用 try-with-resources 就自动解决了;老代码里则要检查每条路径上是否都调了 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 (银行) { 从.(金额); 到.(金额); }   // 粗但正确
持有锁的时候调用「外部代码」是死锁的头号来源。所谓外部代码指的是:回调、监听器、覆写过的方法、传进来的 lambda——你不知道它们内部会去拿什么锁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 而不是限制线程数——把「限流」这个语义和「线程管理」分开,两者本来就是两回事。
虚拟线程最大的价值不是性能,是把写法退回到最好懂的那种在它之前,要处理大量并发 IO 只能用回调、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 Pinned
虚拟线程改变了「线程很贵」这个前提,于是一批老经验跟着失效了。除了「避开 synchronized」,还有:「用线程池复用线程」(虚拟线程不该池化)、「ThreadLocal 要小心内存」(一百万个虚拟线程各一份 ThreadLocal 确实是问题,但答案是用 ScopedValue 而不是回到线程池)、「阻塞是罪恶的、要用响应式」(虚拟线程下阻塞很便宜)。反过来,13 章那些关于共享状态的规则一条都没变——虚拟线程解决的是「线程太贵」,不是「并发很难」。
这一卡本身就是「查资料要看年份」的最佳案例。虚拟线程从 Java 19 预览到 21 正式再到 24 修掉钉住,只用了三年——网上大量关于它的文章停留在 21 的口径,会告诉你要改锁、要小心 ThreadLocal、要用特殊的池。遇到任何关于新特性的建议,先确认它写于哪个版本,然后像上面那样自己跑一个最小实验验证。

开了几个并发任务之后,谁来保证它们都结束了?谁来在一个失败时取消其余的?结构化并发把「并发任务的生命周期」限制在一个代码块内——就像 try-with-resources 之于资源。

(JDK 25,需要 --enable-preview

场景结果
三个任务并行(300/500/400 ms),全部要结果507 ms——等最慢的那个
两个任务,一个 200 ms 就失败、另一个要 2000 ms203 ms 就返回了——慢的那个被自动取消
两个镜像,谁快用谁(anySuccessfulResultOrThrow154 ms——快的一返回就走
带超时 400 ms,任务要 3000 ms403 msTimeoutException

它解决了什么

  • 没有泄漏的任务:出了作用域,所有 fork 出去的任务要么完成要么被取消。用裸 ExecutorService 时很容易漏掉这一点(一个任务失败了,其余的还在后台白跑)。
  • 错误自动传播:一个子任务抛异常,join() 就抛,并且其余子任务被中断
  • 堆栈是连贯的:子任务的堆栈能看到父任务的调用链,调试时不再是一堆孤立的线程栈。
  • 取消是自动的:不需要自己传 CancellationToken 或维护一堆 Futurecancel

它还是预览特性

  • 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; }
    // 取消要自己写,而且容易漏
}
预览特性的 API 会变,而且变得很彻底。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
共享一个 MapConcurrentHashMap(用它的原子方法)
共享一个状态标志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);
并发代码的测试几乎没有意义——这是它和普通代码最本质的区别。13 章那个计数实验说明了原因:同一段有 bug 的代码,跑一百次可能九十次是对的。所以你不能靠「测过了」建立信心,只能靠设计上的论证:这段代码里哪些状态是共享的?每一处共享是怎么保护的?如果你说不清「这个字段被哪些线程碰、怎么保护的」,那它就是有问题的,哪怕现在跑得好好的。
写并发代码前先问一句:这真的需要并发吗?并发引入的复杂度是实打实的(难测、难调、难推理),而收益只在两种情况下存在:任务在等待(IO)、或者有多个核可以同时算。一个处理一千个文件的脚本,串行跑 3 秒和并发跑 1 秒,值不值得引入这些复杂度,是要认真权衡的。「因为看起来更专业」不是理由。

深水区: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 保证,做到了懒加载 + 线程安全 + 零同步开销,而且比双重检查锁短得多也不容易写错。需要单例时用它(不过先问一句:这个东西真的需要是单例吗?大多数时候一个普通对象传进去就够了,13 章讲过全局可变状态的代价)。

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)
JIT 的存在让「微基准测试」变成一个专门的技术活。16 章会详细讲,这里先记住三个必出错的写法:① 不预热就计时(测的是解释器);② 结果没人用(整段代码被优化掉,测出 0 毫秒);③ 循环有闭式解for(i<N) acc+=i 会被编译器用求和公式一步算完)。这三个坑我在写这一页的实验时全踩过——所以本页所有性能数字都用了 volatile 汇聚点和无闭式解的运算。
「Java 慢」这个印象大多来自两个真实但可修的地方:启动预热,和内存占用。稳定运行之后的吞吐,JVM 和提前编译的语言是同一量级(某些场景还更快,因为它有运行期信息)。如果你的场景确实卡在启动上(命令行工具、Serverless),答案是 AOT 缓存(本章第 6 卡)或 NativeAOT(18 章),而不是「换语言」

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 字节对齐。
  • 一百万个 Integer20 MB(每个约 16 字节对象 + 4 字节引用),一百万个 int4 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 -20
StackOverflowErrorOutOfMemoryError 是两回事,别混。前者是某个线程的栈用完了(递归太深、或者每帧局部变量太大),-Xss 调栈大小;后者是用完了,-Xmx 调堆大小。而且两者都可能是「调大了也没用」:无限递归调多大都会炸,内存泄漏也是——调参数前先确认是不是真的需要这么多。堆内存不够时,先用 jcmd <pid> GC.class_histogram 看看到底是什么占满了(16 章)。
「堆越大越好」是错的。堆变大意味着:每次 Full GC 要扫描的对象更多(停顿更长)、超过 32 GB 会失去压缩指针(所有引用从 4 字节变 8 字节,实际可用内存反而可能减少)、以及更容易掩盖真正的内存泄漏。合理做法是先量实际用量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              # 每秒打印一次各代使用情况
GC 调参是最容易做无用功的地方。网上流传的「JVM 调优参数模板」大多来自特定版本、特定负载、特定硬件,直接抄过来经常不如默认值(现代 JVM 的自适应策略已经相当好)。正确顺序:① 先确认真的有问题(有没有明显停顿?用 -Xlog:gc 看停顿时长和频率);② 确认是不是代码问题(是不是在制造不必要的长命对象);③ 只在前两步都排除后才动参数,而且一次只动一个并量化效果
「Java 有 GC 所以不会内存泄漏」是个误解。GC 回收的是「没有任何引用指向」的对象——如果你的代码一直拿着引用(静态 Map 只增不减、监听器没反注册、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 编译反而更快(省下编译时间)
AOT 缓存和 CDS 归档都和「JDK 版本 + 类路径」强绑定,不匹配时会静默降级换了 JDK、加了一个 jar、甚至类路径顺序变了,缓存就可能失效——而 JVM 默认只是忽略它继续跑,不报错。结果是你以为在用缓存,其实一直在裸跑。验证办法:加 -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(强制每次真的读写内存,虽然这也引入了额外开销)。

所以怎么测才算对

  • 认真的基准测试用 JMHorg.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(算一下()); }
「测法本身错了」比「结论错了」更常见,也更难发现,因为错误的测法通常给出看起来很合理的数字。除了上面三个,还有几个常见的:① 在同一个 JVM 里连续测 A 和 B——先跑的那个会影响 JIT 对后跑那个的编译决策(JMH 用分叉进程解决);② 用 System.currentTimeMillis() 测短时间——它的精度可能只有十几毫秒,测 5 毫秒的东西全是噪音(用 nanoTime());③ 机器上还开着别的程序结论:任何让你惊讶的性能数字,先怀疑测法。
本页每一处性能数字都用了上面这套方法,而且写进正文时都做了处理:确定性的量(O(n²) 的形状、内存的 5 倍差距、GC 前后的存活比例)直接给;随机器变化的量(毫秒数、倍数)写成量级并提示自测。你看到任何性能数字——包括本页的——都该问一句「在什么机器上、怎么测的」。

在动手优化之前,先看一眼这份清单——真实项目里的性能问题,绝大多数是这几类中的一种,而且都不需要什么高深技巧就能修。

① 算法复杂度(收益最大)

  • 在列表里查找: 20 万元素做 2000 次 containsArrayList 约 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 vs int[] 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 而不是 += 在循环里拼字符串——这些是默认就该这么写的,不需要先测。真正需要「先测再改」的是那些牺牲可读性的微观调整。
优化之前先问:这段代码一秒钟跑多少次?一个每次用户点击才跑一次的方法,哪怕花 10 毫秒也无所谓;一个每帧跑一万次的方法,1 微秒都要计较。把精力放在「跑得最多的那一小段」上——这通常是全部代码的百分之一,剩下的百分之九十九怎么写都行,那里应该优先考虑可读性。

内存问题有两种:「一次性用太多」(该用流式处理却全读进内存)和「持续增长」(泄漏)。两者的排查手段一样,判断依据不同。

第一步:看堆里什么最多

jcmd <pid> GC.class_histogram 直接列出每种类有多少个实例、共占多少字节,按大小排序。这是排查内存问题最快的一步,不用改代码、不用重启、几秒钟出结果。

  • 看到大量 byte[]/char[]:通常是字符串或缓冲区没释放。
  • 看到某个业务类有几百万个实例:直接就是嫌疑人。
  • 看到大量 HashMap$Node:某个 Map 在无限增长。

第二步:确认是不是泄漏

  • 隔一段时间再看一次直方图:数量持续增长且 GC 之后也不下降 → 泄漏。
  • 或者看 GC 日志:每次 Full GC 之后的存活量在阶梯式上升 → 泄漏。

第三步:找到是谁引用着它

jcmd <pid> GC.heap_dump 文件.hprof 导出堆快照,用 Eclipse MATVisualVM 打开,看「支配树」和「到 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 打开
不知道进程 pidjcmd -ljps -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.flags
这些工具需要和目标 JVM 同一个用户运行,而且在容器里会更麻烦。常见问题:① 用 sudojcmd 反而连不上(因为它按用户匹配临时文件);② 容器里的进程在宿主机上看不到(要 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>(预计大小);         // 预分配
「优化」最大的隐藏成本是它常常引入 bug。缓存要考虑失效和一致性、并行化要考虑线程安全、对象池要考虑状态清理、手写的快速路径要和慢速路径保持行为一致——每一个都是新的出错面而且这些 bug 通常很难复现(只在并发下、只在缓存满时、只在特定数据上)。所以「改完更快了」不等于「改对了」:优化之后要跑一遍完整的正确性测试,性能改动尤其需要。
上面那个「正则每次重新编译」是真实项目里出现频率极高的一类问题,同族的还有:每次调用都 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(名字, 分数);
<splitreplaceAllmatches 吃正则,replace 不吃——所以 "a.b".split(".") 返回空数组、"1+1".replaceAll("+", "-") 直接抛异常。只做字面替换就用 replace;完整的转义清单与 Pattern.quote 在 04 章。
Pattern 一定要提成 static finals.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 / indexOf
  • removeIf(条件) · 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 / offerLastpush(= addFirst)
取出pollFirst(空返 null)pop(空抛异常)
查看peekFirstpeek
// 计数(最常用的模式之一)
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();
subListkeySetvaluesentrySet 返回的都是视图不是拷贝。在视图上删元素会真的删原集合(map.values().removeIf(...) 正是利用这一点),而在原集合上改结构会让视图失效(后续操作抛 ConcurrentModificationException)。需要独立的一份就显式拷贝new ArrayList<>(list.subList(a,b))Set.copyOf(map.keySet())
Java 21 加了 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();
<IO 来源的流必须关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.filePath + 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 / getLastModifiedTime
  • Files.createDirectories会建多级,已存在也不报错)· createFile · delete / deleteIfExists · copy / move
  • Files.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(路径) 则会抛出带具体原因的异常(NoSuchFileExceptionAccessDeniedExceptionDirectoryNotEmptyException)。老 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 别名
打进 jar 的资源不能用 Files/File 读。Files.readString(Path.of("词典.txt")) 在 IDE 里跑得好好的(那时它是磁盘上的真文件),打成 jar 之后就 NoSuchFileException——因为它现在是 zip 里的一个条目,不是文件系统上的路径。必须用 getResourceAsStream。这是「本地能跑、打包后跑不了」的头号原因。
jar 就是 zip,可以用任何解压工具打开看。调试「资源文件到底打进去没有」时,jar --list --file app.jar | grep 词典 一秒钟就有答案。反过来,也可以直接用 zip 工具往 jar 里塞文件(虽然用 jar --update 更规范)。

「用户得先装 JDK」是 Java 分发的老大难。JDK 14 起自带的 jpackage 能把你的程序 + 一个裁剪过的运行时打成一个原生安装包——用户双击就能用,完全不知道底下是 Java。

本机上的体积对比

产物大小
完整 JDK 25363 MB
jlink --add-modules java.base 的运行时44 MB
加上 --compress zip-631 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.loggingjava.sqljdk.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 上面这个文件.java
别在还没写过 500 行 Java 的时候去啃构建工具。Maven 的目录约定、生命周期、插件体系,Gradle 的 DSL 与配置缓存——这些都是为了解决你还没遇到的问题而存在的,先学只会让你觉得 Java 复杂得没道理。倒过来才对:先用单文件写到「手动管 jar 已经很烦了」,那时候再往下读,五分钟就懂它在解决什么。
JDK 里还藏着一个能立刻用上的小工具:jwebserver(Java 18 起)。jwebserver -p 8000 -d . 就在当前目录起一个静态文件服务器,用来临时分享文件或调试前端页面非常方便。默认只绑定本机回环地址,要让局域网里其它设备访问得加 -b 0.0.0.0——它自己在启动时就会提示这一点。

上一卡说了不用构建工具能走多远。这一卡说清什么时候真的需要,以及最小配置长什么样。

出现下面任何一条,就该上构建工具了

  • 需要第三方库,而且不止一两个(手动下 jar 还要下它们的依赖的依赖);
  • 要跑自动化测试
  • 要发布(打胖 jar、发到仓库);
  • 要和别人协作(对方需要能一条命令构建出同样的结果);
  • 要在 CI 上构建

Maven vs Gradle

MavenGradle
配置文件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 dependencies
别把「构建工具」和「Java」混为一谈。初学者常见的困惑——「Maven 报错了,是不是我 Java 写错了」——两者是独立的:构建失败可能是网络问题、仓库配置、依赖版本,和你的代码毫无关系判断办法:先用 javac 手动编一下你的源文件。能编过,问题就在构建配置里;编不过,才是代码问题。这个隔离习惯能省掉大量白费的调试。
刚建好的项目跑不起来,八成不是你的代码。撞到的两个典型:① Gradle 上用 JUnit 6,测试直接起不来——报 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:test

2 条声明 → 12 个 jar。这就是「传递依赖」:你的依赖的依赖也会进来。看不懂依赖树,就没法排查后面那些问题。

scope:这个依赖在哪一步存在

scope编译主代码跑测试打进发布物典型用途
compile(默认)绝大多数库
testJUnit、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.0

Gradle 选了 2.22.1,程序正常跑出结果。——同一份依赖声明,一个崩一个不崩,差别只在两个工具的默认策略。记住这条:「在他机器上是好的」有时候真的不是玄学。

修法:BOM,以及一个反直觉的

BOM(Bill of Materials)是一份「这一族库互相兼容的版本表」,importdependencyManagement 之后,这族依赖都不用再写版本号。

但踩到一件事:先只加了 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 统一版本;排除只适用于「这个库我确实一行都用不到」的情况(典型是排掉某个日志实现,换成自己那套)。
不加 -Dverbosedependency:tree 只给你最终结果,看不出冲突发生过。对比:普通模式下 jackson-core 就安静地显示成 2.13.0,像是你本来就要这个版本;加上 -Dverbose 才会多出 omitted for conflict with 2.13.0 这一行,告诉你有另一个版本被淘汰了。排查 NoSuchMethodError/NoClassDefFoundError 时,第一条命令就该是它。

标准库缺的东西不多(19 章列过),但缺的那几样必须从外面拿。这一卡是一张按「缺口」组织的清单——只列一个人做东西真会用到的,并且给出每一条的代价。

缺口 → 常用选择

你缺的常用选择怎么挑
JSONJackson / GsonJackson 功能全、生态大;Gson 小而简单,脚本类程序够用
HTTP 客户端JDK 自带 HttpClient / OkHttp先用自带的(19 章);要连接池、拦截器、重试才上 OkHttp
日志SLF4J + LogbackSLF4J 是接口、Logback 是实现,配一次用很久
断言更好读AssertJassertThat(x).isEqualTo(y),链式、报错信息更清楚
替身对象Mockito只在真难构造依赖时用——能用真对象就用真的
测试要真数据库Testcontainers测试里起一个真容器,比装一个本地数据库干净
数据库JDBC / HikariCP / JDBI / Hibernate小项目直接 JDBC 写 SQL;连接池用 HikariCP;ORM 是最后才需要的那层
数据库版本管理Flyway把建表语句变成有序的迁移脚本,本地和线上不会分叉
命令行参数picocli一个注解就有子命令、帮助、补全,写工具很省事
Web 框架Javalin / Spring Boot见 19 章和本章后面那卡
零散工具函数Guava / Apache Commons先查标准库——今天大部分需求已经内置了

代价:一行依赖到底拖进来多少

版本连带 jar 数磁盘
picocli4.7.71408 KB
Gson2.13.22304 KB
SLF4J + Logback1.5.343992 KB
OkHttp5.4.052.1 MB
AssertJ3.27.629.9 MB
spring-boot-starter-web4.1.034见后面那卡

先看清数量级再决定。为了一个字符串工具方法引 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 是自动生成的,不用自己写
别按「教程里用了什么」选库。中文技术文章的半衰期比库本身短得多,照着抄很容易抄到上一代甚至上上代的组合——典型的几个:还在用 Apache HttpClient 4.x 而 JDK 自带的已经支持 HTTP/2、还在写 JUnit 4 的 @RunWith、还在教 log4j 1.x、还在用 SimpleDateFormat 而不是 java.time判断办法只有一个:去 Maven Central 看它最后一次发布是什么时候,再看它的官方文档首页写的是哪个大版本。
引一个库之前,先花五分钟确认标准库真的没有。最常被无谓引入的几类,其实 JDK 早就自带了:HTTP 客户端(19 章)、时间日期(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/tests
依赖执行顺序、依赖外部环境、依赖当前时间的测试,会变成长期负担。三个典型症状:① 单独跑能过、一起跑挂(测试之间共享了状态);② 在你机器上过、CI 上挂(依赖了本地文件或网络);③ 半夜挂了(用了 LocalDate.now() 而逻辑跨了天)。修法:每个测试自己准备数据(@BeforeEach)、用 @TempDir 而不是固定路径、把时间作为参数传进去而不是在方法里取Clock 就是为此设计的)。
从「修一个 bug 就补一个测试」开始,比一上来追求覆盖率有用得多。这样写出来的测试每一个都对应一个真实发生过的问题,价值密度极高,而且不会积累一堆没人看的样板测试。等你有了十几个这样的测试,重构时的底气就完全不一样了。

上一卡的 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")
Mockito 用过头会让测试变成「实现细节的复印件」。典型症状:你把一个方法的内部调用顺序全 verify 了一遍,于是只要重构内部写法——哪怕行为完全没变、所有功能都正常——测试就红一片。这种测试的作用是反的:它阻止重构,而测试本来是为了让你敢重构而写的。只验证跨出边界的调用(真的发出去的请求、真的写下去的数据),内部怎么算的交给结果断言去管。判断标准:如果一个测试在「行为正确但实现改了」时会挂,它多半测错了东西。
「用真的还是用假的」有一条实用的判断线:这个依赖吗、有副作用吗、不确定吗?三个都不占就用真的——纯计算的类、内存数据结构、内存数据库全都属于这一类,用真的既简单又能测到真问题。占了任意一个才需要替身:网络请求(慢 + 不确定)、发邮件发消息(副作用)、当前时间和随机数(不确定,把 ClockRandomGenerator 作为参数传进去就行)。这条线画对了,你的测试里 mock 会少一大半。

Java 的 IDE 是它生态里少有的、确实比命令行强很多的地方——类型信息完整,让自动补全、重构、跳转定义都极其精确。

选哪个

  • IntelliJ IDEA 社区版:免费,Java 支持是业界标杆。不知道选什么就用它。(Ultimate 版加的主要是 Web 框架和数据库工具。)
  • VS Code + Extension Pack for Java:轻量,如果你已经在用 VS Code 写别的语言,这个更顺手。
  • Eclipse / NetBeans:仍然活跃,各有拥趸。

真正值得学的操作(其余用到再说)

做什么IntelliJVS Code
跳到定义Ctrl+BF12
找所有用到的地方Alt+F7Shift+F12
全局搜任何东西Shift ShiftCtrl+P
重命名(安全重构Shift+F6F2
抽取方法Ctrl+Alt+M右键 Refactor
快速修复 / 生成代码Alt+EnterCtrl+.
格式化Ctrl+Alt+LShift+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>
别让 IDE 的自动生成替你思考。「生成 getter/setter」一键就能给每个字段配一对——但 05 章讲过,直通存取器等于没有封装;「生成 equals/hashCode」很方便,但你得知道该基于哪些字段(可变字段参与哈希会出事,06 章)。IDE 擅长的是「你已经决定要做什么之后,帮你少打字」;决定本身还是你的事。同理,自动补全出来的第一个方法不一定是你要的那个remove(int) vs remove(Object),03 章)。
IntelliJ 的「双击 Shift」全局搜索是最值得先学的一个操作:它能搜类名、文件名、方法名、设置项、甚至 IDE 的功能。不知道某个功能在哪个菜单里,直接搜功能名字就行——这一个快捷键能替代记住几十个菜单位置。

与外面的世界打交道

程序总要和外界交换点什么:抓个网页、起个服务、读写 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,对方接了连接但一直不返回数据,你的线程就永远挂着。两个超时都要设。
抓网页做统计这类小工具,用 JDK 自带的 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 逐条对照,你就知道这一层到底买到了什么:

事情裸 HttpServerJavalin
路径参数自己 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=你好 的中文参数原样拿到。

代价:三档摆在一起看

裸 HttpServerJavalin 7.2.2Spring Boot 4.1.0
依赖 jar04134
打出来的 jar1974 字节10.4 MB19.9 MB
启动基本瞬时444 ms1672 ms
要学的概念只有 HTTPContext 一个类容器 + 自动配置 + 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)
轻框架的 API 变得比你想象中快,网上的示例大多是旧版的。我照着流传最广的写法写 app.get("/路径", ctx -> ...),在 Javalin 7.2.2 上直接编译不过——7 把路由从 Javalin 实例挪进了配置里(cfg.routes.get(...)),虚拟线程开关也从 cfg.useVirtualThreads 挪到了 cfg.concurrency.useVirtualThreads判断办法:看官网文档页顶上标的版本号是不是你引的那个;拿不准就用 javap -cp 那个.jar 类名 把方法列出来看(这也是我最后确认 API 的办法)——字节码不会过期,博客会。
引了 logback 却不放 logback.xml,你会被 Jetty 的 DEBUG 日志淹掉。同一次启动:没有配置文件时输出 124 行(全是 Jetty 内部组件的 DEBUG),放一个只有六行的 src/main/resources/logback.xml 把根级别设成 INFO 之后是 11 行加日志实现的同时就把配置文件建好,否则你的程序自己那句「启动完成」会淹没在别人的调试信息里。

JDK 里有 XML 解析器,却没有 JSON——这是标准库最著名的一个缺口(逐个探过:javax.jsonjava.json 都不存在,javax.xml.parsers 倒是有)。原因是历史:JSON 流行起来时 Java 已经很成熟了,而加进标准库意味着永远不能改。

两个主流选择

JacksonGson
体量较大,功能全小,够用
速度更快够快
record 支持原生支持较新版本支持
生态事实标准(Spring 默认用它)Android 常见

拿不准就用 Jacksoncom.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));
对接外部 API 时,一定要开 FAIL_ON_UNKNOWN_PROPERTIES = false默认情况下,只要 JSON 里出现了你的 record 没定义的字段,Jackson 就直接抛异常——而对方随时可能加字段。你的程序会在某天毫无预兆地全线失败,原因只是别人加了个无关的字段。另一个方向的坑:JSON 里的 null 会变成对象的 null 字段,而 record 的紧凑构造器里如果有 Objects.requireNonNull 就会抛异常——反序列化的数据要当成不可信输入来校验。
ObjectMapper 必须复用(提成 static final)——它是线程安全的,但构造很贵(要初始化一堆内部缓存)。每次调用都 new 一个是 16 章那类「本该只做一次的初始化跑进了热路径」的典型案例,而且非常常见。同族的还有 DateTimeFormatterPattern

上一卡够写完 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不限堆峰值堆
一次性 readValueOutOfMemoryError575 ms229 MB
流式 readValues524 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 的模块版本必须和 jackson-databind 完全一致,这是 18 章那个依赖冲突问题在这里的具体形态:jackson-datatype-jsr310jackson-databind 版本对不上时,症状不是编译错误,而是运行期的 NoSuchMethodError 或者更隐蔽的「注册了模块但不生效」。jackson-bom 统一管(18 章那一卡),别一个个手写版本号。另外 readTree 出来的 JsonNode 上,asText() 对数字节点也返回字符串、对 null 节点返回 "null" 这个字面串——要判断真的有没有值,用 isMissingNode() / isNull(),别拿 asText() 的结果去比。
判断该不该流式,只问一句:你需要「同时」持有全部数据吗?统计、筛选、格式转换、逐行写进数据库——全都不需要,那就流式。真正需要全量在手的只有排序和随机访问,而那两件事到了这个数据量本来也该交给数据库(下一卡)而不是内存。另外:写大文件也有同样的问题,先在内存里攒一个百万元素的 ListwriteValue,和读的时候一样会 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 / executeBatch17 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,不是 nullgetInt 得 0、getStringnull)。要区分「值是 0」和「没有值」,只能紧接着调 wasNull()——它说的是上一次 get 的那一列
  • ResultSetStatementConnection 三样都要关,而且顺序有讲究——全部交给 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 只适合「一个程序在写」的场景。它用文件锁做并发控制,多个进程同时写会拿到 SQLITE_BUSY;单进程内多线程写也要小心(默认连接不是线程安全地共享的——一个线程一个连接,或者加连接池)。个人工具、单机服务、桌面程序完全够用;一旦是多个实例同时写同一个库,就该换成 PostgreSQL 那类真正的服务端数据库了。另一个反方向的提醒:别因为「以后可能要换数据库」就一上来套一层 ORM——JDBC 本身就是那层抽象,换库时要改的主要是 SQL 方言,而 ORM 并不能真的帮你免掉这件事。
建表语句放进代码里,用 CREATE TABLE IF NOT EXISTS 在启动时跑一遍。这样程序第一次运行就能自己把库建好,不需要你先手动执行一份 SQL 文件——发给别人的时候尤其省事(对方双击就能用,18 章那条路才走得通)。等到表结构开始需要改、而且库里已经有不能丢的数据时,再上 Flyway 那种迁移工具。顺带:SQLite 的文件就是数据,复制走就是备份,删掉就是重来。

写工具时经常需要调用别的命令(gitffmpegpython)。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.encodingstdout.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(() -> 存档()));
不读子进程的输出会导致它卡死,这是调用外部命令最经典的坑。操作系统给管道的缓冲区只有几十 KB,子进程写满之后就阻塞等你读,而你在 waitFor() 等它结束——双方永远等下去。症状是「调用某些命令时偶尔卡住」(输出少的时候不会,输出多了才会)。解法redirectErrorStream(true) + 读完输出再 waitFor(),或者用 redirectOutput(文件) 让它直接写文件。
密钥、令牌、密码一律从环境变量读,不要写进代码或配置文件。写进代码的密钥会跟着 git 历史永远留在那儿,即使后来删掉了也还在历史里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());
C 的类型大小是平台相关的,照抄头文件会出错。long 在 Windows 上是 32 位、在 Linux/macOS 上是 64 位;size_t、指针的宽度也随平台变。FFM 里要用 ValueLayout.JAVA_LONG 还是 JAVA_INT,取决于目标平台的实际 ABI——写错不会报错,只会读到垃圾数据或者崩溃。规避:用 jextract 从头文件自动生成绑定(它知道平台差异),或者至少在每个目标平台上都真跑一遍验证。
FFM 的边界检查是它相对 JNI 最实用的改进。MemorySegment 知道自己有多大,越界访问抛 Java 异常;Arena 知道内存什么时候该释放,用完再访问也抛异常。这让「和 C 打交道」从「一错就是段错误」变成了「一错就是可调试的异常」——对写这类代码的人来说,这个差别是决定性的。

做点自己的东西

前面十九章讲的是能力,这一章讲用它做什么。桌面窗口、小游戏、Minecraft 模组、自动化脚本、自己的服务——每条路都给了起手代码和第一个可以做出来的东西,并且把绕不开的 Spring Boot 单独讲明白。

Swing 是 JDK 自带的图形界面库javax.swing.JFrame 在,而 javafx 不在——JavaFX 需要单独下载)。不装任何东西,一个文件就能做出一个有窗口的程序。

三个概念就够开始

  • 容器JFrame(窗口)、JPanel(面板,用来分区和自定义绘制)。
  • 组件JButtonJLabelJTextFieldJTextAreaJListJTableJFileChooser
  • 布局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();
在非 EDT 线程上碰界面组件,是 Swing 最经典的错误——而且它通常不报错症状是界面偶尔画不对、闪烁、组件状态错乱,而且很难复现(取决于线程调度时机)。规则很简单:任何 setText/add/setVisible/repaint 之外的界面操作都要包在 SwingUtilities.invokeLater 里。反向的错误同样常见:在按钮的回调里直接做耗时操作——那个回调本身就跑在 EDT 上,界面会整个冻住直到它结束。
「现在你来」:把 03 章那个命令行小工具加个窗口。逻辑一行不用改——把 main 里读参数的部分换成 JFileChooser,把 System.out.printf 换成 结果.setText(...)十分钟就有一个能双击运行的桌面工具(配合 18 章的 jpackage 还能发给别人)。这是「把计算和界面分开」(18 章测试那卡)最直接的回报。

Java 在游戏领域有一块特殊的地盘:Minecraft 的 Java 版本身就是 Java 写的,它的模组生态是全世界最大的之一。此外还有成熟的 2D/3D 游戏框架。

从零开始:一个游戏循环

不用任何框架,Swing 的自定义绘制(上一卡)加一个定时器就能做出 2D 游戏:更新状态 → 重绘 → 等一帧。贪吃蛇、俄罗斯方块、扫雷、打砖块,都能在两三百行内做完。这是最好的练手项目——用到 07 章的建模、08 章的状态分发、10 章的集合。

往上走的几个框架

框架做什么特点
libGDX2D/3D 游戏,跨平台成熟、文档好、能发布到桌面和 Android
LWJGLOpenGL / 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 上,直接改界面会出上一卡那类问题。
上面这个贪吃蛇不到一百行,而且用到了本页七八章的内容:record 建模、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 它就给你 Pathlong 就给 longString[] + 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"]
把「学 Spring」当成「学 Java 服务端」是最常见的弯路。Spring 是一套非常成熟的框架,但它解决的是大型团队长期维护复杂应用的问题——依赖注入、AOP、配置管理、自动装配这些机制,在你写一个五个接口的小服务时全是纯粹的开销,而且它们会遮住底下真正发生的事。更好的顺序是:先用裸 HttpServer 或轻量框架把 HTTP、并发、数据、错误处理弄明白,然后你会自己发现「这些样板确实该有人帮我管」——那时再学框架,每一个特性都对应一个你亲身遇到过的痛点。
上面这个服务不到八十行,但它是个真的能用的键值存储服务:有路由、有 REST 语义、有错误处理、有健康检查、用虚拟线程处理并发。把它作为起点,需要什么加什么——需要持久化就加 JDBC,需要 JSON 就加 Jackson(19 章),需要复杂路由再考虑换框架。这个「从能跑的最小版本长出来」的路径,比从框架模板开始要扎实得多。

前一卡说「别从 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 项因条件不满足被跳过。生效的里面有 DispatcherServletAutoConfigurationErrorMvcAutoConfigurationHttpEncodingAutoConfiguration 这些名字——每一个都对应一件它默默替你做了的事。

这也是它最让人迷路的地方:你的代码里没有一行说明 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 项自动配置。
读别人的 Spring 代码,认得三类注解就能看懂大半。@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 里对任何标准库方法按跳转就能看到实现。ArrayListHashMapString 的源码是极好的学习材料——它们写得干净、注释详尽,而且你每天都在用它们,读起来有真实的语境。

// 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 章说"已知大小时要预分配容量"
别急着「学下一门语言」来逃避当前的困难。换语言解决不了「不知道怎么设计程序」的问题——那些困难在每门语言里都存在,只是长相不同一个更有效的做法:把你已经做出来的东西再做一遍,用上你新学的东西(把命令行工具加个界面、把单线程改成并发、给它补上测试)。第二遍做同一个东西的收获,通常比第一遍做一个新东西大得多。
读 JDK 源码时从 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.swingjava.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   —— 就这样,没有第二步
建过 Swing 组件之后,JVM 不会自己退出。AWT 的事件分发线程是非守护线程——把 System.exit(0)(或给窗口设 EXIT_ON_CLOSE)去掉后,main 明明打印完「main 结束」,进程仍然挂着不退,只能被外部杀掉。命令行工具顺手加了个窗口之后发现「程序跑完不返回」,十有八九是这条。
想快速试控件效果不必每次写完整类:jshell 里可以直接 import javax.swing.* 然后一句一句搭窗口,改一行看一眼。搭好了再誊回文件。

Swing 本身不难,但有三件事不知道就会一路踩:界面只能在一个特定线程上碰、布局不要手写坐标、以及一个很少被提起的能力——它能不开窗口就把界面画出来

一、所有控件操作都在 EDT 上

  • Swing 不是线程安全的。控件的创建与修改都必须在事件分发线程(EDT)上进行——从别的线程改界面,症状是随机的错乱和偶发异常,不会当场报错。
  • 从别处切进去用 SwingUtilities.invokeLater(() -> ...)
  • 反过来,耗时的活儿绝不能写在 EDT 上:按钮回调里直接下载文件、扫全盘,界面会整个冻住(连重绘都停)。用 SwingWorkerdoInBackground() 在后台线程跑,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,画出来是一张空白图或者所有东西叠在左上角。setSizedoLayout 最后 paint,顺序不能换。
SwingWorkerdone() 里一定要调 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();            // ← 忘了这句终端会留在乱掉的状态
全屏 TUI 会把终端切到「备用屏 + 原始模式」,程序异常退出而没有恢复,用户的终端就会留在不回显、不换行的坏状态(得敲 reset 才好)。startScreen() 之后一定要用 try/finally 保证 stopScreen() 执行——这条对所有语言的 TUI 库都成立。
拿不准该做窗口还是做终端界面,先问一句:这个工具你会不会在 SSH 或者别人的机器上跑?会,就做 TUI——省掉的不只是打包,还有「对方有没有图形环境」这个问题。

写完了想发给朋友,第一个问题就来了:对方电脑上没装 Java。这是 Java 桌面程序真正的成本所在,也是选 Swing 而不是 JavaFX 的一个很实际的理由。

两个工具,JDK 自带

  • jlink裁出一个只含你用到的模块的精简运行时。Swing 在 java.desktop 模块里,能被它裁进来。
  • jpackage:把「你的 jar + 一个运行时」打成平台原生的安装包或免安装目录(app-image)——对方双击就能用,完全不需要知道 Java 是什么。

体积到底是多少

做法体积说明
整个 JDK363 MB让对方自己装——基本不现实
jlink --add-modules java.base44 MB最小运行时
再加 --compress zip-631 MB压缩后
jpackage --type app-image121 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 rt
<里程碑最容易变成收藏夹里的清单。四个阶段每一阶段都该落一个交付物——能跑的代码、能讲清的机制、能部署的服务;没有交付物的阶段等于没走完。搜资料时另外提防一件事:排在前面的答案常常来自 Java 8 时代,过时信号的完整清单在 00 章。
每个阶段都以「做出一个东西」结尾,而不是「读完某几章」。这不是形式——读懂和写得出之间隔着一道很宽的沟,而唯一的过桥方式是自己动手撞几次墙。本页每一章的 tip 里都埋了「现在你来」式的小任务,挑你感兴趣的做。

Java 的更新节奏是每半年一个版本,每两年一个 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 很啰嗦」「Java 全是样板」「Java 就是写企业系统的」的说法——其中一部分曾经是真的,一部分是把某套工作方式的问题算到了语言头上。今天的 Java 写一个能跑的程序要三行(01 章),建模一个数据类型要一行(07 章),并发抓一百个网页不需要任何回调(14 章)。它是一门有三十年沉淀、每半年在改进、标准库厚实、而且拿来做自己的东西相当顺手的语言。用它做点你自己想要的东西——这一页的全部目的就是这个。
本页每一条结论都可以自己复现,而且成本极低:把卡片里的代码存成一个 .java 文件,java 它.java 就出结果——不用建项目、不用装构建工具(01 章)。看到任何断言心里犯嘀咕,就当场跑一遍。这比多读三遍解释有用得多,也是本页所有结论的产出方式。