很多人学 Java 的第一天是这样度过的:下载 JDK、一路点下一步、配环境变量、写个 HelloWorld、控制台打印出 ,然后觉得自己已经"入门"了。可是一旦换台电脑、换个系统、接手一个 Maven 工程,问题就接踵而至:、、、、依赖下不下来、jar 跑不起来……每一个都能卡住新手一整天。根本原因在于:绝大多数教程只教"怎么做",从不讲"为什么这么做"。本系列的目标就是把这条底层链路彻底打通,让你知其然更知其所以然。
一、Java 技术体系全景
1.1 JDK、JRE、JVM 三者到底是什么关系
这是面试与自学中最经典的开场题,也是理解整个 Java 生态的地基。三者的关系可以用一句话概括:JVM 是执行引擎,JRE 是运行环境,JDK 是开发工具包,逐层包含。
┌─────────────────────────────────────────────┐
│ 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. 从 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
# 方式一: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:
# 列出机器上所有已安装的 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
# 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 去哪里找用户类和第三方类库,它才是真正影响类加载的环境变量。查找顺序遵循以下原则:
- 先找 JDK 自己的类库(Bootstrap ClassLoader 加载
java.base等核心模块,Extension/Platform ClassLoader 加载扩展模块)——永远优先,你无法通过 CLASSPATH 覆盖java.lang.String。 - 再找 CLASSPATH 指定的路径,这是 Application ClassLoader(系统类加载器)的职责,按
CLASSPATH中出现的从左到右顺序查找。 - 路径可以是目录(JVM 会按包名转成子目录去查找对应的
.class)或 jar/zip 文件(JVM 会打开归档文件按条目查找)。
# 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 之后请不要设置全局 环境变量。 它是一个进程级的全局状态,一旦设置会影响机器上所有 Java 程序:你在 A 项目设了一个 jar 路径,跑 B 项目时可能被意外污染,产生极难排查的类冲突。正确做法是每次运行用 (或 )显式指定,或者交给 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 指向具体版本 |
系统自带,无额外依赖 | 只能切全局,粒度粗 |
| 手工切换 | 全平台 | 直接改环境变量 | 无依赖,最可控 | 繁琐,易出错 |
# ========== 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 三连:编写、编译、运行
// 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. 编译: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 并不是一个简单的文本转换器,它是一条完整的编译流水线,主要阶段如下:
源码字符流
↓ ① 词法分析(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 文件是一份严格二进制格式的协议,不是文本。可以用十六进制查看:
# 查看 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 的方法调用是"运行时动态绑定"的。
# 查看常量池与完整字节码助记符
javap -v HelloWorld.class
# 只看方法签名(不含字节码)
javap HelloWorld.class
# 反编译出接近 Java 源码的伪代码(需第三方工具 CFR / Procyon / Fernflower)
java -jar cfr.jar HelloWorld.class
// 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 报错 | 迁移老项目 |
# 一次规范的编译命令(真实项目手工编译写法)
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)是运行期决定的(动态分派)。所以下面这段代码的输出常常让人意外:
// 重载的静态分派:编译期就按"声明类型"选定了方法,与对象实际类型无关
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 类加载的时机与全过程
一个类从磁盘到可用,经历加载 → 链接 → 初始化三大步,其中链接又细分为验证、准备、解析:
加载 Loading 链接 Linking 初始化 Initialization
┌──────────────┐ ┌──────────────────────────┐ ┌──────────────────────┐
│ 通过全限定名 │ │ 验证 Verify: │ │ 执行 <clinit>() │
│ 获取二进制流 │ → │ 魔数/版本/字节码合法性 │ → │ 静态变量赋值 │
│ 转为方法区内 │ │ 准备 Prepare: │ │ 静态代码块执行 │
│ 的数据结构 │ │ 静态变量分配内存并置零值 │ │ 父类先于子类初始化 │
│ 生成 Class 对象│ │ 解析 Resolve: │ │ JVM 保证线程安全加锁 │
└──────────────┘ │ 符号引用→直接引用 │ └──────────────────────┘
└──────────────────────────┘
初始化时机是面试高频点,JVM 规范规定有且仅有以下六种情况会触发类初始化(称为主动引用):
- 遇到
new、getstatic、putstatic、invokestatic四条字节码指令时。 - 使用
java.lang.reflect对类进行反射调用时。 - 初始化一个类时,其父类尚未初始化(先初始化父类)。
- 虚拟机启动时,包含
main方法的主类。 - JDK 7 动态语言支持中,
MethodHandle解析结果为静态字段/方法且对应类未初始化。 - 接口中定义了 default 方法,其实现类初始化时,该接口先被初始化。
被动引用不会触发初始化,这是最容易踩的反例:
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 时才暂停。这是调试循环的神器。
// 场景:一个 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)这一层本地接口。
# 远端启动(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 方法再跑快得多。
$ 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 |
# 一条龙排查 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 的本质是命名空间加上目录结构约定。它的三重作用:
- 避免命名冲突:两个
User类分属com.a.User和com.b.User,互不干扰。 - 控制访问权限:
protected和默认(包级私有)访问修饰符的可见范围与包直接挂钩。 - 组织代码结构,并强制与磁盘目录一一对应(javac 会校验)。
// 文件路径必须是: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 语法不允许 。所以:任何正式代码都必须声明 package,哪怕只是 。只在 JShell 或临时验证时可以不写。
1.2 JDK 9 模块化与 JPMS
JDK 9 引入的 JPMS(Java Platform Module System,项目代号 Jigsaw) 是自 Java 诞生以来最大的一次平台级重构。它要解决三个历史问题:
- JDK 过于臃肿:JDK 8 的
rt.jar有 60MB+,即使跑个 HelloWorld 也要加载全部。模块化的java.base最小可裁剪到几十 MB,配合jlink能做出 30MB 左右的运行时镜像,容器化收益巨大。 - JAR Hell:classpath 是"一锅乱炖",同名的类谁在前面谁生效,A 依赖 guava 20、B 依赖 guava 30,只能二选一。模块系统让依赖显式化、可校验。
- 强封装缺失:任何人都能反射访问
sun.misc.Unsafe这类内部 API,导致 JDK 无法演进而不得不背负历史包袱。模块化后内部 API 被真正封装(JDK 17 起默认强封装,--illegal-access选项被移除)。
模块声明写在 module-info.java 里:
// 文件路径: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 清单文件。
# 打包: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 注意 |
值后面必须换行,且不能有前导空格,否则解析失败 | — |
与 是互斥的,这是新手最常踩的坑之一。当使用 时,JVM 会完全忽略 参数和 环境变量,只认两处:jar 内部的 里的 ,以及 jar 内打包的类。所以 是无效的, 根本不会被加载。解决办法有三:把依赖写进 的 ;打成 fat jar(把所有依赖塞进一个 jar,Spring Boot 的 就是这么干的);或者改用 。
# 方式一: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 会自动识别):
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 核心元素
<?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 加载会失败。 importscope 是依赖管理利器:Spring Boot 的spring-boot-dependencies、Spring Cloud 的spring-cloud-dependencies都是 BOM(Bill of Materials),用import导入后,你声明 Spring 组件时可以不写版本号,由 BOM 统一保证兼容性。
<!-- 导入 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 用两条规则调解:
- 最短路径优先(Nearest Wins):路径深度浅的版本胜出。
- 路径相同时,先声明者优先(First Declaration Wins):在 pom.xml 中先声明的那条路径上的版本胜出。
项目 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 胜出(最短路径优先)
# 查看依赖树,排查冲突的第一手段
mvn dependency:tree
# 只看冲突(被仲裁掉的依赖会标 omitted for conflict)
mvn dependency:tree -Dverbose
# 找出哪些依赖被声明但未使用 / 使用了但未声明
mvn dependency:analyze
# 强制把某个版本固定下来(比 exclusions 更省心)
<!-- 方式一: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>
排查依赖问题的标准流程:先 看清依赖究竟从哪来、被谁仲裁掉了;如果是版本冲突,用 锁定统一版本;如果是引入了不需要的传递依赖(比如 commons-logging 和 log4j 打架),用 精准排除;最后 清理无用声明。永远不要靠"删掉本地仓库目录重新下载"来碰运气。
1.6 Maven 生命周期与常用命令
Maven 有三套相互独立的生命周期:clean(清理)、default(构建,也叫 build)、site(生成站点文档)。每套生命周期由若干阶段(phase)组成,阶段是有序的——执行某个阶段会自动先执行它之前的所有阶段。这就是 mvn test 会自动先编译的原因。
default 生命周期的主要阶段顺序:
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 同时构建其依赖模块 |
多模块项目只构建一个模块 |
# 日常开发最常用的完整命令(带常用参数)
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(静态代码块抛异常) |
// 三种错误的触发代码示例,对照理解
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 |
# 一套标准的环境自检脚本,出问题先跑一遍
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 契约的源码级剖析。