《Java 基础》带你跑通语法、OOP、集合、并发入门,能写"能跑的小程序"。本页进入生产级 Java 工程师真正吃饭的本事:JVM 怎么管内存、GC 怎么调、并发底层的可见性与锁、现代构建工具、以及 Java 21+ 的虚拟线程等新特性。我会像《Java 基础》那样,把每个知识点拆成"它是什么 → 为什么这样设计 → 怎么用 → 踩过什么坑"。基线:JDK 21 LTS(虚拟线程已正式)/ JDK 25 LTS。
JVM 内存模型与 GC:程序住在哪里,垃圾怎么回收
Java 不用你手动 free,但理解 JVM 怎么管内存,才能在 OOM、频繁 Full GC、延迟抖动 时不慌。这一章把"对象从生到死"讲透。
运行时内存区域:对象都住在哪个房间
JVM 把内存切成几块,各管一摊事。搞清"哪块会 OOM、哪块线程私有",排查才有方向。
| 区域 | 存什么 | 线程私有? | 会 OOM 吗 |
|---|---|---|---|
| 堆 Heap | 几乎所有对象实例(最大块) | 否(共享) | 会(最常见) |
| 元空间 Metaspace | 类元数据、方法字节码 | 否 | 会(类加载泄漏) |
| 虚拟机栈 | 方法调用栈帧、局部变量 | 是 | 会(无限递归 StackOverflow) |
| 本地方法栈 | Native(C/C++)调用 | 是 | 会 |
| 程序计数器 | 当前执行到哪条指令 | 是 | 不会 |
| 直接内存 | NIO 的 DirectByteBuffer | 否 | 会(Netty 常见) |
论为什么"栈私有、堆共享"很重要
局部变量在栈上,随方法结束自动消失,所以不用 GC、也不会有并发问题。对象在堆上共享,多线程都能碰,所以才有"可见性""锁"那些麻烦事。理解这一点,后面并发章节就好懂了。
对象是怎么"生"出来的:内存分配的小聪明
你写 new User(),JVM 不是直接去堆里随便找块地。它先尝试在栈上分配(逃逸分析发现对象没逃出方法,就直接在栈帧里分配,方法结束自动没了,零 GC 压力),不行再走 TLAB(线程本地分配缓冲,每个线程在自己的一小块 Eden 里分配,免锁)。
对象头与指针压缩:一个对象到底占多少字节
每个对象在堆里都带一个"对象头"(Mark Word 存哈希/锁状态/GC 年龄,Klass Pointer 指向类元数据)。64 位机器上指针本该 8 字节,但 JVM 默认开启指针压缩(CompressedOops),把它压成 4 字节,堆小于 32G 时能省下可观内存。
一旦 -Xmx 超过约 32G,指针压缩失效,指针从 4B 变回 8B,对象变大、缓存命中率下降,反而可能更慢。所以调堆大小不是越大越好,常见做法是控制在 32G 以内,或干脆上 ZGC 跑大堆。
对象是怎么"死"的:可达性分析
JVM 不用"引用计数"(会死锁循环引用),而用可达性分析:从 GC Roots(活动线程栈帧、静态变量、JNI 引用等)出发,顺着引用链能摸到的对象算"活着",摸不到的就是垃圾。
论为什么不用引用计数
两个对象互指(A.b=B, B.a=A),谁都不被外部引用,引用计数都是 1,但实际是垃圾——引用计数永远回收不了,造成泄漏。可达性分析从"根"出发,这种孤岛自然被判定死亡。
GC 三大基础算法:G1/ZGC 都是它们的拼盘
| 算法 | 做法 | 优点 | 缺点 |
|---|---|---|---|
| 标记-清除 | 标出活的,清掉死的 | 简单 | 产生内存碎片 |
| 标记-整理 | 标出活的,搬到一端 | 无碎片 | 搬运成本高 |
| 复制 | 把活的对象复制到空白区 | 无碎片、快 | 浪费一半空间 |
分代模型:为什么要分"新生代"和"老年代"
统计发现大多数对象朝生夕死(弱代假说)。于是把堆分代:新生代放刚创建的对象,用复制算法快速回收;活过几轮 GC 的"晋升"到老年代,用标记-整理。各用最合适的算法,整体效率最高。
| 分代 | 算法 | 触发 | 特点 |
|---|---|---|---|
| 新生代 Eden + 2 Survivor | 复制 | Eden 满(Minor GC) | 频繁但极快 |
| 老年代 Old | 标记-整理 | 老年代满(Full GC) | 慢,要尽量避免 |
收集器演进:从 Serial 到 ZGC
| 收集器 | 特点 | 停顿 | 适用 |
|---|---|---|---|
| Serial / Parallel | 单/多线程 STW 回收 | 高(随堆增大) | 小应用、批处理 |
| CMS | 并发标记清除 | 中(JDK 14 已移除) | 老年方案(别用了) |
| G1 | Region 化、可预测停顿 | 低(默认推荐) | 大堆通用首选 |
| ZGC / Shenandoah | 并发、着色指针/读屏障 | 极低(<10ms) | 超大堆、低延迟 |
论ZGC 为什么能做到毫秒级停顿
传统 GC 要在"对象搬家"时暂停所有线程(怕你正指着旧地址)。ZGC 用着色指针(把对象状态编码进指针本身)+ 读屏障(访问对象时顺便修正地址),让"搬家"全程与业务线程并发,几乎不暂停。代价是吞吐量略低、实现复杂——但对延迟敏感的服务(交易、网关)值回票价。
调优实战:先把参数说清楚
① 盲目调大 -Xmx:堆太大,一次 Full GC 停顿更久;应先看是不是真不够,还是泄漏。② 设了 MaxGCPauseMillis 就以为一定达成:它只是目标,JVM 尽力而为。③ 不收集日志就调参:等于蒙眼开车。先 -Xlog:gc* 拿到数据再说。
案例:频繁 Full GC 怎么查
论排查的顺序永远是先看证据
现象 → GC 日志定位大类(内存泄漏 / 堆太小 / 大对象)→ 抓现场(dump / 火焰图)→ 定位到具体类/方法 → 改代码加单测验证。每一步都要有证据,避免"我觉得是 XX 所以调了一下"。
并发深入:可见性、有序性与锁的真相
基础篇讲了"怎么建线程"。进阶要懂为什么多线程会出诡异的 bug:一个线程改了变量,另一个看不见;代码顺序和执行的顺序不一样;i++ 在多线程下会丢更新。这一章把底层机制讲清楚。
JMM 与 happens-before:并发的"交通规则"
Java 内存模型(JMM)规定了一套 happens-before 规则:如果 A happens-before B,那么 A 的修改对 B 可见、且 A 先于 B 执行。它不是"禁止重排",而是"在规则内的重排不影响正确性"。
| happens-before 规则 | 含义 |
|---|---|
| 程序顺序规则 | 同一线程内,前面的操作 happens-before 后面的 |
| 监视器锁规则 | unlock happens-before 之后的 lock |
| volatile 规则 | 写 volatile happens-before 之后读它 |
| 线程启动/结束 | start() 前的写对子线程可见;子线程结束对 join() 后可见 |
volatile:可见性 + 禁止重排,但不是原子
volatile 保证你读到的一定是别人刚写的最新值,但 i++ 是"读-改-写"三步,volatile 不保证这三步不被打断。并发自增必须用 AtomicInteger 或锁。
synchronized 的锁升级:无锁 → 偏向 → 轻量 → 重量
很多人以为 synchronized 一定慢。其实 HotSpot 做了锁升级:刚开始是"偏向锁"(假设只有一个线程,几乎零成本)→ 有竞争升级"轻量级锁"(CAS 自旋)→ 竞争激烈才升级"重量级锁"(操作系统互斥,线程挂起)。所以低竞争下它并不慢。
论该用 synchronized 还是 ReentrantLock?
大多数情况 synchronized 够用且不易出错。需要可中断、超时获取、公平锁、多条件变量时才上 ReentrantLock。能用高层并发工具(并发容器、CompletableFuture)就别自己玩锁。
CAS 与原子类:无锁并发的底层
CAS(Compare-And-Swap) 是一条 CPU 原子指令:比较内存值是否等于预期,是则写入新值。所有原子类(AtomicInteger)都靠它实现"无锁自增"。
值从 A 改成 B 又改回 A,CAS 检查"还是 A"就成功——但状态已经变过。用带版本号的 AtomicStampedReference 解决。另外高竞争下 CAS 空转消耗 CPU,此时锁反而更划算。
AQS 与 ReentrantLock:可重入锁的骨架
JUC 里一大批类(ReentrantLock、Semaphore、CountDownLatch)都建立在 AQS(AbstractQueuedSynchronizer) 之上:用一个 volatile 的 state 表示锁状态、一个 CLH 双向队列挂等待线程。子类只需实现"怎么拿锁、怎么放锁"。
线程池:7 个参数决定生死
Executors.newFixedThreadPool() 用着方便,但隐藏坑:它的队列是无界的 LinkedBlockingQueue,任务积压时 OOM。生产里应该自己 new ThreadPoolExecutor,把每个参数想清楚。
| 拒绝策略 | 行为 | 何时用 |
|---|---|---|
| AbortPolicy | 抛异常 | 不允许丢任务时 |
| CallerRunsPolicy | 提交者自己执行 | 温和背压(推荐默认) |
| DiscardOldestPolicy | 丢最老的任务 | 只关心最新数据 |
| DiscardPolicy | 静默丢弃 | 可丢的埋点类 |
线程池大小到底怎么定
没有万能公式,但有个经验起点:CPU 密集 → 约等于核数(N+1);IO 密集 → 核数 × 目标利用率 × (1 + 等待时间/计算时间),可能几十上百。关键是看依赖的瓶颈是 CPU 还是 IO,并用监控(活跃线程数、队列长度)持续校准。
每次请求都 new ThreadPoolExecutor,等于把线程池变成了"一次性线程工厂",很快耗尽系统线程。线程池必须全局复用(单例 / 注入),并给它取个有意义的名字,方便 jstack 排查。
CompletableFuture:把回调地狱拍平
论为什么不用回调嵌套
嵌套回调会让代码向右缩进成"金字塔"。thenCombine/allOf/anyOf 把"等多个异步结果"表达成声明式组合,可读、好维护,也方便统一异常处理(exceptionally)。
并发容器:别自己给 HashMap 加锁
ConcurrentHashMap 用 CAS + synchronized 锁单个桶(而不是整表),高并发读几乎无锁、写只锁局部,比 Collections.synchronizedMap 快得多。复合操作(判断后更新)要用它的原子方法,别自己"先 get 再 put"。
ThreadLocal:每个线程一份自己的副本
用来在"同一线程的不同方法间传值"(比如把登录用户、traceId 放进上下文,不用每层传参)。但用完必须 remove,否则线程池复用会导致串号、内存泄漏。
ThreadLocalMap 的 key 是弱引用,value 是强引用。线程不结束(线程池!)且不 remove,value 就一直挂着。所以必须 try/finally remove,这是用线程池时的铁律。
构建与依赖:Maven 与 Gradle
"能跑"靠 IDE,"能 reproducible 地构建"靠构建工具。Maven 用 XML 声明式,Gradle 用 DSL 脚本、更快更灵活。这一章讲清"依赖为什么有时会撞车"。
Maven 的三板斧:坐标、生命周期、依赖传递
一个依赖用 groupId:artifactId:version(坐标)唯一确定。构建分三套生命周期(clean / default / site),default 里 compile→test→package→install→deploy 依次走。
依赖冲突:Jar 地狱的成因与解法
两个库各自引了不同版本的同一依赖,运行时可能 NoSuchMethodError——编译期好好的,一跑就炸。Maven 的仲裁规则是"路径最短优先,同长度先声明优先",但靠规则不如靠显式管理。
论Maven vs Gradle 怎么选
Maven:XML 声明、约定优于配置、资料多、上手快,团队保守选型首选。Gradle:Kotlin/Groovy DSL、增量构建 + 构建缓存让大项目快数倍、组合式多模块更优雅,但学习曲线陡。新项目、大单体、CI 受限时可优先考虑 Gradle。
Gradle 的现代写法:版本目录与依赖锁定
Gradle 用 libs.versions.toml(版本目录)集中管理依赖版本,多模块共享一份,升级时改一处。配合 --write-locks 生成 gradle.lockfile,把整棵依赖树的精确版本锁死。
可重现构建:同样的代码,到处编出一样的包
生产事故里有一类特别坑:"我本地是好的"。可重现构建要求依赖版本锁定、构建环境一致。做法:提交 lockfile(Gradle 的 gradle.lockfile / Maven 的 dependencyManagement + 私服)、固定 JDK 版本、CI 里用相同基础镜像。
SNAPSHOT 是"快照",同一个版本号内容会变。生产依赖 SNAPSHOT,等于每次构建都在抽奖——今天能跑明天就炸。上线产物必须固定为正式版本号。
Java 21+ 关键新特性
停在 Java 8 等于放弃十年红利。下面几个特性显著改变写法,尤其是虚拟线程,几乎是并发范式的转移。
虚拟线程:并发的范式转移
传统线程直接映射操作系统线程——贵(默认 1MB 栈)、少(几百上千就到顶)。虚拟线程是用户态轻量线程,由 JVM 调度到少量"载体线程"上,阻塞时自动让出,让"每请求一线程"模型轻松扩展到百万级并发。
论虚拟线程不是"免费午餐"
它解决的是等待密集型(IO、网络、锁等待)并发。CPU 密集任务不会更快。两禁忌:① 别用池化虚拟线程(它本来就廉价,newVirtualThreadPerTaskExecutor 即可);② 同步块里别长期持锁,会 pin 住载体线程,退化为平台线程行为。
在 synchronized 块内做阻塞 IO,会把载体线程"钉住"(pin),失去让出能力——IO 密集场景建议改用 ReentrantLock。另外虚拟线程数量巨大,缓存 ThreadLocal 会成倍放大内存,尽量用 ScopedValue(JDK 25 已转正)或干脆不用。
Record 与模式匹配:少写样板,多写意图
密封类:把"可穷尽"写进类型系统
sealed 类限定"只允许哪些子类",编译器便知道所有可能的形态。配合模式匹配 switch,漏掉一个分支就编译报错,不用再写一堆 else 兜底。
结构化并发:把"一群子任务"当一个整体管
以前用 ExecutorService 手动 submit 一堆 Future,任务间关系隐式、取消要靠自己串。结构化并发(StructuredTaskScope)让子任务的生命周期绑定到代码块:块结束,所有子任务要么完成要么取消,异常自动传播。
虚拟线程在 JDK 21 已正式;但结构化并发、ScopedValue 等很多仍是预览特性(需 --enable-preview)。上生产前确认 JDK 版本是否正式支持,预览特性跨版本可能改语法,还可能无法与普通代码混编。
诊断工具与故障排查
线上出问题,不能靠猜。下面几样是 Java 工程师的"听诊器"。
命令行三件套
| 工具 | 解决什么 |
|---|---|
jstat -gcutil | 实时 GC 统计、各分代占用 |
jstack | 抓线程栈,查死锁/卡在哪 |
jmap -heap/-histo | 看堆概况/对象分布 |
jcmd | 统一入口:堆 dump、GC.run、VM 参数 |
jstack 查死锁:一眼看出谁在等谁
Arthas:不重启在线诊断
最香的是"生产环境不能停,但想知道这个方法为什么慢/参数是啥"。Arthas 能热挂上去,watch 方法入参返回值、trace 调用链耗时、tt 重放。
火焰图:一眼看出 CPU 热点
async-profiler 采样后生成火焰图——横轴是调用栈、宽度是耗时占比。哪个"柱子"最宽,哪就是优化重点。
论JFR:低开销的"黑匣子"
Java Flight Recorder 以极低开销(<1%)持续记录 JVM 内部事件(GC、锁竞争、IO、方法采样)。出事后回放 JFR 文件,能还原"那段时间到底发生了什么",比事后抓快照强得多。生产建议常开。
内存泄漏的标准排查流程
jmap -dump 会暂停应用(尤其大堆可能数秒到数十秒)。生产慎用 -F 强制模式;优先用 -dump:live 只导活对象,或提前配置 -XX:+HeapDumpOnOutOfMemoryError 让 OOM 时自动留证据。
工程化最佳实践
能跑只是起点,可维护、可观测、可回滚才是生产级。
异常设计:别吞、别泛、带错误码
异常构造会填充栈轨迹(fillInStackTrace),开销不小。别用"抛异常"代替正常的 if 分支;高频路径的"预期失败"(如查无结果)应返回 Optional 或状态。异常留给真正的异常。
日志:结构化、带 traceId、别打敏感
① 打敏感信息(密码、身份证)进日志,合规事故。② 循环里打 DEBUG,量爆炸把磁盘写满。③ 用 + 拼接字符串,即使不打印也先拼了,浪费;用 {} 占位符,惰性求值。
参数校验与防御式编程
边界问题(null、越界、超长)最容易被忽略,却最容易上线炸。对外部输入入口即校验:Bean Validation 注解 + 全局异常处理,把"坏数据"挡在业务逻辑之外。
幂等设计:重试与消息重复的必修课
网络重试、消息重复投递都可能导致同一请求执行多次。幂等的做法:用唯一业务键 + 去重表/Redis 存储,或让写操作"天然幂等"(如 UPDATE balance SET x=? WHERE id=? 而非 x=x+?)。
可观测三件套落地
论Metrics / 日志 / 追踪 各管一摊
Metrics(Micrometer + Prometheus):QPS、耗时分位、错误率,配告警。日志(结构化 + 集中采集):事后查细节。追踪(Micrometer Tracing / OpenTelemetry):一次跨服务请求全链路耗时。三者互补,缺一个排障就少一只眼。
| 信号 | 回答什么问题 | 代表工具 |
|---|---|---|
| Metrics | "整体是否健康、有没有恶化" | Micrometer / Prometheus |
| Logs | "这一次到底发生了什么" | Logback + ELK / Loki |
| Traces | "慢在哪一跳、跨服务链路" | OpenTelemetry / Jaeger |
JVM 把内存分区(堆/元空间/栈/直接内存),对象经逃逸分析、TLAB 分配,靠可达性分析判定死亡,GC 用分代 + 复制/标记整理组合;G1 是默认,ZGC 用着色指针做到毫秒停顿。并发要懂 happens-before、volatile 的局限、CAS/ABA、synchronized 锁升级、AQS、线程池 7 参数与拒绝策略、CompletableFuture 组合、并发容器的原子复合操作、ThreadLocal 须 remove。Maven/Gradle 保证可重现构建、警惕依赖冲突;Java 21+ 的虚拟线程、Record、密封类、模式匹配、结构化并发显著改变写法;排查靠 jstat/jstack/Arthas/火焰图/JFR;工程化纪律(异常/日志/校验/幂等/可观测)决定系统能否长期维护。
1.volatile 能保证 i++ 的线程安全吗?为什么?
查看答案
不能。volatile 只保证可见性和禁止重排,但不保证"读-改-写"这一复合操作的原子性;i++ 仍需 AtomicInteger(CAS)或加锁。
2.为什么 Executors.newFixedThreadPool 在生产要慎用?
查看答案
它的工作队列是无界 LinkedBlockingQueue,任务积压时无限堆积导致 OOM。应自己 new ThreadPoolExecutor,用有界队列 + 合适的拒绝策略,并给线程池命名以便排查。
3.虚拟线程最适合解决哪类并发问题?有什么禁忌?
查看答案
最适合等待密集型(IO/网络/锁等待)并发,能轻松扩展到百万级。禁忌:池化虚拟线程(它本就廉价)、在 synchronized 块里长期持锁或做阻塞 IO(会 pin 载体线程退化)。CPU 密集任务不会更快。
4.ThreadLocal 为什么用完必须 remove?不 remove 会怎样?
查看答案
线程池线程长期存活,不 remove 会导致 value 强引用泄漏、且下一个任务复用该线程时读到上一个任务的残留数据(串号)。key 是弱引用但 value 是强引用,必须 try/finally remove。
5.为什么 -Xmx 不是设得越大越好?
查看答案
堆越大,单次 Full GC 停顿越久;而且超过约 32G 时指针压缩失效,指针从 4B 变 8B,对象变大、缓存命中率下降,可能反而更慢。应先判断是泄漏还是真不够,再决定。
6.CAS 的 ABA 问题是什么?怎么解?
查看答案
值从 A 改成 B 后又改回 A,CAS 只检查"当前是否等于 A",会误判为未变化。用带版本戳的 AtomicStampedReference(或 AtomicMarkableReference)解决。高竞争下 CAS 空转耗 CPU,此时用锁更划算。
下一步往哪走
路学完 Java 进阶之后
① 看 Spring 进阶页:JVM 调优的成果要靠 Spring 的缓存、事务、安全管理落地到业务。
② 配合系统设计与 DevOps:限流、熔断、可观测正是 Java 后端高可用的主场。
③ 真机压测:用 JMeter / Gatling 打一波,看 GC 与线程池在压力下表现,对照本章的 jstat/火焰图方法。