读者你好,这里是即客开发,最近在网上复习JVM八股文的时候,感觉网上的八股文讲的都是大同小异,根本没有什么区分度,经常就是读下来之后感觉学了个寂寞,所以我就尝试自己对自己线上的应用进行了JVM调优,顺手写了篇文章,希望能为大家对JVM的学习提供思路。
这篇文章适合了解过JVM,并且有过一定动手经验的同学,纯小白可以划走了,因为这篇文章理论部分很水,主要聚焦的是对JVM的堆对象进行处理,以及如何结合JVM状态对服务器和代码进行优化
一.JVM内存结构
首先我们先来回顾一下Java的JVM的内存区域,这里就不赘述了,默认你全知道,不知道还调个蛋的优,赶紧去复习一波。
1.整体内存分布

2. 方法区
- 主要存储类的结构信息,如类的字段信息、方法信息、构造方法信息等。
- 运行时常量池,用于存储编译期生成的各种字面量和符号引用。
- 永久代(Permanent Generation):在 JDK 7 及之前版本使用,JDK 8 开始移除了永久代,用元空间(Metaspace)代替。

3. JVM的堆结构(重点)
新生代和老生代存Java程序运行时产生的对象,他们会被垃圾回收器GC的时候回收掉
元空间存储Java对象的原数据信息(其实就是经过类加载器,加载的class文件里面的东西,什么public修饰符,类的名称之类的,通过元数据信息来快速new java 对象)

4. JVM方法栈结构(非native方法)
- 存储局部变量、操作数栈、方法出口等信息。
- 每个线程都有一个私有的栈,用于存储方法的局部变量和部分结果。
- 栈(Stack)是一种数据结构,它按照后进先出(Last In, First Out,LIFO)的原则管理数据,即最后进入的元素最先被访问。栈可以看作是一种特殊的线性表,只允许在一端进行插入和删除操作,该端被称为栈顶(Top),而另一端被称为栈底(Bottom)。

5. JVM本地方法栈结构(native方法 如unsafe类的方法等等)
- 与栈类似,用于存储执行本地(native)方法的数据。
- 本地方法栈(Native Method Stack)是Java虚拟机(JVM)内存模型中的一部分,用于支持本地方法的调用。本地方法指的是用非Java语言(如C、C++)编写的,通过Java Native Interface(JNI)在Java程序中调用的方法。
在Java程序中,当需要调用本地方法时,JVM会创建一个本地方法栈,用于执行本地方法的操作。与虚拟机栈类似,本地方法栈也是线程私有的,每个线程都有自己的本地方法栈。

6. 程序计数器
- 每个线程都有一个程序计数器,用于存储当前线程正在执行的指令的地址。
- 线程切换时,程序计数器也会切换到相应线程的执行地址。

二.常见的垃圾回收算法
垃圾回收的前提--垃圾标记算法
1.引用计数(不好用)
- 原理:给每个对象分配一个 “引用计数器”,每当有一个地方引用该对象时,计数器 + 1;引用失效时,计数器 - 1。当计数器为 0 时,认为对象已死。
- 优点:实现简单,判定效率高。
- 缺点:无法解决循环引用问题(如对象 A 引用对象 B,对象 B 引用对象 A,两者计数器都不为 0,但实际已无外部引用,却无法被回收)。
- 现状:JVM 未采用这种方法(Java、C# 等主流语言均不使用),仅在少数场景(如 Python 的部分实现)中使用。
2.可达性分析算法 --GC roots算法(好用)
-
原理:以 “GC Roots” 为起点,向下搜索所有可达的对象(即能通过引用链连接到 GC Roots 的对象),不可达的对象被标记为 “可回收”。
-
GC Roots 的范围(这边讲的太复杂了,等会一句话讲懂):
- 虚拟机栈(栈帧中的局部变量表)中引用的对象(如方法参数、局部变量);
- 方法区中类静态属性引用的对象(如
static Object obj = new Object()中的obj); - 方法区中常量引用的对象(如
public static final Object OBJ = new Object()中的OBJ); - 本地方法栈中 JNI(即 Native 方法)引用的对象;
- JVM 内部的引用(如基本数据类型对应的 Class 对象、异常对象
NullPointerException等)。
-
示例:当一个对象既不在 GC Roots 中,也无法通过任何引用链从 GC Roots 到达时,就会被判定为 “死亡”(可回收)。即使存在循环引用(如 A→B→A),只要两者都不可达 GC Roots,就会被标记为可回收。
标记完成之后的垃圾回收策略--垃圾回收算法
1.标记-清除
-
步骤:
- 标记:通过可达性分析,标记出所有 “已死” 的对象(或 “存活” 的对象,不同实现标记目标可能相反);
- 清除:遍历内存区域,回收所有被标记为 “已死” 的对象,释放其占用的内存。
-
优点:实现简单,不需要移动对象。
-
缺点:
- 效率低:标记和清除过程都需要遍历大量对象,回收效率随对象数量增加而下降;
- 内存碎片:回收后会产生大量不连续的内存碎片(小内存块),若后续需要分配大对象,可能因找不到足够大的连续内存而提前触发另一次 GC。
-
适用场景:适用于对象存活率高、回收频率低的区域(如老年代,因为老年代对象存活久,回收次数少,碎片影响相对可控)。

2.标记-复制
-
步骤:
- 将内存区域划分为大小相等的两块(如 A 和 B),每次只使用其中一块(如 A);
- 标记:通过可达性分析,标记出 A 中所有 “存活” 的对象;
- 复制:将 A 中所有存活对象复制到 B 中(按顺序连续存放);
- 清除:清空 A 区域的所有内存(此时 A 中剩余的都是死对象)。后续分配对象时,切换到 B 区域使用,下次回收时再将 B 中的存活对象复制到 A,循环往复。
-
优点:
- 解决了内存碎片问题(存活对象连续存放);
- 回收效率高(只需复制存活对象,清除时直接清空整块区域,无需遍历死对象)。
-
缺点:
- 内存利用率低:始终有一半内存处于空闲状态(如 A 和 B 只能用一块);
- 若存活对象多(如老年代),复制成本高(需要大量内存复制操作)。
-
优化与适用场景:实际中不会严格按 1:1 划分,而是采用 “多块划分”(如年轻代的 Eden 区和 Survivor 区)。例如:年轻代分为 1 个 Eden 区(占 80%)和 2 个 Survivor 区(各占 10%),每次使用 Eden +Survivor(From),回收时将存活对象复制到另一个 Survivor(To),Eden 和 From 被清空。由于年轻代对象存活率极低(通常 < 5%),复制成本低,因此非常适合标记 - 复制算法。

3.标记-整理
-
步骤:
- 标记:同标记 - 清除,标记出所有存活对象;
- 整理:将所有存活对象向内存区域的一端 “移动”,按顺序连续排列;
- 清除:直接清理掉 “存活对象边界” 之外的所有内存(即所有死对象)。
-
优点:
- 解决了内存碎片问题(存活对象连续存放);
- 内存利用率高(无需预留一半内存)。
-
缺点:
- 比标记 - 清除多了 “移动对象” 的步骤,成本高(需要更新所有引用该对象的指针,耗时较长);
- 回收过程中可能需要暂停用户线程(STW,Stop The World),影响响应速度。
-
适用场景:老年代(对象存活率高,移动成本虽高,但避免碎片和提高内存利用率更重要)。

三.常见的GC回收器--G1
调优的话主要是对Java写的服务端代码进行调优,现在基本上都是G1垃圾回收器了,所以先了解这个垃圾回收器就行了
前情提要:网上讲的 Minor GC,Young GC 这些术语本质都一样,都是特指的年轻代回收,Major GC 和 Old GC 都是特指老年代回收, Full GC都是特指整堆回收。STW(Stop The World JOJO乱入。。。)是指JVM停顿,垃圾回收器标记回收的行为。不同垃圾回收器的垃圾回收策略不同(使用不同的回收算法),但是都有上述的行为。
G1垃圾回收器(Garbage-First):

G1垃圾回收器主要将堆内存划分为多个大小相等的区域(称为Region),各个区域根据需要扮演不同的角色,可以被定义为Eden区、Survivor区、Old区和Humongous区(存放大对象,老年代的一部分),采用复制算法针对每个区域进行垃圾回收,同样也支持动态的调整内存大小。同时各个Region不需要连续的存储,颠覆了以往堆内存结构的连续性,具备强大的灵活性,也进一步提高了内存利用率。
其中区域Region的内存大小默认是通过整个堆内存大小除以2048得到的,例如整个堆内存为4G,则Region = 4G / 2048 = 2M,同时也支持通过JVM参数指定Region的内存大小(图文出处)。
G1回收器的垃圾回收策略:
这里不过分赘述了,不是本文重点,可以参考上面的图文出处来进行学习,那篇文章讲的不错。
四.Spring应用实战调优
1.工具准备
这里我们使用的JVM监控工具是阿里开源的arthas 附上地址 arthas
同时我们还需要下载堆内存分析工具 MAT 这个是用来分析当前堆内存快照的工具 Eclipse downloads - Select a mirror | The Eclipse Foundation
先启动我们的Spring应用程序,然后记好这个进程ID

下载好之后,你会有一个二进制文件,点进去,然后输入CMD,进入到这个文件夹的控制台页面

使用这个命令启动他,然后在底下输入你的spring应用对应的进程ID
java -jar arthas-boot.jar

启动成功之后你就能为所欲为了

2.基础监控
堆内存监控:
使用 memory 指令就能对堆内存进行简单的监控

可以看到我这个新启动的Spring应用heap区域也就是常规的堆内存只使用了92Mb,使用率是1.14%非常低。
但是我的nonheap,也就是元空间区域几乎要满了,这是为啥?想想刚才我们说的,元空间是存放java class文件的地方,是不是就有点头绪了?没头绪也不急,我们再继续看看其他监控指令

JVM状态监控:
使用jvm指令,能够直接打印当前JVM的状态信息
开头是JVM的基础信息,包括JVM的参数,启动时间之类的,涉及到比较深入的调优,现在暂时用不上
这里展示的几个参数都很重要,能大致判断JVM的状况
CLASS-LOADING:
展示类加载与卸载的数目,当前加载了2.3w个class文件到我们的元空间,这就是为什么我们的元空间要满了,因为springboot应用一般涉及到很多第三方库,他们都有对应的class文件,但是不要担心,元空间是可以自动扩容的。
COMPILATION:
展示JIT实时编译情况,这里总共JIT编译了1.8s,应该是在应用启动的时候把一些热点代码提前转化为机器码执行,提升效率,例如初始化Bean的Java代码。
GARBAGE-COLLECTORS:
反映G1垃圾回收器的运行状态,可以看到youngGC进行了20次,耗时80ms,Old GC进行了0 次,符合初始化的流程。因为有大量的bean初始化,属于新对象创建的高峰期,因此会触发很多次的young GC,至于为什么会触发这么多GC,欢迎学习我上一篇Spring bean初始化流程的文章,去debug看看bean初始化会创建什么对象。

这里的参数的话主要和G1相关,感兴趣的自行搜索一下,我们主要关注最后面的PENDING-FINALIZE-COUNT 这个参数 ,他描述的是等待执行finalize() 方法的对象为0。经常有同学把这个东西跟正常的垃圾回收的行为搞混,认为垃圾回收是靠执行这个方法来进行回收的。
但其实,finalize() 方法的作用是在对象被回收前执行一些自定义清理逻辑(如释放 native 资源),但它是 附加流程,不是垃圾回收的必要环节:
-
对于未重写
finalize()的对象:G1 标记为 “死亡” 后,直接在下次回收中释放内存(无任何额外步骤); -
对于重写了
finalize()的对象:G1 会先将其放入 Finalizer 队列,由 Finalizer 线程执行finalize(),之后再在下一次回收中重新判断是否回收(若仍不可达,则释放内存)。
但由于 finalize() 存在诸多问题(如延迟回收、对象复活风险),官方早已不推荐使用。现代 Java 应用中,几乎没有对象会重写 finalize(),因此 G1 实际回收时,绝大多数情况下都不会触发 Finalizer 线程,直接按 “标记 - 复制” 流程高效回收。

线程监控:
可以通过thread指令来查看当前JVM运行的线程
这里的每个线程都很有用,感兴趣的自己学习一下,篇幅有限,就不展开讲了

3.深度监控
堆内存快照分析:
使用 heapdump 命令可以下载当前的堆内存快照,一般线上环境都会给jvm配置
-XX:+HeapDumpBeforeFullGC这个参数,来在JVM Full GC之前生成堆内存的快照文件,以便后续分析为什么会触发Full GC。以及-XX:+HeapDumpOnOutOfMemoryError 在JVM OOM之前保存堆内存的快照文件,以便分析为什么会OOM。
这里我们就先拉一个本地应用的dump文件下来简单分析一下。

使用MAT打开dump文件,然后你就能进入到这个主界面了,看起来有点眼花缭乱,但是不急,推荐一篇手册 一文深度讲解JVM 内存分析工具 MAT及实践(建议收藏)-阿里云开发者社区

进来这个总览主页,信息量就很大了,你可能会好奇堆大小怎么只有58.9MB?因为刚刚我们使用memory指令展示的内存大小足足有92MB,其实这是因为dump指令只会根据GC Root来记录那些能够存活的对象,就这么简单。classLoader只有115个,这是不是和双亲委派模型对上了?原来八股根本就不需要背,一眼就能看懂。

看懂总览,我们来愉快地进行堆快照的内存分析吧。
点击histogram按钮进入内存占用直方图的分析
![]()
1.Objects是当前class创建的对象数
2.shallow heap是当前class的所有对象自身占用的堆内存大小
3.Retaind heap 是当前对象被GC回收之后能释放的内存大小,你可能会好奇为什么下面两个释放的大小比他们本身占用的堆内存大小大很多?还记不记得垃圾回收时候的GC Root,答案就是他?Retaind heap 这个参数指的就是以当前对象为GC root,把与这个root有引用关系的对象全部回收后能释放的空间大小。

展示当前dump文件的内存占用情况

这个按钮能展示当前dump文件内部占用堆内存最多的对象class name 。因为我这是新起的项目,所有最大的就是个类加载器,因为还没运行任何业务代码,线上项目一般会是容易辨别的Java业务DTO或者是自定义线程池的线程。
但是有时候你业务接口可能会查表查出一个很大的list,并封装为java对象。因为list很大,可能有几十几百MB,甚至达到GB,因为JVM对大对象的内存分配策略是直接进入老年代,如果老年代内存不足会直接触发Full GC。线上就会观察到机器频繁的Full GC,这个时候我们就需要根据dump文件,分析他的内存占用情况,然后根据类定位到对应的接口,执行对应的止损策略。

线程快照分析:
arthas 可以使用thread -b 指令来检测当前JVM是否存在死锁(目前只支持synchronized),如果没有死锁,一般是这样的

如果有死锁,他就会展示线程Id
$ thread -b
"http-bio-8080-exec-4" Id=27 TIMED_WAITING
at java.lang.Thread.sleep(Native Method)
at test.arthas.TestThreadBlocking.doGet(TestThreadBlocking.java:22)
- locked java.lang.Object@725be470 <---- but blocks 4 other threads!
at javax.servlet.http.HttpServlet.service(HttpServlet.java:624)
at javax.servlet.http.HttpServlet.service(HttpServlet.java:731)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:303)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:208)
at org.apache.tomcat.websocket.server.WsFilter.doFilter(WsFilter.java:52)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:241)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:208)
at test.filter.TestDurexFilter.doFilter(TestDurexFilter.java:46)
at org.apache.catalina.core.ApplicationFilterChain.internalDoFilter(ApplicationFilterChain.java:241)
at org.apache.catalina.core.ApplicationFilterChain.doFilter(ApplicationFilterChain.java:208)
at org.apache.catalina.core.StandardWrapperValve.invoke(StandardWrapperValve.java:220)
at org.apache.catalina.core.StandardContextValve.invoke(StandardContextValve.java:122)
at org.apache.catalina.authenticator.AuthenticatorBase.invoke(AuthenticatorBase.java:505)
at com.taobao.tomcat.valves.ContextLoadFilterValve$FilterChainAdapter.doFilter(ContextLoadFilterValve.java:191)
at com.taobao.eagleeye.EagleEyeFilter.doFilter(EagleEyeFilter.java:81)
at com.taobao.tomcat.valves.ContextLoadFilterValve.invoke(ContextLoadFilterValve.java:150)
at org.apache.catalina.core.StandardHostValve.invoke(StandardHostValve.java:170)
at org.apache.catalina.valves.ErrorReportValve.invoke(ErrorReportValve.java:103)
at org.apache.catalina.core.StandardEngineValve.invoke(StandardEngineValve.java:116)
at org.apache.catalina.connector.CoyoteAdapter.service(CoyoteAdapter.java:429)
at org.apache.coyote.http11.AbstractHttp11Processor.process(AbstractHttp11Processor.java:1085)
at org.apache.coyote.AbstractProtocol$AbstractConnectionHandler.process(AbstractProtocol.java:625)
at org.apache.tomcat.util.net.JIoEndpoint$SocketProcessor.run(JIoEndpoint.java:318)
- locked org.apache.tomcat.util.net.SocketWrapper@7127ee12
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1142)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:617)
at org.apache.tomcat.util.threads.TaskThread$WrappingRunnable.run(TaskThread.java:61)
at java.lang.Thread.run(Thread.java:745)
Number of locked synchronizers = 1
- java.util.concurrent.ThreadPoolExecutor$Worker@31a6493e
当然,如果是Lock包下面的声明式的锁造成的死锁,我们一样能解决,使用JDK自带的jstack指令,导出对应的Java应用的堆栈信息,然后进一步分析死锁产生的原因
jstack -l <PID> > thread_dump.txt # 将线程信息导出到文件
如何基于快照分析调优代码与JVM:
线上机器出现问题一般有几种
1.代码未处理好引用关系导致内存泄漏,时间长了机器OOM
- 这种问题一般来说可能和线程池有关,譬如ThreadLocal,父子线程的任务传递问题,一般都有成熟的解决方案,如果真遇到了,直接拉dump文件,使用MAT的Leak Suspects 进行分析,查看当前dump文件潜在的内存泄漏对象有哪些就行了。
2.大对象创建频繁导致频繁Full GC 导致用户线程阻塞,严重时也会导致OOM
- 如果是业务代码问题,业务上尽量避免大对象一次性加载到内存中,可以尝试分片分批读取,加载。
- 如果是框架问题,例如Spring AOP频繁创建代理对象,因为代理切面未实现接口,这个时候就可以优化一下业务代码的架构,使他适配框架,减少大量对象的生成。
3.代码中存在死锁,导致线程持续利用CPU时间片空转,进而导致整体性能下降
- 这种问题一般挺好解决,绝大部分是代码编写不规范的问题,一般给锁加一个超时时间就可以了,并且一定要用try-catch-finally来包裹上锁 解锁的代码块,synchronized 这种粗粒度的锁尽量减少使用。
4.基于业务表现,调优JVM堆的各个区域的大小参数思路
- Young GC 频繁导致业务卡顿或者是启动卡顿:观察young GC频率,调整新生代总体大小以及Eden 与 Survivor 的比例,这一步需要动态调优,综合比对!

- Full GC 频繁导致程序OOM:观察业务告警,调整老年代与新生代的比例,增大老年代的大小。
- 元空间不足导致 Metaspace OOM:Spring的 AOP动态代理产生的代理类会频繁在元空间被类加载器加载进来,分配内存。需要适当加大内存空间,深入调优的话我一两句话讲不明白,感兴趣自行学习。
- 直接内存不足导致 OOM:这块主要涉及到的是NIO,因为NIO的buffer缓冲区就算使用的直接内存,如果遇到OOM,先适当调大,然后看看自己业务是不是处理能力太弱了,适当增加一下机器的配置,或者搞搞负载均衡。
五.总结
好了,以上就是我动手对JVM进行的基础调优,希望我的思路能对你有所帮助,我是即客开发,我们下次再见!
1193

被折叠的 条评论
为什么被折叠?



