Java 从入门到精通(十八):工程化与性能调优实战——构建、测试、日志与诊断工具箱

这是「Java 从入门到精通」系列的第 18 篇,也是收官篇。写到这里,语法、集合、并发、JVM 这些”知识点”其实都只是入场券——真正决定一个工程师上限的,是他能不能把代码稳定地、可复现地、可观测地交付出去。本篇按”构建 → 规范 → 测试 → 日志 → 调优 → 收官”的顺序,讲清工具背后的原理,而不是给你一份工具清单。建议配合前面的篇章一起读:知道了 HashMap 怎么扩容,才知道为什么要给集合预设初始容量;知道了 JIT 会消除死代码,才知道为什么 System.currentTimeMillis() 测出来的性能十有八九是假的。

一、工程结构与构建:从”能编译”到”可复现”

1.1 坐标、依赖范围与依赖传递

Maven 的一切都建立在坐标(GAV)之上。坐标不只是”下载地址”,它是构件的唯一身份;仓库(本地仓库、私服、中央仓库)本质是”坐标 → 构件文件”的映射表,而 Maven 的核心工作,就是把 POM 里声明的坐标解析成一棵依赖树,再按这棵树拼出 classpath。

真正容易被忽略、却天天在影响你的是依赖范围(scope):它决定依赖在哪些 classpath 生效、以及会不会被传递下去。

scope 决定依赖在编译/测试/运行三个 classpath 中的可见性与是否传递:compile 全可见且传递;provided 编译与测试可见、运行由容器提供且不传递(如 servlet-api);runtime 编译不可见、运行必需且传递(如 JDBC 驱动);test 仅测试可见;import 只用于导入 BOM。

两个高频坑:runtime 依赖编译期不可见,所以代码里不该出现 com.mysql.cj.jdbc.Driver 这类直接引用,应面向 DataSource 编程;provided 不传递,下游必须自己再声明——容器运行时会提供,但测试环境未必有。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<!-- pom.xml:scope 与依赖声明的正确姿势 -->
<dependencies>
<!-- 编译期就要用,运行时也要用 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.14.0</version>
</dependency>

<!-- 编译期需要,运行时由 Tomcat 提供,且不传递 -->
<dependency>
<groupId>jakarta.servlet</groupId>
<artifactId>jakarta.servlet-api</artifactId>
<version>6.0.0</version>
<scope>provided</scope>
</dependency>

<!-- 编译期用不到(面向接口编程),运行期必须存在 -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<version>8.4.0</version>
<scope>runtime</scope>
</dependency>

<!-- 只在测试期生效 -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>
</dependencies>

1.2 依赖调解:最短路径优先,路径相同则先声明优先

依赖冲突几乎每个 Java 项目都会遇到。Maven 的调解规则只有两条,且先后顺序严格:

  1. 最短路径优先(nearest definition):依赖树中路径更浅的版本胜出。A → B → C → log4j:1.2 与 A → D → log4j:2.0,生效的是 2.0(深度 2 < 3)。
  2. 先声明优先(first declaration wins):路径深度相同时,POM 中先声明的那条路径胜出。

最常见的误解是”版本高的胜出”——Maven 从来不看版本号大小。A → B → jackson:2.9 与 A → C → jackson:2.15 深度相同时,你在 POM 里先写了 B,生效的就是 2.9。这也解释了为什么”把依赖挪到前面”能”莫名其妙修好问题”,以及这种修法为何脆弱:别人调一下顺序就又坏了。正解是显式仲裁(1.3 节)。

1.3 optional、exclusions 与 dependencyManagement/BOM

三种控制手段语义完全不同,别混用:

  • optional=true:声明”这是我内部实现需要的,但不传递给下游“。典型场景是 ORM 同时支持 MySQL 与 PostgreSQL,两个驱动都设为 optional,由使用者自选。表达的是”能力可选”。
  • exclusions:消费者主动剪掉某条传递路径。表达的是”我不要这个”。滥用会破坏依赖图的完整性,只在确认冲突且无法仲裁时使用。
  • dependencyManagement:只声明版本,不引入依赖,表达”如果最终用到了,就用这个版本”。它不改变依赖图结构、只做版本收敛,是最推荐的仲裁手段。

BOM(Bill Of Materials)就是把一批 dependencyManagement 打成一个 POM,用 scope=import + type=pom 导入,一次性对齐整个技术栈的版本(Spring Boot 的 spring-boot-dependencies 是最著名的 BOM)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
<!-- 1)导入 BOM:只锁版本,不引入任何依赖 -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.3.0</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<!-- 第三方库对齐:覆盖 BOM 中没有或版本不满意的 -->
<dependency>
<groupId>com.fasterxml.jackson</groupId>
<artifactId>jackson-bom</artifactId>
<version>2.17.1</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<dependencies>
<!-- 2)有了 BOM,这里可以省略 version,且永远自洽 -->
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>

<!-- 3)剪掉某个传递依赖(例如不想用 logback,改用 log4j2) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>

1.4 多模块聚合与继承

多模块有两种关系,经常被混为一谈:

  • 继承(parent):子模块 <parent> 指向父 POM,继承 properties、dependencyManagement、pluginManagement,解决”配置复用”。
  • 聚合(modules):父 POM 声明 <modules>,构建时 Maven 按拓扑顺序(依模块间依赖自动排序,而非书写顺序)构建全部模块,解决”一起构建”。

两者可单独存在(只聚合或只继承),同时存在时才是通常说的”父工程”。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
<!-- 父工程:packaging 必须是 pom -->
<project>
<groupId>com.example</groupId>
<artifactId>order-platform</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>pom</packaging>

<modules>
<module>order-api</module> <!-- 接口与 DTO -->
<module>order-service</module> <!-- 领域与业务逻辑 -->
<module>order-web</module> <!-- 控制器与启动入口 -->
</modules>

<properties>
<maven.compiler.release>17</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>

<dependencyManagement>
<dependencies>
<!-- 统一管理内部模块版本,子模块引用时无需写 version -->
<dependency>
<groupId>com.example</groupId>
<artifactId>order-api</artifactId>
<version>${project.version}</version>
</dependency>
</dependencies>
</dependencyManagement>

<build>
<pluginManagement>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
</plugin>
</plugins>
</pluginManagement>
</build>

<!-- 环境隔离:打包时通过 -P 激活 -->
<profiles>
<profile>
<id>dev</id>
<activation><activeByDefault>true</activeByDefault></activation>
<properties><env>dev</env></properties>
</profile>
<profile>
<id>prod</id>
<properties><env>prod</env></properties>
</profile>
</profiles>
</project>

profile 的正确用法是隔离环境差异,而不是隔离代码逻辑:把可变项放进 application-${env}.properties,用 filtering 或 spring.profiles.active 激活。最忌讳在 profile 里写不同的依赖版本——那样测试与生产跑的根本不是同一套字节码。

1.5 Gradle 的取舍

Gradle 与 Maven 的差别不在”能不能构建”,而在构建模型的抽象方式:Maven 是声明式生命周期(固定的 clean → validate → compile → test → package → verify → install → deploy),灵活度靠插件”挂载”到生命周期;Gradle 是任务有向无环图(Task DAG),每个 Task 声明 inputs/outputs,Gradle 据此做增量构建——输入输出没变就直接 UP-TO-DATE 跳过,并支持构建缓存(Build Cache)跨机器复用产物。这在大仓库里能带来数量级的构建加速。

Kotlin DSL 相比 Groovy DSL 有类型安全与 IDE 补全优势,代价是脚本首编译稍慢、报错更绕。

维度 Maven Gradle
构建模型 固定生命周期 + 插件绑定 Task DAG + 增量构建 + 构建缓存
配置语言 XML(冗长、严格、可校验) Groovy/Kotlin DSL(可编程、灵活)
学习/维护成本 低,约定大于配置 高,脚本本身也需要工程治理
大型多模块构建速度 一般(可并行但增量弱) 优秀(增量 + 缓存 + 守护进程)
生态与兼容性 最广泛,几乎所有工具都认 pom Android 首选,服务端增长中
选型建议 团队协作、追求稳定与一致性 超大型仓库、构建耗时已成瓶颈、Android

结论很务实:别为了”时髦”换 Gradle。若痛点是构建慢到影响开发节奏且团队能治理脚本,再迁移;否则把 Maven 的版本仲裁、模块划分和私服做好,收益更大。

1.6 构建产物与打包方式

  • jar / war:jar 是类库或可执行包(清单带 Main-Class);war 部署到外部 Servlet 容器,如今只在老系统维护中见到。
  • fat jar:把全部依赖打进一个 jar。Spring Boot 用的是嵌套 jar(BOOT-INF/lib 下的 jar 原样嵌入,由 LaunchedURLClassLoader 加载)而非解压合并——后者会面临同名资源覆盖、META-INF/services 被冲掉的问题,这正是 maven-shade-plugin 需要 AppendingTransformer / ServicesResourceTransformer 的原因。
  • 分层 jar:按变更频率切为 dependencies / spring-boot-loader / snapshot-dependencies / application 四层。构建镜像时低频层先 COPY,可最大化利用镜像层缓存——“改一行代码推 80MB 镜像”变成只推几十 KB。
1
2
3
4
5
# 查看分层结构
java -Djarmode=layertools -jar order-web-1.0.0.jar list

# 按层解压,供 Dockerfile 分层 COPY
java -Djarmode=layertools -jar order-web-1.0.0.jar extract --destination extracted

1.7 依赖冲突排查实操

排查思路分三步:看树 → 定版本 → 查加载来源。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 1)完整依赖树,看清楚每棵子树
mvn dependency:tree

# 2)带冲突详情:被调解掉的版本会标 (version managed from ...)、(omitted for conflict with ...)
mvn dependency:tree -Dverbose

# 3)只看某个 groupId/artifactId 出现在哪些路径
mvn dependency:tree -Dincludes=com.fasterxml.jackson.core:jackson-databind

# 4)找出"声明了但没用"和"用了但没声明"的依赖(规范检查利器)
mvn dependency:analyze

# 5)把依赖拷到目录,再用 jdeps / 反编译工具确认类来自哪个 jar
mvn dependency:copy-dependencies -DoutputDirectory=target/libs
jdeps -verbose:class -cp 'target/libs/*' target/classes | grep -i jackson

# 6)终极手段:运行时看 JVM 到底从哪个 jar 加载了这个类
java -verbose:class -jar app.jar | grep 'com.fasterxml.jackson.databind.ObjectMapper'

IDEA 的 Maven 依赖分析(Show Dependencies / Diagram)能图形化展示冲突,冲突边标红;jclasslib 则可在字节码层面确认”某个类的常量池里引用的方法签名在目标版本里到底存不存在”——NoSuchMethodError 往往就是编译期用新版、运行期加载旧版导致的。

1.8 私服与镜像配置要点

私服(Nexus / Artifactory)的价值有三:加速(缓存中央仓库)、稳定(外网不可用时仍能构建)、可控(托管内部构件与第三方黑名单)。配置放在 ~/.m2/settings.xml——不要写进项目 pom.xml,那是团队共享的,不该含个人凭证与内网地址。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<!-- ~/.m2/settings.xml -->
<settings>
<mirrors>
<!-- mirrorOf 用 * 会连私服自己的仓库也代理掉,推荐用 central 或 *,!internal -->
<mirror>
<id>nexus-public</id>
<mirrorOf>central</mirrorOf>
<url>https://nexus.example.com/repository/maven-public/</url>
</mirror>
</mirrors>
<servers>
<!-- id 必须与 distributionManagement / repository 的 id 完全一致 -->
<server>
<id>nexus-releases</id>
<username>deploy</username>
<password>${env.NEXUS_PWD}</password>
</server>
</servers>
<profiles>
<profile>
<id>nexus</id>
<repositories>
<repository>
<id>nexus-releases</id>
<url>https://nexus.example.com/repository/maven-releases/</url>
<releases><enabled>true</enabled></releases>
<snapshots><enabled>false</enabled></snapshots>
</repository>
</repositories>
</profile>
</profiles>
<activeProfiles><activeProfile>nexus</activeProfile></activeProfiles>
</settings>

还有一条工程纪律:永远不要依赖 SNAPSHOT 发布生产。SNAPSHOT 会被定期检查更新(默认一天一次,-U 强制),意味着构建产物不 reproducible——同一个 commit,今天打包和明天打包可能不一样。发布必须固化成 Release 版本,构建才可复现。

二、编码规范与静态检查:把”约定”变成”编译期约束”

2.1 为什么必须统一规范

规范的价值不在”好看”,而在降低认知负担与审查成本:一个团队里有人用 isDeleted、有人用 getIsDeleted(),序列化框架、MyBatis 映射、前端联调就会各踩一次坑。靠人肉 Code Review 执行规范不可持续,唯一可靠的落地方式是把它接进构建流程,让不合规的代码过不了 CI。

阿里 Java 开发手册(P3C)之所以流行,正因为它把”经验”写成了可枚举、可检查的规则:命名、常量、OOP、集合、并发、控制语句、注释、异常日志、MySQL、工程结构、设计规约。

2.2 高频规约要点

挑几条最容易出问题、且背后有原理的:

  • 集合初始化容量:new HashMap<>(expectedSize / 0.75f + 1),原理是扩容阈值 = capacity × loadFactor,预设容量可避免多次 resize。
  • Arrays.asList() 返回定长列表:add/remove 抛 UnsupportedOperationException,基本类型数组还会被当成单个元素。
  • 不要在 foreach 中 remove/add:会触发 ConcurrentModificationException,改用 Iterator.remove() 或 removeIf()。
  • 线程池禁用 Executors:newFixedThreadPool 用无界队列,堆积即 OOM;必须显式构造并设定队列上限与拒绝策略。
  • 金额禁用 float/double:二进制浮点无法精确表示十进制小数,必须用 BigDecimal(String);异常不做流程控制:构造异常要 fillInStackTrace,成本比一次判断高几个数量级。
  • equals 与 hashCode 必须同时重写,SimpleDateFormat 非线程安全(改用不可变的 DateTimeFormatter)。

2.3 把静态检查接进构建流程

三类工具各司其职:Checkstyle 管风格(命名、缩进、import 顺序、行长度),PMD / P3C 管坏味道与规约(空 catch、重复代码、过度复杂),SpotBugs / ErrorProne 管缺陷(空指针、错误的 equals、资源未关闭、== 比较字符串)。ErrorProne 跑在 javac 内部,能在编译期把 bug 变成编译错误,反馈最快;SpotBugs 基于字节码,能发现编译期看不到的跨方法问题。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<!-- 构建时强制检查:style 挂在 validate 阶段,bug 检查挂在 verify 阶段 -->
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-checkstyle-plugin</artifactId>
<version>3.4.0</version>
<configuration>
<!-- 可用阿里规约的 checkstyle 配置,或团队自定义的 checkstyle.xml -->
<configLocation>config/checkstyle.xml</configLocation>
<consoleOutput>true</consoleOutput>
<failsOnError>true</failsOnError>
<violationSeverity>warning</violationSeverity>
<includeTestSourceDirectory>false</includeTestSourceDirectory>
</configuration>
<executions>
<execution>
<id>checkstyle-validate</id>
<phase>validate</phase>
<goals><goal>check</goal></goals>
</execution>
</executions>
</plugin>

<plugin>
<groupId>com.github.spotbugs</groupId>
<artifactId>spotbugs-maven-plugin</artifactId>
<version>4.8.3.0</version>
<configuration>
<effort>Max</effort>
<threshold>Default</threshold>
<excludeFilterFile>config/spotbugs-exclude.xml</excludeFilterFile>
</configuration>
<executions>
<execution>
<id>spotbugs-check</id>
<phase>verify</phase>
<goals><goal>check</goal></goals>
</execution>
</executions>
</plugin>
</plugins>
</build>

2.4 格式化统一:EditorConfig + Spotless

风格争论是最没价值的争论,正解是用工具强制统一,然后不再讨论:.editorconfig 管编辑器基础行为,Spotless 在构建时真正格式化(或至少校验)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
<plugin>
<groupId>com.diffplug.spotless</groupId>
<artifactId>spotless-maven-plugin</artifactId>
<version>2.43.0</version>
<configuration>
<java>
<googleJavaFormat><version>1.19.2</version></googleJavaFormat>
<removeUnusedImports/>
<importOrder>
<order>java|javax,org,com,\#</order>
</importOrder>
</java>
</configuration>
<executions>
<execution>
<!-- 推荐先跑 apply 自动修复,CI 上跑 check 卡住漏网的 -->
<id>spotless-apply</id>
<phase>process-sources</phase>
<goals><goal>apply</goal></goals>
</execution>
</executions>
</plugin>

2.5 Code Review 清单(20 条)

# 检查项 关注点
1 命名是否表意 类名名词、方法名动词、布尔字段不加 is 前缀
2 方法长度与圈复杂度 单方法不超过 80 行,复杂分支考虑卫语句与策略模式
3 参数个数 超过 5 个考虑封装为参数对象
4 空指针防护 返回集合不要返回 null,返回空集合;用 Optional 表达”可能没有”
5 异常是否吞掉 禁止空 catch;catch 后必须记录或向上抛
6 异常信息是否有上下文 携带关键业务主键,便于排查
7 资源是否关闭 一律 try-with-resources
8 集合容量与遍历 预设容量;禁止 foreach 中 remove
9 Map 的 key 是否可变对象 可变 key 会导致取不出来
10 并发可见性 共享变量是否需要 volatile / 原子类 / 锁
11 线程池是否自定义 禁止 Executors;队列有界;有拒绝策略
12 ThreadLocal 是否 remove 线程池复用会导致脏数据与内存泄漏
13 事务边界是否合理 事务中禁止远程调用、禁止大循环
14 SQL 是否有索引支撑 看执行计划,避免隐式转换与函数包裹索引列
15 是否存在 N+1 查询 循环里查库必须改批量
16 日志是否合规 占位符、不打敏感信息、异常打栈
17 是否有魔法值 抽取常量或枚举
18 时间处理是否用 JDK8 API Instant/LocalDateTime,带时区语义
19 接口是否幂等 重试与消息重复消费场景必须考虑
20 是否有对应测试 核心逻辑必须有单测,bugfix 必须补回归用例

三、单元测试与质量保障:让”改动”不再可怕

3.1 测试分层与测试金字塔

层级 测试对象 速度 依赖 数量占比建议 典型框架
单元测试 单个类/方法 毫秒级 无外部依赖,全部用测试替身 70% JUnit 5 + Mockito
集成测试 多模块/真实中间件 秒级 DB、Redis、MQ(容器化) 20% Testcontainers、Spring Test
端到端测试 完整业务流程 分钟级 部署好的环境 10% REST Assured、Playwright

金字塔的形状是有道理的:越靠上反馈越慢、越脆弱、定位越难。把 70% 的断言压在单元测试层,才能在改一行代码后几秒内知道有没有破坏既有行为。”倒金字塔”(大量端到端、几乎没有单测)正是很多团队不敢重构的直接原因。

3.2 JUnit 5 核心用法

JUnit 5 相比 JUnit 4 最大的变化是架构拆分:junit-jupiter(API + 引擎)跑在 junit-platform 之上,测试类不再要求 public、不再要求无参构造,扩展机制从 Runner 变成了 Extension。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import org.junit.jupiter.api.*;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
import org.junit.jupiter.api.parallel.Execution;
import org.junit.jupiter.api.parallel.ExecutionMode;

import static org.junit.jupiter.api.Assertions.*;

@DisplayName("订单金额计算器")
class OrderAmountCalculatorTest {

private OrderAmountCalculator calculator;

// 每个测试方法前执行(默认 PER_METHOD 生命周期)
@BeforeEach
void setUp() {
calculator = new OrderAmountCalculator();
}

@AfterEach
void tearDown() {
// 清理资源,例如还原系统属性、清理临时文件
}

// 所有方法共享一次,适合昂贵资源(如启动容器)
@BeforeAll
static void initAll() {
System.setProperty("discount.enabled", "true");
}

@Test
@DisplayName("满 100 减 10:边界值应命中优惠")
void shouldApplyDiscountWhenAmountEqualsThreshold() {
// given - 准备输入与前置条件
List<OrderItem> items = List.of(new OrderItem("A", 1, new BigDecimal("100.00")));

// when - 执行被测行为
BigDecimal actual = calculator.calculate(items);

// then - 断言结果
assertEquals(0, new BigDecimal("90.00").compareTo(actual));
}

// 参数化:一次声明多组输入,避免复制粘贴多个测试方法
@ParameterizedTest(name = "金额 {0} 期望优惠后 {1}")
@CsvSource({
"100.00, 90.00",
"99.99, 99.99",
"200.00, 180.00"
})
void shouldCalculateDiscount(String amount, String expected) {
List<OrderItem> items = List.of(new OrderItem("A", 1, new BigDecimal(amount)));
assertEquals(0, new BigDecimal(expected).compareTo(calculator.calculate(items)));
}

// 断言异常:用 assertThrows 拿到异常对象,可以进一步断言 message
@ParameterizedTest
@ValueSource(strings = {"", " "})
void shouldRejectBlankSku(String sku) {
IllegalArgumentException ex = assertThrows(
IllegalArgumentException.class,
() -> new OrderItem(sku, 1, BigDecimal.ONE));
assertTrue(ex.getMessage().contains("sku"));
}

// 超时断言:防止性能回归(注意:不要用 assertTimeoutPreemptively 跑共享资源的代码)
@Test
void shouldFinishFast() {
assertTimeout(java.time.Duration.ofMillis(200),
() -> calculator.calculate(List.of()));
}

// 嵌套测试:把同一场景下的多个变体组织在一起,语义更清晰
@Nested
@DisplayName("VIP 用户场景")
class VipScenario {

@Test
@DisplayName("VIP 折扣叠加满减")
void shouldStackVipDiscount() {
// 复用外层 calculator,但语义上属于独立分组
assertDoesNotThrow(() -> calculator.calculate(List.of()));
}
}
}

几个实用细节:assertAll 一次报出全部分组断言的失败;@Tag("slow") 配合 Surefire 的 excludedGroups 可把慢测试剔出日常构建;并行执行需在 junit-platform.properties 开启且默认关闭。

3.3 Mockito 与”不要过度 Mock”

先分清测试替身(Test Double)——很多人把它们都叫”mock”:Dummy 只填满参数从不使用;Stub 提供固定返回值;Spy 包装真实对象、默认走真实逻辑;Mock 是完全替身,可打桩并验证调用;Fake 是可用的轻量实现(如内存版 Repository)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
import org.mockito.*;
import static org.mockito.Mockito.*;
import static org.mockito.ArgumentMatchers.*;

@ExtendWith(MockitoExtension.class) // JUnit5 集成 Mockito 的官方扩展
class OrderServiceTest {

@Mock
private OrderRepository repository; // 完全替身:默认返回 null / 空集合
@Mock
private RiskClient riskClient; // 外部服务
@Spy
private AmountCalculator realCalculator = new AmountCalculator(); // 走真实逻辑
@InjectMocks
private OrderService orderService; // 自动把上面的 mock 注入进来

@Captor
private ArgumentCaptor<Order> orderCaptor;

@Test
void shouldRejectOrderWhenRiskServiceSaysNo() {
// 打桩:指定输入 → 返回值
when(riskClient.check(anyString())).thenReturn(RiskResult.REJECT);
// 连续调用返回不同值(模拟重试场景)
when(repository.findById(100L))
.thenReturn(Optional.of(new Order(100L)))
.thenReturn(Optional.empty());

assertThrows(RiskRejectedException.class, () -> orderService.submit(100L));

// 验证行为:riskClient 被调用了一次,且参数被捕获
verify(riskClient, times(1)).check(eq("user-1"));
// 验证"从未调用"——比验证"调用了"更有价值
verify(repository, never()).save(any(Order.class));
}

@Test
void shouldSaveOrderWithCalculatedAmount() {
when(riskClient.check(anyString())).thenReturn(RiskResult.PASS);

orderService.submit(100L);

// 捕获真实入参,断言其内部结构
verify(repository).save(orderCaptor.capture());
Order saved = orderCaptor.getValue();
assertEquals(OrderStatus.CREATED, saved.getStatus());
}

@Test
void shouldRollbackOnRepositoryFailure() {
when(riskClient.check(anyString())).thenReturn(RiskResult.PASS);
// 模拟异常:thenThrow 用于验证异常分支的补偿逻辑
doThrow(new DataAccessException("db down")).when(repository).save(any());

assertThrows(DataAccessException.class, () -> orderService.submit(100L));
// 断言补偿动作确实发生了(例如发了告警)
verify(riskClient).reportFailure(anyString());
}
}

不要过度 Mock 是这里最重要的原则。若为了测金额计算而 mock 掉 Repository、Clock、汇率服务,测试验证的其实是”调用按你写的顺序发生了”,而非”金额算对了”——一重构,逻辑没变,测试全红。判断标准:能用 Fake(内存实现)就用 Fake;只有跨进程边界(远程服务、MQ、文件系统、时间)才用 Mock。测业务逻辑时 new AmountCalculator() 远比 @Mock AmountCalculator 有价值。

3.4 集成测试:H2、Testcontainers 与测试切片

H2 虽快,但和 MySQL 的行为并不完全一致(隔离级别、函数、DDL 语法、索引行为),所以涉及 SQL 语义的测试别用 H2。Testcontainers 用真实 Docker 容器跑真实中间件,牺牲几秒启动时间换来”测试环境与生产一致”,是目前最靠谱的方案。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
@Testcontainers
@DataJpaTest // Spring Boot 测试切片:只加载 JPA 相关的 Bean,不启动整个容器
@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE) // 别替换成 H2
class OrderRepositoryIT {

// 静态字段:所有测试方法共享一个容器(否则每个方法起一个,慢到无法接受)
@Container
static MySQLContainer<?> mysql =
new MySQLContainer<>("mysql:8.4")
.withDatabaseName("order")
.withUsername("test")
.withPassword("test")
.withReuse(true); // 需要 ~/.testcontainers.properties 开启 reuse

@DynamicPropertySource // 把容器地址动态注入 Spring Environment
static void datasourceProps(DynamicPropertyRegistry registry) {
registry.add("spring.datasource.url", mysql::getJdbcUrl);
registry.add("spring.datasource.username", mysql::getUsername);
registry.add("spring.datasource.password", mysql::getPassword);
}

@Autowired
private OrderRepository repository;

@Test
void shouldFindByStatus() {
repository.save(new Order("A-1", OrderStatus.CREATED));
// TestEntityManager 让你可以精确控制 flush/clear,验证真实落库行为
assertThat(repository.findByStatus(OrderStatus.CREATED)).hasSize(1);
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// Web 层切片:只加载 Controller + MVC 基础设施,Service 用 @MockBean 替掉
@WebMvcTest(OrderController.class)
class OrderControllerTest {

@Autowired
private MockMvc mockMvc;
@MockBean
private OrderService orderService; // 不加载真实 Service 及其依赖链

@Test
void shouldReturnOrderJson() throws Exception {
when(orderService.getOrder(1L)).thenReturn(new OrderDTO(1L, "A-1", OrderStatus.CREATED));

mockMvc.perform(get("/orders/{id}", 1L).accept(MediaType.APPLICATION_JSON))
.andExpect(status().isOk())
.andExpect(jsonPath("$.orderNo").value("A-1"))
.andExpect(jsonPath("$.status").value("CREATED"));
}

@Test
void shouldReturn400WhenIdInvalid() throws Exception {
mockMvc.perform(get("/orders/{id}", "abc"))
.andExpect(status().isBadRequest());
}
}

测试切片(@WebMvcTest / @DataJpaTest / @JsonTest)的意义不只是”快”,更重要的是隔离:它强迫你想清楚每一层到底要测什么,避免写出一个 @SpringBootTest 起全套容器、然后什么都不敢改的巨型测试。

3.5 TDD 与测试可读性

TDD 的红—绿—重构循环,本质价值不在”先写测试”的仪式,而在强迫你在写代码前先定义”什么叫对”:规则明确、边界复杂的逻辑(金额计算、状态机、解析器)收益极高,探索性代码(UI、原型)硬套只会浪费时间。可读性上坚持 given-when-then 三段式与两条纪律:测试名要描述”什么条件下会发生什么”(shouldRejectOrderWhenRiskServiceSaysNo 远好于 testSubmit1);一个测试只验证一个行为。

3.6 覆盖率的陷阱与 CI 接入

覆盖率是发现盲区的工具,不是质量指标:100% 行覆盖也可能测不出任何逻辑错误——所有行都执行了,却没有一条断言。执着于把覆盖率从 82% 提到 85%,只会催生”调用一下但不断言”的垃圾测试。合理做法是把它当门禁(新增代码分支覆盖不低于 70%、整体不低于基线),同时审查测试内容本身。

1
2
3
4
5
6
# 生成覆盖率报告并作为门禁:规则写在 jacoco-maven-plugin 的 <rules> 里
mvn clean verify
# 报告在 target/site/jacoco/index.html,重点看 Missed 列与复杂度高的类

# CI 中的典型顺序(GitHub Actions / GitLab CI / Jenkins 通用)
mvn -B -U clean verify -DskipITs=false

CI 的最小闭环:push/PR 触发 → 编译 → 静态检查 → 单元测试 → 覆盖率门禁 → 打包。集成测试较慢,建议放在合入主干后或定时流水线。

四、日志与可观测性:线上问题的第一现场

4.1 日志门面演进与绑定机制

日志框架的历史是一部”填坑史”:System.out → Log4j 1.x → JCL → SLF4J → Logback / Log4j2。SLF4J 的定位是门面:代码只依赖 org.slf4j.Logger,运行期由 classpath 上的绑定器(slf4j-api + logback-classic 或 log4j-slf4j2-impl)路由到具体实现,好处是库作者不必替使用者决定日志实现。

绑定机制的关键点:SLF4J 2.x 用 ServiceLoader(org.slf4j.spi.SLF4JServiceProvider)查找实现,classpath 上出现多个绑定会告警并任选其一。因此必须排除多余绑定,尤其是 commons-logging——它靠运行时反射查找实现,行为诡异且与 SLF4J 体系冲突。标准做法是引入 jcl-over-slf4j(或 spring-jcl)桥接:把对 JCL 的调用重定向到 SLF4J,同时用 <exclusions> 排除真正的 commons-logging。

后端选型:Logback 是 Spring Boot 默认、配置简洁,一般业务系统首选;Log4j2 胜在无锁异步(Disruptor 环形队列)与无垃圾模式,适合高吞吐/低延迟场景;Log4j 1.x 已 EOL 且有安全漏洞,必须迁移。

4.2 日志级别规范与占位符

级别不是”重要程度”,而是“谁需要看、保留多久、能不能删”:给运维看的要报警、给开发排查的要保留、给调试用的生产可关。

级别 使用场景 生产是否开启 示例
ERROR 系统出错、业务流程无法完成、需要人工介入 是(通常告警) 支付回调失败且重试耗尽
WARN 可自愈的异常、降级触发、参数可疑但已容错 是 缓存miss率高、重试第2次成功
INFO 关键业务状态变更、启动/关闭、外部调用摘要 是(需控制量) 订单状态流转、服务启动耗时
DEBUG 排查问题所需的中间过程、出入参 否(按需临时开) 一次请求的完整参数
TRACE 极详细的框架内部流程 否 连接池借还连接
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 错误示范:字符串拼接 + 不必要的开销
log.debug("order detail: " + JSON.toJSONString(order)); // 即使 DEBUG 关闭,toJSONString 也执行了
log.info("user " + userId + " bought " + itemId); // 每次都创建新 String

// 正确示范:占位符 + 惰性求值
log.debug("order detail: {}", order); // 只在启用时才调用 toString()
log.info("user {} bought {}", userId, itemId);

// 需要计算开销大的内容时,用 isDebugEnabled() 守卫(或 Java 8+ 的 Supplier)
if (log.isDebugEnabled()) {
log.debug("big payload: {}", JSON.toJSONString(order));
}
// SLF4J 2.x 支持 Fluent API 与 Supplier,避免显式 if
log.atDebug().addArgument(() -> JSON.toJSONString(order)).log("payload: {}");

{} 占位符的原理是:MessageFormatter 格式化前先检查级别是否启用,不启用就直接返回,参数对象因此不会被 toString()。但参数在调用点就已求值装箱,JSON.toJSONString(order) 写在参数位置依然会执行——这就是必须用 isDebugEnabled() 守卫的原因。

4.3 日志格式设计与 MDC 链路追踪

一条好日志应”自解释”:一眼看出是哪次请求、哪个线程、哪个类、耗时多少。推荐 pattern:

1
%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] [%X{traceId:-}] %logger{36}:%L - %msg%n

%X{traceId:-} 取自 MDC(Mapped Diagnostic Context),底层是 ThreadLocal<Map<String,String>>,因此能”自动”给同一线程内的所有日志加上上下文——前提是入口(Filter / Interceptor)放入、出口清掉。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
@Component
public class TraceIdFilter extends OncePerRequestFilter {

@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
// 优先透传上游链路 ID(网关/SkyWalking/上游服务),没有则生成
String traceId = Optional.ofNullable(request.getHeader("X-Trace-Id"))
.filter(StringUtils::hasText)
.orElseGet(() -> UUID.randomUUID().toString().replace("-", ""));
MDC.put("traceId", traceId);
response.setHeader("X-Trace-Id", traceId);
try {
long start = System.nanoTime();
chain.doFilter(request, response);
// 统一记录访问日志:URI、状态、耗时
log.info("{} {} status={} cost={}ms",
request.getMethod(), request.getRequestURI(),
response.getStatus(), (System.nanoTime() - start) / 1_000_000);
} finally {
MDC.remove("traceId"); // 必须清理:线程来自线程池,不清理会导致 traceId 串号
}
}
}

MDC 跨线程的坑最容易踩:MDC 基于 ThreadLocal,线程池里的任务是另一个线程,MDC.get("traceId") 会是 null。解决办法只有一个——显式传递:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
// 方案 1:提交任务时拷贝上下文,任务内还原
public static Runnable wrap(Runnable task) {
Map<String, String> ctx = MDC.getCopyOfContextMap(); // 在主线程捕获
return () -> {
if (ctx != null) MDC.setContextMap(ctx);
try {
task.run();
} finally {
MDC.clear(); // 用完清掉,避免污染池中线程
}
};
}

// 方案 2(Spring):给线程池设置 TaskDecorator,对所有 @Async 生效
@Bean
public Executor taskExecutor() {
ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
executor.setCorePoolSize(8);
executor.setMaxPoolSize(32);
executor.setQueueCapacity(2000); // 必须有界
executor.setTaskDecorator(r -> {
Map<String, String> ctx = MDC.getCopyOfContextMap();
return () -> {
if (ctx != null) MDC.setContextMap(ctx);
try { r.run(); } finally { MDC.clear(); }
};
});
executor.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy());
executor.initialize();
return executor;
}

InheritableThreadLocal 看似能”自动”传给子线程,但在线程池下会失效(线程复用,只在创建时继承一次),还可能造成内存泄漏,不要用它自欺欺人。

4.4 异步日志:队列、丢弃策略与背压

日志输出涉及文件 IO(甚至网络),同步写会让业务线程阻塞在磁盘上。异步化把”格式化 + 写文件”搬到独立的 Appender 线程,业务线程只做一次入队。

异步日志的丢弃策略是很多人配置完就忘了看的关键项。Logback 的 AsyncAppender 默认 discardingThreshold = queueSize / 5:队列剩余容量低于 20% 时会静默丢弃 TRACE/DEBUG/INFO 事件,只留 WARN/ERROR——高峰期你最需要的 INFO 可能集体消失,且毫无提示。想要”不丢日志”就设 discardingThreshold=0,代价是队列满时业务线程被阻塞(背压传导);想要”绝不阻塞业务”就设 neverBlock=true,代价是直接丢弃。这是典型的延迟 vs 数据完整性取舍,必须显式决策,不能依赖默认值。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
<!-- logback.xml:异步日志 + 按大小时间滚动 + 丢弃策略显式配置 -->
<configuration>
<appender name="FILE" class="ch.qos.logback.core.rolling.RollingFileAppender">
<file>logs/app.log</file>
<rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
<fileNamePattern>logs/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern>
<maxFileSize>200MB</maxFileSize>
<maxHistory>15</maxHistory>
<totalSizeCap>10GB</totalSizeCap>
</rollingPolicy>
<encoder>
<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] [%X{traceId:-}] %logger{36}:%L - %msg%n</pattern>
</encoder>
<!-- 立即刷盘开关:true 保证不丢,但性能损失明显,生产通常 false -->
<immediateFlush>false</immediateFlush>
</appender>

<appender name="ASYNC" class="ch.qos.logback.classic.AsyncAppender">
<appender-ref ref="FILE"/>
<!-- 队列容量:太小容易丢,太大故障时会占用大量内存并积压陈旧日志 -->
<queueSize>8192</queueSize>
<!-- 0 = 不丢弃任何级别(队列满则阻塞业务线程,形成背压)
默认值 queueSize/5 = 剩余不足 20% 时丢弃 TRACE/DEBUG/INFO -->
<discardingThreshold>0</discardingThreshold>
<!-- false = 队列满时阻塞等待(保护日志完整性,但会拖慢业务)
true = 队列满时直接丢弃新事件(保护业务线程,丢日志) -->
<neverBlock>false</neverBlock>
<!-- 队列剩余容量低于此值时打印自身的 INFO 提示,便于发现日志瓶颈 -->
<includeCallerData>false</includeCallerData>
</appender>

<root level="INFO">
<appender-ref ref="ASYNC"/>
</root>
<!-- 关键业务日志单独输出到独立文件,避免被淹没 -->
<logger name="com.example.order" level="INFO" additivity="false">
<appender-ref ref="ASYNC"/>
</logger>
</configuration>

Log4j2 的性能优势来自架构差异:AsyncLogger 基于 LMAX Disruptor 环形缓冲区,用无锁(lock-free)的 CAS + 内存屏障取代 LinkedBlockingQueue 的 ReentrantLock;并支持无垃圾模式,复用 StringBuilder 与 LogEvent,运行时几乎不产生临时对象,把日志对 GC 的压力降到最低;Disruptor 的批处理唤醒(攒一批再消费)又显著减少了上下文切换。这就是”同样叫异步日志,吞吐能差数倍”的根本原因。

1
2
3
4
5
6
7
# 启用 Log4j2 全异步(全局 AsyncLogger),需引入 disruptor 依赖
java -Dlog4j2.contextSelector=org.apache.logging.log4j.core.async.AsyncLoggerContextSelector \
-Dlog4j2.asyncLoggerRingBufferSize=262144 \
-jar app.jar

# Log4j2 异步(混合模式,部分 logger 异步)
java -DLog4jContextSelector=org.apache.logging.log4j.core.async.AsyncLoggerContextSelector -jar app.jar

4.5 日志规范与可观测性三支柱

几条硬性规范:禁止在循环体内打日志(10 万次的循环能瞬间打满磁盘);异常必须打栈(log.error("xxx failed, orderId={}", id, e),异常对象作最后一个参数且不需要 {});敏感信息必须脱敏(手机号、身份证、密码、token),最好在 DTO 的 toString() 层统一处理,而不是靠调用点自觉。

可观测性三支柱各有分工,别用日志去干指标的活:日志(离散事件文本,回答”这一次发生了什么”,ELK / Loki)、指标(时序聚合数值,回答”整体健康度与趋势”,Micrometer + Prometheus + Grafana)、链路(带 span 的调用树,回答”一次请求慢在哪”,SkyWalking / OpenTelemetry)。Micrometer 是”指标门面”(类比 SLF4J),负责埋点并把数据暴露给 Prometheus 抓取;SkyWalking 通过 Java Agent 字节码增强实现无侵入链路追踪,适合已有系统低成本接入;OpenTelemetry 是正统一的行业标准,新项目建议直接采用。

五、性能调优方法论:先测量,再动手

5.1 性能问题的四个层次

优化失败最常见的原因是层次搞错了:在代码层抠字符串拼接,而真正的瓶颈是算法复杂度或一次多余的全表扫描。按收益从大到小分四层:

  1. 业务层:这功能真的需要吗?能否合并请求、异步化、用缓存换实时性?这一层往往是一个数量级的收益。
  2. 算法层:时间复杂度可接受吗?O(n²) 在 n=10 万时就是灾难;数据结构选型合理吗?
  3. 代码层:锁竞争、对象创建、集合扩容、序列化、日志、异常。
  4. JVM 层:GC 频率与停顿、堆大小、JIT 编译、内存布局。

5.2 优化的顺序与取舍

三条纪律:先测量再优化(凭直觉的优化 90% 是在改不相关的代码);先架构再代码(架构错了,微优化救不了);避免过早优化(可读性损失换来的 3% 提升不值得)。

延迟(Latency)与吞吐(Throughput)常常是矛盾的:批处理能提高吞吐但增加单个请求延迟;更细的锁能提高并发(吞吐)但增加锁开销(延迟)。优化前必须先明确目标——是”用户感受到的 P99 要低”,还是”系统整体能扛更多量”,两者最优解不同。

5.3 常用性能指标与压测要点

核心指标有四类:QPS/TPS(吞吐上限)、RT 均值(易被长尾掩盖,参考价值有限)、P99/P999(99%/99.9% 请求的耗时上限,直接决定用户感受,是真正的北极星)、资源指标(CPU/load、GC 次数与停顿、IO wait 与网络 RT——load 高于核数说明排队,Full GC 频繁说明内存或参数有问题)。

压测三条铁律:必须预热(JIT 未稳定前的数据毫无意义);梯度加压(逐步加压找拐点,而非一把梭到最大值看崩在哪);基准线与回归对比(每次优化记录同一套数据,才能量化收益、发现劣化)。

工具选择:JMeter(功能全、GUI 友好,适合复杂业务链路)、wrk/ab(轻量高压,适合纯 HTTP 基准)、Gatling(Scala DSL,报告漂亮,易纳入 CI 做性能回归)。

1
2
3
4
5
6
7
8
# wrk:12 线程 400 并发,持续 5 分钟,输出延迟分布
wrk -t12 -c400 -d300s --latency -s post.lua http://localhost:8080/orders

# ab:1000 个请求,并发 50
ab -n 1000 -c 50 http://localhost:8080/orders/1

# JMeter 非 GUI 模式(GUI 模式仅供调试脚本,压测必须用 CLI)
jmeter -n -t order-test.jmx -l result.jtl -e -o report/

5.4 火焰图与 Arthas:定位热点的两把刀

火焰图的原理是:以固定频率(如 99Hz)采样线程栈,按调用关系聚合”栈顶方法”。横轴是样本占比(越宽越耗时),纵轴是调用深度。看火焰图只看顶部:顶部出现宽平台,说明该方法自身在耗 CPU;顶部都是窄条而下面很宽,说明耗时在子调用里。注意它是 on-CPU 采样,看不到锁等待与 IO 阻塞——所以”火焰图很干净但接口依然慢”时,问题一定在阻塞上。

1
2
3
4
5
6
7
8
9
10
11
12
# async-profiler:低开销(<1%)、支持 Java 栈与 native 栈、无需改代码
# 采集 60 秒 CPU 事件,输出火焰图(HTML)
./profiler.sh -d 60 -e cpu -f flamegraph.html <pid>

# 采集锁竞争(定位 synchronized / ReentrantLock 争用)
./profiler.sh -d 30 -e lock -f lock.html <pid>

# 采集内存分配热点(找出哪里在疯狂创建对象)
./profiler.sh -d 30 -e alloc -f alloc.html <pid>

# perf(Linux 原生,需要 perf-map-agent 才能解析 Java 符号)
perf record -F 99 -g -p <pid> -- sleep 60 && perf script | FlameGraph/stackcollapse-perf.pl | FlameGraph/flamegraph.pl > out.svg

Arthas 的价值在于”无需改代码、无需重启”地观测线上方法级行为:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 查看最耗 CPU 的线程及其栈
thread -n 3

# 追踪方法内部每一层调用的耗时(>10ms 才输出),定位慢在哪个子调用
trace com.example.order.OrderService getOrderDetail '#cost>10' -n 5

# 观察方法的入参、返回值、异常(不用加日志)
watch com.example.order.OrderService getOrderDetail '{params, returnObj}' -x 3

# 反编译确认线上跑的代码版本,避免"代码改了但没生效"
jad com.example.order.OrderService

# 查看方法调用链路径
stack com.example.order.OrderService getOrderDetail

# 实时监控 JVM 内存/GC/线程
dashboard -i 2000

六、微基准测试与 JIT:为什么你的”性能测试”是假的

6.1 System.currentTimeMillis() 为什么测不准

1
2
3
4
5
6
// 一个典型的"错误基准":这段代码测出来的结果几乎没有任何意义
long start = System.currentTimeMillis();
for (int i = 0; i < 100_000; i++) {
String s = "" + i; // 结果从未被使用
}
System.out.println(System.currentTimeMillis() - start);

这段代码至少有六个致命问题:

  1. 死代码消除(DCE):JIT 发现 s 从未被使用、循环无副作用,会整个循环优化掉,你测的是 0 次执行。
  2. 常量折叠与循环展开:即使没被消除,编译器也可能提前算好可推导的表达式、展开或向量化循环体,测到的已不是你写的逻辑。
  3. 未预热:代码先解释执行,再由 C1 编译(快、优化少),热点方法才交给 C2(慢、优化激进)。前几十毫秒测的是解释器,后面才是编译后代码,两者可差几十倍。
  4. OSR 与去优化:循环中途被替换成编译版本、或因分支预测失败被”去优化”回解释执行,都会让单次结果剧烈波动。
  5. GC 干扰与内联阈值:循环创建的 10 万个对象触发的 GC 停顿被算进”执行时间”;方法体过大或层级过深则不内联,测的是调用开销而非真实逻辑。
  6. 操作系统噪声:CPU 频率调节、上下文切换、NUMA 调度、其他进程抢占,都会让单次测量不可复现。

结论很直接:任何”自己写循环 + 计时”的 Java 微基准都不可信。正确工具是 JMH(Java Microbenchmark Harness),它由 HotSpot 团队维护,专门为对抗上述 JIT 优化而设计:通过 @State 持有输入、通过返回值或 Blackhole 制造真实副作用、通过 @Warmup 完成预热、通过 @Fork 隔离不同编译结果、通过多轮迭代与统计消除噪声。

6.2 JMH 的正确用法

注解/类 作用
@BenchmarkMode / @OutputTimeUnit 测量模式(Throughput / AverageTime / SampleTime)与结果单位
@Warmup / @Measurement / @Fork 预热轮次、测量轮次、独立 JVM 进程数(隔离编译差异)
@State / @Param / @Setup 状态作用域、参数化输入、每轮准备与清理
Blackhole / @CompilerControl 消费结果防消除 / 强制或禁止内联做对照

6.3 完整示例:String 拼接 vs StringBuilder

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
package com.example.bench;

import org.openjdk.jmh.annotations.*;
import org.openjdk.jmh.infra.Blackhole;
import java.util.concurrent.TimeUnit;

@BenchmarkMode(Mode.AverageTime) // 关注单次操作耗时
@OutputTimeUnit(TimeUnit.MICROSECONDS)
@Warmup(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) // 预热:让 JIT 完成分层编译
@Measurement(iterations = 5, time = 1, timeUnit = TimeUnit.SECONDS) // 正式测量
@Fork(2) // 跑 2 个独立 JVM,避免单次编译偶然性
@State(Scope.Benchmark)
public class StringConcatBenchmark {

@Param({"10", "100", "1000"}) // 三种规模,跑出 3×2 的结果矩阵
private int count;

// 关键:数据从 @State 字段读取而非方法内常量,
// 否则 JIT 可能做常量折叠,把整段逻辑优化成一次赋值
private String[] parts;

@Setup(Level.Trial)
public void setup() {
parts = new String[count];
for (int i = 0; i < count; i++) {
parts[i] = "item-" + i;
}
}

@Benchmark
public String plusConcat() {
String result = "";
for (String p : parts) {
result = result + p; // 每次新建 StringBuilder + 新建 String:O(n^2)
}
return result; // 返回给 JMH,制造真实副作用,防止死代码消除
}

@Benchmark
public String builderConcat() {
StringBuilder sb = new StringBuilder();
for (String p : parts) {
sb.append(p);
}
return sb.toString();
}

@Benchmark
public String builderWithCapacity() {
// 预设容量:避免 StringBuilder 内部 char[] 多次 Arrays.copyOf 扩容
StringBuilder sb = new StringBuilder(estimateCapacity());
for (String p : parts) {
sb.append(p);
}
return sb.toString();
}

// 只消费不返回时用 Blackhole,JIT 无法推断其无副作用
@Benchmark
public void blackholeDemo(Blackhole bh) {
StringBuilder sb = new StringBuilder();
for (String p : parts) sb.append(p);
bh.consume(sb.toString());
}

private int estimateCapacity() {
int len = 0;
for (String p : parts) len += p.length();
return len;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<!-- JMH 依赖:注解处理器在编译期生成基准执行骨架,必须配置 annotationProcessorPaths -->
<dependencies>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-core</artifactId>
<version>1.37</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.openjdk.jmh</groupId>
<artifactId>jmh-generator-annprocess</artifactId>
<version>1.37</version>
<scope>test</scope>
</dependency>
</dependencies>
<!-- 或用 exec-maven-plugin 直接生成独立 jar,避免与测试 classpath 混用 -->
1
2
3
4
5
6
7
8
9
10
# 方式一:用官方 archetype 生成的骨架,打成可执行 jar 后运行(推荐,环境最干净)
mvn clean package
java -jar target/benchmarks.jar -rf csv -rff result.csv

# 方式二:IDE 直接运行需加 JVM 参数或保证注解处理器生效
# 查看可用基准
java -jar target/benchmarks.jar -l

# 只跑指定基准并输出更详细结果
java -jar target/benchmarks.jar 'StringConcatBenchmark.builderConcat' -wi 5 -i 5 -f 2

典型结论(JDK 17,count=1000):plusConcat 比 builderConcat 慢一到两个数量级,差距随 count 平方级扩大;builderWithCapacity 另有 10%~30% 收益(省掉扩容拷贝)。JDK 9 后 String 拼接被编译成 invokedynamic + StringConcatFactory,简单场景差距缩小,但循环内拼接仍必须显式用 StringBuilder——编译器无法跨迭代复用同一个 builder。

6.4 分层编译与逃逸分析

HotSpot 采用 C1 与 C2 两层编译,JDK 7 起默认开启分层编译:第 0 层解释执行并收集 profile;第 1~3 层由 C1 编译(快、优化少);第 4 层由 C2 基于 profile 做激进优化(内联、循环展开、向量化、逃逸分析),追求峰值性能。

内联的关键阈值(MaxInlineSize 默认 35 字节字节码、热点可达 325,FreqInlineSize 默认 325)可用 -XX:+PrintCompilation 与 -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining 观察。被内联后方法调用开销归零,这是”小方法”既好读又快的物理原因。

1
2
3
4
5
6
7
8
# 观察 JIT 编译过程:时间戳 编译ID 方法名 层级
java -XX:+PrintCompilation -jar app.jar | grep -i orderservice

# 查看内联决策(需要解锁诊断选项)
java -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining -jar app.jar

# 关闭分层编译做对照实验(不推荐生产使用,仅用于理解层级差异)
java -XX:-TieredCompilation -Xbatch -jar app.jar

逃逸分析(Escape Analysis)是 C2 的核心优化:对象若未逃逸出当前方法(不被返回、不赋给静态字段、不被其他线程看到),JIT 可做两件事——标量替换(拆成局部变量,栈上/寄存器分配,完全不占堆)与锁消除(去掉不可能竞争的 synchronized)。这解释了局部 new StringBuffer().append(...) 的同步开销为何可能为零。但它有条件:对象一旦被返回、放进集合或方法体超出内联阈值,优化立刻失效,别指望它抵消不必要的对象创建。

七、常见性能陷阱与优化清单

7.1 集合、字符串、IO 与 JVM

陷阱 现象 原因 验证方法 修复方案
集合未设初始容量 插入卡顿、GC 升高 多次 resize 与 Arrays.copyOf JMH、-e alloc 采样 预设 expected/0.75f+1
循环内 String + 数据量一大就超时 O(n²) 拷贝 JMH(见 6.3) StringBuilder + 预设容量
BigDecimal 滥用 CPU 高、GC 频繁 不可变对象,每次运算新建 火焰图见 BigDecimal.add 中间计算用 long 分
日志字符串拼接 关闭 DEBUG 仍变慢 参数在调用点求值 火焰图 占位符 + isDebugEnabled
无缓冲小块 IO / 逐条写 吞吐极低 每次都是系统调用或刷盘 strace、iostat 8KB 缓冲、批量提交
大对象进老年代 / Full GC 频繁 停顿数秒 超 PretenureSizeThreshold 或泄漏 GC 日志 + MAT 支配树 拆分对象、调新生代比例
堆外/元空间增长 容器被 OOM Kill 类加载器泄漏、DirectBuffer 未释放 jcmd VM.native_memory 限制并排查类加载器泄漏

7.2 并发

陷阱 现象 原因 验证方法 修复方案
锁粒度过粗 并发上不去 串行化比例高 -e lock 采样 缩小同步块、改用分段/读写锁
伪共享 False Sharing 多核扩展非线性 无关变量落在同一缓存行,MESI 反复失效 JMH + @Contended 对照 填充或 @Contended
ThreadLocal 未 remove 脏数据、内存泄漏 线程池线程复用 MAT 看 ThreadLocalMap finally 中 remove()
无界队列 高峰期 OOM LinkedBlockingQueue 默认无界 堆 dump 设有界队列 + 拒绝策略
线程池参数拍脑袋 CPU 空转或排队 核心线程数与任务类型不匹配 监控队列与活跃数 CPU 密集≈核数,IO 密集可放大
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 伪共享:两个独立的 AtomicLong 若相邻,会落在同一 64 字节缓存行,
// 两个线程分别修改它们时,会互相使对方的缓存行失效(MESI 协议),性能甚至低于单线程。
public class CounterWithPadding {
// JDK 8:sun.misc.Contended
// JDK 9+:jdk.internal.vm.annotation.Contended(需 -XX:-RestrictContended 或 --add-exports)
@jdk.internal.vm.annotation.Contended
private volatile long valueA;
@jdk.internal.vm.annotation.Contended
private volatile long valueB;

// 不使用注解时的手工填充方案(JDK 8 常见写法)
public static class PaddedAtomicLong extends java.util.concurrent.atomic.AtomicLong {
// 对象头(12~16B) + value(8B) + 前后各 7 个 long 填充,确保独占缓存行
private volatile long p1, p2, p3, p4, p5, p6, p7 = 0L;
public long sumPaddingToPreventOptimization() {
return p1 + p2 + p3 + p4 + p5 + p6 + p7;
}
}
}
// 运行对照实验:
// java -XX:-RestrictContended -jar benchmarks.jar FalseSharingBenchmark

7.3 数据库与网络

陷阱 现象 原因 验证方法 修复方案
N+1 查询 接口 RT 高、DB CPU 高 循环内逐条查库 链路看 SQL 调用次数 批量 IN 查询 + 内存组装
索引缺失/失效 慢 SQL 隐式类型转换、函数包裹索引列、最左前缀失效 EXPLAIN、慢查询日志 加联合索引、改写 SQL
连接池过小 吞吐上不去 连接成为瓶颈 监控 getConnection 耗时 按 核数*2+磁盘数 估算后压测
大事务 锁等待、主从延迟 事务内含远程调用/大循环 SHOW ENGINE INNODB STATUS 缩小事务边界,异步化
序列化开销 CPU 高、包体大 JSON 反复序列化、每次 new ObjectMapper 火焰图见 ObjectMapper 复用单例、考虑二进制协议
未复用 HTTP 连接 RT 抖动、TIME_WAIT 堆积 短连接每次握手/TLS `netstat -an \ grep TIME_WAIT` 连接池 + Keep-Alive

7.4 完整案例:订单详情接口 P99 从 800ms 到 80ms

现象:大促压测中订单详情接口 TPS 仅 120,P50 60ms 但 P99 高达 800ms,CPU 只用了 40%,堆平稳、无 Full GC。

第一步:看链路分布。 SkyWalking 显示一次请求内 DB 调用 220 次,90% 落在 OrderItemMapper 与 OrderAddressMapper;但 DB 侧单次查询平均仅 0.8ms。

第二步:定位热点。 arthas trace 显示 getOrderDetail 内部 buildItems() 累计 520ms、被调用 40 次;火焰图上除 JDBC 外,fastjson 序列化占了 18%。

第三步:根因(四个叠加问题):① N+1 查询——buildItems() 循环调用 selectByOrderId,40 笔订单放大成 220 次 DB 调用;② 日志参数提前求值——log.debug("detail: {}", JSON.toJSONString(detail)) 在未开启 DEBUG 时仍执行全量序列化,正是那 18%;③ 索引缺失——order_item 只有 order_id 单列索引,WHERE order_id=? AND status=? 未命中联合索引导致回表;④ 每次 new ObjectMapper(),类加载与配置构建开销巨大。

第四步:优化动作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 优化前:循环查库 + 日志全量序列化 + 每次 new ObjectMapper
for (Order order : orders) {
List<OrderItem> items = itemMapper.selectByOrderId(order.getId()); // N+1
order.setItems(items);
log.debug("detail: {}", JSON.toJSONString(order)); // 无条件序列化
}
String json = new ObjectMapper().writeValueAsString(result); // 每次新建

// 优化后:批量查询 + 占位符惰性输出 + 静态单例 + 索引
List<Long> ids = orders.stream().map(Order::getId).toList();
Map<Long, List<OrderItem>> itemsByOrder = itemMapper.selectByOrderIds(ids) // 一次 IN 查询
.stream().collect(Collectors.groupingBy(OrderItem::getOrderId)); // 内存分组
for (Order order : orders) {
order.setItems(itemsByOrder.getOrDefault(order.getId(), List.of()));
log.debug("orderId={}, itemCount={}", order.getId(), order.getItems().size()); // 只输出标量
}
String json = JsonHolder.MAPPER.writeValueAsString(result); // 静态单例

1
2
-- 新增联合索引,覆盖常用过滤条件,避免回表
ALTER TABLE order_item ADD INDEX idx_order_status (order_id, status);

同时把 HikariCP 的 maximumPoolSize 由 10 调到 30(压测后确定),并给字典数据加了 Caffeine 本地缓存。

第五步:验证数据(同一脚本、同样预热 3 分钟):

指标 优化前 优化后 变化
P99 800ms 82ms -89.8%
P50 60ms 24ms -60%
TPS 120 1150 +858%
单次请求 DB 调用数 220 6 -97%
CPU 使用率 40%(瓶颈在等待) 62%(计算被有效利用) 更健康的资源利用

这个案例最值得记住的不是那几行代码,而是推理链:先看分布(别只看均值)→ 用链路找”调用次数异常”→ 用 trace/火焰图定位具体方法 → 用压测量化每处改动 → 记录基准线。全程没有一步是”我觉得这里可能慢”。

八、收官:Java 工程师的能力地图

8.1 七维自评表

维度 合格(能干活) 进阶(能设计) 精通(能攻坚)
语言基础 语法熟练、API 会查 理解泛型擦除、Lambda 实现 读过 JDK 核心源码,理解设计取舍
并发 会用线程池、synchronized 理解 JMM、AQS、并发容器 能设计无锁结构、定位死锁与伪共享
JVM 会配 -Xmx 理解内存结构、GC 算法 能读 GC 日志、做逃逸分析调优
框架 会用 Spring Boot 理解 IoC/AOP/自动配置 能定制 Starter、排查框架级泄漏
中间件 会用 Redis/MQ/MySQL 理解持久化、消息可靠性、索引 能设计分布式锁、幂等、最终一致
工程 会写单测、能打 jar 规范落地、CI/CD、多模块 能搭建研发流程与质量门禁
架构 能按图实现 能做模块拆分与选型 能做容量规划、容灾与演进路线

8.2 从”会写”到”写好”的三个阶段

会写:语法过关,能把需求翻译成代码,靠搜索与复制拼出可运行的程序——不确定代码为什么能跑,出 bug 靠打印日志猜。

写好:开始关心边界、异常、并发与可读性;知道每个 API 的代价;能写测试、能读懂别人的代码;意识到”能跑”和”能维护”是两件事。

写对:能在需求阶段发现设计漏洞,能预判性能瓶颈与故障模式,能用量化的方式证明改动是好的,能把个人能力沉淀成团队的规范与工具。

判断自己在哪一阶段只需一个问题:你上一次定位线上问题,是靠猜还是靠证据?

8.3 持续进阶的路径

  • 源码阅读:带着问题读(”HashMap 怎么扩容?”)。方法:写最小复现 → 打断点跟进 → 画调用链 → 自己复述一遍。读不懂处正是知识缺口。
  • 参与开源:从提 issue、改文档、补测试做起,逐步到修 bug、提 feature。它给的不只是技术,还有”让别人看懂你的代码”的纪律。
  • 性能优化实践:别只看文章,自己复现——用 JMH 跑一遍本文例子,用 async-profiler 给自己的项目画张火焰图。
  • 写:把学到的写成文章或分享。能讲清楚,才是真的懂。

8.4 本系列 18 篇完整索引

篇号 标题 一句话主旨
一 (一)开发环境搭建与第一个程序 JDK/JRE/JVM 关系与编译运行全流程
二 (二)基础语法全解——数据类型、运算符与流程控制 基本类型、类型转换与流程控制
三 (三)面向对象基础——类、对象、封装、继承与多态 三大特性与重写/重载的本质区别
四 (四)面向对象进阶——抽象类、接口、内部类与枚举 接口取舍、内部类实现、枚举本质
五 (五)字符串与常用类——String、包装类、日期时间与 BigDecimal 常量池、不可变性与精确计算
六 (六)异常处理机制——异常体系、try-with-resources 与最佳实践 异常链与资源自动关闭原理
七 (七)泛型与类型系统——通配符、类型擦除与 PECS 原则 擦除代价、通配符与桥接方法
八 (八)集合框架(上)——List、Set、Queue 与迭代器机制 线性表取舍、fail-fast、队列选型
九 (九)集合框架(下)——Map 家族与 HashMap 源码剖析 哈希冲突、树化与扩容机制
十 (十)IO 与 NIO——字节流、字符流、序列化与 NIO 三大组件 IO 模型与 Buffer/Channel/Selector
十一 (十一)反射、注解与动态代理——框架底层的三块基石 元注解与 JDK/CGLIB 代理对比
十二 (十二)并发编程基础——线程、JMM、synchronized 与 volatile 三大特性、happens-before、锁升级
十三 (十三)JUC 核心工具——AQS、Lock、原子类与并发容器 AQS 骨架、CAS/ABA、LongAdder 分段
十四 (十四)线程池与异步编程——ThreadPoolExecutor、CompletableFuture 与 ForkJoin 参数决策链、拒绝策略与异步编排
十五 (十五)JVM 内存结构与类加载机制——从双亲委派到对象布局 运行时数据区、对象布局与双亲委派
十六 (十六)JVM 垃圾回收与线上调优——GC 收集器、参数与 OOM 排查 三色标记、收集器选型与 GC 日志分析
十七 (十七)Java 8~21 新特性全景——Lambda、Stream、Record 与虚拟线程 Lambda 原理、Stream 惰性与新语法糖
十八 (十八)工程化与性能调优实战——构建、测试、日志与诊断工具箱 依赖治理、测试分层、日志与调优方法论

8.5 不同阶段读者的阅读建议

  • 刚入门:按 1→12 顺序读,重点看二、三、五、八、九、十二;看不懂的并发与 JVM 部分先跳过,写完两三千行代码再回来,感受完全不同。
  • 有一两年经验:重点看七、十、十一、十三、十四,把”会用”补成”知道为什么”;同时把本篇的测试与日志规范直接落地到当前项目——这是性价比最高的一步。
  • 准备进阶/面试:精读九、十二、十三、十四、十五,配合 JDK 源码与本文的 JMH、arthas 实操;能画出 HashMap 扩容、AQS 排队、GC 分代这三张图,就超过了多数候选人。
  • 已在带团队:重点看本篇一、二、三、四章和第七章的案例推理链,把它们变成团队的流水线、规范与质量门禁——个人能力乘以团队,才是真正的杠杆。

8.6 写在最后

十八篇写到这里,从 Hello World 到火焰图,从 for 循环到伪共享,从 System.out 到可观测性三支柱——这条路上真正难的从来不是某个知识点,而是把知识变成判断力的过程。

优秀与一般的分水岭,往往不在”会不会用某个框架”,而在几个很朴素的习惯:性能问题先测量而不是先猜;改完代码用一个能复现的用例证明它真的变好了;写日志时想着三个月后半夜被叫起来看这段代码的人;以及对”它为什么是这样”始终保有一点不甘心。

Java 走过近三十年,也许不再是最新潮的语言,但它依然是把”工程化”做得最彻底的生态之一——严谨的类型系统、成熟的构建体系、深厚的诊断工具链、庞大的工业级实践。这套东西教会你的是一种对复杂系统的敬畏与掌控方式,它不会因为换一门语言而失效。

愿你在写出第一个能跑的程序之后,还能写出第一个让别人放心改的程序。这条路没有终点,但每一步都算数。感谢一路读完这个系列的你,我们江湖再见。