jvm之生老病死

今天我们来聊聊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可能会把它标量替换
    • 什么是标量? 不可再拆分的数据,如 intlongreference
    • 什么是聚合量? 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秒)。
  • 诊断分析
    1. 检查老年代回收效率 :观察Full GC前后老年代内存的变化。如果回收前是 1.8G,回收后是 1.7G,说明回收效率极低。这通常是内存泄漏的强烈信号。
    2. 检查元空间(Metaspace) :如果日志显示 Metaspace 持续增长且触发Full GC,可能是动态生成类(如CGLIB)过多导致的。
    3. 检查GC原因 :如果是 Concurrent Mode Failure (CMS收集器),说明老年代预留空间不足,CMS来不及回收。

症状二:频繁的 Young GC

  • 日志特征GC (Allocation Failure) 事件非常密集,几乎不间断地发生。
  • 诊断分析
    1. 新生代过小 :新生代(尤其是Eden区)空间不足,导致新对象频繁触发Minor GC。
    2. 对象分配速率过高 :代码中可能存在短时间内创建大量临时对象的操作。

症状三:对象过早晋升

  • 日志特征:每次Young GC后,老年代内存都稳定增长,而新生代回收掉的内存很少。
  • 诊断分析
    1. Survivor区过小 :Survivor空间不足以容纳本次GC后的存活对象,导致大量对象直接"晋升"到老年代。
    2. 存在大对象 :代码中直接分配了无法放入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): 专业的堆内存分析工具,能自动生成内存泄漏疑点报告。

调优和总结

  1. 看日志-Xloggc:file.log -XX:+PrintGCDetails -XX:+PrintGCTimeStamps。不看日志就调优,就是耍流氓。
  2. 定目标
    • 低延迟(如金融交易):选G1或ZGC。
    • 高吞吐(如后台报表):选Parallel。
  3. 调参数
    • 新生代大小-Xmn。新生代太小,对象过早进入老年代,导致老年代频繁GC;新生代太大,Minor GC时间变长。
    • 晋升年龄-XX:MaxTenuringThreshold
    • 堆内存比例-XX:NewRatio

JVM的垃圾回收,本质上是在吞吐量延迟之间做权衡。

  • 想快:选Parallel,但停顿久。
  • 想稳:选G1,平衡吞吐和延迟。
  • 想极致低延迟:选ZGC,但吃CPU。

最后,送上金句

"调优不是靠猜,是靠数据。GC日志是你的心电图,通过分析停顿时间和回收频率,让每一字节的内存都物尽其用。"

"内存泄漏在Java里不是指内存丢了找不回来,而是指你明明不需要它了,GC Roots 却还死死抓着它不放,让它想死死不了,最后撑爆堆内存。"

相关推荐
阿里小阿希2 小时前
ERP 资源大批量导入实践:PostgreSQL Staging 临时表 + 异步任务
数据库·postgresql
王二车8 小时前
交叉编译microcom ARM终端串口调试工具
数据库
xxxibolva10 小时前
SQL 学习
数据库·sql·学习
孪生质数-10 小时前
MySQL主从延迟根因诊断法
数据库·mysql
bLEd RING10 小时前
Redis 设置密码无效问题解决
数据库·redis·缓存
WiChP11 小时前
【V0.1B5】从零开始的2D游戏引擎开发之路
java·服务器·数据库
751158912 小时前
笔记:postgresql如何下载驱动并安装?
数据库·postgresql
荒川之神12 小时前
拉链表概念与基本设计
java·开发语言·数据库
Highcharts.js12 小时前
适合报表系统的可视化图表|Highcharts支持直接导出PNG和PDF
javascript·数据库·react.js·pdf