本页定位 · Java 进阶

《Java 基础》带你跑通语法、OOP、集合、并发入门,能写"能跑的小程序"。本页进入生产级 Java 工程师真正吃饭的本事:JVM 怎么管内存、GC 怎么调、并发底层的可见性与锁、现代构建工具、以及 Java 21+ 的虚拟线程等新特性。我会像《Java 基础》那样,把每个知识点拆成"它是什么 → 为什么这样设计 → 怎么用 → 踩过什么坑"。基线:JDK 21 LTS(虚拟线程已正式)/ JDK 25 LTS。

1

JVM 内存模型与 GC:程序住在哪里,垃圾怎么回收

JVM Memory · Garbage Collection · Tuning

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 里分配,免锁)。

// 逃逸分析示例:sb 没逃出方法,JVM 可能标量替换成局部变量,不在堆分配 public String build() { StringBuilder sb = new StringBuilder(); sb.append("a").append("b"); return sb.toString(); // sb 没被外部引用,逃逸分析可优化掉堆分配 }

对象头与指针压缩:一个对象到底占多少字节

每个对象在堆里都带一个"对象头"(Mark Word 存哈希/锁状态/GC 年龄,Klass Pointer 指向类元数据)。64 位机器上指针本该 8 字节,但 JVM 默认开启指针压缩(CompressedOops),把它压成 4 字节,堆小于 32G 时能省下可观内存。

// 一个只含两个 int 的对象,实际占用不是 8 字节 class Point { int x; int y; } // 对象头 12B(压缩后)+ 两个 int 8B = 20B,按 8 字节对齐 → 补齐到 24B // 结论:小对象别乱造,海量小对象的内存开销远超字段本身
堆开到 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 已移除)老年方案(别用了)
G1Region 化、可预测停顿低(默认推荐)大堆通用首选
ZGC / Shenandoah并发、着色指针/读屏障极低(<10ms)超大堆、低延迟

论ZGC 为什么能做到毫秒级停顿

传统 GC 要在"对象搬家"时暂停所有线程(怕你正指着旧地址)。ZGC 用着色指针(把对象状态编码进指针本身)+ 读屏障(访问对象时顺便修正地址),让"搬家"全程与业务线程并发,几乎不暂停。代价是吞吐量略低、实现复杂——但对延迟敏感的服务(交易、网关)值回票价。

调优实战:先把参数说清楚

# 最常用的一组(G1 为例) java -Xms4g -Xmx4g \ # 初始/最大堆都设 4G,避免动态扩容抖动 -XX:+UseG1GC \ # 显式选 G1(JDK 9+ 默认就是它) -XX:MaxGCPauseMillis=200 \ # 目标最大停顿 200ms(是"目标"不是保证) -Xlog:gc*:file=gc.log \ # 打 GC 日志,排查的第一手证据 -jar app.jar
新手最常犯的调优错

① 盲目调大 -Xmx:堆太大,一次 Full GC 停顿更久;应先看是不是真不够,还是泄漏。② 设了 MaxGCPauseMillis 就以为一定达成:它只是目标,JVM 尽力而为。③ 不收集日志就调参:等于蒙眼开车。先 -Xlog:gc* 拿到数据再说。

案例:频繁 Full GC 怎么查

# 1) 实时看 GC 统计(-gcutil:各分代占用%、YGC/YGCT/FGC/FGCT) jstat -gcutil <pid> 1000 # 2) 老年代在涨还是稳;FGC 次数猛增 = 回收跟不上分配 # 3) 抓堆 dump 找"谁占着内存不放" jmap -dump:live,format=b,file=heap.hprof <pid> # 4) 用 MAT / VisualVM 打开,按"保留大小"排序,定位泄漏根因

论排查的顺序永远是先看证据

现象 → GC 日志定位大类(内存泄漏 / 堆太小 / 大对象)→ 抓现场(dump / 火焰图)→ 定位到具体类/方法 → 改代码加单测验证。每一步都要有证据,避免"我觉得是 XX 所以调了一下"。

2

并发深入:可见性、有序性与锁的真相

JMM · volatile · CAS · AQS · 线程池

基础篇讲了"怎么建线程"。进阶要懂为什么多线程会出诡异的 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 防"半初始化对象"被读到 private static volatile Singleton instance; public static Singleton get() { if (instance == null) { // 第一次检查(无锁,快) synchronized (Singleton.class) { if (instance == null) // 第二次检查(持锁) instance = new Singleton(); // 没 volatile 可能返回未构造完的对象 } } return instance; }
volatile 救不了 i++

volatile 保证你读到的一定是别人刚写的最新值,但 i++ 是"读-改-写"三步,volatile 不保证这三步不被打断。并发自增必须用 AtomicInteger 或锁。

synchronized 的锁升级:无锁 → 偏向 → 轻量 → 重量

很多人以为 synchronized 一定慢。其实 HotSpot 做了锁升级:刚开始是"偏向锁"(假设只有一个线程,几乎零成本)→ 有竞争升级"轻量级锁"(CAS 自旋)→ 竞争激烈才升级"重量级锁"(操作系统互斥,线程挂起)。所以低竞争下它并不慢。

论该用 synchronized 还是 ReentrantLock?

大多数情况 synchronized 够用且不易出错。需要可中断、超时获取、公平锁、多条件变量时才上 ReentrantLock。能用高层并发工具(并发容器、CompletableFuture)就别自己玩锁。

CAS 与原子类:无锁并发的底层

CAS(Compare-And-Swap) 是一条 CPU 原子指令:比较内存值是否等于预期,是则写入新值。所有原子类(AtomicInteger)都靠它实现"无锁自增"。

// 用 AtomicInteger 做无锁并发计数 AtomicInteger counter = new AtomicInteger(0); counter.incrementAndGet(); // 内部是 CAS 循环,无锁且线程安全 // 对比:counter++ 在多线程下会丢更新
CAS 的 ABA 问题

值从 A 改成 B 又改回 A,CAS 检查"还是 A"就成功——但状态已经变过。用带版本号的 AtomicStampedReference 解决。另外高竞争下 CAS 空转消耗 CPU,此时锁反而更划算。

AQS 与 ReentrantLock:可重入锁的骨架

JUC 里一大批类(ReentrantLock、Semaphore、CountDownLatch)都建立在 AQS(AbstractQueuedSynchronizer) 之上:用一个 volatile 的 state 表示锁状态、一个 CLH 双向队列挂等待线程。子类只需实现"怎么拿锁、怎么放锁"。

// ReentrantLock 的典型用法:务必 finally 解锁 ReentrantLock lock = new ReentrantLock(true); // true=公平锁(按排队顺序) lock.lock(); try { doBusiness(); } finally { lock.unlock(); // 忘记解锁 = 后续线程永久阻塞 }

线程池:7 个参数决定生死

Executors.newFixedThreadPool() 用着方便,但隐藏坑:它的队列是无界的 LinkedBlockingQueue,任务积压时 OOM。生产里应该自己 new ThreadPoolExecutor,把每个参数想清楚。

ThreadPoolExecutor pool = new ThreadPoolExecutor( 4, 8, 60, TimeUnit.SECONDS, // core=4, max=8, 空闲60s回收超出core的 new ArrayBlockingQueue<>(1024), // 有界队列,满了才扩容/拒绝 new ThreadPoolExecutor.CallerRunsPolicy()); // 满了让提交者自己跑,温和背压
拒绝策略行为何时用
AbortPolicy抛异常不允许丢任务时
CallerRunsPolicy提交者自己执行温和背压(推荐默认)
DiscardOldestPolicy丢最老的任务只关心最新数据
DiscardPolicy静默丢弃可丢的埋点类

线程池大小到底怎么定

没有万能公式,但有个经验起点:CPU 密集 → 约等于核数(N+1);IO 密集 → 核数 × 目标利用率 × (1 + 等待时间/计算时间),可能几十上百。关键是看依赖的瓶颈是 CPU 还是 IO,并用监控(活跃线程数、队列长度)持续校准。

线程池放在循环里 new

每次请求都 new ThreadPoolExecutor,等于把线程池变成了"一次性线程工厂",很快耗尽系统线程。线程池必须全局复用(单例 / 注入),并给它取个有意义的名字,方便 jstack 排查。

CompletableFuture:把回调地狱拍平

// 并发调两个接口,等两个都回来再合并 CompletableFuture<User> u = CompletableFuture.supplyAsync(() -> userSvc.get(id)); CompletableFuture<Order> o = CompletableFuture.supplyAsync(() -> orderSvc.list(id)); u.thenCombine(o, (user, orders) -> new Profile(user, orders)) .thenAccept(profile -> render(profile));

论为什么不用回调嵌套

嵌套回调会让代码向右缩进成"金字塔"。thenCombine/allOf/anyOf 把"等多个异步结果"表达成声明式组合,可读、好维护,也方便统一异常处理(exceptionally)。

并发容器:别自己给 HashMap 加锁

ConcurrentHashMap 用 CAS + synchronized 锁单个桶(而不是整表),高并发读几乎无锁、写只锁局部,比 Collections.synchronizedMap 快得多。复合操作(判断后更新)要用它的原子方法,别自己"先 get 再 put"。

// 错误:check-then-act 不是原子的 if (!map.containsKey(k)) map.put(k, compute()); // 并发下会重复计算/覆盖 // 正确:用原子复合方法 map.computeIfAbsent(k, key -> compute());

ThreadLocal:每个线程一份自己的副本

用来在"同一线程的不同方法间传值"(比如把登录用户、traceId 放进上下文,不用每层传参)。但用完必须 remove,否则线程池复用会导致串号、内存泄漏。

private static final ThreadLocal<String> TID = new ThreadLocal<>(); try { TID.set(traceId); biz(); // 方法链里任意处都能 TID.get() } finally { TID.remove(); // 关键:避免线程池复用串数据 + 泄漏 }
ThreadLocal 内存泄漏真相

ThreadLocalMap 的 key 是弱引用,value 是强引用。线程不结束(线程池!)且不 remove,value 就一直挂着。所以必须 try/finally remove,这是用线程池时的铁律。

3

构建与依赖:Maven 与 Gradle

Build Tools · 依赖传递 · 冲突仲裁 · 可重现构建

"能跑"靠 IDE,"能 reproducible 地构建"靠构建工具。Maven 用 XML 声明式,Gradle 用 DSL 脚本、更快更灵活。这一章讲清"依赖为什么有时会撞车"。

Maven 的三板斧:坐标、生命周期、依赖传递

一个依赖用 groupId:artifactId:version(坐标)唯一确定。构建分三套生命周期(clean / default / site),default 里 compile→test→package→install→deploy 依次走。

<!-- 依赖传递:引入 A,A 依赖 B,B 自动间接引入 --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> <version>3.3.0</version> </dependency>

依赖冲突:Jar 地狱的成因与解法

两个库各自引了不同版本的同一依赖,运行时可能 NoSuchMethodError——编译期好好的,一跑就炸。Maven 的仲裁规则是"路径最短优先,同长度先声明优先",但靠规则不如靠显式管理。

# 先看依赖树,找到冲突的版本 mvn dependency:tree # 解法一:排除传递依赖 <exclusions><exclusion>...旧版本...</exclusions> # 解法二:dependencyManagement 统一版本(只声明版本,不引入) <dependencyManagement><dependencies>...guava 33.0...</dependencies></dependencyManagement>

论Maven vs Gradle 怎么选

Maven:XML 声明、约定优于配置、资料多、上手快,团队保守选型首选。Gradle:Kotlin/Groovy DSL、增量构建 + 构建缓存让大项目快数倍、组合式多模块更优雅,但学习曲线陡。新项目、大单体、CI 受限时可优先考虑 Gradle。

Gradle 的现代写法:版本目录与依赖锁定

Gradle 用 libs.versions.toml(版本目录)集中管理依赖版本,多模块共享一份,升级时改一处。配合 --write-locks 生成 gradle.lockfile,把整棵依赖树的精确版本锁死。

# gradle/libs.versions.toml [versions] spring = "3.3.0" [libraries] spring-web = { module = "org.springframework.boot:spring-boot-starter-web", version.ref = "spring" } # build.gradle.kts 里引用:implementation(libs.spring.web)

可重现构建:同样的代码,到处编出一样的包

生产事故里有一类特别坑:"我本地是好的"。可重现构建要求依赖版本锁定、构建环境一致。做法:提交 lockfile(Gradle 的 gradle.lockfile / Maven 的 dependencyManagement + 私服)、固定 JDK 版本、CI 里用相同基础镜像。

SNAPSHOT 依赖上生产

SNAPSHOT 是"快照",同一个版本号内容会变。生产依赖 SNAPSHOT,等于每次构建都在抽奖——今天能跑明天就炸。上线产物必须固定为正式版本号。

4

Java 21+ 关键新特性

Virtual Threads · Records · 密封类 · 模式匹配 · 结构化并发

停在 Java 8 等于放弃十年红利。下面几个特性显著改变写法,尤其是虚拟线程,几乎是并发范式的转移。

虚拟线程:并发的范式转移

传统线程直接映射操作系统线程——贵(默认 1MB 栈)、少(几百上千就到顶)。虚拟线程是用户态轻量线程,由 JVM 调度到少量"载体线程"上,阻塞时自动让出,让"每请求一线程"模型轻松扩展到百万级并发。

// 一百万个并发任务,曾经不可想象,现在一行 try (var executor = Executors.newVirtualThreadPerTaskExecutor()) { IntStream.range(0, 1_000_000).forEach(i -> executor.submit(() -> { Thread.sleep(Duration.ofMillis(100)); // 阻塞时载体线程去干别的 return i; })); }

论虚拟线程不是"免费午餐"

它解决的是等待密集型(IO、网络、锁等待)并发。CPU 密集任务不会更快。两禁忌:① 别用池化虚拟线程(它本来就廉价,newVirtualThreadPerTaskExecutor 即可);② 同步块里别长期持锁,会 pin 住载体线程,退化为平台线程行为。

虚拟线程的 pin 与 ThreadLocal

在 synchronized 块内做阻塞 IO,会把载体线程"钉住"(pin),失去让出能力——IO 密集场景建议改用 ReentrantLock。另外虚拟线程数量巨大,缓存 ThreadLocal 会成倍放大内存,尽量用 ScopedValue(JDK 25 已转正)或干脆不用。

Record 与模式匹配:少写样板,多写意图

// Record:不可变数据载体,自动生成构造器/访问器/equals/hashCode record Point(int x, int y) {} Point p = new Point(1, 2); int x = p.x(); // 模式匹配 switch:把"类型判断+强转"合成一步,且穷尽检查 String describe(Object o) { return switch (o) { case Integer i -> "int:" + i; case String s -> "str:" + s; case Point(int x0, int y0) -> "pt:" + x0 + "," + y0; default -> "other"; }; }

密封类:把"可穷尽"写进类型系统

sealed 类限定"只允许哪些子类",编译器便知道所有可能的形态。配合模式匹配 switch,漏掉一个分支就编译报错,不用再写一堆 else 兜底。

sealed interface Shape permits Circle, Square {} record Circle(double r) implements Shape {} record Square(double a) implements Shape {} double area(Shape s) { return switch (s) { // 穷尽:只列了两种,无需 default case Circle c -> Math.PI * c.r() * c.r(); case Square q -> q.a() * q.a(); }; }

结构化并发:把"一群子任务"当一个整体管

以前用 ExecutorService 手动 submit 一堆 Future,任务间关系隐式、取消要靠自己串。结构化并发(StructuredTaskScope)让子任务的生命周期绑定到代码块:块结束,所有子任务要么完成要么取消,异常自动传播。

try (var scope = new StructuredTaskScope.ShutdownOnFailure()) { var user = scope.fork(() -> userSvc.get(id)); var orders = scope.fork(() -> orderSvc.list(id)); scope.join(); // 等两个都结束 scope.throwIfFailed(); // 任一失败,整体失败 render(user.get(), orders.get()); }
新特性别追新追到生产翻车

虚拟线程在 JDK 21 已正式;但结构化并发、ScopedValue 等很多仍是预览特性(需 --enable-preview)。上生产前确认 JDK 版本是否正式支持,预览特性跨版本可能改语法,还可能无法与普通代码混编。

5

诊断工具与故障排查

jstat · jstack · Arthas · async-profiler · JFR

线上出问题,不能靠猜。下面几样是 Java 工程师的"听诊器"。

命令行三件套

工具解决什么
jstat -gcutil实时 GC 统计、各分代占用
jstack抓线程栈,查死锁/卡在哪
jmap -heap/-histo看堆概况/对象分布
jcmd统一入口:堆 dump、GC.run、VM 参数

jstack 查死锁:一眼看出谁在等谁

# 连续抓两次线程栈,间隔看哪些线程一直 RUNNABLE/WAITING jstack <pid> > t1.txt; sleep 5; jstack <pid> > t2.txt # jstack 会自动检测死锁并打印 "Found one Java-level deadlock" # 关注:BLOCKED 状态的线程在等哪把锁,谁持有它

Arthas:不重启在线诊断

最香的是"生产环境不能停,但想知道这个方法为什么慢/参数是啥"。Arthas 能热挂上去,watch 方法入参返回值、trace 调用链耗时、tt 重放。

# 监控某个方法的入参和耗时 watch com.example.OrderService create '{params, returnObj}' '#cost>100' # 追踪调用链每条耗时,定位慢在哪一层 trace com.example.OrderController placeOrder

火焰图:一眼看出 CPU 热点

async-profiler 采样后生成火焰图——横轴是调用栈、宽度是耗时占比。哪个"柱子"最宽,哪就是优化重点。

./profiler.sh -d 30 -f flamegraph.html <pid> # 采样 30s 出图

论JFR:低开销的"黑匣子"

Java Flight Recorder 以极低开销(<1%)持续记录 JVM 内部事件(GC、锁竞争、IO、方法采样)。出事后回放 JFR 文件,能还原"那段时间到底发生了什么",比事后抓快照强得多。生产建议常开。

内存泄漏的标准排查流程

# 1) 确认是泄漏还是"堆太小":看老年代占用是否持续涨、Full GC 后不回落 jstat -gcutil <pid> 1000 # 2) 抓两个时间点的堆 dump 做对比(diff) jmap -dump:live,format=b,file=a.hprof <pid> # 隔一段时间再抓 b.hprof # 3) MAT 打开,Dominator Tree 按 Retained Size 排序,找最大的"根"
线上抓 dump 会 STW

jmap -dump 会暂停应用(尤其大堆可能数秒到数十秒)。生产慎用 -F 强制模式;优先用 -dump:live 只导活对象,或提前配置 -XX:+HeapDumpOnOutOfMemoryError 让 OOM 时自动留证据。

6

工程化最佳实践

异常设计 · 日志 · 校验 · 幂等 · 可观测

能跑只是起点,可维护、可观测、可回滚才是生产级。

异常设计:别吞、别泛、带错误码

// 反例:空 catch,出了事连痕迹都没有 try { doWork(); } catch (Exception e) { } // 永远别这么写 // 正例:自定义业务异常,带错误码,便于前端归类处理 class BizException extends RuntimeException { final String code; BizException(String code, String msg) { super(msg); this.code = code; } }
用异常做流程控制

异常构造会填充栈轨迹(fillInStackTrace),开销不小。别用"抛异常"代替正常的 if 分支;高频路径的"预期失败"(如查无结果)应返回 Optional 或状态。异常留给真正的异常。

日志:结构化、带 traceId、别打敏感

// SLF4J + MDC:把 traceId 塞进每行日志,串起一次请求的全链路 MDC.put("traceId", Trace.current()); log.info("order created id={}", orderId); // 占位符,别用字符串拼接
日志的三个雷

① 打敏感信息(密码、身份证)进日志,合规事故。② 循环里打 DEBUG,量爆炸把磁盘写满。③ 用 + 拼接字符串,即使不打印也先拼了,浪费;用 {} 占位符,惰性求值。

参数校验与防御式编程

边界问题(null、越界、超长)最容易被忽略,却最容易上线炸。对外部输入入口即校验:Bean Validation 注解 + 全局异常处理,把"坏数据"挡在业务逻辑之外。

public record CreateOrder( @NotNull Long userId, @Min(1) int quantity) {} // Controller 上加 @Valid,校验失败抛 MethodArgumentNotValidException // 全局 @RestControllerAdvice 捕获,统一返回 400 + 字段错误

幂等设计:重试与消息重复的必修课

网络重试、消息重复投递都可能导致同一请求执行多次。幂等的做法:用唯一业务键 + 去重表/Redis 存储,或让写操作"天然幂等"(如 UPDATE balance SET x=? WHERE id=? 而非 x=x+?)。

// 基于唯一键的幂等:插入去重表成功才继续,冲突即视为重复 if (dedupRepo.insertIfAbsent(requestId)) { businessLogic(); // 首次执行 } else { log.info("duplicate request {}", requestId); // 重复,直接返回 }

可观测三件套落地

论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/火焰图方法。