JVM八股分析

JVM八股分析
mengnankkzhouJVM
1.那有哪些对象是可以直接在栈上分配呢?
在Java中,并不是特定类型的对象能够直接在栈上分配,而是取决于该对象的作用域。JVM通过一种叫做“逃逸分析”(Escape Analysis)的技术来判断一个对象是否可以安全地在栈上分配。
如果一个对象的引用没有“逃逸”出它被创建的方法之外,那么它就可能被优化为在栈上分配。这样做的好处是,当方法执行结束时,栈帧被弹出,对象的内存会立即被回收,无需等待垃圾回收(GC),从而提高性能。
- 逃逸分析是JIT(即时编译器)的一项优化技术,默认在现代JVM中是开启的。只有那些生命周期完全局限于单个方法调用内、体积较小且线程安全的对象,才最有可能被优化到栈上进行分配。
未逃逸的定义:
- 仅在方法内部使用:对象的引用完全封装在方法体内,没有被方法返回。
- 未赋值给外部变量:没有将该对象的引用赋值给任何类变量(
static字段)或实例变量。 - 未传递给可能逃逸的方法:没有将该对象的引用作为参数传递给其他方法,或者传递给了但能确定其他方法也不会让它“逃逸”。
逃逸的例子:
比如对象作为方法的返回值,他就是逃离了这个方法的作用域
对象引用赋值给实例变量,也是逃离这个方法的作用域
2.JMM和一个对象的生命周期
JMM划分:
线程共享:方法区 堆
线程私有:程序计数器,虚拟机栈,本地方法栈
生命周期:
创建: 类加载检查 -> 堆内存分配(指针碰撞/空闲列表)-> 初始化零值 -> 设置对象头 -> 执行 init 方法。
进行使用
回收:可达性分析 -> 垃圾回收算法 -> 分代回收(Minor GC, Full GC)
优化手段:
逃逸分析、栈上分配、TLAB(线程本地分配缓冲)等优化手段
逃逸分析、栈上分配和TLAB是JVM为了自动化地提升对象分配效率、降低GC压力而设计的一套协同工作的优化组合拳
逃逸分析是决策入口,它决定了一个不逃逸的对象是否有资格享受栈上分配这一‘特权’,从而完全避免GC。对于必须在堆上分配的逃逸对象,TLAB则为它们提供了线程私有的‘VIP通道’,避免了并发分配时的锁竞争。
- 当我们在代码中写下
new User()时,这个User对象在JVM中并不是“无脑地”直接被分配到堆上。它会经历一个由JVM JIT(即时编译器)主导的、充满优化的“审批流程” - 逃逸分析:逃逸分析是一种编译期优化技术,它不是直接的优化手段,而是一种分析手段。JIT编译器会分析一个对象的动态作用域,判断这个对象是否有可能“逃逸”出它的创建方法或当前线程。
- 如果逃逸分析的结果是:“这个对象完全不逃逸!”,那么JVM就会启用一个颠覆性的优化。栈上分配是指将那些不逃逸的小对象,直接在当前线程的虚拟机栈(Stack)上进行分配,而不是在堆(Heap)上。
- 如果逃逸分析的结果是:“这个对象逃逸了,必须在堆上分配”,那么JVM并不会立刻去抢占全局的堆内存,而是会尝试一个更高效的策略。
- TLAB(线程本地分配缓冲)\是JVM为了*提升对象在堆上分配的效率而设计的一种机制。JVM会在堆的新生代(Eden区)*为*每个线程预先分配一小块私有的内存区域,这个区域就叫TLAB。避免并发冲突:堆是所有线程共享的。如果没有TLAB,那么每次
new一个对象,多个线程都需要去竞争同一块Eden区的内存*。这个过程需要加锁(比如CAS)来保证分配的原子性,在高并发下会成为性能瓶颈。 - 当一个线程需要分配一个新对象时,它会首先尝试在自己的TLAB中进行分配。
- 因为TLAB是线程私有的,所以在这个区域内分配对象完全不需要加锁,速度极快,这是一个简单的指针碰撞(Bump the Pointer)操作。
- 只有当TLAB的空间用完了,或者要分配的对象太大TLAB放不下时,线程才会去申请一个新的TLAB,或者在全局的Eden区(此时需要加锁)进行分配。
- 然后在堆中是如何分配的呢?
内存分配方式:
- 指针碰撞 (Bump-the-Pointer)
- 适用场景:当Java堆内存是绝对规整的时候使用。这种情况通常由带有压缩功能的垃圾收集器产生,如
Serial、Parallel Scavenge、G1等。 - 底层原理:所有已用内存都放在一边,所有未用内存放在另一边,中间有一个指针作为分界点指示器。当需要分配内存时,仅仅是把该指针向空闲空间那边挪动一段与对象大小相等的距离。这个过程非常高效,分配内存的动作等同于一次指针移动。
- 适用场景:当Java堆内存是绝对规整的时候使用。这种情况通常由带有压缩功能的垃圾收集器产生,如
- 空闲列表 (Free List)
- 适用场景:当Java堆内存不是规整的,已用内存和空闲内存相互交错时使用。这种情况通常由不带压缩功能的垃圾收集器产生,如
CMS(Concurrent Mark Sweep)。 - 底层原理:虚拟机内部会维护一个列表,记录着哪些内存块是可用的。当需要分配内存时,会从这个列表中找到一块足够大的空间划分给对象实例,并更新列表上的记录。寻找合适空间的过程会比指针碰撞慢。
- 适用场景:当Java堆内存不是规整的,已用内存和空闲内存相互交错时使用。这种情况通常由不带压缩功能的垃圾收集器产生,如
并发处理:在多线程并发创建对象时,如何保证堆上分配的线程安全?
- 线程本地分配缓冲 (TLAB - Thread Local Allocation Buffer)
- 核心思想:空间换时间,避免竞争。这是首选和主要的解决方案。
- 底层原理:在JVM的Eden区,会为每一个新创建的线程预先分配一小块私有内存,这块内存就是TLAB。当一个线程需要为新对象分配内存时,它会首先在自己的TLAB中进行分配。因为这是线程私有的,所以这个分配过程完全不需要任何同步或加锁,可以直接使用“指针碰撞”的方式快速完成。这极大地提升了分配效率。
- 只有当线程的TLAB用完,需要申请新的TLAB时,或者要分配一个大于TLAB剩余空间的大对象时,才会触发下面的同步机制。
- 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性能。
使用:
- Netty、RocketMQ等高性能网络/消息框架,大量使用堆外内存作为网络通信的缓冲区。
- 需要缓存大量数据,且不希望对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区,我们通常称之为
S0和S1。在任何时刻,总有一个是空的(To-Survivor),另一个是有数据的(From-Survivor)。 - 当下一次Minor GC发生时,GC会同时扫描Eden区和From-Survivor区(即上次存放幸存对象的那个区)。
- 所有存活的对象,都会被再次复制到那个空的To-Survivor区。
- 同样,在复制过程中,这些对象的年龄会再次加1。
- 清空Eden区和From-Survivor区。然后,
S0和S1的角色互换,为下一次GC做准备。 - 这个过程会一直重复。对象就在
S0和S1之间来回“倒腾”,每经历一次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 时间非常长,应极力避免。
- 老年代空间不足:
- 从新生代晋升过来的对象大小大于老年代的剩余空间。
- 大对象直接在老年代分配时,空间不足。
- 方法区(元空间/永久代)空间不足:加载的类过多,或者运行时生成的动态代理类过多,导致方法区溢出。
- 显式调用
System.gc():代码中手动调用该方法,建议 JVM 执行 Full GC(不推荐使用)。 - CMS 收集器下的
Promotion Failed或Concurrent Mode Failure:Promotion Failed: Minor GC 时,Survivor 空间放不下,想晋升到老年代,但老年代也没有足够空间。Concurrent Mode Failure: 在 CMS 并发标记和清理期间,业务线程运行太快,导致老年代被填满,CMS 无法完成回收,只能STW并进行 Full GC。
排查方法 排查频繁 Full GC 的核心思路是:找出导致内存无法被正常回收的原因,通常是内存泄漏或不合理的内存配置。
- 开启并分析 GC 日志:
- 通过 JVM 参数(如
-Xlog:gc*:file=gc.log:time,level,tags:filecount=5,filesize=10m)开启 GC 日志。 - 使用 GCeasy、GCViewer 等工具分析日志,查看 Full GC 的频率、耗时以及触发原因。
- 通过 JVM 参数(如
- 使用命令行工具:
jps:查看 Java 进程 ID。jstat -gcutil <pid> <interval>:实时监控 GC 情况,查看老年代(O)、元空间(M)的使用率变化。如果 O/M 的使用率持续增长并接近 100%,则很可能存在内存泄漏。
- 生成和分析堆转储快照 (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(泄漏嫌疑) 报告,它们能快速定位到持有大量内存、无法被回收的对象及其引用链。
- 在出现 OOM 时自动 Dump(
- 检查代码:根据快照分析结果,检查代码中是否存在:
- 生命周期过长的对象,特别是静态集合类(
static Map等)中只增不减的数据。 - 资源未关闭,如
InputStream、数据库连接、Socket等。 - 不合理的对象创建,如在循环中创建大量临时大对象。
- 生命周期过长的对象,特别是静态集合类(
- 调整 JVM 参数:如果不是内存泄漏,而是内存分配不合理,可以考虑调整堆大小、新生代与老年代的比例、晋升阈值、元空间大小等参数。
4.Tomcat如何打破双亲委派?Tomcat类加载顺序?
- 为何打破: 核心原因是为了实现Web应用之间的隔离性。一个标准的Tomcat服务器可以同时部署多个Web应用(多个
.war包)。如果遵循标准的双亲委派模型,所有应用都会共享同一个父类加载器(AppClassLoader)。这会导致:- 依赖冲突:应用A可能依赖
Spring 4.x,而应用B依赖Spring 5.x。如果都由父加载器加载,那么只有一个版本的Spring能被加载,另一个应用必然会因类版本不匹配而崩溃。 - 隔离失效:一个应用的类可以被另一个应用访问到,无法实现真正的隔离。
- 依赖冲突:应用A可能依赖
如何打破: Tomcat通过自定义一个WebAppClassLoader来打破双亲委派模型。这个类加载器的loadClass()方法颠倒了标准的加载顺序:
- 先在自己这里找:优先在Web应用自己的目录下(
/WEB-INF/classes和/WEB-INF/lib)查找类。 - 自己找不到,再交给爹:如果自己找不到,才会遵循双亲委派模型,向上委托给父加载器。
这种“先己后亲”的模式,保证了每个Web应用优先使用自己打包的类库,从而实现了应用间的完美隔离
类的加载顺序?
Tomcat的类加载器体系是一个层次化的结构,其加载顺序如下:
- Bootstrap (引导类加载器):加载JVM自身的核心类库,如
java.lang.*。这是顶层,无法被Java程序直接访问。 - System (系统类加载器):加载JVM系统级别的类,如
CLASSPATH环境变量中指定的类。 - Common (公共类加载器):加载Tomcat和所有Web应用共享的类库,位于Tomcat安装目录的
/lib下。如数据库驱动jar包通常放在这里。 - 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,其原因已经从“隔离”转变为“适配”和“兼容”:
- 适配Spring Boot的“胖Jar”结构:
- 一个标准的Spring Boot可执行
jar文件,其内部结构是BOOT-INF/classes和BOOT-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下的资源。
- 一个标准的Spring Boot可执行
- 遵循Servlet规范和保持Tomcat内部机制的兼容性:
- Servlet规范中定义了类加载的逻辑,比如
ServletContext.getResourceAsStream()等API的行为都与类加载器紧密相关。 - Tomcat内部有很多机制,如JSP的编译和热加载,都严重依赖于
WebAppClassLoader的存在和其特定的加载机制。 - Spring Boot选择嵌入Tomcat,而不是重写它。为了不破坏Tomcat这个成熟容器的内部工作原理,最安全、最可靠的方式就是保留其原有的类加载架构,并在此基础上进行适配,而不是推倒重来。
- Servlet规范中定义了类加载的逻辑,比如
总结:在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时,它的分配策略和普通对象完全不同:
- 不进Eden区:它不会在年轻代的Eden区进行分配,而是直接在老年代寻找连续的空闲Region来存放。
- 寻找Humongous Region:G1会专门开辟一类特殊的Region,称为Humongous Region,来存储这些巨型对象。这些Region在逻辑上属于老年代。
- 跨Region存储:
- 如果一个Humongous Object的大小小于一个完整的Region,它就会被放入一个单独的Humongous Region中。这个Region中剩余的空间将会被浪费,无法再分配给其他对象。这就是内存碎片的来源之一。
- 如果一个对象的大小超过了单个Region的容量,G1会寻找N个连续的空闲Region来存储它,并将这些Region都标记为Humongous Region。
总结分配过程就是:G1会为巨型对象在老年代直接分配连续的、专门的Humongous Region来存放。
那么如何回收呢?
Humongous Object的回收有以下几个特点:
- 不参与年轻代GC (Young GC):因为它们直接分配在老年代,所以任何一次Young GC都不会扫描和回收它们。
- 在并发标记周期中被识别:G1的并发标记(Concurrent Marking)会扫描整个堆,包括Humongous Region。如果一个Humongous Object在这个阶段被识别为不再存活,它就会被标记为垃圾。
- 回收时机:
- 混合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)频繁时,根本原因只有一个:年轻代空间被快速填满。这通常由以下两种情况导致:
- 原因一:年轻代(特别是Eden区)设置过小。应用的正常运行就需要一定的内存分配速率,如果Eden区太小,很快就会被填满,自然导致频繁YGC。
- 原因二:应用存在内存分配速率过高的问题。代码中可能存在“内存泄漏”或“内存抖动”,在短时间内创建了大量对象,即使这些对象很快就死亡,也会瞬间占满Eden区。
使用jstat进行宏观监控,jstat是定位GC问题的首选命令行工具,它轻量、无侵入,可以实时监控GC活动。
1 | # 1. 先用 jps 或 ps -ef | grep java 找到Java进程的PID |
如何分析:
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(类名)。- 重点关注:
instances和bytes这两列。排在最前面的类,就是当前在堆中数量最多或占用空间最大的对象。 - 关联YGC问题: 如果YGC频繁,通常意味着有大量的短生命周期对象被创建。这些对象可能在
jmap运行时已经被回收了。但我们仍然可以从列表中找到那些创建速率极高的类的蛛丝马迹。比如,你发现java.lang.String、byte[]、或者某个业务DTO类的实例数量异常地高,那么问题很可能就出在创建这些对象的代码上。
- 重点关注:
进行代码审查:
代码审查: 拿着
jmap找到的嫌疑类名,去代码库中搜索,看看哪些业务逻辑在大量、循环地创建这些类的实例。- 常见问题场景:
- 在一个循环中,反复创建
String对象进行拼接(应该使用StringBuilder)。 - 不合理地使用了
new关键字,本可以复用的对象却在循环中反复创建。 - 日志级别过低(如DEBUG),在生产环境输出了大量不必要的字符串对象。
- 从数据库或缓存中查询出了巨大的数据集合,并创建了大量的DTO/VO对象。
- 在一个循环中,反复创建
- 常见问题场景:
解决方案:
优化代码 (首选): 如果是代码问题,根本的解决方案是优化代码。比如使用对象池、优化算法、减少循环中的对象创建等。
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虚拟机中的内存布局。它主要由三部分组成:
- 对象头 (Header):
- Mark Word: 存储了对象的哈希码、GC分代年龄、锁状态标志等。
- Klass Pointer: 指向该对象对应的类元数据(Klass)的指针。
- 数组长度: 如果是数组对象,还会有这部分。
- 实例数据 (Instance Data):对象真正存储有效信息的字段内容。
- 对齐填充 (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*等同步原语来保证多线程操作的正确性。












