mvn compile 一卡半小时,这种事情遇到一次就够记一辈子。早上我照常敲下mvn clean compile -DskipTests,然后去接水,回来发现光标还停在 "Building" 那一行。三分钟、五分钟、十五分钟,进度条纹丝不动,风扇倒是越转越响。这项目跑了一整年的构建,怎么说卡就卡了?换做以前,我大概率会直接 Ctrl+C 重跑,或者怀疑是 Maven 仓库抽风。但这回我没有动手,因为我想先搞清楚一件事:它到底是"死了",还是在"瞎忙"。后来的结论让我很意外——这次卡住的元凶,不是网络,不是依赖解析,也不是注解处理器,而是一个藏得很深的类型不匹配编译错误。javac 为了这个错误在类型推断里烧掉了二十多分钟 CPU,最后才慢悠悠吐出一句看起来普普通通的编译错误。这篇就把完整的定位思路、排查工具和修复过程都捋一遍,遇到同类问题可以直接照着抄。
1. 半小时的CPU不是白烧的:先把"卡住"定性
1.1 先看现象,再谈猜测
一遇到构建卡住,很多人第一反应是去查 Maven 仓库通不通、公司内网代理有没有挂。我这次也差点走了这条路,但多留了个心眼:top一开,发现那个 java 进程的 CPU 占用稳在 150% 上下,不是偶尔跳一下,是持续压满。
这个信息很关键。一个真正"挂死"的进程,比如在等锁、等 IO、或者被 suspend,CPU 使用率通常会掉到接近 0。而 CPU 在疯狂转,说明进程根本没死,它正在干活,只是干的活对我们来说是黑盒。所以这次的问题不是"卡住",准确说是"慢到让人以为是卡住",而且很可能是在一个错误方向上消耗算力。
当时我还顺手看了 IO 和网络。iostat里磁盘读写很平稳,没有任何异常的大刷盘;lsof -p PID看了下进程打开的网络连接,也没有大量等待中的 socket。这一下就把"依赖下载卡住"和"磁盘 IO 瓶颈"两个大嫌疑排除了。剩下的可能性基本集中在三块:注解处理器、代码生成器、以及 javac 本身的类型检查。
1.2 三板斧:top、jstack、jstat
排查这种问题,我最常用的三样工具是top、jstack和jstat,它们分别回答三个问题:进程在干什么、线程在执行什么代码、内存和 GC 有没有异常。
top负责定位进程 PID 和整体 CPU/内存情况,想知道哪个线程在烧 CPU 就用top -H -p PID,把线程 ID 记下来,后面转十六进制就能跟 jstack 里的线程对上号。jstack PID直接导出所有线程栈,看 JVM 各线程到底停在哪个调用栈上。jstat -gcutil PID 1000则是看 GC 频率和堆占用,如果 Old 区一直涨、Full GC 不断,那就是内存问题,如果 GC 很平静而线程堆栈不变,就基本是业务计算在死循环或者做超重计算。
这三样东西说起来都是基础操作,但真到现场,很多人能 hold 住top,却忘了jstack其实比重启更快。我这次是核心线程栈抓了两份,间隔 30 秒,发现两份栈惊人地相似,线程都安稳地躺在 javac 的调用链里。这个"两次快照几乎一样"本身就是情报:它排除了很多"偶发因素",说明 javac 正在稳定地重复做某件耗时的事。
1.3 第一份线程栈:javac在干活,且是蠢活
线程栈原文长这样:
"main" #1 prio=5 os_prio=31 cpu=145208.30ms elapsed=1952.20s tid=... nid=0x1a03 runnable java.lang.Thread.State: RUNNABLE at com.sun.tools.javac.comp.Attr.attribTree(Attr.java:598) at com.sun.tools.javac.comp.Attr.visitApply(Attr.java:1906) at com.sun.tools.javac.comp.Attr.visitLambda(Attr.java:2398) at com.sun.tools.javac.comp.Attr.attribTree(Attr.java:598) at com.sun.tools.javac.comp.Attr.attribExpr(Attr.java:650) at com.sun.tools.javac.comp.Attr.attribStat(Attr.java:684) at com.sun.tools.javac.comp.Attr.visitBlock(Attr.java:1163) ...说句大白话:javac 正窝在Attr这个类型检查组件里,反复做表达式属性分析。堆栈里一堆visitLambda、visitApply、attribTree,说明它正在处理某个包含 lambda 和方法调用的表达式,而且在这个地方不肯出来。我当时的内心 OS 是:这编译器怕不是陷入了某个大型推断里面。
注意:看到 javac 在
Attr/Check里转圈,先别杀进程。抓一次线程栈再重跑一次,如果两次都停在同一个类、同一个包路径,基本就是确定性复现。这时候修代码比重启构建有价值得多。
2. 真相慢慢浮出水面:类型不匹配如何骗过所有人
2.1 javac的类型推断其实是在做约束求解
搞清楚 javac 为什么会这么慢,得先理解它遇到泛型方法时在干什么。你写一句bind("onUpdate", PayloadConverter::toModel),编译器要先根据参数类型推断出泛型 T 的具体类型。这个过程不是简单看一眼类型签名就行,它要根据方法引用、lambda 的形参、返回值、目标类型、通配符边界,生成一堆类型约束,再统一求解。
正常情况下,约束求解是很快的,毫秒级别就能完成。但某些代码结构会让候选解空间变得巨大:比如嵌套的泛型方法、方法引用 + 泛型 + 通配符叠加、返回类型又泡在Optional、CompletableFuture、Function这种容器里。javac 的推断算法在某些路径上会做回溯式尝试,一个约束不满足,就换一组类型变量继续试。这个"试"的行为,如果约束设计很差,会出现近似指数级的爆炸。
很多人在命令行里看到一行incompatible types,觉得就是个普通编译错误。但这次经历让我彻底改观:同一类错误,在约束空间小的时候是秒报,在约束空间失控的时候,编译可以先折磨你半小时再报错。编译器不是故意报复你,它只是把所有可能都排除了,才确定"类型不匹配"这个最终答案。
2.2 一个泛型写歪的绑定类,差点让我怀疑编译器
出问题的代码是这样的。项目里有一个绑定注册类,负责把外部事件映射到 Java 处理器上:
public class BindingRegistry { public static <T> void bind(String signal, Function<EventPayload, T> handler) { // 注册逻辑,省略 } }调用侧原本应该这样写:
BindingRegistry.bind("onUpdate", (EventPayload p) -> PayloadConverter.toModel(p));但实际代码里有人图省事,写成了方法引用,而且PayloadConverter.toModel的签名已经变了:
public static CompletableFuture<Model> toModel(EventPayload payload) { ... }于是调用变成了:
BindingRegistry.bind("onUpdate", PayloadConverter::toModel);这里能一眼看出问题吗?能。但问题在于,这段调用被包在一个很深的流式处理链里,外面套了Optional.map、再套flatMap,最后还有一个泛型通配符的转换方法。javac 在推断T时,一方面要考虑Function<EventPayload, T>的返回值是否兼容CompletableFuture<Model>,另一方面还要跟外层泛型做统一,于是它开始了一轮又一轮的类型变量尝试,每次都发现边界对不上,但又不肯马上停止,因为外层还给了它别的可能性。最终,在烧了二十多分钟 CPU 后,它认输了,输出了一行"类型不匹配"。
我后来把这段代码单独抽出来做最小复现,发现去掉外层流式调用后,编译报错在一秒内就会出现。这说明问题不在某个单点类型上,而在类型推断的搜索空间被畸形泛型放大了。
2.3 QML编译错误为什么会藏在Maven构建里
这里还得交代一下项目背景。这个仓库是统一构建,既有 Java 后端模块,也有一个基于 Qt/QML 的客户端界面模块。客户端不是靠 IDE 手工编译的,而是在 Maven 的生命周期里,通过exec-maven-plugin拉起qmlcachegen来预编译和校验 QML 文件。也就是说,mvn compile这一步,既包含 javac 的 Java 编译,也串着一个 QML 编译检查环节。
当天的构建日志里,其实还藏着一行第三方插件的输出:
[ERROR] qmlcachegen: /.../qml/MainPage.qml:42:17 Type Error: Cannot assign [QVariant(QString)] to [int]这行错误被插件缓冲住了,没有立刻打到终端,导致 Maven 看起来像是卡死在某个模块。点开那个 QML 文件,42 行附近是一个属性绑定:
property int status: 0 // 某处在写: status = someJsStringValue // someJsStringValue 是字符串,而 status 声明为 int,类型不匹配QML 侧这个错误本身很直白,但因为 exec 插件把子进程的输出吞了、没有及时刷新,它变成了第二个"假卡死"点。所以我后来说,这次排查看似在解决"构建卡住",实际是同时撞上了 Java 和 QML 两个编译错误,它们通过构建脚本的健壮性问题叠加到了一起。
3. 定位过程全记录:从盲人摸象到一击命中
3.1 Maven日志里的第一条线索
回过头看,Maven 日志里第一行有价值的线索在"卡住"之前的几秒就出现了。我这次跑的是mvn clean compile -DskipTests,日志停在:
[INFO] --- maven-compiler-plugin:3.11.0:compile (default-compile) @ backend ---之后就是漫长的静默。注意,它没有卡在 dependency resolution,没有卡在 resources 阶段,而是卡在 compiler 插件本身。这基本就能把问题圈定在 javac 内部。很多人忽略了日志里"当前执行到哪个插件"这个信息,看到卡住就立刻怀疑网线,其实 Maven 输出已经帮你把嫌疑人缩小到了最小范围。
我当时做的第二件事是加参数复跑。我用了mvn clean compile -DskipTests -X,想看看 debug 日志能不能暴露更多信息。结果-X只带来了一大堆不痛不痒的插件调试信息,真正有用的还是线程栈。这里多说一句:-X不是万能的,它主要暴露 Maven 插件交互层面的细节,javac 内部的黑盒行为它管不着。
3.2 让javac把心里话全说出来
既然要对付 javac 的黑盒,就得用 javac 自己的诊断开关。我临时给maven-compiler-plugin加了一段配置:
<plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-compiler-plugin</artifactId> <version>3.11.0</version> <configuration> <compilerArgs> <arg>-Xdiags:verbose</arg> <arg>-Xmaxerrs:1000</arg> <arg>-Xmaxwarns:1000</arg> </compilerArgs> </configuration> </plugin>-Xdiags:verbose会给编译错误附带类型推断的上下文,比如"应为 X,实际为 Y"会展开成更完整的路径;-Xmaxerrs默认值是 100,如果错误数量超过这个数,剩下的会被吞掉,这在大量泛型报错时非常容易造成"日志里啥都没有"。把上限调到 1000,至少不会因为早期报错太多而把真正有价值的错误淹掉。
改完配置再跑,这次虽然还是卡了十几分钟,但最终输出的错误信息比第一次丰富了一个量级。它明确告诉我是BindingRegistry.bind的方法引用参数不匹配,后面还跟着一大段类型推断上下文。看到这行,我基本确定真凶就是 Java 侧的类型推断爆炸。
3.3 收到的"延迟报错"
完整报错原文长这样:
[ERROR] /.../src/main/java/com/example/Application.java:[45,55] incompatible types: no suitable method found for bind(java.lang.String,com.example.PayloadConverter::toModel) method BindingRegistry.<T>bind(java.lang.String,Function<EventPayload,T>) is not applicable (argument mismatch; bad return type in method reference found: CompletableFuture<Model> required: Model)这个报错在语义上没有任何歧义:方法引用期望返回Model,但toModel返回的是CompletableFuture<Model>。可它偏偏在构建卡了半小时之后才出现,原因就是 javac 在匹配泛型方法时,没有第一时间把这个不匹配当成"失败终止信号",而是尝试了各种其他推断候选。从工程角度讲,这个行为本身是编译器实现的一种取舍,但对使用者来说,体验就变成了一场漫长的等待。
提示:如果遇到"编译很久才报一个很简单的类型错误",优先怀疑外层泛型把推断空间撑大了。处理办法是给关键调用显式指明类型参数,比如
BindingRegistry.<Model>bind(...),这能让编译器快速收敛,不再做无用穷举。
3.4 修复前的最后确认:用最小复现验证假设
拿到报错之后,我没有立刻动手改业务代码,而是先做了个最小复现来验证"类型推断爆炸"这个假设。我复制了BindingRegistry、PayloadConverter和一个简化的调用链到一个空的 Java 项目里,外层用一个Function和stream组合复现原始结构,然后分别计时编译:
- 完整原始结构:编译耗时超过 10 分钟,命令行窗口毫无输出
- 去掉外层 stream 包装:几秒内就报出类型不匹配
- 换用显式类型参数:立刻编译失败,同样秒级
三个结果一出来,假设基本坐实。这一步很关键,因为它把"业务代码玄学问题"变成了一个可复现、可解释、可回归验证的工程问题。后面修代码的时候,我心里是有底的。
4. 修复与验证:从半小时到四十秒
4.1 改代码的两种姿势
Java 侧的修复,最干净的做法是让调用方显式表达意图,不再依赖编译器的自动推断。我把原来那行"装聪明的写法"改成了 lambda 加显式转换:
BindingRegistry.bind("onUpdate", (EventPayload payload) -> PayloadConverter.toModel(payload) .thenApply(Model::fromFuture) .join());这里我顺手处理了CompletableFuture<Model>的结果。如果业务上真的需要异步,那BindingRegistry的泛型签名本身就该改成Function<EventPayload, CompletableFuture<T>>,而不是让调用方捂着类型硬撑。但这次需求场景要求同步返回,所以选择在调用侧完成类型收口。
QML 侧同样简单,属性类型跟赋值数据类型保持一致:
property string status: "0" // 赋值处: status = someJsStringValue // 类型一致了或者反过来,把赋值处转成 int。关键原则是:声明类型和运行时赋值类型必须一致,QML 编译器在这点上比 Java 还严格,它不做隐式转换。
4.2 用 mvn clean compile -DskipTests 做回归
代码改完,我没急着跑完整项目,先做了一次干净的编译回归。命令还是熟悉的mvn clean compile -DskipTests,这次我把时间掐了表:
real 0m39.742s user 0m32.101s sys 0m5.418s从半小时级别降到 40 秒,这才是这个项目正常该有的水平。这里多说一句关于参数的小事:很多人会被网上流传的mvn clean compile -dskiptest带偏,注意 Maven 属性参数大小写敏感,正确写法是-DskipTests,小写的-dskiptest会被当成一个普通自定义属性传给 JVM,surefire 根本读不到它,测试照样执行。我看到这个写法在搜索热词里出现频率不低,顺手提醒一下。
之后我再跑了一次全量构建,包含 QML 模块的那一步,也顺利通过。qmlcachegen的错误消失后,Maven 的输出不再被缓冲卡顿,构建日志一路刷到底,整个过程流畅得像换了个项目。
4.3 给构建加几条"防呆护栏"
修复只是开始,我更关心的是这类问题下次能不能更早暴露。我做了几件小事:
第一,在 CI 的编译命令里固定加上-Xdiags:verbose和-Xmaxerrs:0,让编译器在出错时尽量多吐信息,而不是把错误吞进默认上限里。第二,给 Maven 构建加了一个超时阈值,超过 10 分钟直接判定失败,避免"看似卡死实则慢死"的模糊状态。第三,在团队代码规范里加了一条:泛型方法的调用尽量不要依赖完全自动的推断,复杂场景显式写明类型参数。
另外,对于 exec 插件拉起外部工具的场景,我建议在插件配置里把输出实时转发到 Maven 日志,不要等进程结束再一次性 flush。这次 qmlcachegen 的错误被缓冲住,就是没设置好输出转发导致的。
5. 其他"假卡死"和真排查清单
5.1 常见假卡死场景对照表
实战里我见过太多"假卡死",列个表方便排查时对照:
| 现象 | CPU 特征 | 线程栈/日志特征 | 处理路径 |
|---|---|---|---|
| 依赖下载慢/镜像抖动 | 低 | 卡在plexus/HTTP 相关栈 | mvn -o离线模式,或换内网镜像 |
| javac 类型推断爆炸 | 高且持续 | 栈定位在com.sun.tools.javac.comp.* | 检查泛型调用,显式类型参数 |
| 注解处理器死循环 | 高 | 栈里出现 Lombok/MapStruct 处理器 | 排查处理器版本,或者先禁用验证 |
| exec 插件吞输出 | 低 | 子进程已结束但插件不退出 | 配置输出实时转发,修正 successCodes |
| 测试进程没退出 | 中低 | surefire 栈里出现非 daemon 线程 | 检查测试里的线程池/锁,杀掉挂起线程 |
| 杀毒/文件索引扫描 | 中 | 进程在用户态和内核态切换频繁 | 把本地 Maven 仓库加入白名单 |
这张表不是万能药,但覆盖了大部分"mvn compile 卡住"的常见现场。排查时先对应特征,效率会高很多。
5.2 按这个顺序排查,十分钟内给出结论
遇到构建卡住,我现在的固定流程是:
top -H -p PID看 CPU 分布和线程 ID,确认是在计算还是等待。jstack PID抓两份线程栈,间隔 30 秒以上,看主线程栈是否稳定在一个位置。- 根据栈帧判断是 javac、注解处理器、surefire 还是 HTTP 客户端。
- 如果是 javac 且栈在
Attr/Check,大概率是类型推断问题;加-Xdiags:verbose和-Xmaxerrs重跑,让报错信息完整暴露。 - 如果栈在 HTTP/网络相关包,检查 Maven 仓库连通性,必要时切离线模式。
- 如果栈在 exec 插件相关,检查外部命令是否已经结束,查看插件输出缓冲配置。
这套流程跑下来,就算不能立刻修好,也能在十分钟内把问题的类别界定清楚,避免无意义地反复 kill 和重跑。
5.3 几点真实心得
这次排完故障,我心里最深的感触是:构建工具报错的方式会骗人,"卡住"不一定代表死锁,也可能是编译器在一个错误方向上燃烧算力。遇到这种情况,别急着杀进程,先抓线程栈,花三分钟看它到底在干什么,往往比盲目重跑半小时有价值得多。
另外,类型不匹配这类编译错误,真的不能只把它当成"改个类型就好"的小事。类型推断在现代 Java 里承担着大量隐式逻辑,一个歪掉的泛型签名,轻则报错,重则让构建时间以分钟计膨胀。我现在的习惯是:凡是泛型方法调用变得复杂,就显式给出类型参数;凡是 Maven 构建突然慢得反常,就先怀疑 javac 的约束求解,而不是怀疑网络。
最后再说一条:构建要塞尽量做好输出转发和超时告警。那些"静默卡住"的构建之所以难排查,很大程度是错误信息被工具层吞了。让日志在第一时间露出来,很多问题其实一眼就能看到。