JVM-GC 过程

JVM-GC 过程

对应代码 https://github.com/liangkang1436/CoreJavaLearn
其实我们可以借助一些 GC 日志分析工具来分析 GC 的过程,比如本地工具gcviewer-1.36.jar,或者可部署在自己的环境的在线工具gcplot.com,在线的第三方的网站怕不安全
但是我们依然需要手动分析一遍,这样我们才能打好扎实的基础。

内存结构

1000
堆内存分为年轻代(Young Generation)和老年代(Old Generation),年轻代分为 Eden Space、From Space(有的也叫 Survivor0)、To Space(有的也叫 Survivor1)如下所示

GC 主要针对的是堆内存区域

准备 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 参数
1000
勾选之后,多出一个 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 三个区。

CMS 算法详细描述
CMS(Concurrent Mark Sweep)收集器是一种以获得最短回收停顿时间为目标的收集器。主要应用在互联网或 BS 系统的服务器上,这类应用尤其重视服务器的响应速度,希望停顿时间最短,以给用户最好的体验。
CMS 是基于标记清除算法实现的,主要分为 4 个步骤:

对象存活判断

GC Roots 对象包括

其他

Java 8 以前,堆中还有一个永久代。永久代指内存的永久保存区域,主要存放 Class 和 Meta(元数据)的信息,Class 在被加载的时候被放入永久区域. 它和和存放实例的区域不同,GC 不会在主程序运行期对永久区域进行清理。所以这也导致了永久代的区域会随着加载的 Class 的增多而胀满,最终抛出 OOM 异常。
在 Java8 中,永久代已经被移除,被一个称为“元数据区”(元空间)的区域所取代。
元空间的本质和永久代类似,都是对 JVM 规范中方法区的实现。不过元空间与永久代之间最大的区别在于:元空间并不在虚拟机中,而是使用本地内存。因此,默认情况下,元空间的大小仅受本地内存限制。类的元数据放入 native memory, 字符串池和类的静态变量放入 java 堆中. 这样可以加载多少类的元数据就不再由 MaxPermSize 控制, 而由系统的实际可用空间来控制.
采用元空间而不用永久代的几点原因:(参考:http://www.cnblogs.com/paddix/p/5309550.html)

日志分析

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 参数为

其中各项配置的意思:

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 打印的日志:
1000

我们来具体分析一下啊 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 个常见的时机:

动态年龄判定进入老年代

准备 JVM 配置


新生代通过“-XX:NewSize”设置为 10MB,根据 -XX:SurvivorRatio=8 可得出其中 Eden 区是 8MB,每个 Survivor 区是 1MB,Java 堆总大小是 20MB,老年代是 10MB,

演示代码
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 方式