type
status
date
slug
summary
tags
category
icon
password
catalog
sort
前言:为什么“看不见的细节”决定程序性能?
在Java开发中,我们常常遇到这样的困惑:明明代码逻辑没问题,却在高并发下出现诡异的结果;系统运行一段时间后突然卡顿,日志里满是GC警告;缓存明明加了,性能却没提升多少……这些问题的根源,往往藏在内存屏障、引用类型、缓存算法和垃圾回收这些“看不见的细节”里。
- CPU 有多快,内存就有多慢;
- Java 没指针,但有四种引用;
- 真正拖垮系统的,往往不是你写的 bug,而是看不见的缓存抖动 + GC 停顿。
今天咱们把知识揉碎、拌点段子,做成一份「从入门到入土」的渐进式学习笔记。读完你能:
- 把 volatile 的内存屏障说出花儿来;
- 用 SoftReference 写个不会 OOM 的本地缓存;
- 徒手撕 LRU、LFU、FIFO 并知道为啥面试只考 LRU;
- 看懂 GC log 里那一串
[Full GC (Metadata GC Threshold) ...]
到底在鬼叫啥;
- 顺手把 JDK 21 的 ZGC、分代 Shenandoah 八卦一通。
本文是一份系统的学习笔记,整合了内存模型、并发编程、缓存设计和垃圾回收的核心知识,通过“原理拆解→代码实现→实战踩坑→性能优化”的渐进式讲解,帮你打通从理论到实践的任督二脉。全文超过2万字,包含30+代码示例、10+对比表格和大量实战技巧,适合有一定Java基础,想深入理解底层原理的开发者。
一、内存屏障:CPU与JVM之间的“交通信号灯”
1.1 为什么需要内存屏障?——从一个“反直觉”的并发问题说起
先看一段简单的代码:
直觉上,这段代码的执行结果可能有三种:
- 若t1先执行完:a=1, b=1 → x=1, y=1
- 若t2先执行完:b=1, a=1 → x=1, y=1
- 若t1执行①后t2执行③④:x=0, y=1
但在ARM架构的服务器上,却可能出现x=0, y=0的结果。这不是代码的问题,而是CPU和编译器的“优化”导致的——它们会在不影响单线程语义的前提下,对指令重排或延迟写入内存,从而破坏多线程的可见性。
于是硬件老爷爷造了个「内存屏障」:
一句话——让某些指令必须“排队”,并且把缓存刷干净。
导致问题的三大“元凶”
- 指令重排:编译器或CPU为了提高效率,会调整指令执行顺序。例如t1中,①和②没有数据依赖,可能被重排为“先执行②,再执行①”;同理t2中③和④也可能重排。
- CPU缓存与写缓冲:CPU不会直接操作主存,而是通过L1/L2/L3缓存读写数据。写操作会先存入“写缓冲”(Store Buffer),延迟刷入主存。若t1的①存入缓冲未刷出,t2执行④时读到的a仍是0。
- 缓存一致性协议的延迟:不同CPU核心的缓存独立,修改数据后需通过MESI协议通知其他核心,但这个过程存在延迟。t1修改a后,t2的缓存可能还未更新,导致读取旧值。
内存屏障的本质:“强制排队”与“数据同步”
内存屏障(Memory Barrier)是硬件提供的指令,作用是阻止特定类型的指令重排,并强制刷新缓存,确保多线程间的数据可见性。用一句话概括:让屏障前后的指令“按顺序执行”,并让数据状态“全局同步”。
1.2 四种内存屏障:各司其职的“交通规则”
硬件层面的内存屏障主要分为四类,它们的作用和适用场景如下:
屏障类型 | 作用(严格定义) | 通俗理解 | 典型应用场景 |
LoadLoad | 确保屏障前的所有Load指令(读操作)先于屏障后的Load指令执行 | 读A → 屏障 → 读B:A读完才能读B | final字段的安全发布、volatile读 |
StoreStore | 确保屏障前的所有Store指令(写操作)先于屏障后的Store指令执行,并刷新到主存 | 写A → 屏障 → 写B:A写完并同步到主存后,才能写B | volatile写前、对象构造完成后 |
LoadStore | 确保屏障前的Load指令先于屏障后的Store指令执行 | 读A → 屏障 → 写B:A读完才能写B | 较少见,如读数据后立即修改共享变量 |
StoreLoad | 确保屏障前的Store指令先于屏障后的Load指令执行,且Store的结果已同步到主存 | 写A → 屏障 → 读B:A写完并同步到主存后,才能读B | volatile写后、synchronized解锁 |
不同CPU架构的“特殊待遇”
不是所有CPU都需要这四种屏障。例如:
- x86/64架构:硬件天然保证LoadLoad、StoreStore、LoadStore的顺序,只需要StoreLoad屏障(通过
lock
前缀指令实现)。
- ARM/POWER架构:四种屏障都需要显式插入(如ARM的
dmb
、dsb
指令)。
这也是为什么同样的代码在x86上“看似正确”,在ARM上却可能出错——x86的硬件特性掩盖了内存可见性问题。
1.3 JVM中的内存屏障:从硬件到Java代码的映射
JVM的内存模型(JMM)通过插入内存屏障来实现多线程的可见性和有序性,无需开发者直接操作硬件指令。以下是关键场景的实现:
volatile关键字:靠屏障保证可见性
volatile ≠ 锁,但比锁轻 JVM对
volatile
变量的读写会插入特定屏障:- 写操作:在
volatile
写后插入StoreLoad屏障,确保写操作对其他线程可见;写前插入StoreStore屏障,防止之前的写操作被重排到volatile写之后。
- 读操作:在
volatile
读前插入LoadLoad屏障,防止之后的读操作被重排到volatile读之前;读后插入LoadStore屏障,防止读操作被重排到写操作之后。
代码验证(HotSpot源码
orderAccess_linux_x86.inline.hpp
):lock
前缀的指令会触发缓存一致性协议,强制将写缓冲中的数据刷入主存,并使其他核心的对应缓存行失效,确保可见性。synchronized:更“重量级”的屏障
synchronized
的同步块在进入和退出时,会隐含更严格的屏障:- 进入同步块:获取锁时,插入LoadLoad、LoadStore屏障,确保读取到最新的共享变量。
- 退出同步块:释放锁时,插入StoreStore、StoreLoad屏障,确保所有修改对其他线程可见。
这也是
synchronized
能保证原子性、可见性和有序性的底层原因(除了屏障,还依赖锁机制)。final关键字:靠屏障实现安全发布
final
字段在构造函数中初始化后,其他线程能看到正确的值,依赖JVM在构造函数末尾插入的StoreStore屏障——防止final
字段的写操作被重排到构造函数之外,避免其他线程看到“未初始化完成的对象”。反例(若没有屏障可能出现的问题):
StoreStore屏障确保①一定在②之前执行,避免reader线程看到
value=0
的情况。1.4 内存屏障的性能成本:别滥用!
内存屏障不是免费的,它会阻塞指令流水线,增加延迟:
- StoreLoad屏障:在x86上约50-100纳秒(ns),是四种屏障中最慢的。
- 频繁使用
volatile
或synchronized
,会因屏障累积导致性能下降。
优化建议:
- 能用局部变量或线程封闭(Thread Confinement)解决的场景,就不要用共享变量。
- 对高频写操作,用
LongAdder
替代AtomicLong
(分段CAS,减少屏障竞争)。
- 利用x86架构的特性:在x86上,
volatile
读的成本较低(无显式屏障),可适当多用;写操作成本高,需谨慎。
1.5 学习小结:内存屏障的核心价值
内存屏障是多线程可见性的“底层保障”,它通过约束指令重排和缓存同步,让Java代码在不同硬件架构上表现一致。理解屏障的作用,能帮我们:
- 解释
volatile
、synchronized
的底层原理;
- 避免写出“看似正确,实则有并发隐患”的代码;
- 在性能优化时,平衡可见性和执行效率。
二、Java引用类型:对象“生死权”的掌控者
Java的引用类型决定了对象何时被垃圾回收,是内存管理的核心工具。从“绝不回收”到“随时回收”,四种引用类型构成了对象生命周期的“梯度管理”。
2.1 强引用:“宁死不屈”的默认引用
定义:普通的对象引用(如
Object obj = new Object()
),是Java中最常见的引用类型。回收时机:只要强引用存在,GC绝对不会回收被引用的对象,即使内存溢出(OOM)也不例外。
使用场景:99%的业务代码中,对象都通过强引用持有,确保核心数据不被意外回收。
踩坑案例:静态集合导致的内存泄漏
解决方案:不再需要对象时,手动移除引用(
CACHE.remove(obj)
)或清空集合(CACHE.clear()
),切断强引用。GC 处理流程:
- 引用链判断:只要对象被强引用关联(且引用链可达,即从 GC Roots 出发能找到该对象),GC 就绝不会回收该对象,无论内存是否充足。
- 回收触发条件:只有当强引用被显式断开(如
obj = null
),或引用所在的上下文被销毁(如局部变量出栈),导致对象不再被任何强引用关联时,该对象才会被标记为 “可回收”。
- 极端情况:如果大量对象被强引用持有且无法释放,可能导致内存耗尽,触发
OutOfMemoryError
(OOM)。
2.2 软引用(SoftReference):“内存不足才回收”的缓存神器
定义:通过
SoftReference
类实现,用于描述“有用但非必需”的对象。回收时机:当内存不足时(JVM即将抛出OOM前),GC会回收只被软引用持有的对象。
使用场景:本地缓存(如图片缓存、网页数据缓存),既能保留常用数据,又能在内存紧张时自动释放空间。
正确用法:软引用+引用队列
为什么需要引用队列?
软引用的
get()
方法返回null
时,说明对象已被回收,但软引用本身仍在缓存中(占用内存)。通过引用队列,可及时移除这些“空壳子”,避免内存泄漏。GC 处理流程:
- 内存充足时:即使对象仅被软引用关联,GC 也不会回收该对象,确保缓存数据可用。
- 内存不足时:当 JVM 判断内存即将耗尽(触发内存紧张检测),GC 会主动回收所有仅被软引用关联的对象,以释放内存。
- 引用队列配合:软引用可关联一个
ReferenceQueue
,当被引用的对象被 GC 回收后,软引用实例会被放入该队列,程序可通过遍历队列清理无效的软引用(避免内存泄漏)。
应用场景:图片缓存、数据缓存等,在内存充足时保留,内存不足时自动释放,避免 OOM。
2.3 弱引用(WeakReference):“GC一到就回收”的临时数据容器
定义:通过
WeakReference
类实现,用于描述“非必需”的对象。回收时机:只要GC运行,无论内存是否充足,只被弱引用持有的对象都会被回收。
使用场景:
- 临时数据缓存(如ThreadLocal的key);
- 关联管理(如监听器注册,避免监听器未注销导致的内存泄漏)。
经典应用:WeakHashMap
WeakHashMap
的key是弱引用,当key被回收后,对应的键值对会自动从map中删除:注意:若
WeakHashMap
的value间接引用key(如key = new String("a"); map.put(key, key)
),会导致key永远无法回收(value的强引用→key),引发内存泄漏。GC 处理流程:
- 回收时机:无论内存是否充足,只要发生 GC(Minor GC 或 Full GC),所有仅被弱引用关联的对象都会被立即回收。
- 引用队列配合:与软引用类似,弱引用可关联
ReferenceQueue
,当对象被回收后,弱引用实例会进入队列,方便程序清理无效引用。
典型应用:
WeakHashMap
的键(Key)使用弱引用,当键对应的对象被回收后,键值对会自动从 Map 中移除,避免缓存数据长期占用内存。2.4 虚引用(PhantomReference):“只为回收通知”的幽灵引用
定义:通过
PhantomReference
类实现,是最弱的引用类型。特点:
- 虚引用的
get()
方法永远返回null
,无法通过引用获取对象;
- 必须与
ReferenceQueue
结合使用,当对象被回收时,虚引用会进入队列,作为“回收通知”。
使用场景:管理堆外内存(如NIO的DirectByteBuffer),在对象回收时释放堆外资源。
原理示例:
DirectByteBuffer中的应用:
DirectByteBuffer
通过虚引用(Cleaner
,继承自PhantomReference
)管理堆外内存。当DirectByteBuffer
被回收时,Cleaner
的clean()
方法会调用Unsafe.freeMemory()
释放堆外内存,避免内存泄漏。GC 处理流程:
- 无直接引用能力:虚引用无法通过
get()
方法获取对象(调用返回null
),因此不能通过虚引用访问对象。
- 回收通知作用:当对象被 GC 标记为 “可回收” 并即将被回收时,虚引用实例会被放入关联的
ReferenceQueue
,通知程序对象已进入回收阶段。
- 资源清理时机:程序通过监听队列得知对象即将被回收后,可在此时释放与对象关联的非堆内存资源(如直接内存
DirectByteBuffer
的清理),避免资源泄漏。
核心作用:跟踪对象被 GC 回收的过程,用于安全释放非 Java 堆资源(JVM 无法自动管理的资源)。
2.5 四种引用类型对比与实战选择
引用类型 | 回收触发条件 | 能否通过引用获取对象 | 典型应用场景 | 内存泄漏风险 |
强引用 | 无(除非断引用) | 能 | 普通对象引用 | 高(易因忘记断引用导致) |
软引用 | 内存不足时 | 能(回收前) | 图片缓存、网页缓存 | 中(需配合引用队列清理) |
弱引用 | GC运行时 | 能(回收前) | ThreadLocal、WeakHashMap | 低(注意value不引用key) |
虚引用 | 任意时机(回收时通知) | 不能 | 堆外内存管理、回收通知 | 极低 |
选择原则:
- 核心数据(如用户会话)→ 强引用;
- 常用缓存(如首页数据)→ 软引用;
- 临时关联数据(如缓存临时计算结果)→ 弱引用;
- 资源释放通知(如堆外内存)→ 虚引用。
2.6 学习小结:引用类型是内存管理的“开关”
四种引用类型让Java的内存管理更灵活:既可以通过强引用保证核心数据不丢失,又能通过软/弱引用实现缓存的自动释放,还能通过虚引用处理资源回收。理解它们的回收时机和使用场景,是避免OOM和内存泄漏的关键。
三、缓存算法:有限空间里的“数据优先级”管理
缓存的核心问题是“空间有限时,该淘汰哪些数据”。不同的缓存算法(页面置换算法)决定了缓存的命中率和性能,其中LRU、LFU、FIFO是最经典的三种。
3.1 缓存算法的评价标准
一个优秀的缓存算法需要满足:
- 高命中率:尽可能保留常用数据,减少缓存失效;
- 低时间复杂度:get/put操作高效(最好O(1));
- 低空间开销:不需要太多额外内存存储元数据(如访问次数)。
3.2 FIFO(先进先出):最简单的“排队淘汰”
核心思想:按数据进入缓存的顺序淘汰,最早进入的先被删除(类似队列)。
原理示例
假设缓存容量为3,操作序列:
put(A) → put(B) → put(C) → put(D) → get(B) → put(E)
put(A)
→ 缓存:[A](队尾为A)
put(B)
→ 缓存:[A, B](队尾为B)
put(C)
→ 缓存:[A, B, C](队尾为C)
put(D)
→ 缓存满,淘汰最早的A → [B, C, D](队尾为D)
get(B)
→ 缓存不变(FIFO不关心访问频率)→ [B, C, D]
put(E)
→ 缓存满,淘汰最早的B → [C, D, E](队尾为E)
实现方式(双向链表+HashMap)
优缺点
- 优点:实现简单,时间复杂度O(1)。
- 缺点:命中率低,无法识别常用数据。例如上述示例中,B被访问过但仍被淘汰,导致后续若再次访问B需重新加载。
适用场景:数据访问顺序固定(如日志缓存),或对命中率要求不高的场景。
3.3 LFU(最不经常使用):按“访问次数”淘汰
核心思想:根据数据被访问的次数淘汰,访问次数最少的先被删除。
原理示例
假设缓存容量为3,操作序列:
put(A) → put(B) → put(C) → get(A) → get(A) → get(B) → put(D)
put(A)
→ 缓存:{A:1}(次数1)
put(B)
→ 缓存:{A:1, B:1}
put(C)
→ 缓存:{A:1, B:1, C:1}
get(A)
→ A次数+1 → {A:2, B:1, C:1}
get(A)
→ A次数+1 → {A:3, B:1, C:1}
get(B)
→ B次数+1 → {A:3, B:2, C:1}
put(D)
→ 缓存满,淘汰次数最少的C → {A:3, B:2, D:1}
实现方式(双哈希表优化)
直接用小顶堆实现LFU会导致
get
操作复杂度O(n)(需更新堆),优化方案用“哈希表+频率桶”:keyToNode
:key→节点(存value和访问次数);
freqToNodes
:频率→双向链表(相同频率的节点);
minFreq
:记录最小频率,快速定位要淘汰的节点。
优缺点
- 优点:优先保留高频访问数据,命中率高于FIFO。
- 缺点:
- 实现复杂,需维护频率和链表;
- 对“突发高频”数据不友好(如微博热搜):旧高频数据可能长期占据缓存,新热点数据被淘汰。
适用场景:访问频率稳定的场景(如电商的热销商品)。
3.4 LRU(最近最少使用):按“访问时间”淘汰
核心思想:淘汰“最近最久未被使用”的数据,认为最近使用的 data 将来更可能被再次使用(符合局部性原理)。
原理示例
假设缓存容量为3,操作序列:
put(A) → put(B) → put(C) → get(A) → put(D) → get(B)
put(A)
→ 缓存:[A](最近使用:A)
put(B)
→ 缓存:[B, A](最近使用:B)
put(C)
→ 缓存:[C, B, A](最近使用:C)
get(A)
→ A被访问,移到队头 → [A, C, B](最近使用:A)
put(D)
→ 缓存满,淘汰最久未用的B → [D, A, C](最近使用:D)
get(B)
→ B不在缓存(需重新加载)→ 加载后加入队头 → [B, D, A]
实现方式(双向链表+HashMap)
优缺点
- 优点:
- 实现较简单,时间复杂度O(1);
- 命中率高,符合实际应用中“最近使用的数据更可能被再次使用”的规律。
- 缺点:对“周期性访问”的数据不友好(如每小时访问一次的报表数据,可能被频繁淘汰)。
适用场景:大多数缓存场景(如浏览器缓存、Redis缓存、本地缓存),是工业界的首选。
3.5 工业级缓存算法:LRU的优化与变种
实际应用中,纯LRU可能仍有不足,衍生出多种优化算法:
- Redis的近似LRU:
不维护完整链表,而是在每个key中记录“最后访问时间戳”,淘汰时随机采样N个key(默认5个),淘汰时间戳最小的。优点是节省内存,性能接近纯LRU。
- Caffeine的W-TinyLFU:
结合LFU和LRU的优点:用LFU过滤低频数据,用LRU管理高频数据,解决LFU对突发热点的不友好问题,是Java中性能最好的本地缓存库。
- LRU-K:
记录数据的最近K次访问时间,当访问次数达到K时才加入缓存,淘汰时选择第K次访问时间最早的数据,减少“一次性访问”数据对缓存的污染。
3.6 学习小结:缓存算法的选择艺术
算法 | 核心依据 | 实现复杂度 | 命中率 | 典型应用 |
FIFO | 进入顺序 | 简单 | 低 | 日志缓存 |
LFU | 访问频率 | 复杂 | 中高 | 热销商品 |
LRU | 访问时间 | 中等 | 高 | 浏览器、Redis |
选择时需结合业务场景:
- 简单场景用LRU(性价比最高);
- 高频稳定场景用LFU;
- 顺序访问场景用FIFO;
- 高性能场景直接用成熟库(如Caffeine、Guava Cache)。
四、垃圾回收(GC):JVM的“内存清洁工”
刚开始我以为GC就只是“回收内存”,后来才发现它还有两个隐藏技能:
- 清除内存碎片:频繁创建和删除对象会导致内存像“打补丁”一样零散,GC会把存活对象挪到一起,腾出连续的内存块(就像整理衣柜,把衣服叠好腾出空间)。
- 保障程序安全:避免手动释放内存时的操作失误(比如C++里的double free问题),Java的GC从根源上杜绝了这类bug。
当然,GC也不是完美的——它会占用CPU时间,极端情况下可能让程序卡顿(比如“Stop The World”现象)。但总体来说,有了GC,我们终于不用像C++开发者那样,天天担心“忘了释放内存”了~
垃圾回收是JVM自动管理内存的核心机制,负责识别和回收“不再使用的对象”,避免内存泄漏和OOM。理解GC的工作原理,是排查内存问题和性能调优的基础。
4.1 垃圾回收的核心问题:“什么是垃圾?”
GC的第一步是判断对象是否为“垃圾”(即不再被引用),主要有两种判断方法:
引用计数法(已淘汰)简单但有“死结”
原理:给每个对象分配一个“引用计数器”,被引用时+1,引用失效时-1,计数器为0则为垃圾。
优点:简单高效,实时性高(对象一成为垃圾就能被发现)。
缺点:无法解决“循环引用”问题(如A引用B,B引用A,计数器均为1,实际已无外部引用),因此现代JVM(如HotSpot)不采用。
可达性分析(主流方法)
这是现在最常用的方法。它把对象看作“节点”,引用看作“边”,从一组“根对象”(比如栈中的局部变量、静态变量、JNI引用等)出发,能被遍历到的对象都是“活的”,遍历不到的就是垃圾。
就像警察抓逃犯:从“根节点”(已知好人)开始排查,能联系上的都是“同伙”(存活对象),联系不上的就是“逃犯”(垃圾)。
原理:从“GC Roots”(根对象)出发,遍历对象引用链,未被遍历到的对象即为垃圾。
哪些是“根节点”?GC Roots包含:
- 虚拟机栈中引用的对象(如方法局部变量);
- 方法区中类静态属性引用的对象(如
static Object obj
);
- 方法区中常量引用的对象(如
String s = "常量"
);
- JNI(Native方法)引用的对象。
示例:
4.2 垃圾回收算法:“如何清理垃圾?”
找到垃圾后,需要清理并整理内存,主要有四种经典算法:
1. 标记-清除算法(Mark-Sweep)
步骤:
- 标记:从GC Roots出发,标记所有存活对象;
- 清除:遍历堆内存,回收未标记的垃圾对象。
优缺点:
- 优点:简单,无需移动对象;
- 缺点:效率低(需全堆遍历),产生内存碎片(回收后内存不连续,大对象可能无法分配)。
2. 复制算法(Copying)
步骤:
- 将内存分为两块(From区和To区),只使用From区;
- 回收时,将From区的存活对象复制到To区,然后清空From区;
- 交换From区和To区的角色,重复使用。
优化:HotSpot的新生代采用“Eden + 2个Survivor”(比例8:1:1),每次使用Eden和1个Survivor,回收时复制存活对象到另一个Survivor,空间利用率达90%。
优缺点:
- 优点:无内存碎片,效率高(只复制存活对象);
- 缺点:浪费部分内存(需预留To区),不适合存活对象多的场景(如老年代)。
3. 标记-整理算法(Mark-Compact)
步骤:
- 标记:同标记-清除,标记存活对象;
- 整理:将存活对象向内存一端移动,然后清理边界外的垃圾。
优缺点:
- 优点:无内存碎片,空间利用率高;
- 缺点:整理阶段需移动对象,成本高(尤其大对象)。
4. 分代收集算法(Generational Collection)
核心思想:根据对象存活时间(分代假说),对不同区域采用不同算法:
- 新生代(对象存活时间短,存活率低):用复制算法;
- 老年代(对象存活时间长,存活率高):用标记-清除或标记-整理算法。
这是所有现代JVM的默认选择,兼顾效率和空间利用率。
4.3 HotSpot的内存布局与GC流程
堆内存分代
HotSpot将堆分为新生代和老年代:
- 新生代:Eden(80%) + Survivor0(10%) + Survivor1(10%);
- 老年代:存储存活时间长的对象(默认新生代:老年代=1:2,可通过
XX:NewRatio
调整);
- 元空间(Metaspace):JDK8后替代永久代,存储类元数据(如类信息、方法信息),使用本地内存。
对象的生命周期
- 创建:对象优先在Eden区分配;
- Minor GC:Eden区满时触发,回收新生代垃圾,存活对象移到Survivor区,年龄+1;
- 晋升老年代:
- 年龄达到阈值(默认15,
XX:MaxTenuringThreshold
调整); - Survivor区中对象总大小超过一半,年龄≥该大小对应年龄的对象直接晋升;
- 大对象(超过Eden区一半)直接进入老年代(
XX:PretenureSizeThreshold
控制);
- Major GC/Full GC:老年代满时触发,回收整个堆(新生代+老年代),成本高,可能导致STW(Stop-The-World)。
4.4 常见GC收集器:从Serial到ZGC
不同的GC收集器采用不同的实现策略,适应不同场景:
收集器 | 适用代际 | 算法 | 特点 | JDK版本支持 |
Serial GC | 新生代 | 复制 | 单线程GC,STW时间长,适合小堆(<100MB) | 所有版本(Client默认) |
ParNew GC | 新生代 | 复制 | Serial的多线程版,CMS的搭档 | JDK5-14(JDK14移除) |
Parallel GC | 新生代 | 复制 | 多线程,吞吐量优先(GC时间占比低),Server默认 | 所有版本 |
CMS GC | 老年代 | 标记-清除 | 低延迟(并发标记和清除),内存碎片多,CPU消耗高 | JDK5-14(JDK14移除) |
G1 GC | 全代 | 区域化复制 | 可预测停顿( -XX:MaxGCPauseMillis ),兼顾吞吐量和延迟,JDK9+默认 | JDK7+ |
ZGC | 全代 | 染色指针+读屏障 | 超低延迟(<10ms),支持TB级堆,并发整理,JDK21生产可用 | JDK11+ |
Shenandoah | 全代 | Brooks指针 | 低延迟,并发整理,RedHat主导,JDK17+转正 | JDK12+ |
G1 GC:区域化分代收集器
前面的算法都有优缺点,分代收集是“组合拳”——根据对象存活时间把内存分成“新生代”和“老年代”,用不同算法:
- 新生代(对象活不久):用复制算法(存活对象少,复制成本低)。
- 老年代(对象活很久):用标记-清除或标记-整理算法(存活对象多,复制不划算)。
这就像垃圾分类:易腐垃圾(新生代对象)快速处理,可回收物(老年代对象)集中处理,效率翻倍~
G1将堆分为多个大小相等的Region(默认1-32MB),每个Region可动态标记为Eden、Survivor或Old。核心流程:
- 初始标记:STW,标记GC Roots直接关联的对象;
- 并发标记:并发遍历对象引用链,标记存活对象;
- 最终标记:STW,处理并发标记的遗留引用;
- 筛选回收:STW,根据Region的回收收益(回收空间/时间)排序,优先回收收益高的Region(Garbage-First的由来)。
优势:通过控制回收的Region数量,可保证GC停顿不超过预设阈值(如200ms)。
ZGC:下一代低延迟收集器
ZGC的核心技术:
- 染色指针:在指针中嵌入标记信息(如是否存活),无需额外空间存储标记;
- 读屏障:访问对象时触发,处理并发标记和移动;
- 并发整理:对象移动在并发阶段完成,STW阶段仅需处理少量收尾工作。
性能指标:停顿时间<10ms,支持8MB-4TB堆,吞吐量损失<15%,适合高并发、低延迟场景。
4.5 GC日志解读:从日志看GC健康度
通过JVM参数开启GC日志:
典型G1 GC日志解析:
GC(1)
:第1次GC;
Pause Young
:Minor GC;
Eden: 1024M->0B
:Eden区被清空;
Heap: 1024M->256M
:堆内存使用从1GB降到256MB;
2.000ms
:STW时间2ms(可接受)。
一行典型 Full GC:
- Young 区从 512K → 0K,复制算法把活对象扔 Old 区;
- Old 区 4083K → 3921K,标记整理后回收了 162K;
- 整个堆从 4.5M → 3.8M;
- Metaspace 没变化,说明只是类加载器没卸载;
- STW 12 ms,可以接受。
4.6 GC调优实战:从“卡顿”到“丝滑”
GC调优的核心目标:减少STW时间,降低GC频率,步骤如下:
1. 明确调优目标
- 吞吐量优先:GC时间占比低(如<5%),适合后台任务(如数据分析);
- 延迟优先:GC停顿时间短(如<100ms),适合实时服务(如交易系统)。
2. 监控GC状态
- 工具:JConsole、VisualVM、GCEasy(在线分析GC日志);
- 关键指标:
- Minor GC频率(正常<10次/分钟);
- Full GC频率(正常<1次/小时);
- STW时间(延迟敏感场景<50ms)。
3. 常见问题与解决方案
问题 | 可能原因 | 解决方案 |
Minor GC频繁 | 新生代过小,对象晋升快 | 调大新生代( -Xmn 或-XX:NewRatio=1 ) |
Full GC频繁 | 老年代内存泄漏,大对象多 | 排查内存泄漏(用MAT工具),增大老年代( -Xms/-Xmx ),避免大对象直接进老年代 |
GC停顿时间长 | 堆过大,GC遍历耗时 | 用G1/ZGC(支持大堆低延迟),设置 -XX:MaxGCPauseMillis=200 |
元空间OOM | 类加载过多,未卸载 | 增大元空间( -XX:MetaspaceSize=256m -XX:MaxMetaspaceSize=512m ),检查类加载器泄漏 |
4. ZGC调优示例(JDK21+)
ZGC在大堆(>8GB)场景下优势明显,停顿时间稳定在10ms以内,适合高并发服务。
4.7 什么时候会触发GC?别等内存炸了才反应
GC不是定时执行的,JVM有自己的“触发逻辑”。主要有两种情况:
(1)内存不够了:被迫干活
当创建对象时,JVM发现内存不够,会主动触发GC。比如新生代的Eden区满了,会触发“Minor GC”(清理新生代);如果老年代也满了,会触发“Full GC”(清理整个堆)。
Full GC成本很高,可能导致程序卡顿(Stop The World),所以要尽量避免频繁Full GC。
(2)手动“催更”:System.gc()
我们可以调用
System.gc()
通知JVM执行GC,但这只是“建议”,JVM可以无视。比如:不推荐:频繁调用会增加GC负担,影响性能。除非你明确知道现在有大量垃圾(比如批量处理完数据后),否则别用。
4.8 finalize():对象的“临终遗言”
每个对象都有个
finalize()
方法(继承自Object),GC回收对象前会调用它,相当于给对象一个“最后说话的机会”。但这方法坑很多,比如:
- 调用时机不确定:你不知道GC什么时候来,所以别指望它做“必须及时完成”的事(比如释放文件句柄)。
- 可能让对象“复活”:如果在
finalize()
里给对象加个强引用,它就不会被回收了(但不建议这么玩,纯属炫技)。
- 最多被调用一次:对象被“复活”后,下次GC不会再调用它的
finalize()
。
示例:
输出:
结论:别依赖
finalize()
,释放资源(比如关文件、关流)最好手动调用close()
方法,靠谱多了。4.9 优化GC:让你的程序少“卡顿”
GC频繁或耗时太长会导致程序卡顿,分享几个实战优化技巧:
- 少用静态集合存大量数据:静态集合的对象是强引用,不会被回收,容易占满老年代。
- 字符串拼接用StringBuffer/StringBuilder:String是不可变的,拼接会产生大量临时对象(垃圾),而StringBuffer是可变的,减少垃圾。
- 及时释放无用引用:不用的对象手动设为
null
(尤其是大对象)。
- 用基本类型代替包装类:
int
比Integer
省内存,long
比Long
省内存。
- 避免频繁创建大对象:比如循环里new大数组,最好提前创建好复用。
- 合理设置JVM参数:比如新生代和老年代的比例(-XX:NewRatio=2)、Eden和Survivor的比例(-XX:SurvivorRatio=8),根据业务调优。
4.10 学习小结:GC是“服务质量”的隐形管家
GC的性能直接影响应用的响应速度和稳定性。理解分代收集、常见收集器特性和调优方法,能帮助我们:
- 快速定位内存泄漏和OOM问题;
- 根据业务场景选择合适的GC收集器;
- 通过参数调优平衡吞吐量和延迟。
五、内存屏障和缓存算法在JVM中具体如何影响性能?
把“内存屏障”和“缓存算法”放在一起聊性能,很多人会觉得它们一个在 CPU 层、一个在应用层,八竿子打不着。但其实 JVM 作为一个横跨硬件和软件的“巨兽”,这两个机制会在**微秒级(屏障)+ 毫秒级(算法)**两个时间尺度上产生“共振”,最终把一条普通 Java 语句拖慢 10 倍、甚至 100 倍。下面用“一条链、一张图、两个公式、三组实验”给你彻底讲清。
5.1 从 Java 语句到纳秒停顿
假设我们写了一段看似人畜无害的代码:
把①放大看,在 HotSpot + x86 上 CPU 视角其实是:
- ② 只是普通的 store,L1 hit 大概 4 cycles(≈ 1.2 ns)。
- ③ 因为要插入 StoreLoad 屏障,编译器会加
lock
前缀,CPU 必须: - 把 store buffer 刷干净(Drain Buffer)
- Invalidate 其他核的对应缓存行
- 保证全局顺序一致
这一套组合拳在 Skylake 上大约 40-60 cycles(12-18 ns),比 ② 慢了 10-15 倍。
如果此时 缓存行伪共享(False Sharing)再来凑热闹——比如另一个核也在写同一个 64 B 行——
lock
会触发 MESI Fwd/RFO 风暴,延迟直接飙升到 300-500 ns,从纳秒变微秒。结论:
内存屏障把“CPU 缓存速度”瞬间拉低到“总线速度”,而缓存算法决定我们多久触碰到这个惩罚一次。
5.2 屏障 × 缓存算法 的耦合点
翻译成人话:
缓存算法决定了对象何时被放进老年代,从而决定了Full GC 频率;
Full GC 时 JVM 内部需要大量写屏障(Card Table、SATB)保证并发标记正确,又进一步放大屏障开销。
于是出现一种“死亡螺旋”:
缓存命中率低 → 对象频繁晋升 → Old Gen 快满 → Full GC 增多 → 屏障更多 → CPU stall ↑ → TPS ↓ → 缓存抖动 ↑ ……
5.3 两个公式:量化性能影响
1. 屏障成本模型(简化)
L_hit
:普通 L1 hit 延迟
L_flush
:刷 store buffer 延迟
L_invalidate
:跨核失效延迟
经验值:
- 单线程空跑 volatile++:≈ 18 ns/op
- 四核同时写同一行:≈ 420 ns/op(23 倍)
2. 缓存算法成本模型
P_full
:缓存满的概率(由命中率决定)
T_promote
:对象晋升到老年代的平均时间
T_gc_barrier
:晋升时 JVM 额外屏障(Card Mark、SATB)
举例:
- LRU 命中率 90% → 每 10 次 put 触发 1 次晋升 → Old Gen 缓慢增长 → 10 min 一次 Major GC
- FIFO 命中率 60% → 大量冷数据晋升 → 30 s 一次 Major GC → 屏障风暴
5.4 三组实验:在 JMH 里看到数字
实验 1:volatile 写 vs 普通写
结果(i9-12900K):
Benchmark | Score | 单位 |
plain | 1.2 | ns |
volat | 18.7 | ns |
实验 2:伪共享放大
把
int v
换成 long v[16]
,四个线程写不同槽,但落在同一缓存行:场景 | 延迟 |
无伪共享 | 18 ns |
伪共享同一行 | 420 ns |
实验 3:缓存算法对 GC 的影响
- SoftLRUCache: 命中率 92%,Old Gen 占用上升 100 MB / min,Major GC 间隔 8 min。
- SoftFIFOCache: 命中率 55%,Old Gen 上升 400 MB / min,Major GC 间隔 45 s,Full GC 时 CPU stall 150 ms。
学习小结
内存屏障决定了“单次操作的下限”,缓存算法决定了“多久触碰一次下限”。
因此,在 JVM 世界里做性能优化,要把两层因素一起考虑:
- 业务层:用 命中率更高的 LRU/LFU + 弱引用 减少对象晋升;
- 并发层:用 填充(@Contended)+ 分段 降低屏障冲突;
- 系统层:用 ZGC/Shenandoah 把 Full GC 停顿压到 10 ms 以下,让屏障风暴来不及形成。
把这三层串成一条“性能护城河”,你的服务才能既快又稳。
六、JVM中的内存屏障和缓存算法如何影响并发性能?
屏障风暴(Memory-Barrier Storm)最直观的体感就是:CPU 利用率不低,但 TPS/Latency 突然雪崩。
在 JVM + 应用层面,我们把它拆解成 “减少数量、降低强度、错开热点” 三板斧,下面给出可直接落地的 8 组招式。
🔧 招式 1:把 volatile
当药而不是糖
- 只在 状态标志 / 单例双重检查 / 懒发布 场景用
volatile
;
- 能用
final
、局部变量或线程封闭就别volatile
;
- 对计数器、累加器,用
LongAdder/Striped64
代替AtomicLong
,分段 CAS 把屏障摊薄 。
🔧 招式 2:锁粒度 = 屏障密度
- 大锁 → 进入/退出各一次 StoreLoad;
- 拆锁:
- 读写分离
ReentrantReadWriteLock
; - 分段锁
ConcurrentHashMap
; - 无锁化
VarHandle.compareAndSet
;
- 锁内部再做一次 偏向/轻量级/无锁 三级跳,让屏障只在真正竞争时出现 。
🔧 招式 3:消灭伪共享(False Sharing)
- JDK 15+ 直接支持
@Contended
,128 B 填充让不同核永远打不到同一行缓存,跨核 Invalidate 立即归零 。
- 低版本可用 8 个 long 手动填充或继承
jdk.internal.misc.Unsafe
的allocateMemory
。
🔧 招式 4:批量刷屏障 —— 写合并(Write Combining)
- 把多个
volatile
写攒到一个 release fence 里一次性刷:
- JDK 9+
VarHandle.setRelease()
同理 。
🔧 招式 5:利用 平台屏障强度差异
平台 | 需要屏障 | 可省略屏障 | 建议动作 |
x86/ARMv8.3 | StoreLoad | LoadLoad/LoadStore/StoreStore | 大胆用普通读+显式 fence |
ARMv7/POWER | 四种都要 | 无 | 尽可能用更高层并发工具 |
例子:x86 下把 volatile 读改成普通读 + Unsafe.loadFence(),能减少一次 LoadLoad 。
🔧 招式 6:GC 屏障也得管
- G1/Shenandoah 并发标记阶段会插入 SATB/Card Mark 屏障;
- 减少晋升 ⇒ 减少标记 ⇒ 减少屏障:
- 调大
XX:NewRatio
或XX:SurvivorRatio
; - 避免超大对象直接进 Old;
- 对缓存使用 软引用 + LRU,命中率 90%+ 时 GC 屏障总量下降 5-10 倍 。
🔧 招式 7:让 JIT 帮你剪屏障
- 开启
XX:+UnlockDiagnosticVMOptions -XX:+PrintAssembly
,确认热点路径是否被优化: - 连续
volatile
写是否被合并; - 循环内是否被提升到循环外。
- 常见坑:循环内
volatile
读 → JIT 无法提升,手动提到局部变量。
🔧 招式 8:线程池 + 数据分片
- 减少线程数 = 减少竞争 = 减少屏障触发次数;
- 用 ForkJoinPool.commonPool() 或 虚拟线程 把任务切到单线程本地执行,完全避开共享变量。
- 数据结构做 分片(如 RingBuffer 的序列号按 128 字节对齐),让不同线程落在不同缓存行 。
✅ 一张速查表(建议打印贴墙)
场景 | 替代方案 | 屏障减少量 | 备注 |
高并发计数器 | LongAdder | ≈ 90 % | 分段 CAS |
状态标志位 | volatile boolean → AtomicBoolean.lazySet | 50-70 % | lazySet 无 StoreLoad |
读写锁 | ReentrantReadWriteLock | 40-60 % | 读共享无屏障 |
单例 DCL | volatile 保留,但 JDK 17+ 可用 VarHandle | 20-30 % | 平台屏障省略 |
缓存行竞争 | @Contended / 填充 | 100 % | 消除 Invalidate |
GC 屏障风暴 | 提升缓存命中率 + ZGC | 80 % | 并发标记几乎无 STW |
学习小结
屏障风暴不是洪水猛兽,而是“不必要的可见性保证”堆积成的雪崩。
在 JVM 和实际开发中,减少共享、缩小粒度、批量刷、平台化、让 JIT/GC 帮你干脏活 这五步组合拳,就能把风暴压成微风。
七、实战:构建高可用本地缓存系统
结合前面的知识点,我们来实现一个“软引用+LRU+并发安全”的本地缓存系统,满足高并发场景下的性能和稳定性需求。
7.1 需求分析
- 核心功能:缓存键值对,支持get/put/remove操作;
- 自动释放:内存不足时自动回收缓存(软引用);
- 缓存淘汰:容量满时淘汰最近最少使用的数据(LRU);
- 并发安全:支持多线程同时读写;
- 避免泄漏:及时清理失效的软引用。
7.2 设计方案
- 数据结构:
- 用
ConcurrentHashMap
存储“key→软引用(值)”,保证并发安全; - 用
LinkedHashMap
实现LRU淘汰(accessOrder=true
); - 用
ReferenceQueue
跟踪被回收的软引用,及时清理缓存。
- 核心机制:
- 软引用:包装缓存值,内存不足时自动回收;
- LRU:超过容量时,移除最近最少使用的键值对;
- 引用队列:当软引用的对象被回收,自动从缓存中删除对应的键。
7.3 代码实现
7.4 关键技术解析
- 并发安全:
- 用
ConcurrentHashMap
保证get/put的线程安全; - 用
ReentrantLock
保护LRUMap的操作(LinkedHashMap
非线程安全)。
- 内存管理:
- 软引用确保内存不足时自动释放缓存值;
- 引用队列及时清理失效的键,避免内存泄漏。
- 缓存淘汰:
- LRUMap跟踪访问顺序,超过容量时自动淘汰最久未使用的键;
- get操作会更新LRU顺序,保证常用数据不被淘汰。
7.5 性能测试
用JMH测试缓存的get/put性能(对比HashMap和Guava Cache):
测试结果(吞吐量,数值越高越好):
- 自定义缓存:≈800 ops/ms
- HashMap:≈1200 ops/ms(无淘汰和软引用,性能更高)
- Guava Cache:≈750 ops/ms(功能更全,性能略低)
自定义缓存在保证自动释放和LRU淘汰的前提下,性能接近成熟库,适合对内存敏感的场景。
7.6 实战建议
- 容量设置:根据可用内存设置,建议不超过堆内存的30%(避免GC压力)。
- 过期策略:可扩展添加时间过期(如定时清理过期键)。
- 监控告警:添加缓存命中率统计,当命中率<70%时告警(可能需要调整缓存策略)。
- 替代方案:生产环境优先使用成熟库(如Caffeine,性能优于自定义实现),本文实现仅供学习。
八、学习总结与成果
8.1 核心知识点体系
通过本文的学习,我们构建了从底层原理到上层应用的完整知识链:
- 内存屏障:理解了CPU和JVM如何通过屏障保证多线程可见性,解释了
volatile
和synchronized
的底层原理。
- 引用类型:掌握了强/软/弱/虚引用的回收时机和使用场景,能避免OOM和内存泄漏。
- 缓存算法:对比了FIFO/LFU/LRU的优缺点,理解了工业级缓存(如Redis/Caffeine)的实现思路。
- 垃圾回收:熟悉了分代收集、常见GC收集器和调优方法,能解决实际工作中的内存问题。
8.2 实战能力提升
- 能独立设计并实现本地缓存系统,结合软引用和LRU算法,平衡性能和内存安全。
- 能解读GC日志,使用JVM参数调优GC性能,解决Minor/Full GC频繁、停顿时间长等问题。
- 能识别并发编程中的可见性问题,合理使用
volatile
和锁机制,避免诡异的多线程bug。
8.3 进一步学习方向
- 底层深度:深入学习CPU架构(如MESI协议)、JVM源码(如HotSpot的GC实现)。
- 分布式缓存:研究Redis的缓存策略(如过期淘汰、持久化),对比本地缓存和分布式缓存的适用场景。
- 性能调优工具:掌握Arthas(在线诊断)、JProfiler(性能分析)、MAT(内存分析)等工具的使用。
8.4 结语
内存管理和缓存优化是Java开发的“内功”,它们不像业务逻辑那样直观,却直接决定了系统的性能上限和稳定性。从理解内存屏障的微观作用,到设计高可用的缓存系统,每一步都需要理论与实践的结合。
记住:没有银弹级的解决方案,只有适合场景的选择。在实际开发中,始终以“测量”为前提(如通过GC日志、性能测试数据),再结合本文的知识进行优化,才能写出既高效又稳定的代码。
希望这份笔记能成为你深入Java底层的阶梯,让每一行代码都经得起高并发的考验。
- 作者:Honesty
- 链接:https://blog.hehouhui.cn/archives/java-reference-types-jvm-garbage-collection-processing-flow
- 声明:本文采用 CC BY-NC-SA 4.0 许可协议,转载请注明出处。
相关文章