一句话速览:这一篇收拢散落在各处的”JVM 与并发”知识——它们在本项目的踩坑记录里反复作为”底层原因”出现(Nacos OOM、UserContextHolder、缓存 DCL、MQ 幂等),但一直没有一篇文档把它们讲透。主线是:JVM 部分记住”排查靠工具不靠猜”,并发部分记住”可见性/原子性/有序性三个问题各由什么机制解决”,幂等部分记住”任何可能被重复执行的入口都要自问一遍幂等”

涉及原始记录:07-面试问题/开发问题/01-依赖与配置问题.md P1-07(Nacos OOM);
关联模块:livin-common-securityUserContextHolder(ThreadLocal)、livin-house 的缓存 DCL、各 MQ 消费者

最近修订:2026-08-07

目录


技术点 1:JVM 内存结构与 OOM 排查

难度:⭐⭐⭐ | 掌握要求:内存分区、两类 OOM 的区别、排查工具链

核心概念

JVM 运行时内存的分区,每个区放什么、满了报什么错,要能对应上:

flowchart TD
    subgraph JVM内存
        H[堆 Heap
对象实例
-Xms/-Xmx 控制] M[元空间 Metaspace
类元数据
MaxMetaspaceSize] S[虚拟机栈
方法调用栈帧
-Xss 控制] D[直接内存
NIO/Netty ByteBuf
MaxDirectMemorySize] end H -->|溢出| E1["OutOfMemoryError: Java heap space"] M -->|溢出| E2["OutOfMemoryError: Metaspace"] S -->|溢出| E3["StackOverflowError"] D -->|溢出| E4["OutOfMemoryError: Direct buffer memory"]

不同报错指向不同区,排查方向完全不同:堆 OOM 查”什么对象占内存”(内存泄漏/大集合),Metaspace OOM 查”类加载器泄漏”(常见于热部署、动态代理类无限生成),直接内存 OOM 查 Netty/NIO 的 ByteBuf 是否忘了 release()(呼应 05-网络通信Netty.md 的引用计数)。

关联踩坑:Nacos 容器 OOM(开发问题 P1-07)

完整复盘见 08-容器化部署Docker.md。这里提炼 JVM 侧的教训:容器里跑 JVM 要分清两层 OOM——

  • JVM 堆 OOM:JVM 自己抛 OutOfMemoryError,应用通常还活着、日志有记录;
  • 容器 OOM Kill:进程总内存(堆+元空间+栈+直接内存+JIT 开销)超过容器 limit,Linux 内核直接 SIGKILL 掉进程,Java 日志里什么异常都没有,要用 docker inspectOOMKilled: true 才能确认。

注意堆不是全部:Xmx 256m 的 JVM 进程实际占用可能到 400M+(元空间、线程栈、直接内存、CodeCache 都在堆外)。容器内存预算要按”Xmx + 1.2~1.5 倍堆外冗余”估,不能 Xmx 给多少容器就限多少。

OOM 排查工具链(标准动作)

  1. 事前埋点:JVM 参数加 -XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=...,OOM 瞬间自动保存堆快照——事后想复现 OOM 现场几乎不可能,这个参数必须提前配
  2. 事中观察jstat -gcutil <pid> 1000 每秒看一次各代占用和 GC 次数——老年代(O 列)持续上涨且 Full GC 后降不下来,是内存泄漏的典型特征;jmap -histo <pid> 看对象实例数排行。
  3. 事后分析:把 heap dump 拉到本地用 MAT(Eclipse Memory Analyzer)打开,看 Dominator Tree(谁占用了最多内存)和 Leak Suspects 报告,沿着 GC Roots 引用链找到”是什么在拽着这批对象不放”。
  4. GC 日志-Xlog:gc*(JDK 9+ 统一日志语法)打开 GC 日志,判断是”内存不够”还是”GC 效率问题”。

GC 基础速通

  • 可达性分析:从 GC Roots(栈局部变量、静态引用、JNI 引用等)出发遍历引用链,走不到的对象判定可回收——这是”循环引用的对象也能被回收”的原因,区别于引用计数法。
  • 分代假说:绝大多数对象朝生夕死 → 堆分新生代(Eden + 2 个 Survivor,复制算法)和老年代(标记-整理);新生代 Minor GC 频繁但快,老年代 Full GC 慢且要尽量避免。
  • JDK 21 的收集器选择:默认 G1(把堆切成若干 Region,可预测停顿,吞吐与延迟平衡,8G 以下堆够用);大堆+极致低延迟选 ZGC(停顿 <1ms,与堆大小无关);记住结论”默认 G1,别乱换“即可应付大多数场景。

技术点 2:ThreadLocal —— 项目里 UserContextHolder 的底层

难度:⭐⭐⭐ | 掌握要求:实现原理、内存泄漏原因、线程池场景的坑

核心概念

UserContextHolder 能让”拦截器 set 的 userId 在 Service 层 get 到”,底层就是 ThreadLocal:每个线程内部维护一个 ThreadLocalMap(key 是 ThreadLocal 对象的弱引用,value 是存的值),同一个 ThreadLocal 在不同线程里存的是互相隔离的副本——它不是”全局变量”,是”线程私有变量”。

flowchart LR
    subgraph 请求线程A
        TA[ThreadLocalMap
userId=1001] end subgraph 请求线程B TB[ThreadLocalMap
userId=1002] end TL[UserContextHolder
同一个ThreadLocal对象] --> TA & TB

三个必须记住的坑

  1. 线程池场景必须 remove():Tomcat/业务线程池的线程是复用的——上一个请求 set 的 userId,如果这个请求没 remove,下一个复用该线程的请求会读到上一个请求的用户(数据串号,严重安全事故)。所以拦截器的 afterCompletion 里必须 UserContextHolder.clear()。这也呼应 07-可观测性.md 里 MDC(同为 ThreadLocal)必须 clear 的要求。
  2. 内存泄漏的机制:ThreadLocalMap 的 key 是弱引用(ThreadLocal 对象被 GC 后 key 变 null),但 value 是强引用——key 没了 value 还在,线程不死(线程池场景)这个 value 永远不可达也不可回收。remove() 同时清掉 key 和 value,这就是为什么”用完必须 remove”是铁律而不是建议。
  3. 异步/线程切换即失效@AsyncCompletableFuture、MQ 消费者里 UserContextHolder.getUserId() 拿不到值——值存在原线程里,不会跟着任务走。需要跨线程传递时,要么显式传参,要么用 TransmittableThreadLocal(阿里 TTL,线程池提交任务时把上下文”搬运”过去)。

技术点 3:Java 并发三要素与 DCL / 分布式锁

难度:⭐⭐⭐ | 掌握要求:可见性/原子性/有序性、DCL 为什么需要 volatile、synchronized 与 ReentrantLock 区别

并发三要素:所有并发问题都能归到这三类

问题 定义 解决机制
可见性 线程 A 改了变量,线程 B 看不到(CPU 缓存未刷回主存) volatile(强制读写主存)、锁(释放锁时刷主存)
原子性 “读-改-写”多步操作被并发打断(count++ 实际是三步) synchronized/Lock、CAS(AtomicInteger)、数据库原子 SQL
有序性 编译器/CPU 指令重排序,代码执行顺序与书写顺序不一致 volatile(禁止重排序)、happens-before 规则

DCL 双检锁:为什么单例必须加 volatile

经典单例 DCL 代码(本项目缓存回源用的也是同一模式):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public class Singleton {
private static volatile Singleton instance; // volatile 不能省

public static Singleton getInstance() {
if (instance == null) { // 第一次检查(无锁,快)
synchronized (Singleton.class) {
if (instance == null) { // 第二次检查(防并发重复创建)
instance = new Singleton();
}
}
}
return instance;
}
}

instance = new Singleton() 不是原子操作,它实际分三步:① 分配内存;② 调用构造函数初始化;③ 把引用指向这块内存。没有 volatile 时,② 和 ③ 可能被指令重排序——另一个线程在第一次检查时看到引用非空(③已执行),但对象还没初始化完(②没执行),拿到一个半构造对象volatile 禁止了这次重排序。这是”volatile 保证有序性”最经典的考法。

synchronized vs ReentrantLock vs 分布式锁

  • synchronized:JVM 内置,自动加解锁,JDK 6 后有锁升级优化(偏向锁→轻量级→重量级),大多数场景够用。
  • ReentrantLock:手动 lock/unlock,多了可中断等待、超时尝试、公平锁、多条件变量能力,需要这些特性时才用。
  • Redisson 分布式锁SET key value NX PX 抢锁 + 看门狗自动续期(默认锁 30s,业务没执行完每 10s 续一次)+ Lua 脚本保证”加锁/解锁”原子性。记住两个考点:解锁要校验是不是自己加的锁(防误删别人的锁,value 存线程标识);RedLock 多节点方案在业界有争议(马丁·克莱普曼的著名论战),一般业务用主从单点 + 看门狗就够,资金级场景宁可用数据库乐观锁兜底。
  • 选型主线同 06-数据库与并发控制.md能下推成一条原子 SQL 的,不用任何锁

技术点 4:接口幂等性设计

难度:⭐⭐ | 掌握要求:幂等定义、四种实现方案、本项目哪里需要

核心概念

幂等(Idempotent):同一个操作执行一次和执行 N 次,产生的业务效果相同。 为什么微服务下它从”加分项”变成”必答题”——因为重试无处不在:网关重试、Feign 重试、MQ 至少一次投递(02 篇)、用户双击、网络超时后客户端重发。任何”可能被重复执行”的入口,设计时都要自问一遍:重复执行会怎样?

四种标准实现方案

方案 原理 适用
数据库唯一索引 业务唯一键(如订单号)建唯一索引,重复插入直接冲突 新增类操作,最简单可靠
状态机前置校验 UPDATE ... SET status='PAID' WHERE id=? AND status='UNPAID',影响行数 0 说明已处理过 状态流转类(支付回调、租约签约),本项目支付回调该用这招
Token 机制 先领一次性 token,提交时服务端校验并删除,重复提交 token 已不存在 表单防重复提交
分布式锁/去重表 Redis setnx 或专门的”已处理消息表”(消息ID 唯一),消费前查重 MQ 消费幂等(02 篇的重试/死信场景)

与本项目的对应关系

  • 支付回调livin-payment):第三方支付平台明确会重复回调(收不到正确响应就按策略重发),回调处理必须幂等——先查账单状态,已支付就直接返回成功,不重复流转租约状态。
  • MQ 消费者livin-lease 的支付/退款结果消费者):RocketMQ 至少一次投递 + Rebalance 必然产生重复消息,消费逻辑要以”业务单号”为准判断处理过没有。
  • 冻结房源livin-housefreezeHouse):Seata 全局事务重试、Feign 超时重试都可能导致重复调用,status 已经是 OCCUPIED 时再次冻结应该是无操作而不是报错。

判断一个写接口是否幂等的最快方法:问自己”这个接口被同一个参数连续调 10 次,数据库状态和第 1 次之后有区别吗”——有区别就不是幂等的,且这个接口一旦接入任何带重试的机制就会出脏数据。


术语表

术语 含义
堆 / 元空间 / 直接内存 JVM 内存分区:对象实例 / 类元数据 / NIO 堆外缓冲,OOM 报错各不相同
OOM Kill 进程总内存超容器 limit 被内核强杀,Java 日志无异常,区别于堆 OOM
Heap Dump / MAT OOM 时的堆快照 / 堆分析工具(Dominator Tree 找内存大头)
GC Roots / 可达性分析 引用遍历起点 / 从 Roots 出发走不到的对象即垃圾的判定法
G1 / ZGC JDK 默认分区式收集器(停顿可预测)/ 大堆超低停顿收集器
ThreadLocal 线程私有变量机制;线程池场景必须 remove() 防串号和泄漏
TransmittableThreadLocal 阿里 TTL,解决线程池切换时 ThreadLocal 上下文丢失
可见性 / 原子性 / 有序性 Java 并发三要素,分别由 volatile/锁、锁/CAS、volatile/happens-before 解决
DCL 双检锁 两次判空夹一次加锁的单例模式,必须配 volatile 防指令重排
看门狗(Watchdog) Redisson 分布式锁的自动续期机制,防业务未执行完锁先过期
幂等 同一操作执行 N 次与 1 次效果相同;重试无处不在的微服务下的必备属性