《第4章 JVM GC》节略部分
JVM类加载机制
当工程师们准备将写好的源代码变成可执行的程序时,在JVM中会经历这样一个过程。

首先,将源代码文件
xxxx.java
编译成.class
字节码,或者是打成jar
包(或war
包)。然后,通过类加载器
ClassLoader
将类的字节码加载到JVM中执行。
上面的图简化了类加载的过程,但其实一个类从加载到使用,整个过程会包括下面的这些步骤,

JVM的策略是当需要使用类的时候再加载,而不是一开始就一股脑装进来,这和单例模式中的懒汉模式
是一样的。
类加载中比较关键四个阶段如下图所示。

验证阶段
:JVM校验加载进来的.class
文件是否符合规范。准备阶段
:给类(包括static修饰的)和变量分配内存空间(仅仅是分配并给一个默认的初始值)。解析阶段
:把符号引用替换为直接引用,所谓符号引用就是变量名,所谓直接引用则是内存地址。初始化阶段
:执行类的初始化代码和静态代码块(准备阶段的变量在此赋值)。
至于什么时候真正初始化一个类?以下的几个时机一定会执行初始化动作。
new Object()时。
包含main()方法的类。
某个类的父类还未初始化,那就必须要先初始化其父类。
而Java的类加载器有下面这么几种,它们的继承关系如图所示。

Bootstrap ClassLoader
:启动类加载器,加载Java核心类(lib中的类),它是顶级类加载器。Extension ClassLoader
:扩展类加载器,继承自Bootstrap ClassLoader
,用于加载lib
和ext
中的类。Application ClassLoader
:应用程序类加载器,继承自Extension ClassLoader
,加载ClassPath
环境变量所指定的路径中存在的类。自定义类加载器
:根据用户自定义的需求加载类,继承自Application ClassLoader
。

当加载类的时候,ClassLoader
类加载器的流程如下。
先判断此类是否已经被加载,如果类已经被加载则返回。
如果没有被加载,那么会顺着继承结构往上(也就是开始第
1.x
相关步骤),先委托父加载器看看该类是否已被加载过,有则返回,无则继续向上委托。如果父加载器没找到要加载的类时,再顺着继承结构向下(也就是开始第
2.x
相关步骤),由子加载器加载。
这就是所谓的双亲委派机制
。
它避免了类的重复加载,确保类的全局唯一性。而且各层职责分明,也保护了程序安全,防止核心包被篡改。

JVM内存管理
就像家里的柴、米、油、盐、酱、醋、茶要分别放在不同的缸子里那样,JVM也会把运行时需要的数据放在不同的储物罐
里。
JVM的存储空间大概都有这么些:方法区、程序计数器、栈、堆。

JDK 1.8
之前的版本中,JVM的方法区
主要存放从.class
加载进来的类。而在JDK 1.8
之后改为metaspace(元数据空间)
。
JVM之所以知道程序执行到了哪里,运行了几次,又是怎么被引用的,是程序计数器在起作用。它就是用来记录当前执行的字节码指令的位置的。

而由于JVM是支持多线程的,所以每个线程都会有自己的程序计数器。

另外一块存储区域就是栈
。
线程执行某个方法,就会对该方法的调用创建一个对应的
栈帧
。栈帧
里保存这个方法的局部变量表、操作数栈、链接等,可以说是线程自己的王国。
任何方法的调用,都遵循先入栈,再出栈
的方式。


最后,正如之前在多线程的内容中所指出的那样,在JVM堆中存放共享数据、对象实例和数组等引用类型。
JVM栈中的局部变量只是一个引用地址(即指针),指向Java堆内存里实际存储的对象实例的地址。

在知晓JVM的加载机制及各种存储区域后,就可以大致描绘出较为完整的JVM架构轮廓了。
首先,JVM进程启动,类加载器通过双亲委派机制将需要使用的类的字节码加载到JVM中,加载的信息按照JVM规范存放在其不同的内存区域。
然后,执行
main()
方法,并在main线程
中将main()
方法压入JVM栈。执行各个不同方法的时候,依次入栈再出栈。

所有在JVM中创建的对象实例,都是要占用内存资源的,而且内存资源是有限的,不可能无限制地存下去。
所以,当资源不再被使用时,JVM就需要一个机制将资源回收再利用。
这就是JVM的垃圾回收机制。

JVM调优案例
搞清楚了垃圾回收算法、垃圾回收器之后,就可以看看在实际的生产环境中该如何调整JVM参数了。
仍然以前面的支付系统为例,假设年轻代指定的垃圾回收器为ParNew
,老年代指定的垃圾回收器为CMS
。
目前的业务简化后的状况如下。
支付系统遇到节假日大促,DAU达到了
500万
,峰值订单量达到了1000笔/秒
。现有3台计算机,平均每台至少需承接
330笔/秒
的订单量。以保守情况预估,按每台计算机每秒承接500笔
订单的请求量来计算。计算机的配置为
4C8GB
,按JVM × 2 = 物理内存
来计算,那么JVM应分配到4GB
的内存空间。默认情况下,JVM年轻代和老年代的内存大小比例是
1:2
。但为了避免年轻代被很快填满,所以这里将年轻代和老年代的比例调整为1:1
,也就是都设置为1.5GB
。经预估,每笔订单信息输入的数据量大概在
1KB
左右,而连带的订单详情、优惠券、SKU、物流等信息,需要将订单对象的开销放大10 ~ 20倍
,这里按20倍
计算。除此之外,订单系统还会有很多相关的其他操作,比如查询、发消息,所以这些算起来,还要在上一步的基础上再扩大
10倍
的开销。
综合以上信息,整个支付系统的案例背景如下图所示。

因此,初始的JVM GC参数就按如下参数配置。
-Xms3072M -Xmx3072M
-Xmn1536M
-Xss1M
-XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M
因为在JDK 1.6
之后不再需要-XX:HandlePromotionFailure
这个参数,所以就不加了。
按以上估算,支付系统每秒会生成100MB
的数据填充年轻代。

可以预见,照此速度,15秒之后年轻代就会被填满。

如果JVM参数-XX:SurvivorRatio=8
,那么Eden
将只有1.2GB
,会不等到15秒而提前触发Minor GC
。

增加-XX:SurvivorRatio
后,JVM GC参数调整如下。
-Xms3072M -Xmx3072M
-Xmn1536M
-Xss1M
-XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M
-XX:SurvivorRatio=8
增加这个参数的目标就是避免年轻代被填得连Minor GC
的空间都没有了。但调整Survivor
空间时,也要注意几个问题。
有可能会出现
Survivor
空间不足而直接进入老年代的情况。动态年龄判定规则
:全部对象大小之和超过Survivor
空间50%
会直接进入老年代。单次GC之后存活对象大小超过
Survivor
,则直接进入老年代。
因此,基于如上考虑,一方面可以适当调整年轻代的大小,因为普通业务系统的大部分对象生存周期都很短,根本不应该进入老年代,而是要尽量让它们留在年轻代里。另一方面,也可以增加Survivor
空间,根据动态年龄判定规则,增加-XX:TargetSurvivorRatio
参数,让可以长久存活的尽量早点进入老年代,给Survivor
腾出空间处理新对象。

按照上图,JVM GC参数也需要相应调整。
-Xms3072M -Xmx3072M
-Xmn2048M
-Xss1M
-XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M
-XX:SurvivorRatio=8
-XX:TargetSurvivorRatio=30
同时,可以降低进入老年代的年龄门槛限制,给Survivor
腾出更多空间。那么需要给JVM增加-XX:MaxTenuringThreshold
参数了。
-Xms3072M -Xmx3072M
-Xmn2048M
-Xss1M
-XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M
-XX:SurvivorRatio=8
-XX:TargetSurvivorRatio=30
-XX:MaxTenuringThreshold=5
而且可以指定某些超过指定大小的内存对象直接进入老年代,减轻Survivor
压力,所以可以给JVM增加-XX:PretenureSizeThreshold
参数。
-Xms3072M -Xmx3072M
-Xmn2048M
-Xss1M
-XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M
-XX:SurvivorRatio=8
-XX:MaxTenuringThreshold=5
-XX:TargetSurvivorRatio=30
-XX:PretenureSizeThreshold=10MB
之前提过Concurrent Mode Failure
的问题,但这种概率极小,不需要为极小概率事件调整JVM GC设置。
也没有必要修改执行多少次Full GC
之后进行碎片清理,因为经过优化后,Full GC
执行次数大大降低了。而且即使是在大促期间,真正的系统压力峰值时间也是有限的,比如持续2小时可能就结束了。如果JVM能做到500单/秒,大约1小时才触发一次Full GC
,那么峰值过后,JVM的压力就会小很多,就不会再触发Full GC
了。
最后,要记得给年轻代和老年代指定所要使用的垃圾回收器。
-Xms3072M -Xmx3072M
-Xmn2048M
-Xss1M
-XX:MetaspaceSize=256M -XX:MaxMetaspaceSize=256M
-XX:SurvivorRatio=8
-XX:MaxTenuringThreshold=5
-XX:TargetSurvivorRatio=30
-XX:PretenureSizeThreshold=10MB
-XX:+UseParNewGC
-XX:+UseConcMarkSweepGC
以上的JVM GC参数设置,就是根据大促期间的支付系统表现而逐步调整出来的。
当然,这些设置还需要经过实践的检验,不可能一劳永逸,它还需要在业务运行期间不断根据实际需要而进行调整。
Full GC实践案例
之前已经讨论过几种情况下的GC日志,不过都属于小打小闹。下面就弄出个Full GC
看看。
JVM基础参数如下。
-Xms20M -Xmx20M -Xmn10M
-XX:SurvivorRatio=8
-XX:PretenureSizeThreshold=3M
-XX:+UseParNewGC
-XX:+UseConcMarkSweepGC
-verbose:gc
-XX:+PrintGCDetails
-XX:+PrintGCDateStamps
-Xloggc:[日志目录]/gc.log
/**
* 在命令行中执行代码时需要先注释掉package相关的行再用javac命令编译
* 否则会出现"找不到主类"的错误
*
*/
package cn.javabook.chapter04;
/**
* 代码运行基于如下命令(可修改并拷贝后执行)
*
* 注意JVM参数的不同:-XX:PretenureSizeThreshold=3M
*
* cd [xxx.java源代码所在的目录]
* javac ExperimentObjectE.java
* java -Xms20M -Xmx20M -Xmn10M -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=3M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -verbose:gc -Xloggc:[指定目录地址]\gc.log ExperimentObjectE
*/
public class ExperimentObjectE {
public static void main(String[] args) {
byte[] array1 = new byte[4 * 1024 * 1024];
array1 = null;
byte[] array2 = new byte[2 * 1024 * 1024];
byte[] array3 = new byte[2 * 1024 * 1024];
byte[] array4 = new byte[2 * 1024 * 1024];
byte[] array5 = new byte[128 * 1024];
byte[] array6 = new byte[2 * 1024 * 1024];
}
}
代码执行前的内存分配如下图所示。

因为array1
的大小超过了JVM参数-XX:PretenureSizeThreshold
规定的大小,所以就被直接分配到了老年代。

接着分配内存给array2
、array3
、array4
和array5
,此时JVM内存状态如下图所示。

接着给array6
分配空间,此时Eden区
空间不足,触发Minro GC
,GC日志精简后如下图所示。

ParNew (promotion failed): 7299K->8065K(9216K)
表示回收失败,因为array2
、array3
、array4
、array5
、array6
都是强引用,一个都回收不掉,且所需空间之和超过Eden
区的8MB容量。
而当尝试往老年代中存放时,发现老年代也放不下,因为已有一个大小为4MB的弱引用array1
,故进而触发老年代的Major GC
。
[CMS: 8194K->6897K(10240K), ......]
表明老年代空间从GC前的8MB变为GC后的6MB,同时触发元空间的GC(Metaspace GC
)。
从8MB变为6MB的过程如下。
- 先将
array3
和array4
放入老年代。

- 触发
CMS
的Full GC
,回收掉无用的数组array1
。

- 将
array2
和arrya5
再放进去,因此老年代的大小就是array2 + array3 + array4 + array5 = 3 × 2M + 128K
。

- 再将
array6
放到Eden
区。

此时的JVM内存分配状态正是GC日志所显示的那样:Eden
区的28%
被array6
填充,而老年代的6897KB
,正是被array2
、array3
、array4
、array5
及其他存活对象霸占。
可视化工具
jconsole不仅可以让工程师看到线程的的运行信息,而且也能看到关于JVM GC的信息。

除此之外,还有几个比jconsole相对更专业的工具,可以获得更多与GC相关的信息。
想使用jstat
要具备两个条件。
通过
jps
得到正在运行的Java进程的PID
。通过命令选项告诉
jstat
想得到什么结果(选项列表可以用jstat -options得到
)。
至于jstat
的命令参数和-options
中各个选项的意义就不在这里赘述。
jstat -gc [PID]
命令用于查看JVM内存使用和GC概要信息。

因为要顾及显示问题,所以当字体放大以后,界面被压缩了。但还是能清楚地看到jstat -gc
和jstat -gcutil
所列出的数据项。
S0C/S1C
:From/To Servivor区,也就是S0/S1的大小。S0U/S1U
:From/To Servivor区,也就是S0/S1已使用的内存大小。EC/EU
:Eden区大小及其当前使用的内存大小。OC/OU
:老年代及其当前使用的内存大小。MC/MU
:元空间及其当前使用的内存大小。CCSC/CCSU
:类空间及其当前使用的内存大小。YGC
:运行到目前为止Young GC的次数。YGCT
:Young GC的耗时。FGC
:运行到目前为止Full GC的次数。FGCT
:Full GC的耗时。GCT
:所有GC的总耗时。
只不过-gcutil
所列出的信息更粗略而已。其他几个和GC相关的命令如下。
jstat -gccapacity [PID]
:查看整个JVM堆中各代内存大小、对象实例及其使用状况。jstat -gcnew [PID]
:查看年轻代GC状况,TT和MTT分别表示对象在年轻代的存活年龄和最大存活年龄。jstat -gcnewcapacity [PID]
:查看年轻代内存大小、对象实例的信息及其使用状况。jstat -gcold [PID]
:查看老年代GC状况。jstat -gcoldcapacity [PID]
:查看老年代内存大小、对象实例的信息及其使用状况。jstat -gcmetacapacity [PID]
:元空间内存大小、对象实例的信息及其使用状况。
常用jsta -gc [PID] [更新频率(毫秒)] [更新次数]
命令来获得以下信息。
查看年轻代对象增长速率。
推算Minor GC的触发频率和耗时。
推算Minor GC后多少对象存活。
推算Minor GC后有多少对象进入老年代。
查看老年代对象的增长速率。
推算Full GC的触发频率和耗时。
例如,下面的jstat -gc 644 1000 3
命令表示查看GC情况,每秒更新,共3次。

如果发现JVM内存占用量特别大,想知道是哪个对象实例搞的鬼,那么就要轮到jmap
出场了。
通过jmap -heap [PID]
命令,可以知道到底是哪些对象占据了那么多的内存。

从上图可以看出,jmap
命令显示的信息和GC日志中JVM堆内存状况的数据有些类似。
使用jmap
也可以了解系统运行时的对象分布,例如jmap -histo [PID]
命令将结果按照各种对象占用内存空间的大小降序排列,占用内存最多的在最上。

而jhat
命令最好用的地方就是可以通过浏览器的方式看到生成的堆内存快照,不过它需要jmap
的配合。
先用
jmap
生成堆内存快照:jmap -dump:live,format=b,file=C:\dump.hprof 644
。再用
jhat
读取这个快照文件:jhat C:\dump.hprof
。
这两步过程如下图所示。

jhat
默认的访问端口是7000
,在浏览器中打开如下地址即可:http://localhost:7000。

可视化案例实践一
有一个企业内部的自研报表系统,它的JVM配置除了将 -Xms20M -Xmx20M -Xmn10M -XX:PretenureSizeThreshold=10M
改为-Xms200M -Xmx200M -Xmn100M -XX:PretenureSizeThreshold=3M
以外,其运行环境、其他JVM参数配置和前面的Full GC实践案例
保持一致。
这个自研报表系统的核心代码如下。
/**
* 在命令行中执行代码时需要先注释掉package相关的行再用javac命令编译
* 否则会出现"找不到主类"的错误
*
*/
package cn.javabook.chapter04;
import java.util.concurrent.TimeUnit;
/**
* 代码运行基于如下命令(可修改并拷贝后执行)
* cd [xxx.java源代码所在的目录]
* javac ToolsPracticeA.java
* java -Xms200M -Xmx200M -Xmn100M -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=3M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -verbose:gc -Xloggc:[指定目录地址]\gc.log ToolsPracticeA
*/
public class ToolsPracticeA {
private static void loadData() throws Exception {
byte[] data = null;
for (int i = 0; i < 50; i++) {
data = new byte[100 * 1024];
}
data = null;
TimeUnit.MILLISECONDS.sleep(1000);
}
public static void main(String[] args) throws Exception {
// 这里休眠30秒纯粹是为了准备好命令行的相关准备工作,如通过jps查看PID以及输入命令所需的时间
TimeUnit.MILLISECONDS.sleep(30000);
while (true) {
loadData();
}
}
}
从JVM参数可以知道如下事实。
整个JVM堆内存大小200MB。
年轻代100MB,其中Eden区80MB,S0和S1各10MB。
老年代100MB。
使用
ParNew
+CMS
垃圾回收算法。分配的
对象大小 > 3M
时直接进入老年代。年轻代
对象年龄 > 15
时进入老年代。
启动程序运行,通过jps
命令得到PID
,然后使用jstat -gc [PID]
或者jstat -gc [PID] 1000 1000
。

从上图可以看出,Eden区的使用空间从第1秒的7577.5,经过15秒之后逐步升至77588.4,当第16秒再分配对象时,发现已经超过Eden区空间的大小时,触发第1次YGC(也就是Minor GC),此时S1也从0上升至 796.8。
程序继续执行,当经过17次之后,Eden区空间大小又从1805.5飙升至81920.0,于是当第18次再分配对象时,触发第2次YGC,导致S0从0上升至 951.2,而S1则从796.8降至0,Eden区则降至5243.7。

持续观察观察一段时间,会发现每隔10多次,就会触发一次YGC,而年轻代的S0、S1和Eden这三个地方的内存空间,像变魔术一样倒腾来倒腾去,如此循环交替。
到第16次GC时,老年代使用空间OU变为655.7,并且一直稳定在这一数字上下,之后不管再有多少次YGC,其变化也可忽略不计。
随着YGC次数的增多,除了Eden区不停变化外,S0U和S1U也趋向于0。
这一数据变化趋势,通过下面三张图可以很清楚地看出来。这种稳定的数据变化也几乎不会引起Full GC。



可视化案例实践二
另一个企业内部的日志采集组件,它的JVM配置除了将 -Xms20M -Xmx20M -Xmn10M -XX:PretenureSizeThreshold=10M
改为-Xms200M -Xmx200M -Xmn100M -XX:PretenureSizeThreshold=20M
以外,其运行环境、其他JVM参数配置和Full GC实践案例
保持一致。
/**
* 在命令行中执行代码时需要先注释掉package相关的行再用javac命令编译
* 否则会出现"找不到主类"的错误
*
*/
package cn.javabook.chapter04;
import java.util.concurrent.TimeUnit;
/**
* 代码运行基于如下命令(可修改并拷贝后执行)
*
* 注意JVM参数的不同:-XX:PretenureSizeThreshold=20M
*
* cd [xxx.java源代码所在的目录]
* javac ToolsPracticeB.java
* java -Xms200M -Xmx200M -Xmn100M -XX:SurvivorRatio=8 -XX:PretenureSizeThreshold=20M -XX:+UseParNewGC -XX:+UseConcMarkSweepGC -XX:+PrintGCDetails -XX:+PrintGCDateStamps -verbose:gc -Xloggc:[指定目录地址]\gc.log ToolsPracticeB
*
*/
public class ToolsPracticeB {
private static void loadData() throws Exception {
byte[] data = null;
for (int i = 0; i < 4; i++) {
data = new byte[10 * 1024 * 1024];
}
data = null;
byte[] data1 = new byte[10 * 1024 * 1024];
byte[] data2 = new byte[10 * 1024 * 1024];
byte[] data3 = new byte[10 * 1024 * 1024];
data3 = new byte[10 * 1024 * 1024];
TimeUnit.MILLISECONDS.sleep(1000);
}
public static void main(String[] args) throws Exception {
// 这里休眠30秒纯粹是为了准备好命令行的相关准备工作,如通过jps查看PID以及输入命令所需的时间
TimeUnit.MILLISECONDS.sleep(30000);
while (true) {
loadData();
}
}
}
从JVM参数可以知道如下事实。
整个JVM堆内存大小200MB。
年轻代100MB,其中Eden区80MB,S0和S1各10MB。
老年代100MB。
使用
ParNew
+CMS
垃圾回收算法。分配的
对象大小 > 20M
时直接进入老年代。年轻代
对象年龄 > 15
时进入老年代。
启动程序运行,通过jps
命令得到PID
,然后使用jstat -gc [PID]
或者jstat -gc [PID] 1000 1000
。

第1次
YGC
后,就有30MB对象存活,S0和S1都放不下,直接进入老年代,因此OU
是30722.1。每隔1秒就触发1次
YGC
,每次都有10 ~ 20MB存活对象进入老年代。当
YGC
转移过来的存活对象老年代放不下或者空间也快占满时,触发Full GC
,也就是最右边显示的FGC
。Full GC
也频繁触发,基本上几秒钟就触发1次。从第17次
YGC
开始,S0U
=S1U
=0,而且到第146次之后,Full GC
就不再被触发了。

- 可以发现
YGC
耗时比FGC
要慢很多,基本上是10倍左右(YGCT
/FGCT
≈ 10)的差距。这是因为它触发Full GC
后,必须等Full GC
执行完了,老年代有了足够的空间才能继续往里放存活对象。
可以发现虽然到了第146次之后,系统逐渐稳定下来,几乎不再触发FGC
,但初期却抖动
得厉害。且它最大的问题是YGC
后每次存活的对象太多,导致S0或S1空间不足,直接进入老年代,频繁触发Full GC
。
针对这个问题,其实只需要调大年轻代即可,即将-XX:SurvivorRatio=8
调整为-XX:SurvivorRatio=2
,也就是Eden : S0 : S1
= 2 : 1 : 1
,让S0或S1的空间从10MB变为25MB。
在修改了JVM的参数-XX:SurvivorRatio
之后,再次用jstat
命令查看GC情况。

可以明显看出,不管YGC
发生多少次,Full GC
再也没有被触发过。而且S0U
和S1U
也变为了0。
OU
经过15次YGC
后稳定在664.0,其后续微小变化可以忽略不计。
感谢支持
更多内容,请移步《超级个体》。