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

Java 从入门到精通(一):开发环境搭建与第一个程序
神经蛙很多人学 Java 的第一天是这样度过的:下载 JDK、一路点下一步、配环境变量、写个 HelloWorld、控制台打印出 Hello, World!,然后觉得自己已经”入门”了。可是一旦换台电脑、换个系统、接手一个 Maven 工程,问题就接踵而至:javac 不是内部或外部命令、UnsupportedClassVersionError、ClassNotFoundException、NoClassDefFoundError、依赖下不下来、jar 跑不起来……每一个都能卡住新手一整天。根本原因在于:绝大多数教程只教”怎么做”,从不讲”为什么这么做”。本系列的目标就是把这条底层链路彻底打通,让你知其然更知其所以然。
一、Java 技术体系全景
1.1 JDK、JRE、JVM 三者到底是什么关系
这是面试与自学中最经典的开场题,也是理解整个 Java 生态的地基。三者的关系可以用一句话概括:JVM 是执行引擎,JRE 是运行环境,JDK 是开发工具包,逐层包含。
1 | ┌─────────────────────────────────────────────┐ |
逐层拆解:
- 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 | :: 1. 从 https://adoptium.net 下载 Temurin 21 (Windows x64) 的 .msi 安装包 |
Windows 安装的坑有两个:一是安装路径不要有中文和空格(虽然现代 JDK 多数已支持,但很多老旧脚本、Maven wrapper、Gradle 依然会在空格路径上翻车);二是 .msi 安装器会自动往 PATH 里塞一个 C:\Program Files\Common Files\Oracle\Java\javapath 软链,导致你明明配了 JDK 21,命令行里跑的却是别的版本,这个目录必须检查并清理。
macOS
1 | # 方式一:Homebrew(推荐,便于版本管理) |
macOS 上最关键的一条认知:JAVA_HOME 的正确值不是 JDK 根目录,而是 Contents/Home 子目录。获取它的标准做法是用系统自带的 /usr/libexec/java_home:
1 | # 列出机器上所有已安装的 JDK |
Linux
1 | # Ubuntu / Debian(apt) |
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 会打开归档文件按条目查找)。
1 | # CLASSPATH 典型写法(Linux/macOS 用冒号分隔,Windows 用分号分隔) |
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 | # ========== SDKMAN ========== |
三、第一个程序与运行全链路
1.1 HelloWorld 三连:编写、编译、运行
1 | // HelloWorld.java |
1 | # 1. 编译:javac 把 .java 源文件翻译成 .class 字节码文件 |
最容易犯的三个错误:写 java HelloWorld.class(报 Could not find or load main class HelloWorld.class);文件名与 public 类名不一致(编译期报错);类名用了默认包却从别的目录运行(找不到类)。
1.2 javac 编译全流程:源码是怎么变成字节码的
javac 并不是一个简单的文本转换器,它是一条完整的编译流水线,主要阶段如下:
1 | 源码字符流 |
每一步的价值:
- 词法分析把字符流切成 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 | # 查看 class 文件头 16 字节 |
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 | # 查看常量池与完整字节码助记符 |
1 | // javap -c 输出的 main 方法字节码(节选),逐条解读 |
这段字节码体现了 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 | # 一次规范的编译命令(真实项目手工编译写法) |
1.5 编译期与运行期的本质区别
这条边界必须分清,很多错误的根源就是混淆了它。
- 编译期:
javac在工作。检查语法、类型、可达性;做泛型擦除、常量折叠(final int x = 1 + 2会被优化成3)、重载解析(调用哪个重载版本在编译期就确定了)。编译期错误是静态的,IDE 能实时画红线。 - 运行期:JVM 在工作。类加载、链接、初始化、GC、JIT 编译、反射、多态分派。运行期错误(空指针、数组越界、类型转换失败、类找不到)IDE 画不出来,只能靠日志和测试。
一个经典对照:方法重载(Overload)是编译期决定的(静态分派),方法重写(Override)是运行期决定的(动态分派)。所以下面这段代码的输出常常让人意外:
1 | // 重载的静态分派:编译期就按"声明类型"选定了方法,与对象实际类型无关 |
1.6 类加载的时机与全过程
一个类从磁盘到可用,经历加载 → 链接 → 初始化三大步,其中链接又细分为验证、准备、解析:
1 | 加载 Loading 链接 Linking 初始化 Initialization |
初始化时机是面试高频点,JVM 规范规定有且仅有以下六种情况会触发类初始化(称为主动引用):
- 遇到
new、getstatic、putstatic、invokestatic四条字节码指令时。 - 使用
java.lang.reflect对类进行反射调用时。 - 初始化一个类时,其父类尚未初始化(先初始化父类)。
- 虚拟机启动时,包含
main方法的主类。 - JDK 7 动态语言支持中,
MethodHandle解析结果为静态字段/方法且对应类未初始化。 - 接口中定义了 default 方法,其实现类初始化时,该接口先被初始化。
被动引用不会触发初始化,这是最容易踩的反例:
1 | public class InitDemo { |
类加载器层面还遵循双亲委派模型(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 | // 场景:一个 10 万次的循环里,只有第 8888 次的数据有问题 |
其他高级断点:
- 字段断点(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 | # 远端启动(JDK 5-8 写法) |
配好之后在 IDEA 里 Run → Edit Configurations → + → Remote JVM Debug,填 host 和 5005 即可。生产环境严禁长期开启远程调试端口,它等于给攻击者开了一扇能执行任意代码的大门。
1.4 JShell 交互式验证
JDK 9 引入的 JShell(REPL)是验证 API 行为的利器,比写一个 main 方法再跑快得多。
1 | $ jshell |
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 | # 一条龙排查 CPU 飙高的标准流程 |
五、包与模块化
1.1 package、import 与全限定名
package 的本质是命名空间加上目录结构约定。它的三重作用:
- 避免命名冲突:两个
User类分属com.a.User和com.b.User,互不干扰。 - 控制访问权限:
protected和默认(包级私有)访问修饰符的可见范围与包直接挂钩。 - 组织代码结构,并强制与磁盘目录一一对应(javac 会校验)。
1 | // 文件路径必须是:src/com/example/demo/UserService.java |
默认包(没有 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 诞生以来最大的一次平台级重构。它要解决三个历史问题:
- 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 里:
1 | // 文件路径:src/main/java/module-info.java |
模块化 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 | # 打包:c=create, v=verbose, f=file, -C 先切换目录再打包(避免把绝对路径打进去) |
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 | # 方式一:MANIFEST 声明 Class-Path(依赖放在 jar 同级的 lib/ 目录) |
六、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 | my-app/ |
1.3 pom.xml 核心元素
1 |
|
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 统一保证兼容性。
1 | <!-- 导入 Spring Boot BOM,之后声明 starter 无需写 version --> |
1.5 依赖传递与冲突调解
Maven 的依赖是传递的:A 依赖 B,B 依赖 C,那么 A 自动获得 C,不需要显式声明。这带来便利,也带来冲突——当两条依赖路径引入了同一个组件的不同版本时,Maven 用两条规则调解:
- 最短路径优先(Nearest Wins):路径深度浅的版本胜出。
- 路径相同时,先声明者优先(First Declaration Wins):在 pom.xml 中先声明的那条路径上的版本胜出。
1 | 项目 A |
1 | # 查看依赖树,排查冲突的第一手段 |
1 | <!-- 方式一:dependencyManagement 锁定版本(推荐,对所有传递依赖生效) --> |
排查依赖问题的标准流程:先 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 | validate → compile → test → package → verify → install → deploy |
| 命令 | 执行到哪个阶段 | 作用 | 何时用 |
|---|---|---|---|
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 | # 日常开发最常用的完整命令(带常用参数) |
七、常见报错排查速查与学习路线
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 | // 三种错误的触发代码示例,对照理解 |
特别注意 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 | # 一套标准的环境自检脚本,出问题先跑一遍 |
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 契约的源码级剖析。

















