type
status
date
slug
summary
tags
category
icon
password
catalog
sort

前言:为什么“看不见的细节”决定程序性能?

在Java开发中,我们常常遇到这样的困惑:明明代码逻辑没问题,却在高并发下出现诡异的结果;系统运行一段时间后突然卡顿,日志里满是GC警告;缓存明明加了,性能却没提升多少……这些问题的根源,往往藏在内存屏障、引用类型、缓存算法和垃圾回收这些“看不见的细节”里。
  1. CPU 有多快,内存就有多慢;
  1. Java 没指针,但有四种引用;
  1. 真正拖垮系统的,往往不是你写的 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和编译器的“优化”导致的——它们会在不影响单线程语义的前提下,对指令重排或延迟写入内存,从而破坏多线程的可见性。
于是硬件老爷爷造了个「内存屏障」:
一句话——让某些指令必须“排队”,并且把缓存刷干净。

导致问题的三大“元凶”

  1. 指令重排:编译器或CPU为了提高效率,会调整指令执行顺序。例如t1中,①和②没有数据依赖,可能被重排为“先执行②,再执行①”;同理t2中③和④也可能重排。
  1. CPU缓存与写缓冲:CPU不会直接操作主存,而是通过L1/L2/L3缓存读写数据。写操作会先存入“写缓冲”(Store Buffer),延迟刷入主存。若t1的①存入缓冲未刷出,t2执行④时读到的a仍是0。
  1. 缓存一致性协议的延迟:不同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的dmbdsb指令)。
这也是为什么同样的代码在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),是四种屏障中最慢的。
  • 频繁使用volatilesynchronized,会因屏障累积导致性能下降。
优化建议
  1. 能用局部变量或线程封闭(Thread Confinement)解决的场景,就不要用共享变量。
  1. 对高频写操作,用LongAdder替代AtomicLong(分段CAS,减少屏障竞争)。
  1. 利用x86架构的特性:在x86上,volatile读的成本较低(无显式屏障),可适当多用;写操作成本高,需谨慎。

1.5 学习小结:内存屏障的核心价值

内存屏障是多线程可见性的“底层保障”,它通过约束指令重排和缓存同步,让Java代码在不同硬件架构上表现一致。理解屏障的作用,能帮我们:
  • 解释volatilesynchronized的底层原理;
  • 避免写出“看似正确,实则有并发隐患”的代码;
  • 在性能优化时,平衡可见性和执行效率。

二、Java引用类型:对象“生死权”的掌控者

Java的引用类型决定了对象何时被垃圾回收,是内存管理的核心工具。从“绝不回收”到“随时回收”,四种引用类型构成了对象生命周期的“梯度管理”。

2.1 强引用:“宁死不屈”的默认引用

定义:普通的对象引用(如Object obj = new Object()),是Java中最常见的引用类型。
回收时机:只要强引用存在,GC绝对不会回收被引用的对象,即使内存溢出(OOM)也不例外。
使用场景:99%的业务代码中,对象都通过强引用持有,确保核心数据不被意外回收。
踩坑案例:静态集合导致的内存泄漏
解决方案:不再需要对象时,手动移除引用(CACHE.remove(obj))或清空集合(CACHE.clear()),切断强引用。
GC 处理流程:
  1. 引用链判断:只要对象被强引用关联(且引用链可达,即从 GC Roots 出发能找到该对象),GC 就绝不会回收该对象,无论内存是否充足。
  1. 回收触发条件:只有当强引用被显式断开(如 obj = null),或引用所在的上下文被销毁(如局部变量出栈),导致对象不再被任何强引用关联时,该对象才会被标记为 “可回收”。
  1. 极端情况:如果大量对象被强引用持有且无法释放,可能导致内存耗尽,触发 OutOfMemoryError(OOM)。

2.2 软引用(SoftReference):“内存不足才回收”的缓存神器

定义:通过SoftReference类实现,用于描述“有用但非必需”的对象。
回收时机:当内存不足时(JVM即将抛出OOM前),GC会回收只被软引用持有的对象。
使用场景:本地缓存(如图片缓存、网页数据缓存),既能保留常用数据,又能在内存紧张时自动释放空间。
正确用法:软引用+引用队列
为什么需要引用队列?
软引用的get()方法返回null时,说明对象已被回收,但软引用本身仍在缓存中(占用内存)。通过引用队列,可及时移除这些“空壳子”,避免内存泄漏。
GC 处理流程:
  1. 内存充足时:即使对象仅被软引用关联,GC 也不会回收该对象,确保缓存数据可用。
  1. 内存不足时:当 JVM 判断内存即将耗尽(触发内存紧张检测),GC 会主动回收所有仅被软引用关联的对象,以释放内存。
  1. 引用队列配合:软引用可关联一个 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 处理流程:
  1. 回收时机:无论内存是否充足,只要发生 GC(Minor GC 或 Full GC),所有仅被弱引用关联的对象都会被立即回收
  1. 引用队列配合:与软引用类似,弱引用可关联 ReferenceQueue,当对象被回收后,弱引用实例会进入队列,方便程序清理无效引用。
典型应用WeakHashMap 的键(Key)使用弱引用,当键对应的对象被回收后,键值对会自动从 Map 中移除,避免缓存数据长期占用内存。

2.4 虚引用(PhantomReference):“只为回收通知”的幽灵引用

定义:通过PhantomReference类实现,是最弱的引用类型。
特点
  • 虚引用的get()方法永远返回null,无法通过引用获取对象;
  • 必须与ReferenceQueue结合使用,当对象被回收时,虚引用会进入队列,作为“回收通知”。
使用场景:管理堆外内存(如NIO的DirectByteBuffer),在对象回收时释放堆外资源。
原理示例
DirectByteBuffer中的应用
DirectByteBuffer通过虚引用(Cleaner,继承自PhantomReference)管理堆外内存。当DirectByteBuffer被回收时,Cleanerclean()方法会调用Unsafe.freeMemory()释放堆外内存,避免内存泄漏。
GC 处理流程:
  1. 无直接引用能力:虚引用无法通过 get() 方法获取对象(调用返回 null),因此不能通过虚引用访问对象。
  1. 回收通知作用:当对象被 GC 标记为 “可回收” 并即将被回收时,虚引用实例会被放入关联的 ReferenceQueue通知程序对象已进入回收阶段
  1. 资源清理时机:程序通过监听队列得知对象即将被回收后,可在此时释放与对象关联的非堆内存资源(如直接内存 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)
  1. put(A) → 缓存:[A](队尾为A)
  1. put(B) → 缓存:[A, B](队尾为B)
  1. put(C) → 缓存:[A, B, C](队尾为C)
  1. put(D) → 缓存满,淘汰最早的A → [B, C, D](队尾为D)
  1. get(B) → 缓存不变(FIFO不关心访问频率)→ [B, C, D]
  1. 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)
  1. put(A) → 缓存:{A:1}(次数1)
  1. put(B) → 缓存:{A:1, B:1}
  1. put(C) → 缓存:{A:1, B:1, C:1}
  1. get(A) → A次数+1 → {A:2, B:1, C:1}
  1. get(A) → A次数+1 → {A:3, B:1, C:1}
  1. get(B) → B次数+1 → {A:3, B:2, C:1}
  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)
  1. put(A) → 缓存:[A](最近使用:A)
  1. put(B) → 缓存:[B, A](最近使用:B)
  1. put(C) → 缓存:[C, B, A](最近使用:C)
  1. get(A) → A被访问,移到队头 → [A, C, B](最近使用:A)
  1. put(D) → 缓存满,淘汰最久未用的B → [D, A, C](最近使用:D)
  1. get(B) → B不在缓存(需重新加载)→ 加载后加入队头 → [B, D, A]

实现方式(双向链表+HashMap)

优缺点

  • 优点
    • 实现较简单,时间复杂度O(1);
    • 命中率高,符合实际应用中“最近使用的数据更可能被再次使用”的规律。
  • 缺点:对“周期性访问”的数据不友好(如每小时访问一次的报表数据,可能被频繁淘汰)。
适用场景:大多数缓存场景(如浏览器缓存、Redis缓存、本地缓存),是工业界的首选。

3.5 工业级缓存算法:LRU的优化与变种

实际应用中,纯LRU可能仍有不足,衍生出多种优化算法:
  1. Redis的近似LRU
    1. 不维护完整链表,而是在每个key中记录“最后访问时间戳”,淘汰时随机采样N个key(默认5个),淘汰时间戳最小的。优点是节省内存,性能接近纯LRU。
  1. Caffeine的W-TinyLFU
    1. 结合LFU和LRU的优点:用LFU过滤低频数据,用LRU管理高频数据,解决LFU对突发热点的不友好问题,是Java中性能最好的本地缓存库。
  1. LRU-K
    1. 记录数据的最近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)

步骤
  1. 标记:从GC Roots出发,标记所有存活对象;
  1. 清除:遍历堆内存,回收未标记的垃圾对象。
优缺点
  • 优点:简单,无需移动对象;
  • 缺点:效率低(需全堆遍历),产生内存碎片(回收后内存不连续,大对象可能无法分配)。

2. 复制算法(Copying)

步骤
  1. 将内存分为两块(From区和To区),只使用From区;
  1. 回收时,将From区的存活对象复制到To区,然后清空From区;
  1. 交换From区和To区的角色,重复使用。
优化:HotSpot的新生代采用“Eden + 2个Survivor”(比例8:1:1),每次使用Eden和1个Survivor,回收时复制存活对象到另一个Survivor,空间利用率达90%。
优缺点
  • 优点:无内存碎片,效率高(只复制存活对象);
  • 缺点:浪费部分内存(需预留To区),不适合存活对象多的场景(如老年代)。

3. 标记-整理算法(Mark-Compact)

步骤
  1. 标记:同标记-清除,标记存活对象;
  1. 整理:将存活对象向内存一端移动,然后清理边界外的垃圾。
优缺点
  • 优点:无内存碎片,空间利用率高;
  • 缺点:整理阶段需移动对象,成本高(尤其大对象)。

4. 分代收集算法(Generational Collection)

核心思想:根据对象存活时间(分代假说),对不同区域采用不同算法:
  • 新生代(对象存活时间短,存活率低):用复制算法;
  • 老年代(对象存活时间长,存活率高):用标记-清除或标记-整理算法。
这是所有现代JVM的默认选择,兼顾效率和空间利用率。

4.3 HotSpot的内存布局与GC流程

堆内存分代

HotSpot将堆分为新生代和老年代:
  • 新生代:Eden(80%) + Survivor0(10%) + Survivor1(10%);
  • 老年代:存储存活时间长的对象(默认新生代:老年代=1:2,可通过XX:NewRatio调整);
  • 元空间(Metaspace):JDK8后替代永久代,存储类元数据(如类信息、方法信息),使用本地内存。

对象的生命周期

  1. 创建:对象优先在Eden区分配;
  1. Minor GC:Eden区满时触发,回收新生代垃圾,存活对象移到Survivor区,年龄+1;
  1. 晋升老年代
      • 年龄达到阈值(默认15,XX:MaxTenuringThreshold调整);
      • Survivor区中对象总大小超过一半,年龄≥该大小对应年龄的对象直接晋升;
      • 大对象(超过Eden区一半)直接进入老年代(XX:PretenureSizeThreshold控制);
  1. 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。核心流程:
  1. 初始标记:STW,标记GC Roots直接关联的对象;
  1. 并发标记:并发遍历对象引用链,标记存活对象;
  1. 最终标记:STW,处理并发标记的遗留引用;
  1. 筛选回收: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频繁或耗时太长会导致程序卡顿,分享几个实战优化技巧:
  1. 少用静态集合存大量数据:静态集合的对象是强引用,不会被回收,容易占满老年代。
  1. 字符串拼接用StringBuffer/StringBuilder:String是不可变的,拼接会产生大量临时对象(垃圾),而StringBuffer是可变的,减少垃圾。
  1. 及时释放无用引用:不用的对象手动设为null(尤其是大对象)。
  1. 用基本类型代替包装类intInteger省内存,longLong省内存。
  1. 避免频繁创建大对象:比如循环里new大数组,最好提前创建好复用。
  1. 合理设置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 必须:
      1. 把 store buffer 刷干净(Drain Buffer)
      1. Invalidate 其他核的对应缓存行
      1. 保证全局顺序一致
      这一套组合拳在 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.UnsafeallocateMemory

🔧 招式 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:NewRatioXX: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 booleanAtomicBoolean.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 设计方案

  1. 数据结构
      • ConcurrentHashMap存储“key→软引用(值)”,保证并发安全;
      • LinkedHashMap实现LRU淘汰(accessOrder=true);
      • ReferenceQueue跟踪被回收的软引用,及时清理缓存。
  1. 核心机制
      • 软引用:包装缓存值,内存不足时自动回收;
      • LRU:超过容量时,移除最近最少使用的键值对;
      • 引用队列:当软引用的对象被回收,自动从缓存中删除对应的键。

7.3 代码实现

7.4 关键技术解析

  1. 并发安全
      • ConcurrentHashMap保证get/put的线程安全;
      • ReentrantLock保护LRUMap的操作(LinkedHashMap非线程安全)。
  1. 内存管理
      • 软引用确保内存不足时自动释放缓存值;
      • 引用队列及时清理失效的键,避免内存泄漏。
  1. 缓存淘汰
      • 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 实战建议

  1. 容量设置:根据可用内存设置,建议不超过堆内存的30%(避免GC压力)。
  1. 过期策略:可扩展添加时间过期(如定时清理过期键)。
  1. 监控告警:添加缓存命中率统计,当命中率<70%时告警(可能需要调整缓存策略)。
  1. 替代方案:生产环境优先使用成熟库(如Caffeine,性能优于自定义实现),本文实现仅供学习。

八、学习总结与成果

8.1 核心知识点体系

通过本文的学习,我们构建了从底层原理到上层应用的完整知识链:
  • 内存屏障:理解了CPU和JVM如何通过屏障保证多线程可见性,解释了volatilesynchronized的底层原理。
  • 引用类型:掌握了强/软/弱/虚引用的回收时机和使用场景,能避免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底层的阶梯,让每一行代码都经得起高并发的考验。
Keycloak 客户端授权服务 JetBrains Annotations:从入门到落地,彻底告别 NullPointerException
Loading...
目录
0%
Honesty
Honesty
花には咲く日があり、人には少年はいない
统计
文章数:
84
目录
0%