JVM-GC 过程
JVM-GC 过程
对应代码 https://github.com/liangkang1436/CoreJavaLearn
其实我们可以借助一些 GC 日志分析工具来分析 GC 的过程,比如本地工具gcviewer-1.36.jar,或者可部署在自己的环境的在线工具gcplot.com,在线的第三方的网站怕不安全
但是我们依然需要手动分析一遍,这样我们才能打好扎实的基础。
内存结构
堆内存分为年轻代(Young Generation)和老年代(Old Generation),年轻代分为 Eden Space、From Space(有的也叫 Survivor0)、To Space(有的也叫 Survivor1)如下所示
GC 主要针对的是堆内存区域
准备 GC 日志
设置 JVM 参数:
- -XX:+PrintGC 输出 GC 日志
- -XX:+PrintGCDetails 输出 GC 的详细日志,常用这一种
- -XX:+PrintGCTimeStamps 输出 GC 的时间戳(相对于 JVM 启动之后经过的时间 )
- -XX:+PrintGCDateStamps 输出 GC 的时间戳(绝对时间,以日期的形式,如 2013-05-04T21:53:59.234+0800),常用这一种
- -Xloggc:../logs/gc.log 日志文件的输出路径,常用这一种
- -XX:+PrintHeapAtGC 在进行 GC 的前后打印出堆的信息,主要的作用是直观地看到老年代的使用大小,对于 GC 内存分析非常有用。
非常好用
一般用这四个即可
- -XX:+PrintGCDetails 打印详细的 gc 日志
- -XX:+PrintGCDateStamps 这个参数可以打印出来每次 GC 发生的时间
- -XX:+PrintHeapAtGC 在 GC 前后答应日志,并记录 GC 的次数和 Full GC 的次数
- -Xloggc:/path2logfile/gc.log 这个参数可以设置将 gc 日志写入一个磁盘文件,这里可以用绝对路径,也可以用相对路径,
注意,这个日志文件在每次 JVM 启动的时候都会被重置,即写入的时候是覆盖写入,而不是追加写入,
JavaEE 项目(Tomcat 环境下)
如果是 Tomcat 运行 webapp,则需要在 tomcat 的 bin 目录下找到 catalina.sh 在其中加入配置
JAVA_OPTS="-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:/var/GClog/gc.log"
加入位置在
这两句之上即可
JavaSE 项目
其实还是用那三个参数
IDE 中
在 Run/Debug Configuration 中,在 Modify options 中勾选 Add VM options,这样,我们才能添加 JVM 参数
勾选之后,多出一个 VM options 的输入框
输入参数:-XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC-Xloggc:gc.log
注意gc.log 的路径是项目的工作路径,也是 java -jar 等虚拟机的运行路径,为了让日志在当前模块中自动创建,我们可以把 Run/Debug Configuration 的 Working directory 路径设置成模块路径(默认是项目路径)
然后启动即可。
java -jar
打包成可执行 jar(参考 IDEATips.txt 中创建可执行 jar 的部分),然后执行,
java -jar -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintHeapAtGC -Xloggc:gc.log JVMPart.jar
gc.log 会在 java -jar 的执行路径中被创建。
GC 基础
JVM 中的堆,一般分为两大部分:新生代、老年代
一:新生代
新生代主要是用来存放新生的对象。一般占据堆的 1/2 空间。由于频繁创建对象,所以新生代会频繁触发 MinorGC 进行垃圾回收。
新生代又分为 Eden 区、ServivorFrom、ServivorTo 三个区。
- Eden 区:Java 新对象的出生地(如果新创建的对象占用内存很大,则直接分配到老年代)。当 Eden 区内存不够的时候就会触发 MinorGC,对新生代区进行一次垃圾回收。
- ServivorFrom:上一次 GC 的幸存者,作为这一次 GC 的被扫描者。
- ServivorTo:保留了一次 MinorGC 过程中的幸存者。
MinorGC (又称 young GC)的过程:(配合 -XX:+UseParNewGC 参数)MinorGC 采用复制算法。首先,把 Eden 和 ServivorFrom 区域中存活的对象复制到 ServicorTo 区域(如果有对象的年龄已经达到了老年的标准,则赋值到老年代区),同时把这些对象的年龄 +1(如果 ServicorTo 不够位置了就放到老年区);然后,清空 Eden 和 ServicorFrom 中的对象;最后,ServicorTo 和 ServicorFrom 互换,原 ServicorTo 成为下一次 GC 时的 ServicorFrom 区。
二:老年代
老年代:主要存放应用程序中生命周期长的内存对象。
老年代的对象比较稳定,所以 Major GC(又称 Full GC)不会频繁执行。在进行 Major GC 前一般都先进行了一次 Minor GC,使得有新生代的对象晋身入老年代,导致空间不够用时才触发。当无法找到足够大的连续空间分配给新创建的较大对象时也会提前触发一次 Major GC 进行垃圾回收腾出空间。
Major GC(配合 -XX:+UseConcMarkSweepGC 参数)采用标记—清除算法:首先扫描一次所有老年代,标记出存活的对象(这个标记的过程比较耗时),然后回收没有标记的对象。Major GC 的耗时比较长,因为要扫描再回收。Major GC 会产生内存碎片,为了减少内存损耗,我们一般需要进行合并或者标记出来方便下次直接分配。
当老年代也满了装不下的时候,就会抛出 OOM(Out of Memory)异常。
CMS 算法详细描述
CMS(Concurrent Mark Sweep)收集器是一种以获得最短回收停顿时间为目标的收集器。主要应用在互联网或 BS 系统的服务器上,这类应用尤其重视服务器的响应速度,希望停顿时间最短,以给用户最好的体验。
CMS 是基于标记清除算法实现的,主要分为 4 个步骤:
- 初始标记(CMS initial mark):标记 GC Roots 能直接关联到的对象,速度很快。
- 并发标记(CMS concurrent mark):进行 roots tracing 过程。
- 重新标记(CMS remark):修正并发标记阶段因用户程序继续运作而导致标记产生变动的哪一部分对象的标记记录,这个极端停顿时间比初始标记长。但远比并发标记短。
- 并发清除(CMS concurrent sweep): 回收资源。
上述步骤中,初始标记、重新标记这两个步骤需要停止所有线程。
对象存活判断
- 引用计数:每个对象有一个引用计数属性,新增一个引用时计数加 1,引用释放时计数减 1,计数为 0 时可以回收。此方法简单,无法解决对象相互循环引用的问题
- 可达性分析:从 GC Roots 开始向下搜索,搜索所走过的路径称为引用链。当一个对象到 GC Roots 没有任何引用链相连时,则证明此对象是不可用的,不可达对象
GC Roots 对象包括
- 虚拟机栈 (栈帧中的本地变量表) 中引用的对象
- 方法区中类静态属性引用的对象
- 方法区中常量引用的对象
- 本地方法栈中 JNI(即一般说的 Native 方法) 引用的对象
- 已启动且未停止的 java 线程
其他
Java 8 以前,堆中还有一个永久代。永久代指内存的永久保存区域,主要存放 Class 和 Meta(元数据)的信息,Class 在被加载的时候被放入永久区域. 它和和存放实例的区域不同,GC 不会在主程序运行期对永久区域进行清理。所以这也导致了永久代的区域会随着加载的 Class 的增多而胀满,最终抛出 OOM 异常。
在 Java8 中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。
元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory, 字符串池和类的静态变量放入 java 堆中. 这样可以加载多少类的元数据就不再由 MaxPermSize 控制, 而由系统的实际可用空间来控制.
采用元空间而不用永久代的几点原因:(参考:http://www.cnblogs.com/paddix/p/5309550.html)
- 为了解决永久代的 OOM 问题,元数据和 class 对象存在永久代中,容易出现性能问题和内存溢出。
- 类及方法的信息等比较难确定其大小,因此对于永久代的大小指定比较困难,太小容易出现永久代溢出,太大则容易导致老年代溢出(因为堆空间有限,此消彼长)。
- 永久代会为 GC 带来不必要的复杂度,并且回收效率偏低。
- Oracle 可能会将 HotSpot 与 JRockit 合二为一。
日志分析
Minor GC
准备 JVM 配置
为了更好的查看 log,我们直接在 IDE 中观察 GC,同时为了让虚拟机更容易触发 GC,我们直接设置更小的堆内存
添加以下配置
-XX:InitialHeapSize=10m
-XX:MaxHeapSize=10m
-XX:NewSize=5m
-XX:MaxNewSize=5m
-XX:SurvivorRatio=8
-XX:PretenureSizeThreshold=10m
同时指定垃圾收集器
-XX:+UseParNewGC
-XX:+UseConcMarkSweepGC
最终 JVM 参数为
其中各项配置的意思:
- -XX:InitialHeapSize : 初始堆大小
- -XX:MaxHeapSize : 最大堆大小
- -XX:NewSize : 初始新生代大小
- -XX:MaxNewSize : 最大新生代大小
- -XX:SurvivorRatio :新生代中 Eden 区域和 Survivor 区域(From 幸存区或 To 幸存区)的比例,默认为 8,也就是说 Eden 占新生代的 8/10,From 幸存区和 To 幸存区各占新生代的 1/10
- -XX:PretenureSizeThreshold=10485760 : 指定了大对象阈值是 10MB。超过 10MB 才会直接使用老年代的空间。
- -XX:+UseParNewGC : 使用 ParNew 垃圾收集器,如果不配置的话,默认使用 -XX:+UseParallelGC
- -XX:+UseConcMarkSweepGC :使用 ConcMarkSweep 垃圾收集器,简称 CMS,
- -XX:+PrintGCDetils:打印详细的 gc 日志
- -XX:+PrintGCDateStamps:这个参数可以打印出来每次 GC 发生的时间
- -Xloggc:gc.log:这个参数可以设置将 gc 日志写入一个磁盘文件
-XX:InitialHeapSize、-XX:MaxHeapSize、-XX:NewSize、-XX:MaxNewSize、-XX:SurvivorRatio 这 5 个参数就可以把整个堆的内存都设置好。背下来!!!!!!
public class GCTest {
public static void main(String[] args) throws InterruptedException {
byte[] aArr = new byte[1024 * 1024];
aArr = new byte[1024 * 1024];
aArr = new byte[1024 * 1024];
aArr = null;
byte[] aArr2 = new byte[2*1024 * 1024];
}
}
我们让程序睡 100 秒,然后用 VisualVM 的 Visual GC 插件看堆内存,可以很直观的看到,确实如我们所计算的一样,Eden 区 4m ,survivor0 和 survivor1 都是 512kb,Old Gen 是 5m
我么可以简化为
演示代码
演示代码:
public class GCTest {
public static void main(String[] args) throws InterruptedException {
byte[] aArr = new byte[1024 * 1024];
aArr = new byte[1024 * 1024];
aArr = new byte[1024 * 1024];
aArr = null;
byte[] aArr2 = new byte[2*1024 * 1024];
}
}
第一行:会在 JVM 的 Eden 区内放入一个 1MB 的对象(1024byte 就是 1kb,1024kb 就是 1M),同时在 main 线程的虚拟机栈中会压入一个 main() 方法的栈帧,在 main() 方法的栈帧内部,会有一个“aArr”变量,这个变量是指向堆内存 Eden 区的那个 1MB 的数组,
第二行:此时会在堆内存的 Eden 区中创建第二个数组,并且让局部变量指向第二个数组,然后第一个数组就没人引用了,此时第一个数组就成了没人引用的“垃圾对象”了,如下图所示。
第三行代码:这行代码在堆内存的 Eden 区内创建了第三个数组,同时让 aArr 变量指向了第三个数组,此时前面两个数组都没有人引用了,就都成了垃圾对象,如下图所示。更普遍的产生垃圾的情况是,比如说我调用了一个方法,这个方法里声明了很多变量,在堆中占用了空间,那当这个方法返回,栈帧释放,那这个方法里所有的变量所占的空间就都成了垃圾,下一次 gc 的时候就会回收。
第四行代码:aArr 这个变量什么都不指向了,此时会导致之前创建的 3 个数组全部变成垃圾对象,如下图。
第五行代码:会分配一个 2MB 大小的数组 aArr2,尝试放入 Eden 区中,因为 Eden 区总共就 4MB 大小,而且里面已经放入了 3 个 1MB 的数组了,所以剩余空间只有 1MB 了,此时你放一个 2MB 的数组是放不下的。所以这个时候就会触发年轻代的 Young GC,清理掉 3M 的垃圾空间
Young GC 日志
前三行是配置信息,第一行是 JVM 版本信息,第二行是内存相关信息,第三行是此次运行的 JVM 参数(生效的参数),有的是我们设置的,有的是默认的,(我们设置内存的时候是以 M 为单位,这里换算成了以字节 byte 为单位)
接下来就是一次 Young GC 打印的日志:
- 2022-02-17T08:07:08.534+0800 – GC 发生的时间;
- 0.160 – GC 开始,相对 JVM 启动的相对时间,单位是秒;表示系统运行以后过了多少秒发生了本次 GC
- GC – 区别 Minor GC 和 Full GC 的标识,这里代表的是 Minor GC;
- Allocation Failure – Minor GC 的原因,在这条记录里边,是由于年轻代不满足申请的空间,因此触发了 MinorGC;
- 2022-02-17T08:07:08.534+0800: 0.160 - 意义和前面的时间相同
- ParNew – 收集器的名称,触发的是年轻代的 Young GC,所以是用我们指定的 ParNew 垃圾回收器执行的 GC,它预示了年轻代使用一个并行的 mark-copy stop-the-world 垃圾收集器;
- 3564K->512K – 收集前后年轻代的使用情况;GC 之前使用了 4030KB 了,但是 GC 之后只有 512KB 的对象是存活下来,明明在 Eden 区里就放了 3 个 1MB 的数组,一共是 3MB,也就是 3072KB 的对象,那么 GC 之前年轻代应该是使用了 3072KB 的内存啊,为啥是使用了 3564KB 的内存呢?其实你创建的数组本身虽然是 1MB,但是为了存储这个数组,JVM 内置还会附带一些其他信息,所以每个数组实际占用的内存是大于 1MB 的;除了你自己创建的对象以外,可能还有一些你看不见的对象在 Eden 区里,最终,GC 之前,三个数组和其他一些未知对象加起来,就是占据了 3564K 的内存。实际上回收了 3M 的垃圾之后,Eden 区应该为 0 啊,为什么还有 512K 呢,这 512K 就是 JVM 自身需要使用的一些空间跟对象存储无关,这部分空间大概是 500 多到 600 多 K,至于这些空间里存储的未知对象是什么,后面我们有专门的工具可以分析堆内存快照,现在先略过。
- (4608K) – 整个年轻代的容量;年轻代可用空间是 4608KB,也就是 4.5MB,而不是 5M。Eden 区是 4MB,两个 Survivor 中只有一个是可以放存活对象的,另外一个是必须一致保持空闲的,所以他考虑年轻代的可用空间,就是 Eden+1 个 Survivor 的大小,也就是 4.5MB。
- 0.0026517secs – 这个解释用原滋原味的解释:Duration for the collection w/o final cleanup.
- 3564K->1635K – 收集前后整个堆的使用情况;注意跟前面年轻代的使用情况相比,1635K 比 512K 多出来的,就是老年代的使用情况(这是个很常用的技巧),即老年代使用了 1123K,跟后面的对内存信息对上了。TODO,很奇怪,明明新生代应该已经没有被使用的内存了,为什么还会有内存进入老年代?
- (9728K) – 整个堆的容量;年轻代可用容量 + 老年代可用容量 5M,为 9.5M 正好为 9728K
- 0.0028383 secs – ParNew 收集器标记和复制年轻代活着的对象所花费的时间(包括和老年代通信的开销、对象晋升到老年代时间、垃圾收集周期结束一些最后的清理对象等的花销);
[Times: user=0.78 sys=0.01, real=0.11 secs]
– GC 事件在不同维度的耗时,具体的用英文解释起来更加合理:- user – Total CPU time that was consumed by Garbage Collector threads during this collection CPU 消耗时间
- sys – Time spent in OS calls or waiting for system event 系统等待时间
- real – Clock time for which your application was stopped. (系统暂停时间,距离用户感知最近的时间)With Parallel GC this number should be close to (user time + system time) divided by the number of threads used by the Garbage Collector((user 时间 +sys 时间)/ GC 线程数). In this particular case 8 threads were used. Note that due to some activities not being parallelizable, it always exceeds the ratio by a certain amount.
我们来具体分析一下啊 GC 的过程:GC 之前,三个数组和其他一些未知对象加起来,就是占据了 3564KB 的内存。接着你想要在 Eden 分配一个 2MB 的数组,但是 Eden 只有 4608K-3564KB=1044KB 了,肯定触发了“Allocation Failure“,对象分配失败,就触发了 Young GC 然后 ParNew 执行垃圾回收,回收掉之前我们创建的三个数组,此时因为他们都没人引用了,已经是垃圾对象了,gc 回收之后,从 3564KB 内存使用降低到了 512K 的内存使用,也就是说这次 GC 有 512K 的对象存活了下来,从 Eden 区转移到了 Survivor1 区,叫做 Survivor To 区,另外一个 Survivor 叫做 Survivor From 区,每一次 GC,都是从 Eden 和 Survivor From 区中搜索垃圾,然后将存活的对象复制到 Survivor To 区,接着清空 Eden 和 Survivor From 区,然后 Survivor To 区变成下一次 GC 的 Survivor From 区,当前的 Survivor From 区变成下一次 Survivor To 区。
注意:512K 这个大小非常有意思,这意味着我们什么都不做,年轻代中就会有这么多空间被占用,这是一个常量,在其他情况下也如这么大。
注意,GC 日志只能告诉你 GC 执行结束的那一瞬间虚拟机的状态,后面的状态它不知道**,**所以后面我又新建了一个 2M 的数组,堆里面是什么情况,GC 日志无法显现(在 #JVM-GC 过程#详细版的 GC 日志分析 中的详细版的 GC 日志中可以看到),只能通过上图中后面一段日志分析,后面这段输出,这段日志是在 JVM 退出的时候打印出来的当前堆内存的使用情况,刚好可以看到新建 2M 大小的字节数组之后的情况。
par new generation 表示新生代内存空间 总共 4608K(4.5M),使用了 3746K,survivor from 区 512K 用满了,所以可以计算出 Eden 区使用了 3234K,除以 Eden 区域的总大小 4096K,刚好是 78%。所以新生代的使用情况是:Eden:3234K,survivor from 区 512K,survivor to 区 0K,也就是说,一个 2M 的数组,实际上在内存中占据了 3234K 的内存。原因同上。
concurrent mark-sweep generation: Concurrent Mark-Sweep 垃圾回收器,也就是 CMS 垃圾回收器,后面表示老年代空间,总共 5120k(5M),使用了 1123k,我们在 GC 日志中计算过,GC 结束的时候,老年代就是 1123k,说明我们后面的代码操作(新建 2M 数组),没有增加老年代的大小。
Metaspace 和 class space 就是 JVM内存结构-Java8 中提到的方法区和类信息
详细版的 GC 日志分析
添加 -XX:+PrintHeapAtGC 配置,其作用是在 GC 前后打印堆日志
代码:
public class GCTest {
public static void main(String[] args) throws InterruptedException {
byte[] aArr = new byte[1024 * 1024];
aArr = new byte[1024 * 1024];
aArr = new byte[1024 * 1024];
aArr = null;
byte[] aArr2 = new byte[2*1024 * 1024];
}
}
执行日志:
可以看到在 GC 前后都打印了日志,而且,还记录了 GC 的调用次数和 Full GC 的次数
我们在 JVM-GC过程#Young GC 日志 中对 GC 日志的分析,这里都直观的显示了出来,
GC 前:
Eden 区使用了 3580K,对应 3M 即 3072K 的大小 +508 多 k 的未知空间,其他空间都没有被使用,
GC 后:
这里有我不懂的地方,按理说,新生代和老年代这个时候应该都是 0,
但是看结果,新生代中的 Survivor From 区使用了 512k,老年代使用了 1145K,这些空间都是怎么来的,不懂。TODO
后来,我尝试了一下这个代码:
public class Run0 {
public static void main(String[] args) {
}
}
JVM 配置
发现,
明明啥都没有做,新生代还占用了 1815K 的内存,TODO
新生代进入老年代
对象进入老年代的 4 个常见的时机:
- 躲过 15 次 gc,达到 15 岁高龄之后进入老年代;
- 动态年龄判定规则,一次 GC 后,如果 Survivor 区域内年龄 1+ 年龄 2+ 年龄 3+ 年龄 n 的对象总和大于 Survivor 区的 50%,此时年龄为 n 及以上的对象会进入老年代,不一定要达到 15 岁,也就是说 Survivor TO 的使用率大于 50% 的时候,将年龄大的对象直接移到老年代,保证 Survivor TO 的使用率不大于 50%。
- 如果一次 Young GC 后存活对象太多无法放入 Survivor 区,此时直接计入老年代
- 大对象直接进入老年代
动态年龄判定进入老年代
准备 JVM 配置
新生代通过“-XX:NewSize”设置为 10MB,根据 -XX:SurvivorRatio=8 可得出其中 Eden 区是 8MB,每个 Survivor 区是 1MB,Java 堆总大小是 20MB,老年代是 10MB,
- -XX:+PrintHeapAtGC 在 GC 前后打印日志,并记录 GC 的次数和 Full GC 的次数
- 根据 -XX:PretenureSizeThreshold 配置大对象必须超过 10MB 才会直接使用老年代的空间,
- 同时 "-XX:MaxTenuringThreshold=15" 设置了,只要对象年龄达到 15 岁才会直接进入老年代。
演示代码
public class Run1 {
public static void main(String[] args) {
byte[] aArr = new byte[2 * 1024 * 1024];
aArr = new byte[2 * 1024 * 1024];
aArr = new byte[2 * 1024 * 1024];
aArr = null;
byte[] aArr2 = new byte[128 * 1024];
//在执行这一步之前触发GC
byte[] aArr3 = new byte[2 * 1024 * 1024];
}
}
前五行代码执行完之后(执行 GC 前),大概可以发现,Eden 区已经至少占用了 6M+128K 的内存,但是 Eden 还没有满
从图中可以看出可回收的垃圾是 3 个 2M 的数组
注意,在 JVM 启动的时候,年轻代本身就会有一些空间占用,大概是 500 多到 600 多 K,这个时候再放入一个 2M 的数组,肯定会超过 8M,只能 GC,结果是 128K 和一些默认占用进入全部进入 Survivor To 区,Eden 和 Survivor From 区域清空,然后 Eden 区放入 2M 数组。
PS:这个故事告诉我们,我们应该尽量将 Eden 区域的大小设置的大一点,避免频繁 GC
YoungGC 日志分析
直接看 GC 日志那一行
年轻代总可用空间,9216k 刚好是 9M,即 Eden 区 + 一个 Survivor 区,堆总可用大小是 19456K 也就是 19M
GC 前,
年轻代 7923k,6m+128k(6272k),考虑到堆中一直有 500-600 多 K 的默认占用(1651k),这个误差其实不算大,
GC 后
792K(128k+664K 的默认占用,也就是说,未知空间也被 GC 回收了),差不多,因为老年代根本就没用,所以整个堆 GC 前后的数据跟年轻代 GC 前后的数据一样,这次 GC 之后,还存活的对象全部放入 Survivor to 区,然后反转 Survivor to 区和 Survivor from 区。
之后放入 2M 的数组:
新生代总共使用了 2922K,Survivor from 区中有 GC 后存活的 792K,占用比例是 792/1024 = 77%,那 Eden 去剩下 2922k-792k=2130k, 2130k/8192k= 26%
可以看到老年代根本没用
重点来了:
现在 Survivor From 区里的那 782K 的对象,熬过一次 GC,年龄就会增长 1 岁,他们现在是 1 岁。而且 Survivor 区域总大小是 1MB,此时 Survivor 区域中的存活对象已经有 700KB 了,绝对超过了 50%。根据规则,如果再进行一次 GC,这 782K 的对象会直接进入老年代。(这些对象仍然需要经过这次 GC,实际进入老年代的对象会少于 782K)
增加代码
JVM 配置
代码:
public class Run2 {
public static void main(String[] args) {
byte[] aArr = new byte[2 * 1024 * 1024];
aArr = new byte[2 * 1024 * 1024];
aArr = new byte[2 * 1024 * 1024];
aArr = null;
byte[] aArr2 = new byte[128 * 1024];
byte[] aArr3 = new byte[2 * 1024 * 1024];
// 重复一次,消耗Eden区域
aArr3 = new byte[2 * 1024 * 1024];
aArr3 = new byte[2 * 1024 * 1024];
aArr3 = new byte[128 * 1024];
aArr3 = null;
//在执行这一步之前触发GC
byte[] aArr5 = new byte[2 * 1024 * 1024];
}
}
GC 日志分析
第一次 GC:
第二次 GC:
最终:
第二次 GC 之前:
Eden 区:7142 减去上一次 GC 的结果 756,得出此时 Eden 占用 6386 除以 8192,为 77%
这个时候,Eden 区域已经有 6m+128k 的数组,放不下 2m 的数组了,只能再次 GC,这一次 GC,Eden 区域的 3 个 2M 数组和 128k 的数组都要被回收,同时,Survivor From 区域已经有大于 50% 的空间了(73%,第一次 GC 的结果 756k),经过这次 GC,大概率仍然大于 50%,根据规则,存活下来的对象要进入老年代,
第二次 GC 后:
Eden 区域全部被回收,Survivor From 经过回收,剩余 741K,全部进入老年代,也就是说回收了 15K,
最后看放入 aArr5 之后的堆
Eden 区域空间为 2606k-421k,为 2185k,就是 2M 数组占用的空间,Survivor From 区域为空
老年代 741K,就是第二次 GC 后进入老年代的对象,一直没变。
PS:
从这里,我们可以得知,一个对象实际在堆中的位置,可能是年轻代,也可能是老年代,可能是是在 Eden 中,也可能是在 Survivor From 区中。
另一种场景(大对象直接进入老年代)
JVM 配置:
Java 代码:
public class Run3 {
public static void main(String[] args) {
byte[] aArr = new byte[2 * 1024 * 1024];
aArr = new byte[2 * 1024 * 1024];
aArr = new byte[2 * 1024 * 1024];
byte[] aArr2 = new byte[128 * 1024];
aArr2 = null;
//在执行这一步之前触发GC
byte[] aArr3 = new byte[2 * 1024 * 1024];
}
}
日志分析
GC 之前:
Eden 区总共 8M,aArr 前后占用了 6M,有 4M 是垃圾,aArr2 占用了 128K 的空间,然后不用这个空间了,这 128K 就变成了可回收的垃圾,这个时候,Eden 去已经用了 6M 多了,加上默认的占用空间,剩下的空间绝对不够放得下 2M 的 aArr3,所以只能 GC,这个时候 4M+128K 会被回收,剩下 2M 的 aArr 会进入 Survivor From 区,但是问题来了,Survivor From 区只有 1M,放不下 2M 的 aArr,所以,他只能去老年代。
Eden 区有 6M+128K 的占用,也就是 6272K,日志中记录的是 7923K,误差有点大(1651k),考虑到堆中一致有 500-600 多 K 的默认占用,这个误差其实不算大,跟 4.2.1.3 的 GC 日志一样,
GC 之后,
Eden 区 +Survivor From 区清空,剩下的都是 Survivor To 区的大小,即 Survivor To 只剩下 636K,这 636K,都是 JVM 的默认占用的大小,从 1651k 的空间到 636k 的空间,说明未知空间也经历了垃圾回收,另外我们可以看到老年代为 2050,跟 2M 的 2048K 的大小一致,也就是说 2M 的 aArr 移动到了老年代中。
最后看 JVM 退出的时候的 JVM 参数,
Survivor From 区有 636K,那 Eden 区就有 2850-680=2214K,跟 GC 后放入的 2M 的 aArr3 对上了,老年代还是老样子,2050K
这里注意一个细节,在垃圾回收之后,Eden 区实际上是有 2M 的数组和 636K 的默认占用的,那为什么 636K 的默认占用可以进入 Survivor From 区而不直接进入老年代呢,所以,大对象的标准,应该是这个大对象进入 Survivor From 区之后,Survivor From 区的内存使用又没有到一半,如果到了一半,根据前面介绍过的动态年龄判定规则,这个对象依然会进入老年代,如果超过了 Survivor From 区整体的大小,那就更不用说了,放都放不下只能进入老年代。
同时满足两条规则的情况分析
这一小节要修改,TODO
JVM 配置:
执行代码为:
public class Run4 {
public static void main(String[] args) {
byte[] aArr = new byte[2 * 1024 * 1024];
aArr = new byte[2 * 1024 * 1024];
aArr = new byte[2 * 1024 * 1024];
aArr = null;
byte[] aArr2 = new byte[128 * 1024];
byte[] aArr3 = new byte[2 * 1024 * 1024];
//重复一次,消耗Eden区域
aArr3 = new byte[2 * 1024 * 1024];
aArr3 = new byte[2 * 1024 * 1024];
byte[] aArr4 = new byte[128 * 1024];
//在执行这一步之前触发GC
byte[] aArr5 = new byte[2 * 1024 * 1024];
}
}
日志分析
第一次 GC:
第二次 GC:
最终:
第二次 GC 之前,内存为下图这样的结构
第二次 GC 之前
Survivor From 区域的大小看上一次 GC 的结果,也就是 800k,也就是说,Survivor From 区的未知空间是:672k
Eden 区的大小是 7102k-800k = 6302k,减去 2M*3+128k = 6272k,未知空间是,30k
从图中可以看出,可以看到,等待回收的有 2 个 2M 数组和 672k 的未知空间和 30k 的未知空间。
这个时候如果再次新建一个 2M 的数组,Eden 空间不够了,那么只能触发 GC,同时 aArr3 因为太大无法放入 Survivor To 区,应该也放入老年代,aArr4 为 128K 可以放入 Survivor To 区,原来的 Survivor from 区域中已经有 800k 的对象了,而且的年龄为 2,第二次 GC 之后,仍然占比超过 50%,所以根据规则,也要进入老年代。
GC 后
128k 进入 Survivor From 区,这应该是指代 aArr4,然后有 2815k 进入老年代,其中,肯定包括 aArr3 的 2M 的空间,原 Survivor From 区的 800k,加起来是 2848,说明,Survivor From 区在进入老年代之前,被回收了 33k 的空间。
同时满足两条以上规则的情况分析
注意,同一个代码运行多次,堆内存的使用情况不会完全相同,即有误差,
比如分析同时满足两条规则的时候使用的 GC 日志重新跑了一次,
第二次 GC 的部分
就会发现第二次 GC 的时候年轻代的内存是 231k,但是无论我跑多少次,第二次 GC 后的年轻代内存没有低于 128 的,所以我的判断应该是正确的。即:第二次 GC 之后,年轻代只剩下了 Survivor From 区中的一个 128k 的数组。
同时满足两条以上规则的时候,实际内存和对象大小之间的误差会更大,这里就不多分析了
Minor GC 总结
实际的 Minor GC 过程(使用 UseParNewGC)是:先是用 Eden 区的内存,用完了就要 Minor GC,回收垃圾,回收的过程很简单,扫一遍 Eden 区和 Survivor From 区,把还在被使用的空间复制到 Survivor TO 区,然后清空 Eden 区和 Survivor From 区,然后 Survivor TO 区变成 Survivor From 区,在把还在被使用的空间复制到 Survivor TO 区的时候,会有两个问题,第一,Eden 区存活下来的对象太大了,Survivor TO 区放不下,解决方法是直接放到老年代,另外如果 Survivor TO 区发现空间复制进来后超过了 Survivor TO 区大小的一半,那年龄最大的那一批对象直接进入老年代,这是为了保证 Survivor TO 区一直都有空间,这样来来回回 GC,如果一个对象经过了 15 次 GC(-XX:MaxTenuringThreshold 配置),那这个对象应该就是一个长期使用的对象,没有必要留在新生代增加 Minor GC 的工作量,放到老年代比较合适。
Survivor 区就像是从 Eden 区进入老年代的中间地带,是一个的缓冲区,筛选的地带,只有符合某种条件,Survivor 区的对象才能进入老年代,避免老年代被迅速占满,新生代中大部分的空间在被临对象占用的,这些对象占用的空间会在反复的 GC 过程中不断被回收,不会进入老年代,老年代的特点是 GC 的频率不高,其中的对象都很稳定,
Full GC/Major GC
常规 Full GC
JVM 配置:
其中我们把 -XX:PretenureSizeThreshold 配置修改为了 3M,意思是超过 3MB 的内存分配会直接使用老年代的空间。
测试代码:
public class OOMRun1 {
public static void main(String[] args) {
byte[] aArr1 = new byte[4 * 1024 * 1024];
aArr1 = null;
byte[] aArr2 = new byte[2 * 1024 * 1024];
byte[] aArr3 = new byte[2 * 1024 * 1024];
byte[] aArr4 = new byte[2 * 1024 * 1024];
byte[] aArr5 = new byte[128 * 1024];
byte[] aArr6 = new byte[2 * 1024 * 1024];
}
}
GC 日志分析
Minor GC 之前
老年代已经使用了 4096,因为 aArr1 为 4M,超过了 -XX:PretenureSizeThreshold 的大小,所以直接就在老年代中开辟空间存储,
年轻代中,Eden 区占用了 7923k,应该就是 aArr2、aArr3、aArr4、aArr5 占用的内存,6M+128k,为 6272k,未知空间为 1651k
如图:
可回收的空间就是老年代的 aArr1:4M
Minor GC
原理上来说,应该先放入 Survivor From 区,但是放不下,只能往老年代放,但是老年代至少要存放 6M 的空间,老年代放不下,promotion failed:表示从年轻代往老年代迁移内存失败,所以,只能老年代先 GC 一下。
注意,这个时候,Minor GC 居然让年轻代增加了一些内存,从 7923 到 8713,增加了 790K,我估计是 GC 本身占用的内存,因为内存被中断,所以留在了年轻代中。TODO
Major GC 前
每一个 Major GC 前都会有一个 Minor GC,其实应该反过来说,就是因为 Minor GC 无法往老年代迁移内存了,所以才需要 Major GC。同时还触发了一次元数据区的 GC。
年轻代中的对象已经复制了一些到老年代中:
PS:此时因为没有日志,所以不清楚新生代各区的内存情况
Major GC 后:
CMS 回收的总大小为 8M 空间,回收完之后,继续把年轻代中还没来得及迁移过来的对象迁移过来,加上之前已经迁移的总共(7923k),所以 CMS 之后,老年代中有 6893k,这中间少的大小,就是未知空间中被回收的大小。
老年代已满,年轻代未满
JVM 配置
代码:
public class OOMRun2 {
public static void main(String[] args) {
byte[] aArr1 = new byte[4 * 1024 * 1024];
byte[] aArr2 = new byte[2 * 1024 * 1024];
byte[] aArr3 = new byte[2 * 1024 * 1024];
byte[] aArr4 = new byte[2 * 1024 * 1024];
byte[] aArr5 = new byte[128 * 1024];
byte[] aArr6 = new byte[2 * 1024 * 1024];
}
}
GC 日志:
Minor GC 之前
老年代就已经有了 4096k 的占用,前面介绍过,就是 aArr1 占用的空间
年轻代,Eden 区占用了 7923k,应该就是 aArr2、aArr3、aArr4、aArr5 占用的内存,6M+128k,为 6272k,未知空间为 1651k
如图:
注意,此时没有任何可以回收的空间,
Minor GC 后
原理上来说,应该先放入 Survivor From 区,但是放不下,只能往老年代放,但是老年代至少要存放 6M 的空间,老年代放不下,promotion failed:表示从年轻代往老年代迁移内存失败,所以,只能老年代先 GC 一下。
注意,这个时候,Minor GC 居然让年轻代增加了一些内存,从 7923 到 8721,增加了 798k,我估计是 GC 本身占用的内存,因为内存被中断,所以留在了年轻代中。TODO
Major GC 前
年轻代中的对象已经复制了一些到老年代中:
PS:此时因为没有日志,所以不清楚新生代各区的内存情况(大概率是没动)
Major GC 后:
CMS 回收的总大小为 8M 空间,但是,没有任何可回收的空间,所以,就什么都没有做。老年代的空间没有任何变化,新生代的空间回到正常值,但是 Eden 区没有被回收。
即因为老年代已满,无法收下所有年轻代转移过来的对象,所以有一部分应该转移的对象依然留在年轻代,此时不会报错。
老年代和年轻代都满了
JVM 配置
代码:
public class OOMRun3 {
public static void main(String[] args) {
byte[] aArr1 = new byte[4 * 1024 * 1024];
byte[] aArr2 = new byte[2 * 1024 * 1024];
byte[] aArr3 = new byte[2 * 1024 * 1024];
byte[] aArr4 = new byte[2 * 1024 * 1024];
byte[] aArr5 = new byte[128 * 1024];
byte[] aArr6 = new byte[2 * 1024 * 1024];
byte[] aArr7 = new byte[2 * 1024 * 1024];
byte[] aArr8 = new byte[2 * 1024 * 1024];
}
}
控制台报错信息:
GC 日志:总共发生了三次 GC
第一次:
第二次:
第三次:
最终报错:
最终的堆信息
我们直接从第一次 GC 的结尾说起,就是 new aArr6 那里
第一次 GC 结束:新生代使用了 2793K,老年代使用了 8192K,且没有任何垃圾可以回收
第二次 Major GC 前:就是 new aArr7 那里
使用了 7053k,其中除了两个 2M 的数组,和一个 128k 的数组,其余的都是可回收的未知空间
第二次 Major GC 后:
这一次 GC 其实跟第一次没啥区别,老年代已经满了,整个堆回收不了什么空间了,GC 的过程相当于什么都没做,然后,这个空间只能从 Eden 区中开辟,这次 GC 结束后,新生代已经使用了 6885K
然后依然无法放下 2M 的 aArr7,然后直接触发第三次 GC
第三次 Full GC,
这一次,什么垃圾都没有回收上来,
最终报错,这个错我也看不懂,最奇怪的是,堆内存的最终,把老年代清空了,也是不明白到底是什么操作,TODO
关于 Full GC 的详细分析过程,看《JVM full-GC 分析》,有点难,TODO
此外,根据日志的显示,aArr7 应该是没有成功进入堆中的,但是如果去掉
这一行就不会报错,即 aArr7 放得下去。这是为什么 TODO
总的来说,老年代满了,年轻代也满了,再也拿不出空间存放对象了,就会报错:OutOfMemoryError,即内存溢出。
总结
然后再仔细分析分析 JVM full-GC分析
尝试分析一下,TODO
常用的 GC 算法
堆年轻代和年老代的垃圾收集器组合(以下配合 java8 完美支持,其他版本可能稍有不同),其中标红线的则是我们今天要着重讲的内容:
ParNew and CMS
"Concurrent Mark and Sweep" 是 CMS 的全称,官方给予的名称是:“Mostly Concurrent Mark and Sweep Garbage Collector”;
年轻代:采用 stop-the-world mark-copy 算法;
年老代:采用 Mostly Concurrent mark-sweep 算法;
设计目标:年老代收集的时候避免长时间的暂停;
能够达成该目标主要因为以下两个原因:
1 它不会花时间整理压缩年老代,而是维护了一个叫做 free-lists 的数据结构,该数据结构用来管理那些回收再利用的内存空间;
2 mark-sweep 分为多个阶段,其中一大部分阶段 GC 的工作是和 Application threads 的工作同时进行的(当然,gc 线程会和用户线程竞争 CPU 的时间),默认的 GC 的工作线程为你服务器物理 CPU 核数的 1/4;
补充:当你的服务器是多核同时你的目标是低延时,那该 GC 的搭配则是你的不二选择
具体的概念和其他的 GC 算法,看 Java8 的 GC 方式