Java 从入门到精通(一):开发环境搭建与第一个程序

很多人学 Java 的第一天是这样度过的:下载 JDK、一路点下一步、配环境变量、写个 HelloWorld、控制台打印出 Hello, World!,然后觉得自己已经”入门”了。可是一旦换台电脑、换个系统、接手一个 Maven 工程,问题就接踵而至:javac 不是内部或外部命令、UnsupportedClassVersionError、ClassNotFoundException、NoClassDefFoundError、依赖下不下来、jar 跑不起来……每一个都能卡住新手一整天。根本原因在于:绝大多数教程只教”怎么做”,从不讲”为什么这么做”。本系列的目标就是把这条底层链路彻底打通,让你知其然更知其所以然。

一、Java 技术体系全景

1.1 JDK、JRE、JVM 三者到底是什么关系

这是面试与自学中最经典的开场题,也是理解整个 Java 生态的地基。三者的关系可以用一句话概括:JVM 是执行引擎,JRE 是运行环境,JDK 是开发工具包,逐层包含。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
┌─────────────────────────────────────────────┐
│ JDK │
│ ┌───────────────────────────────────────┐ │
│ │ JRE │ │
│ │ ┌─────────────────────────────────┐ │ │
│ │ │ JVM │ │ │
│ │ │ - 类加载子系统 ClassLoader │ │ │
│ │ │ - 运行时数据区(堆/栈/方法区) │ │ │
│ │ │ - 执行引擎(解释器/JIT/GC) │ │ │
│ │ └─────────────────────────────────┘ │ │
│ │ + Java 核心类库(rt / java.base 等) │ │
│ └───────────────────────────────────────┘ │
│ + 编译器 javac、打包 jar、javadoc │
│ + 诊断工具 jps / jstack / jmap / jcmd │
│ + 交互式工具 jshell │
└─────────────────────────────────────────────┘

逐层拆解:

  • JVM(Java Virtual Machine):一台”虚拟的计算机”。它并不认识 .java 源文件,只认识符合《Java 虚拟机规范》的 .class 字节码。JVM 负责类加载、字节码校验、内存分配、垃圾回收、以及把字节码解释或编译成宿主机 CPU 能执行的机器指令。它屏蔽了操作系统与硬件的差异,这正是”一次编译,到处运行”的物理基础。值得注意的是,JVM 本身与 Java 语言并无绑定关系,任何能编译出合法字节码的语言(Kotlin、Scala、Groovy、Clojure)都能跑在 JVM 上。
  • JRE(Java Runtime Environment):JVM + 运行 Java 程序所必需的核心类库。如果你的机器上只需要运行一个 Java 程序(比如跑一个 jar 包),装 JRE 就够了。从 JDK 9 引入模块化之后,Oracle 不再单独提供 JRE 安装包,取而代之的是用 jlink 裁剪出最小运行时镜像。
  • JDK(Java Development Kit):JRE + 开发工具。核心是 javac 编译器,此外还包含 jar、javadoc、jdb、javap、jps、jstack、jmap、jcmd、jshell 等一整套工具链。学习阶段请一律安装 JDK,不要装 JRE。

一个常见的误解:装了 JDK 就”自带两个 JRE”。在 JDK 8 及以前,安装目录里确实会出现 jdk1.8.0_xxx\jre 和独立的 jre1.8.0_xxx 两个目录,前者供 JDK 内部工具使用,后者供浏览器插件与桌面集成使用。从 JDK 9 开始这种”嵌套 JRE”结构被取消,统一为模块化运行时。

1.2 OpenJDK 与 Oracle JDK 的区别

2006 年 Sun 公司把 Java 开源,诞生了 OpenJDK;2010 年 Oracle 收购 Sun,从此 Java 的演进由 OpenJDK 社区驱动,Oracle JDK 则是在 OpenJDK 源码基础上构建的商业发行版。两者在功能与性能上几乎完全一致(Oracle 官方明确说过 Oracle JDK 基于 OpenJDK,源码差异极小),真正的区别在许可协议、支持周期、附加组件三点。

对比维度 Oracle JDK OpenJDK 其他发行版(Temurin / Zulu / 龙井 / 毕昇)
许可证 NFTC(免费但生产商用需授权) GPL v2 + Classpath 例外,完全自由 多数为 GPL,免费商用
免费商用 JDK 17 之后版本有限制,需按员工数订阅 可 可
发布频率 与 OpenJDK 同步 上游源码 跟随上游,提供补丁
支持周期 商业支持可延长至 8 年以上 社区支持,版本更新即停止维护旧版 部分发行商提供商业 LTS
附加工具 Java Flight Recorder、Mission Control 等(多已开源) 部分缺失 视发行商而定
典型场景 有预算、需要官方 SLA 的大型企业 学习、个人项目、多数互联网公司 云上部署、容器镜像(体积更小)

选型建议:个人学习与绝大多数企业项目,直接选 Eclipse Temurin(原 AdoptOpenJDK) 或阿里巴巴的 Dragonwell、华为的 Bisheng JDK,许可干净、免费商用、容器镜像齐全。除非公司已经采购了 Oracle 的支持服务,否则没必要碰 NFTC 许可的灰色地带。

1.3 Java SE / EE / ME 三条技术路线

  • Java SE(Standard Edition):标准版,包含语言基础、集合、IO、并发、网络、反射、JVM 工具等。这是本系列的重点,也是所有 Java 开发的地基。
  • Java EE(Enterprise Edition):企业版,在 SE 之上叠加 Servlet、JSP、JPA、EJB、JTA、CDI 等规范,用于构建分布式后端系统。2017 年 Oracle 将其捐赠给 Eclipse 基金会,因商标原因更名为 Jakarta EE。javax.* 包名已迁移为 jakarta.*,这是 Spring Boot 3 强制要求 JDK 17 的重要原因之一。
  • Java ME(Micro Edition):微型版,面向嵌入式设备与功能机时代的移动端,如今基本退出主流视野。

需要强调:Java EE / Jakarta EE 只是一套接口规范,具体实现由 Tomcat、Jetty、WildFly 等应用服务器,以及 Spring、Hibernate 等框架提供。这也解释了为什么”学完 SE 还要学 Spring”——Spring 本质上是在 SE 之上提供了一整套工程化能力。

1.4 LTS 版本演进与选型建议

自 JDK 9 起,Oracle 改为每 6 个月发布一个版本,但只有部分版本被标记为 LTS(Long Term Support,长期支持版)。非 LTS 版本在下一个版本发布后即停止更新。

版本 发布年份 是否 LTS 关键特性 现状与建议
Java 8 2014 是 Lambda、Stream API、Optional、新的日期时间 API、默认方法 存量最大,但已成历史包袱;新项目不建议
Java 11 2018 是 局部变量 var、HTTP Client、移除 Java EE 模块、ZGC 实验版、单文件源码直接运行 稳定的过渡选择,云上常见
Java 17 2021 是 Sealed Class、Record、Switch 模式匹配预览、ZGC/Shenandoah 转正、强封装 JDK 内部 API 当前事实标准,Spring Boot 3 最低要求
Java 21 2023 是 虚拟线程(Virtual Threads)、Sequenced Collections、Record Patterns、分代 ZGC、字符串模板预览 新项目首选,并发模型发生质变
Java 25 2025 是 紧凑对象头、Scoped Values 转正、模块化导入声明、AOT 与启动性能增强 最新 LTS,适合追求极致性能的新系统

怎么选:

  • 纯新手:装 JDK 21。语法现代(Record、Switch 模式匹配都用得上),生态支持完备,网上资料也足够多。
  • 要跟公司项目:项目用什么你就装什么。公司还在 8,那就 8 + 21 双版本共存,用后文讲的工具切换。
  • 只维护老系统:JDK 8 或 11,注意 8 之后的 Oracle JDK 商用授权问题,优先切到 OpenJDK 发行版。

版本兼容性还有一个反直觉的点:高版本 JDK 编译出的 class 文件,低版本 JVM 无法运行。JDK 21 默认编译出的字节码主版本号是 65,丢到 JDK 8 的 JVM 上会直接抛 UnsupportedClassVersionError。解决办法是用 --release 8 参数交叉编译,这一点后文第三章会详细展开。

二、JDK 安装与环境变量

1.1 三平台安装方式

Windows

Windows 上没有官方包管理器(winget 可用但源版本滞后),推荐手工安装:

1
2
3
4
5
6
7
8
9
10
:: 1. 从 https://adoptium.net 下载 Temurin 21 (Windows x64) 的 .msi 安装包
:: 2. 双击安装,默认路径通常是:
:: C:\Program Files\Eclipse Adoptium\jdk-21.0.x-hotspot\
:: 3. 验证安装(打开新的 CMD 或 PowerShell,注意必须是新窗口)
java -version
javac -version

:: 4. winget 方式(可选,需管理员权限)
winget search EclipseAdoptium.Temurin.21.JDK
winget install EclipseAdoptium.Temurin.21.JDK

Windows 安装的坑有两个:一是安装路径不要有中文和空格(虽然现代 JDK 多数已支持,但很多老旧脚本、Maven wrapper、Gradle 依然会在空格路径上翻车);二是 .msi 安装器会自动往 PATH 里塞一个 C:\Program Files\Common Files\Oracle\Java\javapath 软链,导致你明明配了 JDK 21,命令行里跑的却是别的版本,这个目录必须检查并清理。

macOS

1
2
3
4
5
6
7
8
9
10
11
12
13
# 方式一:Homebrew(推荐,便于版本管理)
brew install openjdk@21

# Homebrew 安装的 JDK 不会自动软链到系统目录,需要手动做一次
sudo ln -sfn /opt/homebrew/opt/openjdk@21/libexec/openjdk.jdk \
/Library/Java/JavaVirtualMachines/openjdk-21.jdk

# Apple Silicon 芯片是 /opt/homebrew,Intel 芯片是 /usr/local
java -version

# 方式二:下载 .pkg 安装包
# https://adoptium.net 选择 macOS / aarch64 (M 系列) 或 x64 (Intel)
# 安装后位于 /Library/Java/JavaVirtualMachines/jdk-21.jdk/Contents/Home

macOS 上最关键的一条认知:JAVA_HOME 的正确值不是 JDK 根目录,而是 Contents/Home 子目录。获取它的标准做法是用系统自带的 /usr/libexec/java_home:

1
2
3
4
5
6
7
8
9
10
11
12
# 列出机器上所有已安装的 JDK
/usr/libexec/java_home -V

# 输出示例:
# Matching Java Virtual Machines (3):
# 21.0.5 (arm64) "Eclipse Adoptium" - /Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home
# 17.0.9 (arm64) "Eclipse Adoptium" - /Library/Java/JavaVirtualMachines/temurin-17.jdk/Contents/Home
# 1.8.0_392 (x86_64) "Oracle" - /Library/Java/JavaVirtualMachines/jdk8.jdk/Contents/Home

# 动态获取指定版本的 JAVA_HOME(写进 ~/.zshrc 的金标准写法)
export JAVA_HOME=$(/usr/libexec/java_home -v 21)
export PATH=$JAVA_HOME/bin:$PATH

Linux

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# Ubuntu / Debian(apt)
sudo apt update
sudo apt install openjdk-21-jdk # 只装运行时用 openjdk-21-jre-headless
java -version

# CentOS / Rocky / AlmaLinux(yum / dnf)
sudo dnf install java-21-openjdk-devel

# 多版本共存时切换系统默认 JDK(Debian 系)
sudo update-alternatives --config java
sudo update-alternatives --config javac

# 手动安装:下载 tar.gz 解压到统一目录
sudo mkdir -p /usr/local/java
sudo tar -zxvf OpenJDK21U-jdk_x64_linux_hotspot_21.0.5_11.tar.gz -C /usr/local/java
export JAVA_HOME=/usr/local/java/jdk-21.0.5+11
export PATH=$JAVA_HOME/bin:$PATH

1.2 JAVA_HOME / PATH / CLASSPATH 的作用与配置原理

这三个变量新手最容易背得滚瓜烂熟却完全不理解。逐个拆开讲。

JAVA_HOME

JAVA_HOME 并不是 JVM 或 javac 需要的,JVM 启动时压根不读它。它是一个约定俗成的环境变量,被以下工具依赖:

  • Maven、Gradle:读取 JAVA_HOME 决定用哪个 JDK 编译,找不到就退回 PATH 里的 java。
  • Tomcat、Jetty、Kafka、Elasticsearch、Hadoop 等中间件的启动脚本:通过 JAVA_HOME 定位 JVM。
  • IDEA、Eclipse:识别 JDK 时会扫描 JAVA_HOME。

也就是说,配了 JAVA_HOME 但没配 PATH,你在命令行敲 java 依然会报错;反过来只配 PATH 不配 JAVA_HOME,命令行能用但 Maven 会报警告。两个都要配,这是常识也是规范。

PATH

PATH 是操作系统用来查找可执行命令的搜索路径列表。当你在终端敲 javac 时,Shell 会沿 PATH 从左到右逐个目录查找名为 javac(Windows 上是 javac.exe)的可执行文件,找到第一个就执行,全都没找到就报 command not found / 不是内部或外部命令。

要点:PATH 有顺序,谁在前谁生效。这正是多版本 JDK 冲突的根源——如果你装过 JDK 8 又装了 21,且 8 的 bin 在 PATH 中排在前面,那么 java -version 显示的一定是 8。

CLASSPATH

CLASSPATH 告诉 JVM 去哪里找用户类和第三方类库,它才是真正影响类加载的环境变量。查找顺序遵循以下原则:

  1. 先找 JDK 自己的类库(Bootstrap ClassLoader 加载 java.base 等核心模块,Extension/Platform ClassLoader 加载扩展模块)——永远优先,你无法通过 CLASSPATH 覆盖 java.lang.String。
  2. 再找 CLASSPATH 指定的路径,这是 Application ClassLoader(系统类加载器)的职责,按 CLASSPATH 中出现的从左到右顺序查找。
  3. 路径可以是目录(JVM 会按包名转成子目录去查找对应的 .class)或 jar/zip 文件(JVM 会打开归档文件按条目查找)。
1
2
3
4
5
6
# CLASSPATH 典型写法(Linux/macOS 用冒号分隔,Windows 用分号分隔)
export CLASSPATH=.:$JAVA_HOME/lib/tools.jar:./lib/*

# 现代写法:JDK 9 之后 tools.jar 已被移除,且强烈不建议设置全局 CLASSPATH
# 推荐用 -cp 参数在运行时显式指定,作用域可控
java -cp "target/classes:lib/*" com.example.Main

JDK 9 之后请不要设置全局 CLASSPATH 环境变量。 它是一个进程级的全局状态,一旦设置会影响机器上所有 Java 程序:你在 A 项目设了一个 jar 路径,跑 B 项目时可能被意外污染,产生极难排查的类冲突。正确做法是每次运行用 -cp(或 -classpath)显式指定,或者交给 Maven/Gradle 管理。如果已经设了,赶紧删掉。

另外注意通配符 * 的语义:-cp lib/* 不等于 shell 展开,lib/* 只匹配该目录下所有 .jar 和 .JAR 文件,不匹配 .zip,也不递归子目录。而 lib/a* 这类带前缀的通配符是不被支持的,写了会被当成普通路径。

1.3 多版本 JDK 共存与切换

真实开发几乎必然要同时维护 JDK 8 项目和 JDK 17/21 项目,掌握切换方案是刚需。

方案 适用平台 原理 优点 缺点
SDKMAN Linux / macOS / WSL 管理 ~/.sdkman/candidates/java 下的多个版本,切换时修改 PATH 与 JAVA_HOME 一行命令装版本、切版本,支持 Maven/Gradle 一并管理 Windows 原生不支持(需 WSL)
jenv macOS / Linux 通过 shim 机制拦截 java 命令,支持全局/目录级/会话级三档切换 可以按项目目录自动切换,配合 .java-version 文件 只管 PATH,JAVA_HOME 需额外插件
update-alternatives Debian 系 Linux 系统级软链 /usr/bin/java 指向具体版本 系统自带,无额外依赖 只能切全局,粒度粗
手工切换 全平台 直接改环境变量 无依赖,最可控 繁琐,易出错
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
# ========== SDKMAN ==========
curl -s "https://get.sdkman.io" | bash
source "$HOME/.sdkman/bin/sdkman-init.sh"

sdk list java # 列出所有可安装版本
sdk install java 21.0.5-tem # 安装指定版本
sdk install java 17.0.9-tem
sdk use java 17.0.9-tem # 当前 shell 会话切换到 17
sdk default java 21.0.5-tem # 设置全局默认版本

# ========== jenv ==========
brew install jenv
jenv add /Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home
jenv add /Library/Java/JavaVirtualMachines/temurin-8.jdk/Contents/Home
jenv versions
jenv global 21 # 全局默认
jenv local 1.8 # 当前目录写 .java-version,进入目录自动切到 8

# ========== 手工切换(macOS / Linux,写进 ~/.zshrc)==========
# 定义函数而不是写死变量,随时可切
jdk() {
version=$1
export JAVA_HOME=$(/usr/libexec/java_home -v"$version")
export PATH=$JAVA_HOME/bin:$PATH
java -version
}
# 使用:jdk 8 / jdk 17 / jdk 21

三、第一个程序与运行全链路

1.1 HelloWorld 三连:编写、编译、运行

1
2
3
4
5
6
7
8
9
10
11
12
// HelloWorld.java
// 注意:public 类的名字必须与文件名完全一致(含大小写),这是 Java 的强制规定
public class HelloWorld {
// main 方法是 JVM 的入口约定,签名必须严格为:public static void main(String[] args)
public static void main(String[] args) {
System.out.println("Hello, Java 21!");
// 打印当前 JVM 的关键信息,验证跑的到底是不是你期望的那个 JDK
System.out.println("Java version: " + System.getProperty("java.version"));
System.out.println("JAVA_HOME 之外,JVM 实际路径: " + System.getProperty("java.home"));
System.out.println("当前类路径: " + System.getProperty("java.class.path"));
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 1. 编译:javac 把 .java 源文件翻译成 .class 字节码文件
javac HelloWorld.java

# 2. 查看产物
ls -l
# HelloWorld.java HelloWorld.class

# 3. 运行:注意 java 命令后面跟的是"类名",不是文件名,绝不能写 .class 后缀
java HelloWorld

# 输出:
# Hello, Java 21!
# Java version: 21.0.5
# JAVA_HOME 之外,JVM 实际路径: /Library/Java/JavaVirtualMachines/temurin-21.jdk/Contents/Home
# 当前类路径: .

最容易犯的三个错误:写 java HelloWorld.class(报 Could not find or load main class HelloWorld.class);文件名与 public 类名不一致(编译期报错);类名用了默认包却从别的目录运行(找不到类)。

1.2 javac 编译全流程:源码是怎么变成字节码的

javac 并不是一个简单的文本转换器,它是一条完整的编译流水线,主要阶段如下:

1
2
3
4
5
6
7
8
9
10
11
源码字符流
↓ ① 词法分析(Lexer / Scanner)
Token 流(关键字、标识符、字面量、运算符)
↓ ② 语法分析(Parser)
抽象语法树 AST
↓ ③ 符号表填充(Enter)
↓ ④ 注解处理(Annotation Processing,可多轮循环)
↓ ⑤ 语义分析与标注检查(Attribute:类型检查、方法重载解析)
↓ ⑥ 数据流分析与语法糖解糖(Flow / Desugar:泛型擦除、拆装箱、Lambda、增强 for、switch 字符串)
↓ ⑦ 字节码生成(Generate)
.class 字节码文件

每一步的价值:

  • 词法分析把字符流切成 Token,这一步不关心语法是否正确。
  • 语法分析按 Java 语法规则构造 AST,语法错误(; 缺失、括号不匹配)在这一阶段被捕获。
  • 注解处理是 Lombok、MapStruct、Dagger 这类框架的工作时机:它们通过 AbstractProcessor 在编译期读取/修改 AST,生成新的源文件,然后编译器再来一轮。Lombok 的 @Data 能凭空变出 getter/setter,就是这么来的。
  • 语义分析负责真正的类型检查:能不能赋值、方法调没调错参数、final 变量有没有被二次赋值。
  • 解糖(Desugar)是把 Java 语法糖还原成等价的朴素结构。泛型在这一步被擦除(List<String> 变成 List + 强制转型),Lambda 被转成 invokedynamic 调用点,增强 for 循环被转成迭代器。理解”泛型是编译期语法糖、运行期已被擦除”,是理解泛型各种诡异限制(如不能 new T()、不能 instanceof List<String>)的钥匙。
  • 字节码生成输出符合 JVM 规范的 .class 文件。

1.3 class 文件结构解剖

.class 文件是一份严格二进制格式的协议,不是文本。可以用十六进制查看:

1
2
3
4
5
6
# 查看 class 文件头 16 字节
xxd -l 16 HelloWorld.class
# 00000000: cafe babe 0000 0041 0034 0a00 0200 0307 .......A.4......

# 更友好的方式:javap 反编译
javap -c -p HelloWorld.class

cafe babe 就是传说中的魔数(magic number),固定为 0xCAFEBABE,JVM 用它快速判定”这是不是一个合法的 class 文件”。紧接着的 4 字节是版本号:0x00000041 = 65,对应 Java 21。

完整结构如下(顺序严格):

区域 内容 说明
magic 0xCAFEBABE 魔数,身份标识
minor_version / major_version 次/主版本号 Java 8=52,11=55,17=61,21=65,25=69
constant_pool_count / constant_pool 常量池 存放字面量、类名、方法名、字段名、描述符等符号引用,是 class 文件中最大的部分
access_flags 访问标志 ACC_PUBLIC、ACC_FINAL、ACC_SUPER 等
this_class / super_class 本类与父类索引 指向常量池
interfaces 接口索引集合 支持多实现
fields 字段表 字段名、描述符、属性
methods 方法表 方法名、描述符、Code 属性(字节码指令就在这里)
attributes 属性表 SourceFile、LineNumberTable、LocalVariableTable、BootstrapMethods 等

常量池是理解类加载与反射的关键。它存放的是符号引用:一个 System.out.println("...") 调用,在 class 文件里并不是内存地址,而是常量池中形如 java/lang/System.out:Ljava/io/PrintStream; 的字符串。只有在类加载的解析(Resolution)阶段,这些符号引用才会被翻译成 JVM 内存中的直接引用。这也是为什么 Java 的方法调用是”运行时动态绑定”的。

1
2
3
4
5
6
7
8
# 查看常量池与完整字节码助记符
javap -v HelloWorld.class

# 只看方法签名(不含字节码)
javap HelloWorld.class

# 反编译出接近 Java 源码的伪代码(需第三方工具 CFR / Procyon / Fernflower)
java -jar cfr.jar HelloWorld.class
1
2
3
4
5
6
7
// javap -c 输出的 main 方法字节码(节选),逐条解读
public static void main(java.lang.String[]);
Code:
0: getstatic #7 // Field java/lang/System.out:Ljava/io/PrintStream;
3: ldc #13 // String Hello, Java 21! ← 从常量池加载常量入栈
5: invokevirtual #15 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
8: return

这段字节码体现了 JVM 基于操作数栈的执行模型:getstatic 把 System.out 对象引用压入操作数栈,ldc 把字符串常量压栈,invokevirtual 弹出这两个参数并调用方法。没有寄存器,全靠栈,这就是字节码能跨平台的原因之一。

1.4 java 与 javac 常用参数速查

参数 命令 作用 典型场景
-d <dir> javac 指定 class 输出目录,并按包名自动建子目录 javac -d target/classes src/**/*.java
-cp / -classpath 两者通用 指定依赖查找路径,覆盖 CLASSPATH 环境变量 javac -cp lib/*:. Main.java
--release <N> javac 交叉编译:用当前 JDK 编译出目标版本字节码,并限制只能使用该版本 API javac --release 8 Main.java
-encoding UTF-8 javac 指定源文件编码,中文注释乱码的救星 必加
-Xlint:all javac 打开全部编译警告 代码质量检查
-parameters javac 保留方法参数名,反射才能拿到真实参数名(Spring MVC 依赖) 框架开发
-sourcepath <dir> javac 指定依赖源码位置,编译器可自动编译缺失的类 多模块手工编译
-version / --version 两者通用 打印版本 环境校验
-Xmx512m java 设置堆最大值 java -Xmx2g -jar app.jar
-Dkey=value java 设置系统属性,代码里用 System.getProperty 读 配置注入
-verbose:class java 打印每个类的加载来源,排查类冲突神器 类冲突排查
--add-opens java 打开模块封装,解决 JDK 17 反射内部 API 报错 迁移老项目
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 一次规范的编译命令(真实项目手工编译写法)
javac -encoding UTF-8 -parameters -Xlint:all \
-d target/classes \
-cp "lib/*" \
$(find src/main/java -name "*.java")

# 交叉编译:用 JDK 21 编出 JDK 8 能跑的字节码
javac --release 8 -d out HelloWorld.java

# 单文件源码直接运行(JDK 11+),不产生 .class 文件,适合快速验证
java HelloWorld.java

# 打印类加载来源,排查"到底加载了哪个 jar 里的类"
java -verbose:class -cp "lib/*" com.example.Main | grep "com.example"

1.5 编译期与运行期的本质区别

这条边界必须分清,很多错误的根源就是混淆了它。

  • 编译期:javac 在工作。检查语法、类型、可达性;做泛型擦除、常量折叠(final int x = 1 + 2 会被优化成 3)、重载解析(调用哪个重载版本在编译期就确定了)。编译期错误是静态的,IDE 能实时画红线。
  • 运行期:JVM 在工作。类加载、链接、初始化、GC、JIT 编译、反射、多态分派。运行期错误(空指针、数组越界、类型转换失败、类找不到)IDE 画不出来,只能靠日志和测试。

一个经典对照:方法重载(Overload)是编译期决定的(静态分派),方法重写(Override)是运行期决定的(动态分派)。所以下面这段代码的输出常常让人意外:

1
2
3
4
5
6
7
8
9
10
// 重载的静态分派:编译期就按"声明类型"选定了方法,与对象实际类型无关
public class DispatchDemo {
static void say(Object o) { System.out.println("Object"); }
static void say(String s) { System.out.println("String"); }

public static void main(String[] args) {
Object obj = "hello"; // 声明类型是 Object,实际类型是 String
say(obj); // 输出 Object,不是 String
}
}

1.6 类加载的时机与全过程

一个类从磁盘到可用,经历加载 → 链接 → 初始化三大步,其中链接又细分为验证、准备、解析:

1
2
3
4
5
6
7
8
9
  加载 Loading          链接 Linking                    初始化 Initialization
┌──────────────┐ ┌──────────────────────────┐ ┌──────────────────────┐
│ 通过全限定名 │ │ 验证 Verify: │ │ 执行 <clinit>() │
│ 获取二进制流 │ → │ 魔数/版本/字节码合法性 │ → │ 静态变量赋值 │
│ 转为方法区内 │ │ 准备 Prepare: │ │ 静态代码块执行 │
│ 的数据结构 │ │ 静态变量分配内存并置零值 │ │ 父类先于子类初始化 │
│ 生成 Class 对象│ │ 解析 Resolve: │ │ JVM 保证线程安全加锁 │
└──────────────┘ │ 符号引用→直接引用 │ └──────────────────────┘
└──────────────────────────┘

初始化时机是面试高频点,JVM 规范规定有且仅有以下六种情况会触发类初始化(称为主动引用):

  1. 遇到 new、getstatic、putstatic、invokestatic 四条字节码指令时。
  2. 使用 java.lang.reflect 对类进行反射调用时。
  3. 初始化一个类时,其父类尚未初始化(先初始化父类)。
  4. 虚拟机启动时,包含 main 方法的主类。
  5. JDK 7 动态语言支持中,MethodHandle 解析结果为静态字段/方法且对应类未初始化。
  6. 接口中定义了 default 方法,其实现类初始化时,该接口先被初始化。

被动引用不会触发初始化,这是最容易踩的反例:

1
2
3
4
5
6
7
8
9
10
11
12
13
public class InitDemo {
public static void main(String[] args) {
// ① 通过子类引用父类的静态字段:只有父类初始化,子类不初始化
System.out.println(Sub.value);

// ② 通过数组定义引用类:不触发类初始化,JVM 自动生成数组类
Super[] arr = new Super[10];

// ③ 引用编译期常量(static final + 字面量):不触发初始化
// 因为常量在编译期就已经被"常量传播"进调用方的常量池了
System.out.println(Const.MESSAGE);
}
}

类加载器层面还遵循双亲委派模型(Parents Delegation Model):一个类加载请求会先委派给父加载器,只有父加载器无法完成时才自己尝试加载。层级为 Bootstrap ClassLoader(加载核心模块)→ Platform/Extension ClassLoader → Application ClassLoader(加载 CLASSPATH)→ 自定义 ClassLoader。这套机制保证了核心类库不会被用户同名类篡改(你自己写个 java.lang.String 是加载不进去的),也是 Tomcat 实现多应用隔离、SPI 机制需要打破双亲委派(线程上下文类加载器)的背景原因。

四、IDE 与开发效率

1.1 IDEA 工程结构与核心概念

IntelliJ IDEA 是目前 Java 开发的事实标准 IDE。它的工程模型有几层需要分清:

概念 层级 说明 常见坑
Project 最外层 对应一个 .idea 目录和 .iml 文件,可以包含多个 module 不要把多个不相关仓库塞进一个 Project
Module 中间层 一个可独立编译的子工程,有自己的源码目录、依赖、输出目录 Maven 多模块项目导入后,每个 Maven module 对应一个 IDEA module
Facet Module 内的特性 声明”这个 module 具备某种能力”,如 Spring、Web、JPA Facet 丢失会导致 Spring 配置不识别
SDK 全局 就是 JDK 实例,在 File → Project Structure → SDKs 管理 三个地方要一致:Project SDK、Module SDK、Maven 的 JAVA_HOME
Language Level 项目属性 决定 IDE 允许你使用哪些语法(如 var、Record) 低于 JDK 版本会导致语法报红

IDEA 的 SDK 配置有三处,必须全部核对:

  • File → Project Structure → Project → SDK:编译时用的 JDK。
  • File → Project Structure → Modules → Dependencies → Module SDK。
  • Settings → Build Tools → Maven → Runner → JRE:执行 Maven 目标时用的 JDK,这一处经常被人忽略,导致”IDE 里编译通过,mvn 命令行报错”。

另外务必设置:Settings → Editor → File Encodings 全部改为 UTF-8,并勾选 Transparent native-to-ascii conversion(properties 文件中文转义)。这是 Windows 上中文乱码的头号原因。

1.2 必会快捷键

功能 macOS Windows / Linux
万能搜索(类/文件/动作) Shift Shift(双击 Shift) 双击 Shift
搜索类 Cmd + O Ctrl + N
搜索文件 Cmd + Shift + O Ctrl + Shift + N
全局文本搜索 Cmd + Shift + F Ctrl + Shift + F
查看方法/类定义 Cmd + B 或 Cmd + 点击 Ctrl + B
查看方法实现 Cmd + Alt + B Ctrl + Alt + B
查看类继承结构 Ctrl + H Ctrl + H
生成代码(getter/setter/构造器) Cmd + N Alt + Insert
自动补全 Ctrl + Space Ctrl + Space
智能补全(按类型过滤) Ctrl + Shift + Space Ctrl + Shift + Space
万能修复(生成变量/导包/try-catch) Option + Enter Alt + Enter
重命名(安全重构) Shift + F6 Shift + F6
抽取变量/方法 Cmd + Option + V / M Ctrl + Alt + V / M
格式化代码 Cmd + Option + L Ctrl + Alt + L
优化导入 Ctrl + Option + O Ctrl + Alt + O
查看方法参数 Cmd + P Ctrl + P
最近文件 Cmd + E Ctrl + E
运行当前上下文 Ctrl + Shift + R Ctrl + Shift + F10
调试当前上下文 Ctrl + Shift + D Shift + F9

1.3 断点调试技巧

调试是程序员最重要的基本功之一,比会写 System.out.println 高一个数量级。

基础断点操作:在行号左侧单击打断点;F8 Step Over(单步跳过)、F7 Step Into(进入方法)、Shift+F8 Step Out(跳出当前方法)、F9 Resume(继续到下一个断点)、Alt+F9 运行到光标处。

条件断点:右键点击断点 → 在 Condition 框输入布尔表达式,只有表达式为 true 时才暂停。这是调试循环的神器。

1
2
3
4
5
6
7
// 场景:一个 10 万次的循环里,只有第 8888 次的数据有问题
// 条件断点表达式示例(直接写在这行左侧的断点上):
// i == 8888 && user != null && user.getAge() > 100
// 或者用断点命中次数:右键断点 → 勾选 "Pass count" 填 8888
for (int i = 0; i < list.size(); i++) {
process(list.get(i));
}

其他高级断点:

  • 字段断点(Field Watchpoint):在字段声明行打断点,任何对该字段的读写都会暂停,用来追”这个变量到底在哪被改了”。
  • 方法断点:在方法签名行打断点,方法进入/退出都会暂停。
  • 异常断点:Run → View Breakpoints → + → Java Exception Breakpoints,填入 NullPointerException。这样任何位置抛出 NPE 时都会自动暂停在抛出的那一行,无需猜测。这是排查 NPE 最快的手段,没有之一。
  • 表达式求值:暂停状态下按 Alt+F8,可以执行任意表达式、调用方法来验证假设。
  • 强制返回 / 修改变量:在 Variables 面板右键变量 Set Value,可以现场改数据走不同的分支,不用重启。
  • Drop Frame:在调用栈里右键 Drop Frame,可以回退到上一层栈帧重新执行(注意:已经改变的对象状态不会回滚)。

远程调试原理:IDEA 通过 JDWP(Java Debug Wire Protocol) 协议与远端 JVM 通信。远端 JVM 启动时加载一个 JDWP Agent,监听一个 TCP 端口;IDEA 作为调试器(debugger)连接上去,完成断点下发、栈帧读取、变量求值。核心是 JVMTI(JVM Tool Interface)这一层本地接口。

1
2
3
4
5
6
7
8
9
10
11
12
# 远端启动(JDK 5-8 写法)
java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=5005 -jar app.jar

# JDK 9+ 必须指定 address 的主机名,否则只能本机连接;*:5005 表示允许任意网卡
java -agentlib:jdwp=transport=dt_socket,server=y,suspend=n,address=*:5005 -jar app.jar

# 参数说明:
# transport=dt_socket 使用 socket 通信
# server=y 本 JVM 作为被调试的服务端(等待 IDE 连入)
# suspend=n 启动后不挂起,立即运行(设为 y 则会卡住等调试器连上再继续,
# 适合调试"启动阶段"的代码)
# address=*:5005 监听端口

配好之后在 IDEA 里 Run → Edit Configurations → + → Remote JVM Debug,填 host 和 5005 即可。生产环境严禁长期开启远程调试端口,它等于给攻击者开了一扇能执行任意代码的大门。

1.4 JShell 交互式验证

JDK 9 引入的 JShell(REPL)是验证 API 行为的利器,比写一个 main 方法再跑快得多。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$ jshell
| 欢迎使用 JShell -- 版本 21.0.5
jshell> int x = 10
x ==> 10

jshell> x * 2
$2 ==> 20

jshell> String s = new String("abc").intern()
s ==> "abc"

jshell> s == "abc"
$4 ==> true // 验证字符串常量池行为,秒懂 intern()

jshell> var list = List.of(1, 2, 3)
list ==> [1, 2, 3]

jshell> /vars // 列出所有定义的变量
jshell> /methods // 列出所有定义的方法
jshell> /imports // 查看已导入的包
jshell> /edit // 打开编辑器批量修改
jshell> /save a.jsh // 保存会话
jshell> /open a.jsh // 加载会话
jshell> /exit

1.5 JDK 自带工具速查表

工具 作用 典型用法
javac 编译器 javac -d out Main.java
java 启动器 java -jar app.jar
javap 反编译 class(看字节码、常量池) javap -c -p MyClass
jar 打包/解包 jar cvf app.jar -C out .
jps 列出本机所有 JVM 进程及 PID jps -l
jcmd 向 JVM 发诊断命令(首选,功能最全) jcmd <pid> VM.flags、jcmd <pid> GC.heap_info
jstack 打印线程栈,排查死锁/CPU 飙高 jstack -l <pid> > thread.txt
jmap 堆内存快照/直方图 jmap -histo <pid>、jmap -dump:format=b,file=heap.hprof <pid>
jstat 实时 GC 与类加载统计 jstat -gcutil <pid> 1000
jinfo 查看/修改 JVM 参数 jinfo -flags <pid>
jconsole / jvisualvm 图形化监控 直接运行
jshell 交互式 REPL jshell
jlink 裁剪出最小运行时镜像 jlink --module-path $JAVA_HOME/jmods --add-modules java.base --output myjre
jpackage 打包成原生安装包(exe/dmg) jpackage --input lib --main-jar app.jar
jdeps 分析类/包的依赖 jdeps -s app.jar
1
2
3
4
5
6
7
8
9
10
11
# 一条龙排查 CPU 飙高的标准流程
jps -l # 1. 找到 Java 进程 PID
top -Hp <pid> # 2. 找出占用 CPU 最高的线程 ID(十进制)
printf "%x\n" <tid> # 3. 转成十六进制,如 1a2b
jstack <pid> | grep -A 20 "nid=0x1a2b" # 4. 定位到具体代码行

# 用 jcmd 一把梭(JDK 推荐替代 jstack/jmap 的统一入口)
jcmd <pid> Thread.print # 等价于 jstack
jcmd <pid> GC.heap_info # 堆概要
jcmd <pid> VM.system_properties # 查看所有系统属性
jcmd <pid> help # 列出该 JVM 支持的所有命令

五、包与模块化

1.1 package、import 与全限定名

package 的本质是命名空间加上目录结构约定。它的三重作用:

  1. 避免命名冲突:两个 User 类分属 com.a.User 和 com.b.User,互不干扰。
  2. 控制访问权限:protected 和默认(包级私有)访问修饰符的可见范围与包直接挂钩。
  3. 组织代码结构,并强制与磁盘目录一一对应(javac 会校验)。
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
// 文件路径必须是:src/com/example/demo/UserService.java
package com.example.demo;

// 单类型导入:精确,推荐。IDEA 默认策略
import java.util.List;
import java.util.ArrayList;

// 按需导入:导入整个包下所有 public 类型
import java.util.*;
// 注意:按需导入只是"编译期省事",不会增加运行期开销,
// 但会降低可读性并可能引入歧义,大型项目通常禁用

// 静态导入:可以直接用静态成员而不写类名
import static java.lang.Math.PI;
import static java.util.Arrays.asList;

public class UserService {
// 全限定名(Fully Qualified Name)= 包名 + 类名
// com.example.demo.UserService
private List<String> names = new ArrayList<>(asList("Tom", "Jerry"));

public double area(double r) {
return PI * r * r; // 静态导入后可直接用 PI
}

// 两个包都有同名类时,必须用全限定名消歧义
public void ambiguous() {
java.sql.Date sqlDate = new java.sql.Date(System.currentTimeMillis());
java.util.Date utilDate = new java.util.Date();
}
}

默认包(没有 package 声明的类)是一个陷阱。 同一个包内的类可以互相引用而不需 import,但默认包里的类无法被任何带包名的类 import,因为 Java 语法不允许 import UserService;。所以:任何正式代码都必须声明 package,哪怕只是 package com.example;。只在 JShell 或临时验证时可以不写。

1.2 JDK 9 模块化与 JPMS

JDK 9 引入的 JPMS(Java Platform Module System,项目代号 Jigsaw) 是自 Java 诞生以来最大的一次平台级重构。它要解决三个历史问题:

  1. JDK 过于臃肿:JDK 8 的 rt.jar 有 60MB+,即使跑个 HelloWorld 也要加载全部。模块化的 java.base 最小可裁剪到几十 MB,配合 jlink 能做出 30MB 左右的运行时镜像,容器化收益巨大。
  2. JAR Hell:classpath 是”一锅乱炖”,同名的类谁在前面谁生效,A 依赖 guava 20、B 依赖 guava 30,只能二选一。模块系统让依赖显式化、可校验。
  3. 强封装缺失:任何人都能反射访问 sun.misc.Unsafe 这类内部 API,导致 JDK 无法演进而不得不背负历史包袱。模块化后内部 API 被真正封装(JDK 17 起默认强封装,--illegal-access 选项被移除)。

模块声明写在 module-info.java 里:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 文件路径:src/main/java/module-info.java
module com.example.app {
// requires:声明本模块依赖哪些模块(传递性依赖需用 requires transitive)
requires java.base; // 隐式依赖,可省略(所有模块自动依赖 java.base)
requires java.sql; // 依赖 JDK 的 java.sql 模块
requires transitive com.example.core; // 传递依赖:依赖本模块的模块自动获得 com.example.core
requires static com.example.optional; // 编译期需要,运行期可选

// exports:把本模块的哪些包暴露出去(不 export 的包外部完全不可见)
exports com.example.app.api;
exports com.example.app.spi to com.example.plugin; // 限定导出给指定模块

// opens:允许运行期反射访问(Spring/MyBatis/Hibernate 这类框架需要)
opens com.example.app.entity;
// 更宽松:整个模块开放反射
// open module com.example.app { ... }

// uses:声明本模块使用某个服务接口(配合 ServiceLoader 实现 SPI)
uses com.example.spi.Parser;
// provides ... with ...:声明本模块提供服务实现
provides com.example.spi.Parser with com.example.app.JsonParser;
}

模块化 vs classpath 的核心差异对照:

维度 传统 classpath JPMS 模块路径
依赖声明 隐式,靠 CLASSPATH 环境变量或 -cp 显式,module-info.java 中 requires
缺失依赖的时机 运行期 NoClassDefFoundError 启动期即报错(模块解析失败)
可见性控制 public 即全局可见 public 且所在包被 exports 才可见
反射 可反射任何类(含 JDK 内部) 需 opens,否则 InaccessibleObjectException
依赖冲突 JAR Hell,先到先得 启动时检测重复模块、版本冲突
打包 fat jar / war jlink 裁剪运行时、jmod 格式

现实提醒:绝大多数 Spring Boot 项目仍然跑在 classpath 上,并没有使用 JPMS。Spring、Hibernate 等框架对模块化的支持是”自动模块”级别的兼容。所以日常开发中你真正需要知道的只有两条:一是 JDK 17 之后遇到 InaccessibleObjectException 时知道是模块化强封装导致的,用 --add-opens java.base/java.lang=ALL-UNNAMED 解决;二是理解 module-info.java 长什么样,遇到模块化项目能读懂。

1.3 jar 打包与运行

jar 命令本质是一个 zip 工具,额外支持生成 META-INF/MANIFEST.MF 清单文件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 打包:c=create, v=verbose, f=file, -C 先切换目录再打包(避免把绝对路径打进去)
jar cvf app.jar -C target/classes .

# 指定主类(Main-Class),让 jar 可直接执行
# e 选项会自动在 MANIFEST.MF 中写入 Main-Class
jar cvfe app.jar com.example.Main -C target/classes .

# 查看 jar 内容
jar tf app.jar

# 解压
jar xf app.jar

# 查看清单文件
unzip -p app.jar META-INF/MANIFEST.MF

MANIFEST.MF 的关键属性:

属性 作用 示例
Main-Class 指定入口类,配合 java -jar 使用 Main-Class: com.example.Main
Class-Path 声明依赖的外部 jar 路径(相对路径,空格分隔) Class-Path: lib/a.jar lib/b.jar
Manifest-Version 清单版本,固定 1.0 Manifest-Version: 1.0
Main-Class 注意 值后面必须换行,且不能有前导空格,否则解析失败 —

java -jar 与 -cp 是互斥的,这是新手最常踩的坑之一。当使用 java -jar app.jar 时,JVM 会完全忽略 -cp 参数和 CLASSPATH 环境变量,只认两处:jar 内部的 MANIFEST.MF 里的 Class-Path,以及 jar 内打包的类。所以 java -jar app.jar -cp lib/* 是无效的,lib 根本不会被加载。解决办法有三:把依赖写进 MANIFEST.MF 的 Class-Path;打成 fat jar(把所有依赖塞进一个 jar,Spring Boot 的 spring-boot-maven-plugin 就是这么干的);或者改用 java -cp "app.jar:lib/*" com.example.Main。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 方式一:MANIFEST 声明 Class-Path(依赖放在 jar 同级的 lib/ 目录)
# MANIFEST.MF 内容:
# Manifest-Version: 1.0
# Main-Class: com.example.Main
# Class-Path: lib/gson-2.10.jar lib/commons-lang3-3.12.0.jar
java -jar app.jar

# 方式二:classpath 通配符(注意 * 必须加引号,防止被 shell 展开)
java -cp "app.jar:lib/*" com.example.Main # Linux / macOS
java -cp "app.jar;lib\*" com.example.Main # Windows(分隔符是分号)

# 方式三:模块化运行
java --module-path mods -m com.example.app/com.example.Main

# 常见排错参数
java -verbose:class -jar app.jar # 打印每个类的加载来源

六、Maven 入门

1.1 为什么需要构建工具

手工用 javac 编译在单文件时代可行,一旦项目变复杂就会立刻崩溃:几十个源文件要一个个列出来;依赖的 jar 要去官网手动下载,还得自己下载”依赖的依赖”;版本升级时不知道哪些 jar 互相兼容;编译、测试、打包、部署的工序全靠人肉记忆。

Maven 用两个核心思想解决这些问题:约定优于配置(Convention over Configuration) 和 依赖管理。

  • 约定优于配置:只要你把源码放在 src/main/java、测试放在 src/test/java,Maven 就知道该编译什么、输出到哪里,无需任何配置。这让所有 Maven 项目结构一致,新人接手立刻上手。
  • 依赖管理:你只需声明”我要 gson 2.10.1”,Maven 会自动从中央仓库下载它、下载它的传递依赖、并把它们正确加入编译与运行的 classpath。

1.2 Maven 坐标与标准目录结构

坐标(GAV) 是 jar 在全球仓库中的唯一身份证:

坐标元素 含义 命名惯例 示例
groupId 组织/公司/项目组标识 反写域名 + 项目名 com.alibaba、org.springframework.boot
artifactId 项目(模块)名 小写,单词用 - 分隔 spring-web、fastjson2
version 版本号 x.y.z,-SNAPSHOT 表示快照(开发中) 3.2.1、1.0.0-SNAPSHOT
packaging 打包类型 jar(默认)/ war / pom(父工程/聚合) jar
classifier 附属构件区分符 可选 sources、javadoc

标准目录结构(必须背下来,IDE 会自动识别):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
my-app/
├── pom.xml
├── src/
│ ├── main/
│ │ ├── java/ # 主源码(会自动编译)
│ │ ├── resources/ # 主资源(会被复制到 classpath 根,如 application.yml)
│ │ └── webapp/ # Web 应用根目录(仅 war 工程)
│ └── test/
│ ├── java/ # 测试源码(仅测试阶段编译,不进生产包)
│ └── resources/ # 测试资源
└── target/ # 构建输出目录(mvn clean 会删掉整个目录)
├── classes/ # 编译后的 .class
├── test-classes/ # 测试编译产物
├── surefire-reports/ # 测试报告
└── my-app-1.0.0.jar # 打包产物

1.3 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
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
<?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">
<!-- POM 模型版本,固定 4.0.0,几乎不改 -->
<modelVersion>4.0.0</modelVersion>

<!-- 本项目坐标 -->
<groupId>com.example</groupId>
<artifactId>demo-app</artifactId>
<version>1.0.0-SNAPSHOT</version>
<packaging>jar</packaging>

<!-- 属性:集中管理版本号,避免到处硬编码 -->
<properties>
<maven.compiler.source>21</maven.compiler.source>
<maven.compiler.target>21</maven.compiler.target>
<maven.compiler.release>21</maven.compiler.release>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<gson.version>2.10.1</gson.version>
</properties>

<dependencies>
<dependency>
<groupId>com.google.code.gson</groupId>
<artifactId>gson</artifactId>
<version>${gson.version}</version>
</dependency>

<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
<version>5.10.2</version>
<scope>test</scope>
</dependency>

<!-- 排除传递依赖:解决版本冲突、剔除不需要的间接依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>3.2.5</version>
<exclusions>
<exclusion>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-logging</artifactId>
</exclusion>
</exclusions>
</dependency>
</dependencies>

<build>
<finalName>demo-app</finalName>
<plugins>
<!-- 指定编译版本,避免每次都用默认 1.5 的老版本 -->
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.13.0</version>
<configuration>
<release>21</release>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
</plugins>
</build>
</project>

1.4 依赖范围 scope 详解

scope 决定依赖在哪些阶段生效、是否传递。这是 Maven 最容易被误解也最重要的概念之一。

scope 编译 classpath 测试 classpath 运行 classpath 是否传递 典型场景
compile(默认) 是 是 是 是 业务代码直接用到的库,如 gson、commons-lang3
provided 是 是 否 否 容器已提供:Servlet API、Lombok、JDK 自带工具
runtime 否 是 是 是 编译用接口、运行时才需实现:JDBC 驱动(MySQL connector)
test 否 是 否 否 JUnit、Mockito、测试专用工具
system 是 是 是 否 已从 Maven 3.x 起废弃,需配合 systemPath 指向本地文件,不要用
import — — — — 仅用于 <dependencyManagement> 且 type=pom,导入 BOM 依赖清单

几个必须理解的点:

  • provided 为什么存在:servlet-api 在编译时需要(HttpServletRequest 得能 import),但部署到 Tomcat 时 Tomcat 自己的 lib 目录里已经有了,如果你打进去会造成两个不同 ClassLoader 加载同名类,直接报 ClassCastException。所以标记为 provided——编译打包时用它,但不进最终的 lib 目录。
  • Lombok 用 provided:因为它只在编译期通过注解处理器起作用,运行期完全不需要。
  • JDBC 驱动用 runtime:你的代码只面向 java.sql 接口编程,编译期不需要具体的 MySQL 驱动;但运行期必须有,否则 Class.forName 或 SPI 加载会失败。
  • import scope 是依赖管理利器:Spring Boot 的 spring-boot-dependencies、Spring Cloud 的 spring-cloud-dependencies 都是 BOM(Bill of Materials),用 import 导入后,你声明 Spring 组件时可以不写版本号,由 BOM 统一保证兼容性。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!-- 导入 Spring Boot BOM,之后声明 starter 无需写 version -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>3.2.5</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<dependencies>
<!-- 版本由上面的 BOM 决定,无需写 <version> -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>

1.5 依赖传递与冲突调解

Maven 的依赖是传递的:A 依赖 B,B 依赖 C,那么 A 自动获得 C,不需要显式声明。这带来便利,也带来冲突——当两条依赖路径引入了同一个组件的不同版本时,Maven 用两条规则调解:

  1. 最短路径优先(Nearest Wins):路径深度浅的版本胜出。
  2. 路径相同时,先声明者优先(First Declaration Wins):在 pom.xml 中先声明的那条路径上的版本胜出。
1
2
3
4
5
6
7
项目 A
├── B:1.0 ──────→ C:1.0
└── D:1.0 ──→ E:1.0 ──→ C:2.0

路径:A → B → C:1.0 深度 2
A → D → E → C:2.0 深度 3
结果:C:1.0 胜出(最短路径优先)
1
2
3
4
5
6
7
8
9
10
# 查看依赖树,排查冲突的第一手段
mvn dependency:tree

# 只看冲突(被仲裁掉的依赖会标 omitted for conflict)
mvn dependency:tree -Dverbose

# 找出哪些依赖被声明但未使用 / 使用了但未声明
mvn dependency:analyze

# 强制把某个版本固定下来(比 exclusions 更省心)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
<!-- 方式一:dependencyManagement 锁定版本(推荐,对所有传递依赖生效) -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
<version>2.17.1</version>
</dependency>
</dependencies>
</dependencyManagement>

<!-- 方式二:exclusions 排除掉不想要的那条传递路径 -->
<dependency>
<groupId>com.example</groupId>
<artifactId>some-lib</artifactId>
<version>1.0</version>
<exclusions>
<exclusion>
<groupId>commons-logging</groupId>
<artifactId>commons-logging</artifactId>
</exclusion>
</exclusions>
</dependency>

排查依赖问题的标准流程:先 mvn dependency:tree -Dverbose 看清依赖究竟从哪来、被谁仲裁掉了;如果是版本冲突,用 dependencyManagement 锁定统一版本;如果是引入了不需要的传递依赖(比如 commons-logging 和 log4j 打架),用 exclusions 精准排除;最后 mvn dependency:analyze 清理无用声明。永远不要靠”删掉本地仓库目录重新下载”来碰运气。

1.6 Maven 生命周期与常用命令

Maven 有三套相互独立的生命周期:clean(清理)、default(构建,也叫 build)、site(生成站点文档)。每套生命周期由若干阶段(phase)组成,阶段是有序的——执行某个阶段会自动先执行它之前的所有阶段。这就是 mvn test 会自动先编译的原因。

default 生命周期的主要阶段顺序:

1
2
3
4
validate → compile → test → package → verify → install → deploy
↓ ↓ ↓ ↓ ↓ ↓ ↓
校验项目 编译主源 跑单元 打成jar/ 集成测试 安装到本地 发布到
结构完整性 码 测试 war 质量检查 仓库 远程仓库
命令 执行到哪个阶段 作用 何时用
mvn clean clean 生命周期 删除 target/ 目录 构建异常、产物污染时先 clean
mvn compile compile 编译主源码到 target/classes 快速验证能否编译
mvn test test 编译并运行单元测试 提交前自测
mvn package package 打包成 jar/war 到 target/ 出部署包
mvn verify verify 跑集成测试、质量检查 CI 流水线
mvn install install 安装到本地仓库 ~/.m2/repository 多模块项目,让别的模块能引用
mvn deploy deploy 发布到远程私服(Nexus/Artifactory) 正式发版
mvn clean install 组合 先清后装,最常用的组合命令 日常构建
mvn -DskipTests package 组合 打包但跳过测试执行 紧急构建(不推荐常态化)
mvn -Dmaven.test.skip=true package 组合 跳过测试编译和执行(比上面的更彻底) —
mvn -o clean install 组合 -o 离线模式,不访问网络 断网/加速
mvn -U clean install 组合 -U 强制更新 SNAPSHOT 依赖 依赖没拉到最新版时
mvn -pl module-a -am install 组合 -pl 指定模块、-am 同时构建其依赖模块 多模块项目只构建一个模块
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# 日常开发最常用的完整命令(带常用参数)
mvn -U clean install -DskipTests -B

# 多模块项目只构建指定模块及其依赖
mvn -pl user-service -am clean install

# 打印有效 POM(所有父 POM、BOM 合并后的最终配置),排查"这个配置到底从哪来"
mvn help:effective-pom

# 查看当前项目的可用插件与版本
mvn help:describe -Dplugin=compiler -Ddetail

# 加速:配置国内镜像(~/.m2/settings.xml)
# <mirror>
# <id>aliyun</id>
# <mirrorOf>central</mirrorOf>
# <name>Aliyun Maven</name>
# <url>https://maven.aliyun.com/repository/public</url>
# </mirror>

七、常见报错排查速查与学习路线

1.1 三个”找不到类”错误的本质区别

这是本系列最实用的一节。三个异常名字相似,成因与排查方向完全不同。

异常 类型 抛出时机 根本原因 排查方向
UnsupportedClassVersionError Error(ClassFormatError 子类) 类加载的加载阶段 编译时 JDK 版本 高于 运行时 JVM 版本,class 主版本号不被识别 对齐 java -version 与 javac -version;用 --release 交叉编译;检查 Maven 的 maven.compiler.release;检查运行容器的 JDK
ClassNotFoundException Exception(受检) 运行期代码主动加载类时 Class.forName()、ClassLoader.loadClass()、反射按名字加载,在 classpath 里找不到该类 检查类名字符串是否拼错(必须是全限定名);检查依赖 jar 是否真的在 classpath;检查是否漏了依赖声明
NoClassDefFoundError Error 编译通过但运行期找不到某个类的定义 编译时类在,运行时类不在(典型:provided/test scope 的依赖在运行时缺失);或类的静态初始化失败导致 JVM 标记该类不可用 检查 scope 配置;检查依赖是否被 exclusion 掉;检查是否有 ExceptionInInitializerError(静态代码块抛异常)
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
// 三种错误的触发代码示例,对照理解
public class ErrorDemo {
public static void main(String[] args) throws Exception {
// ① ClassNotFoundException:受检异常,必须 catch 或 throws
// 类名必须是全限定名,写成 "Driver" 一定找不到
Class<?> c = Class.forName("com.mysql.cj.jdbc.Driver");

// ② NoClassDefFoundError 的典型场景:
// 编译时 servlet-api 在(scope=provided),
// 但用 java -jar 直接跑时 Tomcat 不在,HttpServletRequest 类缺失
// HttpServletRequest req = ...; ← 运行到这里才炸

// ③ NoClassDefFoundError 的另一个隐蔽成因:静态初始化失败
try {
new BadStatic();
} catch (Throwable t) {
System.out.println("第一次: " + t); // ExceptionInInitializerError
}
try {
new BadStatic();
} catch (Throwable t) {
System.out.println("第二次: " + t); // NoClassDefFoundError
}
}
}

class BadStatic {
static int value = 1 / 0; // 静态初始化抛异常 → 类被 JVM 标记为错误状态
} // 后续任何使用都直接 NoClassDefFoundError

特别注意 NoClassDefFoundError 最隐蔽的一种成因:静态初始化失败。第一次访问类时静态代码块抛出 RuntimeException,JVM 会包装成 ExceptionInInitializerError,并把该类永久标记为”不可用”;此后任何对该类的访问都会抛 NoClassDefFoundError,而且异常栈里看不到真正的原始错误。排查方法是往上翻日志,找到最早出现的 ExceptionInInitializerError,那才是根因。这种情形常见于静态代码块里读配置文件、初始化连接池失败。

1.2 环境变量配置错误的典型表现与排查

现象 可能原因 排查命令 / 解决办法
javac 不是内部或外部命令(Windows)/command not found: javac(Unix) PATH 没配 bin 目录;或装的是 JRE 不是 JDK echo %JAVA_HOME% / echo $JAVA_HOME;确认路径指向 JDK 根目录且 bin 下有 javac
java -version 显示的不是刚装的版本 PATH 里有别的 JDK 排在前面;Windows 的 javapath 目录干扰 Unix:which -a java;Windows:where java;清理多余的 PATH 项
IDEA 能跑,mvn 命令行报错 IDEA 用的 JDK 与命令行 JAVA_HOME 不是同一个 对比 mvn -version 输出的 Java version 与 IDEA Project SDK
Maven 报 JAVA_HOME should point to a JDK not a JRE JAVA_HOME 指向了 jre 目录,或末尾多了 \bin 去掉 \bin,指向 JDK 根目录(macOS 是 Contents/Home)
中文注释编译报”编码 GBK 的不可映射字符” javac 默认用系统编码(Windows 是 GBK) javac -encoding UTF-8;Maven 配 project.build.sourceEncoding=UTF-8
控制台输出中文乱码 终端编码、JVM file.encoding、IDE 编码三者不一致 全部设 UTF-8;java -Dfile.encoding=UTF-8
Linux 下 java 命令在 sudo 后找不到 sudo 会重置环境变量 sudo env JAVA_HOME=$JAVA_HOME PATH=$PATH java -version,或用 sudo -E
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 一套标准的环境自检脚本,出问题先跑一遍
echo "=== 1. JAVA_HOME ==="
echo $JAVA_HOME # Windows: echo %JAVA_HOME%

echo "=== 2. 实际生效的命令位置(可能有多个) ==="
which -a java javac mvn # Windows: where java javac mvn

echo "=== 3. 版本一致性(javac 与 java 必须一致) ==="
javac -version
java -version

echo "=== 4. Maven 实际使用的 JDK(最容易不一致的地方) ==="
mvn -version
# 输出里有一行:Java version: 21.0.5, vendor: Eclipse Adoptium,
# runtime: /Library/Java/JavaVirtualMachines/.../Contents/Home

echo "=== 5. 编码 ==="
locale # Windows: chcp(936=GBK,65001=UTF-8)

1.3 其它高频问题的速查表

问题 原因 解决
Error: Could not find or load main class Xxx 写了 .class 后缀;类名与文件名不符;类有 package 但没在对应目录下运行 java com.example.Main(不带后缀),且从 classpath 根目录执行
程序包 xxx 不存在 依赖没声明、没下载成功、scope 不对 mvn dependency:tree 确认;-U 强制更新
InaccessibleObjectException: module java.base does not open java.lang JDK 17 起强封装内部 API,老框架反射失败 升级框架版本;临时加 --add-opens java.base/java.lang=ALL-UNNAMED
Maven 依赖下载极慢/超时 默认连中央仓库,国内网络慢 配阿里云镜像到 ~/.m2/settings.xml
jar 运行报找不到主类 MANIFEST 缺 Main-Class;Main-Class 后没换行 jar cvfe app.jar com.example.Main -C target/classes .
java -jar 时 -cp 不生效 两者互斥(见第五章说明) 改用 java -cp "app.jar:lib/*" com.example.Main
修改代码后运行还是旧逻辑 没重新编译;IDEA 缓存;浏览器/容器缓存了旧包 mvn clean;IDEA File → Invalidate Caches

1.4 三个月学习路线

下面的路线假设每天能投入 2 小时左右,顺序不能跳——后面的内容都依赖前面的地基。

阶段 周次 核心内容 产出目标
第一阶段:语言基础 第 1 周 环境搭建、基本语法、数据类型与运算符、流程控制、数组 能手写并命令行编译运行 10 个小程序;理解编译与运行的区别
第一阶段 第 2 周 面向对象:类与对象、封装/继承/多态、this/super、抽象类与接口、内部类 用 OOP 思想重构第一周的代码;讲清重载与重写的区别
第一阶段 第 3 周 常用类:String/StringBuilder、包装类与自动拆装箱、日期时间 API、异常体系 掌握 String 常量池、Integer 缓存陷阱;写出规范的异常处理
第一阶段 第 4 周 集合框架:List/Set/Map 源码级理解、ArrayList 扩容、HashMap 哈希冲突与红黑树、迭代器 fail-fast 能手画 HashMap 结构;说清 == 与 equals/hashCode 的契约
第二阶段:进阶能力 第 5 周 泛型、反射、注解、IO/NIO 与序列化 用反射+注解写一个简易 ORM;理解泛型擦除
第二阶段 第 6 周 并发编程:Thread/Runnable、线程池、synchronized/volatile、Lock、ConcurrentHashMap、JMM 与 happens-before 能解释 volatile 为什么不能保证原子性;实现一个生产者消费者
第二阶段 第 7 周 JVM 入门:内存区域、垃圾回收算法与 GC 日志、类加载机制、JVM 参数调优 能看懂 GC 日志;用 jstack/jmap 定位一次 OOM
第二阶段 第 8 周 Maven 进阶与单元测试:多模块、BOM、JUnit 5 + Mockito、常用插件 拆分出一个多模块项目,测试覆盖率达标
第三阶段:工程实践 第 9-10 周 MySQL 基础 + JDBC + 连接池(HikariCP/Druid);MyBatis 或 JPA 完成一个带数据库的 CRUD 项目
第三阶段 第 11-12 周 Spring 核心(IoC/DI/AOP)+ Spring Boot + Spring MVC,做一个完整的 RESTful 后端项目 独立产出可部署的接口服务,配好日志与全局异常处理

学习方法建议:每学一个知识点,必须动手写一段能跑的验证代码,而不是只看。JShell 和 javap 是你验证底层行为最好的两个工具——比如学完 String,就用 javap -c 看看字符串拼接在 JDK 21 下到底编译成了什么(invokedynamic + StringConcatFactory,而不是很多人以为的 StringBuilder)。这种”验证式学习”是把知识变成能力的关键。

下一篇我们将深入 Java 语言核心:基本类型与包装类的陷阱、String 的不可变性与常量池、以及 equals/hashCode 契约的源码级剖析。