工作五年后,重新把JVM翻了一遍——类加载、GC回收、调优工具这些东西到底怎么理解,VisualVM、Arthas、MAT的性能调优参数详解
我精心收集了一份10万字的java全面知识总结,包括了java几乎所有的面试基础解读,有兴趣可以获取看看
领取方式
https://pan.quark.cn/s/4e6e3d03407a
一、类加载这件事,比想象中要绕
很多人背过双亲委派,但真正用到的时候往往说不清楚。
Java程序运行起来,类不是一次性全部加载进内存的,而是用到哪个加载哪个。加载一个类的过程大致是:找到字节码文件、验证格式是否合法、给静态变量分配内存、解析符号引用,最后初始化静态代码块。这个过程没什么悬念,关键是"谁来加载"这个问题。

JVM默认有三层加载器:
BootstrapClassLoader(启动类加载器)用C++写的,不是Java类,所以你拿不到它的引用,调 getClassLoader() 会返回null。它负责加载JVM核心库,就是 jre/lib/rt.jar 里那些东西,比如 java.lang.*、java.util.* 这些包。
ExtClassLoader(扩展类加载器)加载 jre/lib/ext 目录下的jar包,或者 java.ext.dirs 指定路径下的东西。实际项目里这一层很少自己去扩展,不过了解它存在的位置有用。
AppClassLoader(应用程序类加载器)加载我们写的业务代码,就是CLASSPATH里的类。日常工作里大部分类都是它加载的。

双亲委派的意思是:AppClassLoader收到加载请求,不会自己直接去找,而是先把请求交给ExtClassLoader,ExtClassLoader再往上交给Bootstrap。Bootstrap如果能加载,就直接返回;不能加载才往下退。整个流程是"自底向上委派,自顶向下查找"。
这个机制有两个实际作用。一是防止重复加载,同一个类只会被加载一次,节省内存也保证了一致性。二是安全,你自己写个 java.lang.String 放在项目里,Bootstrap加载器根本不会让AppClassLoader有机会加载它——核心类的控制权被锁死了。
不过这个机制不是绝对的,有些场景必须打破它。比如JDBC,java.sql.Driver 接口是Bootstrap加载的,但具体的数据库驱动实现(如MySQL的驱动)在应用类路径里,Bootstrap找不到,这时候就用了线程上下文类加载器,让父加载器反过来委托子加载器去加载,这是SPI机制下的变通方式。
Tomcat也是典型例子。同一个容器里跑了两个Web应用,都有一个叫 UserService 的类但实现不同,Tomcat给每个应用单独建了类加载器,两个 UserService 互不干扰。这就是主动打破双亲委派,重写了 loadClass() 方法来实现类隔离。
如果只是想自定义加载逻辑,比如从数据库或加密文件加载字节码,通常重写 findClass() 就够了,双亲委派流程不受影响。只有真的要打破委派才需要动 loadClass()。
二、GC的分代思想,为什么是这样设计的
GC领域有一个核心观察,叫"弱代假说":大部分对象生命周期很短,创建完不久就死了,只有少数对象会长期存活。JVM的分代模型就是基于这个观察设计的。
堆内存被切成两块:新生代(Young Generation)和老年代(Old Generation)。新生代又分Eden区和两个Survivor区(S0、S1),默认比例是8:1:1。对象一般先在Eden区分配,Minor GC之后还活着的就进Survivor,在Survivor里撑过一定次数(默认15次,每次GC年龄+1)就晋升到老年代。大对象(比如很长的数组)可以直接进老年代,跳过新生代,避免频繁复制。
这样设计的好处是,新生代GC只扫描Eden和一个Survivor,范围小、速度快,而且由于大部分对象很快就死了,存活率低,用"复制算法"效率高——把活着的对象复制到另一块空间,原来的区域整个清掉,没有碎片问题。
老年代对象存活率高,复制算法代价太大,用的是标记-整理(Mark-Compact)或者标记-清除(Mark-Sweep)。标记-清除不移动对象,速度快但会留碎片;标记-整理把活对象压缩到一端,内存连续但要移动对象,有STW(Stop-The-World)停顿。
STW是个绕不开的话题。GC要准确标记对象,必须有一个时刻让所有线程暂停(到达Safepoint),否则对象引用还在变,标记结果不可信。STW时间越长,应用响应越差,这是GC调优的核心矛盾。

几个主流GC收集器对比:
CMS是Java里第一个真正关注延迟的收集器,采用标记-清除,老年代回收大部分阶段和用户线程并发执行。它的问题也很明显:标记-清除会产生内存碎片,碎片积累到一定程度会触发Serial Old进行Full GC,这一停顿可能很长;另外CMS对CPU敏感,并发线程会抢占业务线程资源。CMS在Java 9标记弃用,Java 14被彻底移除,现在基本不该用了。
G1是JDK 9之后的默认收集器,设计思路完全不一样。它把整个堆切成大量等大的Region(每个1-32MB),不再有物理上连续的新生代老年代,而是每个Region在逻辑上属于某一代,可以随时变换角色。G1维护每个Region的回收价值,优先回收垃圾多的Region,这是"Garbage-First"名字的来源。
G1可以设置 -XX:MaxGCPauseMillis 来控制最大停顿时间目标,它会自动调节回收频率和范围。实际上G1并不能保证一定达到目标,而是尽量接近。对大对象(超过Region一半大小)会放进专门的Humongous区。G1比CMS的核心改进是解决了碎片问题,不过它的转移阶段仍然有STW,停顿时间受堆大小影响。
ZGC是JDK 11引入、JDK 15正式可用的低延迟收集器,目标是把停顿时间压到10ms以内,而且停顿时间不随堆大小增长。它用了两个关键技术:染色指针(Colored Pointers)和读屏障(Load Barrier)。染色指针把对象状态信息直接编码在指针的高位bit里,读屏障在对象被访问时检查并修正引用,这样对象转移和引用更新可以几乎全程并发进行,STW只剩极短的初始标记和根扫描阶段。
美团有过一篇实战文章,把核心服务升级到JDK 17 + ZGC之后,GC停顿从几百ms降到3ms以内,同时机器成本还降了约10%。某电商平台大促期间ZGC把订单支付接口TP99从320ms压到了210ms。这些数据挺说明问题的。
不过ZGC不是银弹。它的并发标记会增加CPU压力,在内存带宽受限的云实例上,LLC缓存缺失率比G1高不少。吞吐量也比G1略低,大约最差降15%。ZGC刻意减少了可调参数(只有约16个核心参数),对它的调优建议是:先跑默认参数,有问题再微调,不要把G1时代的参数照搬过来,大部分会没有效果甚至有害。
选型参考:堆内存小于4G、对延迟要求不高,G1足够;堆8G以上、需要控制停顿,上G1并设置 -XX:MaxGCPauseMillis=100 试试;对延迟极度敏感(金融、实时推荐)或者堆超过16G,考虑ZGC。
三、JVM调优,怎么做才不是瞎猜
JVM调优第一原则:不要在不知道问题在哪的情况下乱改参数。很多老项目里有一堆祖传JVM参数,没人知道为啥这么设,也没人敢动。正确的做法是先搞清楚现象,再定位原因,再调整参数,调完要验证效果。
常用参数及含义:
-Xms / -Xmx:初始堆大小和最大堆大小。一般设成相同值,避免JVM频繁扩缩堆造成额外开销。服务器应用通常设为可用物理内存的60%-75%,留一部分给操作系统和堆外内存。
-Xmn:新生代大小。用了G1之后不建议手动设这个,G1会自己管新生代大小,手动设了反而锁死调度能力。
-XX:+UseG1GC / -XX:+UseZGC:选择垃圾回收器。JDK 9以后默认G1,一般不用显式指定,除非想切换成ZGC。
-XX:MaxGCPauseMillis:G1的停顿时间目标,默认200ms。调低可以减少单次停顿,但会导致GC更频繁;调高停顿增加但吞吐提升。根据业务对延迟的要求设置。
-XX:MetaspaceSize / -XX:MaxMetaspaceSize:元空间大小。元空间用本地内存,默认没有上限,设一个合理上限可以防止内存被无限消耗,特别是用了动态类加载的框架。
-XX:+PrintGCDetails(JDK 8)或 -Xlog:gc*(JDK 9+):开启GC日志。线上环境必须开,是分析GC问题的直接依据。
-XX:+HeapDumpOnOutOfMemoryError:OOM时自动dump堆内存快照。不开这个,OOM发生后基本没法分析原因。

调优的依据从哪来?
主要看两类数据:GC日志和监控指标。GC日志里能看到每次GC的类型(Minor/Full)、停顿时间、回收前后内存变化。如果Full GC频率高,先看老年代是不是太小,或者有对象大量晋升;如果Minor GC停顿长,看新生代大小和Survivor空间够不够用。
监控上关注堆使用率的波动趋势、GC停顿时间的P99/P999、每秒对象分配速率。如果堆使用率持续上升不回落,基本是内存泄漏。
工具这块用什么:
jps / jstat / jstack / jmap 这几个JDK自带的命令行工具是基础。jps 看进程列表,jstat -gcutil <pid> 1000 每秒打印GC统计,jstack <pid> 打印线程快照(排查死锁和线程卡死好用),jmap -dump:format=b,file=heap.hprof <pid> 生成堆转储文件。
VisualVM 是Oracle官方的可视化监控工具,JDK自带(或独立下载),能实时查看堆内存、CPU、线程状态,支持生成堆转储和线程快照,有CPU Profiler可以采样方法执行时间。开发测试环境定位问题很方便。CPU Profiler里找"自用时间(CPU)"高的方法,就是真正的CPU热点。
MAT(Memory Analyzer Tool) 专门分析堆转储文件,找内存泄漏很好用。把 jmap 生成的hprof文件导入,看Dominator Tree(哪些对象占内存最多)和Leak Suspects(MAT自动分析的可疑泄漏点),大部分内存泄漏问题能直接定位到是哪个类、哪个引用链在持有大量对象没释放。
Arthas 是阿里开源的线上诊断工具,无需重启应用,无侵入。几个常用操作:
trace com.example.UserController getUser 追踪方法调用链路,显示每一层耗时,快速定位慢在哪。
thread -b 检测死锁,直接打出死锁的线程和等待关系。
watch com.example.UserService login '{params, returnObj}' 监控方法的入参和返回值,线上调试很方便。
classloader -t 查看类加载器层级关系,排查类加载问题。
Arthas线上诊断的优势是不需要重启、不用提前埋点,临时接上去排查问题,排查完disconnect就行。JProfiler功能更强但是重量级,本地开发或者测试环境用比较合适,线上开的话CPU开销不小。
实际工作中见过的问题:新生代Survivor区设太小,Minor GC后对象直接晋升老年代,导致老年代增长快、Full GC频繁,把 -XX:SurvivorRatio 调小(增大Survivor比例)就缓解了。还有Metaspace OOM的,原因是用了某个字节码增强框架动态生成了大量类,加了 -XX:MaxMetaspaceSize=512m 并检查代码里的类生成逻辑,问题才彻底解决。
参数调了不等于问题解决了,调完要跑压测或者放一段流量观察GC日志,确认停顿时间、Full GC频率、堆使用率都在合理范围。JVM调优是个反复迭代的过程,没有一次设好就万事大吉这种说法。
更多推荐




所有评论(0)