今天我们来聊聊JVM里最"败家"的部门------垃圾收集器 ,以及对象们"从生到死"的悲惨一生。在Java的世界里,程序员只管new,不管free。这种"只管生不管养"的潇洒,全靠JVM在背后默默收拾烂摊子。今天,我们硬核打开jvm,深入底层,看看JVM是如何在内存的废墟上建立秩序的。
第一站:对象分配------投胎是一门技术活
当你执行 new Object() 时,你以为它只是简单地申请一块内存?太天真了!JVM为了让你跑得快,在"投胎"环节做了无数优化。下面先总后分
逃逸分析:是住豪宅(堆)还是住胶囊公寓(栈)?
默认情况下,对象都出生在堆内存(Heap),这是昂贵的"豪宅区",住着要交物业费(GC开销)。
但是,JVM有个聪明的管家叫逃逸分析。它会盯着你的对象看:(之前say过)
- 如果不逃逸 :如果你创建的对象只在当前方法里用用,方法结束就死,JVM就会说:"别去堆里挤了,直接在栈 上分配吧!"或者直接把你拆散了(标量替换),变成几个基本类型变量,连对象头都省了。
- 如果逃逸:那你只能乖乖去堆里排队。
架构师洞察 :
这就是为什么我们在写代码时,尽量缩小变量的作用域。作用域越小,越容易被JVM优化成栈上分配,减少GC压力。
1.指针碰撞 vs 空闲列表:内存分配的"排队哲学"
Java堆内存是否规整,决定了分配策略。这取决于你用的垃圾收集器(比如Serial、ParNew这种基于复制算法 的,内存是规整的;而CMS这种基于标记-清除的,内存是破碎的)。
- 指针碰撞 :
- 场景:内存规整,用过的放一边,没用过的放一边。
- 原理:堆里有个指针,指向"已用"和"未用"的分界线。分配内存?简单,把指针往后挪一挪(Bump the Pointer)就完事了。
- 比喻:就像你在食堂打饭,队伍是直的,你只需要排在最后一个人后面就行。
- 空闲列表 :
- 场景:内存不规整,到处是坑。
- 原理:JVM必须维护一个列表,记录哪里有空地。分配时,去列表里找一块够大的,占上,然后更新列表。
- 比喻:就像你在网吧找机子,网管得拿着小本本记着"3号机空了"、"5号机空了",还得看你要开黑(大对象)还是单排(小对象)。
2.并发分配的大坑:TLAB 与 CAS
如果是多线程并发 new 对象,大家都去挪那个"指针",岂不是要打架?
HotSpot 给出了两套解决方案:
- 方案一:CAS + 失败重试
- 利用底层的
Compare-And-Swap指令,保证原子性。如果我发现指针被别人挪了,我就重试,直到我抢到为止。这叫"乐观锁"。
- 利用底层的
- 方案二:TLAB ------ 线程私有缓冲区
- 这是默认开启的优化(
-XX:+UseTLAB)。 - 原理 :JVM在Eden区给每个线程都划分了一小块私有领地。线程分配对象时,先在自己的TLAB里分,不用加锁!只有TLAB用完了,才去Eden区公共区域抢。
- 参数 :
-XX:TLABSize可以设置大小,-XX:PrintTLAB可以查看使用情况。-
TLAB:VIP快速通道
堆内存是共享的,多线程抢内存得加锁,太慢!
于是,JVM在新生代 的Eden区给每个线程划分了一块私有领地 ,叫TLAB。
-
优先分配 :99%的小对象,直接在自己的TLAB里
new,不用抢锁,速度极快。 -
分配失败:只有TLAB装不下了,才去Eden区的公共区域抢。
-
- 这是默认开启的优化(
3. 对象头:对象的"身份证"
对象在堆里不是光秃秃的数据,它头上顶着东西(Object Header)。
以HotSpot为例,对象头包含两部分:
- Mark Word :存储对象自身的运行时数据,如哈希码 、GC分代年龄 、锁标志状态 、线程持有的锁等。它是堆内存中占用空间最小但信息量最大的部分。
- Klass Pointer:类型指针,指向方法区里的类元数据,JVM靠它知道这个对象是哪个类的实例。
第二站:对象的一生------分代与晋升
1. 逃逸分析:栈上分配与标量替换
这是JIT编译器的"神优化"。
JVM会分析新创建的对象,是否只被当前线程的方法访问(即不逃逸)。
- 如果逃逸:乖乖去堆里分配。
- 如果不逃逸 :JVM可能会把它标量替换 。
- 什么是标量? 不可再拆分的数据,如
int、long、reference。 - 什么是聚合量?
Object,因为它可以拆分成字段。 - 优化手段 :如果一个对象不逃逸,JVM直接把它拆散,把字段变成局部变量,分配到栈帧 里。方法结束,栈帧弹出,对象直接"灰飞烟灭",根本不需要GC!
- 什么是标量? 不可再拆分的数据,如
2. 内存区域详解:Eden、Survivor、Tenured
- Eden区:出生地。
- Survivor区(From/To) :幸存者营地。
- 为什么要两个? 为了实现复制算法的无损切换。
- 动态年龄判定 :对象不一定要熬过15次GC才进老年代。如果Survivor区里,同年龄的所有对象大小总和,超过了Survivor空间的一半,那么年龄大于等于该年龄的对象,就可以直接晋升老年代,不用等15岁。
对象住进堆里后,就进入了残酷的"大逃杀"。JVM根据"弱分代假说"(绝大多数对象都是朝生夕死的),把堆分成了新生代 和老年代。
新生代:朝生夕死的"托儿所"
- Eden区:新对象的出生地。
- Survivor区 :从Eden区经历一次Minor GC后还活着的对象,会被挪到这里。
底层原理 :
Minor GC使用的是复制算法。把Eden区里活着的对象,一股脑复制到Survivor区,然后把Eden区一把火烧光。
- 优点:没有内存碎片,实现简单。
- 缺点:浪费空间(总是有一半是空的)。
老年代:长命百岁的"养老院"
对象在Survivor区里每熬过一次GC,年龄就+1。
当年龄达到阈值(默认15岁),或者Survivor区装不下了,对象就会被晋升 到老年代。
特殊情况:
- 大对象直接进入老年代:比如一个巨大的数组,在Eden区复制来复制去太费劲,JVM直接把它扔到老年代,这叫"早产儿"。
第三章:垃圾回收算法------数学与哲学的博弈
1. 可达性分析:谁是老大?
GC Roots 是找对象的起点。以下对象可以作为GC Roots:
- 虚拟机栈(栈帧中的本地变量表)中引用的对象。
- 方法区中的类静态属性引用的对象。
- 方法区中的常量引用的对象。
- 本地方法栈中JNI(即一般说的Native方法)引用的对象。
2. 三色标记法:CMS与G1的核心
在标记阶段,为了防止对象"漏网",JVM引入了颜色概念:
- 白色:未被GC回收器访问到的对象(最终会被回收)。
- 灰色:已被GC回收器访问到,但其成员变量还没被扫描完。
- 黑色:已被访问,且成员变量也已扫描完。
核心痛点:漏标
如果在并发标记过程中,用户线程修改了引用关系,导致一个黑色对象指向了一个白色对象,而这个白色对象本来应该被标记的,结果就被漏掉了,最后被误杀。
解决方案:写屏障与SATB
- CMS :使用增量更新。只要黑色对象引用了白色对象,就把黑色对象变回灰色,重新扫描。
- G1 :使用SATB 。在引用修改的一瞬间,把旧的引用记录下来,作为GC Roots的一部分,保证白色对象能被扫描到。
第四站:垃圾回收------四种"清洁工"的较量
当内存满了,GC线程就要出来打扫卫生。不同的收集器,干活的方式完全不同。
Serial:单线程的"独狼"
- 原理 :只有一条线程干活,干活时,所有用户线程全部暂停(Stop-The-World)。
- 场景:客户端应用,或者单核CPU。
- ParNew:Serial的多线程版,主要为了配合CMS使用。
Parallel:多线程的"暴力狂"
- 原理:开启多条线程一起扫,效率比Serial高,但STW时间依然很长。
- 目标 :
- 让CPU跑满,干最多的活
- 追求吞吐量(单位时间内干完最多的活),不管停顿多久。适合后台批处理任务。
- 自适应调节策略:JVM会根据历史数据,自动调整Eden和Survivor的比例,以达到目标吞吐量
CMS:追求低延迟的"洁癖"
- 算法:标记-清除
- 原理 :它是第一个追求最短停顿时间 的收集器。低延迟
- 初始标记:停一下,标记GC Roots直接关联的对象。
- 并发标记:不停顿,和用户线程一起跑,遍历整个对象图。
- 重新标记:再停一下,修正并发期间变动的对象。
- 并发清除:不停顿,清理垃圾。
- 缺点 :
- 碎片化:标记-清除不整理内存,导致大对象分配困难,触发Full GC。
- 浮动垃圾:并发清理时产生的垃圾,只能等下次GC
- 对CPU敏感:并发阶段抢占线程资源
G1:现代架构的"全能王"
- 原理 :它把堆内存切成了很多个Region ,不再物理隔离新生代和老年代。每个Region可以是Eden、Survivor、Old、Humongous(专门存大对象)。
- 可预测停顿:你可以告诉它:"我只要你每次停顿不超过200ms"。G1会根据这个目标,选择回收价值最大的Region先扫(Garbage First)(价值 = 回收空间大小 / 回收耗时)。
- 整理算法 :它基于标记-整理算法,不会产生内存碎片。
- Remembered Set:为了避免扫描整个老年代来判断跨Region引用,G1给每个Region维护了一个RSet,记录了谁引用了我。
- 地位:JDK 9+ 的默认收集器,大内存、多核环境的首选。
ZGC/Shenandoah:毫秒级的"未来战士"
- 核心黑科技 :染色指针 。
- 把标记位直接存在对象的指针上(64位指针里,高几位用来标记颜色)。
- 这样,读取对象时,CPU可以直接通过指针判断对象状态,不需要额外的内存屏障。
- 停顿时间:不超过10ms,且堆内存可以支持到TB级别。
- 原理 :利用读屏障 和染色指针 技术,几乎做到了完全并发,停顿时间控制在10ms以内,哪怕堆内存有几个TB。
通过日志分析定位GC调优方向
要通过日志分析定位GC调优方向,你需要将GC日志视为JVM的"心电图",通过解读其波形(停顿时间、频率、内存变化)来诊断系统的健康状况。
整个过程可以分为四个步骤:开启日志 -> 解读指标 -> 诊断问题 -> 确定调优方向。
第一步:开启GC日志这扇"窗"
在生产环境中,你需要在JVM启动参数中添加以下配置,以便捕获详细的GC信息。
-
基础参数 (JDK 8)
-XX:+PrintGCDetails #打印详细的GC信息,包括回收前后各代内存的变化
-XX:+PrintGCDateStamps #为每次GC事件打上时间戳,便于追踪
-Xloggc:/path/to/gc.log #指定GC日志的输出文件
-XX:+UseGCLogFileRotation #启用日志滚动,防止单个日志文件过大
-XX:NumberOfGCLogFiles=5
-XX:GCLogFileSize=20M
统一日志框架 (JDK 9+)
-Xlog:gc*:file=gc.log:time,level,tags:filecount=5,filesize=20M
第二步:解读日志中的核心指标
| 指标类别 | 关键信息 | 说明 |
|---|---|---|
| GC类型 | GC (Young GC) / Full GC |
Young GC只回收新生代,速度快;Full GC回收整个堆,通常伴随长时间停顿。 |
| 停顿时间 | real=0.015 secs |
应用线程实际暂停的时间。这是衡量系统延迟的核心指标。 |
| GC原因 | Allocation Failure / Ergonomics |
触发GC的直接原因,如新生代空间不足(Allocation Failure)或系统主动触发(Ergonomics)。 |
| 内存变化 | [PSYoungGen: 65536K->8192K(76288K)] |
回收前后,Eden、Survivor、老年代(Old Gen)的内存使用情况。 |
第三步:根据症状诊断问题
通过分析上述指标的组合,你可以定位到具体的性能瓶颈。
症状一:频繁且耗时的 Full GC
- 日志特征 :
Full GC出现的频率非常高(例如每分钟数次),且real停顿时间很长(超过1秒)。 - 诊断分析 :
- 检查老年代回收效率 :观察Full GC前后老年代内存的变化。如果回收前是
1.8G,回收后是1.7G,说明回收效率极低。这通常是内存泄漏的强烈信号。 - 检查元空间(Metaspace) :如果日志显示
Metaspace持续增长且触发Full GC,可能是动态生成类(如CGLIB)过多导致的。 - 检查GC原因 :如果是
Concurrent Mode Failure(CMS收集器),说明老年代预留空间不足,CMS来不及回收。
- 检查老年代回收效率 :观察Full GC前后老年代内存的变化。如果回收前是
症状二:频繁的 Young GC
- 日志特征 :
GC (Allocation Failure)事件非常密集,几乎不间断地发生。 - 诊断分析 :
- 新生代过小 :新生代(尤其是Eden区)空间不足,导致新对象频繁触发Minor GC。
- 对象分配速率过高 :代码中可能存在短时间内创建大量临时对象的操作。
症状三:对象过早晋升
- 日志特征:每次Young GC后,老年代内存都稳定增长,而新生代回收掉的内存很少。
- 诊断分析 :
- Survivor区过小 :Survivor空间不足以容纳本次GC后的存活对象,导致大量对象直接"晋升"到老年代。
- 存在大对象 :代码中直接分配了无法放入Eden区的大对象,它们会直接进入老年代。
第四步:确定调优方向
| 诊断问题 | 调优方向 | 具体措施 |
|---|---|---|
| 内存泄漏 | 定位泄漏源 | 1. 添加 -XX:+HeapDumpOnOutOfMemoryError 参数,在OOM时自动生成堆转储文件。 2. 使用 jmap -dump:live,format=b,file=heap.hprof <pid> 手动生成堆快照。 3. 使用 Eclipse MAT 或 VisualVM 分析堆快照,定位占用内存最大的对象和引用链。 |
| 老年代空间不足 | 增大堆内存或优化收集器 | 1. 增大最大堆内存 -Xmx 和初始堆内存 -Xms(建议设为相同值,避免动态扩容开销)。 2. 切换到 G1收集器 (-XX:+UseG1GC),它能更好地控制停顿时间并整理内存碎片。 |
| 新生代过小 | 调整新生代比例 | 1. 增大新生代大小 -Xmn。 2. 调整新生代与老年代的比例 -XX:NewRatio (例如设置为2,表示老年代:新生代=2:1)。 |
| Survivor区过小 | 调整Survivor比例 | 调整Eden与Survivor的比例 -XX:SurvivorRatio (例如设置为8,表示Eden:Survivor=8:1)。 |
| 对象过早晋升 | 优化对象生命周期 | 1. 检查代码,避免创建短命的大对象。 2. 适当调大Survivor区,让对象在新生代多"活"几轮。 |
辅助工具
- 命令行工具 :
jstat -gcutil <pid> 1000: 实时监控GC统计信息,观察各代内存使用率的变化趋势。jmap -heap <pid>: 查看JVM堆的详细配置。
- 可视化分析工具 :
- GCViewer / gceasy.io: 将GC日志文件上传,自动生成可视化图表,直观展示停顿时间、吞吐量等趋势。
- Eclipse MAT (Memory Analyzer): 专业的堆内存分析工具,能自动生成内存泄漏疑点报告。
调优和总结
- 看日志 :
-Xloggc:file.log -XX:+PrintGCDetails -XX:+PrintGCTimeStamps。不看日志就调优,就是耍流氓。 - 定目标 :
- 低延迟(如金融交易):选G1或ZGC。
- 高吞吐(如后台报表):选Parallel。
- 调参数 :
- 新生代大小 :
-Xmn。新生代太小,对象过早进入老年代,导致老年代频繁GC;新生代太大,Minor GC时间变长。 - 晋升年龄 :
-XX:MaxTenuringThreshold。 - 堆内存比例 :
-XX:NewRatio。
- 新生代大小 :
JVM的垃圾回收,本质上是在吞吐量 和延迟之间做权衡。
- 想快:选Parallel,但停顿久。
- 想稳:选G1,平衡吞吐和延迟。
- 想极致低延迟:选ZGC,但吃CPU。
最后,送上金句 :
"调优不是靠猜,是靠数据。GC日志是你的心电图,通过分析停顿时间和回收频率,让每一字节的内存都物尽其用。"
"内存泄漏在Java里不是指内存丢了找不回来,而是指你明明不需要它了,GC Roots 却还死死抓着它不放,让它想死死不了,最后撑爆堆内存。"