JVM笔记(1.2)垃圾收集器和内存分配策略

369 阅读17分钟

垃圾收集器(GC)的作用相信大家都知道,它将我们的不用的内存空间给回收,Java的垃圾收集器是"动态分配内存和垃圾收集"的。正因为它是动态的,所以很多人都忽略了它,但当出现一些内存泄漏、内存溢出的问题时,我们必须掌握JVM才能去解决问题

现在,我们从GC设计者的角度来看它需要完成哪些工作:

  1. 哪些内存需要回收
  2. 什么时候回收
  3. 如何回收

对于第一个问题:

上文中,我们说到程序计数器、虚拟机栈、本地方法栈这3个区域随线程生,随线程死

栈中的栈帧随着方法的进入和退出而出栈、入栈,每个栈帧分配多少内存基本在类结构确定时就是已知的(不包括JIT的优化)

而Java堆和方法区只在程序运行期间才会知道开辟的空间(),这部分内存分配和回收是动态的,所以垃圾收集器关注的这部分内存

对于第二个问题

当一段内存不再使用(不处于存活状态)时就回收,下文会谈到哪些内存将不再使用

对于第三个问题

这就是我们下文要讲到的各种回收机制

判断对象是否'存活'

首先,来看堆,堆中存放了几乎所有的实例对象,在对堆进行回收内存时,要先判断哪些对象能被回收(存活)。

引用计数法

每当有一个地方引用它,计数器+1,每当一个引用失效,计数器-1;任何时刻计数器为0的对象是不能被使用的。

存在的问题

这种分析虽然简单,但有一个问题,如循环引用:

public class A {
    Object obj;
    
    public void testGC(){
        A a1 = new A();
        A a2 = new A();
        a1.obj = a2;
        a2.obj = a1;
        a1 = null;
        a2 = null;
        System.gc();//如果采用引用记数法则不回收
    }
}

可达性分析算法

所以,我们需要一种更全面的回收机制

思路:

通过一系列被称为“GC Roots”的对象作为起点,从这些节点开始往下搜索,搜所走过的路径成为引用链,当一个对象到GC Roots没有任何引用链相连,则证明此对象是不可用的

image

可作为GC Roots的对象:

  • 虚拟机栈中引用的对象
  • 方法去中类静态属性的对象
  • 方法区中常量引用的对象
  • 本地方法栈中JNI(native方法)引用的对象

引用

垃圾收集器判断对象存活都和引用有关,下面来看引用有哪些类型

  • 强引用:普遍存在,如Object obj = new Object();只要引用在,就不会回收
  • 软引用:jdk1.2之后,SoftReference类实现软引用。在系统发出内存溢出之前,会把这些对象二次回收,若还不够,抛出异常。
  • 弱引用:jdk1.2之后,WeakReference来实现弱引用。只能生存到下一次垃圾收集发生之前
  • 虚引用:jdk1.2之后,PhantomReference类实现虚引用。这个对象被系统回收时收到一个通知

生存还是死亡

一个对象要被宣告死亡,要经历两部:

  • 如果对象进行可达分析后没有和GC Roots相连,那她将会被第一次标记并且进行一次筛选,如果有finalize()方法则放置在F-Queue队列中执行finalize()方法,(不保证它有运行结果)
  • 如果是第二次被标记并且没有引用,那就只有被回收了

一个对象的finalize()方法只会被执行一次

在Java9中,finalize()方法已被弃用,原因如下:

  • finalize机制可能会导致性能问题,死锁和线程挂起。
  • finalize中的错误可能导致内存泄漏;如果不在需要时,也没有办法取消垃圾回收;并且没有指定不同执行finalize对象的执行顺序。
  • 没有办法保证finlize的执行时间。

回收方法区

永久代的方法区分为两部分:

  • 废弃常量(如String常量)
  • 无用的类(同时满足以下三种为无用的类)
    • 该类所有实例都已经被回收,Java堆中不存在该类的任何实例
    • 加载该类的ClassLoader已经被回收
    • 对应的java.lang.Class对象没有在任何地方被引用,无法在任何地方通过反射访问

垃圾收集算法

知道了要回收哪些东西,我们还要知道如何回收,下面来看一下典型的垃圾回收算法

标记-清除算法

分为标记清除两个阶段:首先标记出所有需要回收的对象,在标记完成后同意收回被标记的对象

image

不足:
  • 效率:标记和清除两个过程效率都不高
  • 空间:标记清除后会产生大量不连续的内存碎片(这会导致若有大对象但找不到连续内存时必须再触发一次垃圾收集)

复制算法

他将内存分为两块,每次只使用其中一块。当一块内存用完后,就将还存活的对象复制到另一块上面,再将已使用的内存一次清理掉。

image

但是,我们一般不将它对半分,而是分为一块较大的Eden和两块较小的Survivor区域,HotSpot默认Eden:Survivor比例大小为8:1,即Eden为收集前的空间,一块Survivor为收集后的大小,只浪费了10%的空间。

注:当每次回收有大于10%的对象存活时,通过分配担保机制让Survivor中剩余存不下的进入老年代

标记-整理算法

标记过程和标记-清除算法一样,清理之前,让所有存活的对象都向一端移动,然后直接清理掉边界以外的内存。

image

分带收集算法

根据对象存活周期的不同划分为几块,一般为新生代和老年代

  • 新生代中,有大量对象死去,用复制算法
  • 老年代中,存活率高,必须使用标记-清理或者标记-整理算法来回收

HotSpot算法实现

以上为理论的垃圾收集算法,实际如HotSpot虚拟机会对算法有严格的考量。。。

枚举根节点

时间消耗:
  • 查找GC Roots节点
  • GC停顿,整个分析期间整个执行系统就像被冻结在某个时间点上,因为查找时不能出现分析时对象过程稿还在不停变化的情况

OopMap:虚拟机用它来得知哪些地方存放着对象引用,在类加载完成后,HotSpot就把对象内什么偏移量上是什么类型的数据计算出来,在JIT编译过程中,也会在特定位置记录栈和寄存器中哪些位置是引用

安全点

程序在安全点才能暂停执行GC,所以安全点一般选定为“是否具有让程序长时间执行的特征”(如方法调用,循环跳转,异常跳转),前文“特定位置”就被称为安全点。

如何让所有线程都跑到最近安全点上停下来:

  • 抢先式中断:把所有线程中断,如果有中断线程不在安全点上,恢复线程,让它跑回安全点上(几乎没有了)
  • 主动式中断:设置一个标志,让各个线程去轮询这个标志,发现中断标志为真时就自己中断挂起,轮询标志的地方和安全点重合。

安全区域(Safe Region)

安全点保证了程序执行时在不太长的时间内就会遇到可进入的GC的安全点,例如线程处于SLeep或Blocked状态,这时线程无法响应JVM中断请求,这种情况,就需要安全区域来解决。

安全区域是指在一段代码中,引用关系不会发生变化。

当线程执行到了安全区域中的代码,标识自己进入了Safe Region,挡在这段时间里JVM要发起GC时,就不用管标识自己为Safe Region状态的线程了。在线程要离开Safe Region时,去检查是否完成根节点枚举,如果完成,线程继续执行;否则它就必须等待直到收到可以安全离开Safe Region的信号为止。

垃圾收集器

可以理解为收集算法是内存回收的方法论,垃圾收集器就是内存回收的具体实现

image

Serial收集器

特点:

  • 单线程收集器:在他进行垃圾收集时,必须暂停其他工作线程,直到它收集结束

image

ParNew收集器

特点:

  • Serial收集器的多线程版本,除了Serial收集器外,只有它能和CMS收集器合作
    image

Parallel Scavenge收集器

特点:

  • Parallel Scavenge收集器是一个新生代收集器,它也是使用复制算法的收集器,又是并行的多线程收集器
  • 该收集器的目标是达到一个可控制的吞吐量(Throughput)。所谓吞吐量就是CPU用于运行用户代码的时间与CPU总消耗时间的比值,即 吞吐量=运行用户代码时间/(运行用户代码时间+垃圾收集时间)
  • 自适应调节策略也是Parallel Scavenge收集器与ParNew收集器的一个重要区别

Serial Old收集器

Serial Old是Serial收集器的老年代版本,它同样是一个单线程收集器,使用标记整理算法。这个收集器的主要意义也是在于给Client模式下的虚拟机使用。

如果在Server模式下,主要两大用途:

  • (1)在JDK1.5以及之前的版本中与Parallel Scavenge收集器搭配使用
  • (2)作为CMS收集器的后备预案,在并发收集发生Concurrent Mode Failure时使用

image

Parallel Old收集器

Parallel Old 是Parallel Scavenge收集器的老年代版本,使用多线程和“标记-整理”算法。这个收集器在1.6中才开始提供。

image

CMS收集器

这类应用尤其重视服务器的响应速度,希望系统停顿时间最短,以给用户带来较好的体验。CMS收集器就非常符合这类应用的需求

CMS收集器是基于“标记-清除”算法实现的。它的运作过程相对前面几种收集器来说更复杂一些,整个过程分为4个步骤:

  • 初始标记:标记一下GC Roots能直接关联的对象,速度快
  • 并发标记:GC Roots Tracing的过程
  • 重新标记:修正并发标记期间因用户程序继续运作而导致标记产生变动的那一部分对象的标记记录,一般比初始标记阶段稍长,但比并发标记时间短
  • 并发清除:清除

其中,初始标记、重新标记这两个步骤仍然需要“Stop The World”.

CMS收集器主要优点:

  • 并发收集
  • 低停顿。

CMS三个明显的缺点:

  • CMS收集器对CPU资源非常敏感。CPU个数少于4个时,CMS对于用户程序的影响就可能变得很大,为了应付这种情况,虚拟机提供了一种称为“增量式并发收集器”的CMS收集器变种。所做的事情和单CPU年代PC机操作系统使用抢占式来模拟多任务机制的思想
  • CMS收集器无法处理浮动垃圾,可能出现“Concurrent Mode Failure”失败而导致另一次Full GC的产生。"浮动垃圾"是指CMS并发清理时用户线程还在运行,伴随程序运行有新垃圾出现,这一部分垃圾在标记之后出现,所以本次无法清理。在JDK1.5的默认设置下,CMS收集器当老年代使用了68%的空间后就会被激活,这是一个偏保守的设置,如果在应用中蓝年代增长不是太快,可以适当调高参数-XX:CMSInitiatingOccupancyFraction的值来提高触发百分比,以便降低内存回收次数从而获取更好的性能,在JDK1.6中,CMS收集器的启动阀值已经提升至92%。
  • CMS是基于“标记-清除”算法实现的收集器,收集结束时会有大量空间碎片产生。空间碎片过多,可能会出现老年代还有很大空间剩余,但是无法找到足够大的连续空间来分配当前对象,不得不提前出发FullGC。为了解决这个问题,CMS收集器提供了一个-XX:+UseCMSCompactAtFullCollection开关参数(默认就是开启的),用于在CMS收集器顶不住要进行FullGC时开启内存碎片合并整理过程,内存整理的过程是无法并发的,空间碎片问题没有了,但停顿时间变长了。虚拟机设计者还提供了另外一个参数-XX:CMSFullGCsBeforeCompaction,这个参数是用于设置执行多少次不压缩的Full GC后,跟着来一次带压缩的(默认值为0,标识每次进入Full GC时都进行碎片整理)

G1收集器

G1收集器的优势:
  • 并行与并发 (停顿时间少)
  • 分代收集 (采用不同的收集方式处理不同年代的堆)
  • 空间整理 (标记整理算法,复制算法)
  • 可预测的停顿 (让使用者能控制一次收集的时间长度👏)
G1采用的堆布局:

使用G1收集器时,Java堆的内存布局是整个规划为多个大小相等的独立区域(称为Region,大小为2的幂次方,如1M,2M,4M),虽然还保留有新生代和老年代的概念,但新生代和老年代不再是物理隔离的了,它们都是一部分Region的集合。

能够预测停顿的时间的原因:

G1收集器之所以能建立可预测的停顿时间模型,是因为它可以有计划地避免在真个Java堆中进行全区域的垃圾收集。G1跟踪各个Region里面的垃圾堆积的价值大小(回收所获取的空间大小以及回收所需要的时间的经验值),在后台维护一个优先列表,每次根据允许的收集时间,优先回收价值最大的Region(这也就是Garbage-First名称的又来)。这种使用Region划分内存空间以及有优先级的区域回收方式,保证了G1收集器在有限的时间内可以获取尽量可能高的收集效率

G1 内存“化整为零”的思路:

问:若一个对象在Region中,但他如果有其他Region中、甚至整个堆任意对象有引用关系,做可达性判定对象存活时,要扫描整个对空间吗?

答:

  1. 虚拟机通过Remembered Set避免全堆扫描,每个Region都有与之对应的Remembered Set。
  2. 当程序对引用类型进行写操作时,会产生一个Write Barrier暂停中断写操作,检查引用的对象是否处于Region之中。是,就通过CardTable将引用信息记录到所属Region的Remember Set中。
  3. 回收时,Remembered Set就可以保证不用进行全堆扫描了。
如果不计算维护Remembered Set的操作,G1收集器的运作大致可划分为一下步骤:
  • 初始标记:标记GC Roots能直接关联的对象,修改Next Top at Mark Start的值,让下一阶段程序运行时,在正确的Region中创建对象,停顿线程,耗时短。
  • 并发标记:从GC Root对堆对象进行可达性分析,找存活对象,可与用户线程并发执行,耗时长。
  • 最终标记:修正并发标记因用户程序继续运作导致标记变动的部分,JVM将这段变化记录在Remembered Set Logs中,最终标记阶段将Remembered Set Logs数据合并到Remembered Set中。
  • 筛选回收:对各个Region的回收价值和成本进行排序,根据用户期望GC停顿时间制定回收计划。
G1的回收模式

G1中提供了三种模式垃圾回收模式,young gc、mixed gc 和 full gc,在不同的条件下被触发。

  • Young GC:一般对象(除了巨型对象)都是在eden region中分配内存,当所有eden region被耗尽无法申请内存时,就会触发一次young gc,这种触发机制和之前的young gc差不多,执行完一次young gc,活跃对象会被拷贝到survivor region或者晋升到old region中,空闲的region会被放入空闲列表中,等待下次被使用。
  • 回收整个young region,还会回收一部分的old region
  • 老年代被填满,就会触发一次full gc(尽量避免)

image

内存分配与回收策略

自动内存管理最终可以归结为自动化地解决了两个问题:

  • 给对象分配内存
  • 回收分配给对象的内存

简单来说,对象内存分配主要是在堆中分配。但是分配的规则并不是固定的,取决于使用的收集器组合以及JVM内存相关参数的设定

对象优先在Eden分配

大多数情况下,对象在Eden区分配内存

Minor GC和Full GC的区别:

  • 新生代GC(Minor GC):指发生在新生代的垃圾回收动作,频繁,回收速度也快
  • 老年代GC(Major GC/Full GC):指发生在老年代的GC,出现了Major GC,经常会伴随至少一次Minor GC(并非绝对,在Parallel Scavenege收集器的收集策略里就有进行Major GC的策略过程选择),它的速度一般比Minor GC慢十倍。

大对象直接进入老年代

大对象是指,需要连续内存空间的Java对象,例如很长的字符串或数组

长期存活的对象进入老年代

虚拟机给每个对象定义了一个对象年龄(Age)计数器,如果对象在Eden出生并经过第一次Minor GC后仍然存活,并且能被Survivor容纳的话,会被移动到Survivor空间中,并且对象年龄为1.每在Survivor区中渡过一次Minor GC,年龄增加1,当它的年龄增加到一定程度(默认15),就被晋升到老年代。

动态对象年龄判定

如果在Survivor空间中相同年龄所有对象大小的总和大于Survivor空间的一半,年龄大于或等于该年龄的对象就可以直接进入老年代

空间分配担保

伪代码解释:

    //准备Minor GC:
    if (老年代中最大连续可用空间>新生代所有对象总空间){
        //开始Minor GC
    } else {
        if (允许担保失败){
            if (老年代最大连续可用空间>历次晋升老年代对象平均大小){
                //开始Minor GC
            } else {
                //开始Full GC
            }
        } else {
            //开始Full GC
        }
    }

一般来说,新生代只使用一个survivor空间来进行轮换时的备份,所以当出现极端情况(即新生代空间在一次minor GC后全部存活)时survivor空间有可能爆满,所以此时需要老年代进行分配担保,即survivor区无法容纳的对象都进入老年代。

在JDK 6 Updale 24 之后,Handle PromotionFailure 不会再影响到虚拟机的空间分配担保策略,只要老年代的连续空间大于新生代对象总大小或者历次晋升的平均大小就会进行Minor GC,否则将进行Full GC。

总结

最后,内存回收和垃圾收集器很多时候都是影响系统性能,并发能力的原因,虚拟机也提供了多种收集器和大量的调节参数,因为很多时候我们要选择自己的业务来设置相应的收集方式