Java 环境搭建全攻略:JDK 安装、环境变量与第一个程序
写 Java 的第一步不是写代码,而是把「翻译官 + 执行引擎 + 查找目录」三样东西摆好位置。你写的 .java 文件人类看得懂、电脑看不懂,所以要一个编译器(javac)把它翻译成字节码(.class);字节码哪台装了 JVM 的电脑都能跑,所以要一个虚拟机(java)当场执行它;而这两样命令默认都不在你的终端能直接看到的地方,所以要用两个环境变量告诉它们「装在哪」。整篇文章讲的就是这三件事:装什么、怎么让系统找到、找到了之后一条命令里发生了什么。
把 JDK 想成一座图书馆。JAVA_HOME 是「这座馆在城里哪条街」登记在市政系统里的地址——Maven、Gradle、IDEA 这些「送货员」都按这条地址来取书;PATH 是「借书窗口开在几楼」——你自己走进大门(敲 java)时,前台按这个指引把你带到窗口。地址写成「街区」还是写成「窗口所在的房间」,就是新手最常见的翻车点。

学完这一篇,你应该能回答三个问题:
- 我到底装的是 JDK 还是 JRE?为什么现在没人单独装 JRE 了?
JAVA_HOME和PATH分别给谁看?写错了各会报什么错?java Hello这七个字符敲下去,机器里依次发生了哪五件事?
几乎每个初学者都在这一步懵过:网上教程一会儿让你装 JDK,一会儿说 JRE,打开任务管理器又冒出来一个 JVM。这三者不是三个平行的东西,而是一层套一层的俄罗斯套娃。

| 名词 | 全称 | 一句话解释 | 里面有什么 |
|---|---|---|---|
| JVM | Java Virtual Machine | 真正执行程序的「虚拟电脑」 | 解释器、JIT、GC、内存模型 |
| JRE | Java Runtime Environment | 运行 Java 程序的最小集合 | JVM + 核心类库 |
| JDK | Java Development Kit | 开发 Java 程序的完整工具包 | JRE + javac / jar / javadoc 等工具 |
只运行别人做好的程序,装 JRE 就够;只要写哪怕一行 Java 代码,就必须装 JDK。现代 JDK 安装包已经内置 JRE,所以别再单独去找 JRE 下载页了。
Java 每半年发一个版本,但不是每个版本都值得用。带 LTS 字样的才进生产环境——这是行业铁律:
| 版本 | 类型 | 支持截止 | 建议 |
|---|---|---|---|
| Java 8 | LTS(上古) | 2030 | 只用于维护老项目 |
| Java 11 | LTS | 2026 | 过渡期选择 |
| Java 17 | LTS | 2029+ | Spring Boot 3 的最低要求 |
| Java 21 | LTS | 2031+ | 新项目首选,虚拟线程已转正 |
Spring Boot 3.x 的硬性门槛是 JDK 17。用 JDK 8 打开 Spring Boot 3 项目,会在启动第一秒抛出 UnsupportedClassVersionError,而且错误信息又长又吓人。

手动去 Oracle 官网下载虽然可行,但版本管理麻烦。推荐用 winget 或 Scoop,一条命令搞定:
# 方案一:winget(Win10 1809+ 自带)winget install EclipseAdoptium.Temurin.21.JDK# 方案二:Scoop(版本切换更灵活)scoop bucket add javascoop install temurin21-jdk装完后不需要手动配置环境变量——包管理器会自动注册。手动解压安装包的方式则需要自己配,见第三节。
brew install --cask temurin@21# 让系统把 JAVA_HOME 指向它echo 'export JAVA_HOME=$(/usr/libexec/java_home -v 21)' >> ~/.zshrcsource ~/.zshrcmacOS 的 /usr/libexec/java_home 是系统自带的 JDK 定位工具,比手写路径聪明得多——装了多个 JDK 时它能按版本号挑选。
# Debian / Ubuntusudo apt update && sudo apt install -y openjdk-21-jdk# 想要更自由的版本切换,用 SDKMANcurl -s "https://get.sdkman.io" | bashsdk install java 21.0.2-temsdk use java 17.0.10-tem # 临时切换到 17提示:服务器上安装完 JDK 后,java -version 显示的可能是系统预装的旧版本。原因是 PATH 里旧版本排在前面,用 which java 确认实际用的是哪一个。
这是新手最容易抄错、也最该理解原理的一步。两个变量分工完全不同:
JAVA_HOME = D:\dev\jdk-21 # 告诉「工具」:JDK 装在哪儿PATH += %JAVA_HOME%\bin # 告诉「终端」:去哪找 java 命令- JAVA_HOME 是给谁用的?Maven、Gradle、Tomcat、IDEA 这些工具会读它来定位 JDK。
- PATH 是给谁用的?命令行 shell——你敲
java时,它在 PATH 列出的目录里挨个找。

两套规则互不通气,所以「写错了谁报错」这件事背是背不住的。来玩一局配对:左边是配置项,右边点它的真实职责——配错了当场告诉你为什么。
JVM 找一个类,像在大学里找一本书先看总馆——先去总馆藏书库(JDK 自带的基础类库),那里有就不用去别的楼;总馆说「我没这本」,才轮到院系分馆(你 -cp 里列的 jar 和目录)去找。这套「先问上级、上级没有才自己找」的规矩叫双亲委派,它保证你在自己项目里写一个 String.java 也永远盖不掉 JDK 的那个 String。
把 JAVA_HOME 写成 D:\dev\jdk-21\bin 是最经典的错误。工具会自动在后面拼 \bin\java,拼出来变成 bin\bin\java,于是 Maven 报「JAVA_HOME is set to an invalid directory」。
配置好之后,用一个命令验证——注意看输出的大写版本号:
$ java -versionopenjdk version "21.0.2" 2024-01-16 LTSOpenJDK Runtime Environment Temurin-21.0.2+13 (build 21.0.2+13-LTS)OpenJDK 64-Bit Server VM Temurin-21.0.2+13 (build 21.0.2+13-LTS, mixed mode)- 三行输出分别对应:JDK 版本、运行环境、虚拟机实现
64-Bit Server VM表示 64 位服务端模式——生产标准配置- 如果显示 1.8.0_xxx,说明你的 PATH 仍然指向 Java 8
注意:java -version(一个横杠)与 java --version(两个横杠)都能用,但前者是历史遗留写法,后者才是规范的 GNU 风格。
新建文件夹,用任意文本编辑器写下这段代码(记得保存为 Hello.java,不要用记事本默认的 .txt):
public class Hello { public static void main(String[] args) { System.out.println("Hello, Java!"); // main 是 JVM 约定的入口:固定签名,一个字都不能改 for (int i = 0; i < args.length; i++) { System.out.println("参数 " + (i + 1) + ": " + args[i]); } }}然后打开终端,进入该文件夹,执行两条命令:
# 第一步:编译,把源码翻译成字节码javac Hello.java # 生成 Hello.class# 第二步:运行,JVM 加载并执行java Hello 张三 李四 # 注意:没有 .class 后缀javac Hello.java:调用编译器,产出Hello.class——一份平台无关的字节码java Hello:启动 JVM,把 Hello.class 加载进来执行 main- 命令行参数
张三 李四会装进String[] args数组,被循环打印出来
为什么要有 .class 这一步? 因为 Java 的跨平台靠的就是它:源码只编译一次,生成的字节码在任何装了 JVM 的系统上都能跑——「一次编写,到处运行」说的正是字节码。
java Hello.class 是错的。给 java 命令的永远是类名(Hello),不是文件名。多打四个字符,JVM 就会报找不到类的错。
上面那五件事(找到命令 → 装上 JVM → 拼 classpath → 加载主类 → 调 main)读起来像顺口溜,但真正出事的是其中某一格。把它摊成一次单步执行:左边是要走的六行,右边同步刷新「此刻的变量」和「谁在调用谁」。连点下一步,重点盯第 4 步——第九节那句报错就诞生在那里:
javac Hello.java # 终端沿 PATH 逐个目录找到 javacjava Hello # 同一个规则,这次找到的是 java.exe// java.exe 只是引导程序:把 jvm.dll / libjvm.so 装进进程// 拼 classpath:-cp 优先,其次 CLASSPATH 环境变量,最后当前目录// 按全限定名找 Hello.class 并加载成 Class 对象// 反射调用 public static void main(String[])| 要找的命令 | javac |
| 依据 | PATH 的目录顺序 |
| 产出 | Hello.class |
shell → PATH → javac.exe真实工作中,你很可能上午维护 JDK 8 的老系统,下午开发 JDK 21 的新项目。永远不要卸载重装来切换版本,用工具管理:
| 工具 | 平台 | 核心命令 | 适用场景 |
|---|---|---|---|
| SDKMAN | macOS / Linux | sdk use java 17.0.10-tem | 命令行爱好者 |
| jenv | macOS / Linux | jenv global 21 | 需要 per-project 配置 |
| winget + 手改 PATH | Windows | 环境变量界面拖动 | 偶尔切换 |
| IDEA 内置 JDK 下拉框 | 全平台 | File → Project Structure | 只在 IDE 内切换 |
多版本共存这件事的坑不在「怎么装」,而在三方各按各的规矩挑:终端按 PATH 的顺序、工具按 JAVA_HOME、IDE 按项目 SDK。下面这段动画把三条线并排走了一遍,注意第 6 帧——三方不一致的那一刻,就是「IDE 能跑、命令行崩」诞生的那一刻:

而「别让本机状态决定构建结果」这句话,落到工程上就是一段 pom。别去抄——勾一遍,看它长什么样,尤其注意 java.version 那一行和三处 scope:
<?xml version="1.0" encoding="UTF-8"?>
<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>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.4</version> <!-- 版本由 BOM 统管,子依赖不写 version -->
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>demo-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<properties>
<java.version>21</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>| 报错 / 现象 | 根因 | 解决 |
|---|---|---|
javac 不是内部或外部命令 | PATH 没配或没生效 | 重开终端;检查 PATH 是否包含 %JAVA_HOME%\bin |
JAVA_HOME is set to an invalid directory | JAVA_HOME 多写了 \bin | 改为 JDK 根目录 |
错误: 找不到或无法加载主类 Hello | 类名与文件名不一致 / 运行了 .class | 改名一致;java Hello |
UnsupportedClassVersionError | 用低版本 JDK 运行高版本编译的 class | 统一升级 JDK 到 17+ |
编码 GBK 的不可映射字符 | 源码含中文且编译未指定编码 | javac -encoding UTF-8 Hello.java |
| IDEA 里能跑、终端里不能跑 | 两者用的不是同一个 JDK | 终端 which java 对比 IDEA 设置 |
光看文字记不住环境这件事,因为它是看不见的路径查找。下面四个内核实验在浏览器里真实执行 JDK 的那几层,每个都可以自己切参数。
第一个实验把「一行代码的一生」拆开。字节码是 javac 产出的中间语言,类加载器是把 .class 文件读进内存的那个角色,JIT(即时编译)是 JVM 跑热了之后把热点字节码翻译成机器码的加速器。切到「JIT 分层编译」你会看到同一段循环先被解释执行、再被编译成机器码——这就是「Java 开头慢、跑久了变快」的原因:
第二个实验直接回答第三节那个类比:为什么你写的 String.java 盖不掉 JDK 的 String。选「双亲委派」看请求如何一路往上问;选「jar 包冲突」看两个 jar 里有同一个类时谁赢——答案是 classpath 里排前面的那个,这也正是第九节速查表里 NoClassDefFoundError 那一行的根因:
同一套机制反过来用,就是新手最崩溃的那句报错。切到「ClassNotFoundException」,你能亲眼看到 JVM 把 classpath 上每个目录都翻了一遍、最后没找到:
第三个实验解释下一篇会大量遇到的现象——IDEA 点 Run 之前偷偷帮你做了一件事。「编译产物」那一步能看到 .java 变成 target/classes 里的 .class;不先编译就运行,用的还是旧字节码:
第四个实验专答第三节那句「到底哪个 JDK 在干活」。切到「谁说了算」,你会看到终端沿 PATH 逐个目录试、第一个命中就用;切到「JAVA_HOME 与 PATH」,两个变量各自的读者就浮出水面了;「装了好几个版本」这一档最真实——8、17、21 三份共存时,工具、终端、IDE 三方各挑各的:
上一条链的执行顺序,光看图记不住,得点。下面这张图把「找书先看总馆」拆成五格,一格一格点着看:
最后提前看一眼「环境不对时应用是怎么死的」。它不是 找不到主类 那种一句话报错,而是一整屏——下面的报错急救台练的就是这种现场:
实验做够了可以换成命令行自己敲。下面这台控制台连着浏览器里的同一个 Java 内核,回显全部由内核算出来——先敲 whoami 看当前生效的是哪份 JDK,再逐条 lab envpath:
lab envpath home 和 lab envpath fix 要连着敲才有意义——前者复现「JAVA_HOME 多写了 \bin」,后者给出验证顺序。同一个报错,先看清它为什么发生,再看怎么确认修好了。

上面这条链里,第 1 步和第 3 步都能被你写错。左边改地址写法,右边立刻给出「谁会读到它、报什么错」;下面再选一个 JDK 版本,看看 Spring Boot 和语言特性分别要求到哪一档:
mvn -v: OKSpring Boot 3.x: OKswitch 模式匹配可用(预览特性已转正的部分)
沙盘里最容易忽略的一格是「jre-only」。Oracle 从 JDK 11 起不再单独发布 JRE,所以你现在下载到的所谓「JRE」其实是个精简 JDK——遇到 javac 不是内部或外部命令,第一件事是确认手上这份到底带不带编译器(去 bin 目录看有没有 javac)。
这一表的「报错原文」都是可以直接整段粘进搜索框的关键词,别意译、别缩写:
| 报错原文(片段) | 真实原因 | 30 秒自救 | 深挖看第几篇 |
|---|---|---|---|
错误: 找不到或无法加载主类 Hello / Error: Could not find or load main class Hello | 运行的是文件名而不是类名,或 bin 里没有这个 .class,或包名与目录不一致 | 改成 java Hello(不带 .class);dir target\classes 确认字节码真的存在 | 本篇第四、六节 · #3 IDEA 工程 |
java.lang.UnsupportedClassVersionError: Hello has been compiled by a more recent version of the Java Runtime (class file version 61.0), this version of the Java Runtime only recognizes class file versions up to 52.0 | 用 JDK 8 的运行器去跑 JDK 17 编出来的字节码(61 = 17,52 = 8) | java -version 与 mvn -v 打印的 Java version 对齐,统一升到 17+ | 本篇第三节 · #36 打包部署 |
The JAVA_HOME is not defined correctly / JAVA_HOME is set to an invalid directory | JAVA_HOME 没设、指到了 bin、或指到了一个只剩 JRE 的目录 | 改成 JDK 根目录(如 D:\dev\jdk-21),然后重开终端再看 echo %JAVA_HOME% | 本篇第三节 · 上方沙盘 |
'mvn' 不是内部或外部命令,也不是可运行的程序或批处理文件。 | Maven 的 bin 不在 PATH 里,或改完环境变量没重开终端 | 把 %MAVEN_HOME%\bin 加进 PATH 并新开窗口;mvn -v 验证 | #2 Maven 入门 |
'java' 不是内部或外部命令 | PATH 里没有 %JAVA_HOME%\bin | 补 PATH;Windows 记得往下拖动列表顺序 | 本篇第三节 |
错误: 编码 GBK 的不可映射字符 | 源文件是 UTF-8 含中文,编译按系统默认 GBK 解码 | javac -encoding UTF-8 Hello.java;IDEA 里全局设 UTF-8 | #35 日志与环境 · #3 IDEA |
Exception in thread "main" java.lang.NoClassDefFoundError: com/example/Foo | 编译时有、运行时没有——classpath 少了那个 jar | java -cp "target/classes;lib/*" com.example.Main | 本篇第七节 classpath 实验 |
Could not find or load main class 这句话有两个成因被合并在一起了——找不到类文件(路径/classpath 问题)和找到了但加载失败(版本不匹配、静态初始化块抛异常)。分不清时加 -verbose:class 重跑一次,JVM 会把每个类的来源打出来。
上面那条「两个成因合并」的报错,最典型的例子就是版本不匹配。下面这段是真实堆栈,先别看答案——点出你认为的凶手行:
本地用 JDK 21 编出 jar,交给 CI 上那台只有 JDK 8 的机器运行,进程连 main 都没进去。
目标:在纯命令行里走通「编译 → 运行 → 故意改错 → 读懂报错」全链路,不借助任何 IDE。
// EnvCheck.java —— 放在任意空目录下,注意文件名必须是 EnvCheck.javapublic class EnvCheck { public static void main(String[] args) { System.out.println("java.version = " + System.getProperty("java.version")); System.out.println("java.home = " + System.getProperty("java.home")); System.out.println("user.dir = " + System.getProperty("user.dir")); System.out.println("classpath = " + System.getProperty("java.class.path")); for (int i = 0; i < args.length; i++) { System.out.println("arg[" + i + "] = " + args[i]); } }}依次执行并核对输出:
javac EnvCheck.javajava EnvCheck 张三 李四java -cp . EnvCheck # 显式写出 classpath,理解「-cp 是什么」java EnvCheck.class # ← 故意做错一次,看清那句中文报错长什么样预期输出(版本号会随你的 JDK 变化):
java.version = 21.0.2java.home = D:\dev\jdk-21user.dir = D:\tmp\envcheckclasspath = .arg[0] = 张三arg[1] = 李四验收清单:四行属性都能打印;最后一条错误命令能让你口头说出「为什么 java 后面不能带 .class」。
三个小改动,各自只动一个变量,观察结论不同:
- 把
JAVA_HOME临时改成带\bin的写法,再跑mvn -v。你会观察到 Maven 报The JAVA_HOME is set to an invalid directory,而java -version依然正常——因为它们读的不是同一个东西。 - 用
java -cp " nonexistent_dir " EnvCheck之类把 classpath 写歪。你会观察到找不到或无法加载主类,此时第七节的 classpath 实验能告诉你 JVM 究竟翻了哪几个目录。 - 在同一目录放两份同名类的 class(
javac -d out1 EnvCheck.java与-d out2,改掉一行打印内容),再用java -cp "out1;out2" EnvCheck和"out2;out1"各跑一次。你会观察到 输出跟着 classpath 的顺序变——这就是「重复 jar 谁排前面谁生效」。
提示:做完第 3 条再回来看第七节的「jar 包冲突」实验,抽象规则会立刻落地。
给自己做一个「环境体检脚本」,以后每次换电脑、被同事的电脑气到时都能一键自查。
- Windows 用
check-env.ps1,macOS / Linux 用check-env.sh - 至少打印:
java -version的实际版本、javac -version、mvn -v的 Maven 与 Java 两行、which java/where java命中的完整路径、JAVA_HOME是否指向根目录(不是 bin) - 每项要么打
[OK]要么打[FAIL] 原因 + 一句修法,全部通过时退出码为 0 - 额外挑战:检测「同时装了多个 JDK」并把它们全列出来(Linux/macOS 可用
/usr/libexec/java_home -V或update-alternatives --list java)
验收清单:① 故意删掉 PATH 里的 java 一项,脚本报 FAIL 而不是崩溃;② 在两台机器上跑,输出差异能直接指出「版本不一致」这件事;③ 三个月后的你自己读得懂这些 FAIL 文案。
不看上文,说出 JDK / JRE / JVM 的包含关系,以及「现在为什么不用单独装 JRE」。
JAVA_HOME 和 PATH 各自给谁读?把 JAVA_HOME 写成 ...\bin 会让谁报错、报哪句?
java Hello 后面为什么不能加 .class?加了之后 JVM 会把你输入的字符串当成什么去找?
字节码为什么能让「一次编写,到处运行」成立?跨的是哪一层——源码层还是机器码层?
classpath 里出现两个同名类,哪一份生效?这条规则和「找书先看总馆」的类比怎么对上?
JDK 管装、JAVA_HOME 管找、PATH 管喊、字节码管跑——装的是工具,找的是路径,喊的是命令,跑的是平台无关的 class。
这一节真正要记住的只有三件事——JDK 包含 JRE 包含 JVM;JAVA_HOME 指向根目录、PATH 指向 bin;版本管理交给工具,别手动折腾。这三件事理顺,后面所有教程都不会再被环境问题打断。