详解JVM垃圾收集器和内存分配策略

JVM的内存结构模型由方法区、堆、虚拟机栈、本地方法区和程序计数器五个部分组成。虚拟机栈、本地方法和程序计数器是线程私有的,随着方法或线程的结束,对应的内存也被回收了,而Java虚拟机规范也指出可不对方法区(jdk8叫元空间)做垃圾收集,因而可以说垃圾收集主要关注的是堆空间。

对象是否需要被回收

内存回收前需要先确定那些对象已经“死亡”(没有被其他对象引用)。判断对象的存活方式有2中,分别是“引用计数算法”和“可达性分析算法”。

引用计数算法的原理跟它的名字一样明了,通过在对象中维护一个计数器判断对象是否被使用。当有一个地方引用它,则计数器+1,当引用失效,则计数器-1,任何时刻计数器值为0的对象则是不可被使用的。该算法实现简单,效率也高,但无法处理对象之间的相互循环引用的问题。

public class ReferenceCounterGC {
    public Object instance = null;
    private static final int SIZE= 1024 * 1024;
    //占点内存
    private byte[] socket = new byte[2 * SIZE];
    public static void main(String[] args) {
        ReferenceCounterGC objA = new ReferenceCounterGC();
        ReferenceCounterGC objB = new ReferenceCounterGC();
        objA.instance = objB;
        objB.instance = objA;
        objA = null;
        objB = null;
        System.gc();  //手动GC
    }
}

通过GC日志发现,内存还是被回收了,可见JVM用的并非引用计数算法。

可达性分析算法,也叫根搜索算法。通过一些“GCRoots”对象做为起点,向下搜索对象,走过的路径称为“引用链”。当一个对象到达“GC Roots”没有任何引用链的时候,表明该对象不可用,可被回收。JVM规定可作为“GC Roots”对象的包括:虚拟机栈中的引用对象、方法区中类静态属性引用的对象,方法区中常量引用的对象,Native方法引用的对象。

对象逃脱

但事实上当一个对象不存在引用链时候,也不一定就会被回收。判断对象真正“死亡”还需要经过标记。

第一次标记会进行筛选,判断对象是否有必要执行finalize(),若对象没有覆盖finalize()或者finalize()方法已经被调用过,则被视作没有必要,对象会被直接回收。若有必要执行,该对象会被放置在一个F-Queue队列里,由一条低优先级的线程去执行finalize(),再回收内存。若此时对象获得新的引用链,则可逃脱被清除的命运,不过运行代价高,不确定性比较大,所以一般不推荐这种方法拯救对象。

关于引用

垃圾收集算法判定对象的存活其实是跟“引用”有关,一个对象存在被引用和没有被引用这两种状态。若对象没有存在引用状态,此时JVM的内存还很充足,是可以考虑保留对象在内存之中,等到内存不足时再回收。jdk通过定义不同等级的引用对这类“食之无味,弃之可惜”的对象进行划分。

不同等级的引用包含:强引用、软引用、弱引用、虚引用(也叫幻像引用),关于他们的介绍网上资料很多,这里就不再累述了,主要区别在于对象存活生命周期的不同。

内存分配策略

Java自动内存管理归结到底就两点:给对象分配内存以及回收对象的内存。内存区域分为新生代和老年代,新生代包括1个Eden区和2个Survivor。运行的时候可以通过添加对应参数分配指定内存空间,举个例子:

-verbose:gc -Xms20m -Xmx20m -Xmn10m -XX:+PrintGCDetails -XX:SurvivorRatio=8

这里限制了Java堆大小为20M,且不可扩展,10M分配给新生代,剩余10M给老年代,Eden区和Survivor的空间比例是8:1。但整个新生代可用的内存空间其实只有9M,因为新生代采用的是复制算法,需要保留一个Survivor作为轮换。

对象优先分配在Eden,大对象(很长的字符串以及数组)直接进入老年代。新生代连续的内存空间不足以存储对象时会触发Minor GC,老年代同理则会出发Full GC。出现Full GC会至少伴随一次Minor GC且速度比Minor GC慢10倍,因而应该尽量避免Full GC,减少Minor GC。但Minor GC时也是有可能会引发Full GC,因为存在内存空间分配担保。

新生代中的对象熬过一次Minor GC,年龄就会+1,当年龄大于一定程度(默认是15)就会进入老年代,可通过-XX:MaxTenuringThreshold设置。但也有特殊情况,当Surivor空间中相同年龄所有对象大小总和大于Surivor空间的一半,年龄大于等于该年龄的对象可以直接进入老年代,无须受到限制。

虚拟机也提供了参数 -XX:PretenureSizeThreshold,设置老年代分配的阈值,当对象大于该值则直接分配在老年代,可避免在Eden区和两个Survivor区之间发生大量的内存复制。

几款垃圾收集器
1. Serial收集器

Serial是最基本、单线程的收集器。所谓单线程,并不仅指使用一个CPU和一条线程去执行垃圾收集工作,也是指执行垃圾收集工作时候会暂停其他所有的线程,直到收集工作结束,即所谓的STW(stop the world),新生代使用复制算法,老年代使用标记-整理算法。

2. ParNew收集器

ParNew收集器是Serial收集器的多线程版,除了使用多条线程进行垃圾收集外,其他都跟Serial一样。默认开启的收集线程数跟CPU的数量相同,因此单CPU的情况下ParNew没有比Serial收集器的效果好。特点是目前只有它能与CMS收集器配合使用

3. Parallel Scavenge收集器

Parallel Scavenge是一个新生代的收集器,也是使用复制算法,同时也是并行的多线程收集器。该收集器的主要目标是达到可控的吞吐量,吞吐量 = 代码运行时间 / (代码运行时间 + 垃圾收集时间)。Parallel Scavenge提供了 -XX:MaxGPauseMills和-XX:GCTimeRation两个参数用来控制吞吐量。

Parallel Scavenge收集器也有一个老年代版本的收集器“Parallel Old”,使用多线程的“标记-整理”算法,

4. CMS收集器

CMS以降低系统停顿时间为目标,基于“标记-清楚”算法实现的一款收集器。运作过程主要包含4步,初始标记、并发标记、重新标记、并发清除。初始标记和重新标记依然需要STW。初始标记是标记GC Root直接关联的对象,速度很快。重新标记则是为了修正并发标记阶段产生变动的那一部分对象标记记录。而耗时较长的并发标记和并发清除可跟用户线程一起并发执行。

CMS默认开启的回收线程数是(CPU数量+3)/4,因而CMS相对来说对CPU的资源会比较敏感。当CPU的数量较少时,JVM还会产生“增量式并发收集器”,使得回收线程和用户线程之间是抢占关系,避免回收线程一家独大,独占资源。不过这种收集器好像效果一般,已不推荐使用。

除此之外,CMS也存在着无法清除浮动垃圾(并发清除阶段用户线程又产生的垃圾),存在空间碎片过多的问题,会导致老年代对象分配的时候有可能连续的内存空间不足而触发Full GC。

5. G1收集器

G1收集器可看做是CMS收集器的改良版,保留了CMS的优良特性,解决了CMS的弊端。G1充分利用CPU、多核环境缩短STW的时间;保留了以往收集器分代的概念;基于“标记-整理”算法实现,运作期间不会产生内存空间,因而解决了内存空间碎片导致提前触发GC的问题;可预测的停顿,运行指定一定时间内垃圾收集消耗的时间。

G1收集器运作包括4个阶段,初始标记、并发标记、最终标记、刷选回收。前面三步跟CMS收集器类似:初始标记标记GC Root能直接关联的对象;并发标记是根据GC Root进行可达性分析,找出存活的对象,这个阶段耗时长,但可跟用户程序并发执行;最终标记是修正并发标记过程中发生变化的对象;刷选回收则是根据用户指定的停顿时间等参数制定执行回收计划。

6. ZGC收集器

jdk11添加的全新垃圾收集器,据说能支持TB级别的内存容量,还没仔细了解,忏愧,略过…

理解GC日志

最后以JDK1.8版本为例子说一说如何理解GC日志:

//这里是GC类型,Full GC表明这次GC发生了STW
//方括号内6115K->728K(9216K),含义:GC前该区域使用情况->GC后该区域使用情况(该区域总容量)
//方括号外6115K->736K(19456K),含义:GC前Java堆已使用容量->GC后Java堆已使用容量(Java堆总容量)
//0.0039696 secs表示该区域GC占用时间,单位是秒
[GC (System.gc()) [PSYoungGen: 6115K->728K(9216K)] 6115K->736K(19456K), 0.0039696 secs] [Times: user=0.01 sys=0.00, real=0.00 secs] 
[Full GC (System.gc()) [PSYoungGen: 728K->0K(9216K)] [ParOldGen: 8K->623K(10240K)] 736K->623K(19456K), [Metaspace: 3426K->3426K(1056768K)], 0.0083509 secs] [Times: user=0.00 sys=0.00, real=0.01 secs] 
Heap
//新生代,包括eden和survivor的大小和使用情况
PSYoungGen      total 9216K, used 166K [0x00000000ff600000, 0x0000000100000000, 0x0000000100000000)
eden space 8192K, 2% used [0x00000000ff600000,0x00000000ff629998,0x00000000ffe00000)
from space 1024K, 0% used [0x00000000ffe00000,0x00000000ffe00000,0x00000000fff00000)
to   space 1024K, 0% used [0x00000000fff00000,0x00000000fff00000,0x0000000100000000)
//老年代内存使用信息
ParOldGen       total 10240K, used 623K [0x00000000fec00000, 0x00000000ff600000, 0x00000000ff600000)
object space 10240K, 6% used [0x00000000fec00000,0x00000000fec9bcc0,0x00000000ff600000)
//元空间使用信息
Metaspace       used 3441K, capacity 4496K, committed 4864K, reserved 1056768K
class space    used 375K, capacity 388K, committed 512K, reserved 1048576K

以上便是对JVM垃圾收集器与内存分配策略做的一个总结,学而时习之,不亦乐乎。很高兴你能看到了这里,如果觉得文章不错或对内容有疑问,欢迎留言/私信/点赞交流。

猜你喜欢

转载自blog.csdn.net/hsf15768615284/article/details/102881495
今日推荐