JVM八股分析

JVM

1.那有哪些对象是可以直接在栈上分配呢?

在Java中,并不是特定类型的对象能够直接在栈上分配,而是取决于该对象的作用域。JVM通过一种叫做“逃逸分析”(Escape Analysis)的技术来判断一个对象是否可以安全地在栈上分配。

如果一个对象的引用没有“逃逸”出它被创建的方法之外,那么它就可能被优化为在栈上分配。这样做的好处是,当方法执行结束时,栈帧被弹出,对象的内存会立即被回收,无需等待垃圾回收(GC),从而提高性能。

  • 逃逸分析是JIT(即时编译器)的一项优化技术,默认在现代JVM中是开启的。只有那些生命周期完全局限于单个方法调用内、体积较小线程安全的对象,才最有可能被优化到栈上进行分配。

未逃逸的定义:

  1. 仅在方法内部使用:对象的引用完全封装在方法体内,没有被方法返回。
  2. 未赋值给外部变量:没有将该对象的引用赋值给任何类变量(static字段)或实例变量。
  3. 未传递给可能逃逸的方法:没有将该对象的引用作为参数传递给其他方法,或者传递给了但能确定其他方法也不会让它“逃逸”。

逃逸的例子:

比如对象作为方法的返回值,他就是逃离了这个方法的作用域

对象引用赋值给实例变量,也是逃离这个方法的作用域

2.JMM和一个对象的生命周期

JMM划分:

​ 线程共享:方法区 堆

​ 线程私有:程序计数器,虚拟机栈,本地方法栈

生命周期:

​ 创建: 类加载检查 -> 堆内存分配(指针碰撞/空闲列表)-> 初始化零值 -> 设置对象头 -> 执行 init 方法。

​ 进行使用

​ 回收:可达性分析 -> 垃圾回收算法 -> 分代回收(Minor GC, Full GC)

优化手段:

逃逸分析、栈上分配、TLAB(线程本地分配缓冲)等优化手段

逃逸分析、栈上分配和TLAB是JVM为了自动化地提升对象分配效率、降低GC压力而设计的一套协同工作的优化组合拳

逃逸分析是决策入口,它决定了一个不逃逸的对象是否有资格享受栈上分配这一‘特权’,从而完全避免GC。对于必须在堆上分配的逃逸对象,TLAB则为它们提供了线程私有的‘VIP通道’,避免了并发分配时的锁竞争。

  1. 当我们在代码中写下 new User() 时,这个User对象在JVM中并不是“无脑地”直接被分配到堆上。它会经历一个由JVM JIT(即时编译器)主导的、充满优化的“审批流程”
  2. 逃逸分析:逃逸分析是一种编译期优化技术,它不是直接的优化手段,而是一种分析手段。JIT编译器会分析一个对象的动态作用域,判断这个对象是否有可能“逃逸”出它的创建方法或当前线程。
  3. 如果逃逸分析的结果是:“这个对象完全不逃逸!”,那么JVM就会启用一个颠覆性的优化。栈上分配是指将那些不逃逸的小对象,直接在当前线程的虚拟机栈(Stack)上进行分配,而不是在堆(Heap)上。
  4. 如果逃逸分析的结果是:“这个对象逃逸了,必须在堆上分配”,那么JVM并不会立刻去抢占全局的堆内存,而是会尝试一个更高效的策略。
  5. TLAB(线程本地分配缓冲)\是JVM为了*提升对象在堆上分配的效率而设计的一种机制。JVM会在堆的新生代(Eden区)*为*每个线程预先分配一小块私有的内存区域,这个区域就叫TLAB。避免并发冲突:堆是所有线程共享的。如果没有TLAB,那么每次new一个对象,多个线程都需要去竞争同一块Eden区的内存*。这个过程需要加锁(比如CAS)来保证分配的原子性,在高并发下会成为性能瓶颈。
  6. 当一个线程需要分配一个新对象时,它会首先尝试在自己的TLAB中进行分配
  7. 因为TLAB是线程私有的,所以在这个区域内分配对象完全不需要加锁,速度极快,这是一个简单的指针碰撞(Bump the Pointer)操作。
  8. 只有当TLAB的空间用完了,或者要分配的对象太大TLAB放不下时,线程才会去申请一个新的TLAB,或者在全局的Eden区(此时需要加锁)进行分配。
  9. 然后在堆中是如何分配的呢?

内存分配方式:

  1. 指针碰撞 (Bump-the-Pointer)
    • 适用场景:当Java堆内存是绝对规整的时候使用。这种情况通常由带有压缩功能的垃圾收集器产生,如 SerialParallel ScavengeG1 等。
    • 底层原理:所有已用内存都放在一边,所有未用内存放在另一边,中间有一个指针作为分界点指示器。当需要分配内存时,仅仅是把该指针向空闲空间那边挪动一段与对象大小相等的距离。这个过程非常高效,分配内存的动作等同于一次指针移动。
  2. 空闲列表 (Free List)
    • 适用场景:当Java堆内存不是规整的,已用内存和空闲内存相互交错时使用。这种情况通常由不带压缩功能的垃圾收集器产生,如 CMS (Concurrent Mark Sweep)。
    • 底层原理:虚拟机内部会维护一个列表,记录着哪些内存块是可用的。当需要分配内存时,会从这个列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。寻找合适空间的过程会比指针碰撞慢。

并发处理:在多线程并发创建对象时,如何保证堆上分配的线程安全?

  1. 线程本地分配缓冲 (TLAB - Thread Local Allocation Buffer)
    • 核心思想空间换时间,避免竞争。这是首选和主要的解决方案。
    • 底层原理:在JVM的Eden区,会为每一个新创建的线程预先分配一小块私有内存,这块内存就是TLAB。当一个线程需要为新对象分配内存时,它会首先在自己的TLAB中进行分配。因为这是线程私有的,所以这个分配过程完全不需要任何同步或加锁,可以直接使用“指针碰撞”的方式快速完成。这极大地提升了分配效率。
    • 只有当线程的TLAB用完,需要申请新的TLAB时,或者要分配一个大于TLAB剩余空间的大对象时,才会触发下面的同步机制。
  2. CAS + 失败重试 (Compare-And-Swap)
    • 核心思想乐观锁,失败则重试。这是备用和辅助的解决方案。
    • 底层原理:当一个线程的TLAB用完需要申请新TLAB,或者虚拟机禁用了TLAB(可以通过 -XX:-UseTLAB 关闭),线程就必须在共享的Eden区进行内存分配。为了保证线程安全,虚拟机会采用CAS原子操作来尝试更新内存分配指针。
    • 具体过程是:线程先读取指针的当前位置,计算出分配后的新位置,然后通过CAS指令尝试将指针的原位置更新为新位置。如果更新成功,说明分配成功;如果更新失败,说明在操作期间有其他线程修改了指针,当前线程就会自旋(Spinning)重试,直到成功为止。

除了这些JMM内还有一个优化的策略就是堆外内存,它是一种手动管理的内存区域,不属于JVM GC的管理范畴。

通过NIO的ByteBuffer.allocateDirect()方法分配的内存。这块内存并不在Java堆上,而是直接向操作系统申请的本地内存。

特性 堆内存 (Heap) 堆外内存 (Off-Heap)
管理者 JVM (GC自动管理) 开发者 (手动管理) / Cleaner机制
分配速度 快 (TLAB) 慢 (系统调用)
访问速度 极快 (与I/O交互时)
GC影响 受GC影响,可能STW 不受GC影响
大小限制 -Xmx参数限制 受物理内存限制

我们可以使用他来完成零拷贝的操作

  • 当进行网络或文件I/O操作时,如果数据在堆内存中,需要先从堆内存拷贝到内核缓冲区,再由操作系统发送出去。
  • 如果数据直接在堆外内存中,JVM可以直接将这块内存的地址交给操作系统,省去了从用户态到内核态的这次数据拷贝,实现了“零拷贝”,极大地提升了I/O性能。

使用:

  • NettyRocketMQ等高性能网络/消息框架,大量使用堆外内存作为网络通信的缓冲区。
  • 需要缓存大量数据,且不希望对GC造成巨大压力的场景(例如,本地缓存框架)。
  • 但是,容易出现内存泄漏和排查困难的问题

如何定位和分析内存问题的?

通过监控工具如 Arthas, VisualVM, 或者日志,通过 jmap dump 堆内存,再用 MAT 等工具分析,是优化了数据结构减少内存占用,还是调整了 JVM 参数,比如 -Xmx, -Xms

回收:

每个对象从诞生之初,JVM就在它的对象头(Object Header)里,为它设置了一个‘年龄计数器’(Age),占4个bit。这个‘年龄’是对象晋升老年代的主要依据

  • 绝大多数新创建的对象,首先会被分配在新生代的Eden区。此时,它们的年龄为0
  • 当Eden区满了之后,会触发第一次Minor GC
  • GC会扫描Eden区,将所有存活的对象复制到新生代的Survivor区中的一个(我们称之为To-Survivor区)
  • 在这个复制的过程中,这些幸存对象的年龄会加1
  • Eden区中所有未被复制的(被判定为垃圾的)对象,都会被一次性清空。
  • 新生代有两个Survivor区,我们通常称之为S0S1。在任何时刻,总有一个是空的(To-Survivor),另一个是有数据的(From-Survivor)。
  • 下一次Minor GC发生时,GC会同时扫描Eden区From-Survivor区(即上次存放幸存对象的那个区)。
  • 所有存活的对象,都会被再次复制到那个空的To-Survivor区
  • 同样,在复制过程中,这些对象的年龄会再次加1
  • 清空Eden区和From-Survivor区。然后,S0S1的角色互换,为下一次GC做准备。
  • 这个过程会一直重复。对象就在S0S1之间来回“倒腾”,每经历一次Minor GC,只要它还活着,年龄就会加1。
  • 当一个对象在Survivor区中不断地“倒腾”,其年龄达到一个设定的阈值时,在下一次Minor GC中,它将不再被复制到另一个Survivor区,而是被直接晋升(Promote)到老年代
  • 这个年龄阈值可以通过JVM参数-XX:MaxTenuringThreshold来设置。默认是15,因为对象头中的年龄计数器只有4个bit,最大能表示的数字就是15(二进制1111)。

一个新生代的晋升流程Eden -> S0 -> S1 -> … -> Old,年龄为15的时候进入老年代

除了年龄达到阈值,还有一种情况会触发晋升:如果在 Survivor 区中,相同年龄的所有对象大小的总和,大于 Survivor 空间的一半,那么年龄大于或等于该年龄的对象就可以直接进入老年代,无需等到 MaxTenuringThreshold。这个规则是为了防止Survivor区被过度填充。如果大量同龄的对象在某次GC后集中幸存,可能会导致Survivor区空间不足,进而触发更复杂的分配担保机制。动态年龄判断可以在这种情况发生前,提前将一些“年长”的对象送到老年代,为更“年轻”的对象腾出空间。

大对象直接晋升,这个对象的大小超过了由-XX:PretenureSizeThreshold参数设定的阈值,那么这个大对象将不会被分配在新生代的Eden区,而是会被直接分配到老年代

在执行Minor GC之前,JVM会检查老年代的连续可用空间是否大于新生代所有对象的总大小(或者大于历次晋升到老年代的对象的平均大小)。

  • 如果,那么这次Minor GC是安全的。如果,JVM会进行一次Full GC来清理老年代,以腾出更多空间。
  • 如果在Minor GC过程中,Survivor区确实无法容纳所有存活对象,那么多余的对象就会通过这个分配担保机制,被直接移入老年代

3.GC

对象死亡的三个方法,引用计数器,可达性分析,finalize方法,可达性分析需要两次标记,第一次看是不是没用跟引用链相连,第二次看队列中的是不是还没有相连

CMS 选择标记-清除的核心原因是,它是一个并发(Concurrent)收集器,在垃圾收集的大部分阶段,用户线程(Mutator)和 GC 线程是可以同时运行的。而标记-整理算法需要移动对象,这个过程非常复杂,很难与用户线程并发执行,所以 CMS 只能选择实现相对简单的标记-清除。这也正是 CMS 产生内存碎片的根本原因。

CMS 的核心优势恰恰在于它的并发标记(Concurrent Mark)\和*并发清除(Concurrent Sweep)*阶段,是*可以和用户线程一起运行的,从而大大缩短了 STW 时间。CMS 的 STW 主要发生在初始标记(Initial Mark)*和*重新标记(Remark)\这两个非常短暂的阶段。你应该强调 CMS 的 STW \*总时长很短**,但*不可预测*

而 G1 的 STW 虽然也是分阶段的,但其总时长可以在一个目标范围内被预测和控制

Full GC:

Full GC 是对整个 Java 堆(新生代和老年代)以及方法区进行的垃圾回收,STW 时间非常长,应极力避免。

  1. 老年代空间不足
    • 从新生代晋升过来的对象大小大于老年代的剩余空间。
    • 大对象直接在老年代分配时,空间不足。
  2. 方法区(元空间/永久代)空间不足:加载的类过多,或者运行时生成的动态代理类过多,导致方法区溢出。
  3. 显式调用 System.gc():代码中手动调用该方法,建议 JVM 执行 Full GC(不推荐使用)。
  4. CMS 收集器下的 Promotion FailedConcurrent Mode Failure
    • Promotion Failed: Minor GC 时,Survivor 空间放不下,想晋升到老年代,但老年代也没有足够空间。
    • Concurrent Mode Failure: 在 CMS 并发标记和清理期间,业务线程运行太快,导致老年代被填满,CMS 无法完成回收,只能STW并进行 Full GC。

排查方法 排查频繁 Full GC 的核心思路是:找出导致内存无法被正常回收的原因,通常是内存泄漏或不合理的内存配置

  1. 开启并分析 GC 日志
    • 通过 JVM 参数(如 -Xlog:gc*:file=gc.log:time,level,tags:filecount=5,filesize=10m)开启 GC 日志。
    • 使用 GCeasy、GCViewer 等工具分析日志,查看 Full GC 的频率、耗时以及触发原因。
  2. 使用命令行工具
    • jps:查看 Java 进程 ID。
    • jstat -gcutil <pid> <interval>:实时监控 GC 情况,查看老年代(O)、元空间(M)的使用率变化。如果 O/M 的使用率持续增长并接近 100%,则很可能存在内存泄漏。
  3. 生成和分析堆转储快照 (Heap Dump)
    • 在出现 OOM 时自动 Dump(-XX:+HeapDumpOnOutOfMemoryError),或使用 jmap -dump:format=b,file=heap.hprof <pid> 手动 Dump。
    • 使用 Eclipse MAT (Memory Analyzer Tool)JProfiler 等工具分析 .hprof 文件。
    • 重点关注 Dominator Tree(支配树)Leak Suspects(泄漏嫌疑) 报告,它们能快速定位到持有大量内存、无法被回收的对象及其引用链。
  4. 检查代码:根据快照分析结果,检查代码中是否存在:
    • 生命周期过长的对象,特别是静态集合类static Map 等)中只增不减的数据。
    • 资源未关闭,如 InputStream、数据库连接、Socket 等。
    • 不合理的对象创建,如在循环中创建大量临时大对象。
  5. 调整 JVM 参数:如果不是内存泄漏,而是内存分配不合理,可以考虑调整堆大小、新生代与老年代的比例、晋升阈值、元空间大小等参数。

4.Tomcat如何打破双亲委派?Tomcat类加载顺序?

  • 为何打破: 核心原因是为了实现Web应用之间的隔离性。一个标准的Tomcat服务器可以同时部署多个Web应用(多个.war包)。如果遵循标准的双亲委派模型,所有应用都会共享同一个父类加载器(AppClassLoader)。这会导致:
    1. 依赖冲突:应用A可能依赖Spring 4.x,而应用B依赖Spring 5.x。如果都由父加载器加载,那么只有一个版本的Spring能被加载,另一个应用必然会因类版本不匹配而崩溃。
    2. 隔离失效:一个应用的类可以被另一个应用访问到,无法实现真正的隔离。

如何打破: Tomcat通过自定义一个WebAppClassLoader来打破双亲委派模型。这个类加载器的loadClass()方法颠倒了标准的加载顺序

  1. 先在自己这里找:优先在Web应用自己的目录下(/WEB-INF/classes/WEB-INF/lib)查找类。
  2. 自己找不到,再交给爹:如果自己找不到,会遵循双亲委派模型,向上委托给父加载器。

这种“先己后亲”的模式,保证了每个Web应用优先使用自己打包的类库,从而实现了应用间的完美隔离


类的加载顺序?
Tomcat的类加载器体系是一个层次化的结构,其加载顺序如下:

  1. Bootstrap (引导类加载器):加载JVM自身的核心类库,如java.lang.*。这是顶层,无法被Java程序直接访问。
  2. System (系统类加载器):加载JVM系统级别的类,如CLASSPATH环境变量中指定的类。
  3. Common (公共类加载器):加载Tomcat和所有Web应用共享的类库,位于Tomcat安装目录的/lib下。如数据库驱动jar包通常放在这里。
  4. WebApp (Web应用类加载器):每个Web应用都有一个独立的WebAppClassLoader实例。它负责加载当前应用的类,路径是/WEB-INF/classes/WEB-INF/lib它正是打破双亲委派的关键

当一个类需要被加载时,Tomcat的查找顺序是:WebApp自己的目录 -> Common -> System -> Bootstrap。但有一个例外,对于Java核心类库(java.*, javax.*),为了防止覆盖JVM的核心API,WebAppClassLoader仍然会优先委托给父加载器。


那么为什么内嵌的Tomcat仍要打破双亲委派?

在一个Spring Boot应用中,通常只有一个Web应用,所以上面提到的“多应用隔离”的理由似乎不再成立了。但内嵌的Tomcat仍然保留了打破双亲委派的WebAppClassLoader,其原因已经从“隔离”转变为“适配”和“兼容”

  1. 适配Spring Boot的“胖Jar”结构
    • 一个标准的Spring Boot可执行jar文件,其内部结构是BOOT-INF/classesBOOT-INF/lib。标准的AppClassLoader无法读取嵌套jar文件(即BOOT-INF/lib里的jar)中的类的。
    • Spring Boot通过一个自定义的LaunchedURLClassLoader来启动应用,这个类加载器懂得如何从嵌套jar中加载类
    • 然而,Tomcat的WebAppClassLoader被设计为从标准的Web目录结构(文件系统路径或war包结构)中加载类。它不认识Spring Boot的胖jar结构。
    • 解决方案:Spring Boot在启动嵌入式Tomcat时,会创建一个Tomcat的WebAppClassLoader实例,但会巧妙地将其“喂食”的源头指向LaunchedURLClassLoader能够解析的路径。本质上,WebAppClassLoader仍然在工作,但它的“工作目录”被Spring Boot动态地设置为了BOOT-INF下的资源。
  2. 遵循Servlet规范和保持Tomcat内部机制的兼容性
    • Servlet规范中定义了类加载的逻辑,比如ServletContext.getResourceAsStream()等API的行为都与类加载器紧密相关。
    • Tomcat内部有很多机制,如JSP的编译和热加载,都严重依赖于WebAppClassLoader的存在和其特定的加载机制。
    • Spring Boot选择嵌入Tomcat,而不是重写它。为了不破坏Tomcat这个成熟容器的内部工作原理,最安全、最可靠的方式就是保留其原有的类加载架构,并在此基础上进行适配,而不是推倒重来。

总结:在Spring Boot内嵌场景下,Tomcat打破双亲委派的WebAppClassLoader,其核心作用已经不再是为了隔离多个Web应用,而是为了作为一个“适配器”,优雅地桥接Spring Boot独特的胖Jar类加载机制和标准Servlet容器对类加载环境的期望,从而保证了整个Web服务的正确、稳定运行。

5.G1回收器怎么处理大对象?

关于G1回收器如何处理大对象,这涉及到G1一个非常特殊的设计——Humongous Region

整个过程可以分为“是什么”、“如何分配”“如何回收”三个部分来理解。

  • 首先,G1中不叫“大对象”,而是有一个专门的术语,叫做“巨型对象”(Humongous Object)
    • 定义:一个对象的大小如果超过了单个Region容量的50%,就会被判定为Humongous Object。
    • Region的大小:G1在启动时会根据堆大小将整个堆划分为大约2048个大小相等的、不连续的Region。每个Region的大小在1MB到32MB之间,是2的N次幂。例如,一个Region是2MB,那么任何大于1MB的对象都会被视为Humongous Object。
  • 当G1遇到一个Humongous Object时,它的分配策略和普通对象完全不同:
  1. 不进Eden区:它不会在年轻代的Eden区进行分配,而是直接在老年代寻找连续的空闲Region来存放。
  2. 寻找Humongous Region:G1会专门开辟一类特殊的Region,称为Humongous Region,来存储这些巨型对象。这些Region在逻辑上属于老年代。
  3. 跨Region存储:
    • 如果一个Humongous Object的大小小于一个完整的Region,它就会被放入一个单独的Humongous Region中。这个Region中剩余的空间将会被浪费,无法再分配给其他对象。这就是内存碎片的来源之一。
    • 如果一个对象的大小超过了单个Region的容量,G1会寻找N个连续的空闲Region来存储它,并将这些Region都标记为Humongous Region。

总结分配过程就是G1会为巨型对象在老年代直接分配连续的、专门的Humongous Region来存放。

那么如何回收呢?

Humongous Object的回收有以下几个特点:

  1. 不参与年轻代GC (Young GC):因为它们直接分配在老年代,所以任何一次Young GC都不会扫描和回收它们。
  2. 在并发标记周期中被识别:G1的并发标记(Concurrent Marking)会扫描整个堆,包括Humongous Region。如果一个Humongous Object在这个阶段被识别为不再存活,它就会被标记为垃圾。
  3. 回收时机
    • 混合GC (Mixed GC):在并发标记之后,如果G1发现某个Humongous Region中的巨型对象已经完全是垃圾,那么在下一次的Mixed GC中,这个Region有可能会被直接回收。G1会评估回收它的收益(释放了大量空间)和成本(几乎为零,因为整个Region都是垃圾),如果划算,就会把它加入到回收集合(CSet)中一并清理。
    • Full GC:这是最后的手段。如果Humongous对象的分配和回收导致了严重的内存碎片,使得后续的对象(无论是普通对象还是巨型对象)都找不到足够的连续空间进行分配时,G1会触发一次Full GC。Full GC会进行空间压缩整理,彻底清理这些碎片,但这会导致长时间的“Stop-the-World”暂停,是我们极力要避免的。
    • JDK 8u60+ 的优化 - 巨型对象回收的增强:为了缓解Full GC的压力,从JDK 8u60开始,G1引入了一个优化。在并发标记周期结束后,如果发现某个Humongous Object是垃圾,G1可以在下一次Young GC发生时,顺便回收这个Humongous Region,而不需要等到Mixed GC。这被称为“Eager Reclamation of Humongous Objects”(巨型对象的积极回收)。

G1将大小超过Region容量一半的对象定义为Humongous Object。它会跳过年轻代,直接在老年代分配连续的、专门的Humongous Region来存储。这些对象的回收不发生在Young GC中,而是在并发标记确认其死亡后,可以在Mixed GC甚至Young GC(得益于Eager Reclamation优化)中被高效地整体回收。但是,Humongous对象的分配和回收容易导致内存碎片,如果碎片问题严重,最终可能会退化为代价高昂的Full GC。因此,在实践中,我们应该尽量避免创建生命周期很短的巨型对象,或者通过调整 -XX:G1HeapRegionSize 参数来减少巨型对象的产生。

6.如果发现 young GC频繁,我该怎么定位,怎么用 jvm 的指令定位。

会按照一个“提出假设 -> 工具验证 -> 分析解决”的实战流程来回答。

当发现Young GC(也称Minor GC)频繁时,根本原因只有一个:年轻代空间被快速填满。这通常由以下两种情况导致:

  1. 原因一:年轻代(特别是Eden区)设置过小。应用的正常运行就需要一定的内存分配速率,如果Eden区太小,很快就会被填满,自然导致频繁YGC。
  2. 原因二:应用存在内存分配速率过高的问题。代码中可能存在“内存泄漏”或“内存抖动”,在短时间内创建了大量对象,即使这些对象很快就死亡,也会瞬间占满Eden区。

使用jstat进行宏观监控,jstat是定位GC问题的首选命令行工具,它轻量、无侵入,可以实时监控GC活动。

1
2
3
4
5
# 1. 先用 jps 或 ps -ef | grep java 找到Java进程的PID
jps -l

# 2. 使用 jstat 监控GC情况,每秒刷新一次
jstat -gcutil <PID> 1000
  • 如何分析: jstat -gcutil的输出包含以下关键列:

    • S0, S1: Survivor 0和1区的使用率。
    • E: Eden区的使用率
    • O: 老年代使用率。
    • M: 元空间使用率。
    • YGC: 年轻代GC的总次数
    • YGCT: 年轻代GC的总耗时
    • FGC: Full GC的总次数。
    • FGCT: Full GC的总耗时。
  • 观察YGC:如果这个数字在短时间内(比如几秒钟)快速、稳定地增长,就证实了YGC确实非常频繁。

  • 观察E:你会看到Eden区的使用率像心电图一样,在短时间内从一个较低的值飙升到接近100%,然后一次YGC后又瞬间降下来,周而复始。这个“心跳”的频率越高,说明YGC越频繁。

使用jmap定位内存中的“元凶”,它能生成堆转储快照(heap dump)或查看堆中对象的统计信息。

1
jmap -histo <PID> | head -n 20
  • 如何分析: jmap -histo的输出包含四列:num(序号), instances(实例数量), bytes(总字节数), class name(类名)。
    • 重点关注instancesbytes这两列。排在最前面的类,就是当前在堆中数量最多占用空间最大的对象。
    • 关联YGC问题: 如果YGC频繁,通常意味着有大量的短生命周期对象被创建。这些对象可能在jmap运行时已经被回收了。但我们仍然可以从列表中找到那些创建速率极高的类的蛛丝马迹。比如,你发现java.lang.Stringbyte[]、或者某个业务DTO类的实例数量异常地高,那么问题很可能就出在创建这些对象的代码上。

进行代码审查:

  1. 代码审查: 拿着jmap找到的嫌疑类名,去代码库中搜索,看看哪些业务逻辑在大量、循环地创建这些类的实例。

    • 常见问题场景:
      • 在一个循环中,反复创建String对象进行拼接(应该使用StringBuilder)。
      • 不合理地使用了new关键字,本可以复用的对象却在循环中反复创建。
      • 日志级别过低(如DEBUG),在生产环境输出了大量不必要的字符串对象。
      • 从数据库或缓存中查询出了巨大的数据集合,并创建了大量的DTO/VO对象。
  2. 解决方案:

    • 优化代码 (首选): 如果是代码问题,根本的解决方案是优化代码。比如使用对象池、优化算法、减少循环中的对象创建等。

    • JVM调优 (次选): 如果代码中的内存分配速率是业务所必须的、合理的,但YGC依然频繁,那么就应该考虑调整JVM参数来

      增大年轻代的空间。

      • -Xms / -Xmx: 设置堆的总大小。
      • -Xmn: 直接设置年轻代的大小。增大这个值可以有效降低YGC的频率。
      • -XX:NewRatio=N: 设置老年代与年轻代的比例(老年代/年轻代)。减小这个值,相当于增大了年轻代的占比。
      • -XX:SurvivorRatio=N: 设置Eden区与单个Survivor区的比例。

7.GC回收时对象地址的拷贝是怎么实现的

对象地址的拷贝,或者更准确地说,是对象的移动(Moving),是所有“复制”(Copying)“标记-整理”(Mark-Compact)算法的GC中必不可少的一环。它的实现方式直接关系到GC的效率。

这个过程的实现,可以从“为什么需要拷贝”“拷贝的是什么”以及“如何高效地实现拷贝”这三个层面来理解。

为什么?拷贝对象的核心目的是为了解决内存碎片化问题。

通过将所有存活的对象拷贝(移动)\到内存的一端,使其紧凑排列,GC就可以在另一端获得一块*完整、连续的空闲空间*。这不仅解决了碎片问题,还使得后续的内存分配可以恢复使用高效的“指针碰撞”方式。

是什么?

一个Java对象在HotSpot虚拟机中的内存布局。它主要由三部分组成:

  1. 对象头 (Header)
    • Mark Word: 存储了对象的哈希码、GC分代年龄、锁状态标志等。
    • Klass Pointer: 指向该对象对应的类元数据(Klass)的指针。
    • 数组长度: 如果是数组对象,还会有这部分。
  2. 实例数据 (Instance Data):对象真正存储有效信息的字段内容。
  3. 对齐填充 (Padding):HotSpot VM要求对象起始地址必须是8字节的整数倍,如果对象大小不是8字节的倍数,就需要这部分来补齐。

GC拷贝的就是这整个对象(对象头 + 实例数据 + 对齐填充)的完整内存块。

如何高效地实现拷贝?以G1为例的底层实现

G1的年轻代和老年代回收,都是基于“复制”算法。它会将一个或多个Region(称为CSet,Collection Set)中的存活对象,拷贝到新的空闲Region中。这个过程发生在“Stop-the-World”(STW)暂停期间。

会基于以下的步骤:

  • GC线程会遍历CSet中所有Region。对于每个Region,它会查找那些在并发标记阶段被标记为存活的对象。
  • 分配新空间:在目标空闲Region中,为该对象申请一块大小相同的新内存空间。这通常通过高效的线程本地分配缓冲(TLAB)来完成,以避免多线程竞争。
  • 拷贝对象内容:执行一次内存块的批量拷贝。这通常是通过调用底层高效的memcpy之类的函数来完成的,将旧地址的整个对象内容原封不动地复制到新分配的内存地址。
  • 对象被拷贝到新地址后,必须在旧对象的对象头(Mark Word)中,留下一个指向新地址的“转发指针”
  • 这个转发指针的作用是:在GC的后续工作中,如果其他对象仍然持有指向这个旧对象的引用,当GC扫描到这个引用时,它会通过旧对象头里的转发指针,发现该对象已经“搬家”了,并直接将这个引用更新为指向新地址
  • 当一个对象被拷贝后,GC线程会立即扫描这个新拷贝的对象内部的所有引用字段
    • 如果该对象已经被拷贝(即其旧地址的对象头里已经有了转发指针),则直接将当前对象的引用更新为转发指针指向的新地址。
    • 如果该对象还未被拷贝,则递归地对这个被引用的对象执行上述的拷贝、设置转发指针、更新引用的完整流程。
  • 这个过程就像一个深度优先或广度优先的图遍历。从根对象(GC Roots)出发,一边拷贝存活对象,一边修复指向它们的引用,直到所有可达的存活对象都被拷贝到新的Region中,并且所有相关的引用都已更新为新地址。

当CSet中所有的存活对象都被拷贝完毕后,这些旧的Region就可以被整体视为完全空闲,并被回收以备后续使用。

总结以下:

  • 通过内存批量拷贝(如memcpy)高效地移动对象内容。
  • 利用转发指针机制,在旧对象的对象头记录新地址,作为后续引用更新的依据。
  • 通过递归或迭代的方式,遍历对象图,一边拷贝对象,一边修复指向已移动对象的引用。
  • 在多线程GC中,通常会使用线程本地分配缓冲(TLAB)\来加速新空间的分配,并使用*CAS*等同步原语来保证多线程操作的正确性。