
上周同事把仓库权限开给我我 clone 下来第一件事就是敲./gradlew build然后屏幕就停在 Downloading https://services.gradle.org/distributions/gradle-8.7-bin.zip 上十几分钟没动静。终端里没有进度条没有报错只有光标在闪。等它终于动起来又抛了一个编码错误接着是依赖解析失败最后才轮到真正的编译错误。整个过程加起来四十多分钟其中真正跟 Java 代码有关的不到两分钟。这就是 Gradle 编译 Java 项目最真实的体验难点从来不在怎么写 Java而在怎么让这套构建工具在你的机器上顺利跑起来。版本对不对得上、依赖从哪拿、编码是不是一致、工具链有没有配错任何一个环节出问题都会让编译在走出第一步之前就失败。这篇内容我想把 Gradle 编译 Java 项目这件事从头捋一遍包括安装路线的取舍、最小工程的骨架长什么样、依赖解析的机制、编译失败的排查链路、多模块下的构建提速以及最后怎么拿出一个能直接java -jar跑的产物。适合刚接触 Gradle 的 Java 开发者也适合那些一直在用但每次出问题都靠搜的同学。1. Gradle 在 Java 项目里到底扮演什么角色1.1 一次 build 命令背后的三个阶段很多人对 Gradle 的认知是和 Maven 差不多的东西都是拉依赖然后编译。这个理解没错但不够用因为一旦出错你就不知道该看哪里。Gradle 执行一次命令其实分三步初始化阶段读取settings.gradle决定这次构建包含哪些子项目。多模块工程里include写漏了最典型的现象就是明明有这个模块但./gradlew build完全没编译它。配置阶段执行所有参与项目的build.gradle脚本本身。注意这里是执行脚本不是执行任务。所以你在build.gradle顶层写一句println hello哪怕跑的是./gradlew tasks它也会打印出来。我见过有人把大量计算逻辑写在脚本顶层结果每敲一次命令都要等十几秒配置。执行阶段根据任务依赖图决定哪些任务需要真正运行。Gradle 的增量能力就体现在这一步——它会给每个任务记录输入输出快照输入没变就跳过。理解这三个阶段的实际价值在于报错信息出现的位置决定了该改哪个文件。脚本语法错误、插件找不到属于配置阶段改build.gradle或settings.gradlepackage xxx does not exist属于执行阶段要去看依赖声明和源码。1.2 Gradle 和 Maven 的差异以及什么时候值得换既然 Maven 能用为什么要折腾 Gradle我把实际用下来感受最明显的几点列出来维度MavenGradle配置形式XML结构固定Groovy/Kotlin DSL可写逻辑增量构建插件支持粒度较粗内置任务级输入输出快照构建缓存依赖本地/远程仓库扩展原生支持本地与远程构建缓存依赖作用域compile/provided/runtime 等implementation/api/compileOnly 等语义更细上手成本低约定强略高灵活度换来的我的判断标准很朴素如果项目只有十来个模块、依赖关系简单、团队没人愿意维护构建脚本Maven 完全够用别为了新技术而换。反过来如果你需要按环境动态切换依赖、要写自定义任务做代码生成、要在 CI 上把构建时间压下来Gradle 的灵活性就值回票价了。需要提醒一点Gradle 的灵活是双刃剑。你能在build.gradle里写 if-else 和循环就意味着别人可能写出只有他本人看得懂的脚本。团队里最好约定脚本里只做声明复杂逻辑抽到buildSrc或者独立插件里。2. 安装路线怎么选wrapper、本地分发、IDE 内置版本2.1 wrapper 才是团队协作的底线如果你只记一件事就记这个团队项目必须提交 Gradle Wrapper。它由四个文件组成gradlew # Linux/macOS 启动脚本 gradlew.bat # Windows 启动脚本 gradle/wrapper/gradle-wrapper.jar gradle/wrapper/gradle-wrapper.properties其中gradle-wrapper.jar是一个小型的引导程序它会读取gradle-wrapper.properties里的distributionUrl检查本地缓存里有没有对应版本的 Gradle没有就下载然后启动真正的构建。这就是为什么所有人执行./gradlew build都能得到一致的 Gradle 版本——版本号写在配置文件里而不是取决于每个人电脑上装了什么。gradle-wrapper.properties的典型内容distributionBaseGRADLE_USER_HOME distributionPathwrapper/dists distributionUrlhttps\://services.gradle.org/distributions/gradle-8.7-bin.zip networkTimeout10000 validateDistributionUrltrue zipStoreBaseGRADLE_USER_HOME zipStorePathwrapper/dists几个实操要点-bin和-all的区别bin包只有运行时all包额外带源码和文档体积大不少。日常构建用bin就够如果你要在 IDE 里点进 Gradle 内部 API 看源码才需要all。不要提交gradle-wrapper.jar就完事记得确认.gitignore没有把它误伤。这个坑我踩过一次同事的.gitignore里有一行*.jar结果新克隆的仓库根本跑不起来报错信息还很隐晦。生成或升级 wrapper本地装好任意一个版本的 Gradle 后执行gradle wrapper --gradle-version 8.7 --distribution-type bin四个文件会自动更新。升级 Gradle 版本用这个方式最干净别手动改 URL 里的版本号因为 wrapper jar 本身有时也需要同步更新。2.2 本地安装与 JAVA_HOME 的关系有些场景你必须本地装一份 Gradle比如第一次生成 wrapper鸡生蛋问题或者你要写独立的 Gradle 脚本、做插件开发。安装本身很简单——解压然后把bin目录加进PATH。真正容易出问题的是 JDK 的关联。Gradle 的启动脚本会按这个顺序找 JDK环境变量JAVA_HOME指向的目录PATH里的java命令如果都没有直接报 JAVA_HOME is not setWindows 上最常见的失败是JAVA_HOME配到了C:\Program Files\Java\jdk-17\bin而不是C:\Program Files\Java\jdk-17。多了一层binGradle 就找不到lib目录。装完之后第一件事是验证./gradlew --version输出里会分别打印 Gradle 版本、Kotlin 版本、JVM 版本和 OS 信息。养成看这段输出的习惯——很多版本不匹配类的问题看一眼这里的 JVM 版本就明白了。如果你的 IDE 里跑出来是一套 JDK命令行跑出来是另一套八成是 IDE 的 Gradle JVM 设置和终端环境不一致。2.3 版本与 JDK 的对应关系别搞混这里有个概念必须先分清运行 Gradle 的 JDK和编译你的代码所用的 JDK是两个独立的配置。前者由JAVA_HOME或org.gradle.java.home决定只影响 Gradle 自身能否启动。后者由java.toolchain或sourceCompatibility/targetCompatibility决定影响产出的 class 文件版本。大致的兼容关系是这样的以 Gradle 8.x 系列为例Gradle 版本运行所需 JDK支持编译的目标版本8.0 - 8.4JDK 8 及以上最高 Java 208.5 及以上JDK 8 及以上最高 Java 218.8 及以上JDK 8 及以上最高 Java 229.xJDK 17 及以上视具体小版本而定所以当你看到类似 Your build is currently configured to use Java 21.0.4 and Gradle 8.8 的提示先别急着降版本这段话多数时候只是在陈述事实真正的错误往往在后面几行。Gradle 8.8 运行在 Java 21 上是没问题的。读 Gradle 报错一定要往下翻第一行通常只是上下文最后那个Caused by才是根因。3. 一个能被编译通过的最小 Java 工程骨架3.1 目录约定与 sourceSetsGradle 的java插件自带一套目录约定跟 Maven 几乎一致src/main/java 主源码 src/main/resources 主资源文件会打进 jar src/test/java 测试源码 src/test/resources 测试资源 build/ 所有输出可随时删掉重建build目录是纯产物目录不要手动往里改东西也不要提交到版本库。如果某个类只有在build/classes里能看到、源码里找不到那说明它是注解处理器或代码生成任务产生的你要去找对应的生成配置而不是把 class 文件拷进源码目录——这是我见过的最常见的修好了但以后一定会炸的操作。如果你确实需要非标准目录比如要兼容老项目结构改sourceSetssourceSets { main { java { srcDirs [src/main/java, src/generated/java] } } }3.2 build.gradle 里每一块在干什么下面这份配置是我做新项目时的起手式可以直接抄plugins { id java id application } group com.example version 0.1.0 java { toolchain { languageVersion JavaLanguageVersion.of(17) } } repositories { maven { url uri(https://maven.aliyun.com/repository/public) } mavenCentral() } dependencies { implementation com.google.guava:guava:33.2.1-jre compileOnly org.projectlombok:lombok:1.18.32 annotationProcessor org.projectlombok:lombok:1.18.32 testImplementation platform(org.junit:junit-bom:5.10.2) testImplementation org.junit.jupiter:junit-jupiter testRuntimeOnly org.junit.platform:junit-platform-launcher } tasks.withType(JavaCompile).configureEach { options.encoding UTF-8 options.compilerArgs -parameters } tasks.named(test) { useJUnitPlatform() }逐块说明plugins块用新版 DSL 声明插件比老的apply plugin: java更好因为它能在配置阶段之前就确定插件构建更快、报错更清晰。java.toolchain指定编译用的 JDK 版本。工具链的好处是它跟环境解耦你机器上装的是 JDK 21但只要声明languageVersion 17Gradle 就会去找一个 17 的 JDK 来编译产出 Java 17 的字节码。repositories里的顺序有意义后面第 4 节细说。依赖作用域别乱用。implementation是私有依赖不传递给下游模块api才会传递需要java-library插件。很多人把什么都写成implementation然后下游模块编译报 package does not exist就是这个原因。Lombok 这类注解处理器必须同时声明compileOnly和annotationProcessor只写前者编译期会找不到处理器。options.encoding UTF-8这行极其重要第 5 节会展开。3.3 settings.gradle 和版本目录settings.gradle决定这次构建的范围多模块时是必需项rootProject.name demo-service include demo-api include demo-core include demo-web如果要用版本目录Version Catalog统一管理依赖版本在gradle/libs.versions.toml里定义[versions] guava 33.2.1-jre junit 5.10.2 [libraries] guava { module com.google.guava:guava, version.ref guava } junit-jupiter { module org.junit.jupiter:junit-jupiter, version.ref junit } [plugins] springboot { id org.springframework.boot, version 3.3.2 }脚本里就能写成implementation libs.guava、id org.springframework.boot version libs.versions.springboot.get()。版本号集中在一个文件里多模块工程升版本的时候不用满仓库搜IDE 还能给出补全提示。唯一要注意的是版本目录只能改版本和坐标改不了作用域别指望它包办一切。4. 依赖解析构建卡在下载环节的根源4.1 缓存机制与解析流程Gradle 的依赖缓存放在GRADLE_USER_HOME下默认是~/.gradle具体路径是~/.gradle/caches/modules-2/files-2.1/这里会按group/module/version分目录存放。理解这个结构有两个用处一是排查依赖冲突时可以直接进去翻有哪些版本二是缓存出问题时删掉对应目录就能强制重新拉取比clean有效得多clean只删build目录跟依赖缓存毫无关系这一点经常被误解。解析流程是这样的Gradle 按repositories中声明的顺序依次查找一旦在某个仓库里找到了对应的模块元数据就不再继续往后找。这意味着如果两个仓库里有同名但内容不同的构件排在前面的那个会赢。公司内网仓库和公共仓库并存时这个特性可能让内网的定制版本被公共版本覆盖也可能反过来。我建议内网仓库排在前面并且开启依赖校验。还有一个容易忽略的点默认情况下动态版本比如1.2.、latest.release和快照版本的缓存有效期是 24 小时。已经缓存过的版本不会每次都去请求远端。要强制刷新./gradlew build --refresh-dependencies这行命令在明明远端有新版本但本地一直用旧的时候特别有用。4.2 仓库声明与镜像配置的实操细节repositories支持多种写法最常用的三种repositories { mavenLocal() // 本机 ~/.m2/repository maven { url uri(https://maven.aliyun.com/repository/public) } // 镜像仓库 mavenCentral() // 官方中央仓库 }关于mavenLocal()我的建议是尽量别加进团队共享的脚本里。它会让构建结果依赖开发者自己本机装过什么出现我这能跑你那不能跑的经典问题。真要调试本地构件用临时脚本或者单独的分支处理。镜像仓库的配置有两个容易漏掉的地方第一插件仓库要单独配。项目依赖走repositories但plugins {}块里声明的插件走的是另一套解析逻辑需要在settings.gradle里配pluginManagement { repositories { maven { url uri(https://maven.aliyun.com/repository/public) } gradlePluginPortal() } } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.PREFER_SETTINGS) repositories { maven { url uri(https://maven.aliyun.com/repository/public) } mavenCentral() } }只配了项目依赖的仓库结果插件下载不动这是非常高频的一类问题。看到构建卡在插件解析上先检查pluginManagement。第二镜像来源要覆盖全。公共镜像一般会聚合中央仓库的内容但不会覆盖所有第三方仓库。比如 Android 项目的构件在google()仓库里只配公共镜像就会报找不到。所以稳妥的做法是镜像在前官方仓库兜底两条都写上。另外如果公司有内部仓库可以在gradle.properties里用变量区分环境避免把内网地址硬编码进脚本repoUrlhttps://maven.aliyun.com/repository/public脚本里用url uri(providers.gradleProperty(repoUrl).get())读取。4.3 离线模式的适用边界--offline让 Gradle 完全不访问网络只用本地缓存./gradlew build --offline这个参数的价值在 CI 上体现得很明显预热的构建机上用离线模式能把构建时间压掉一大截同时避免网络波动导致的偶发失败。但它也有明确的边界——缓存里没有的依赖直接报 No cached version available for offline mode。所以我的用法是本地开发正常联网构建一次把依赖全部拉到缓存CI 镜像构建时先跑一次联网的./gradlew dependencies预热缓存或者直接复用构建镜像里的~/.gradle目录正式构建加--offline既快又能暴露某个依赖其实没进缓存的问题。如果你的构建必须在全新机器上跑别用离线模式那只会让你更难定位问题。5. 编译失败的排查链路5.1 中文乱码从不可映射字符说起现象是这样的源码里写了一句中文注释或中文提示编译时报错误: 编码 GBK 的不可映射字符原因是 javac 默认使用的编码取决于操作系统区域设置。Windows 中文环境下可能是 GBK而你的文件实际是 UTF-8读出来自然对不上。解决方式分两层两层都要做第一层告诉编译器用 UTF-8tasks.withType(JavaCompile).configureEach { options.encoding UTF-8 }第二层确认源文件本身真的是 UTF-8。很多编辑器在你另存为的时候会悄悄改编码或者从别处复制粘贴进来的内容带着系统编码。用 VS Code 或 IDEA 右下角的编码指示器看一眼比猜快得多。还可以顺手统一 JVM 层面的默认编码在gradle.properties里加org.gradle.jvmargs-Xmx2g -Dfile.encodingUTF-8注意-Dfile.encoding影响的是 Gradle 守护进程自身不是编译任务的编码两者别混为一谈。只配这一个乱码问题通常还是会在编译环节出现。5.2 工具链找不到 JDK 的情况声明了toolchain { languageVersion JavaLanguageVersion.of(17) }但机器上只有 JDK 21会发生什么在 Gradle 8 以前它会尝试自动下载缺失的 JDK。从 Gradle 8 开始自动下载需要显式启用否则直接报错说找不到匹配的工具链。启用方式是在settings.gradle里加一个工具链解析插件plugins { id org.gradle.toolchains.foojay-resolver-convention version 0.8.0 }加了之后Gradle 会自动去下载并安装所需的 JDK 到~/.gradle/jdks下。内网环境慎用因为它需要访问外部源。这种场景更合适的做法是在构建机或镜像里预装好目标 JDK然后用org.gradle.java.installations.paths告诉 Gradle 去哪找org.gradle.java.installations.paths/opt/jdk-17,/opt/jdk-21配置好之后执行./gradlew -q javaToolchains能看到所有被识别到的 JDK 及其版本这个命令我强烈建议你记住排查工具链问题的第一站就是它。5.3 几类典型报错的排查路径我把实际工作中遇到最多的几类报错整理成表按看到什么 → 先查什么的顺序列报错关键字大概率原因排查动作SocketTimeoutException且发生在下载 distribution 时wrapper 的distributionUrl访问慢或不通换成内网可达的分发地址或用本地已解压的 Gradle 直接构建绕开 wrapper 下载Could not resolve gradle:gradle:8.7之类的模块解析失败把 Gradle 发行版当成了普通依赖写进了dependencies检查是否误写classpath gradle:gradle:8.7Gradle 自身不是通过 Maven 坐标引入的package xxx does not exist作用域写错或缺少annotationProcessor检查该依赖是implementation还是api多模块下尤其要看上游用哪个Could not find or load main classManifest 缺Main-Class或 classpath 不含依赖先确认 jar 里的META-INF/MANIFEST.MF内容再确认是不是 fat jar插件解析失败、卡在plugins {}pluginManagement没配仓库补上pluginManagement { repositories { ... } }排查时有两个参数几乎必用./gradlew build --stacktrace --info--stacktrace直接打印完整堆栈定位异常抛出的位置--info输出任务级别的详细信息包括依赖解析过程。如果还不够用--debug但输出量极大建议重定向到文件再搜./gradlew build --debug build.log 21另外提醒一句如果你在 Android 或 Flutter 项目里看到 You are applying Flutters main Gradle plugin imperatively using the apply script method 这类提示那是老式apply写法与新插件 DSL 的差异导致跟 Java 项目的构建逻辑是两回事按官方当前模板改成plugins {}块即可别用 Java 项目的思路去改。6. 多模块工程与构建提速的实际手段6.1 模块拆分和 api / implementation 的边界模块拆分的核心问题是哪些依赖是给别人看的哪些是自己用的。这直接对应两个作用域implementation只在当前模块内部可见不参与编译期的依赖传递。api会传递给依赖本模块的下游模块。举个例子。demo-core里有个方法返回com.google.common.collect.ImmutableList那么guava就必须用api声明。如果写成implementation下游demo-web在调用这个方法时会报找不到ImmutableList。反过来如果 guava 只在 core 内部用那就该用implementation让下游不必被迫引入。用implementation的收益不只是干净还有实打实的构建速度上游模块内部改了一个implementation依赖下游模块的编译任务不会被判定为过期可以跳过。启用api需要换成java-library插件plugins { id java-library } dependencies { api com.google.guava:guava:33.2.1-jre implementation org.slf4j:slf4j-api:2.0.13 }我的经验是默认全用implementation只有在下游确实需要该类型出现在编译期时才提升为api。反过来做先全用api后期几乎不可能收窄。6.2 那几个真正有效的提速开关gradle.properties里这几行是我每个项目都会加的org.gradle.jvmargs-Xmx3g -XX:MaxMetaspaceSize768m -Dfile.encodingUTF-8 org.gradle.paralleltrue org.gradle.cachingtrue org.gradle.configuration-cachetrue org.gradle.daemontrue逐条说明代价和收益jvmargs给守护进程足够的堆。默认值在大项目上很容易 OOM报Java heap space或者Metaspace。注意这不是越大越好3g 对绝大多数项目够了开太大反而拖慢机器。parallel多模块并行构建。收益取决于模块间依赖是否紧密串行依赖多的工程提升有限。caching开启构建缓存任务输出可以在不同构建之间复用。这个开关是提升改一点代码重新构建体验最明显的也可以进一步配远程缓存给 CI 共用。configuration-cache缓存配置阶段的结果。第二次跑同样的命令时跳过整个配置阶段在配置逻辑重的工程上收益很大。代价是部分老插件不兼容会直接报错。遇到报错先注释掉这一行确认是不是它引起的别一上来就怀疑业务代码。daemon守护进程保持常驻避免每次构建重新启动 JVM。默认就是开的除非你在容器里跑一次性构建那种场景反而要关掉。想看清楚时间到底花在哪用构建扫描或者--profile./gradlew build --profile生成的报告里会按任务列出耗时配置阶段和执行阶段分开统计。先看报告再调参数比盲目加开关有效得多。6.3 增量编译失效的常见原因任务被判定为不是最新的时加--info能看到原因./gradlew compileJava --info输出里会有类似 Task :compileJava is not up-to-date because: ... 的说明。常见的失效原因有几种自定义任务没有声明输入输出。你自己写的task generateXxx如果没标Input/OutputDirectoryGradle 无从判断它是否需要重跑只能每次都执行。任务产出了带时间戳的内容。比如自动生成一个含构建时间的常量类内容每次都不一样下游任务自然每次都重编。注解处理器行为不稳定。某些处理器会扫描整个 classpath导致输入快照频繁变化。跑过clean。这条看起来像废话但确实有人习惯每次构建都clean build把增量能力彻底废掉了。除非在排查诡异问题否则没必要。7. 从 class 文件到能跑的产物7.1 jar 任务的默认行为和 Manifestjava插件自带jar任务默认产出的位置是build/libs/你的项目名-版本号.jar。有个必须知道的默认行为它只打包你自己写的 class 和src/main/resources下的资源不包含任何第三方依赖。所以直接java -jar会报NoClassDefFoundError。补上入口类信息tasks.named(jar) { manifest { attributes( Main-Class: com.example.App, Implementation-Version: project.version ) } }这样至少能启动但如果代码引用了外部库照样会崩。要跑得起来要么手工拼 classpath要么打成包含依赖的胖 jar。7.2 application 插件与胖 jar 的取舍application插件解决的是方便地跑起来和分发application { mainClass com.example.App applicationDefaultJvmArgs [-Xmx512m] }它带来几个任务./gradlew run直接跑开发时最顺手。./gradlew installDist生成build/install/项目名/目录里面有bin启动脚本和lib全部依赖拷到服务器上就能用。./gradlew distZip/distTar打成压缩包适合发布。我个人更偏好installDist这种方式因为依赖是平铺在lib目录里的出问题能直接看到是哪个 jar比一个巨大的胖 jar 好排查。如果确实需要单文件分发用 Shadow 插件打胖 jar。这里有两个坑我踩过签名文件冲突某些依赖的META-INF下带.SF、.DSA文件合并后 JVM 校验签名会失败报 Invalid signature file digest。处理方式是打包时排除掉这些文件。ServiceLoader 文件被覆盖多个依赖都有META-INF/services/xxx时简单合并会丢掉内容。Shadow 提供了 mergeServiceFiles 之类的处理方式需要显式开启。7.3 我发布前一定会做的几件事下面这几条是我在若干个项目上总结出来的检查项做一遍大概五分钟能省掉大量发布后才发现的问题在干净环境跑一次完整构建。删掉build目录./gradlew clean build确认不依赖任何上次留下的中间产物。用--warning-mode all看废弃警告。./gradlew build --warning-mode all会列出所有用了即将移除 API 的地方尤其是插件和自定义任务提前处理比升级 Gradle 时被动挨打强。打开产物确认内容。jar tf build/libs/xxx.jar | head -50看 Manifest 是否写进去了、资源文件是不是按预期位置放的。实际运行一次。java -jar或./gradlew installDist ./build/install/xxx/bin/xxx跑通主流程再交付。锁住关键依赖版本。多模块项目里用版本目录统一管理或者对核心依赖使用依赖锁定避免某天上游发新版导致构建突然失败。最后再分享一个我用了很久的小习惯当守护进程行为诡异比如改了build.gradle但行为没变、报错信息跟代码对不上时先执行./gradlew --stop把守护进程全杀掉再重新构建。这个动作大概能解决我遇到的三成莫名其妙的构建问题比反复改脚本快得多。守护进程会缓存配置和类加载器长期不清确实会积攒一些状态。