刚入行的朋友经常会问我一个问题:Java项目到底是怎么变成能跑的程序?IDE里点一下绿色的运行按钮,代码就跑起来了,看起来确实像是“不需要编译”。但一旦脱离IDE,回到命令行或者服务器上部署,很多人就开始懵:javac有什么用?class文件哪来的?maven又是在做什么?标题这句“如何用Java编译Java的项目”,听起来像句废话,但实际上它是理解Java工具链最精确的一句话——编译Java项目的编译器(javac)本身,就是一门用Java写成的程序,跑在JVM上,然后它再去编译你的Java代码。这就是所谓的“自举”。
这篇文章就是围绕这条主线,把从JDK安装、环境变量配置、javac手工编译,到Maven自动构建、常见编译报错排查的完整链路捋一遍。适合刚学完Java基础语法但没搞明白“项目怎么构建”的初学者,也适合准备Java面试前想把编译相关的底层知识补一补的朋友。内容偏实操,我会把每个步骤为什么这么做、坑在哪里讲清楚,可以直接照着敲。
1. 内容整体设计与思路拆解
1.1 Java到底是不是解释型语言
很多人说Java是解释型语言,这个说法对了一半,也容易误导人。Java的完整执行过程是“编译 + 解释 + 即时编译”混合的。
你的.java源文件,第一步会被javac编译生成.class字节码文件。这个字节码文件不是机器码,CPU不能直接执行,它是一套JVM定义的指令集。第二步,JVM加载.class文件,通过解释器逐条解释执行字节码。第三步,当某段代码被反复执行(比如一个热点循环),JVM的JIT编译器会把这部分字节码直接编译成当前平台的原生机器码,后续直接执行机器码,速度会快很多。
所以准确的说法是:Java是编译到字节码、再依托JVM解释执行的混合型语言。学习编译期和运行期的分工,能帮你理解很多常见问题,比如“为什么改了代码没生效?”——大概率是你只改了.java,没重新编译成.class;又比如“为什么本地好好的,部署到服务器就报ClassNotFoundException?”——很可能是编译好的.class没有被打进部署包。
1.2 “用Java编译Java”到底是什么意思
标题这句话,字面意思背后藏着Java工具链的核心设计。JDK中的javac、jar、javadoc这些工具,全部是用Java语言自己写的,然后编译成字节码,运行在JVM之上。
这就有意思了:当你在命令行敲javac Test.java的时候,实际上是启动了一个JVM进程,在JVM里运行javac这个Java程序,这个程序再去解析你的.java源码,做词法分析、语法分析、语义分析,最终生成.class字节码文件。
这个设计带来的直接好处是:编译器可以用统一的语言维护,工具链可移植性强,换一个平台不需要重写编译器。坏处也很明显:编译一个大型项目,JVM要启动一次,编译过程中的内存开销其实是挺大的,所以Maven、Gradle这类构建工具都会尽量复用编译进程,避免反复启动JVM。
理解这一点之后,你在看“编译原理”相关面试题时会顺畅很多:词法分析、语法分析、语义分析、中间代码生成、目标代码生成,在javac里是真实存在的几个阶段,不是纯理论。
1.3 手工编译还是自动化构建
很多初学者觉得Maven很神秘,其实它的核心功能就是替你管理“编译过程中的那几个麻烦事”:源码目录在哪、依赖包在哪、编译结果放哪、怎么打jar包。
手工用javac编译一个单文件很简单,但项目一旦涉及多目录、第三方依赖jar,命令就会变得非常长而且容易出错。Maven把这件事约定化、自动化了,但它的底层调用的依然是javac的编译器接口(通过javac的API或者命令行方式)。这也是为什么我建议你先学手工编译,再用Maven——你知道了Maven帮你做了什么,遇到问题才不会一脸茫然。
2. 环境准备与JDK选型
2.1 JDK和JRE,别再分不清
新手常看到一个概念区分:JRE是Java运行环境,只包含JVM和核心类库,用来跑Java程序;JDK是Java开发工具包,包含JRE的全部内容,另外还有javac、javap、jpackage等开发调试工具。
写代码和编译代码,必须安装JDK,光装JRE是没法编译的。现在Oracle JDK从Java 8开始,默认不单独发布JRE安装包了,因为JDK足够覆盖绝大部分运行场景。服务器上如果只是跑jar包,理论上JRE就够,但出于省事和兼容性考虑,大多数部署环境也直接装JDK。
2.2 OpenJDK还是Oracle JDK
现在Java 17之后,Oracle JDK和OpenJDK在功能上几乎没有差别,Oracle JDK 17及之后的版本也已经免费可商用。个人学习、中小项目直接用OpenJDK发行版就行。
选版本时记住一句话:长期支持版是首选,别追新。Java 8是目前存量项目最大的一个版本,但已经在2026年底结束免费更新;Java 11是上一代LTS;Java 17和21是目前主流的新LTS版。如果你是新项目,建议直接上Java 17或者Java 21,语法特性多、性能好。如果是维护老项目,老老实实用项目原本的版本,强行升级JDK很容易出一堆兼容性问题。
我个人的建议是:学编译原理、学Java基础的时候,装一个JDK 17就够了,然后用记事本加命令行把基础打牢,再去用IDEA的“一键运行”。
2.3 JAVA_HOME、PATH、CLASSPATH到底怎么配
Windows配置JAVA_HOME和PATH,大家应该都会:新建系统变量JAVA_HOME指向JDK的安装目录,然后在PATH里加上%JAVA_HOME%\bin。但背后的原因值得知道。
JAVA_HOME:不是Java运行时用的,而是给其他工具用的。Tomcat、Maven、Gradle、IDEA这些工具启动时都需要找JAVA_HOME来确定用哪个JDK。不配JAVA_HOME,Maven可能直接报“JAVA_HOME not found”。PATH里的%JAVA_HOME%\bin:让系统直接找到javac和java可执行文件。配了它,才能在任意目录下敲javac命令。CLASSPATH:这个变量劝你不用手动配。它的作用是告诉JVM“去哪找类”。手工编译时可以用-classpath参数临时指定,比改全局变量干净得多。一旦把CLASSPATH写进系统环境变量,后续不同项目的依赖路径互相污染,排查起来非常痛苦。
Linux/macOS上,把下面两行写进~/.bashrc或~/.zshrc:
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64 export PATH=$JAVA_HOME/bin:$PATH配置完了,命令行执行java -version,再执行javac -version。两个命令都正常显示版本号,环境就准备好了。只显示java版本而javac报错,基本可以断定PATH里只进了JRE的路径,或者安装的是JRE不是JDK。
3. 手工编译:从单文件到多模块
3.1 最简单的单文件编译
新建一个Hello.java文件,内容就写最基础的“Hello World”:
public class Hello { public static void main(String[] args) { System.out.println("Hello, Java Compile!"); } }然后在同目录执行:
javac Hello.java这条命令执行完,目录里多了一个Hello.class文件。这就是编译产物。接着运行:
java Hello注意运行的时候不要带.class后缀,否则JVM会尝试找一个名为Hello.class.class的类,然后报错“找不到主类”。这个坑我见新手踩过无数次。
单文件编译时,javac会在源文件相同目录生成.class文件。但这只是最理想的情况。
3.2 -d参数与目录结构
真实项目的源码不是平铺在根目录的。一般会按功能拆包,像com.example.util、com.example.service这种。源码目录要跟包名保持一致,这是Java编译器的硬性要求。
假设项目结构是这样:
myapp/ src/ com/ example/ Main.java util/ StringUtils.javaMain.java里用到了StringUtils:
package com.example; import com.example.util.StringUtils; public class Main { public static void main(String[] args) { System.out.println(StringUtils.capitalize("hello")); } }StringUtils.java:
package com.example.util; public class StringUtils { public static String capitalize(String s) { if (s == null || s.isEmpty()) { return s; } return s.substring(0, 1).toUpperCase() + s.substring(1); } }如果你在src/com/example目录下直接敲javac Main.java,大概率会报错“软件包com.example.util不存在”。原因是编译器在找依赖类时,默认会从当前目录的类路径下搜索,而Main.java位于com/example目录,它期望的包结构是com.example,这就对不上了。
规范的编译方式是在src目录下,指定输出目录:
cd myapp/src javac -d ../out com/example/Main.java com/example/util/StringUtils.java-d参数指定编译输出的根目录,编译器会自动按照包名结构,在out/com/example/下生成Main.class和util/StringUtils.class。
也可以一次性编译整个目录下的所有Java文件,省去一个个列文件名的麻烦:
javac -d ../out $(find . -name "*.java")注意:Windows的cmd不支持$(...)这种Unix风格命令替换,需要用Git Bash或者PowerShell。在PowerShell里可以这样:
javac -d ..\out (Get-ChildItem -Recurse -Filter "*.java" | ForEach-Object { $_.FullName })3.3 外部依赖jar怎么处理
项目一旦用了第三方库,javac就会因为找不到别人的类而报错。比如你用Apache Commons Lang 3,代码里写着StringUtils.capitalize——注意我上面例子是自己写的工具类,如果你用的是commons-lang3的StringUtils,编译时就必须引入对应的jar包,否则编译器一脸懵,报出密集的“找不到符号”。
处理方法是用-classpath(简写-cp)参数。先把jar下载到lib目录,然后编译时指定:
javac -d ../out -cp ../lib/commons-lang3-3.14.0.jar com/example/Main.java如果依赖的jar不止一个,在Windows上路径之间用分号隔开,在Linux/macOS上是用冒号:
# Linux/macOS javac -d out -cp lib/commons-lang3-3.14.0.jar:lib/guava-33.0.0-jre.jar src/com/example/Main.java # Windows javac -d out -cp lib\commons-lang3-3.14.0.jar;lib\guava-33.0.0-jre.jar src\com\example\Main.java这里有个细节很多人不知道:-cp影响的是javac编译时的类查找路径,而运行时也需要同样的依赖,所以java命令同样要配-cp:
java -cp out:lib/commons-lang3-3.14.0.jar:lib/guava-33.0.0-jre.jar com.example.Main没有运行时classpath的JVM,会认得出Main类,但等它真正要用StringUtils时才加载不到,于是运行到一半抛NoClassDefFoundError。
3.4 编译整个项目的最小可行命令
我们把手动编译的完整命令整合一遍。项目结构:
myapp/ lib/ # 放所有依赖jar src/ # 源码 out/ # 编译输出(第一次编译前需要手动创建)编译:
mkdir -p out find src -name "*.java" > sources.txt javac -encoding UTF-8 -d out -cp "lib/*" @sources.txtlib/*通配符表示把lib目录下所有的jar都加入classpath,注意这个是javac支持的特殊写法,Windows和Linux都能用,但规范写法-cp "lib/*"在Windows上双引号内也可以带分号,比如-cp "lib/*;lib2/*"。@sources.txt是从文件读取源码列表,避免命令行太长。
运行:
java -cp "out;lib/*" com.example.Main到了这一步,恭喜你,已经完全理解了“Java项目编译”最底层的手工玩法。接下来要进入的是真实项目里99%的情况——用Maven编译。
4. Maven自动化构建:真实项目的编译方式
4.1 Maven的目录约定与生命周期
Maven是Java生态里当前最主流的项目构建工具。它有一套强制的目录约定:
myproject/ pom.xml src/ main/ java/ # 生产代码 resources/ # 配置文件,会被复制到classpath根目录 test/ java/ # 单元测试代码Maven生命周期里跟编译直接相关的核心命令:
| 命令 | 作用 | 背后做的事 |
|---|---|---|
mvn clean | 清理 | 删除target目录 |
mvn compile | 编译 | 把src/main/java编译到target/classes |
mvn test-compile | 编译测试代码 | 依赖compile阶段,结果输出target/test-classes |
mvn package | 打包 | 依赖test-compile,生成jar/war,产物在target目录 |
mvn install | 安装到本地仓库 | 依赖package,把jar复制到~/.m2/repository供其他项目引用 |
它的编译核心,是通过maven-compiler-plugin插件调用javac的API来完成的。所以你可以理解为Maven是给javac套了一层管理壳。
4.2 一个可直接用的pom.xml
真实项目的pom.xml要配的东西不少,但起步阶段,关注两个核心配置:maven-compiler-plugin的版本和release(或source/target)。
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"> <modelVersion>4.0.0</modelVersion> <groupId>com.example</groupId> <artifactId>hello-compile</artifactId> <version>1.0.0</version> <packaging>jar</packaging> <properties> <maven.compiler.release>17</maven.compiler.release> <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding> </properties> <dependencies> <dependency> <groupId>org.apache.commons</groupId> <artifactId>commons-lang3</artifactId> <version>3.14.0</version> </dependency> </dependencies> </project>然后命令行执行:
mvn clean compile控制台能看到编译成功的信息,target/classes下面就是编译好的字节码文件。
如果要用Java 21的新语法,比如虚线程、switch模式匹配,那么maven.compiler.release就设成21。注意:release优先级高于source和target,三者只配一个就够了,配release最不容易出错。
4.3 source/target/release三者的区别和坑
这三个参数研究起来有点绕,但面试八股文里很喜欢问,实际踩坑概率也高:
source:指定源码的Java语言语法版本,编译器只允许你使用该版本及之前的语法特性。target:指定生成的字节码版本,也就是.class文件要兼容的最低JVM版本。release:source + target的组合,同时还会限制编译器使用的JDK内部API。
用-source 17 -target 8这种配置看起来是“源码用新版,字节码发旧版”,听起来很美好,但实际上Java 17的编译器切成target 8时,用的是JDK 8的符号表,很多新方法根本找不到,而且老JVM也跑不了新版编译器生成的某些字节码结构。所以正确做法是直接设release,让它保持source、target完全一致,避免“低版本JVM跑高版本字节码”这种隐蔽问题。
4.4 maven编译时的常见坑:编码和依赖下载
编译期间最常见的两个问题,第一是中文乱码,第二是依赖下载失败。
中文乱码的根源是:源码文件编码与编译器解析时使用的编码不一致。Windows上默认编码通常是GBK,而项目源码大多是UTF-8。如果不显式指定,javac会按平台的默认字符集读取源码,然后在类文件里记录它读取时的字符集,一旦和源码本身编码对不上,中文字符串就会编译成乱码。解决办法就是在pom里显式声明project.build.sourceEncoding为UTF-8,同时确保IDE和编辑器的文件编码也是UTF-8。
依赖下载失败,最常见原因是网络源不稳定。国内环境可以配置阿里云镜像。修改~/.m2/settings.xml:
<mirrors> <mirror> <id>aliyunmaven</id> <mirrorOf>central</mirrorOf> <name>阿里云公共仓库</name> <url>https://maven.aliyun.com/repository/public</url> </mirror> </mirrors>配置完重新执行mvn clean compile,依赖会重新从配置的镜像地址拉取。
5. 常见问题与排查技巧实录
5.1 编译期异常类型速查
我把日常遇到的高频编译异常按症状归了类,每条都给了定位思路和解决办法。
| 报错信息 | 原因 | 处理方式 |
|---|---|---|
错误: 找不到符号 符号: 方法 xxx() | 调用了不存在的类/方法/变量 | 优先确认包名导入是否正确,再确认依赖jar版本,最后看是不是IDEA缓存导致的,执行mvn clean |
程序包com.example.xxx不存在 | 编译器classpath里缺了对应源码或jar包 | 要么把依赖加入编译参数,要么用Maven声明依赖,要么检查源码目录是否满足包名结构 |
编码GBK的不可映射字符 | 源码是UTF-8,但编译器用GBK读取了 | 编译命令加-encoding UTF-8,pom里配好project.build.sourceEncoding |
警告: 源发行版 17 需要目标发行版 17 | IDE或Maven的编译级别与JDK版本不一致,一般出现在JDK 17配了默认target低于17 | 在pom里设maven.compiler.release为17;IDEA里在设置->Build->Compiler->Java Compiler里把target字节码版本调成17 |
使用sun.misc.Unsafe等内部API | 用了不安全的JDK内部类 | 非极端场景,建议换公共API处理;如果一定要用,--add-exports或--add-opens可以临时解决,但要注意JVM升级后兼容性没有保障 |
5.2 运行时找不到类:ClassNotFoundException和NoClassDefFoundError
初学者经常把这两个弄混。
ClassNotFoundException是JVM运行时按类名查找.class文件没找到,属于类加载阶段的问题。典型场景是手工java -cp漏掉了包含某个类的jar包或目录。
NoClassDefFoundError是类在编译时存在、运行时另一个类在初始化时需要它,但classpath里同样找不到,属于链接阶段问题。区别在于:前者你直接调一个类,JVM找不到;后者是某个类内部依赖了另一个已经不在classpath的类,初始化时失败。
比如你用了commons-lang3的StringUtils,编译时-cp里写对了,运行时java -cp out:lib/*写错了,单列出out却漏了lib/*,这时运行到用到StringUtils的那一行就会抛NoClassDefFoundError。排查思路很简单:把你编译时的整个classpath完整复刻给运行时。
5.3 maven使用中的三个高频问题
第一个:mvn命令不是内部或外部命令。
多半是Maven没有配置环境变量。Maven需要M2_HOME或直接在PATH里指向maven目录的bin;同时它强依赖JAVA_HOME,两者都配好,重启终端再试。
第二个:Could not resolve dependencies。
依赖一直拉不下来。最好先看一眼错误信息里具体是哪个groupId/artifactId拉不下来。如果是你的私有仓库地址拼错了,改正确之后,记得删除本地仓库里对应目录下的*.lastUpdated文件,否则Maven会认为这个依赖已经拉失败、短期内不去重新尝试。
第三个:cached in the local repository。
这个往往出现在你改了本地仓库里某个SNAPSHOT依赖之后,另一个项目还在用旧版本。执行mvn -U强制更新,或者检查依赖声明里是否指定了具体版本而不是快照范围。
5.4 实操心得:IDEA里的编译和命令行编译为什么结果不一样
这是工作里最容易碰到的玄学问题。IDEA的Build菜单默认用IntelliJ自带的编译器(基于javac的封装),和Maven编译是两套机制。当IDEA显示代码能跑,但mvn clean package却报编译错误时,通常是因为IDEA自动导入依赖时把某个错误依赖从缓存里排除了,或者IDEA的编译级别和Maven的release不一致。
我自己的做法是:以Maven为准。IDEA指定用Maven来做构建,方法是在IDEA的Build Tools -> Maven -> Runner里设置成使用Maven的编译参数。这样两边行为保持一致,能少踩一半莫名其妙的构建坑。
6. 扩展:编译期与运行期的一次对比实验
6.1 看字节码:javap使用入门
javap -c com.example.Main这个命令可以反汇编编译好的class文件,查看字节码指令。比如System.out.println这样的调用,实际对应的是getstatic(拿到System.out)、ldc(加载字符串常量)、invokevirtual(调用println方法)。面试题常考的“字符串拼接用StringBuilder”、“try-with-resources自动关闭流”,在字节码层面都一目了然。
感兴趣的话,编一个小项目,把-g参数加进编译命令,然后javap -v看局部变量表,你会直观理解“源码丢了也能从class里还原出很多信息”,这也是很多反编译工具能工作的原因。
6.2 从字节码到机器码:聊聊JIT和AOT
JIT编译是JVM在运行时对热点代码的优化,它不是Java独有的概念,任何解释型语言的高级实现基本都有JIT。它的核心思路是省掉“每次都解释执行”的开销,但代价是运行时要占用额外CPU和内存做编译决策。
AOT编译则是把Java代码在运行前直接编译成特定平台的原生可执行程序,典型代表是GraalVM的native-image。好处是启动快、内存占用低,坏处是编译时间长、字节码反射等动态特性支持受限。
搞清楚这三层的区别,再看“Java是编译型还是解释型”的面试题,你能给出一个更有层次感的答案:语法和字节码是编译期确定的,语义和性能优化是运行期动态完成的。
6.3 亲手做一个“编译发布”小流程
最后,建议你把前面所有内容串成一个可反复练习的成品流程。新建一个含日志依赖的小项目,手动完成三步:先用javac编译并在out下跑起来,再用Maven做同样的编译打包,最后用java -jar直接运行Maven打出的可执行jar。
为了让java -jar能正常运行,pom里需要加上maven-jar-plugin和maven-shade-plugin,下面这段配置帮你在打包时带上所有依赖:
<build> <finalName>hello-compile</finalName> <plugins> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-jar-plugin</artifactId> <version>3.4.1</version> <configuration> <archive> <manifest> <mainClass>com.example.Main</mainClass> </manifest> </archive> </configuration> </plugin> <plugin> <groupId>org.apache.maven.plugins</groupId> <artifactId>maven-shade-plugin</artifactId> <version>3.5.2</version> <executions> <execution> <phase>package</phase> <goals> <goal>shade</goal> </goals> </execution> </executions> </plugin> </plugins> </build>然后执行:
mvn clean package java -jar target/hello-compile.jar看到控制台输出正常后,你再回头看自己手动敲的javac -d out -cp lib/* @sources.txt,就会明白Maven只是在帮你自动完成同样的事,顺便解决了依赖Jar的远程下载和版本管理。
这个流程跑通之后,你对“Java项目的编译”这件事,就有了从原理到实操的完整认知,后续再学Gradle、学模块化、学GraalVM,都会有清晰的参照系。
最后再分享一个个人习惯:无论工具多便捷,我每隔一段时间都会故意关掉IDE,回到命令行用手工编译方式跑一个简单的项目。这个动作不是为了怀旧,而是为了保持对工具链底层逻辑的敏感度——一旦构建出问题,你知道该去查classpath、查编码、查字节码版本,而不是盲目百度“报错原因”。这种底层的掌控感,才是“用Java编译Java”这件事带给你的最大价值。